✨(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:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user