(frontend) subdocs can manage link reach

The subdocs can now have their own link reach
properties, dissociated from the parent document.
This commit is contained in:
Anthony LC
2025-07-17 12:33:16 +02:00
parent 04273c3b3e
commit 34ce276222
8 changed files with 177 additions and 143 deletions

View File

@@ -11,6 +11,7 @@ and this project adheres to
### Added
- ✨(backend) allow masking documents from the list view #1171
- ✨(frontend) subdocs can manage link reach #1190
- ✨(frontend) add duplicate action to doc tree #1175
### Changed

View File

@@ -31,9 +31,7 @@ test.describe('Inherited share accesses', () => {
await verifyDocName(page, parentTitle);
});
});
test.describe('Inherited share link', () => {
test('it checks if the link is inherited', async ({ page, browserName }) => {
await page.goto('/');
// Create root doc
@@ -47,12 +45,50 @@ test.describe('Inherited share link', () => {
// Create sub page
await createRootSubPage(page, browserName, 'sub-page');
// // verify share link is restricted and reader
// Verify share link is like the parent document
await page.getByRole('button', { name: 'Share' }).click();
// await expect(page.getByText('Inherited share')).toBeVisible();
const docVisibilityCard = page.getByLabel('Doc visibility card');
await expect(docVisibilityCard).toBeVisible();
await expect(docVisibilityCard.getByText('Connected')).toBeVisible();
await expect(docVisibilityCard.getByText('Reading')).toBeVisible();
// Verify inherited link
await docVisibilityCard.getByText('Connected').click();
await expect(
page.getByRole('menuitem', { name: 'Private' }),
).toBeDisabled();
// Update child link
await page.getByRole('menuitem', { name: 'Public' }).click();
await docVisibilityCard.getByText('Reading').click();
await page.getByRole('menuitem', { name: 'Editing' }).click();
await expect(docVisibilityCard.getByText('Connected')).toBeHidden();
await expect(docVisibilityCard.getByText('Reading')).toBeHidden();
await expect(
docVisibilityCard.getByText('Public', {
exact: true,
}),
).toBeVisible();
await expect(docVisibilityCard.getByText('Editing')).toBeVisible();
await expect(
docVisibilityCard.getByText(
'The link sharing rules differ from the parent document',
),
).toBeVisible();
// Restore inherited link
await page.getByRole('button', { name: 'Restore' }).click();
await expect(docVisibilityCard.getByText('Connected')).toBeVisible();
await expect(docVisibilityCard.getByText('Reading')).toBeVisible();
await expect(docVisibilityCard.getByText('Public')).toBeHidden();
await expect(docVisibilityCard.getByText('Editing')).toBeHidden();
await expect(
docVisibilityCard.getByText(
'The link sharing rules differ from the parent document',
),
).toBeHidden();
});
});

View File

@@ -1,4 +1,6 @@
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Doc, KEY_DOC } from '@/docs/doc-management';
@@ -39,6 +41,8 @@ export function useUpdateDocLink({
}: UpdateDocLinkProps = {}) {
const queryClient = useQueryClient();
const { broadcast } = useBroadcastStore();
const { toast } = useToastProvider();
const { t } = useTranslation();
return useMutation<Doc, APIError, UpdateDocLinkParams>({
mutationFn: updateDocLink,
@@ -52,6 +56,14 @@ export function useUpdateDocLink({
// Broadcast to every user connected to the document
broadcast(`${KEY_DOC}-${variable.id}`);
toast(
t('The document visibility has been updated.'),
VariantType.SUCCESS,
{
duration: 2000,
},
);
onSuccess?.(data);
},
});

View File

@@ -0,0 +1,67 @@
import { Button } from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import {
Doc,
KEY_DOC,
KEY_LIST_DOC,
useUpdateDocLink,
} from '@/docs/doc-management';
import Desync from './../assets/desynchro.svg';
import Undo from './../assets/undo.svg';
interface DocDesynchronizedProps {
doc: Doc;
}
export const DocDesynchronized = ({ doc }: DocDesynchronizedProps) => {
const { t } = useTranslation();
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const { mutate: updateDocLink } = useUpdateDocLink({
listInvalideQueries: [KEY_LIST_DOC, KEY_DOC],
});
return (
<Box
$background={colorsTokens['primary-100']}
$padding="3xs"
$direction="row"
$align="center"
$justify="space-between"
$gap={spacingsTokens['4xs']}
$color={colorsTokens['primary-800']}
$css={css`
border: 1px solid ${colorsTokens['primary-300']};
border-radius: ${spacingsTokens['2xs']};
`}
>
<Box $direction="row" $align="center" $gap={spacingsTokens['3xs']}>
<Desync />
<Text $size="xs" $theme="primary" $variation="800" $weight="400">
{t('The link sharing rules differ from the parent document')}
</Text>
</Box>
{doc.abilities.accesses_manage && (
<Button
onClick={() =>
updateDocLink({
id: doc.id,
link_reach: doc.ancestors_link_reach,
link_role: doc?.ancestors_link_role || undefined,
})
}
size="small"
color="primary-text"
icon={<Undo />}
>
{t('Restore')}
</Button>
)}
</Box>
);
};

View File

@@ -51,8 +51,18 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
const { isDesktop } = useResponsiveStore();
/**
* The modal content height is calculated based on the viewport height.
* The formula is:
* 100dvh - 2em - 12px - 34px
* - 34px is the height of the modal title in mobile
* - 2em is the padding of the modal content
* - 12px is the padding of the modal footer
* - 690px is the height of the content in desktop
* This ensures that the modal content is always visible and does not overflow.
*/
const modalContentHeight = isDesktop
? 'min(690px, calc(100dvh - 2em - 12px - 34px))' // 100dvh - 2em - 12px is the max cunningham modal height. 690px is the height of the content in desktop ad 34px is the height of the modal title in mobile
? 'min(690px, calc(100dvh - 2em - 12px - 34px))'
: `calc(100dvh - 34px)`;
const [selectedUsers, setSelectedUsers] = useState<User[]>([]);
const [userQuery, setUserQuery] = useState('');
@@ -230,13 +240,7 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
</Box>
<Box ref={handleRef}>
{showFooter && (
<DocShareModalFooter
doc={doc}
onClose={onClose}
canEditVisibility={canShare}
/>
)}
{showFooter && <DocShareModalFooter doc={doc} onClose={onClose} />}
</Box>
</Box>
</Modal>

View File

@@ -7,17 +7,15 @@ import { Doc, useCopyDocLink } from '@/docs/doc-management';
import { DocVisibility } from './DocVisibility';
type Props = {
type DocShareModalFooterProps = {
doc: Doc;
onClose: () => void;
canEditVisibility?: boolean;
};
export const DocShareModalFooter = ({
doc,
onClose,
canEditVisibility = true,
}: Props) => {
}: DocShareModalFooterProps) => {
const copyDocLink = useCopyDocLink(doc.id);
const { t } = useTranslation();
return (
@@ -29,7 +27,7 @@ export const DocShareModalFooter = ({
>
<HorizontalSeparator $withPadding={true} customPadding="12px" />
<DocVisibility doc={doc} canEdit={canEditVisibility} />
<DocVisibility doc={doc} />
<HorizontalSeparator customPadding="12px" />
<Box

View File

@@ -1,9 +1,4 @@
import {
Button,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { useCallback, useMemo, useState } from 'react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -29,89 +24,54 @@ import { useResponsiveStore } from '@/stores';
import { useTranslatedShareSettings } from '../hooks/';
import Desync from './../assets/desynchro.svg';
import Undo from './../assets/undo.svg';
import { DocDesynchronized } from './DocDesynchronized';
interface DocVisibilityProps {
doc: Doc;
canEdit?: boolean;
}
export const DocVisibility = ({ doc, canEdit = true }: DocVisibilityProps) => {
export const DocVisibility = ({ doc }: DocVisibilityProps) => {
const { t } = useTranslation();
const { toast } = useToastProvider();
const { isDesktop } = useResponsiveStore();
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const canManage = doc.abilities.accesses_manage && canEdit;
const [linkReach, setLinkReach] = useState<LinkReach>(getDocLinkReach(doc));
const [docLinkRole, setDocLinkRole] = useState<LinkRole>(
doc.computed_link_role ?? LinkRole.READER,
);
const { isDesyncronized } = useTreeUtils(doc);
const canManage = doc.abilities.accesses_manage;
const docLinkReach = getDocLinkReach(doc);
const docLinkRole = doc.computed_link_role ?? LinkRole.READER;
const { isDesynchronized } = useTreeUtils(doc);
const { linkModeTranslations, linkReachChoices, linkReachTranslations } =
useTranslatedShareSettings();
const description =
docLinkRole === LinkRole.READER
? linkReachChoices[linkReach].descriptionReadOnly
: linkReachChoices[linkReach].descriptionEdit;
? linkReachChoices[docLinkReach].descriptionReadOnly
: linkReachChoices[docLinkReach].descriptionEdit;
const api = useUpdateDocLink({
onSuccess: () => {
toast(
t('The document visibility has been updated.'),
VariantType.SUCCESS,
{
duration: 4000,
},
);
},
const { mutate: updateDocLink } = useUpdateDocLink({
listInvalideQueries: [KEY_LIST_DOC, KEY_DOC],
});
const updateReach = useCallback(
(link_reach: LinkReach, link_role?: LinkRole) => {
const params: {
id: string;
link_reach: LinkReach;
link_role?: LinkRole;
} = {
id: doc.id,
link_reach,
};
api.mutate(params);
setLinkReach(link_reach);
if (link_role) {
params.link_role = link_role;
setDocLinkRole(link_role);
}
},
[api, doc.id],
);
const updateLinkRole = useCallback(
(link_role: LinkRole) => {
api.mutate({ id: doc.id, link_role });
setDocLinkRole(link_role);
},
[api, doc.id],
);
const linkReachOptions: DropdownMenuOption[] = useMemo(() => {
return Object.values(LinkReach).map((key) => {
const isDisabled =
doc.abilities.link_select_options[key as LinkReach] === undefined;
const isDisabled = doc.abilities.link_select_options[key] === undefined;
return {
label: linkReachTranslations[key as LinkReach],
callback: () => updateReach(key as LinkReach),
isSelected: linkReach === (key as LinkReach),
label: linkReachTranslations[key],
callback: () =>
updateDocLink({
id: doc.id,
link_reach: key,
}),
isSelected: docLinkReach === key,
disabled: isDisabled,
};
});
}, [doc, linkReach, linkReachTranslations, updateReach]);
}, [
doc.abilities.link_select_options,
doc.id,
docLinkReach,
linkReachTranslations,
updateDocLink,
]);
const haveDisabledOptions = linkReachOptions.some(
(option) => option.disabled,
@@ -120,41 +80,29 @@ export const DocVisibility = ({ doc, canEdit = true }: DocVisibilityProps) => {
const showLinkRoleOptions = doc.computed_link_reach !== LinkReach.RESTRICTED;
const linkRoleOptions: DropdownMenuOption[] = useMemo(() => {
const options = doc.abilities.link_select_options[linkReach] ?? [];
const options = doc.abilities.link_select_options[docLinkReach] ?? [];
return Object.values(LinkRole).map((key) => {
const isDisabled = !options.includes(key);
return {
label: linkModeTranslations[key],
callback: () => updateLinkRole(key),
callback: () => updateDocLink({ id: doc.id, link_role: key }),
isSelected: docLinkRole === key,
disabled: isDisabled,
};
});
}, [doc, docLinkRole, linkModeTranslations, updateLinkRole, linkReach]);
}, [
doc.abilities.link_select_options,
doc.id,
docLinkReach,
docLinkRole,
linkModeTranslations,
updateDocLink,
]);
const haveDisabledLinkRoleOptions = linkRoleOptions.some(
(option) => option.disabled,
);
const undoDesync = () => {
const params: {
id: string;
link_reach: LinkReach;
link_role?: LinkRole;
} = {
id: doc.id,
link_reach: doc.ancestors_link_reach,
};
if (doc.ancestors_link_role) {
params.link_role = doc.ancestors_link_role;
}
api.mutate(params);
setLinkReach(doc.ancestors_link_reach);
if (doc.ancestors_link_role) {
setDocLinkRole(doc.ancestors_link_role);
}
};
return (
<Box
$padding={{ horizontal: 'base' }}
@@ -163,40 +111,9 @@ export const DocVisibility = ({ doc, canEdit = true }: DocVisibilityProps) => {
className="--docs--doc-visibility"
>
<Text $weight="700" $size="sm" $variation="700">
{t('Link parameters')}
{t('Link settings')}
</Text>
{isDesyncronized && (
<Box
$background={colorsTokens['primary-100']}
$padding="3xs"
$direction="row"
$align="center"
$justify="space-between"
$gap={spacingsTokens['4xs']}
$color={colorsTokens['primary-800']}
$css={css`
border: 1px solid ${colorsTokens['primary-300']};
border-radius: ${spacingsTokens['2xs']};
`}
>
<Box $direction="row" $align="center" $gap={spacingsTokens['3xs']}>
<Desync />
<Text $size="xs" $theme="primary" $variation="800" $weight="400">
{t('Sharing rules differ from the parent page')}
</Text>
</Box>
{doc.abilities.accesses_manage && (
<Button
onClick={undoDesync}
size="small"
color="primary-text"
icon={<Undo />}
>
{t('Restore')}
</Button>
)}
</Box>
)}
{isDesynchronized && <DocDesynchronized doc={doc} />}
<Box
$direction="row"
$align="center"
@@ -231,7 +148,7 @@ export const DocVisibility = ({ doc, canEdit = true }: DocVisibilityProps) => {
<Icon
$theme={canManage ? 'primary' : 'greyscale'}
$variation={canManage ? '800' : '600'}
iconName={linkReachChoices[linkReach].icon}
iconName={linkReachChoices[docLinkReach].icon}
/>
<Text
$theme={canManage ? 'primary' : 'greyscale'}
@@ -239,7 +156,7 @@ export const DocVisibility = ({ doc, canEdit = true }: DocVisibilityProps) => {
$weight="500"
$size="md"
>
{linkReachChoices[linkReach].label}
{linkReachChoices[docLinkReach].label}
</Text>
</Box>
</DropdownMenu>
@@ -251,7 +168,7 @@ export const DocVisibility = ({ doc, canEdit = true }: DocVisibilityProps) => {
</Box>
{showLinkRoleOptions && (
<Box $direction="row" $align="center" $gap={spacingsTokens['3xs']}>
{linkReach !== LinkReach.RESTRICTED && (
{docLinkReach !== LinkReach.RESTRICTED && (
<DropdownMenu
disabled={!canManage}
showArrow={true}

View File

@@ -9,9 +9,8 @@ export const useTreeUtils = (doc: Doc) => {
isParent: doc.nb_accesses_ancestors <= 1, // it is a parent
isChild: doc.nb_accesses_ancestors > 1, // it is a child
isCurrentParent: treeContext?.root?.id === doc.id || doc.depth === 1, // it can be a child but not for the current user
isDesyncronized: !!(
isDesynchronized: !!(
doc.ancestors_link_reach &&
doc.ancestors_link_role &&
(doc.computed_link_reach !== doc.ancestors_link_reach ||
doc.computed_link_role !== doc.ancestors_link_role)
),