Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/form dynamic zone #216

Merged
merged 10 commits into from
Sep 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading