(frontend) can restore from trashbin list actions

We can now restore a doc from the trashbin list actions.
This commit is contained in:
Anthony LC
2025-10-03 12:45:55 +02:00
parent 37138c1a23
commit de4d11732f
19 changed files with 332 additions and 64 deletions

View File

@@ -29,10 +29,12 @@ test.describe('Doc Trashbin', () => {
const row1 = await getGridRow(page, title1);
await clickInGridMenu(page, row1, 'Delete');
await page.getByRole('button', { name: 'Delete document' }).click();
await expect(row1.getByText(title1)).toBeHidden();
const row2 = await getGridRow(page, title2);
await clickInGridMenu(page, row2, 'Delete');
await page.getByRole('button', { name: 'Delete document' }).click();
await expect(row2.getByText(title2)).toBeHidden();
await page.getByRole('link', { name: 'Trashbin' }).click();
@@ -51,5 +53,25 @@ test.describe('Doc Trashbin', () => {
name: 'Open the sharing settings for the document',
}),
).toBeDisabled();
await clickInGridMenu(page, row2, 'Restore');
await expect(row2.getByText(title2)).toBeHidden();
await page.getByRole('link', { name: 'All docs' }).click();
const row2Restored = await getGridRow(page, title2);
await expect(row2Restored.getByText(title2)).toBeVisible();
await row2Restored.getByRole('link', { name: /Open document/ }).click();
await verifyDocName(page, title2);
await page.getByRole('button', { name: 'Back to homepage' }).click();
await expect(row2.getByText(title2)).toBeVisible();
await expect(
row2.getByRole('button', {
name: 'Open the sharing settings for the document',
}),
).toBeEnabled();
await page.getByRole('link', { name: 'Trashbin' }).click();
await expect(row2.getByText(title2)).toBeHidden();
});
});

View File

@@ -25,8 +25,6 @@ export const TextErrors = ({
canClose = false,
...textProps
}: TextErrorsProps) => {
const { t } = useTranslation();
return (
<AlertStyled
canClose={canClose}
@@ -34,31 +32,47 @@ export const TextErrors = ({
icon={icon}
className="--docs--text-errors"
>
<Box $direction="column" $gap="0.2rem">
{causes &&
causes.map((cause, i) => (
<Text
key={`causes-${i}`}
$theme="danger"
$variation="600"
$textAlign="center"
{...textProps}
>
{cause}
</Text>
))}
<TextOnlyErrors
causes={causes}
defaultMessage={defaultMessage}
{...textProps}
/>
</AlertStyled>
);
};
{!causes && (
export const TextOnlyErrors = ({
causes,
defaultMessage,
...textProps
}: TextErrorsProps) => {
const { t } = useTranslation();
return (
<Box $direction="column" $gap="0.2rem">
{causes &&
causes.map((cause, i) => (
<Text
key={`causes-${i}`}
$theme="danger"
$variation="600"
$textAlign="center"
{...textProps}
>
{defaultMessage || t('Something bad happens, please retry.')}
{cause}
</Text>
)}
</Box>
</AlertStyled>
))}
{!causes && (
<Text
$theme="danger"
$variation="600"
$textAlign="center"
{...textProps}
>
{defaultMessage || t('Something bad happens, please retry.')}
</Text>
)}
</Box>
);
};

View File

@@ -2,6 +2,7 @@ import { HorizontalSeparator } from '@gouvfr-lasuite/ui-kit';
import {
Fragment,
PropsWithChildren,
ReactNode,
useCallback,
useEffect,
useRef,
@@ -15,7 +16,7 @@ import { useCunninghamTheme } from '@/cunningham';
import { useDropdownKeyboardNav } from './hook/useDropdownKeyboardNav';
export type DropdownMenuOption = {
icon?: string;
icon?: ReactNode;
label: string;
testId?: string;
value?: string;
@@ -220,7 +221,7 @@ export const DropdownMenu = ({
$align="center"
$gap={spacingsTokens['base']}
>
{option.icon && (
{option.icon && typeof option.icon === 'string' && (
<Icon
$size="20px"
$theme="greyscale"
@@ -229,6 +230,9 @@ export const DropdownMenu = ({
aria-hidden="true"
/>
)}
{option.icon &&
typeof option.icon !== 'string' &&
option.icon}
<Text $variation={isDisabled ? '400' : '1000'}>
{option.label}
</Text>

View File

@@ -1,4 +1,3 @@
export * from './modal/AlertModal';
export * from './Box';
export * from './BoxButton';
export * from './Card';
@@ -9,7 +8,7 @@ export * from './Icon';
export * from './InfiniteScroll';
export * from './Link';
export * from './Loading';
export * from './modal/SideModal';
export * from './modal';
export * from './separators';
export * from './Text';
export * from './TextErrors';

View File

@@ -3,7 +3,7 @@ import React from 'react';
import { Box } from '@/components';
const ButtonCloseModal = (props: ButtonProps) => {
export const ButtonCloseModal = (props: ButtonProps) => {
return (
<Button
type="button"
@@ -18,5 +18,3 @@ const ButtonCloseModal = (props: ButtonProps) => {
/>
);
};
export default ButtonCloseModal;

View File

@@ -0,0 +1,2 @@
export * from './AlertModal';
export * from './ButtonCloseModal';

View File

@@ -16,8 +16,7 @@ import { cloneElement, isValidElement, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Text } from '@/components';
import ButtonCloseModal from '@/components/modal/ButtonCloseModal';
import { Box, ButtonCloseModal, Text } from '@/components';
import { useEditorStore } from '@/docs/doc-editor';
import { Doc, useTrans } from '@/docs/doc-management';

View File

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

View File

@@ -3,6 +3,7 @@ import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import {
APIError,
APIList,
InfiniteQueryConfig,
errorCauses,
fetchAPI,
useAPIInfiniteQuery,
@@ -54,10 +55,10 @@ export const getDocs = async (params: DocsParams): Promise<DocsResponse> => {
export const KEY_LIST_DOC = 'docs';
export function useDocs(
params: DocsParams,
queryConfig?: UseQueryOptions<DocsResponse, APIError, DocsResponse>,
) {
type UseDocsOptions = UseQueryOptions<DocsResponse, APIError, DocsResponse>;
type UseInfiniteDocsOptions = InfiniteQueryConfig<DocsResponse>;
export function useDocs(params: DocsParams, queryConfig?: UseDocsOptions) {
return useQuery<DocsResponse, APIError, DocsResponse>({
queryKey: [KEY_LIST_DOC, params],
queryFn: () => getDocs(params),
@@ -65,6 +66,9 @@ export function useDocs(
});
}
export const useInfiniteDocs = (params: DocsParams) => {
return useAPIInfiniteQuery(KEY_LIST_DOC, getDocs, params);
export const useInfiniteDocs = (
params: DocsParams,
queryConfig?: UseInfiniteDocsOptions,
) => {
return useAPIInfiniteQuery(KEY_LIST_DOC, getDocs, params, queryConfig);
};

View File

@@ -6,8 +6,6 @@ import {
import { APIError, errorCauses, fetchAPI } from '@/api';
import { KEY_LIST_DOC } from './useDocs';
interface RemoveDocProps {
docId: string;
}
@@ -24,14 +22,22 @@ export const removeDoc = async ({ docId }: RemoveDocProps): Promise<void> => {
type UseRemoveDocOptions = UseMutationOptions<void, APIError, RemoveDocProps>;
export const useRemoveDoc = (options?: UseRemoveDocOptions) => {
export const useRemoveDoc = ({
listInvalidQueries,
options,
}: {
listInvalidQueries?: string[];
options?: UseRemoveDocOptions;
}) => {
const queryClient = useQueryClient();
return useMutation<void, APIError, RemoveDocProps>({
mutationFn: removeDoc,
...options,
onSuccess: (data, variables, context) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_DOC],
listInvalidQueries?.forEach((queryKey) => {
void queryClient.invalidateQueries({
queryKey: [queryKey],
});
});
if (options?.onSuccess) {
void options.onSuccess(data, variables, context);

View File

@@ -0,0 +1,55 @@
import {
UseMutationOptions,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
interface RestoreDocProps {
docId: string;
}
export const restoreDoc = async ({ docId }: RestoreDocProps): Promise<void> => {
const response = await fetchAPI(`documents/${docId}/restore/`, {
method: 'POST',
});
if (!response.ok) {
throw new APIError(
'Failed to restore the doc',
await errorCauses(response),
);
}
};
type UseRestoreDocOptions = UseMutationOptions<void, APIError, RestoreDocProps>;
export const useRestoreDoc = ({
listInvalidQueries,
options,
}: {
listInvalidQueries?: string[];
options?: UseRestoreDocOptions;
}) => {
const queryClient = useQueryClient();
return useMutation<void, APIError, RestoreDocProps>({
mutationFn: restoreDoc,
...options,
onSuccess: (data, variables, context) => {
listInvalidQueries?.forEach((queryKey) => {
void queryClient.invalidateQueries({
queryKey: [queryKey],
});
});
if (options?.onSuccess) {
void options.onSuccess(data, variables, context);
}
},
onError: (error, variables, context) => {
if (options?.onError) {
void options.onError(error, variables, context);
}
},
});
};

View File

@@ -9,10 +9,13 @@ import { usePathname } from 'next/navigation';
import { useRouter } from 'next/router';
import { Trans, useTranslation } from 'react-i18next';
import { Box, Text, TextErrors } from '@/components';
import ButtonCloseModal from '@/components/modal/ButtonCloseModal';
import { Box, ButtonCloseModal, Text, TextErrors } from '@/components';
import { useConfig } from '@/core';
import { KEY_LIST_DOC_TRASHBIN } from '@/docs/docs-grid';
import { KEY_LIST_DOC } from '../api/useDocs';
import { useRemoveDoc } from '../api/useRemoveDoc';
import { useDocUtils } from '../hooks';
import { Doc } from '../types';
interface ModalRemoveDocProps {
@@ -28,25 +31,31 @@ export const ModalRemoveDoc = ({
}: ModalRemoveDocProps) => {
const { toast } = useToastProvider();
const { t } = useTranslation();
const { data: config } = useConfig();
const trashBinCutoffDays = config?.TRASHBIN_CUTOFF_DAYS || 30;
const { push } = useRouter();
const { hasChildren } = useDocUtils(doc);
const pathname = usePathname();
const {
mutate: removeDoc,
isError,
error,
} = useRemoveDoc({
onSuccess: () => {
if (onSuccess) {
onSuccess(doc);
} else if (pathname === '/') {
onClose();
} else {
void push('/');
}
listInvalidQueries: [KEY_LIST_DOC, KEY_LIST_DOC_TRASHBIN],
options: {
onSuccess: () => {
if (onSuccess) {
onSuccess(doc);
} else if (pathname === '/') {
onClose();
} else {
void push('/');
}
toast(t('The document has been deleted.'), VariantType.SUCCESS, {
duration: 4000,
});
toast(t('The document has been deleted.'), VariantType.SUCCESS, {
duration: 4000,
});
},
},
});
@@ -109,10 +118,18 @@ export const ModalRemoveDoc = ({
<Box className="--docs--modal-remove-doc">
{!isError && (
<Text $size="sm" $variation="600" $display="inline-block" as="p">
<Trans t={t}>
This document and <strong>any sub-documents</strong> will be
permanently deleted. This action is irreversible.
</Trans>
{hasChildren ? (
<Trans t={t}>
This document and <strong>any sub-documents</strong> will be
placed in the trashbin. You can restore it within{' '}
{{ days: trashBinCutoffDays }} days.
</Trans>
) : (
t(
'This document will be placed in the trashbin. You can restore it within {{days}} days.',
{ days: trashBinCutoffDays },
)
)}
</Text>
)}

View File

@@ -5,8 +5,7 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDebouncedCallback } from 'use-debounce';
import { Box, Text } from '@/components';
import ButtonCloseModal from '@/components/modal/ButtonCloseModal';
import { Box, ButtonCloseModal, Text } from '@/components';
import { QuickSearch } from '@/components/quick-search';
import { Doc, useDocUtils } from '@/docs/doc-management';
import { useResponsiveStore } from '@/stores';

View File

@@ -4,8 +4,7 @@ import { useTranslation } from 'react-i18next';
import { createGlobalStyle, css } from 'styled-components';
import { useDebouncedCallback } from 'use-debounce';
import { Box, HorizontalSeparator, Text } from '@/components';
import ButtonCloseModal from '@/components/modal/ButtonCloseModal';
import { Box, ButtonCloseModal, HorizontalSeparator, Text } from '@/components';
import {
QuickSearch,
QuickSearchData,

View File

@@ -3,8 +3,7 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { createGlobalStyle, css } from 'styled-components';
import { Box, Text } from '@/components';
import ButtonCloseModal from '@/components/modal/ButtonCloseModal';
import { Box, ButtonCloseModal, Text } from '@/components';
import { DocEditor } from '@/docs/doc-editor';
import { Doc } from '@/docs/doc-management';

View File

@@ -0,0 +1 @@
export * from './useDocsTrashbin';

View File

@@ -0,0 +1,60 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import {
APIError,
APIList,
InfiniteQueryConfig,
errorCauses,
fetchAPI,
useAPIInfiniteQuery,
} from '@/api';
import { Doc, DocsResponse } from '@/docs/doc-management';
export type DocsTrashbinParams = {
page: number;
};
export type DocsTrashbinResponse = APIList<Doc>;
export const getDocsTrashbin = async (
params: DocsTrashbinParams,
): Promise<DocsTrashbinResponse> => {
const response = await fetchAPI(`documents/trashbin/?page=${params.page}`);
if (!response.ok) {
throw new APIError('Failed to get the docs', await errorCauses(response));
}
return response.json() as Promise<DocsTrashbinResponse>;
};
export const KEY_LIST_DOC_TRASHBIN = 'docs_trashbin';
type UseDocsTrashbinOptions = UseQueryOptions<
DocsResponse,
APIError,
DocsResponse
>;
type UseInfiniteDocsTrashbinOptions = InfiniteQueryConfig<DocsTrashbinResponse>;
export function useDocsTrashbin(
params: DocsTrashbinParams,
queryConfig?: UseDocsTrashbinOptions,
) {
return useQuery<DocsTrashbinResponse, APIError, DocsTrashbinResponse>({
queryKey: [KEY_LIST_DOC_TRASHBIN, params],
queryFn: () => getDocsTrashbin(params),
...queryConfig,
});
}
export const useInfiniteDocsTrashbin = (
params: DocsTrashbinParams,
queryConfig?: UseInfiniteDocsTrashbinOptions,
) => {
return useAPIInfiniteQuery(
KEY_LIST_DOC_TRASHBIN,
getDocsTrashbin,
params,
queryConfig,
);
};

View File

@@ -0,0 +1,88 @@
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { DropdownMenu, DropdownMenuOption, Icon } from '@/components';
import { Doc, KEY_LIST_DOC, useRestoreDoc } from '@/docs/doc-management';
import { KEY_LIST_DOC_TRASHBIN } from '../api';
interface DocsGridTrashbinActionsProps {
doc: Doc;
}
export const DocsGridTrashbinActions = ({
doc,
}: DocsGridTrashbinActionsProps) => {
const { t } = useTranslation();
const { toast } = useToastProvider();
const { mutate: restoreDoc, error } = useRestoreDoc({
listInvalidQueries: [KEY_LIST_DOC, KEY_LIST_DOC_TRASHBIN],
options: {
onSuccess: (_data) => {
toast(t('The document has been restored.'), VariantType.SUCCESS, {
duration: 4000,
});
},
onError: () => {
toast(
t('An error occurred while restoring the document: {{error}}', {
error: error?.message,
}),
VariantType.ERROR,
{
duration: 4000,
},
);
},
},
});
const options: DropdownMenuOption[] = [
{
label: t('Restore'),
icon: (
<Icon
$size="20px"
$theme="greyscale"
$variation="1000"
iconName="undo"
aria-hidden="true"
variant="symbols-outlined"
/>
),
callback: () => {
restoreDoc({
docId: doc.id,
});
},
testId: `docs-grid-actions-restore-${doc.id}`,
},
];
const documentTitle = doc.title || t('Untitled document');
const menuLabel = t('Open the menu of actions for the document: {{title}}', {
title: documentTitle,
});
return (
<DropdownMenu
options={options}
label={menuLabel}
aria-label={t('More options')}
>
<Icon
data-testid={`docs-grid-actions-button-${doc.id}`}
iconName="more_horiz"
$theme="primary"
$variation="600"
$css={css`
cursor: pointer;
&:hover {
opacity: 0.8;
}
`}
/>
</DropdownMenu>
);
};

View File

@@ -1 +1,2 @@
export * from './api';
export * from './components';