diff --git a/src/common/components/Dialog/Dialog.tsx b/src/common/components/Dialog/Dialog.tsx index a1e7b7c..f3e04b6 100644 --- a/src/common/components/Dialog/Dialog.tsx +++ b/src/common/components/Dialog/Dialog.tsx @@ -1,63 +1,114 @@ -import { PropsWithChildren, useEffect, useState } from 'react'; +import { createContext, PropsWithChildren, ReactNode, useContext, useState } from 'react'; +import noop from 'lodash/noop'; +import { cva, VariantProps } from 'class-variance-authority'; -import { cn } from 'common/utils/css'; import { BaseComponentProps } from 'common/utils/types'; +import { cn } from 'common/utils/css'; import Backdrop from '../Backdrop/Backdrop'; +import Divider, { DividerProps } from '../Divider/Divider'; +import { default as CommonButton, ButtonProps as CommonButtonProps } from '../Button/Button'; + +/** + * Defines the properties of the `DialogContext` value. + */ +type DialogContextValue = { + isHidden: boolean; + setIsHidden: (isHidden: boolean) => void; +}; + +/** + * The `DialogContext` instance. + */ +const DialogContext = createContext({ + isHidden: true, + setIsHidden: noop, +}); /** - * Properties for the `Dialog` component. - * @param {boolean} [isOpen] - Indicates if the Dialog should be displayed. - * @param {function} [onClose] - A function called when the Dialog closes. - * @see {@link BaseComponentProps} - * @see {@link PropsWithChildren} + * Defines the properties of the `Dialog` render prop function context object. + * @param close - Closes the dialog. */ -export interface DialogProps extends BaseComponentProps, PropsWithChildren { - isOpen?: boolean; - onClose?: () => void | Promise; +type DialogRenderFnContext = { + close: () => void; +}; + +/** + * The `Dialog` render prop function signature. + */ +type DialogRenderFn = (ctx: DialogRenderFnContext) => ReactNode; + +export interface DialogProps extends BaseComponentProps { + children?: ReactNode | DialogRenderFn; } /** * A `Dialog` is a modal window that displays on top of the main content, * typically asking the user to take an action or confirm a decision. - * @param {DialogProps} props - Component properties. - * @returns {JSX.Element} JSX */ -const Dialog = ({ +const Dialog = ({ children, className, testId = 'dialog' }: DialogProps): JSX.Element => { + const [isHidden, setIsHidden] = useState(true); + + const close = () => { + setIsHidden(true); + }; + + return ( +
+ + {typeof children === 'function' ? children({ close }) : children} + +
+ ); +}; + +/** + * The `Trigger` renders a clickable element used to open a `Dialog`. There + * should be 1 `Trigger` within a `Dialog`. + */ +const Trigger = ({ children, className, - isOpen = false, - onClose, - testId = 'dialog', -}: DialogProps): JSX.Element => { - const [isDialogOpen, setIsDialogOpen] = useState(isOpen); - - useEffect(() => { - setIsDialogOpen(isOpen); - }, [isOpen]); - - const closeDialog = (): void => { - setIsDialogOpen(false); - onClose?.(); - }; + testId = 'dialog-trigger', +}: BaseComponentProps & PropsWithChildren): JSX.Element => { + const { isHidden, setIsHidden } = useContext(DialogContext); - const handleBackdropClick = (): void => { - closeDialog(); - }; + return ( +
setIsHidden(!isHidden)} + data-testid={testId} + > + {children} +
+ ); +}; +Dialog.Trigger = Trigger; - const handleDialogClick = (e: React.MouseEvent): void => { - e.stopPropagation(); - }; +/** + * The `Content` component wraps the contents of a `Dialog` including the + * header, body, and footer. There should be 1 `Content` within a `Dialog`. + */ +const Content = ({ + children, + className, + testId = 'dialog-content', +}: BaseComponentProps & PropsWithChildren): JSX.Element => { + const { isHidden, setIsHidden } = useContext(DialogContext); return ( -
+
setIsHidden(true)} testId={`${testId}-backdrop`} >
e.stopPropagation()} + data-testid={`${testId}-content`} > {children}
@@ -65,5 +116,170 @@ const Dialog = ({
); }; +Dialog.Content = Content; + +/** + * The `Header` is a block within a dialog `Content` and may contain a `Title` + * and `Subtitle`. + */ +const Header = ({ + children, + className, + testId = 'dialog-header', +}: BaseComponentProps & PropsWithChildren): JSX.Element => { + return ( +
+ {children} +
+ ); +}; +Dialog.Header = Header; + +/** + * The `Body` is a block which encloses the main content of the `Dialog`. + */ +const Body = ({ + children, + className, + testId = 'dialog-body', +}: BaseComponentProps & PropsWithChildren): JSX.Element => { + return ( +
+ {children} +
+ ); +}; +Dialog.Body = Body; + +/** + * The `Footer` is a block within a dialog `Content` and contains values such as + * a `ButtonBar`, `Button`, or any components which are located at the bottom of + * the `Dialog`. + */ +const Footer = ({ + children, + className, + testId = 'dialog-footer', +}: BaseComponentProps & PropsWithChildren): JSX.Element => { + return ( +
+ {children} +
+ ); +}; +Dialog.Footer = Footer; + +/** + * A `Title` for the `Dialog`. The title is optional. When present, the `Title` + * is typically located within the dialog `Header`. + */ +const Title = ({ + children, + className, + testId = 'dialog-title', +}: BaseComponentProps & PropsWithChildren): JSX.Element => { + return ( +
+ {children} +
+ ); +}; +Dialog.Title = Title; + +/** + * A `Subtitle` for the `Dialog`. The subtitle is optional. When present, the + * `Subtitle` is typically located within the dialog `Header`, immediately after + * the `Title`. + */ +const Subtitle = ({ + children, + className, + testId = 'dialog-subtitle', +}: BaseComponentProps & PropsWithChildren): JSX.Element => { + return ( +
+ {children} +
+ ); +}; +Dialog.Subtitle = Subtitle; + +/** + * A `ButtonBar` organizes one to many dialog `Button` components in a horizontal + * row. The buttons are right justified within the bar. + */ +const ButtonBar = ({ + children, + className, + testId = 'dialog-button-bar', +}: BaseComponentProps & PropsWithChildren): JSX.Element => { + return ( +
+ {children} +
+ ); +}; +Dialog.ButtonBar = ButtonBar; + +/** + * Define the `Button` component base and variant styles. + */ +const buttonVariants = cva('', { + variants: { + variant: { + danger: 'font-bold text-red-600', + primary: 'font-bold text-blue-600 dark:text-blue-400', + secondary: '', + }, + }, + defaultVariants: { variant: 'secondary' }, +}); + +/** + * The variant attributes of the `Button` component. + */ +type ButtonVariants = VariantProps; + +/** + * Properties for the `Button` component. + */ +export interface ButtonProps extends Omit, ButtonVariants {} + +/** + * A dialog `Button` is a button which is styles for presentation within a + * `Dialog`. + */ +const Button = ({ + className, + variant = 'secondary', + testId = 'dialog-button', + ...props +}: ButtonProps): JSX.Element => { + return ( + + ); +}; +Dialog.Button = Button; + +/** + * The `Separator` component renders a horizontal divider. + * This is useful to organize and separate content. + */ +const Separator = ({ className, testId = 'dialog-separator' }: DividerProps): JSX.Element => { + return ; +}; +Dialog.Separator = Separator; export default Dialog; diff --git a/src/common/components/Dialog/DialogButton.tsx b/src/common/components/Dialog/DialogButton.tsx deleted file mode 100644 index 953661c..0000000 --- a/src/common/components/Dialog/DialogButton.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { cva, VariantProps } from 'class-variance-authority'; - -import { cn } from 'common/utils/css'; -import Button, { ButtonProps } from '../Button/Button'; - -/** - * Define the component base and variant styles. - */ -const variants = cva('', { - variants: { - variant: { - danger: 'font-bold text-red-600', - primary: 'font-bold text-blue-600 dark:text-blue-400', - secondary: '', - }, - }, - defaultVariants: { variant: 'secondary' }, -}); - -/** - * The variant attributes of the DialogButton component. - */ -type DialogButtonVariants = VariantProps; - -/** - * Properties for the `DialogButton` component. - */ -export interface DialogButtonProps extends Omit, DialogButtonVariants {} - -/** - * The `DialogButton` is a type of `Button` specifically styled for use - * within a `Dialog`. - * @param {DialogButtonProps} props - Component properties. - * @returns {JSX.Element} JSX - */ -const DialogButton = ({ - className, - variant, - testId = 'dialog-button', - ...buttonProps -}: DialogButtonProps): JSX.Element => { - return ( - + + + + Are you sure? + Deletion cannot be undone. + + + Delete task 18 Use React. + + + + + Cancel + Delete + + + ), }; -export const Info: Story = { +export const LoremIpsum: Story = { render: (args) => ( - Bob Smith - -
Manager of Stories
-
Raleigh, NC
-
+1 123-456-7890
-
- - Close - + {({ close }) => ( + <> + + + + + + Amet at duis deserunt ad ad ornare. + + Nam cupidatat duis dolore magna aute posuere. Esse arcu morbi quis consequat + facilisis lorem pulvinar. + + + +
+ Cupidatat excepteur mi magna nisi sint. Sint officia donec duis egestas cupidatat + quam consectetur fermentum. Egestas incididunt esse magna ex occaecat nunc arcu. + Adipiscing sit laborum adipiscing aliqua tempor amet. Tempus laboris lorem deserunt + aute sint ullamco magna pariatur. Ipsum elit justo cupidatat ea a exercitation + pariatur. +
+
+ Mi tempor incididunt duis ullamco morbi minim. Proin condimentum adipiscing officia + et ipsum nunc. Est et est quam labore sit velit. Eiusmod laboris facilisis aliquip + nulla aliqua occaecat quam mi. Ligula aute fermentum tempus dolore qui dolore culpa. + Quis quam ligula nulla exercitation exercitation nibh. Bibendum laboris mollit vitae + sunt elit aenean a non. Neque dui proident cupidatat proident morbi sunt sapien. +
+
+ + + + close()}>Cancel + close()} variant="primary"> + Accept + + + +
+ + )}
), }; diff --git a/src/common/components/Dialog/__stories__/DialogButton.stories.tsx b/src/common/components/Dialog/__stories__/DialogButton.stories.tsx deleted file mode 100644 index 8253281..0000000 --- a/src/common/components/Dialog/__stories__/DialogButton.stories.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react'; - -import DialogButton from '../DialogButton'; - -const meta = { - title: 'Common/Dialog/DialogButton', - component: DialogButton, - parameters: { - layout: 'centered', - }, - tags: ['autodocs'], - argTypes: { - children: { description: 'The content.' }, - className: { description: 'Additional CSS classes.' }, - testId: { description: 'The test identifier.' }, - variant: { description: 'The variant.' }, - }, - args: { - children: 'Label', - variant: 'primary', - }, -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = {}; - -export const Primary: Story = { - args: { - children: 'Primary', - }, -}; - -export const Secondary: Story = { - args: { - children: 'Secondary', - variant: 'secondary', - }, -}; - -export const Danger: Story = { - args: { - children: 'Danger', - variant: 'danger', - }, -}; diff --git a/src/common/components/Dialog/__stories__/DialogHeading.stories.tsx b/src/common/components/Dialog/__stories__/DialogHeading.stories.tsx deleted file mode 100644 index 84cbd29..0000000 --- a/src/common/components/Dialog/__stories__/DialogHeading.stories.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react'; - -import DialogHeading from '../DialogHeading'; - -const meta = { - title: 'Common/Dialog/DialogHeading', - component: DialogHeading, - parameters: { - layout: 'centered', - }, - tags: ['autodocs'], - argTypes: { - children: { description: 'The content.' }, - className: { description: 'Additional CSS classes.' }, - testId: { description: 'The test identifier.' }, - }, - args: { - children: 'Are you sure?', - }, -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -export const Confirm: Story = {}; - -export const Info: Story = { - args: { - children: ( - <> - About: Widgets - - ), - }, -}; diff --git a/src/common/components/Dialog/__tests__/DiaglogButtons.test.tsx b/src/common/components/Dialog/__tests__/DiaglogButtons.test.tsx deleted file mode 100644 index 0df883c..0000000 --- a/src/common/components/Dialog/__tests__/DiaglogButtons.test.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { render, screen } from 'test/test-utils'; - -import DialogButtons from '../DialogButtons'; - -describe('DialogButtons', () => { - it('should render successfully', async () => { - // ARRANGE - render( - -
-
, - ); - await screen.findByTestId('dialog-buttons'); - - // ASSERT - expect(screen.getByTestId('dialog-buttons')).toBeDefined(); - expect(screen.getByTestId('children')).toBeDefined(); - }); - - it('should use testId', async () => { - // ARRANGE - render( - -
-
, - ); - await screen.findByTestId('my-dialog-buttons'); - - // ASSERT - expect(screen.getByTestId('my-dialog-buttons')).toBeDefined(); - }); - - it('should use className', async () => { - // ARRANGE - render( - -
-
, - ); - await screen.findByTestId('dialog-buttons'); - - // ASSERT - expect(screen.getByTestId('dialog-buttons')).toHaveClass('my-class'); - }); -}); diff --git a/src/common/components/Dialog/__tests__/Dialog.test.tsx b/src/common/components/Dialog/__tests__/Dialog.test.tsx index accb1f9..f981d7f 100644 --- a/src/common/components/Dialog/__tests__/Dialog.test.tsx +++ b/src/common/components/Dialog/__tests__/Dialog.test.tsx @@ -1,5 +1,5 @@ import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { render, screen, waitFor } from 'test/test-utils'; @@ -8,91 +8,233 @@ import Dialog from '../Dialog'; describe('Dialog', () => { it('should render successfully', async () => { // ARRANGE - render(); + render( + + {({ close }) => ( + <> + Open + + + Title + Subtitle + + Body + + + close()}>Cancel + + + + + )} + , + ); await screen.findByTestId('dialog'); // ASSERT expect(screen.getByTestId('dialog')).toBeDefined(); }); - it('should use testId', async () => { - // ARRANGE - render(); - await screen.findByTestId('my-dialog'); - - // ASSERT - expect(screen.getByTestId('my-dialog')).toBeDefined(); - }); - - it('should use className', async () => { + it('should render successfully with ReactNode children', async () => { // ARRANGE - render(); + render( + + Open + + + Title + Subtitle + + Body + + + close()}>Cancel + + + + , + ); await screen.findByTestId('dialog'); // ASSERT - expect(screen.getByTestId('dialog')).toHaveClass('my-class'); + expect(screen.getByTestId('dialog')).toBeDefined(); }); - it('should be hidden by default', async () => { // ARRANGE - render(); - await screen.findByTestId('dialog'); + render( + + {({ close }) => ( + <> + Open + + + Title + Subtitle + + Body + + + close()}>Cancel + + + + + )} + , + ); + await screen.findByTestId('dialog-content'); // ASSERT - expect(screen.getByTestId('dialog')).toHaveClass('hidden'); + expect(screen.getByTestId('dialog-content')).toHaveClass('hidden'); }); - it('should not be hidden when isOpen true', async () => { + it('should open when trigger is clicked', async () => { // ARRANGE - render(); - await screen.findByTestId('dialog'); + const user = userEvent.setup(); + render( + + {({ close }) => ( + <> + Open + + + Title + Subtitle + + Body + + + close()}>Cancel + + + + + )} + , + ); + await screen.findByTestId('dialog-trigger'); + + // ACT + await user.click(screen.getByTestId('dialog-trigger')); // ASSERT - expect(screen.getByTestId('dialog')).not.toHaveClass('hidden'); + expect(screen.getByTestId('dialog-content')).not.toHaveClass('hidden'); }); - it('should be hidden when isOpen false', async () => { + it('should close when cancel button is clicked', async () => { // ARRANGE - render(); - await screen.findByTestId('dialog'); + const user = userEvent.setup(); + render( + + {({ close }) => ( + <> + Open + + + Title + Subtitle + + Body + + + close()} testId="dialog-button-cancel"> + Cancel + + + + + + )} + , + ); + await screen.findByTestId('dialog-trigger'); + + // ACT + await user.click(screen.getByTestId('dialog-trigger')); + /* dialog is open */ + await waitFor(() => expect(screen.getByTestId('dialog-content')).not.toHaveClass('hidden')); + + await user.click(screen.getByTestId('dialog-button-cancel')); + /* dialog is closed */ + await waitFor(() => expect(screen.getByTestId('dialog-content')).toHaveClass('hidden')); // ASSERT - expect(screen.getByTestId('dialog')).toHaveClass('hidden'); + expect(screen.getByTestId('dialog-content')).toHaveClass('hidden'); }); it('should close when backdrop clicked', async () => { // ARRANGE const user = userEvent.setup(); - const closeFn = vi.fn(); - render(); - await screen.findByTestId('dialog'); - waitFor(() => expect(screen.getByTestId('dialog')).not.toHaveClass('hidden')); + render( + + {({ close }) => ( + <> + Open + + + Title + Subtitle + + Body + + + close()}>Cancel + + + + + )} + , + ); + await screen.findByTestId('dialog-trigger'); // ACT - await user.click(screen.getByTestId('dialog-backdrop')); + await user.click(screen.getByTestId('dialog-trigger')); + /* dialog is open */ + await waitFor(() => expect(screen.getByTestId('dialog-content')).not.toHaveClass('hidden')); + + await user.click(screen.getByTestId('dialog-content-backdrop')); + /* dialog is closed */ + await waitFor(() => expect(screen.getByTestId('dialog-content')).toHaveClass('hidden')); // ASSERT - expect(closeFn).toHaveBeenCalledOnce(); - expect(screen.getByTestId('dialog')).toHaveClass('hidden'); + expect(screen.getByTestId('dialog-content')).toHaveClass('hidden'); }); it('should not close when dialog content clicked', async () => { // ARRANGE const user = userEvent.setup(); - const closeFn = vi.fn(); render( - -
+ + {({ close }) => ( + <> + Open + + + Title + Subtitle + + Body + + + close()}>Cancel + + + + + )} , ); - await screen.findByTestId('dialog'); - expect(screen.getByTestId('dialog')).not.toHaveClass('hidden'); + await screen.findByTestId('dialog-trigger'); // ACT - await user.click(screen.getByTestId('content')); + await user.click(screen.getByTestId('dialog-trigger')); + /* dialog is open */ + await waitFor(() => expect(screen.getByTestId('dialog-content')).not.toHaveClass('hidden')); + + await user.click(screen.getByTestId('dialog-body')); // ASSERT - expect(closeFn).not.toHaveBeenCalled(); - expect(screen.getByTestId('dialog')).not.toHaveClass('hidden'); + expect(screen.getByTestId('dialog-content')).not.toHaveClass('hidden'); }); }); diff --git a/src/common/components/Dialog/__tests__/DialogButton.test.tsx b/src/common/components/Dialog/__tests__/DialogButton.test.tsx deleted file mode 100644 index e1467db..0000000 --- a/src/common/components/Dialog/__tests__/DialogButton.test.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { render, screen } from 'test/test-utils'; - -import DialogButton from '../DialogButton'; - -describe('DialogButton', () => { - it('should render successfully', async () => { - // ARRANGE - render(Submit); - await screen.findByTestId('dialog-button'); - - // ASSERT - expect(screen.getByTestId('dialog-button')).toBeDefined(); - expect(screen.getByTestId('dialog-button')).toHaveTextContent('Submit'); - }); - - it('should use testId', async () => { - // ARRANGE - render(Submit); - await screen.findByTestId('my-dialog-button'); - - // ASSERT - expect(screen.getByTestId('my-dialog-button')).toBeDefined(); - }); - - it('should use className', async () => { - // ARRANGE - render(Submit); - await screen.findByTestId('dialog-button'); - - // ASSERT - expect(screen.getByTestId('dialog-button')).toHaveClass('my-class'); - }); - - it('should render primary variant', async () => { - // ARRANGE - render(Submit); - await screen.findByTestId('dialog-button'); - - // ASSERT - expect(screen.getByTestId('dialog-button')).toHaveClass('text-blue-600'); - }); - - it('should render danger variant', async () => { - // ARRANGE - render(Submit); - await screen.findByTestId('dialog-button'); - - // ASSERT - expect(screen.getByTestId('dialog-button')).toHaveClass('text-red-600'); - }); -}); diff --git a/src/common/components/Dialog/__tests__/DialogContent.test.tsx b/src/common/components/Dialog/__tests__/DialogContent.test.tsx deleted file mode 100644 index 64a412c..0000000 --- a/src/common/components/Dialog/__tests__/DialogContent.test.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { render, screen } from 'test/test-utils'; - -import DialogContent from '../DialogContent'; - -describe('DialogContent', () => { - it('should render successfully', async () => { - // ARRANGE - render(); - await screen.findByTestId('dialog-content'); - - // ASSERT - expect(screen.getByTestId('dialog-content')).toBeDefined(); - }); - - it('should use testId', async () => { - // ARRANGE - render(); - await screen.findByTestId('my-dialog-content'); - - // ASSERT - expect(screen.getByTestId('my-dialog-content')).toBeDefined(); - }); - - it('should use className', async () => { - // ARRANGE - render(); - await screen.findByTestId('dialog-content'); - - // ASSERT - expect(screen.getByTestId('dialog-content')).toHaveClass('my-class'); - }); -}); diff --git a/src/common/components/Dialog/__tests__/DialogHeading.test.tsx b/src/common/components/Dialog/__tests__/DialogHeading.test.tsx deleted file mode 100644 index a4b34c8..0000000 --- a/src/common/components/Dialog/__tests__/DialogHeading.test.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { render, screen } from 'test/test-utils'; - -import DialogHeading from '../DialogHeading'; - -describe('DialogHeading', () => { - it('should render successfully', async () => { - // ARRANGE - render(); - await screen.findByTestId('dialog-heading'); - - // ASSERT - expect(screen.getByTestId('dialog-heading')).toBeDefined(); - }); - - it('should use testId', async () => { - // ARRANGE - render(); - await screen.findByTestId('my-dialog-heading'); - - // ASSERT - expect(screen.getByTestId('my-dialog-heading')).toBeDefined(); - }); - - it('should use className', async () => { - // ARRANGE - render(); - await screen.findByTestId('dialog-heading'); - - // ASSERT - expect(screen.getByTestId('dialog-heading')).toHaveClass('my-class'); - }); -}); diff --git a/src/common/components/Router/Router.tsx b/src/common/components/Router/Router.tsx index ab23907..c9abb73 100644 --- a/src/common/components/Router/Router.tsx +++ b/src/common/components/Router/Router.tsx @@ -20,6 +20,7 @@ import AvatarComponents from 'pages/Components/components/AvatarComponents'; import BadgeComponents from 'pages/Components/components/BadgeComponents'; import ButtonComponents from 'pages/Components/components/ButtonComponents'; import CardComponents from 'pages/Components/components/CardComponents'; +import DialogComponents from 'pages/Components/components/DialogComponents'; import DropdownComponents from 'pages/Components/components/DropdownComponents'; import SearchInputComponents from 'pages/Components/components/SearchInputComponents'; import TabsComponents from 'pages/Components/components/TabsComponents'; @@ -107,6 +108,10 @@ export const routes: RouteObject[] = [ path: 'card', element: , }, + { + path: 'dialog', + element: , + }, { path: 'dropdown', element: , diff --git a/src/pages/Components/ComponentsPage.tsx b/src/pages/Components/ComponentsPage.tsx index be6d611..dfbe49b 100644 --- a/src/pages/Components/ComponentsPage.tsx +++ b/src/pages/Components/ComponentsPage.tsx @@ -35,6 +35,9 @@ const ComponentsPage = (): JSX.Element => { Card + + Dialog + Dropdown diff --git a/src/pages/Components/components/DialogComponents.tsx b/src/pages/Components/components/DialogComponents.tsx new file mode 100644 index 0000000..9048852 --- /dev/null +++ b/src/pages/Components/components/DialogComponents.tsx @@ -0,0 +1,229 @@ +import { createColumnHelper } from '@tanstack/react-table'; + +import { BaseComponentProps } from 'common/utils/types'; +import { ComponentProperty } from '../model/components'; +import Table from 'common/components/Table/Table'; +import CodeSnippet from 'common/components/Text/CodeSnippet'; +import Heading from 'common/components/Text/Heading'; +import Dialog from 'common/components/Dialog/Dialog'; +import Button from 'common/components/Button/Button'; + +/** + * The `DialogComponents` React component renders a set of examples illustrating + * the use of the `Dialog` component. + */ +const DialogComponents = ({ + className, + testId = 'components-dialog', +}: BaseComponentProps): JSX.Element => { + const data: ComponentProperty[] = [ + { + name: 'children', + description: 'The content to be displayed. May be a ReactNode or a render prop function.', + }, + { + name: 'className', + description: 'Optional. Additional CSS class names.', + }, + { + name: 'testId', + description: 'Optional. Identifier for testing.', + }, + ]; + const columnHelper = createColumnHelper(); + const columns = [ + columnHelper.accessor('name', { + cell: (info) => ( + {info.getValue()} + ), + header: () => 'Name', + }), + columnHelper.accessor('description', { + cell: (info) => info.renderValue(), + header: () => 'Description', + }), + ]; + + return ( +
+ + Dialog Component + + +
+ The Dialog is a modal window that displays on + top of the main content, typically asking the user to take an action or confirm a decision. + Compose a Dialog using combinations of: Trigger, Content,{' '} + Header, Title, Subtitle, Body,{' '} + Footer, ButtonBar, and Button. +
+ +
+ + Properties + + data={data} columns={columns} /> +
+ + Examples + + Dialog with ReactNode children +
+
+ + + + + + + Are you sure? + Deletion is permanent and cannot be undone. + + + Delete issue:{' '} + + 987 Use dialog to confirm task delete + + + + + + Cancel + Delete + + + + +
+ + + + + + + Are you sure? + Deletion is permanent and cannot be undone. + + + Delete issue:{' '} + + 987 Use dialog to confirm task delete + + + + + + Cancel + Delete + + + +
`} + /> +
+ + Dialog with render props +
+
+ + {({ close }) => ( + <> + + + + + + Terms and Conditions + + Nam cupidatat duis dolore magna aute posuere. Esse arcu morbi quis consequat + facilisis lorem pulvinar. + + + +
+ Dolor proident aliqua ornare consectetur. Sapien est do quam labore qos + veniam. Aute sint sunt commodo ea e. Ligula anim amet nulla morbi nulla. + Laborum duis sunt exercitation justo. Quis aliquip posuere incididunt et + consectetur. Nostrud incididunt laborum pulvinar ea ut ex at. +
+
+ Pariatur anim veniam morbi dui consectetur officia. Occaecat e dolore mi enim + morbi aliquip e. Condimentum adipiscing sunt commodo proident enim laborum mi. + Dui labore posuere ex exercitation justo morbi. A consequat pulvinar aliqua do + duis ligula. Ullamco enim condimentum pariatur dolor nulla ad quam. Nam + egestas laboris laoreet et ipsum deserunt ligula. +
+
+ + + + close()} testId="dialog-button-decline"> + Decline + + close()} + testId="dialog-button-accept" + > + Accept + + + +
+ + )} +
+
+ + {({ close }) => ( + <> + + + + + + Terms and Conditions + + Nam cupidatat duis dolore magna aute posuere. Esse arcu morbi quis consequat + facilisis lorem pulvinar. + + + +
+ Dolor proident aliqua ornare consectetur. Sapien est do quam labore qos + veniam. Aute sint sunt commodo ea e. Ligula anim amet nulla morbi nulla. + Laborum duis sunt exercitation justo. Quis aliquip posuere incididunt et + consectetur. Nostrud incididunt laborum pulvinar ea ut ex at. +
+
+ Pariatur anim veniam morbi dui consectetur officia. Occaecat e dolore mi enim + morbi aliquip e. Condimentum adipiscing sunt commodo proident enim laborum mi. + Dui labore posuere ex exercitation justo morbi. A consequat pulvinar aliqua do + duis ligula. Ullamco enim condimentum pariatur dolor nulla ad quam. Nam + egestas laboris laoreet et ipsum deserunt ligula. +
+
+ + + + close()}>Decline + close()}> + Accept + + + +
+ + )} +`} + /> +
+ + ); +}; + +export default DialogComponents; diff --git a/src/pages/Components/components/__tests__/DialogComponents.test.tsx b/src/pages/Components/components/__tests__/DialogComponents.test.tsx new file mode 100644 index 0000000..2130aa5 --- /dev/null +++ b/src/pages/Components/components/__tests__/DialogComponents.test.tsx @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import { render, screen } from 'test/test-utils'; + +import DialogComponents from '../DialogComponents'; +import userEvent from '@testing-library/user-event'; + +describe('DialogComponents', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('components-dialog'); + + // ASSERT + expect(screen.getByTestId('components-dialog')).toBeDefined(); + }); + + it('should click items', async () => { + // ARRANGE + const user = userEvent.setup(); + render(); + await screen.findByTestId('components-dialog'); + + // ACT + /* exercising click handlers; normally would assert handler function called */ + await user.click(screen.getByTestId('dialog-button-decline')); + await user.click(screen.getByTestId('dialog-button-accept')); + + // ASSERT + expect(screen.getByTestId('components-dialog')).toBeDefined(); + }); +}); diff --git a/src/pages/Tasks/components/Delete/TaskDeleteDialog.tsx b/src/pages/Tasks/components/Delete/TaskDeleteDialog.tsx index 5a7413b..2ce606c 100644 --- a/src/pages/Tasks/components/Delete/TaskDeleteDialog.tsx +++ b/src/pages/Tasks/components/Delete/TaskDeleteDialog.tsx @@ -1,58 +1,99 @@ -import { ComponentPropsWithoutRef } from 'react'; +import { PropsWithChildren } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { BaseComponentProps } from 'common/utils/types'; import { Task } from 'pages/Tasks/api/useGetUserTasks'; +import { useDeleteTask } from 'pages/Tasks/api/useDeleteTask'; +import { useToasts } from 'common/hooks/useToasts'; import Dialog from 'common/components/Dialog/Dialog'; -import DialogHeading from 'common/components/Dialog/DialogHeading'; -import DialogContent from 'common/components/Dialog/DialogContent'; -import Divider from 'common/components/Divider/Divider'; -import DialogButtons from 'common/components/Dialog/DialogButtons'; -import DialogButton from 'common/components/Dialog/DialogButton'; +import ErrorAlert from 'common/components/Alert/ErrorAlert'; /** * Properties for the `TaskDeleteDialog` component. - * @param {function} onCancel - A function called when the cancel button is clicked. - * @param {function} onDelete - A function called when the delete button is clicked. - * @param {Task} task - The `Task` being deleted. - * @see {@link Dialog} */ -interface TaskDeleteDialogProps extends ComponentPropsWithoutRef { - onCancel: () => void; - onDelete: () => void; +interface TaskDeleteDialogProps extends BaseComponentProps, PropsWithChildren { task: Task; } /** * The `TaskDeleteDialog` renders a dialog prompting for deletion confirmation * of a `Task`. - * @param {TaskDeleteDialog} props - Component properties. - * @returns {JSX.Element} JSX */ const TaskDeleteDialog = ({ - onCancel, - onDelete, + children, + className, task, testId = 'dialog-task-delete', - ...dialogProps }: TaskDeleteDialogProps): JSX.Element => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { createToast } = useToasts(); + const { mutate: deleteTask, isPending, error } = useDeleteTask(); + + /** + * Performs task deletion. + */ + const doDelete = () => { + deleteTask( + { task }, + { + onSuccess: () => { + createToast({ + text: `Task deleted.`, + isAutoDismiss: true, + }); + navigate(-1); + }, + }, + ); + }; + return ( - - Are you sure? - - Deleting task {task.title} is permanent. - - - - onCancel()} testId={`${testId}-button-cancel`}> - Cancel - - onDelete()} - variant="danger" - testId={`${testId}-button-delete`} - > - Delete - - + + {({ close }) => ( + <> + {children} + + + Are you sure? + Deleting a task is permanent. + + + {error && ( + + )} +
+ Delete task {task.title}. +
+
+ + + + close()} + disabled={isPending} + testId={`${testId}-button-cancel`} + > + Cancel + + + Delete + + + +
+ + )}
); }; diff --git a/src/pages/Tasks/components/Delete/__tests__/TaskDeleteDialog.test.tsx b/src/pages/Tasks/components/Delete/__tests__/TaskDeleteDialog.test.tsx index ec55792..1c1516e 100644 --- a/src/pages/Tasks/components/Delete/__tests__/TaskDeleteDialog.test.tsx +++ b/src/pages/Tasks/components/Delete/__tests__/TaskDeleteDialog.test.tsx @@ -1,8 +1,10 @@ import { describe, expect, it, vi } from 'vitest'; import userEvent from '@testing-library/user-event'; -import { render, screen } from 'test/test-utils'; +import { render, screen, waitFor } from 'test/test-utils'; import { todosFixture } from '__fixtures__/todos'; +import { ToastsContextValue } from 'common/providers/ToastsContext'; +import * as UseToasts from 'common/hooks/useToasts'; import TaskDeleteDialog from '../../../../Tasks/components/Delete/TaskDeleteDialog'; @@ -10,15 +12,10 @@ describe('TaskDeleteDialog', () => { it('should render successfully', async () => { // ARRANGE const task = todosFixture[0]; - const mockOnCancel = vi.fn(); - const mockOnDelete = vi.fn(); render( - , + + Open + , ); await screen.findByTestId('dialog'); @@ -26,44 +23,45 @@ describe('TaskDeleteDialog', () => { expect(screen.getByTestId('dialog')).toBeDefined(); }); - it('should call onCancel when cancel button clicked', async () => { + it('should close dialog when cancel button clicked', async () => { // ARRANGE const user = userEvent.setup(); const task = todosFixture[0]; - const mockOnCancel = vi.fn(); - const mockOnDelete = vi.fn(); render( - , + + Open + , ); await screen.findByTestId('dialog'); // ACT + await user.click(screen.getByTestId('dialog-trigger')); + /* wait for dialog to open */ + await waitFor(() => expect(screen.getByTestId('dialog-content')).not.toHaveClass('hidden')); + await user.click(screen.getByTestId('dialog-button-cancel')); + /* wait for dialog to close */ + await waitFor(() => expect(screen.getByTestId('dialog-content')).toHaveClass('hidden')); // ASSERT - expect(screen.getByTestId('dialog')).toBeDefined(); - expect(mockOnCancel).toHaveBeenCalledOnce(); - expect(mockOnDelete).not.toHaveBeenCalled(); + expect(screen.getByTestId('dialog-content')).toHaveClass('hidden'); }); - it('should call onDelete when delete button clicked', async () => { + it('should delete task when delete button clicked', async () => { // ARRANGE const user = userEvent.setup(); const task = todosFixture[0]; - const mockOnCancel = vi.fn(); - const mockOnDelete = vi.fn(); + const mockCreateToast = vi.fn(); + const useToastsSpy = vi.spyOn(UseToasts, 'useToasts'); + useToastsSpy.mockReturnValue({ + createToast: mockCreateToast, + toasts: [], + } as unknown as ToastsContextValue); + render( - , + + Open + , ); await screen.findByTestId('dialog'); @@ -71,8 +69,26 @@ describe('TaskDeleteDialog', () => { await user.click(screen.getByTestId('dialog-button-delete')); // ASSERT - expect(screen.getByTestId('dialog')).toBeDefined(); - expect(mockOnDelete).toHaveBeenCalledOnce(); - expect(mockOnCancel).not.toHaveBeenCalled(); + expect(mockCreateToast).toHaveBeenCalled(); + }); + + it('should display error when there is a problem deleting task', async () => { + // ARRANGE + const user = userEvent.setup(); + const task = { ...todosFixture[0], id: 999999 }; + + render( + + Open + , + ); + await screen.findByTestId('dialog'); + + // ACT + await user.click(screen.getByTestId('dialog-button-delete')); + await waitFor(() => expect(screen.getByTestId('dialog-error')).toBeDefined()); + + // ASSERT + expect(screen.getByTestId('dialog-error')).toBeDefined(); }); }); diff --git a/src/pages/Tasks/components/TaskDetailLayout.tsx b/src/pages/Tasks/components/TaskDetailLayout.tsx index 434eace..e54be11 100644 --- a/src/pages/Tasks/components/TaskDetailLayout.tsx +++ b/src/pages/Tasks/components/TaskDetailLayout.tsx @@ -1,12 +1,8 @@ -import { useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { BaseComponentProps } from 'common/utils/types'; -import { Task } from 'pages/Tasks/api/useGetUserTasks'; import { useGetTask } from '../api/useGetTask'; -import { useDeleteTask } from '../api/useDeleteTask'; -import { useToasts } from 'common/hooks/useToasts'; import LoaderSkeleton from 'common/components/Loader/LoaderSkeleton'; import FAIcon from 'common/components/Icon/FAIcon'; import TaskDeleteDialog from './Delete/TaskDeleteDialog'; @@ -31,10 +27,8 @@ const TaskDetailLayout = ({ testId = 'layout-task-detail', }: TaskDetailLayoutProps): JSX.Element => { const { t } = useTranslation(); - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const navigate = useNavigate(); const { taskId } = useParams(); - const { createToast } = useToasts(); const { data: task, @@ -42,24 +36,6 @@ const TaskDetailLayout = ({ isLoading: isLoadingTask, } = useGetTask({ taskId: Number(taskId) }); - const { mutate: deleteTask, isPending: isDeletePending, error: deleteError } = useDeleteTask(); - - const doDelete = (task: Task) => { - setIsDeleteDialogOpen(false); - deleteTask( - { task }, - { - onSuccess: () => { - createToast({ - text: `Deleted task ${task.id}`, - isAutoDismiss: true, - }); - navigate(-1); - }, - }, - ); - }; - return (
{/* Heading */} @@ -76,24 +52,22 @@ const TaskDetailLayout = ({ {/* Menu */}
- - + {task && ( + + )} + {task && ( + + + + )}