💄(frontend) update DocsGrid component

Implement the new version of  the DocsGrid  component
This commit is contained in:
Nathan Panchout
2024-12-03 09:35:49 +01:00
parent 25e76a76b7
commit c782b05594
15 changed files with 306 additions and 209 deletions
+1
View File
@@ -37,6 +37,7 @@ and this project adheres to
- 💄(frontend) Add left panel #420
- 💄(frontend) updating the header and leftpanel for responsive #421
- ✨(backend) config endpoint #425
- 💄(frontend) update DocsGrid component #431
- ✨(backend) whitelist pod's IP address #443
- ✨(backend) config endpoint #425
- ✨(frontend) config endpoint #424
@@ -17,8 +17,7 @@ export const Card = ({
$background="white"
$radius="4px"
$css={css`
box-shadow: 2px 2px 5px ${colorsTokens()['greyscale-300']};
border: 1px solid ${colorsTokens()['card-border']};
border: 1px solid ${colorsTokens()['greyscale-200']};
${$css}
`}
{...props}
@@ -1,12 +1,15 @@
import { Text, TextType } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
type IconProps = {
type IconProps = TextType & {
iconName: string;
className?: string;
};
export const Icon = ({ iconName, className }: IconProps) => {
return <span className={`material-icons ${className}`}>{iconName}</span>;
export const Icon = ({ iconName, ...textProps }: IconProps) => {
return (
<Text $isMaterialIcon {...textProps}>
{iconName}
</Text>
);
};
interface IconBGProps extends TextType {
@@ -33,6 +33,7 @@ export interface TextProps extends BoxProps {
| 'greyscale';
$variation?:
| 'text'
| '000'
| '100'
| '200'
| '300'
@@ -41,7 +42,8 @@ export interface TextProps extends BoxProps {
| '600'
| '700'
| '800'
| '900';
| '900'
| '1000';
}
export type TextType = ComponentPropsWithRef<typeof Text>;
@@ -5,6 +5,7 @@ import { tokens } from './cunningham-tokens';
type Tokens = typeof tokens.themes.default & Partial<typeof tokens.themes.dsfr>;
type ColorsTokens = Tokens['theme']['colors'];
type FontSizesTokens = Tokens['theme']['font']['sizes'];
type SpacingsTokens = Tokens['theme']['spacings'];
type ComponentTokens = Tokens['components'];
export type Theme = keyof typeof tokens.themes;
@@ -14,6 +15,7 @@ interface AuthStore {
setTheme: (theme: Theme) => void;
themeTokens: () => Partial<Tokens['theme']>;
colorsTokens: () => Partial<ColorsTokens>;
fontSizesTokens: () => Partial<FontSizesTokens>;
spacingsTokens: () => Partial<SpacingsTokens>;
componentTokens: () => ComponentTokens;
}
@@ -31,6 +33,7 @@ export const useCunninghamTheme = create<AuthStore>((set, get) => {
colorsTokens: () => currentTheme().theme.colors,
componentTokens: () => currentTheme().components,
spacingsTokens: () => currentTheme().theme.spacings,
fontSizesTokens: () => currentTheme().theme.font.sizes,
setTheme: (theme: Theme) => {
set({ theme });
},
@@ -1,6 +1,12 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
import {
APIError,
APIList,
errorCauses,
fetchAPI,
useAPIInfiniteQuery,
} from '@/api';
import { Doc } from '../types';
@@ -52,3 +58,7 @@ export function useDocs(
...queryConfig,
});
}
export const useInfiniteDocs = (params: DocsParams) => {
return useAPIInfiniteQuery(KEY_LIST_DOC, getDocs, params);
};
@@ -30,7 +30,7 @@ export const useRemoveDoc = (options?: UseRemoveDocOptions) => {
mutationFn: removeDoc,
...options,
onSuccess: (data, variables, context) => {
void queryClient.resetQueries({
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_DOC],
});
if (options?.onSuccess) {
@@ -0,0 +1,9 @@
<svg width="32" height="36" viewBox="0 0 32 36" 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"/>
<path d="M6.5 11.3889H23.5M6.5 14.2222H23.5M6.5 17.0556H23.5M6.5 19.8889H23.5M6.5 22.7222H20.6667" stroke="#CACAFB" stroke-width="1.88889" stroke-linecap="round"/>
<rect x="7" y="10" width="16" height="16" rx="8" fill="#6A6AF4"/>
<rect x="7" y="10" width="16" height="16" rx="8" stroke="white" stroke-width="1.5"/>
<path d="M16.8 18L18 19.2V20.1H15.45V22.95L15 23.4L14.55 22.95V20.1H12V19.2L13.2 18V14.7H12.6V13.8H17.4V14.7H16.8V18Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 853 B

@@ -0,0 +1,6 @@
<svg width="28" height="34" viewBox="0 0 28 34" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1.01394" y="0.236111" width="25.9722" height="33.5278" rx="3.54167" fill="white"/>
<rect x="1.01394" y="0.236111" width="25.9722" height="33.5278" rx="3.54167" stroke="#DCDCFC" stroke-width="0.472222"/>
<path d="M5.5 7.55554H14" stroke="#6A6AF4" stroke-width="1.88889" stroke-linecap="round"/>
<path d="M5.5 10.3889H22.5M5.5 13.2222H22.5M5.5 16.0556H22.5M5.5 18.8889H22.5M5.5 21.7222H22.5M5.5 24.5556H22.5M5.5 27.3889H22.5M5.5 30.2222H22.5M5.5 33.0556H22.5" stroke="#CACAFB" stroke-width="1.88889" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 635 B

@@ -1,220 +1,92 @@
import {
Column,
DataGrid,
SortModel,
usePagination,
} from '@openfun/cunningham-react';
import React, { useEffect, useState } from 'react';
import { Button, Loader } from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { createGlobalStyle } from 'styled-components';
import { InView } from 'react-intersection-observer';
import { Card, StyledLink, Text, TextErrors } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import {
Doc,
DocsOrdering,
LinkReach,
currentDocRole,
isDocsOrdering,
useDocs,
useTrans,
} from '@/features/docs/doc-management';
import { useDate } from '@/hook/';
import { Box, Card, Text } from '@/components';
import { useResponsiveStore } from '@/stores';
import { PAGE_SIZE } from '../conf';
import { useInfiniteDocs } from '../../doc-management';
import { DocsGridActions } from './DocsGridActions';
const DocsGridStyle = createGlobalStyle`
& .c__datagrid thead{
position: sticky;
top: 0;
background: #fff;
z-index: 1;
}
& .c__pagination__goto{
display:none;
}
`;
type SortModelItem = {
field: string;
sort: 'asc' | 'desc' | null;
};
function formatSortModel(sortModel: SortModelItem): DocsOrdering | undefined {
const { field, sort } = sortModel;
const orderingField = sort === 'desc' ? `-${field}` : field;
if (isDocsOrdering(orderingField)) {
return orderingField;
}
}
import { DocsGridItem } from './DocsGridItem';
export const DocsGrid = () => {
const { colorsTokens } = useCunninghamTheme();
const { transRole } = useTrans();
const { t } = useTranslation();
const { formatDate } = useDate();
const pagination = usePagination({
pageSize: PAGE_SIZE,
});
const [sortModel, setSortModel] = useState<SortModel>([
{
field: 'updated_at',
sort: 'desc',
},
]);
const { page, pageSize, setPagesCount } = pagination;
const [docs, setDocs] = useState<Doc[]>([]);
const { isMobile } = useResponsiveStore();
const ordering = sortModel.length ? formatSortModel(sortModel[0]) : undefined;
const { isDesktop } = useResponsiveStore();
const { data, isLoading, error } = useDocs({
page,
ordering,
});
const { data, isFetching, isLoading, fetchNextPage, hasNextPage } =
useInfiniteDocs({
page: 1,
});
const loading = isFetching || isLoading;
useEffect(() => {
if (isLoading) {
const loadMore = (inView: boolean) => {
if (!inView || loading) {
return;
}
setDocs(data?.results || []);
}, [data?.results, t, isLoading]);
useEffect(() => {
setPagesCount(data?.count ? Math.ceil(data.count / pageSize) : 0);
}, [data?.count, pageSize, setPagesCount]);
const columns: Column<Doc>[] = [
{
headerName: '',
id: 'visibility',
size: 95,
renderCell: ({ row }) => {
return (
row.link_reach === LinkReach.PUBLIC && (
<StyledLink href={`/docs/${row.id}`}>
<Text
$weight="bold"
$background={colorsTokens()['primary-600']}
$color="white"
$padding="xtiny"
$radius="3px"
>
{t('Public')}
</Text>
</StyledLink>
)
);
},
},
{
headerName: t('Document name'),
field: 'title',
renderCell: ({ row }) => {
return (
<StyledLink href={`/docs/${row.id}`}>
<Text $weight="bold" $theme="primary">
{row.title}
</Text>
</StyledLink>
);
},
},
{
headerName: t('Created at'),
field: 'created_at',
renderCell: ({ row }) => {
return (
<StyledLink href={`/docs/${row.id}`}>
<Text $weight="bold">{formatDate(row.created_at)}</Text>
</StyledLink>
);
},
},
{
headerName: t('Updated at'),
field: 'updated_at',
renderCell: ({ row }) => {
return (
<StyledLink href={`/docs/${row.id}`}>
<Text $weight="bold">{formatDate(row.updated_at)}</Text>
</StyledLink>
);
},
},
{
headerName: t('Your role'),
id: 'your_role',
renderCell: ({ row }) => {
return (
<StyledLink href={`/docs/${row.id}`}>
<Text $weight="bold">
{transRole(currentDocRole(row.abilities))}
</Text>
</StyledLink>
);
},
},
{
headerName: t('Members'),
id: 'users_number',
renderCell: ({ row }) => {
return (
<StyledLink href={`/docs/${row.id}`}>
<Text $weight="bold">{row.nb_accesses}</Text>
</StyledLink>
);
},
},
{
id: 'column-actions',
renderCell: ({ row }) => {
return <DocsGridActions doc={row} />;
},
},
];
// Inverse columns for mobile to have the most important information first
if (isMobile) {
const tmpCol = columns[0];
columns[0] = columns[1];
columns[1] = tmpCol;
}
void fetchNextPage();
};
return (
<Card
$padding={{ bottom: 'small', horizontal: 'big' }}
$margin={{ all: isMobile ? 'small' : 'big', top: 'none' }}
$overflow="auto"
aria-label={t(`Datagrid of the documents page {{page}}`, { page })}
$height="100%"
>
<DocsGridStyle />
<Card data-testid="docs-grid" $padding="md" $width="100%" $maxWidth="960px">
<Text
$weight="bold"
as="h2"
$theme="primary"
$margin={{ bottom: 'small' }}
as="h4"
$size="h4"
$weight="700"
$margin={{ top: '0px', bottom: 'xs' }}
>
{t('Documents')}
{t('All docs')}
</Text>
{error && <TextErrors causes={error.cause} />}
<Box>
<Box $direction="row" $padding="xs" data-testid="docs-grid-header">
<Box $flex={6} $padding="3xs">
<Text $size="xs" $variation="600">
{t('Name')}
</Text>
</Box>
{isDesktop && (
<Box $flex={1} $padding="3xs">
<Text $size="xs" $variation="600">
{t('Updated at')}
</Text>
</Box>
)}
<DataGrid
columns={columns}
rows={docs}
isLoading={isLoading}
pagination={pagination}
onSortModelChange={setSortModel}
sortModel={sortModel}
emptyPlaceholderLabel={t("You don't have any document yet.")}
/>
<Box $flex={1} $align="flex-end" $padding="3xs" />
</Box>
{/* Body */}
{data?.pages.map((currentPage) => {
return currentPage.results.map((doc) => (
<DocsGridItem doc={doc} key={doc.id} />
));
})}
</Box>
{loading && (
<Box
data-testid="docs-grid-loader"
$padding="md"
$align="center"
$justify="center"
$width="100%"
>
<Loader />
</Box>
)}
{hasNextPage && !loading && (
<InView
data-testid="infinite-scroll-trigger"
as="div"
onChange={loadMore}
>
{!isFetching && hasNextPage && (
<Button onClick={() => void fetchNextPage()} color="primary-text">
{t('More docs')}
</Button>
)}
</InView>
)}
</Card>
);
};
@@ -1,5 +1,5 @@
import { Button } from '@openfun/cunningham-react';
import React, { useState } from 'react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Doc, ModalRemoveDoc } from '@/features/docs/doc-management';
@@ -19,7 +19,10 @@ export const DocsGridActions = ({ doc }: DocsGridActionsProps) => {
return (
<>
<Button
onClick={() => {
data-testid={`docs-grid-delete-button-${doc.id}`}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setIsModalRemoveOpen(true);
}}
color="tertiary-text"
@@ -0,0 +1,104 @@
import { Button } from '@openfun/cunningham-react';
import { DateTime } from 'luxon';
import { css } from 'styled-components';
import { Box, Icon, StyledLink, Text } from '@/components';
import { useResponsiveStore } from '@/stores';
import { Doc, LinkReach } from '../../doc-management';
import { DocsGridActions } from './DocsGridActions';
import { SimpleDocItem } from './SimpleDocItem';
type DocsGridItemProps = {
doc: Doc;
};
export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
const { isDesktop } = useResponsiveStore();
const isPublic = doc.link_reach === LinkReach.PUBLIC;
const isAuthenticated = doc.link_reach === LinkReach.AUTHENTICATED;
const isRestricted = doc.link_reach === LinkReach.RESTRICTED;
const sharedCount = doc.accesses.length - 1;
const isShared = sharedCount > 0;
return (
<Box
$direction="row"
$width="100%"
$align="center"
role="row"
$padding={{ vertical: 'xs', horizontal: 'sm' }}
$css={css`
cursor: pointer;
border-radius: 4px;
&:hover {
background-color: var(--c--theme--colors--greyscale-100);
}
`}
>
<StyledLink $css="flex: 7; align-items: center;" href={`/docs/${doc.id}`}>
<Box
data-testid={`docs-grid-name-${doc.id}`}
$flex={6}
$padding={{ right: 'base' }}
>
<SimpleDocItem doc={doc} />
</Box>
{isDesktop && (
<Box $flex={1}>
<Text $variation="500" $size="xs">
{DateTime.fromISO(doc.updated_at).toRelative()}
</Text>
</Box>
)}
</StyledLink>
<Box
$flex={1}
$direction="row"
$align="center"
$justify="flex-end"
$gap="10px"
>
{isDesktop && isPublic && (
<Button
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
size="nano"
icon={<Icon $variation="000" iconName="public" />}
>
{isShared ? sharedCount : undefined}
</Button>
)}
{isDesktop && !isPublic && isRestricted && isShared && (
<Button
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
color="tertiary"
size="nano"
icon={<Icon $variation="800" $theme="primary" iconName="group" />}
>
{sharedCount}
</Button>
)}
{isDesktop && !isPublic && isAuthenticated && (
<Button
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
size="nano"
icon={<Icon $variation="000" iconName="corporate_fare" />}
>
{sharedCount}
</Button>
)}
<DocsGridActions doc={doc} />
</Box>
</Box>
);
};
@@ -0,0 +1,83 @@
import { css } from 'styled-components';
import { Box, Icon, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc, LinkReach } from '@/features/docs';
import PinnedDocumentIcon from '@/features/docs/doc-management/assets/pinned-document.svg';
import SimpleFileIcon from '@/features/docs/doc-management/assets/simple-document.svg';
import { useResponsiveStore } from '@/stores';
const ItemTextCss = css`
overflow: hidden;
text-overflow: ellipsis;
white-space: initial;
display: -webkit-box;
line-clamp: 1;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
`;
type SimpleDocItemProps = {
doc: Doc;
isPinned?: boolean;
subText?: string;
};
export const SimpleDocItem = ({
doc,
isPinned = false,
subText,
}: SimpleDocItemProps) => {
const { spacingsTokens } = useCunninghamTheme();
const { isDesktop } = useResponsiveStore();
const spacings = spacingsTokens();
const isPublic = doc?.link_reach === LinkReach.PUBLIC;
const isShared = !isPublic && doc.accesses.length > 1;
const accessCount = doc.accesses.length - 1;
const isSharedOrPublic = isShared || isPublic;
return (
<Box $direction="row" $gap={spacings.sm}>
<Box
$direction="row"
$align="center"
$css={css`
background-color: transparent;
filter: drop-shadow(0px 2px 2px rgba(0, 0, 0, 0.05));
`}
>
{isPinned ? <PinnedDocumentIcon /> : <SimpleFileIcon />}
</Box>
<Box>
<Text
aria-describedby="doc-title"
aria-label={doc.title}
$size="sm"
$variation="1000"
$weight="500"
$css={ItemTextCss}
>
{doc.title}
</Text>
<Box $direction="row" $align="center" $gap={spacings['3xs']}>
{!isDesktop && (
<>
{isPublic && <Icon iconName="public" $size="16px" />}
{isShared && <Icon iconName="group" $size="16px" />}
{isSharedOrPublic && accessCount > 0 && (
<Text $size="12px">{accessCount}</Text>
)}
{isSharedOrPublic && <Text $size="12px">·</Text>}
</>
)}
<Text $size="xs" $variation="500" $weight="500" $css={ItemTextCss}>
{subText ??
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi vel ante libero. Interdum et malesuada fames ac ante ipsum primis in faucibus. Sed imperdiet neque quam, sed euismod metus mollis ut. '}
</Text>
</Box>
</Box>
</Box>
);
};
@@ -1,4 +1,5 @@
import { Select } from '@openfun/cunningham-react';
import { Settings } from 'luxon';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
@@ -34,6 +35,7 @@ const SelectStyled = styled(Select)<{ $isSmall?: boolean }>`
export const LanguagePicker = () => {
const { t, i18n } = useTranslation();
const { preload: languages } = i18n.options;
Settings.defaultLocale = i18n.language;
const optionsPicker = useMemo(() => {
return (languages || []).map((lang) => ({
@@ -7,7 +7,7 @@ import { NextPageWithLayout } from '@/types/next';
const Page: NextPageWithLayout = () => {
return (
<Box $width="100%">
<Box $width="100%" $align="center">
<DocsGrid />
</Box>
);