(frontend) added new features for document management

- Created new files for managing subdocuments and detaching documents.
- Refactored API request configuration to use an improved configuration
type.
- Removed unnecessary logs from the ModalConfirmDownloadUnsafe
component.
This commit is contained in:
Nathan Panchout
2025-03-27 16:08:39 +01:00
committed by Anthony LC
parent 9a64ebc1e9
commit 2a3b31fcff
11 changed files with 189 additions and 37 deletions

View File

@@ -159,6 +159,46 @@ test.describe('Doc Tree', () => {
`doc-sub-page-item-${firstSubPageJson.id}`,
);
});
test('it detachs a document', async ({ page, browserName }) => {
await page.goto('/');
const [docParent] = await createDoc(
page,
'doc-tree-detach',
browserName,
1,
);
await verifyDocName(page, docParent);
const [docChild] = await createDoc(
page,
'doc-tree-detach-child',
browserName,
1,
true,
);
await verifyDocName(page, docChild);
const docTree = page.getByTestId('doc-tree');
const child = docTree
.getByRole('treeitem')
.locator('.--docs-sub-page-item')
.filter({
hasText: docChild,
});
await child.hover();
const menu = child.getByText(`more_horiz`);
await menu.click();
await page.getByText('Convert to doc').click();
await expect(
page.getByRole('textbox', { name: 'doc title input' }),
).not.toHaveText(docChild);
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
await expect(page.getByText(docChild)).toBeVisible();
});
});
test.describe('Doc Tree: Inheritance', () => {

View File

@@ -21,6 +21,11 @@ export type DefinedInitialDataInfiniteOptionsAPI<
TPageParam
>;
export type InfiniteQueryConfig<Q> = Omit<
DefinedInitialDataInfiniteOptionsAPI<Q>,
'queryKey' | 'initialData' | 'getNextPageParam' | 'initialPageParam'
>;
/**
* Custom React hook that wraps React Query's `useInfiniteQuery` for paginated API requests.
*
@@ -38,7 +43,7 @@ export const useAPIInfiniteQuery = <T, Q extends { next?: APIList<Q>['next'] }>(
key: string,
api: (props: T & { page: number }) => Promise<Q>,
param: T,
queryConfig?: DefinedInitialDataInfiniteOptionsAPI<Q>,
queryConfig?: InfiniteQueryConfig<Q>,
) => {
return useInfiniteQuery<Q, APIError, InfiniteData<Q>, QueryKey, number>({
initialPageParam: 1,

View File

@@ -4,6 +4,7 @@ export * from './useDeleteFavoriteDoc';
export * from './useDoc';
export * from './useDocOptions';
export * from './useDocs';
export * from './useSubDocs';
export * from './useDuplicateDoc';
export * from './useUpdateDoc';
export * from './useUpdateDocLink';

View File

@@ -8,22 +8,7 @@ import {
useAPIInfiniteQuery,
} from '@/api';
import { Doc } from '../types';
export const isDocsOrdering = (data: string): data is DocsOrdering => {
return !!docsOrdering.find((validKey) => validKey === data);
};
const docsOrdering = [
'created_at',
'-created_at',
'updated_at',
'-updated_at',
'title',
'-title',
] as const;
export type DocsOrdering = (typeof docsOrdering)[number];
import { Doc, DocsOrdering } from '../types';
export type DocsParams = {
page: number;
@@ -33,26 +18,31 @@ export type DocsParams = {
is_favorite?: boolean;
};
export type DocsResponse = APIList<Doc>;
export const getDocs = async (params: DocsParams): Promise<DocsResponse> => {
export const constructParams = (params: DocsParams): URLSearchParams => {
const searchParams = new URLSearchParams();
if (params.page) {
searchParams.set('page', params.page.toString());
}
if (params.ordering) {
searchParams.set('ordering', params.ordering);
}
if (params.is_creator_me !== undefined) {
searchParams.set('is_creator_me', params.is_creator_me.toString());
}
if (params.title && params.title.length > 0) {
searchParams.set('title', params.title);
}
if (params.is_favorite !== undefined) {
searchParams.set('is_favorite', params.is_favorite.toString());
}
return searchParams;
};
export type DocsResponse = APIList<Doc>;
export const getDocs = async (params: DocsParams): Promise<DocsResponse> => {
const searchParams = constructParams(params);
const response = await fetchAPI(`documents/?${searchParams.toString()}`);
if (!response.ok) {

View File

@@ -0,0 +1,62 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import {
APIError,
InfiniteQueryConfig,
errorCauses,
fetchAPI,
useAPIInfiniteQuery,
} from '@/api';
import { DocsOrdering } from '../types';
import { DocsResponse, constructParams } from './useDocs';
export type SubDocsParams = {
page: number;
ordering?: DocsOrdering;
is_creator_me?: boolean;
title?: string;
is_favorite?: boolean;
parent_id: string;
};
export const getSubDocs = async (
params: SubDocsParams,
): Promise<DocsResponse> => {
const searchParams = constructParams(params);
searchParams.set('parent_id', params.parent_id);
const response: Response = await fetchAPI(
`documents/${params.parent_id}/descendants/?${searchParams.toString()}`,
);
if (!response.ok) {
throw new APIError(
'Failed to get the sub docs',
await errorCauses(response),
);
}
return response.json() as Promise<DocsResponse>;
};
export const KEY_LIST_SUB_DOC = 'sub-docs';
export function useSubDocs(
params: SubDocsParams,
queryConfig?: UseQueryOptions<DocsResponse, APIError, DocsResponse>,
) {
return useQuery<DocsResponse, APIError, DocsResponse>({
queryKey: [KEY_LIST_SUB_DOC, params],
queryFn: () => getSubDocs(params),
...queryConfig,
});
}
export const useInfiniteSubDocs = (
params: SubDocsParams,
queryConfig?: InfiniteQueryConfig<DocsResponse>,
) => {
return useAPIInfiniteQuery(KEY_LIST_SUB_DOC, getSubDocs, params, queryConfig);
};

View File

@@ -37,19 +37,20 @@ export type Base64 = string;
export interface Doc {
id: string;
title?: string;
children?: Doc[];
childrenCount?: number;
content: Base64;
created_at: string;
creator: string;
depth: number;
is_favorite: boolean;
link_reach: LinkReach;
link_role: LinkRole;
user_roles: Role[];
created_at: string;
updated_at: string;
nb_accesses_direct: number;
nb_accesses_ancestors: number;
children?: Doc[];
childrenCount?: number;
numchild: number;
updated_at: string;
user_roles: Role[];
abilities: {
accesses_manage: boolean;
accesses_view: boolean;
@@ -82,6 +83,15 @@ export enum DocDefaultFilter {
SHARED_WITH_ME = 'shared_with_me',
}
export type DocsOrdering =
| 'title'
| 'created_at'
| '-created_at'
| 'updated_at'
| '-updated_at'
| '-title'
| undefined;
export interface AccessRequest {
id: string;
document: string;

View File

@@ -0,0 +1,51 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { KEY_DOC, KEY_LIST_DOC } from '../../doc-management';
export type DetachDocParam = {
documentId: string;
rootId: string;
};
enum POSITION_MOVE {
FIRST_CHILD = 'first-child',
LAST_CHILD = 'last-child',
FIRST_SIBLING = 'first-sibling',
LAST_SIBLING = 'last-sibling',
LEFT = 'left',
RIGHT = 'right',
}
export const detachDoc = async ({
documentId,
rootId,
}: DetachDocParam): Promise<void> => {
const response = await fetchAPI(`documents/${documentId}/move/`, {
method: 'POST',
body: JSON.stringify({
target_document_id: rootId,
position: POSITION_MOVE.LAST_SIBLING,
}),
});
if (!response.ok) {
throw new APIError('Failed to move the doc', await errorCauses(response));
}
return response.json() as Promise<void>;
};
export function useDetachDoc() {
const queryClient = useQueryClient();
return useMutation<void, APIError, DetachDocParam>({
mutationFn: detachDoc,
onSuccess: (_data, variables) => {
void queryClient.invalidateQueries({ queryKey: [KEY_LIST_DOC] });
void queryClient.invalidateQueries({
queryKey: [KEY_DOC, { id: variables.documentId }],
});
},
});
}

View File

@@ -37,7 +37,7 @@ export const DocTreeItemActions = ({
const { togglePanel } = useLeftPanelStore();
const copyLink = useCopyDocLink(doc.id);
const canUpdate = isOwnerOrAdmin(doc);
const { isChild } = useTreeUtils(doc);
const { isCurrentParent } = useTreeUtils(doc);
const { mutate: detachDoc } = useDetachDoc();
const treeContext = useTreeContext<Doc>();
@@ -66,7 +66,7 @@ export const DocTreeItemActions = ({
icon: <Icon iconName="link" $size="24px" />,
callback: copyLink,
},
...(isChild
...(!isCurrentParent
? [
{
label: t('Convert to doc'),

View File

@@ -8,6 +8,6 @@ export const useTreeUtils = (doc: Doc) => {
return {
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, // it can be a child but not for the current user
isCurrentParent: treeContext?.root?.id === doc.id || doc.depth === 1, // it can be a child but not for the current user
} as const;
};

View File

@@ -172,6 +172,7 @@ export class ApiPlugin implements WorkboxPlugin {
content: '',
created_at: new Date().toISOString(),
creator: 'dummy-id',
depth: 1,
is_favorite: false,
nb_accesses_direct: 1,
nb_accesses_ancestors: 1,

View File

@@ -68,11 +68,3 @@ main ::-webkit-scrollbar-thumb:hover,
/* Support for IE. */
font-feature-settings: 'liga';
}
[data-nextjs-dialog-overlay] {
display: none !important;
}
nextjs-portal {
display: none;
}