✨(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:
committed by
Anthony LC
parent
4f4c8905ff
commit
63885117e1
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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?.();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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?.();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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()}`);
|
||||
|
||||
|
||||
@@ -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 |
@@ -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',
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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'} />}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 });
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user