💄(frontend) refactor document sharing and grid components

Improvements:
- Added disabled state for dropdown menus in share settings
- Updated document grid layout and responsiveness
- Simplified sharing and access count logic
- Improved tooltips and visibility of shared documents
- Created a new responsive doc grid hook
This commit is contained in:
Nathan Panchout
2025-01-27 11:38:24 +01:00
parent ee41d156c7
commit 55ddfe9181
16 changed files with 255 additions and 219 deletions

View File

@@ -20,12 +20,14 @@ export type DropdownMenuProps = {
showArrow?: boolean;
label?: string;
arrowCss?: BoxProps['$css'];
disabled?: boolean;
topMessage?: string;
};
export const DropdownMenu = ({
options,
children,
disabled = false,
showArrow = false,
arrowCss,
label,
@@ -40,6 +42,10 @@ export const DropdownMenu = ({
setIsOpen(isOpen);
};
if (disabled) {
return children;
}
return (
<DropButton
isOpen={isOpen}

View File

@@ -1,8 +1,8 @@
import Link from 'next/link';
import styled from 'styled-components';
import styled, { RuleSet } from 'styled-components';
export interface LinkProps {
$css?: string;
$css?: string | RuleSet<object>;
}
export const StyledLink = styled(Link)<LinkProps>`
@@ -12,5 +12,5 @@ export const StyledLink = styled(Link)<LinkProps>`
color: #ffffff;
}
display: flex;
${({ $css }) => $css && `${$css};`}
${({ $css }) => $css && (typeof $css === 'string' ? `${$css};` : $css)}
`;

View File

@@ -42,7 +42,7 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
<Box
aria-label={t('Public document')}
$color={colors['primary-800']}
$background={colors['primary-100']}
$background={colors['primary-050']}
$radius={spacings['3xs']}
$direction="row"
$padding="xs"

View File

@@ -18,11 +18,7 @@ import {
} from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useEditorStore } from '@/features/docs/doc-editor/';
import {
Doc,
ModalRemoveDoc,
useCopyDocLink,
} from '@/features/docs/doc-management';
import { Doc, ModalRemoveDoc } from '@/features/docs/doc-management';
import { DocShareModal } from '@/features/docs/doc-share';
import {
KEY_LIST_DOC_VERSIONS,
@@ -41,8 +37,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const hasAccesses = doc.nb_accesses > 1;
const queryClient = useQueryClient();
const copyDocLink = useCopyDocLink(doc.id);
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const spacings = spacingsTokens();
@@ -55,24 +49,15 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const { isSmallMobile, isDesktop } = useResponsiveStore();
const { editor } = useEditorStore();
const { toast } = useToastProvider();
const canViewAccesses = doc.abilities.accesses_view;
const options: DropdownMenuOption[] = [
...(isSmallMobile
? [
{
label: canViewAccesses ? t('Share') : t('Copy link'),
icon: canViewAccesses ? 'group' : 'link',
callback: () => {
if (canViewAccesses) {
modalShare.open();
return;
}
copyDocLink();
},
label: t('Share'),
icon: 'group',
callback: modalShare.open,
},
{
label: t('Export'),
@@ -165,7 +150,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
$margin={{ left: 'auto' }}
$gap={spacings['2xs']}
>
{canViewAccesses && !isSmallMobile && (
{!isSmallMobile && (
<>
{!hasAccesses && (
<Button
@@ -199,23 +184,13 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
}}
size={isSmallMobile ? 'small' : 'medium'}
>
{doc.nb_accesses - 1}
{doc.nb_accesses}
</Button>
</Box>
)}
</>
)}
{!canViewAccesses && !isSmallMobile && (
<Button
color="tertiary-text"
onClick={() => {
copyDocLink();
}}
size={isSmallMobile ? 'small' : 'medium'}
>
{t('Copy link')}
</Button>
)}
{!isSmallMobile && (
<Button
color="tertiary-text"

View File

@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { createGlobalStyle, css } from 'styled-components';
import { useDebouncedCallback } from 'use-debounce';
import { Box, LoadMoreText } from '@/components';
import { Box, HorizontalSeparator, LoadMoreText, Text } from '@/components';
import {
QuickSearch,
QuickSearchData,
@@ -56,7 +56,7 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
const [userQuery, setUserQuery] = useState('');
const [inputValue, setInputValue] = useState('');
const [listHeight, setListHeight] = useState<string>('0px');
const [listHeight, setListHeight] = useState<string>('400px');
const canShare = doc.abilities.accesses_manage;
const canViewAccesses = doc.abilities.accesses_view;
const showMemberSection = inputValue === '' && selectedUsers.length === 0;
@@ -95,7 +95,7 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
count === 1
? t('Document owner')
: t('Share with {{count}} users', {
count: count - 1,
count: count,
}),
elements: members,
endActions: membersQuery.hasNextPage
@@ -169,10 +169,6 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
};
const handleRef = (node: HTMLDivElement) => {
if (!canViewAccesses) {
setListHeight('0px');
return;
}
const inputHeight = canShare ? 70 : 0;
const marginTop = 11;
const footerHeight = node?.clientHeight ?? 0;
@@ -198,6 +194,7 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
aria-label={t('Share modal')}
$height={canViewAccesses ? modalContentHeight : 'auto'}
$overflow="hidden"
className="noPadding"
$justify="space-between"
>
<Box
@@ -209,7 +206,7 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
}
`}
>
<div ref={selectedUsersRef}>
<Box ref={selectedUsersRef}>
{canShare && selectedUsers.length > 0 && (
<Box
$padding={{ horizontal: 'base' }}
@@ -227,59 +224,76 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
/>
</Box>
)}
</div>
{!canViewAccesses && <HorizontalSeparator />}
</Box>
<Box data-testid="doc-share-quick-search">
<QuickSearch
onFilter={(str) => {
setInputValue(str);
onFilter(str);
}}
inputValue={inputValue}
showInput={canShare}
loading={searchUsersQuery.isLoading}
placeholder={t('Type a name or email')}
>
{canViewAccesses && (
<>
{!showMemberSection && inputValue !== '' && (
<QuickSearchGroup
group={searchUserData}
onSelect={onSelect}
renderElement={(user) => (
<DocShareModalInviteUserRow user={user} />
)}
/>
{!canViewAccesses && (
<Box $height={listHeight} $align="center" $justify="center">
<Text
$maxWidth="320px"
$textAlign="center"
$variation="600"
$size="sm"
>
{t(
'You do not have permission to view users sharing this document or modify link settings.',
)}
{showMemberSection && (
<>
{invitationsData.elements.length > 0 && (
<Box aria-label={t('List invitation card')}>
</Text>
</Box>
)}
{canViewAccesses && (
<QuickSearch
onFilter={(str) => {
setInputValue(str);
onFilter(str);
}}
inputValue={inputValue}
showInput={canShare}
loading={searchUsersQuery.isLoading}
placeholder={t('Type a name or email')}
>
{canViewAccesses && (
<>
{!showMemberSection && inputValue !== '' && (
<QuickSearchGroup
group={searchUserData}
onSelect={onSelect}
renderElement={(user) => (
<DocShareModalInviteUserRow user={user} />
)}
/>
)}
{showMemberSection && (
<>
{invitationsData.elements.length > 0 && (
<Box aria-label={t('List invitation card')}>
<QuickSearchGroup
group={invitationsData}
renderElement={(invitation) => (
<DocShareInvitationItem
doc={doc}
invitation={invitation}
/>
)}
/>
</Box>
)}
<Box aria-label={t('List members card')}>
<QuickSearchGroup
group={invitationsData}
renderElement={(invitation) => (
<DocShareInvitationItem
doc={doc}
invitation={invitation}
/>
group={membersData}
renderElement={(access) => (
<DocShareMemberItem doc={doc} access={access} />
)}
/>
</Box>
)}
<Box aria-label={t('List members card')}>
<QuickSearchGroup
group={membersData}
renderElement={(access) => (
<DocShareMemberItem doc={doc} access={access} />
)}
/>
</Box>
</>
)}
</>
)}
</QuickSearch>
</>
)}
</>
)}
</QuickSearch>
)}
</Box>
</Box>

View File

@@ -13,8 +13,6 @@ type Props = {
};
export const DocShareModalFooter = ({ doc, onClose }: Props) => {
const canShare = doc.abilities.accesses_manage;
const copyDocLink = useCopyDocLink(doc.id);
const { t } = useTranslation();
return (
@@ -24,12 +22,10 @@ export const DocShareModalFooter = ({ doc, onClose }: Props) => {
`}
>
<HorizontalSeparator $withPadding={true} />
{canShare && (
<>
<DocVisibility doc={doc} />
<HorizontalSeparator />
</>
)}
<DocVisibility doc={doc} />
<HorizontalSeparator />
<Box
$direction="row"
$justify="space-between"

View File

@@ -26,10 +26,10 @@ export const DocShareModalInviteUserRow = ({ user }: Props) => {
color: var(--c--theme--colors--greyscale-400);
`}
>
<Text $theme="primary" $variation="600">
<Text $theme="primary" $variation="800">
{t('Add')}
</Text>
<Icon $theme="primary" $variation="600" iconName="add" />
<Icon $theme="primary" $variation="800" iconName="add" />
</Box>
}
/>

View File

@@ -34,6 +34,7 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => {
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const spacing = spacingsTokens();
const colors = colorsTokens();
const canManage = doc.abilities.accesses_manage;
const [linkReach, setLinkReach] = useState<LinkReach>(doc.link_reach);
const [docLinkRole, setDocLinkRole] = useState<LinkRole>(doc.link_role);
const { linkModeTranslations, linkReachChoices, linkReachTranslations } =
@@ -106,29 +107,35 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => {
$direction="row"
$align={isDesktop ? 'center' : undefined}
$padding={{ horizontal: '2xs' }}
$gap={spacing['3xs']}
$gap={canManage ? spacing['3xs'] : spacing['base']}
>
<DropdownMenu
label={t('Visibility')}
arrowCss={css`
color: ${colors['primary-800']} !important;
`}
disabled={!doc.abilities.accesses_manage}
showArrow={true}
options={linkReachOptions}
>
<Box $direction="row" $align="center" $gap={spacing['3xs']}>
<Icon
$theme="primary"
$variation="800"
$theme={canManage ? 'primary' : 'greyscale'}
$variation={canManage ? '800' : '600'}
iconName={linkReachChoices[linkReach].icon}
/>
<Text $theme="primary" $variation="800">
<Text
$theme={canManage ? 'primary' : 'greyscale'}
$variation={canManage ? '800' : '600'}
$weight="500"
$size="md"
>
{linkReachChoices[linkReach].label}
</Text>
</Box>
</DropdownMenu>
{isDesktop && (
<Text $size="xs" $variation="600">
<Text $size="xs" $variation="600" $weight="400">
{description}
</Text>
)}
@@ -137,6 +144,7 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => {
<Box $direction="row" $align="center" $gap={spacing['3xs']}>
{linkReach !== LinkReach.RESTRICTED && (
<DropdownMenu
disabled={!canManage}
showArrow={true}
options={linkMode}
label={t('Visibility mode')}

View File

@@ -26,7 +26,7 @@ export const useTranslatedShareSettings = () => {
},
[LinkReach.AUTHENTICATED]: {
label: linkReachTranslations[LinkReach.AUTHENTICATED],
icon: 'corporate_fare',
icon: 'vpn_lock',
value: LinkReach.AUTHENTICATED,
descriptionReadOnly: t(
'Anyone with the link can view the document if they are logged in',

View File

@@ -10,6 +10,8 @@ import {
} from '@/features/docs/doc-management';
import { useResponsiveStore } from '@/stores';
import { useResponsiveDocGrid } from '../hooks/useResponsiveDocGrid';
import { DocsGridItem } from './DocsGridItem';
import { DocsGridLoader } from './DocsGridLoader';
@@ -22,6 +24,7 @@ export const DocsGrid = ({
const { t } = useTranslation();
const { isDesktop } = useResponsiveStore();
const { flexLeft, flexRight } = useResponsiveDocGrid();
const {
data,
@@ -101,23 +104,21 @@ export const DocsGrid = ({
<Box
$direction="row"
$padding={{ horizontal: 'xs' }}
$gap="20px"
$gap="10px"
data-testid="docs-grid-header"
>
<Box $flex={6} $padding="3xs">
<Box $flex={flexLeft} $padding="3xs">
<Text $size="xs" $variation="600" $weight="500">
{t('Name')}
</Text>
</Box>
{isDesktop && (
<Box $flex={2} $padding="3xs">
<Box $flex={flexRight} $padding={{ vertical: '3xs' }}>
<Text $size="xs" $weight="500" $variation="600">
{t('Updated at')}
</Text>
</Box>
)}
<Box $flex={1.15} $align="flex-end" $padding="3xs" />
</Box>
{/* Body */}

View File

@@ -6,7 +6,6 @@ import {
Doc,
KEY_LIST_DOC,
ModalRemoveDoc,
useCopyDocLink,
useCreateFavoriteDoc,
useDeleteFavoriteDoc,
} from '@/features/docs/doc-management';
@@ -22,10 +21,6 @@ export const DocsGridActions = ({
}: DocsGridActionsProps) => {
const { t } = useTranslation();
const copyDocLink = useCopyDocLink(doc.id);
const canViewAccesses = doc.abilities.accesses_view;
const deleteModal = useModal();
const removeFavoriteDoc = useDeleteFavoriteDoc({
@@ -49,14 +44,10 @@ export const DocsGridActions = ({
testId: `docs-grid-actions-${doc.is_favorite ? 'unpin' : 'pin'}-${doc.id}`,
},
{
label: canViewAccesses ? t('Share') : t('Copy link'),
icon: canViewAccesses ? 'group' : 'link',
label: t('Share'),
icon: 'group',
callback: () => {
if (canViewAccesses) {
openShareModal?.();
return;
}
copyDocLink();
openShareModal?.();
},
testId: `docs-grid-actions-share-${doc.id}`,

View File

@@ -1,23 +1,33 @@
import { useModal } from '@openfun/cunningham-react';
import { Tooltip, useModal } from '@openfun/cunningham-react';
import { DateTime } from 'luxon';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, StyledLink, Text } from '@/components';
import { Doc } from '@/features/docs/doc-management';
import { Box, Icon, StyledLink, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc, LinkReach } from '@/features/docs/doc-management';
import { DocShareModal } from '@/features/docs/doc-share';
import { useResponsiveStore } from '@/stores';
import { useResponsiveDocGrid } from '../hooks/useResponsiveDocGrid';
import { DocsGridActions } from './DocsGridActions';
import { DocsGridItemSharedButton } from './DocsGridItemSharedButton';
import { SimpleDocItem } from './SimpleDocItem';
type DocsGridItemProps = {
doc: Doc;
};
export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
const { t } = useTranslation();
const { isDesktop } = useResponsiveStore();
const { flexLeft, flexRight } = useResponsiveDocGrid();
const { spacingsTokens } = useCunninghamTheme();
const spacings = spacingsTokens();
const shareModal = useModal();
const isPublic = doc.link_reach === LinkReach.PUBLIC;
const isAuthenticated = doc.link_reach === LinkReach.AUTHENTICATED;
const showAccesses = isPublic || isAuthenticated;
const handleShareClick = () => {
shareModal.open();
@@ -29,8 +39,8 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
$direction="row"
$width="100%"
$align="center"
$gap="20px"
role="row"
$gap="20px"
$padding={{ vertical: '2xs', horizontal: isDesktop ? 'base' : 'xs' }}
$css={css`
cursor: pointer;
@@ -41,39 +51,71 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
`}
>
<StyledLink
$css="flex: 8; align-items: center;"
$css={css`
flex: ${flexLeft};
align-items: center;
`}
href={`/docs/${doc.id}`}
>
<Box
data-testid={`docs-grid-name-${doc.id}`}
$flex={6}
$padding={{ right: 'base' }}
$direction="row"
$align="center"
$gap={spacings.xs}
$flex={flexLeft}
$padding={{ right: 'md' }}
>
<SimpleDocItem isPinned={doc.is_favorite} doc={doc} />
{showAccesses && isDesktop && (
<>
<Tooltip
content={
<Text $textAlign="center" $variation="000">
{isPublic
? t('Accessible to anyone')
: t('Accessible to authenticated users')}
</Text>
}
placement="top"
>
<div>
<Icon
$theme="greyscale"
$variation="600"
$size="14px"
iconName={isPublic ? 'public' : 'vpn_lock'}
/>
</div>
</Tooltip>
</>
)}
</Box>
{isDesktop && (
<Box $flex={2}>
<Text $variation="600" $size="xs">
{DateTime.fromISO(doc.updated_at).toRelative()}
</Text>
</Box>
)}
</StyledLink>
<Box
$flex={1.15}
$flex={flexRight}
$direction="row"
$align="center"
$justify="flex-end"
$justify={isDesktop ? 'space-between' : 'flex-end'}
$gap="32px"
>
{isDesktop && (
<DocsGridItemSharedButton
doc={doc}
handleClick={handleShareClick}
/>
<StyledLink href={`/docs/${doc.id}`}>
<Text $variation="600" $size="xs">
{DateTime.fromISO(doc.updated_at).toRelative()}
</Text>
</StyledLink>
)}
<DocsGridActions doc={doc} openShareModal={handleShareClick} />
<Box $direction="row" $align="center" $gap={spacings.lg}>
{isDesktop && (
<DocsGridItemSharedButton
doc={doc}
handleClick={handleShareClick}
/>
)}
<DocsGridActions doc={doc} openShareModal={handleShareClick} />
</Box>
</Box>
</Box>
{shareModal.isOpen && (

View File

@@ -1,66 +1,45 @@
import { Button } from '@openfun/cunningham-react';
import { useMemo } from 'react';
import { Button, Tooltip } from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { Box, Icon } from '@/components';
import { Box, Icon, Text } from '@/components';
import { Doc, LinkReach } from '../../doc-management';
import { Doc } from '../../doc-management';
type Props = {
doc: Doc;
handleClick: () => void;
};
export const DocsGridItemSharedButton = ({ doc, handleClick }: Props) => {
const isPublic = doc.link_reach === LinkReach.PUBLIC;
const isAuthenticated = doc.link_reach === LinkReach.AUTHENTICATED;
const isRestricted = doc.link_reach === LinkReach.RESTRICTED;
const sharedCount = doc.nb_accesses - 1;
const isShared = sharedCount > 0;
const { t } = useTranslation();
const sharedCount = doc.nb_accesses;
const isShared = sharedCount - 1 > 0;
const icon = useMemo(() => {
if (isPublic) {
return 'public';
}
if (isAuthenticated) {
return 'corporate_fare';
}
if (isRestricted) {
return 'group';
}
return undefined;
}, [isPublic, isAuthenticated, isRestricted]);
if (!icon) {
return null;
}
if (!doc.abilities.accesses_view) {
return (
<Box $align="center" $width="100%">
<Icon $variation="800" $theme="primary" iconName={icon} />
</Box>
);
if (!isShared) {
return <Box $minWidth="50px">&nbsp;</Box>;
}
return (
<Button
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleClick();
}}
fullWidth
color={isRestricted ? 'tertiary' : 'primary'}
size="nano"
icon={
<Icon
$variation={isRestricted ? '800' : '000'}
$theme={isRestricted ? 'primary' : 'greyscale'}
iconName={icon}
/>
<Tooltip
content={
<Text $textAlign="center" $variation="000">
{t('Shared with {{count}} users', { count: sharedCount })}
</Text>
}
placement="top"
>
{isShared ? sharedCount : undefined}
</Button>
<Button
style={{ minWidth: '50px', justifyContent: 'center' }}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleClick();
}}
color="tertiary"
size="nano"
icon={<Icon $variation="800" $theme="primary" iconName="group" />}
>
{sharedCount}
</Button>
</Tooltip>
);
};

View File

@@ -1,9 +1,9 @@
import { DateTime } from 'luxon';
import { css } from 'styled-components';
import { Box, Icon, Text } from '@/components';
import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc, LinkReach } from '@/features/docs/doc-management';
import { Doc } from '@/features/docs/doc-management';
import { useResponsiveStore } from '@/stores';
import PinnedDocumentIcon from '../assets/pinned-document.svg';
@@ -34,11 +34,6 @@ export const SimpleDocItem = ({
const { isDesktop } = useResponsiveStore();
const spacings = spacingsTokens();
const isPublic = doc?.link_reach === LinkReach.PUBLIC;
const isShared = !isPublic && doc.nb_accesses > 1;
const accessCount = doc.nb_accesses - 1;
const isSharedOrPublic = isShared || isPublic;
return (
<Box $direction="row" $gap={spacings.sm}>
<Box
@@ -69,22 +64,6 @@ export const SimpleDocItem = ({
$gap={spacings['3xs']}
$margin={{ top: '-2px' }}
>
{isPublic && (
<Icon iconName="public" $size="16px" $variation="600" />
)}
{isShared && (
<Icon iconName="group" $size="16px" $variation="600" />
)}
{isSharedOrPublic && accessCount > 0 && (
<Text $size="12px" $weight="bold" $variation="600">
{accessCount}
</Text>
)}
{isSharedOrPublic && (
<Text $size="12px" $variation="600">
·
</Text>
)}
<Text $variation="600" $size="xs">
{DateTime.fromISO(doc.updated_at).toRelative()}
</Text>

View File

@@ -0,0 +1,29 @@
import { useMemo } from 'react';
import { useResponsiveStore } from '@/stores';
export const useResponsiveDocGrid = () => {
const { isDesktop, screenWidth } = useResponsiveStore();
const flexLeft = useMemo(() => {
if (!isDesktop) {
return 1;
} else if (screenWidth <= 1100) {
return 6;
} else if (screenWidth < 1200) {
return 8;
}
return 8;
}, [isDesktop, screenWidth]);
const flexRight = useMemo(() => {
if (!isDesktop) {
return undefined;
} else if (screenWidth <= 1200) {
return 5;
}
return 4;
}, [isDesktop, screenWidth]);
return { flexLeft, flexRight };
};

View File

@@ -41,3 +41,19 @@ main ::-webkit-scrollbar-thumb:hover,
cursor: pointer;
outline: inherit;
}
.c__modal__scroller:has(.noPadding) {
padding: 0 !important;
.c__modal__close .c__button {
right: 5px;
top: 5px;
padding: 1.5rem 1rem;
}
.c__modal__title {
font-size: var(--c--theme--font--sizes--xs);
padding: var(--c--theme--spacings--base);
margin-bottom: 0;
}
}