From f4ea469d052e1a860c0a61c2fec3370cc47448f8 Mon Sep 17 00:00:00 2001 From: Quentin Le Caignec <12102823+QuentinLeCaignec@users.noreply.github.com> Date: Mon, 9 Sep 2024 14:26:41 +0200 Subject: [PATCH] Feat/dynamic zone (#215) * feat: added DynamicZone, related types and utilities * fix: updated props/types of ActionList for more use-cases --- .changeset/old-suns-deny.md | 6 + packages/haring-react-shared/src/index.tsx | 1 + .../haring-react-shared/src/types/index.ts | 1 + .../haring-react-shared/src/types/utility.ts | 3 + .../src/Components/ActionBar/ActionBar.tsx | 4 +- .../ActionList/ActionList.stories.tsx | 10 +- .../src/Components/ActionList/ActionList.tsx | 20 +- .../src/Form/DynamicZone/DynamicZone.mock.tsx | 87 +++ .../Form/DynamicZone/DynamicZone.module.css | 12 + .../Form/DynamicZone/DynamicZone.stories.tsx | 35 + .../src/Form/DynamicZone/DynamicZone.test.tsx | 27 + .../src/Form/DynamicZone/DynamicZone.tsx | 103 +++ .../DynamicZoneBlock.module.css | 4 + .../DynamicZoneBlock/DynamicZoneBlock.tsx | 130 ++++ .../__snapshots__/DynamicZone.test.tsx.snap | 601 ++++++++++++++++++ packages/haring-react/src/index.tsx | 11 +- .../haring-react/src/types/dynamic-zone.ts | 20 + packages/haring-react/src/types/index.ts | 1 + 18 files changed, 1058 insertions(+), 18 deletions(-) create mode 100644 .changeset/old-suns-deny.md create mode 100644 packages/haring-react-shared/src/types/utility.ts create mode 100644 packages/haring-react/src/Form/DynamicZone/DynamicZone.mock.tsx create mode 100644 packages/haring-react/src/Form/DynamicZone/DynamicZone.module.css create mode 100644 packages/haring-react/src/Form/DynamicZone/DynamicZone.stories.tsx create mode 100644 packages/haring-react/src/Form/DynamicZone/DynamicZone.test.tsx create mode 100644 packages/haring-react/src/Form/DynamicZone/DynamicZone.tsx create mode 100644 packages/haring-react/src/Form/DynamicZone/DynamicZoneBlock/DynamicZoneBlock.module.css create mode 100644 packages/haring-react/src/Form/DynamicZone/DynamicZoneBlock/DynamicZoneBlock.tsx create mode 100644 packages/haring-react/src/Form/DynamicZone/__snapshots__/DynamicZone.test.tsx.snap create mode 100644 packages/haring-react/src/types/dynamic-zone.ts diff --git a/.changeset/old-suns-deny.md b/.changeset/old-suns-deny.md new file mode 100644 index 00000000..ece85a34 --- /dev/null +++ b/.changeset/old-suns-deny.md @@ -0,0 +1,6 @@ +--- +'@smile/haring-react-shared': minor +'@smile/haring-react': minor +--- + +Added DynamicZone component, related types and utilities, updated snapshot diff --git a/packages/haring-react-shared/src/index.tsx b/packages/haring-react-shared/src/index.tsx index 5068ec88..10d7ac72 100644 --- a/packages/haring-react-shared/src/index.tsx +++ b/packages/haring-react-shared/src/index.tsx @@ -44,6 +44,7 @@ export type { IThemeOverride, IThemes, IFilter, + IOmitRespectIndexSignature, } from './types'; // type exports export { mainTheme, primaryTheme, secondaryTheme, themes } from './theme'; diff --git a/packages/haring-react-shared/src/types/index.ts b/packages/haring-react-shared/src/types/index.ts index a8082bf8..0802b594 100644 --- a/packages/haring-react-shared/src/types/index.ts +++ b/packages/haring-react-shared/src/types/index.ts @@ -3,3 +3,4 @@ export * from './options'; export * from './theme'; export * from './filters'; export * from './items'; +export * from './utility'; diff --git a/packages/haring-react-shared/src/types/utility.ts b/packages/haring-react-shared/src/types/utility.ts new file mode 100644 index 00000000..eaaa9fb2 --- /dev/null +++ b/packages/haring-react-shared/src/types/utility.ts @@ -0,0 +1,3 @@ +export type IOmitRespectIndexSignature = { + [P in keyof T as Exclude]: T[P]; +}; diff --git a/packages/haring-react/src/Components/ActionBar/ActionBar.tsx b/packages/haring-react/src/Components/ActionBar/ActionBar.tsx index 46415105..dd973eaa 100644 --- a/packages/haring-react/src/Components/ActionBar/ActionBar.tsx +++ b/packages/haring-react/src/Components/ActionBar/ActionBar.tsx @@ -21,7 +21,9 @@ export function ActionBar>( `${selectedElements} file(s) selected`, ...actionRowOverflowProps } = props; - const numberOfSelectedElements = selectedElements.length; + const numberOfSelectedElements = Array.isArray(selectedElements) + ? selectedElements.length + : [selectedElements].length; return (
diff --git a/packages/haring-react/src/Components/ActionList/ActionList.stories.tsx b/packages/haring-react/src/Components/ActionList/ActionList.stories.tsx index 14caa59f..70d720d2 100644 --- a/packages/haring-react/src/Components/ActionList/ActionList.stories.tsx +++ b/packages/haring-react/src/Components/ActionList/ActionList.stories.tsx @@ -1,4 +1,4 @@ -import type { IActionListAction } from './ActionList'; +import type { IThumbnail } from '../../types'; import type { Meta, StoryObj } from '@storybook/react'; import { ActionList as Cmp } from './ActionList'; @@ -16,19 +16,17 @@ const meta = { type: { name: 'number' }, }, }, - component: Cmp, + component: Cmp, tags: ['autodocs'], title: '3-custom/Components/ActionList', -} satisfies Meta; +} satisfies Meta>; export default meta; type IStory = StoryObj; export const ActionList: IStory = { args: { - actions: actionRowOverflowActionsMock as IActionListAction< - Record - >[], + actions: actionRowOverflowActionsMock, maxVisibleActions: 2, selectedElements: actionRowOverflowSelectedMock, }, diff --git a/packages/haring-react/src/Components/ActionList/ActionList.tsx b/packages/haring-react/src/Components/ActionList/ActionList.tsx index ed0707f2..80871669 100644 --- a/packages/haring-react/src/Components/ActionList/ActionList.tsx +++ b/packages/haring-react/src/Components/ActionList/ActionList.tsx @@ -28,9 +28,9 @@ const defaultTooltipProps = { withArrow: true, }; -export type IActionListAction> = IAction< - Data[] ->; +export type IActionListAction> = + | IAction + | IAction; export interface IActionListProps> extends GroupProps { @@ -41,7 +41,7 @@ export interface IActionListProps> maxVisibleActions?: number; modalProps?: Omit; overflowMenuLabel?: string; - selectedElements: Data[]; + selectedElements: Data | Data[]; } export function ActionList>( @@ -72,7 +72,7 @@ export function ActionList>( children: action.confirmModalProps?.children, confirmColor: action.confirmModalProps?.confirmColor, confirmLabel: action.confirmModalProps?.confirmLabel, - onConfirm: () => action.onAction?.(selectedElements), + onConfirm: () => action.onAction?.(selectedElements as Data & Data[]), title: action.confirmModalProps?.title, }); } @@ -85,7 +85,7 @@ export function ActionList>( clearConfirmAction(); } - function handleModalButton(onAction?: (item: Data[]) => void): void { + function handleModalButton(onAction?: (item: Data | Data[]) => void): void { onAction?.(selectedElements); handleClose(); } @@ -94,7 +94,7 @@ export function ActionList>( if (action.confirmation) { setModal(action); } else { - action.onAction?.(selectedElements); + action.onAction?.(selectedElements as Data & Data[]); } } @@ -103,7 +103,7 @@ export function ActionList>( return ''; } return typeof action.label === 'function' - ? action.label(selectedElements) + ? action.label(selectedElements as Data & Data[]) : action.label; } @@ -112,7 +112,7 @@ export function ActionList>( return null; } return typeof action.icon === 'function' - ? action.icon(selectedElements) + ? action.icon(selectedElements as Data & Data[]) : action.icon; } @@ -123,7 +123,7 @@ export function ActionList>( return undefined; } return typeof action.componentProps === 'function' - ? action.componentProps(selectedElements) + ? action.componentProps(selectedElements as Data & Data[]) : action.componentProps; } diff --git a/packages/haring-react/src/Form/DynamicZone/DynamicZone.mock.tsx b/packages/haring-react/src/Form/DynamicZone/DynamicZone.mock.tsx new file mode 100644 index 00000000..bf06c977 --- /dev/null +++ b/packages/haring-react/src/Form/DynamicZone/DynamicZone.mock.tsx @@ -0,0 +1,87 @@ +import type { IDynamicZoneBlockReference } from './DynamicZoneBlock/DynamicZoneBlock'; +import type { IBaseBlock, IBaseBlockButton } from '../../types'; +import type { IAction } from '@smile/haring-react-shared'; + +import { + Alien, + ArrowDown, + ArrowUp, + Leaf, + Trash, + TreasureChest, +} from '@phosphor-icons/react'; +import { action } from '@storybook/addon-actions'; + +const dynamicZoneBlockActionsMock: IAction[] = [ + { + color: 'white', + icon: , + id: 'move-up', + label: 'Move Up', + onAction: action('Move block up'), + }, + { + color: 'white', + icon: , + id: 'move-down', + label: 'Move Down', + onAction: action('Move block down'), + }, + { + color: 'white', + icon: , + id: 'delete', + label: 'Delete', + onAction: action('Delete block'), + }, +]; + +export const dynamicZoneBlocks: IBaseBlock[] = [ + { + blockActions: dynamicZoneBlockActionsMock, + blockHeader: ( + <> + + First + + ), + blockType: 'default', + id: '1', + opened: false, + value: 'initial', + }, + { + blockActions: dynamicZoneBlockActionsMock, + blockFooter: 'footer', + blockHeader: ( + <> + + Second + + ), + blockType: 'default', + id: '2', + opened: true, + value: 'initial', + }, + { + blockActions: dynamicZoneBlockActionsMock, + blockFooter: 'footer', + blockHeader: ( + <> + + Third + + ), + blockType: 'default', + id: '3', + opened: false, + value: 'initial', + }, +]; + +export const dynamicZoneButtons: IBaseBlockButton[] = [ + { blockType: 'default', label: 'Default', leftSection: }, + { blockType: 'other', label: 'Other', leftSection: }, + { blockType: 'stuff', label: 'Stuff', leftSection: }, +]; diff --git a/packages/haring-react/src/Form/DynamicZone/DynamicZone.module.css b/packages/haring-react/src/Form/DynamicZone/DynamicZone.module.css new file mode 100644 index 00000000..93edbb47 --- /dev/null +++ b/packages/haring-react/src/Form/DynamicZone/DynamicZone.module.css @@ -0,0 +1,12 @@ +.buttonsContainer { + border-radius: 0.5rem; + border: 1px dashed black; +} + +.buttonsLabel { + &:empty { + display: none; + } + + margin-bottom: 10px; +} diff --git a/packages/haring-react/src/Form/DynamicZone/DynamicZone.stories.tsx b/packages/haring-react/src/Form/DynamicZone/DynamicZone.stories.tsx new file mode 100644 index 00000000..e3e9754d --- /dev/null +++ b/packages/haring-react/src/Form/DynamicZone/DynamicZone.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { action } from '@storybook/addon-actions'; + +import { DynamicZone as Cmp } from './DynamicZone'; +import { dynamicZoneBlocks, dynamicZoneButtons } from './DynamicZone.mock'; + +const meta = { + component: Cmp, + tags: ['autodocs'], + title: '3-custom/Form/DynamicZone', +} satisfies Meta; + +export default meta; +type IStory = StoryObj; + +export const DynamicZone: IStory = { + args: { + blockOptions: dynamicZoneButtons, + blocks: dynamicZoneBlocks, + buttonsText: 'Ajouter un block', + internalBlockCardProps: { + headerCardSectionProps: { + bg: 'cadetblue', + c: 'white', + }, + toggleComponentProps: { + actionIconProps: { color: 'white', variant: 'subtle' }, + }, + }, + onAppendBlock: action('onAppendBlock, id'), + onRenderBlockContent: (_b, index) => , + onToggleBlock: action('onToggleBlock'), + }, +}; diff --git a/packages/haring-react/src/Form/DynamicZone/DynamicZone.test.tsx b/packages/haring-react/src/Form/DynamicZone/DynamicZone.test.tsx new file mode 100644 index 00000000..7f63d724 --- /dev/null +++ b/packages/haring-react/src/Form/DynamicZone/DynamicZone.test.tsx @@ -0,0 +1,27 @@ +import type { IBaseBlock } from '../../types'; +import type { ReactElement } from 'react'; + +import { renderWithProviders } from '@smile/haring-react-shared/test-utils'; +import { action } from '@storybook/addon-actions'; +import { expect } from '@storybook/jest'; + +import { DynamicZone } from './DynamicZone'; +import { dynamicZoneBlocks, dynamicZoneButtons } from './DynamicZone.mock'; + +describe('DynamicZone', () => { + it('matches snapshot', () => { + const onRender = (_b: IBaseBlock, index: number): ReactElement => ( + + ); + const { container } = renderWithProviders( + , + ); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/packages/haring-react/src/Form/DynamicZone/DynamicZone.tsx b/packages/haring-react/src/Form/DynamicZone/DynamicZone.tsx new file mode 100644 index 00000000..db2b754f --- /dev/null +++ b/packages/haring-react/src/Form/DynamicZone/DynamicZone.tsx @@ -0,0 +1,103 @@ +import type { IDynamicZoneBlockInternalComponentProps } from './DynamicZoneBlock/DynamicZoneBlock'; +import type { IBaseBlock, IBaseBlockButton, IBaseBlockType } from '../../types'; +import type { + CardProps, + ContainerProps, + GroupProps, + StackProps, + TextProps, +} from '@mantine/core'; +import type { ReactElement } from 'react'; + +import { Button, Container, Group, Stack, Text } from '@mantine/core'; + +import classes from './DynamicZone.module.css'; +import { DynamicZoneBlock } from './DynamicZoneBlock/DynamicZoneBlock'; + +export interface IDynamicZoneProps + extends ContainerProps { + blockCardProps?: CardProps; + blockOptions: IBaseBlockButton[]; + blocks: Block[]; + blocksStackProps?: StackProps; + bottomContainerProps?: ContainerProps; + buttonsGroupProps?: GroupProps; + buttonsText?: string; + buttonsTextProps?: TextProps; + internalBlockCardProps?: IDynamicZoneBlockInternalComponentProps; + onAppendBlock: (blockType: IBaseBlockType) => void; + onRenderBlockContent: (block: Block, index: number) => ReactElement; + onToggleBlock: (block: Block, index: number, opened: boolean) => void; +} + +export function DynamicZone( + props: IDynamicZoneProps, +): ReactElement { + const { + blockCardProps, + blockOptions, + blocks, + blocksStackProps, + bottomContainerProps, + buttonsGroupProps, + buttonsText, + buttonsTextProps, + internalBlockCardProps, + onAppendBlock, + onRenderBlockContent, + onToggleBlock, + ...rootContainerProps + } = props; + + function onAddBlock(blockType: IBaseBlockType): void { + onAppendBlock(blockType); + } + + return ( + + + {blocks.map((block, index) => ( + onToggleBlock(block, index, opened)} + opened={block.opened} + reference={{ arrayLength: blocks.length, id: block.id, index }} + > + {onRenderBlockContent(block, index)} + + ))} + + + + {buttonsText} + + + {blockOptions.map(({ blockType, ...button }) => ( + + ))} + + + + ); +} diff --git a/packages/haring-react/src/Form/DynamicZone/DynamicZoneBlock/DynamicZoneBlock.module.css b/packages/haring-react/src/Form/DynamicZone/DynamicZoneBlock/DynamicZoneBlock.module.css new file mode 100644 index 00000000..1098a3bf --- /dev/null +++ b/packages/haring-react/src/Form/DynamicZone/DynamicZoneBlock/DynamicZoneBlock.module.css @@ -0,0 +1,4 @@ +.header { + cursor: pointer; + user-select: none; +} diff --git a/packages/haring-react/src/Form/DynamicZone/DynamicZoneBlock/DynamicZoneBlock.tsx b/packages/haring-react/src/Form/DynamicZone/DynamicZoneBlock/DynamicZoneBlock.tsx new file mode 100644 index 00000000..b657b694 --- /dev/null +++ b/packages/haring-react/src/Form/DynamicZone/DynamicZoneBlock/DynamicZoneBlock.tsx @@ -0,0 +1,130 @@ +import type { IActionListProps } from '../../../Components/ActionList/ActionList'; +import type { + ActionIconProps, + CardProps, + CardSectionProps, + CollapseProps, + ContainerProps, + GroupProps, +} from '@mantine/core'; +import type { IAction } from '@smile/haring-react-shared'; +import type { ReactElement, ReactNode } from 'react'; + +import { + ActionIcon, + Card, + Collapse, + Container, + Group, + Space, +} from '@mantine/core'; +import { CaretDown, CaretUp } from '@phosphor-icons/react'; + +import { ActionList } from '../../../Components/ActionList/ActionList'; + +import classes from './DynamicZoneBlock.module.css'; + +export interface IDynamicZoneBlockInternalComponentProps { + contentCollapseProps?: CollapseProps; + contentContainerProps?: ContainerProps; + footerCardSectionProps?: CardSectionProps; + headerActionListProps?: IActionListProps; + headerCardSectionProps?: CardSectionProps; + headerGroupProps?: GroupProps; + toggleComponentProps?: IDynamicZoneBlockToggleProps; +} + +export interface IDynamicZoneBlockToggleProps { + actionIconProps?: ActionIconProps; + downIcon?: ReactNode; + upIcon?: ReactNode; +} + +export interface IDynamicZoneBlockReference extends Record { + arrayLength: number; + id: string; + index: number; +} + +export interface IDynamicZoneBlockProps extends CardProps { + actions?: IAction[]; + children: ReactNode; + footerChildren?: ReactNode; + headerChildren: ReactNode; + internalComponentProps?: IDynamicZoneBlockInternalComponentProps; + onToggle: (opened: boolean) => void; + opened: boolean; + reference: IDynamicZoneBlockReference; +} + +export function DynamicZoneBlock(props: IDynamicZoneBlockProps): ReactElement { + const { + actions, + children, + footerChildren, + headerChildren, + internalComponentProps, + onToggle, + opened, + reference, + ...cardProps + } = props; + const toggleProps: IDynamicZoneBlockToggleProps = { + downIcon: , + upIcon: , + ...internalComponentProps?.toggleComponentProps, + }; + + return ( + + + + + onToggle(!opened)} + > + {opened ? toggleProps.downIcon : toggleProps.upIcon} + + + {headerChildren} + + {actions && actions.length > 0 ? ( + + actions={actions} + isCompactStyle + selectedElements={reference} + {...internalComponentProps?.headerActionListProps} + /> + ) : null} + + + + + {children} + + + + {footerChildren} + + + ); +} diff --git a/packages/haring-react/src/Form/DynamicZone/__snapshots__/DynamicZone.test.tsx.snap b/packages/haring-react/src/Form/DynamicZone/__snapshots__/DynamicZone.test.tsx.snap new file mode 100644 index 00000000..7cc3c59d --- /dev/null +++ b/packages/haring-react/src/Form/DynamicZone/__snapshots__/DynamicZone.test.tsx.snap @@ -0,0 +1,601 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DynamicZone matches snapshot 1`] = ` +
+ + +
+
+
+
+
+
+ +
+ + + + First +
+
+ + + +
+
+
+ +
+
+
+
+
+
+ +
+ + + + Second +
+
+ + + +
+
+
+
+
+ +
+
+
+ footer +
+
+
+
+
+
+ +
+ + + + Third +
+
+ + + +
+
+
+ +
+ footer +
+
+
+
+

+

+ + + +
+
+
+
+`; diff --git a/packages/haring-react/src/index.tsx b/packages/haring-react/src/index.tsx index 7fc10ed0..89c27f84 100644 --- a/packages/haring-react/src/index.tsx +++ b/packages/haring-react/src/index.tsx @@ -10,6 +10,8 @@ export type { IFetchAutocompleteFieldProps } from './Form/FetchAutocompleteField export { FetchAutocompleteField } from './Form/FetchAutocompleteField/FetchAutocompleteField'; export type { IAddressAutocompleteFieldProps } from './Form/AddressGouvAutocompleteField/AddressGouvAutocompleteField'; export { AddressGouvAutocompleteField } from './Form/AddressGouvAutocompleteField/AddressGouvAutocompleteField'; +export type { IDynamicZoneProps } from './Form/DynamicZone/DynamicZone'; +export { DynamicZone } from './Form/DynamicZone/DynamicZone'; export type { IAddressFieldsProps } from './Form/AddressFields/AddressFields'; export { AddressFields } from './Form/AddressFields/AddressFields'; export type { IIconCardProps } from './Components/IconCard/IconCard'; @@ -124,4 +126,11 @@ export { setChildrenToTree, } from './helpers'; // type exports -export type { IThumbnail, IThumbnailAction, IThumbnailData } from './types'; +export type { + IThumbnail, + IThumbnailAction, + IThumbnailData, + IBaseBlock, + IBaseBlockButton, + IBaseBlockType, +} from './types'; diff --git a/packages/haring-react/src/types/dynamic-zone.ts b/packages/haring-react/src/types/dynamic-zone.ts new file mode 100644 index 00000000..479dcb43 --- /dev/null +++ b/packages/haring-react/src/types/dynamic-zone.ts @@ -0,0 +1,20 @@ +import type { IDynamicZoneBlockReference } from '../Form/DynamicZone/DynamicZoneBlock/DynamicZoneBlock'; +import type { ButtonProps } from '@mantine/core'; +import type { IAction } from '@smile/haring-react-shared'; +import type { ReactNode } from 'react'; + +export type IBaseBlockType = string; + +export interface IBaseBlock extends Record { + blockActions?: IAction[]; + blockFooter?: ReactNode; + blockHeader: ReactNode; + blockType: IBaseBlockType; + readonly id: string; + opened: boolean; +} + +export interface IBaseBlockButton extends ButtonProps { + blockType: IBaseBlockType; + label: string; +} diff --git a/packages/haring-react/src/types/index.ts b/packages/haring-react/src/types/index.ts index d4ab7a50..ae5dbd6b 100644 --- a/packages/haring-react/src/types/index.ts +++ b/packages/haring-react/src/types/index.ts @@ -1 +1,2 @@ export * from './thumbnail'; +export * from './dynamic-zone';