(frontend) add access request on doc share modal

Add the access request to the document
share modal, allowing admin to see and manage
access requests directly from the modal interface.
This commit is contained in:
Anthony LC
2025-06-24 17:42:41 +02:00
committed by Manuel Raynaud
parent 411d52c73b
commit 2360a832af
7 changed files with 403 additions and 18 deletions

View File

@@ -6,11 +6,19 @@ import {
useQueryClient,
} from '@tanstack/react-query';
import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
import {
APIError,
APIList,
errorCauses,
fetchAPI,
useAPIInfiniteQuery,
} from '@/api';
import { AccessRequest, Doc, Role } from '@/docs/doc-management';
import { OptionType } from '../types';
import { KEY_LIST_DOC_ACCESSES } from './useDocAccesses';
interface CreateDocAccessRequestParams {
docId: Doc['id'];
role?: Role;
@@ -19,7 +27,7 @@ interface CreateDocAccessRequestParams {
export const createDocAccessRequest = async ({
docId,
role,
}: CreateDocAccessRequestParams): Promise<null> => {
}: CreateDocAccessRequestParams): Promise<void> => {
const response = await fetchAPI(`documents/${docId}/ask-for-access/`, {
method: 'POST',
body: JSON.stringify({
@@ -35,12 +43,10 @@ export const createDocAccessRequest = async ({
}),
);
}
return null;
};
type UseCreateDocAccessRequestOptions = UseMutationOptions<
null,
void,
APIError,
CreateDocAccessRequestParams
>;
@@ -50,7 +56,7 @@ export function useCreateDocAccessRequest(
) {
const queryClient = useQueryClient();
return useMutation<null, APIError, CreateDocAccessRequestParams>({
return useMutation<void, APIError, CreateDocAccessRequestParams>({
mutationFn: createDocAccessRequest,
...options,
onSuccess: (data, variables, context) => {
@@ -65,14 +71,21 @@ export function useCreateDocAccessRequest(
type AccessRequestResponse = APIList<AccessRequest>;
interface GetDocAccessRequestsParams {
interface DocAccessRequestsParams {
docId: Doc['id'];
}
export type DocAccessRequestsAPIParams = DocAccessRequestsParams & {
page: number;
};
export const getDocAccessRequests = async ({
docId,
}: GetDocAccessRequestsParams): Promise<AccessRequestResponse> => {
const response = await fetchAPI(`documents/${docId}/ask-for-access/`);
page,
}: DocAccessRequestsAPIParams): Promise<AccessRequestResponse> => {
const response = await fetchAPI(
`documents/${docId}/ask-for-access/?page=${page}`,
);
if (!response.ok) {
throw new APIError(
@@ -87,7 +100,7 @@ export const getDocAccessRequests = async ({
export const KEY_LIST_DOC_ACCESS_REQUESTS = 'docs-access-requests';
export function useDocAccessRequests(
params: GetDocAccessRequestsParams,
params: DocAccessRequestsAPIParams,
queryConfig?: UseQueryOptions<
AccessRequestResponse,
APIError,
@@ -100,3 +113,124 @@ export function useDocAccessRequests(
...queryConfig,
});
}
export const useDocAccessRequestsInfinite = (
params: DocAccessRequestsParams,
) => {
return useAPIInfiniteQuery(
KEY_LIST_DOC_ACCESS_REQUESTS,
getDocAccessRequests,
params,
);
};
interface acceptDocAccessRequestsParams {
docId: string;
accessRequestId: string;
role: Role;
}
export const acceptDocAccessRequests = async ({
docId,
accessRequestId,
role,
}: acceptDocAccessRequestsParams): Promise<void> => {
const response = await fetchAPI(
`documents/${docId}/ask-for-access/${accessRequestId}/accept/`,
{
method: 'POST',
body: JSON.stringify({
role,
}),
},
);
if (!response.ok) {
throw new APIError(
'Failed to accept the access request',
await errorCauses(response),
);
}
};
type UseAcceptDocAccessRequests = Partial<AccessRequest>;
type UseAcceptDocAccessRequestsOptions = UseMutationOptions<
void,
APIError,
UseAcceptDocAccessRequests
>;
export const useAcceptDocAccessRequest = (
options?: UseAcceptDocAccessRequestsOptions,
) => {
const queryClient = useQueryClient();
return useMutation<void, APIError, acceptDocAccessRequestsParams>({
mutationFn: acceptDocAccessRequests,
...options,
onSuccess: (data, variables, context) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_DOC_ACCESSES],
});
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_DOC_ACCESS_REQUESTS],
});
if (options?.onSuccess) {
void options.onSuccess(data, variables, context);
}
},
});
};
interface DeleteDocAccessRequestParams {
docId: string;
accessRequestId: string;
}
export const deleteDocAccessRequest = async ({
docId,
accessRequestId,
}: DeleteDocAccessRequestParams): Promise<void> => {
const response = await fetchAPI(
`documents/${docId}/ask-for-access/${accessRequestId}/`,
{
method: 'DELETE',
},
);
if (!response.ok) {
throw new APIError(
'Failed to delete the access request',
await errorCauses(response),
);
}
};
type UseDeleteDocAccessRequestOptions = UseMutationOptions<
void,
APIError,
DeleteDocAccessRequestParams
>;
export const useDeleteDocAccessRequest = (
options?: UseDeleteDocAccessRequestOptions,
) => {
const queryClient = useQueryClient();
return useMutation<void, APIError, DeleteDocAccessRequestParams>({
mutationFn: deleteDocAccessRequest,
...options,
onSuccess: (data, variables, context) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_DOC_ACCESS_REQUESTS],
});
if (options?.onSuccess) {
void options.onSuccess(data, variables, context);
}
},
});
};

View File

@@ -0,0 +1,149 @@
import {
Button,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { createGlobalStyle } from 'styled-components';
import { Box, BoxButton, Icon, LoadMoreText } from '@/components';
import { QuickSearchData, QuickSearchGroup } from '@/components/quick-search';
import { useCunninghamTheme } from '@/cunningham';
import { AccessRequest, Doc } from '@/docs/doc-management/';
import {
useAcceptDocAccessRequest,
useDeleteDocAccessRequest,
useDocAccessRequestsInfinite,
} from '../api/useDocAccessRequest';
import { DocRoleDropdown } from './DocRoleDropdown';
import { SearchUserRow } from './SearchUserRow';
const QuickSearchGroupAccessRequestStyle = createGlobalStyle`
.--docs--share-access-request [cmdk-item][data-selected='true'] {
background: inherit
}
`;
type Props = {
doc: Doc;
accessRequest: AccessRequest;
};
const DocShareAccessRequestItem = ({ doc, accessRequest }: Props) => {
const { t } = useTranslation();
const { toast } = useToastProvider();
const { spacingsTokens } = useCunninghamTheme();
const { mutate: acceptDocAccessRequests } = useAcceptDocAccessRequest();
const [role, setRole] = useState(accessRequest.role);
const { mutate: removeDocAccess } = useDeleteDocAccessRequest({
onError: () => {
toast(t('Error while removing the request.'), VariantType.ERROR, {
duration: 4000,
});
},
});
if (!doc.abilities.accesses_view) {
return null;
}
return (
<Box
$width="100%"
data-testid={`doc-share-access-request-row-${accessRequest.user.email}`}
className="--docs--doc-share-access-request-item"
>
<SearchUserRow
alwaysShowRight={true}
user={accessRequest.user}
right={
<Box $direction="row" $align="center" $gap={spacingsTokens['sm']}>
<DocRoleDropdown
currentRole={role}
onSelectRole={setRole}
canUpdate={doc.abilities.accesses_manage}
/>
<Button
color="tertiary"
onClick={() =>
acceptDocAccessRequests({
docId: doc.id,
accessRequestId: accessRequest.id,
role,
})
}
size="small"
>
{t('Approve')}
</Button>
{doc.abilities.accesses_manage && (
<BoxButton
onClick={() =>
removeDocAccess({
accessRequestId: accessRequest.id,
docId: doc.id,
})
}
>
<Icon iconName="close" $variation="600" $size="16px" />
</BoxButton>
)}
</Box>
}
/>
</Box>
);
};
interface QuickSearchGroupAccessRequestProps {
doc: Doc;
}
export const QuickSearchGroupAccessRequest = ({
doc,
}: QuickSearchGroupAccessRequestProps) => {
const { t } = useTranslation();
const accessRequestQuery = useDocAccessRequestsInfinite({ docId: doc.id });
const accessRequestsData: QuickSearchData<AccessRequest> = useMemo(() => {
const accessRequests =
accessRequestQuery.data?.pages.flatMap((page) => page.results) || [];
return {
groupName: t('Access Requests'),
elements: accessRequests,
endActions: accessRequestQuery.hasNextPage
? [
{
content: <LoadMoreText data-testid="load-more-requests" />,
onSelect: () => void accessRequestQuery.fetchNextPage(),
},
]
: undefined,
};
}, [accessRequestQuery, t]);
if (!accessRequestsData.elements.length) {
return null;
}
return (
<Box
aria-label={t('List request access card')}
className="--docs--share-access-request"
>
<QuickSearchGroupAccessRequestStyle />
<QuickSearchGroup
group={accessRequestsData}
renderElement={(accessRequest) => (
<DocShareAccessRequestItem doc={doc} accessRequest={accessRequest} />
)}
/>
</Box>
);
};

View File

@@ -17,6 +17,7 @@ import { isValidEmail } from '@/utils';
import { KEY_LIST_USER, useUsers } from '../api';
import { QuickSearchGroupAccessRequest } from './DocShareAccessRequest';
import { DocShareAddMemberList } from './DocShareAddMemberList';
import {
DocShareModalInviteUserRow,
@@ -26,6 +27,9 @@ import { QuickSearchGroupMember } from './DocShareMember';
import { DocShareModalFooter } from './DocShareModalFooter';
const ShareModalStyle = createGlobalStyle`
.--docs--doc-share-modal [cmdk-item] {
cursor: auto;
}
.c__modal__title {
padding-bottom: 0 !important;
}
@@ -173,6 +177,7 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
>
{showMemberSection ? (
<>
<QuickSearchGroupAccessRequest doc={doc} />
<QuickSearchGroupInvitation doc={doc} />
<QuickSearchGroupMember doc={doc} />
</>