(frontend) implement document filtering

- Introduced a new enum for default document filters
to improve code clarity.
- Updated the API call to support filtering documents
based on the creator.
- Enhanced the DocsGrid component to accept a target
filter, allowing dynamic content rendering based on user selection.
- Modified the main layout to include a left panel for improved
navigation and user experience.
- Added a new test suite for document filters, verifying the visibility
and selection states of 'All docs', 'My docs', and 'Shared with me'.
This commit is contained in:
Nathan Panchout
2024-12-04 14:55:01 +01:00
committed by Anthony LC
parent 42d9fa70a2
commit 7696872416
10 changed files with 238 additions and 13 deletions

View File

@@ -14,6 +14,7 @@ and this project adheres to
- 🔧(backend) add option to configure list of essential OIDC claims #525 & #531
- 🔧(helm) add option to disable default tls setting by @dominikkaminski #519
- 💄(frontend) Add left panel #420
- 💄(frontend) add filtering to left panel #475
## Changed
@@ -44,6 +45,9 @@ and this project adheres to
## [1.9.0] - 2024-12-11
## Added

View File

@@ -183,6 +183,80 @@ test.describe('Document grid item options', () => {
});
});
test.describe('Documents filters', () => {
test('it checks the prebuild left panel filters', async ({ page }) => {
// All Docs
await expect(page.getByTestId('docs-grid-loader')).toBeVisible();
const response = await page.waitForResponse(
(response) =>
response.url().includes('documents/?page=1') &&
response.status() === 200,
);
const result = await response.json();
const allCount = result.count as number;
await expect(page.getByTestId('docs-grid-loader')).toBeHidden();
const allDocs = page.getByLabel('All docs');
const myDocs = page.getByLabel('My docs');
const sharedWithMe = page.getByLabel('Shared with me');
// Initial state
await expect(allDocs).toBeVisible();
await expect(allDocs).toHaveCSS('background-color', 'rgb(238, 238, 238)');
await expect(allDocs).toHaveAttribute('aria-selected', 'true');
await expect(myDocs).toBeVisible();
await expect(myDocs).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
await expect(myDocs).toHaveAttribute('aria-selected', 'false');
await expect(sharedWithMe).toBeVisible();
await expect(sharedWithMe).toHaveCSS(
'background-color',
'rgba(0, 0, 0, 0)',
);
await expect(sharedWithMe).toHaveAttribute('aria-selected', 'false');
await allDocs.click();
let url = new URL(page.url());
let target = url.searchParams.get('target');
expect(target).toBe('all_docs');
// My docs
await myDocs.click();
url = new URL(page.url());
target = url.searchParams.get('target');
expect(target).toBe('my_docs');
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.status() === 200,
);
const resultMyDocs = await responseMyDocs.json();
const countMyDocs = resultMyDocs.count as number;
await expect(page.getByTestId('docs-grid-loader')).toBeHidden();
expect(countMyDocs).toBeLessThanOrEqual(allCount);
// Shared with me
await sharedWithMe.click();
url = new URL(page.url());
target = url.searchParams.get('target');
expect(target).toBe('shared_with_me');
await expect(page.getByTestId('docs-grid-loader')).toBeVisible();
const responseSharedWithMe = await page.waitForResponse(
(response) =>
response.url().includes('documents/?page=1&is_creator_me=false') &&
response.status() === 200,
);
const resultSharedWithMe = await responseSharedWithMe.json();
const countSharedWithMe = resultSharedWithMe.count as number;
await expect(page.getByTestId('docs-grid-loader')).toBeHidden();
expect(countSharedWithMe).toBeLessThanOrEqual(allCount);
expect(countSharedWithMe + countMyDocs).toEqual(allCount);
});
});
test.describe('Documents Grid', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');

View File

@@ -356,6 +356,10 @@ input:-webkit-autofill:focus {
gap: var(--c--theme--spacings--2xs);
}
.c__button--nano.c__button--icon-only {
width: auto;
}
.c__button--medium {
padding: 0.9rem var(--c--theme--spacings--s);
}

View File

@@ -28,16 +28,24 @@ export type DocsOrdering = (typeof docsOrdering)[number];
export type DocsParams = {
page: number;
ordering?: DocsOrdering;
is_creator_me?: boolean;
};
export type DocsResponse = APIList<Doc>;
export const getDocs = async (params: DocsParams): Promise<DocsResponse> => {
const searchParams = new URLSearchParams();
if (params.page) {
searchParams.set('page', params.page.toString());
}
export const getDocs = async ({
ordering,
page,
}: DocsParams): Promise<DocsResponse> => {
const orderingQuery = ordering ? `&ordering=${ordering}` : '';
const response = await fetchAPI(`documents/?page=${page}${orderingQuery}`);
if (params.ordering) {
searchParams.set('ordering', params.ordering);
}
if (params.is_creator_me !== undefined) {
searchParams.set('is_creator_me', params.is_creator_me.toString());
}
const response = await fetchAPI(`documents/?${searchParams.toString()}`);
if (!response.ok) {
throw new APIError('Failed to get the docs', await errorCauses(response));

View File

@@ -59,3 +59,9 @@ export interface Doc {
versions_retrieve: boolean;
};
}
export enum DocDefaultFilter {
ALL_DOCS = 'all_docs',
MY_DOCS = 'my_docs',
SHARED_WITH_ME = 'shared_with_me',
}

View File

@@ -5,12 +5,17 @@ import { InView } from 'react-intersection-observer';
import { Box, Card, Text } from '@/components';
import { useResponsiveStore } from '@/stores';
import { useInfiniteDocs } from '../../doc-management';
import { DocDefaultFilter, useInfiniteDocs } from '../../doc-management';
import { DocsGridItem } from './DocsGridItem';
import { DocsGridLoader } from './DocsGridLoader';
export const DocsGrid = () => {
type DocsGridProps = {
target?: DocDefaultFilter;
};
export const DocsGrid = ({
target = DocDefaultFilter.ALL_DOCS,
}: DocsGridProps) => {
const { t } = useTranslation();
const { isDesktop } = useResponsiveStore();
@@ -24,6 +29,10 @@ export const DocsGrid = () => {
hasNextPage,
} = useInfiniteDocs({
page: 1,
...(target &&
target !== DocDefaultFilter.ALL_DOCS && {
is_creator_me: target === DocDefaultFilter.MY_DOCS,
}),
});
const loading = isFetching || isLoading;

View File

@@ -0,0 +1,93 @@
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, BoxButton, Icon, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { DocDefaultFilter } from '@/features/docs';
import { useLeftPanelStore } from '@/features/left-panel';
export const LeftPanelTargetFilters = () => {
const { t } = useTranslation();
const pathname = usePathname();
const { togglePanel } = useLeftPanelStore();
const { colorsTokens, spacingsTokens } = useCunninghamTheme();
const spacing = spacingsTokens();
const colors = colorsTokens();
const searchParams = useSearchParams();
const target =
(searchParams.get('target') as DocDefaultFilter) ??
DocDefaultFilter.ALL_DOCS;
const router = useRouter();
const defaultQueries = useMemo(() => {
return [
{
icon: 'apps',
label: t('All docs'),
targetQuery: DocDefaultFilter.ALL_DOCS,
},
{
icon: 'lock',
label: t('My docs'),
targetQuery: DocDefaultFilter.MY_DOCS,
},
{
icon: 'group',
label: t('Shared with me'),
targetQuery: DocDefaultFilter.SHARED_WITH_ME,
},
];
}, [t]);
const onSelectQuery = (query: DocDefaultFilter) => {
const params = new URLSearchParams(searchParams);
params.set('target', query);
router.replace(`${pathname}?${params.toString()}`);
togglePanel();
};
return (
<Box
$justify="center"
$padding={{ horizontal: 'xs' }}
$gap={spacing['2xs']}
>
{defaultQueries.map((query) => {
const isActive = target === query.targetQuery;
return (
<BoxButton
aria-label={query.label}
key={query.label}
onClick={() => onSelectQuery(query.targetQuery)}
$direction="row"
aria-selected={isActive}
$align="center"
$justify="flex-start"
$gap={spacing['xs']}
$radius={spacing['2xs']}
$padding={{ all: 'xs' }}
$css={css`
cursor: pointer;
background-color: ${isActive
? colors['greyscale-100']
: undefined};
font-weight: ${isActive ? 700 : undefined};
&:hover {
background-color: ${colors['greyscale-100']};
font-weight: 700;
}
`}
>
<Icon $variation="1000" iconName={query.icon} />
<Text $variation="1000">{query.label}</Text>
</BoxButton>
);
})}
</Box>
);
};

View File

@@ -1,4 +1,3 @@
import { PropsWithChildren } from 'react';
import { createGlobalStyle, css } from 'styled-components';
import { Box, SeparatedSection } from '@/components';
@@ -10,6 +9,7 @@ import { useResponsiveStore } from '@/stores';
import { useLeftPanelStore } from '../stores';
import { LeftPanelContent } from './LeftPanelContent';
import { LeftPanelHeader } from './LeftPanelHeader';
const MobileLeftPanelStyle = createGlobalStyle`
@@ -18,7 +18,7 @@ const MobileLeftPanelStyle = createGlobalStyle`
}
`;
export const LeftPanel = ({ children }: PropsWithChildren) => {
export const LeftPanel = () => {
const { isDesktop } = useResponsiveStore();
const { isPanelOpen } = useLeftPanelStore();
const theme = useCunninghamTheme();
@@ -37,7 +37,8 @@ export const LeftPanel = ({ children }: PropsWithChildren) => {
border-right: 1px solid ${colors['greyscale-200']};
`}
>
<LeftPanelHeader>{children}</LeftPanelHeader>
<LeftPanelHeader />
<LeftPanelContent />
</Box>
)}
@@ -65,7 +66,8 @@ export const LeftPanel = ({ children }: PropsWithChildren) => {
gap: ${spacings['base']};
`}
>
<LeftPanelHeader>{children}</LeftPanelHeader>
<LeftPanelHeader />
<LeftPanelContent />
<SeparatedSection showSeparator={false}>
<Box $justify="center" $align="center" $gap={spacings['sm']}>
<ButtonLogin />

View File

@@ -0,0 +1,20 @@
import { useRouter } from 'next/router';
import { Box, SeparatedSection } from '@/components';
import { LeftPanelTargetFilters } from './LefPanelTargetFilters';
export const LeftPanelContent = () => {
const router = useRouter();
const isHome = router.pathname === '/';
return (
<Box $width="100%">
{isHome && (
<SeparatedSection>
<LeftPanelTargetFilters />
</SeparatedSection>
)}
</Box>
);
};

View File

@@ -1,14 +1,19 @@
import { useSearchParams } from 'next/navigation';
import type { ReactElement } from 'react';
import { Box } from '@/components';
import { DocDefaultFilter } from '@/features/docs';
import { DocsGrid } from '@/features/docs/docs-grid/components/DocsGrid';
import { MainLayout } from '@/layouts';
import { NextPageWithLayout } from '@/types/next';
const Page: NextPageWithLayout = () => {
const searchParams = useSearchParams();
const target = searchParams.get('target');
return (
<Box $width="100%" $align="center">
<DocsGrid />
<DocsGrid target={target as DocDefaultFilter} />
</Box>
);
};