From 1d85eee78f4768c4764eaabfcbe945f2bbd78b63 Mon Sep 17 00:00:00 2001 From: Nathan Panchout Date: Mon, 25 Nov 2024 09:44:30 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=84(frontend)=20add=20dropdown=20optio?= =?UTF-8?q?n=20for=20DocGridItem?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement dropdown menu with functionality to delete a document from the list --- CHANGELOG.md | 2 + .../__tests__/app-impress/doc-grid.spec.ts | 123 ++++++++++++----- .../apps/impress/src/components/BoxButton.tsx | 22 ++- .../impress/src/components/DropButton.tsx | 11 +- .../impress/src/components/DropdownMenu.tsx | 112 ++++++++++++++++ .../apps/impress/src/components/index.ts | 1 + .../components/ModalRemoveDoc.tsx | 43 ++---- .../docs/docs-grid/components/DocsGrid.tsx | 125 ++++++++++-------- .../docs-grid/components/DocsGridActions.tsx | 44 +++--- .../docs-grid/components/DocsGridLoader.tsx | 44 ++++++ 10 files changed, 373 insertions(+), 154 deletions(-) create mode 100644 src/frontend/apps/impress/src/components/DropdownMenu.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridLoader.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 5aab3e34..3c118dbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,6 +88,8 @@ and this project adheres to - ✨(frontend) config endpoint #424 - ✨(frontend) add sentry #424 - ✨(frontend) add crisp chatbot #450 +- 💄(frontend) update DocsGridOptions component #432 + ## Changed diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts index efabfc2f..533a97f6 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts @@ -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(); - }); }); diff --git a/src/frontend/apps/impress/src/components/BoxButton.tsx b/src/frontend/apps/impress/src/components/BoxButton.tsx index 9ba32799..c40d5741 100644 --- a/src/frontend/apps/impress/src/components/BoxButton.tsx +++ b/src/frontend/apps/impress/src/components/BoxButton.tsx @@ -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; +export type BoxButtonType = BoxType & { + disabled?: boolean; +}; + +/** /** * Styleless button that extends the Box component. @@ -18,7 +22,7 @@ export type BoxButtonType = ComponentPropsWithRef; * * ``` */ -const BoxButton = forwardRef( +const BoxButton = forwardRef( ({ $css, ...props }, ref) => { return ( ( $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) => { + if (props.disabled) { + return; + } + props.onClick?.(event); + }} /> ); }, diff --git a/src/frontend/apps/impress/src/components/DropButton.tsx b/src/frontend/apps/impress/src/components/DropButton.tsx index f79b78e6..90fee617 100644 --- a/src/frontend/apps/impress/src/components/DropButton.tsx +++ b/src/frontend/apps/impress/src/components/DropButton.tsx @@ -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; diff --git a/src/frontend/apps/impress/src/components/DropdownMenu.tsx b/src/frontend/apps/impress/src/components/DropdownMenu.tsx new file mode 100644 index 00000000..f30f8334 --- /dev/null +++ b/src/frontend/apps/impress/src/components/DropdownMenu.tsx @@ -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; + danger?: boolean; + disabled?: boolean; +}; + +export type DropdownMenuProps = { + options: DropdownMenuOption[]; + showArrow?: boolean; + arrowCss?: BoxProps['$css']; +}; + +export const DropdownMenu = ({ + options, + children, + showArrow = false, + arrowCss, +}: PropsWithChildren) => { + const theme = useCunninghamTheme(); + const spacings = theme.spacingsTokens(); + const colors = theme.colorsTokens(); + const [isOpen, setIsOpen] = useState(false); + + const onOpenChange = (isOpen: boolean) => { + setIsOpen(isOpen); + }; + + return ( + +
{children}
+ +
+ ) : ( + children + ) + } + > + + {options.map((option, index) => { + const isDisabled = option.disabled !== undefined && option.disabled; + return ( + { + 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 && ( + + )} + {option.label} + + ); + })} + + + ); +}; diff --git a/src/frontend/apps/impress/src/components/index.ts b/src/frontend/apps/impress/src/components/index.ts index 2724d018..b6eb2cd8 100644 --- a/src/frontend/apps/impress/src/components/index.ts +++ b/src/frontend/apps/impress/src/components/index.ts @@ -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'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDoc.tsx index 3841d503..fb79c84c 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDoc.tsx @@ -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={ - )} - - )} - + + {loading && ( + + + + )} + {hasNextPage && !loading && ( + + {!isFetching && hasNextPage && ( + + )} + + )} + + ); }; diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridActions.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridActions.tsx index 76ed43d8..c23db7ad 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridActions.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridActions.tsx @@ -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 ( <> -