(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); const row1 = await getGridRow(page, title1);
await clickInGridMenu(page, row1, 'Delete'); await clickInGridMenu(page, row1, 'Delete');
await page.getByRole('button', { name: 'Delete document' }).click(); await page.getByRole('button', { name: 'Delete document' }).click();
await expect(row1.getByText(title1)).toBeHidden();
const row2 = await getGridRow(page, title2); const row2 = await getGridRow(page, title2);
await clickInGridMenu(page, row2, 'Delete'); await clickInGridMenu(page, row2, 'Delete');
await page.getByRole('button', { name: 'Delete document' }).click(); await page.getByRole('button', { name: 'Delete document' }).click();
await expect(row2.getByText(title2)).toBeHidden();
await page.getByRole('link', { name: 'Trashbin' }).click(); await page.getByRole('link', { name: 'Trashbin' }).click();
@@ -51,5 +53,25 @@ test.describe('Doc Trashbin', () => {
name: 'Open the sharing settings for the document', name: 'Open the sharing settings for the document',
}), }),
).toBeDisabled(); ).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, canClose = false,
...textProps ...textProps
}: TextErrorsProps) => { }: TextErrorsProps) => {
const { t } = useTranslation();
return ( return (
<AlertStyled <AlertStyled
canClose={canClose} canClose={canClose}
@@ -34,31 +32,47 @@ export const TextErrors = ({
icon={icon} icon={icon}
className="--docs--text-errors" className="--docs--text-errors"
> >
<Box $direction="column" $gap="0.2rem"> <TextOnlyErrors
{causes && causes={causes}
causes.map((cause, i) => ( defaultMessage={defaultMessage}
<Text {...textProps}
key={`causes-${i}`} />
$theme="danger" </AlertStyled>
$variation="600" );
$textAlign="center" };
{...textProps}
>
{cause}
</Text>
))}
{!causes && ( export const TextOnlyErrors = ({
causes,
defaultMessage,
...textProps
}: TextErrorsProps) => {
const { t } = useTranslation();
return (
<Box $direction="column" $gap="0.2rem">
{causes &&
causes.map((cause, i) => (
<Text <Text
key={`causes-${i}`}
$theme="danger" $theme="danger"
$variation="600" $variation="600"
$textAlign="center" $textAlign="center"
{...textProps} {...textProps}
> >
{defaultMessage || t('Something bad happens, please retry.')} {cause}
</Text> </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 { import {
Fragment, Fragment,
PropsWithChildren, PropsWithChildren,
ReactNode,
useCallback, useCallback,
useEffect, useEffect,
useRef, useRef,
@@ -15,7 +16,7 @@ import { useCunninghamTheme } from '@/cunningham';
import { useDropdownKeyboardNav } from './hook/useDropdownKeyboardNav'; import { useDropdownKeyboardNav } from './hook/useDropdownKeyboardNav';
export type DropdownMenuOption = { export type DropdownMenuOption = {
icon?: string; icon?: ReactNode;
label: string; label: string;
testId?: string; testId?: string;
value?: string; value?: string;
@@ -220,7 +221,7 @@ export const DropdownMenu = ({
$align="center" $align="center"
$gap={spacingsTokens['base']} $gap={spacingsTokens['base']}
> >
{option.icon && ( {option.icon && typeof option.icon === 'string' && (
<Icon <Icon
$size="20px" $size="20px"
$theme="greyscale" $theme="greyscale"
@@ -229,6 +230,9 @@ export const DropdownMenu = ({
aria-hidden="true" aria-hidden="true"
/> />
)} )}
{option.icon &&
typeof option.icon !== 'string' &&
option.icon}
<Text $variation={isDisabled ? '400' : '1000'}> <Text $variation={isDisabled ? '400' : '1000'}>
{option.label} {option.label}
</Text> </Text>

View File

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

View File

@@ -3,7 +3,7 @@ import React from 'react';
import { Box } from '@/components'; import { Box } from '@/components';
const ButtonCloseModal = (props: ButtonProps) => { export const ButtonCloseModal = (props: ButtonProps) => {
return ( return (
<Button <Button
type="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 { useTranslation } from 'react-i18next';
import { css } from 'styled-components'; import { css } from 'styled-components';
import { Box, Text } from '@/components'; import { Box, ButtonCloseModal, Text } from '@/components';
import ButtonCloseModal from '@/components/modal/ButtonCloseModal';
import { useEditorStore } from '@/docs/doc-editor'; import { useEditorStore } from '@/docs/doc-editor';
import { Doc, useTrans } from '@/docs/doc-management'; import { Doc, useTrans } from '@/docs/doc-management';

View File

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

View File

@@ -3,6 +3,7 @@ import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import { import {
APIError, APIError,
APIList, APIList,
InfiniteQueryConfig,
errorCauses, errorCauses,
fetchAPI, fetchAPI,
useAPIInfiniteQuery, useAPIInfiniteQuery,
@@ -54,10 +55,10 @@ export const getDocs = async (params: DocsParams): Promise<DocsResponse> => {
export const KEY_LIST_DOC = 'docs'; export const KEY_LIST_DOC = 'docs';
export function useDocs( type UseDocsOptions = UseQueryOptions<DocsResponse, APIError, DocsResponse>;
params: DocsParams, type UseInfiniteDocsOptions = InfiniteQueryConfig<DocsResponse>;
queryConfig?: UseQueryOptions<DocsResponse, APIError, DocsResponse>,
) { export function useDocs(params: DocsParams, queryConfig?: UseDocsOptions) {
return useQuery<DocsResponse, APIError, DocsResponse>({ return useQuery<DocsResponse, APIError, DocsResponse>({
queryKey: [KEY_LIST_DOC, params], queryKey: [KEY_LIST_DOC, params],
queryFn: () => getDocs(params), queryFn: () => getDocs(params),
@@ -65,6 +66,9 @@ export function useDocs(
}); });
} }
export const useInfiniteDocs = (params: DocsParams) => { export const useInfiniteDocs = (
return useAPIInfiniteQuery(KEY_LIST_DOC, getDocs, params); 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 { APIError, errorCauses, fetchAPI } from '@/api';
import { KEY_LIST_DOC } from './useDocs';
interface RemoveDocProps { interface RemoveDocProps {
docId: string; docId: string;
} }
@@ -24,14 +22,22 @@ export const removeDoc = async ({ docId }: RemoveDocProps): Promise<void> => {
type UseRemoveDocOptions = UseMutationOptions<void, APIError, RemoveDocProps>; type UseRemoveDocOptions = UseMutationOptions<void, APIError, RemoveDocProps>;
export const useRemoveDoc = (options?: UseRemoveDocOptions) => { export const useRemoveDoc = ({
listInvalidQueries,
options,
}: {
listInvalidQueries?: string[];
options?: UseRemoveDocOptions;
}) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<void, APIError, RemoveDocProps>({ return useMutation<void, APIError, RemoveDocProps>({
mutationFn: removeDoc, mutationFn: removeDoc,
...options, ...options,
onSuccess: (data, variables, context) => { onSuccess: (data, variables, context) => {
void queryClient.invalidateQueries({ listInvalidQueries?.forEach((queryKey) => {
queryKey: [KEY_LIST_DOC], void queryClient.invalidateQueries({
queryKey: [queryKey],
});
}); });
if (options?.onSuccess) { if (options?.onSuccess) {
void options.onSuccess(data, variables, context); 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 { useRouter } from 'next/router';
import { Trans, useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
import { Box, Text, TextErrors } from '@/components'; import { Box, ButtonCloseModal, Text, TextErrors } from '@/components';
import ButtonCloseModal from '@/components/modal/ButtonCloseModal'; 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 { useRemoveDoc } from '../api/useRemoveDoc';
import { useDocUtils } from '../hooks';
import { Doc } from '../types'; import { Doc } from '../types';
interface ModalRemoveDocProps { interface ModalRemoveDocProps {
@@ -28,25 +31,31 @@ export const ModalRemoveDoc = ({
}: ModalRemoveDocProps) => { }: ModalRemoveDocProps) => {
const { toast } = useToastProvider(); const { toast } = useToastProvider();
const { t } = useTranslation(); const { t } = useTranslation();
const { data: config } = useConfig();
const trashBinCutoffDays = config?.TRASHBIN_CUTOFF_DAYS || 30;
const { push } = useRouter(); const { push } = useRouter();
const { hasChildren } = useDocUtils(doc);
const pathname = usePathname(); const pathname = usePathname();
const { const {
mutate: removeDoc, mutate: removeDoc,
isError, isError,
error, error,
} = useRemoveDoc({ } = useRemoveDoc({
onSuccess: () => { listInvalidQueries: [KEY_LIST_DOC, KEY_LIST_DOC_TRASHBIN],
if (onSuccess) { options: {
onSuccess(doc); onSuccess: () => {
} else if (pathname === '/') { if (onSuccess) {
onClose(); onSuccess(doc);
} else { } else if (pathname === '/') {
void push('/'); onClose();
} } else {
void push('/');
}
toast(t('The document has been deleted.'), VariantType.SUCCESS, { toast(t('The document has been deleted.'), VariantType.SUCCESS, {
duration: 4000, duration: 4000,
}); });
},
}, },
}); });
@@ -109,10 +118,18 @@ export const ModalRemoveDoc = ({
<Box className="--docs--modal-remove-doc"> <Box className="--docs--modal-remove-doc">
{!isError && ( {!isError && (
<Text $size="sm" $variation="600" $display="inline-block" as="p"> <Text $size="sm" $variation="600" $display="inline-block" as="p">
<Trans t={t}> {hasChildren ? (
This document and <strong>any sub-documents</strong> will be <Trans t={t}>
permanently deleted. This action is irreversible. This document and <strong>any sub-documents</strong> will be
</Trans> 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> </Text>
)} )}

View File

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

View File

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

View File

@@ -3,8 +3,7 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { createGlobalStyle, css } from 'styled-components'; import { createGlobalStyle, css } from 'styled-components';
import { Box, Text } from '@/components'; import { Box, ButtonCloseModal, Text } from '@/components';
import ButtonCloseModal from '@/components/modal/ButtonCloseModal';
import { DocEditor } from '@/docs/doc-editor'; import { DocEditor } from '@/docs/doc-editor';
import { Doc } from '@/docs/doc-management'; 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'; export * from './components';