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(dashboard): Nv 4800 implement arrays and nested forms in custom step controls #7127

Open
wants to merge 10 commits into
base: next
Choose a base branch
from
Open
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
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for the Array went with the UI similar to the objects instead of using the tag-input as it will make it complex to handle different scenarios and also doesn't play well with the rjsf form

Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { ArrayFieldTemplateItemType } from '@rjsf/utils';
import { cn } from '@/utils/ui';
import { RemoveButton } from './button-templates';

export const ArrayFieldItemTemplate = (props: ArrayFieldTemplateItemType) => {
const isChildObjectType = props.schema.type === 'object';

return (
<div className="relative flex items-center gap-2 *:flex-1">
{props.children}
<div
className={cn(
'bg-background absolute right-0 top-0 z-10 mt-2 flex w-5 -translate-y-1/2 items-center justify-end',
{ 'right-4 justify-start': isChildObjectType }
)}
>
{props.hasRemove && (
<RemoveButton
disabled={props.disabled || props.readonly}
onClick={(e) => props.onDropIndexClick(props.index)(e)}
registry={props.registry}
/>
)}
</div>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { CollapsibleContent, CollapsibleTrigger } from '@/components/primitives/collapsible';
import { Collapsible } from '@radix-ui/react-collapsible';
import { ArrayFieldTemplateProps, getTemplate, getUiOptions } from '@rjsf/utils';
import { useMemo, useState } from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { RiExpandUpDownLine } from 'react-icons/ri';
import { getFieldName } from './template-utils';

export function ArrayFieldTemplate(props: ArrayFieldTemplateProps) {
const { disabled, idSchema, uiSchema, items, onAddClick, readonly, registry, required, title, schema, canAdd } =
props;
const {
ButtonTemplates: { AddButton },
} = registry.templates;
const uiOptions = useMemo(() => getUiOptions(uiSchema), [uiSchema]);
const ArrayFieldTitleTemplate = useMemo(
() => getTemplate('ArrayFieldTitleTemplate', registry, uiOptions),
[registry, uiOptions]
);
const ArrayFieldItemTemplate = useMemo(
() => getTemplate('ArrayFieldItemTemplate', registry, uiOptions),
[registry, uiOptions]
);

const [isEditorOpen, setIsEditorOpen] = useState(true);

const handleAddClick = () => {
if (!isEditorOpen) {
setIsEditorOpen(true);
}
onAddClick();
/**
* If the array field has a default value, append it to the array
*/
const defaultValue = schema.default ?? undefined;
const value = Array.isArray(defaultValue) ? defaultValue[0] : defaultValue;
append(value);
};

const { control } = useFormContext();
const extractedName = useMemo(() => getFieldName(idSchema.$id), [idSchema.$id]);

const { append, remove } = useFieldArray({
control,
name: extractedName,
});

return (
<Collapsible
open={isEditorOpen}
onOpenChange={setIsEditorOpen}
className="bg-background border-neutral-alpha-200 relative mt-2 flex w-full flex-col gap-2 rounded-lg border px-3 py-4 data-[state=closed]:rounded-none data-[state=closed]:border-b-0 data-[state=closed]:border-l-0 data-[state=closed]:border-r-0 data-[state=closed]:border-t data-[state=closed]:pb-0"
>
<div className="absolute left-0 top-0 z-10 flex w-full -translate-y-1/2 items-center justify-between p-0 px-2 text-sm">
<div className="flex w-full items-center gap-1">
<span className="bg-background px-1">
<ArrayFieldTitleTemplate
idSchema={idSchema}
title={uiOptions.title || title}
schema={schema}
uiSchema={uiSchema}
required={required}
registry={registry}
/>
</span>
<div className="bg-background text-foreground-600 -mt-px ml-auto mr-4 flex items-center gap-1">
Copy link
Contributor

@rifont rifont Nov 27, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we add a hover state to the collapsible button to be consistent with the add/minus button hover states?

It's generally a good UX practice for all buttons to have a hover state to provide feedback to the user that they are interacting with something (in addition to varying the pointer)

{canAdd && <AddButton onClick={handleAddClick} disabled={disabled || readonly} registry={registry} />}
<CollapsibleTrigger>
<RiExpandUpDownLine className="text-foreground-600 size-3" />
</CollapsibleTrigger>
</div>
</div>
</div>

<CollapsibleContent className="flex flex-col gap-3">
{items.map(({ key, onDropIndexClick, ...itemProps }) => {
return (
<ArrayFieldItemTemplate
key={key}
{...itemProps}
onDropIndexClick={(index) => {
remove(index);
return onDropIndexClick(index);
}}
/>
);
})}
</CollapsibleContent>
</Collapsible>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { ArrayFieldTitleProps } from '@rjsf/utils';

export const ArrayFieldTitleTemplate = (props: ArrayFieldTitleProps) => {
return <legend className="text-foreground-400 px-1 text-xs">{props.title}</legend>;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { RiAddLine, RiSubtractFill } from 'react-icons/ri';
import { IconButtonProps } from '@rjsf/utils';
import { Button } from '@/components/primitives/button';

export const AddButton = (props: IconButtonProps) => {
return (
<Button variant="ghost" className="size-4 rounded-sm p-0.5" type="button" {...props} title="Add item">
<RiAddLine className="text-foreground-600 size-3" />
</Button>
);
};

export const RemoveButton = (props: IconButtonProps) => {
return (
<Button variant="ghost" className="size-4 rounded-sm p-0.5" type="button" {...props} title="Remove item">
<RiSubtractFill className="text-foreground-600 size-3" />
</Button>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ import { WorkflowOriginEnum } from '@/utils/enums';
export function CustomStepControls({
dataSchema,
origin,
formData,
}: {
dataSchema: ControlsMetadata['dataSchema'];
origin: WorkflowOriginEnum;
formData: Record<string, unknown>;
}) {
const [isEditorOpen, setIsEditorOpen] = useState(true);

Expand Down Expand Up @@ -39,8 +41,8 @@ export function CustomStepControls({
</CollapsibleTrigger>

<CollapsibleContent>
<div className="bg-background rounded-md border border-dashed px-3 py-0">
<JsonForm schema={(dataSchema as RJSFSchema) || {}} variables={[]} />
<div className="bg-background rounded-md border border-dashed p-3">
<JsonForm schema={(dataSchema as RJSFSchema) || {}} formData={formData} />
</div>
</CollapsibleContent>
</Collapsible>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,29 +1,11 @@
import Form, { FormProps } from '@rjsf/core';
import { RegistryWidgetsType, UiSchema } from '@rjsf/utils';
import validator from '@rjsf/validator-ajv8';
import { TextWidget } from './text-widget';
import { SwitchWidget } from './switch-widget';
import { SelectWidget } from './select-widget';

export const JSON_SCHEMA_FORM_ID_DELIMITER = '~~~';

const UI_SCHEMA: UiSchema = {
'ui:globalOptions': { addable: true, copyable: false, label: true, orderable: true },
'ui:options': {
hideError: true,
submitButtonOptions: {
norender: true,
},
},
};

const WIDGETS: RegistryWidgetsType = {
TextWidget: TextWidget,
URLWidget: TextWidget,
EmailWidget: TextWidget,
CheckboxWidget: SwitchWidget,
SelectWidget: SelectWidget,
};
import { ArrayFieldItemTemplate } from './array-field-item-template';
import { ArrayFieldTemplate } from './array-field-template';
import { ArrayFieldTitleTemplate } from './array-field-title-template';
import { AddButton, RemoveButton } from './button-templates';
import { ObjectFieldTemplate } from './object-field-template';
import { JSON_SCHEMA_FORM_ID_DELIMITER, UI_SCHEMA, WIDGETS } from './template-utils';

type JsonFormProps<TFormData = unknown> = Pick<
FormProps<TFormData>,
Expand All @@ -36,20 +18,23 @@ export function JsonForm(props: JsonFormProps) {
return (
<Form
tagName={'fieldset'}
className="[&_.control-label]:hidden [&_.field-decription]:hidden [&_.panel.panel-danger.errors]:hidden"
className="*:flex *:flex-col *:gap-3 [&_.control-label]:hidden [&_.field-decription]:hidden [&_.panel.panel-danger.errors]:hidden"
uiSchema={UI_SCHEMA}
widgets={WIDGETS}
validator={validator}
autoComplete={'false'}
/**
* TODO: Add support for variables
*/
formContext={{ variables: [] }}
autoComplete="false"
idSeparator={JSON_SCHEMA_FORM_ID_DELIMITER}
templates={{
ButtonTemplates: {
AddButton,
RemoveButton,
},
ArrayFieldTemplate,
ArrayFieldItemTemplate,
ArrayFieldTitleTemplate,
ObjectFieldTemplate,
}}
{...props}
/**
* TODO: Add support for Arrays and Nested Objects
*/
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { useMemo, useState } from 'react';
import { RiExpandUpDownLine } from 'react-icons/ri';
import { useFormContext } from 'react-hook-form';
import { getTemplate, getUiOptions, ObjectFieldTemplateProps } from '@rjsf/utils';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/primitives/collapsible';
import { getFieldName, ROOT_DELIMITER } from './template-utils';
import { FormControl, FormField, FormItem, FormMessage } from '@/components/primitives/form/form';

export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
const { idSchema, uiSchema, registry, required, title, schema, properties } = props;

const uiOptions = getUiOptions(uiSchema);

const ArrayFieldTitleTemplate = getTemplate('ArrayFieldTitleTemplate', registry, uiOptions);

const [isEditorOpen, setIsEditorOpen] = useState(true);

const sectionTitle = uiOptions.title || title;

const { control } = useFormContext();
const extractedName = useMemo(() => getFieldName(idSchema.$id) + '.' + ROOT_DELIMITER, [idSchema.$id]);

if (!sectionTitle) {
return properties.map((element) => {
return <div key={element.name}>{element.content}</div>;
});
}

return (
<FormField
control={control}
name={extractedName}
render={() => (
<FormItem>
<FormControl>
<Collapsible
open={isEditorOpen}
onOpenChange={setIsEditorOpen}
className="bg-background border-neutral-alpha-200 relative mt-2 flex w-full flex-col gap-2 border-t px-3 py-4 pb-0"
>
<div className="absolute left-0 top-0 z-10 flex w-full -translate-y-1/2 items-center justify-between p-0 text-sm">
<div className="-mt-px flex w-full items-center gap-1">
<span className="bg-background ml-3 px-1">
<ArrayFieldTitleTemplate
idSchema={idSchema}
title={sectionTitle}
schema={schema}
uiSchema={uiSchema}
required={required}
registry={registry}
/>
</span>
<div className="bg-background text-foreground-600 ml-auto flex items-center gap-1">
<CollapsibleTrigger
className="flex size-4 items-center justify-center rounded-sm p-0.5"
title="Collapse section"
>
<RiExpandUpDownLine className="text-foreground-600 size-3" />
</CollapsibleTrigger>
</div>
</div>
</div>

<CollapsibleContent className="flex flex-col gap-3">
{properties.map((element) => {
return (
<div key={element.name} className="ml-1">
{element.content}
</div>
);
})}
</CollapsibleContent>
</Collapsible>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
}
Original file line number Diff line number Diff line change
@@ -1,30 +1,42 @@
import { useMemo } from 'react';
import { type WidgetProps } from '@rjsf/utils';
import { useFormContext } from 'react-hook-form';
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/primitives/form/form';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/primitives/select';
import { capitalize } from '@/utils/string';
import { getFieldName } from './template-utils';

export function SelectWidget(props: WidgetProps) {
const { label, required, readonly, options, name } = props;
const { label, required, readonly, options, disabled, id } = props;

const data = options.enumOptions?.map((option) => {
return {
label: option.label,
value: String(option.value),
};
});
const data = useMemo(
() =>
options.enumOptions?.map((option) => {
return {
label: option.label,
value: String(option.value),
};
}),
[options.enumOptions]
);
const extractedName = useMemo(() => getFieldName(id), [id]);

const { control } = useFormContext();

return (
<FormField
control={control}
name={name}
name={extractedName}
render={({ field }) => (
<FormItem className="my-2 py-1">
<FormItem className="py-1">
<FormLabel>{capitalize(label)}</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange} disabled={readonly} required={required}>
<Select
value={field.value}
onValueChange={field.onChange}
disabled={disabled || readonly}
required={required}
>
<SelectTrigger className="group p-1.5 shadow-sm last:[&>svg]:hidden">
<SelectValue asChild>
<div className="flex items-center gap-2">
Expand Down
Loading
Loading