Skip to content

Commit

Permalink
Feat/form dynamic zone (#216)
Browse files Browse the repository at this point in the history
* refactor: modified DynamicZone, FormDynamicZone, types/utilities

* feat: example ReactHookFormDynamicZone

* feat: added option for maximum number of blocks
  • Loading branch information
QuentinLeCaignec committed Sep 26, 2024
1 parent f4ea469 commit d0db54e
Show file tree
Hide file tree
Showing 24 changed files with 2,478 additions and 1,073 deletions.
7 changes: 7 additions & 0 deletions .changeset/chatty-flowers-drop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@smile/haring-react-shared': minor
'storybook-pages': minor
'@smile/haring-react': minor
---

Refactored DynamicZone and FormDynamicZone to be more configurable, remove state and react-hook-form dependency and make them neutral, fixed errors and added a few features, added example page ReactHookFormDynamicZone to implement FormDynamicZone example with react-hook-form, added/reworked types and utilities
2,000 changes: 1,009 additions & 991 deletions package-lock.json

Large diffs are not rendered by default.

27 changes: 27 additions & 0 deletions packages/haring-react-shared/src/helpers/utilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,30 @@ export function isNotNullNorEmpty<S>(
export function isCallback<T, U>(maybeFunc: T | U): maybeFunc is T {
return typeof maybeFunc === 'function';
}

export function isObject(value: unknown): value is object {
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
}

export function findNestedObject(
object: object,
keyToMatch: string,
valueToMatch: string,
): object | null {
if (isObject(object)) {
const entries = Object.entries(object);
for (const element of entries) {
const [objectKey, objectValue] = element;
if (objectKey === keyToMatch && objectValue && valueToMatch) {
return object;
}
if (isObject(objectValue)) {
const child = findNestedObject(objectValue, keyToMatch, valueToMatch);
if (child !== null) {
return child;
}
}
}
}
return null;
}
2 changes: 2 additions & 0 deletions packages/haring-react-shared/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export {
createThemes,
isCallback,
isNotNullNorEmpty,
isObject,
findNestedObject,
typeGuard,
typeGuardInterface,
} from './helpers';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import type {
ActionIconProps,
ButtonProps,
FloatingPosition,
GroupProps,
Expand Down Expand Up @@ -34,7 +35,8 @@ export type IActionListAction<Data extends Record<string, unknown>> =

export interface IActionListProps<Data extends Record<string, unknown>>
extends GroupProps {
actionButtonProps?: ButtonProps;
actionButtonDefaultProps?: ButtonProps;
actionIconDefaultProps?: ActionIconProps;
actionTooltipProps?: TooltipProps;
actions: IActionListAction<Data>[];
isCompactStyle?: boolean;
Expand All @@ -48,7 +50,8 @@ export function ActionList<Data extends Record<string, unknown>>(
props: IActionListProps<Data>,
): ReactNode {
const {
actionButtonProps,
actionButtonDefaultProps,
actionIconDefaultProps,
actionTooltipProps,
actions,
isCompactStyle = false,
Expand Down Expand Up @@ -140,6 +143,7 @@ export function ActionList<Data extends Record<string, unknown>>(
leftSection={getActionIcon(action)}
onClick={() => handleAction(action)}
variant={action.color ? 'filled' : 'default'}
{...actionButtonDefaultProps}
{...getActionComponentProps(action)}
>
{getActionLabel(action)}
Expand All @@ -159,6 +163,7 @@ export function ActionList<Data extends Record<string, unknown>>(
radius={4}
type="button"
variant="subtle"
{...actionIconDefaultProps}
{...getActionComponentProps(action)}
>
{getActionIcon(action)}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { IDynamicZoneBlockReference } from './DynamicZoneBlock/DynamicZoneBlock';
import type { IBaseBlock, IBaseBlockButton } from '../../types';
import type { IBaseBlockButtonOptions, IBaseBlockFull } from '../../types';
import type { IAction } from '@smile/haring-react-shared';

import {
Expand All @@ -14,29 +14,26 @@ import { action } from '@storybook/addon-actions';

const dynamicZoneBlockActionsMock: IAction<IDynamicZoneBlockReference>[] = [
{
color: 'white',
icon: <ArrowUp size={16} />,
id: 'move-up',
label: 'Move Up',
onAction: action('Move block up'),
},
{
color: 'white',
icon: <ArrowDown size={16} />,
id: 'move-down',
label: 'Move Down',
onAction: action('Move block down'),
},
{
color: 'white',
icon: <Trash size={16} />,
id: 'delete',
label: 'Delete',
onAction: action('Delete block'),
},
];

export const dynamicZoneBlocks: IBaseBlock[] = [
export const dynamicZoneBlocks: IBaseBlockFull[] = [
{
blockActions: dynamicZoneBlockActionsMock,
blockHeader: (
Expand Down Expand Up @@ -80,7 +77,7 @@ export const dynamicZoneBlocks: IBaseBlock[] = [
},
];

export const dynamicZoneButtons: IBaseBlockButton[] = [
export const dynamicZoneButtons: IBaseBlockButtonOptions[] = [
{ blockType: 'default', label: 'Default', leftSection: <Alien /> },
{ blockType: 'other', label: 'Other', leftSection: <Leaf /> },
{ blockType: 'stuff', label: 'Stuff', leftSection: <TreasureChest /> },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,11 @@

margin-bottom: 10px;
}

.button {
&:disabled,
&[data-disabled] {
border-color: var(--mantine-color-gray-4);
background-color: transparent;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,22 @@ export const DynamicZone: IStory = {
blockOptions: dynamicZoneButtons,
blocks: dynamicZoneBlocks,
buttonsText: 'Ajouter un block',
internalBlockCardProps: {
onAppendBlock: action('onAppendBlock, id'),
onRenderBlockContent: (_b, index) => <input key={index} />,
onToggleBlock: action('onToggleBlock'),
},
};

export const CustomInternalProps: IStory = {
args: {
blockOptions: dynamicZoneButtons,
blocks: dynamicZoneBlocks,
internalBlockComponentProps: {
headerActionListProps: {
actionIconDefaultProps: {
color: 'white',
},
},
headerCardSectionProps: {
bg: 'cadetblue',
c: 'white',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { IBaseBlock } from '../../types';
import type { IBaseBlockCardOptions } from '../../types';
import type { ReactElement } from 'react';

import { renderWithProviders } from '@smile/haring-react-shared/test-utils';
Expand All @@ -10,9 +10,10 @@ import { dynamicZoneBlocks, dynamicZoneButtons } from './DynamicZone.mock';

describe('DynamicZone', () => {
it('matches snapshot', () => {
const onRender = (_b: IBaseBlock, index: number): ReactElement => (
<input key={index} />
);
const onRender = (
_b: IBaseBlockCardOptions,
index: number,
): ReactElement => <input key={index} />;
const { container } = renderWithProviders(
<DynamicZone
blockOptions={dynamicZoneButtons}
Expand Down
121 changes: 79 additions & 42 deletions packages/haring-react/src/Form/DynamicZone/DynamicZone.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type { IDynamicZoneBlockInternalComponentProps } from './DynamicZoneBlock/DynamicZoneBlock';
import type { IBaseBlock, IBaseBlockButton, IBaseBlockType } from '../../types';
import type {
IBaseBlockButtonOptions,
IBaseBlockFull,
IBaseBlockType,
} from '../../types';
import type {
CardProps,
ContainerProps,
Expand All @@ -9,40 +13,41 @@ import type {
} from '@mantine/core';
import type { ReactElement } from 'react';

import { Button, Container, Group, Stack, Text } from '@mantine/core';
import { Button, Container, Group, Stack, Text, Tooltip } from '@mantine/core';

import classes from './DynamicZone.module.css';
import { DynamicZoneBlock } from './DynamicZoneBlock/DynamicZoneBlock';

export interface IDynamicZoneProps<Block extends IBaseBlock>
extends ContainerProps {
export interface IDynamicZoneInternalComponentProps {
blockCardProps?: CardProps;
blockOptions: IBaseBlockButton[];
blocks: Block[];
blocksStackProps?: StackProps;
bottomContainerProps?: ContainerProps;
buttonsGroupProps?: GroupProps;
buttonsText?: string;
buttonsTextProps?: TextProps;
internalBlockCardProps?: IDynamicZoneBlockInternalComponentProps;
}

export interface IDynamicZoneProps extends ContainerProps {
blockOptions: IBaseBlockButtonOptions[];
blocks: IBaseBlockFull[];
buttonsText?: string;
internalBlockComponentProps?: IDynamicZoneBlockInternalComponentProps;
internalComponentProps?: IDynamicZoneInternalComponentProps;
onAppendBlock: (blockType: IBaseBlockType) => void;
onRenderBlockContent: (block: Block, index: number) => ReactElement;
onToggleBlock: (block: Block, index: number, opened: boolean) => void;
onRenderBlockContent: (block: IBaseBlockFull, index: number) => ReactElement;
onToggleBlock: (
block: IBaseBlockFull,
index: number,
opened: boolean,
) => void;
}

export function DynamicZone<Block extends IBaseBlock>(
props: IDynamicZoneProps<Block>,
): ReactElement {
export function DynamicZone(props: IDynamicZoneProps): ReactElement {
const {
blockCardProps,
blockOptions,
blocks,
blocksStackProps,
bottomContainerProps,
buttonsGroupProps,
buttonsText,
buttonsTextProps,
internalBlockCardProps,
internalComponentProps,
internalBlockComponentProps,
onAppendBlock,
onRenderBlockContent,
onToggleBlock,
Expand All @@ -55,15 +60,24 @@ export function DynamicZone<Block extends IBaseBlock>(

return (
<Container fluid p={0} {...rootContainerProps}>
<Stack gap="sm" {...blocksStackProps}>
<Stack gap="sm" {...internalComponentProps?.blocksStackProps}>
{blocks.map((block, index) => (
<DynamicZoneBlock
{...blockCardProps}
{...internalComponentProps?.blockCardProps}
{...block.blockCardProps}
key={block.id}
actions={block.blockActions}
footerChildren={block.blockFooter}
headerChildren={block.blockHeader}
internalComponentProps={internalBlockCardProps}
footerChildren={
typeof block.blockFooter === 'function'
? block.blockFooter(block, index)
: block.blockFooter
}
headerChildren={
typeof block.blockHeader === 'function'
? block.blockHeader(block, index)
: block.blockHeader
}
internalComponentProps={internalBlockComponentProps}
onToggle={(opened) => onToggleBlock(block, index, opened)}
opened={block.opened}
reference={{ arrayLength: blocks.length, id: block.id, index }}
Expand All @@ -77,25 +91,48 @@ export function DynamicZone<Block extends IBaseBlock>(
fluid
mt="lg"
p="sm"
{...bottomContainerProps}
{...internalComponentProps?.bottomContainerProps}
>
<Text className={classes.buttonsLabel} fw="bold" {...buttonsTextProps}>
{buttonsText}
</Text>
<Group {...buttonsGroupProps}>
{blockOptions.map(({ blockType, ...button }) => (
<Button
radius="md"
size="md"
type="button"
variant="default"
{...button}
key={`button-${blockType}`}
onClick={() => onAddBlock(blockType)}
>
{button.label}
</Button>
))}
{Boolean(buttonsText) && (
<Text
className={classes.buttonsLabel}
fw="bold"
{...internalComponentProps?.buttonsTextProps}
>
{buttonsText}
</Text>
)}
<Group {...internalComponentProps?.buttonsGroupProps}>
{blockOptions.map(
({ blockType, label, tooltipLabel, tooltipProps, ...button }) => (
<Tooltip
key={`button-${blockType}`}
disabled={
tooltipLabel === '' ||
tooltipLabel === undefined ||
(typeof tooltipLabel === 'function' &&
tooltipLabel(button) === '')
}
label={
typeof tooltipLabel === 'function' && tooltipLabel(button)
}
{...tooltipProps}
>
<Button
className={classes.button}
radius="md"
size="md"
type="button"
variant="default"
{...button}
onClick={() => onAddBlock(blockType)}
>
{(typeof label === 'function' && label(button)) ||
(typeof label === 'string' && label)}
</Button>
</Tooltip>
),
)}
</Group>
</Container>
</Container>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ export interface IDynamicZoneBlockInternalComponentProps {
contentCollapseProps?: CollapseProps;
contentContainerProps?: ContainerProps;
footerCardSectionProps?: CardSectionProps;
headerActionListProps?: IActionListProps<IDynamicZoneBlockReference>;
headerActionListProps?: Omit<
IActionListProps<IDynamicZoneBlockReference>,
'actions' | 'isCompactStyle' | 'selectedElements'
>;
headerCardSectionProps?: CardSectionProps;
headerGroupProps?: GroupProps;
toggleComponentProps?: IDynamicZoneBlockToggleProps;
Expand All @@ -50,7 +53,7 @@ export interface IDynamicZoneBlockProps extends CardProps {
actions?: IAction<IDynamicZoneBlockReference>[];
children: ReactNode;
footerChildren?: ReactNode;
headerChildren: ReactNode;
headerChildren?: ReactNode;
internalComponentProps?: IDynamicZoneBlockInternalComponentProps;
onToggle: (opened: boolean) => void;
opened: boolean;
Expand Down
Loading

0 comments on commit d0db54e

Please sign in to comment.