diff --git a/CHANGELOG.md b/CHANGELOG.md index d46e346f..28e07ec4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to - 💄(frontend) Add left panel #420 - 💄(frontend) add filtering to left panel #475 - ✨(frontend) new share modal ui #489 +- ✨(frontend) add favorite feature #515 ## Changed diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-favorite.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-favorite.spec.ts new file mode 100644 index 00000000..da83c520 --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-favorite.spec.ts @@ -0,0 +1,74 @@ +import { expect, test } from '@playwright/test'; + +import { createDoc, verifyDocName } from './common'; + +type SmallDoc = { + id: string; + title: string; +}; + +test.describe('Document favorite', () => { + test('it check the favorite workflow', async ({ page, browserName }) => { + const id = Math.random().toString(7); + await page.goto('/'); + + // Create document + const createdDoc = await createDoc(page, `Doc ${id}`, browserName, 1); + await verifyDocName(page, createdDoc[0]); + + // Reload page + await page.reload(); + await page.goto('/'); + + // Get all documents + let docs: SmallDoc[] = []; + const response = await page.waitForResponse( + (response) => + response.url().endsWith('documents/?page=1') && + response.status() === 200, + ); + const result = await response.json(); + docs = result.results as SmallDoc[]; + await page.getByRole('heading', { name: 'All docs' }).click(); + await expect(page.getByText(`Doc ${id}`)).toBeVisible(); + const doc = docs.find((doc) => doc.title === createdDoc[0]) as SmallDoc; + + // Check document + expect(doc).not.toBeUndefined(); + expect(doc?.title).toBe(createdDoc[0]); + + // Open document actions + const button = page.getByTestId(`docs-grid-actions-button-${doc.id}`); + await expect(button).toBeVisible(); + await button.click(); + + // Pin document + const pinButton = page.getByTestId(`docs-grid-actions-pin-${docs[0].id}`); + await expect(pinButton).toBeVisible(); + await pinButton.click(); + + // Check response + const responsePin = await page.waitForResponse( + (response) => + response.url().includes(`documents/${doc.id}/favorite/`) && + response.status() === 201, + ); + expect(responsePin.ok()).toBeTruthy(); + + // Check left panel favorites + const leftPanelFavorites = page.getByTestId('left-panel-favorites'); + await expect(leftPanelFavorites).toBeVisible(); + await expect(leftPanelFavorites.getByText(`Doc ${id}`)).toBeVisible(); + + // + await button.click(); + const unpinButton = page.getByTestId( + `docs-grid-actions-unpin-${docs[0].id}`, + ); + await expect(unpinButton).toBeVisible(); + await unpinButton.click(); + + // Check left panel favorites + await expect(leftPanelFavorites.getByText(`Doc ${id}`)).toBeHidden(); + }); +}); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts index c1bce7a0..93595a47 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts @@ -96,7 +96,7 @@ test.describe('Document grid item options', () => { let docs: SmallDoc[] = []; const response = await page.waitForResponse( (response) => - response.url().includes('documents/?page=1') && + response.url().endsWith('documents/?page=1') && response.status() === 200, ); const result = await response.json(); @@ -124,7 +124,7 @@ test.describe('Document grid item options', () => { const refetchResponse = await page.waitForResponse( (response) => - response.url().includes('documents/?page=1') && + response.url().endsWith('documents/?page=1') && response.status() === 200, ); @@ -189,7 +189,7 @@ test.describe('Documents filters', () => { await expect(page.getByTestId('docs-grid-loader')).toBeVisible(); const response = await page.waitForResponse( (response) => - response.url().includes('documents/?page=1') && + response.url().endsWith('documents/?page=1') && response.status() === 200, ); const result = await response.json(); @@ -230,7 +230,7 @@ test.describe('Documents filters', () => { await expect(page.getByTestId('docs-grid-loader')).toBeVisible(); const responseMyDocs = await page.waitForResponse( (response) => - response.url().includes('documents/?page=1&is_creator_me=true') && + response.url().endsWith('documents/?page=1&is_creator_me=true') && response.status() === 200, ); const resultMyDocs = await responseMyDocs.json(); @@ -268,7 +268,7 @@ test.describe('Documents Grid', () => { const response = await page.waitForResponse( (response) => - response.url().includes('documents/?page=1') && + response.url().endsWith('documents/?page=1') && response.status() === 200, ); const result = await response.json(); @@ -294,13 +294,13 @@ test.describe('Documents Grid', () => { let docs: SmallDoc[] = []; const responsePromisePage1 = page.waitForResponse( (response) => - response.url().includes(`/documents/?page=1`) && + response.url().endsWith(`/documents/?page=1`) && response.status() === 200, ); const responsePromisePage2 = page.waitForResponse( (response) => - response.url().includes(`/documents/?page=2`) && + response.url().endsWith(`/documents/?page=2`) && response.status() === 200, ); diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/index.ts b/src/frontend/apps/impress/src/features/docs/doc-management/api/index.ts index 65c28fbd..f6f44e9f 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/index.ts @@ -2,5 +2,6 @@ export * from './useCreateDoc'; export * from './useDoc'; export * from './useDocOptions'; export * from './useDocs'; +export * from './useCreateFavoriteDoc'; export * from './useUpdateDoc'; export * from './useUpdateDocLink'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useCreateFavoriteDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useCreateFavoriteDoc.tsx new file mode 100644 index 00000000..05f82cea --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useCreateFavoriteDoc.tsx @@ -0,0 +1,42 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; +import { Doc } from '@/features/docs'; + +export type CreateFavoriteDocParams = Pick; + +export const createFavoriteDoc = async ({ id }: CreateFavoriteDocParams) => { + const response = await fetchAPI(`documents/${id}/favorite/`, { + method: 'POST', + }); + + if (!response.ok) { + throw new APIError( + 'Failed to make the doc as favorite', + await errorCauses(response), + ); + } +}; + +interface CreateFavoriteDocProps { + onSuccess?: () => void; + listInvalideQueries?: string[]; +} + +export function useCreateFavoriteDoc({ + onSuccess, + listInvalideQueries, +}: CreateFavoriteDocProps) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: createFavoriteDoc, + onSuccess: () => { + listInvalideQueries?.forEach((queryKey) => { + void queryClient.invalidateQueries({ + queryKey: [queryKey], + }); + }); + onSuccess?.(); + }, + }); +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDeleteFavoriteDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDeleteFavoriteDoc.tsx new file mode 100644 index 00000000..63d55e8d --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDeleteFavoriteDoc.tsx @@ -0,0 +1,42 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; +import { Doc } from '@/features/docs'; + +export type DeleteFavoriteDocParams = Pick; + +export const deleteFavoriteDoc = async ({ id }: DeleteFavoriteDocParams) => { + const response = await fetchAPI(`documents/${id}/favorite/`, { + method: 'DELETE', + }); + + if (!response.ok) { + throw new APIError( + 'Failed to remove the doc as favorite', + await errorCauses(response), + ); + } +}; + +interface DeleteFavoriteDocProps { + onSuccess?: () => void; + listInvalideQueries?: string[]; +} + +export function useDeleteFavoriteDoc({ + onSuccess, + listInvalideQueries, +}: DeleteFavoriteDocProps) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: deleteFavoriteDoc, + onSuccess: () => { + listInvalideQueries?.forEach((queryKey) => { + void queryClient.invalidateQueries({ + queryKey: [queryKey], + }); + }); + onSuccess?.(); + }, + }); +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx index 042ee888..c9881ad7 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx @@ -30,6 +30,7 @@ export type DocsParams = { ordering?: DocsOrdering; is_creator_me?: boolean; title?: string; + is_favorite?: boolean; }; export type DocsResponse = APIList; @@ -49,6 +50,9 @@ export const getDocs = async (params: DocsParams): Promise => { 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()); + } const response = await fetchAPI(`documents/?${searchParams.toString()}`); diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/assets/pinned-document.svg b/src/frontend/apps/impress/src/features/docs/doc-management/assets/pinned-document.svg index cccfed37..b67a1324 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/assets/pinned-document.svg +++ b/src/frontend/apps/impress/src/features/docs/doc-management/assets/pinned-document.svg @@ -1,4 +1,4 @@ - + diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridActions.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridActions.tsx index c23db7ad..db8b7db8 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridActions.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridActions.tsx @@ -2,7 +2,14 @@ import { useModal } from '@openfun/cunningham-react'; import { useTranslation } from 'react-i18next'; import { DropdownMenu, DropdownMenuOption, Icon } from '@/components'; -import { Doc, ModalRemoveDoc } from '@/features/docs/doc-management'; +import { + Doc, + KEY_LIST_DOC, + ModalRemoveDoc, + useCreateFavoriteDoc, +} from '@/features/docs/doc-management'; + +import { useDeleteFavoriteDoc } from '../../doc-management/api/useDeleteFavoriteDoc'; interface DocsGridActionsProps { doc: Doc; @@ -11,8 +18,26 @@ interface DocsGridActionsProps { export const DocsGridActions = ({ doc }: DocsGridActionsProps) => { const { t } = useTranslation(); const deleteModal = useModal(); + const removeFavoriteDoc = useDeleteFavoriteDoc({ + listInvalideQueries: [KEY_LIST_DOC], + }); + const makeFavoriteDoc = useCreateFavoriteDoc({ + listInvalideQueries: [KEY_LIST_DOC], + }); const options: DropdownMenuOption[] = [ + { + label: doc.is_favorite ? t('Unpin') : t('Pin'), + icon: 'push_pin', + callback: () => { + if (doc.is_favorite) { + removeFavoriteDoc.mutate({ id: doc.id }); + } else { + makeFavoriteDoc.mutate({ id: doc.id }); + } + }, + testId: `docs-grid-actions-${doc.is_favorite ? 'unpin' : 'pin'}-${doc.id}`, + }, { label: t('Remove'), icon: 'delete', diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItem.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItem.tsx index 10abe492..5c45aa4d 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItem.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItem.tsx @@ -54,7 +54,7 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => { $flex={6} $padding={{ right: 'base' }} > - + {isDesktop && ( diff --git a/src/frontend/apps/impress/src/features/header/components/Header.tsx b/src/frontend/apps/impress/src/features/header/components/Header.tsx index 75f5de11..53ec256a 100644 --- a/src/frontend/apps/impress/src/features/header/components/Header.tsx +++ b/src/frontend/apps/impress/src/features/header/components/Header.tsx @@ -48,7 +48,7 @@ export const Header = () => { {!isDesktop && ( )} diff --git a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelContent.tsx b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelContent.tsx index 8b0ab24b..260f6a77 100644 --- a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelContent.tsx +++ b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelContent.tsx @@ -1,20 +1,36 @@ import { useRouter } from 'next/router'; +import { css } from 'styled-components'; import { Box, SeparatedSection } from '@/components'; import { LeftPanelTargetFilters } from './LefPanelTargetFilters'; +import { LeftPanelFavorites } from './LeftPanelFavorites'; export const LeftPanelContent = () => { const router = useRouter(); const isHome = router.pathname === '/'; return ( - + <> {isHome && ( - - - + <> + + + + + + + + + + + )} - + ); }; diff --git a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelFavorites.tsx b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelFavorites.tsx new file mode 100644 index 00000000..8474ee74 --- /dev/null +++ b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelFavorites.tsx @@ -0,0 +1,62 @@ +import { useTranslation } from 'react-i18next'; +import { css } from 'styled-components'; + +import { Box, InfiniteScroll, StyledLink, Text } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; +import { useInfiniteDocs } from '@/features/docs'; +import { SimpleDocItem } from '@/features/docs/docs-grid/components/SimpleDocItem'; + +export const LeftPanelFavorites = () => { + const { t } = useTranslation(); + + const { spacingsTokens } = useCunninghamTheme(); + const spacing = spacingsTokens(); + + const docs = useInfiniteDocs({ + page: 1, + is_favorite: true, + }); + + const invitations = docs.data?.pages.flatMap((page) => page.results) || []; + + if (invitations.length === 0) { + return null; + } + + return ( + + + {t('Pinned documents')} + + void docs.fetchNextPage()} + > + {invitations.map((doc) => ( + + + + + + ))} + + + ); +}; diff --git a/src/frontend/apps/impress/src/features/left-panel/stores/useLeftPanelStore.tsx b/src/frontend/apps/impress/src/features/left-panel/stores/useLeftPanelStore.tsx index f9fd5783..9c087d25 100644 --- a/src/frontend/apps/impress/src/features/left-panel/stores/useLeftPanelStore.tsx +++ b/src/frontend/apps/impress/src/features/left-panel/stores/useLeftPanelStore.tsx @@ -2,10 +2,17 @@ import { create } from 'zustand'; interface LeftPanelState { isPanelOpen: boolean; - togglePanel: () => void; + togglePanel: (value?: boolean) => void; } -export const useLeftPanelStore = create((set) => ({ +export const useLeftPanelStore = create((set, get) => ({ isPanelOpen: false, - togglePanel: () => set((state) => ({ isPanelOpen: !state.isPanelOpen })), + togglePanel: (value?: boolean) => { + const sanitizedValue = + value !== undefined && typeof value === 'boolean' + ? value + : !get().isPanelOpen; + + set({ isPanelOpen: sanitizedValue }); + }, }));