✨(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:
committed by
Anthony LC
parent
42d9fa70a2
commit
7696872416
@@ -14,6 +14,7 @@ and this project adheres to
|
|||||||
- 🔧(backend) add option to configure list of essential OIDC claims #525 & #531
|
- 🔧(backend) add option to configure list of essential OIDC claims #525 & #531
|
||||||
- 🔧(helm) add option to disable default tls setting by @dominikkaminski #519
|
- 🔧(helm) add option to disable default tls setting by @dominikkaminski #519
|
||||||
- 💄(frontend) Add left panel #420
|
- 💄(frontend) Add left panel #420
|
||||||
|
- 💄(frontend) add filtering to left panel #475
|
||||||
|
|
||||||
## Changed
|
## Changed
|
||||||
|
|
||||||
@@ -44,6 +45,9 @@ and this project adheres to
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## [1.9.0] - 2024-12-11
|
## [1.9.0] - 2024-12-11
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
|
|||||||
@@ -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.describe('Documents Grid', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
|||||||
@@ -356,6 +356,10 @@ input:-webkit-autofill:focus {
|
|||||||
gap: var(--c--theme--spacings--2xs);
|
gap: var(--c--theme--spacings--2xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.c__button--nano.c__button--icon-only {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.c__button--medium {
|
.c__button--medium {
|
||||||
padding: 0.9rem var(--c--theme--spacings--s);
|
padding: 0.9rem var(--c--theme--spacings--s);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,16 +28,24 @@ export type DocsOrdering = (typeof docsOrdering)[number];
|
|||||||
export type DocsParams = {
|
export type DocsParams = {
|
||||||
page: number;
|
page: number;
|
||||||
ordering?: DocsOrdering;
|
ordering?: DocsOrdering;
|
||||||
|
is_creator_me?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DocsResponse = APIList<Doc>;
|
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 ({
|
if (params.ordering) {
|
||||||
ordering,
|
searchParams.set('ordering', params.ordering);
|
||||||
page,
|
}
|
||||||
}: DocsParams): Promise<DocsResponse> => {
|
if (params.is_creator_me !== undefined) {
|
||||||
const orderingQuery = ordering ? `&ordering=${ordering}` : '';
|
searchParams.set('is_creator_me', params.is_creator_me.toString());
|
||||||
const response = await fetchAPI(`documents/?page=${page}${orderingQuery}`);
|
}
|
||||||
|
|
||||||
|
const response = await fetchAPI(`documents/?${searchParams.toString()}`);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new APIError('Failed to get the docs', await errorCauses(response));
|
throw new APIError('Failed to get the docs', await errorCauses(response));
|
||||||
|
|||||||
@@ -59,3 +59,9 @@ export interface Doc {
|
|||||||
versions_retrieve: boolean;
|
versions_retrieve: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum DocDefaultFilter {
|
||||||
|
ALL_DOCS = 'all_docs',
|
||||||
|
MY_DOCS = 'my_docs',
|
||||||
|
SHARED_WITH_ME = 'shared_with_me',
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,12 +5,17 @@ import { InView } from 'react-intersection-observer';
|
|||||||
import { Box, Card, Text } from '@/components';
|
import { Box, Card, Text } from '@/components';
|
||||||
import { useResponsiveStore } from '@/stores';
|
import { useResponsiveStore } from '@/stores';
|
||||||
|
|
||||||
import { useInfiniteDocs } from '../../doc-management';
|
import { DocDefaultFilter, useInfiniteDocs } from '../../doc-management';
|
||||||
|
|
||||||
import { DocsGridItem } from './DocsGridItem';
|
import { DocsGridItem } from './DocsGridItem';
|
||||||
import { DocsGridLoader } from './DocsGridLoader';
|
import { DocsGridLoader } from './DocsGridLoader';
|
||||||
|
|
||||||
export const DocsGrid = () => {
|
type DocsGridProps = {
|
||||||
|
target?: DocDefaultFilter;
|
||||||
|
};
|
||||||
|
export const DocsGrid = ({
|
||||||
|
target = DocDefaultFilter.ALL_DOCS,
|
||||||
|
}: DocsGridProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { isDesktop } = useResponsiveStore();
|
const { isDesktop } = useResponsiveStore();
|
||||||
@@ -24,6 +29,10 @@ export const DocsGrid = () => {
|
|||||||
hasNextPage,
|
hasNextPage,
|
||||||
} = useInfiniteDocs({
|
} = useInfiniteDocs({
|
||||||
page: 1,
|
page: 1,
|
||||||
|
...(target &&
|
||||||
|
target !== DocDefaultFilter.ALL_DOCS && {
|
||||||
|
is_creator_me: target === DocDefaultFilter.MY_DOCS,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
const loading = isFetching || isLoading;
|
const loading = isFetching || isLoading;
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { PropsWithChildren } from 'react';
|
|
||||||
import { createGlobalStyle, css } from 'styled-components';
|
import { createGlobalStyle, css } from 'styled-components';
|
||||||
|
|
||||||
import { Box, SeparatedSection } from '@/components';
|
import { Box, SeparatedSection } from '@/components';
|
||||||
@@ -10,6 +9,7 @@ import { useResponsiveStore } from '@/stores';
|
|||||||
|
|
||||||
import { useLeftPanelStore } from '../stores';
|
import { useLeftPanelStore } from '../stores';
|
||||||
|
|
||||||
|
import { LeftPanelContent } from './LeftPanelContent';
|
||||||
import { LeftPanelHeader } from './LeftPanelHeader';
|
import { LeftPanelHeader } from './LeftPanelHeader';
|
||||||
|
|
||||||
const MobileLeftPanelStyle = createGlobalStyle`
|
const MobileLeftPanelStyle = createGlobalStyle`
|
||||||
@@ -18,7 +18,7 @@ const MobileLeftPanelStyle = createGlobalStyle`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const LeftPanel = ({ children }: PropsWithChildren) => {
|
export const LeftPanel = () => {
|
||||||
const { isDesktop } = useResponsiveStore();
|
const { isDesktop } = useResponsiveStore();
|
||||||
const { isPanelOpen } = useLeftPanelStore();
|
const { isPanelOpen } = useLeftPanelStore();
|
||||||
const theme = useCunninghamTheme();
|
const theme = useCunninghamTheme();
|
||||||
@@ -37,7 +37,8 @@ export const LeftPanel = ({ children }: PropsWithChildren) => {
|
|||||||
border-right: 1px solid ${colors['greyscale-200']};
|
border-right: 1px solid ${colors['greyscale-200']};
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<LeftPanelHeader>{children}</LeftPanelHeader>
|
<LeftPanelHeader />
|
||||||
|
<LeftPanelContent />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -65,7 +66,8 @@ export const LeftPanel = ({ children }: PropsWithChildren) => {
|
|||||||
gap: ${spacings['base']};
|
gap: ${spacings['base']};
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<LeftPanelHeader>{children}</LeftPanelHeader>
|
<LeftPanelHeader />
|
||||||
|
<LeftPanelContent />
|
||||||
<SeparatedSection showSeparator={false}>
|
<SeparatedSection showSeparator={false}>
|
||||||
<Box $justify="center" $align="center" $gap={spacings['sm']}>
|
<Box $justify="center" $align="center" $gap={spacings['sm']}>
|
||||||
<ButtonLogin />
|
<ButtonLogin />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,14 +1,19 @@
|
|||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
|
|
||||||
import { Box } from '@/components';
|
import { Box } from '@/components';
|
||||||
|
import { DocDefaultFilter } from '@/features/docs';
|
||||||
import { DocsGrid } from '@/features/docs/docs-grid/components/DocsGrid';
|
import { DocsGrid } from '@/features/docs/docs-grid/components/DocsGrid';
|
||||||
import { MainLayout } from '@/layouts';
|
import { MainLayout } from '@/layouts';
|
||||||
import { NextPageWithLayout } from '@/types/next';
|
import { NextPageWithLayout } from '@/types/next';
|
||||||
|
|
||||||
const Page: NextPageWithLayout = () => {
|
const Page: NextPageWithLayout = () => {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const target = searchParams.get('target');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box $width="100%" $align="center">
|
<Box $width="100%" $align="center">
|
||||||
<DocsGrid />
|
<DocsGrid target={target as DocDefaultFilter} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user