♻️(frontend) bind ui with ability access

Some actions were not available in the frontend
but allowed in the backend, this commit binds the frontend
ui with the ability access coming from the backend.
This commit is contained in:
Anthony LC
2025-04-23 09:49:09 +02:00
parent 7f0eb9117e
commit 9ca79688c9
7 changed files with 156 additions and 153 deletions

View File

@@ -79,7 +79,7 @@ test.describe('Doc Export', () => {
})
.click();
await page
void page
.getByRole('button', {
name: 'Download',
})
@@ -129,7 +129,7 @@ test.describe('Doc Export', () => {
await page.getByRole('combobox', { name: 'Format' }).click();
await page.getByRole('option', { name: 'Docx' }).click();
await page
void page
.getByRole('button', {
name: 'Download',
})
@@ -206,7 +206,7 @@ test.describe('Doc Export', () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
await page
void page
.getByRole('button', {
name: 'Download',
})
@@ -254,7 +254,7 @@ test.describe('Doc Export', () => {
})
.click();
await page
void page
.getByRole('button', {
name: 'Download',
})
@@ -298,7 +298,7 @@ test.describe('Doc Export', () => {
})
.click();
await page
void page
.getByRole('button', {
name: 'Download',
})

View File

@@ -152,12 +152,7 @@ test.describe('Document list members', () => {
await expect(soloOwner).toBeHidden();
await list.click();
const otherOwner = page.getByText(
`You cannot update the role or remove other owner.`,
);
await newUserRoles.click();
await expect(otherOwner).toBeVisible();
await list.click();
await currentUserRole.click();

View File

@@ -12,34 +12,11 @@ export const useTrans = () => {
[Role.OWNER]: t('Owner'),
};
const getNotAllowedMessage = (
canUpdate: boolean,
isLastOwner: boolean,
isOtherOwner: boolean,
) => {
if (!canUpdate) {
return undefined;
}
if (isLastOwner) {
return t(
'You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.',
);
}
if (isOtherOwner) {
return t('You cannot update the role or remove other owner.');
}
return undefined;
};
return {
transRole: (role: Role) => {
return translatedRoles[role];
},
untitledDocument: t('Untitled document'),
translatedRoles,
getNotAllowedMessage,
};
};

View File

@@ -3,32 +3,22 @@ import { css } from 'styled-components';
import { DropdownMenu, DropdownMenuOption, Text } from '@/components';
import { Role, useTrans } from '@/docs/doc-management/';
type Props = {
currentRole: Role;
onSelectRole?: (role: Role) => void;
type DocRoleDropdownProps = {
canUpdate?: boolean;
isLastOwner?: boolean;
isOtherOwner?: boolean;
currentRole: Role;
message?: string;
onSelectRole: (role: Role) => void;
rolesAllowed?: Role[];
};
export const DocRoleDropdown = ({
canUpdate = true,
currentRole,
message,
onSelectRole,
isLastOwner,
isOtherOwner,
}: Props) => {
const { transRole, translatedRoles, getNotAllowedMessage } = useTrans();
const roles: DropdownMenuOption[] = Object.keys(translatedRoles).map(
(key) => {
return {
label: transRole(key as Role),
callback: () => onSelectRole?.(key as Role),
disabled: isLastOwner || isOtherOwner,
isSelected: currentRole === (key as Role),
};
},
);
rolesAllowed,
}: DocRoleDropdownProps) => {
const { transRole, translatedRoles } = useTrans();
if (!canUpdate) {
return (
@@ -38,13 +28,20 @@ export const DocRoleDropdown = ({
);
}
const roles: DropdownMenuOption[] = Object.keys(translatedRoles).map(
(key) => {
return {
label: transRole(key as Role),
callback: () => onSelectRole?.(key as Role),
disabled: rolesAllowed && !rolesAllowed.includes(key as Role),
isSelected: currentRole === (key as Role),
};
},
);
return (
<DropdownMenu
topMessage={getNotAllowedMessage(
canUpdate,
!!isLastOwner,
!!isOtherOwner,
)}
topMessage={message}
label="doc-role-dropdown"
showArrow={true}
options={roles}

View File

@@ -23,12 +23,16 @@ type Props = {
};
export const DocShareMemberItem = ({ doc, access }: Props) => {
const { t } = useTranslation();
const { isLastOwner, isOtherOwner } = useWhoAmI(access);
const { isLastOwner } = useWhoAmI(access);
const { toast } = useToastProvider();
const { isDesktop } = useResponsiveStore();
const { spacingsTokens } = useCunninghamTheme();
const isNotAllowed =
isOtherOwner || !!isLastOwner || !doc.abilities.accesses_manage;
const message = isLastOwner
? t(
'You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.',
)
: undefined;
const { mutate: updateDocAccess } = useUpdateDocAccess({
onError: () => {
@@ -63,7 +67,7 @@ export const DocShareMemberItem = ({ doc, access }: Props) => {
label: t('Delete'),
icon: 'delete',
callback: onRemove,
disabled: isNotAllowed,
disabled: !access.abilities.destroy,
},
];
@@ -82,8 +86,8 @@ export const DocShareMemberItem = ({ doc, access }: Props) => {
currentRole={access.role}
onSelectRole={onUpdate}
canUpdate={doc.abilities.accesses_manage}
isLastOwner={isLastOwner}
isOtherOwner={!!isOtherOwner}
message={message}
rolesAllowed={access.abilities.set_role_to}
/>
{isDesktop && doc.abilities.accesses_manage && (

View File

@@ -70,10 +70,6 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
docId: doc.id,
});
const invitationQuery = useDocInvitationsInfinite({
docId: doc.id,
});
const searchUsersQuery = useUsers(
{ query: userQuery, docId: doc.id },
{
@@ -107,52 +103,6 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
};
}, [membersQuery, t]);
const invitationsData: QuickSearchData<Invitation> = useMemo(() => {
const invitations =
invitationQuery.data?.pages.flatMap((page) => page.results) || [];
return {
groupName: t('Pending invitations'),
elements: invitations,
endActions: invitationQuery.hasNextPage
? [
{
content: <LoadMoreText data-testid="load-more-invitations" />,
onSelect: () => void invitationQuery.fetchNextPage(),
},
]
: undefined,
};
}, [invitationQuery, t]);
const searchUserData: QuickSearchData<User> = useMemo(() => {
const users = searchUsersQuery.data || [];
const isEmail = isValidEmail(userQuery);
const newUser: User = {
id: userQuery,
full_name: '',
email: userQuery,
short_name: '',
language: '',
};
const hasEmailInUsers = users.some((user) => user.email === userQuery);
return {
groupName: t('Search user result'),
elements: users,
endActions:
isEmail && !hasEmailInUsers
? [
{
content: <DocShareModalInviteUserRow user={newUser} />,
onSelect: () => void onSelect(newUser),
},
]
: undefined,
};
}, [searchUsersQuery.data, t, userQuery]);
const onFilter = useDebouncedCallback((str: string) => {
setUserQuery(str);
}, 300);
@@ -254,44 +204,17 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
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={membersData}
renderElement={(access) => (
<DocShareMemberItem doc={doc} access={access} />
)}
/>
</Box>
</>
)}
</>
{showMemberSection ? (
<QuickSearchMemberSection
doc={doc}
membersData={membersData}
/>
) : (
<QuickSearchInviteInputSection
searchUsersRawData={searchUsersQuery.data}
onSelect={onSelect}
userQuery={userQuery}
/>
)}
</QuickSearch>
)}
@@ -306,3 +229,109 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
</>
);
};
interface QuickSearchInviteInputSectionProps {
onSelect: (usr: User) => void;
searchUsersRawData: User[] | undefined;
userQuery: string;
}
const QuickSearchInviteInputSection = ({
onSelect,
searchUsersRawData,
userQuery,
}: QuickSearchInviteInputSectionProps) => {
const { t } = useTranslation();
const searchUserData: QuickSearchData<User> = useMemo(() => {
const users = searchUsersRawData || [];
const isEmail = isValidEmail(userQuery);
const newUser: User = {
id: userQuery,
full_name: '',
email: userQuery,
short_name: '',
language: '',
};
const hasEmailInUsers = users.some((user) => user.email === userQuery);
return {
groupName: t('Search user result'),
elements: users,
endActions:
isEmail && !hasEmailInUsers
? [
{
content: <DocShareModalInviteUserRow user={newUser} />,
onSelect: () => void onSelect(newUser),
},
]
: undefined,
};
}, [onSelect, searchUsersRawData, t, userQuery]);
return (
<QuickSearchGroup
group={searchUserData}
onSelect={onSelect}
renderElement={(user) => <DocShareModalInviteUserRow user={user} />}
/>
);
};
interface QuickSearchMemberSectionProps {
doc: Doc;
membersData: QuickSearchData<Access>;
}
const QuickSearchMemberSection = ({
doc,
membersData,
}: QuickSearchMemberSectionProps) => {
const { t } = useTranslation();
const { data, hasNextPage, fetchNextPage } = useDocInvitationsInfinite({
docId: doc.id,
});
const invitationsData: QuickSearchData<Invitation> = useMemo(() => {
const invitations = data?.pages.flatMap((page) => page.results) || [];
return {
groupName: t('Pending invitations'),
elements: invitations,
endActions: hasNextPage
? [
{
content: <LoadMoreText data-testid="load-more-invitations" />,
onSelect: () => void fetchNextPage(),
},
]
: undefined,
};
}, [data?.pages, fetchNextPage, hasNextPage, t]);
return (
<>
{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={membersData}
renderElement={(access) => (
<DocShareMemberItem doc={doc} access={access} />
)}
/>
</Box>
</>
);
};