(frontend) implement document favorites feature

- Added functionality to mark documents as favorites, including new
hooks `useMakeFavoriteDoc` and `useRemoveFavoriteDoc` for managing
favorite status.
- Enhanced the document management API to support favorite filtering
with the `is_favorite` parameter.
- Created a new e2e test for the favorite workflow to ensure proper
functionality.
- Updated the UI components to reflect favorite status, including
changes in `DocsGridActions`, `DocsGridItem`, and the new
`LeftPanelFavorites` component for displaying pinned documents.
- Adjusted SVG assets for better visual representation of pinned
documents.
This commit is contained in:
Nathan Panchout
2024-12-17 22:11:40 +01:00
committed by Anthony LC
parent 4f4c8905ff
commit 63885117e1
15 changed files with 315 additions and 21 deletions

View File

@@ -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

View File

@@ -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();
});
});

View File

@@ -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,
);

View File

@@ -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';

View File

@@ -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<Doc, 'id'>;
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<void, APIError, CreateFavoriteDocParams>({
mutationFn: createFavoriteDoc,
onSuccess: () => {
listInvalideQueries?.forEach((queryKey) => {
void queryClient.invalidateQueries({
queryKey: [queryKey],
});
});
onSuccess?.();
},
});
}

View File

@@ -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<Doc, 'id'>;
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<void, APIError, DeleteFavoriteDocParams>({
mutationFn: deleteFavoriteDoc,
onSuccess: () => {
listInvalideQueries?.forEach((queryKey) => {
void queryClient.invalidateQueries({
queryKey: [queryKey],
});
});
onSuccess?.();
},
});
}

View File

@@ -30,6 +30,7 @@ export type DocsParams = {
ordering?: DocsOrdering;
is_creator_me?: boolean;
title?: string;
is_favorite?: boolean;
};
export type DocsResponse = APIList<Doc>;
@@ -49,6 +50,9 @@ export const getDocs = async (params: DocsParams): Promise<DocsResponse> => {
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()}`);

View File

@@ -1,4 +1,4 @@
<svg width="32" height="36" viewBox="0 0 32 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg width="28" height="34" viewBox="0 0 28 34" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.01394" y="1.23611" width="25.9722" height="33.5278" rx="3.54167" fill="white"/>
<rect x="2.01394" y="1.23611" width="25.9722" height="33.5278" rx="3.54167" stroke="#DCDCFC" stroke-width="0.472222"/>
<path d="M6.5 8.55556H15" stroke="#6A6AF4" stroke-width="1.88889" stroke-linecap="round"/>

Before

Width:  |  Height:  |  Size: 853 B

After

Width:  |  Height:  |  Size: 853 B

View File

@@ -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',

View File

@@ -54,7 +54,7 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
$flex={6}
$padding={{ right: 'base' }}
>
<SimpleDocItem doc={doc} />
<SimpleDocItem isPinned={doc.is_favorite} doc={doc} />
</Box>
{isDesktop && (
<Box $flex={1.3}>

View File

@@ -48,7 +48,7 @@ export const Header = () => {
{!isDesktop && (
<Button
size="medium"
onClick={togglePanel}
onClick={() => togglePanel()}
aria-label={t('Open the header menu')}
color="primary-text"
icon={<Icon iconName={isPanelOpen ? 'close' : 'menu'} />}

View File

@@ -1,3 +1,5 @@
import { usePathname } from 'next/navigation';
import { useCallback, useEffect } from 'react';
import { createGlobalStyle, css } from 'styled-components';
import { Box, SeparatedSection } from '@/components';
@@ -20,11 +22,22 @@ const MobileLeftPanelStyle = createGlobalStyle`
export const LeftPanel = () => {
const { isDesktop } = useResponsiveStore();
const { isPanelOpen } = useLeftPanelStore();
const theme = useCunninghamTheme();
const { togglePanel, isPanelOpen } = useLeftPanelStore();
const pathname = usePathname();
const colors = theme.colorsTokens();
const spacings = theme.spacingsTokens();
const toggle = useCallback(() => {
togglePanel(false);
}, [togglePanel]);
useEffect(() => {
toggle();
}, [pathname, toggle]);
return (
<>
{isDesktop && (
@@ -34,10 +47,17 @@ export const LeftPanel = () => {
height: calc(100vh - ${HEADER_HEIGHT}px);
width: 300px;
min-width: 300px;
overflow: hidden;
border-right: 1px solid ${colors['greyscale-200']};
`}
>
<LeftPanelHeader />
<Box
$css={css`
flex: 0 0 auto;
`}
>
<LeftPanelHeader />
</Box>
<LeftPanelContent />
</Box>
)}

View File

@@ -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 (
<Box $width="100%">
<>
{isHome && (
<SeparatedSection>
<LeftPanelTargetFilters />
</SeparatedSection>
<>
<Box
$width="100%"
$css={css`
flex: 0 0 auto;
`}
>
<SeparatedSection>
<LeftPanelTargetFilters />
</SeparatedSection>
</Box>
<Box $flex={1} $css="overflow-y: auto; overflow-x: hidden;">
<SeparatedSection showSeparator={false}>
<LeftPanelFavorites />
</SeparatedSection>
</Box>
</>
)}
</Box>
</>
);
};

View File

@@ -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 (
<Box
$justify="center"
$padding={{ horizontal: 'xs' }}
$gap={spacing['2xs']}
$height="100%"
data-testid="left-panel-favorites"
>
<Text $size="sm" $variation="700" $weight="700">
{t('Pinned documents')}
</Text>
<InfiniteScroll
hasMore={docs.hasNextPage}
isLoading={docs.isFetchingNextPage}
next={() => void docs.fetchNextPage()}
>
{invitations.map((doc) => (
<Box
$css={css`
padding: ${spacing['2xs']};
border-radius: 4px;
&:hover {
cursor: pointer;
background-color: var(--c--theme--colors--greyscale-100);
}
`}
key={doc.id}
>
<StyledLink href={`/docs/${doc.id}`}>
<SimpleDocItem showAccesses doc={doc} />
</StyledLink>
</Box>
))}
</InfiniteScroll>
</Box>
);
};

View File

@@ -2,10 +2,17 @@ import { create } from 'zustand';
interface LeftPanelState {
isPanelOpen: boolean;
togglePanel: () => void;
togglePanel: (value?: boolean) => void;
}
export const useLeftPanelStore = create<LeftPanelState>((set) => ({
export const useLeftPanelStore = create<LeftPanelState>((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 });
},
}));