💄(frontend) add dropdown option for DocGridItem

Implement dropdown menu with functionality to delete a document
from the list
This commit is contained in:
Nathan Panchout
2024-11-25 09:44:30 +01:00
committed by Anthony LC
parent 5a46ab0055
commit 1d85eee78f
10 changed files with 373 additions and 154 deletions

View File

@@ -91,6 +91,98 @@ test.describe('Documents Grid mobile', () => {
});
});
test.describe('Document grid item options', () => {
test('it deletes the document', async ({ page }) => {
let docs: SmallDoc[] = [];
const response = await page.waitForResponse(
(response) =>
response.url().includes('documents/?page=1') &&
response.status() === 200,
);
const result = await response.json();
docs = result.results as SmallDoc[];
const button = page.getByTestId(`docs-grid-actions-button-${docs[0].id}`);
await expect(button).toBeVisible();
await button.click();
const removeButton = page.getByTestId(
`docs-grid-actions-remove-${docs[0].id}`,
);
await expect(removeButton).toBeVisible();
await removeButton.click();
await expect(
page.locator('h2').getByText(`Deleting the document "${docs[0].title}"`),
).toBeVisible();
await page
.getByRole('button', {
name: 'Confirm deletion',
})
.click();
const refetchResponse = await page.waitForResponse(
(response) =>
response.url().includes('documents/?page=1') &&
response.status() === 200,
);
const resultRefetch = await refetchResponse.json();
expect(resultRefetch.count).toBe(result.count - 1);
await expect(page.getByTestId('main-layout-loader')).toBeHidden();
await expect(
page.getByText('The document has been deleted.'),
).toBeVisible();
await expect(button).toBeHidden();
});
test("it checks if the delete option is disabled if we don't have the destroy capability", async ({
page,
}) => {
await page.route('*/**/api/v1.0/documents/?page=1', async (route) => {
await route.fulfill({
json: {
results: [
{
id: 'mocked-document-id',
content: '',
title: 'Mocked document',
accesses: [],
abilities: {
destroy: false, // Means not owner
link_configuration: false,
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
accesses_manage: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,
},
link_reach: 'restricted',
created_at: '2021-09-01T09:00:00Z',
},
],
},
});
});
await page.goto('/');
const button = page.getByTestId(
`docs-grid-actions-button-mocked-document-id`,
);
await expect(button).toBeVisible();
await button.click();
const removeButton = page.getByTestId(
`docs-grid-actions-remove-mocked-document-id`,
);
await expect(removeButton).toBeVisible();
await removeButton.isDisabled();
});
});
test.describe('Documents Grid', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -163,35 +255,4 @@ test.describe('Documents Grid', () => {
}),
);
});
test('it deletes the document', async ({ page }) => {
let docs: SmallDoc[] = [];
const response = await page.waitForResponse(
(response) =>
response.url().includes('documents/?page=1') &&
response.status() === 200,
);
const result = await response.json();
docs = result.results as SmallDoc[];
const button = page.getByTestId(`docs-grid-delete-button-${docs[0].id}`);
await expect(button).toBeVisible();
await button.click();
await expect(
page.locator('h2').getByText(`Deleting the document "${docs[0].title}"`),
).toBeVisible();
await page
.getByRole('button', {
name: 'Confirm deletion',
})
.click();
await expect(
page.getByText('The document has been deleted.'),
).toBeVisible();
await expect(button).toBeHidden();
});
});

View File

@@ -1,9 +1,13 @@
import { ComponentPropsWithRef, forwardRef } from 'react';
import { forwardRef } from 'react';
import { css } from 'styled-components';
import { Box, BoxType } from './Box';
export type BoxButtonType = ComponentPropsWithRef<typeof BoxButton>;
export type BoxButtonType = BoxType & {
disabled?: boolean;
};
/**
/**
* Styleless button that extends the Box component.
@@ -18,7 +22,7 @@ export type BoxButtonType = ComponentPropsWithRef<typeof BoxButton>;
* </BoxButton>
* ```
*/
const BoxButton = forwardRef<HTMLDivElement, BoxType>(
const BoxButton = forwardRef<HTMLDivElement, BoxButtonType>(
({ $css, ...props }, ref) => {
return (
<Box
@@ -28,14 +32,24 @@ const BoxButton = forwardRef<HTMLDivElement, BoxType>(
$margin="none"
$padding="none"
$css={css`
cursor: pointer;
cursor: ${props.disabled ? 'not-allowed' : 'pointer'};
border: none;
outline: none;
transition: all 0.2s ease-in-out;
font-family: inherit;
color: ${props.disabled
? 'var(--c--theme--colors--greyscale-400) !important'
: 'inherit'};
${$css || ''}
`}
{...props}
onClick={(event: React.MouseEvent<HTMLDivElement>) => {
if (props.disabled) {
return;
}
props.onClick?.(event);
}}
/>
);
},

View File

@@ -1,9 +1,4 @@
import React, {
PropsWithChildren,
ReactNode,
useEffect,
useState,
} from 'react';
import { PropsWithChildren, ReactNode, useEffect, useState } from 'react';
import { Button, DialogTrigger, Popover } from 'react-aria-components';
import styled from 'styled-components';
@@ -11,7 +6,7 @@ const StyledPopover = styled(Popover)`
background-color: white;
border-radius: 4px;
box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.1);
padding: 0.5rem;
border: 1px solid #dddddd;
opacity: 0;
transition: opacity 0.2s ease-in-out;
@@ -29,7 +24,7 @@ const StyledButton = styled(Button)`
text-wrap: nowrap;
`;
interface DropButtonProps {
export interface DropButtonProps {
button: ReactNode;
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;

View File

@@ -0,0 +1,112 @@
import { PropsWithChildren, useState } from 'react';
import { css } from 'styled-components';
import { Box, BoxButton, BoxProps, DropButton, Icon } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
export type DropdownMenuOption = {
icon?: string;
label: string;
testId?: string;
callback?: () => void | Promise<unknown>;
danger?: boolean;
disabled?: boolean;
};
export type DropdownMenuProps = {
options: DropdownMenuOption[];
showArrow?: boolean;
arrowCss?: BoxProps['$css'];
};
export const DropdownMenu = ({
options,
children,
showArrow = false,
arrowCss,
}: PropsWithChildren<DropdownMenuProps>) => {
const theme = useCunninghamTheme();
const spacings = theme.spacingsTokens();
const colors = theme.colorsTokens();
const [isOpen, setIsOpen] = useState(false);
const onOpenChange = (isOpen: boolean) => {
setIsOpen(isOpen);
};
return (
<DropButton
isOpen={isOpen}
onOpenChange={onOpenChange}
button={
showArrow ? (
<Box>
<div>{children}</div>
<Icon
$css={
arrowCss ??
css`
color: var(--c--theme--colors--primary-600);
`
}
iconName={isOpen ? 'arrow_drop_up' : 'arrow_drop_down'}
/>
</Box>
) : (
children
)
}
>
<Box>
{options.map((option, index) => {
const isDisabled = option.disabled !== undefined && option.disabled;
return (
<BoxButton
data-testid={option.testId}
$direction="row"
disabled={isDisabled}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onOpenChange?.(false);
void option.callback?.();
}}
key={option.label}
$align="center"
$background={colors['greyscale-000']}
$color={colors['primary-600']}
$padding={{ vertical: 'xs', horizontal: 'base' }}
$width="100%"
$gap={spacings['base']}
$css={css`
border: none;
font-size: var(--c--theme--font--sizes--sm);
color: var(--c--theme--colors--primary-600);
font-weight: 500;
cursor: ${isDisabled ? 'not-allowed' : 'pointer'};
user-select: none;
border-bottom: ${index !== options.length - 1
? `1px solid var(--c--theme--colors--greyscale-200)`
: 'none'};
&:hover {
background-color: var(--c--theme--colors--greyscale-050);
}
`}
>
{option.icon && (
<Icon
$size="20px"
$theme={!isDisabled ? 'primary' : 'greyscale'}
$variation={!isDisabled ? '600' : '400'}
iconName={option.icon}
/>
)}
{option.label}
</BoxButton>
);
})}
</Box>
</DropButton>
);
};

View File

@@ -2,6 +2,7 @@ export * from './Box';
export * from './BoxButton';
export * from './Card';
export * from './DropButton';
export * from './DropdownMenu';
export * from './Icon';
export * from './InfiniteScroll';
export * from './Link';

View File

@@ -6,14 +6,13 @@ import {
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { t } from 'i18next';
import { usePathname } from 'next/navigation';
import { useRouter } from 'next/router';
import { useTranslation } from 'react-i18next';
import { Box, Text, TextErrors } from '@/components';
import { useCunninghamTheme } from '@/cunningham/';
import { useRemoveDoc } from '../api/useRemoveDoc';
import IconDoc from '../assets/icon-doc.svg';
import { Doc } from '../types';
interface ModalRemoveDocProps {
@@ -22,13 +21,13 @@ interface ModalRemoveDocProps {
}
export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const { toast } = useToastProvider();
const { push } = useRouter();
const pathname = usePathname();
const {
mutate: removeDoc,
isError,
error,
} = useRemoveDoc({
@@ -36,7 +35,11 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
toast(t('The document has been deleted.'), VariantType.SUCCESS, {
duration: 4000,
});
void push('/');
if (pathname === '/') {
onClose();
} else {
void push('/');
}
},
});
@@ -59,7 +62,7 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
rightActions={
<Button
aria-label={t('Confirm deletion')}
color="primary"
color="danger"
fullWidth
onClick={() =>
removeDoc({
@@ -97,32 +100,6 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
)}
{isError && <TextErrors causes={error.cause} />}
<Text
as="p"
$padding="small"
$direction="row"
$gap="0.5rem"
$background={colorsTokens()['primary-150']}
$theme="primary"
$align="center"
$radius="2px"
>
<IconDoc
className="p-t"
aria-label={t(`Document icon`)}
color={colorsTokens()['primary-500']}
width={58}
style={{
borderRadius: '8px',
backgroundColor: '#ffffff',
border: `1px solid ${colorsTokens()['primary-300']}`,
}}
/>
<Text $theme="primary" $weight="bold" $size="l">
{doc.title}
</Text>
</Text>
</Box>
</Modal>
);

View File

@@ -8,16 +8,23 @@ import { useResponsiveStore } from '@/stores';
import { useInfiniteDocs } from '../../doc-management';
import { DocsGridItem } from './DocsGridItem';
import { DocsGridLoader } from './DocsGridLoader';
export const DocsGrid = () => {
const { t } = useTranslation();
const { isDesktop } = useResponsiveStore();
const { data, isFetching, isLoading, fetchNextPage, hasNextPage } =
useInfiniteDocs({
page: 1,
});
const {
data,
isFetching,
isRefetching,
isLoading,
fetchNextPage,
hasNextPage,
} = useInfiniteDocs({
page: 1,
});
const loading = isFetching || isLoading;
const loadMore = (inView: boolean) => {
@@ -28,65 +35,69 @@ export const DocsGrid = () => {
};
return (
<Card data-testid="docs-grid" $padding="md" $width="100%" $maxWidth="960px">
<Text
as="h4"
$size="h4"
$weight="700"
$margin={{ top: '0px', bottom: 'xs' }}
>
{t('All docs')}
</Text>
<Box $position="relative" $width="100%" $maxWidth="960px">
<DocsGridLoader isLoading={isRefetching} />
<Card data-testid="docs-grid" $padding="md">
<Text
as="h4"
$size="h4"
$weight="700"
$margin={{ top: '0px', bottom: 'xs' }}
>
{t('All docs')}
</Text>
<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">
<Box>
<Box $direction="row" $padding="xs" data-testid="docs-grid-header">
<Box $flex={6} $padding="3xs">
<Text $size="xs" $variation="600">
{t('Updated at')}
{t('Name')}
</Text>
</Box>
)}
{isDesktop && (
<Box $flex={1} $padding="3xs">
<Text $size="xs" $variation="600">
{t('Updated at')}
</Text>
</Box>
)}
<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>
<Box $flex={1} $align="flex-end" $padding="3xs" />
</Box>
{loading && (
<Box
data-testid="docs-grid-loader"
$padding="md"
$align="center"
$justify="center"
$width="100%"
>
<Loader />
{/* Body */}
{data?.pages.map((currentPage) => {
return currentPage.results.map((doc) => (
<DocsGridItem doc={doc} key={doc.id} />
));
})}
</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>
{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>
</Box>
);
};

View File

@@ -1,7 +1,7 @@
import { Button } from '@openfun/cunningham-react';
import { useState } from 'react';
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';
interface DocsGridActionsProps {
@@ -10,29 +10,31 @@ interface DocsGridActionsProps {
export const DocsGridActions = ({ doc }: DocsGridActionsProps) => {
const { t } = useTranslation();
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
const deleteModal = useModal();
if (!doc.abilities.destroy) {
return null;
}
const options: DropdownMenuOption[] = [
{
label: t('Remove'),
icon: 'delete',
callback: () => deleteModal.open(),
disabled: !doc.abilities.destroy,
testId: `docs-grid-actions-remove-${doc.id}`,
},
];
return (
<>
<Button
data-testid={`docs-grid-delete-button-${doc.id}`}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setIsModalRemoveOpen(true);
}}
color="tertiary-text"
icon={<span className="material-icons">delete</span>}
size="small"
style={{ padding: '0rem' }}
aria-label={t('Delete the document')}
/>
{isModalRemoveOpen && (
<ModalRemoveDoc onClose={() => setIsModalRemoveOpen(false)} doc={doc} />
<DropdownMenu options={options}>
<Icon
data-testid={`docs-grid-actions-button-${doc.id}`}
iconName="more_horiz"
$theme="primary"
$variation="600"
/>
</DropdownMenu>
{deleteModal.isOpen && (
<ModalRemoveDoc onClose={deleteModal.onClose} doc={doc} />
)}
</>
);

View File

@@ -0,0 +1,44 @@
import { Loader } from '@openfun/cunningham-react';
import { createGlobalStyle, css } from 'styled-components';
import { Box } from '@/components';
import { HEADER_HEIGHT } from '@/features/header/conf';
const DocsGridLoaderStyle = createGlobalStyle`
body, main {
overflow: hidden!important;
overflow-y: hidden!important;
}
`;
type DocsGridLoaderProps = {
isLoading: boolean;
};
export const DocsGridLoader = ({ isLoading }: DocsGridLoaderProps) => {
if (!isLoading) {
return null;
}
return (
<>
<DocsGridLoaderStyle />
<Box
data-testid="grid-loader"
$align="center"
$justify="center"
$height="calc(100vh - 50px)"
$width="100%"
$maxWidth="960px"
$background="rgba(255, 255, 255, 0.3)"
$zIndex={998}
$position="fixed"
$css={css`
top: ${HEADER_HEIGHT}px;
`}
>
<Loader />
</Box>
</>
);
};