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 4516 in app step editor custom step controls form #6900

Merged
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
3 changes: 3 additions & 0 deletions apps/dashboard/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "@novu/dashboard",

Check warning on line 2 in apps/dashboard/package.json

View workflow job for this annotation

GitHub Actions / Spell check

Unknown word (novu)
"private": true,
"version": "0.0.0",
"type": "module",
Expand All @@ -8,14 +8,14 @@
"start:static:build": "pm2 start proxy-server.js",
"dev": "pnpm start",
"build": "pnpm build:legacy && tsc -b && vite build",
"build:legacy": "rimraf legacy/* && pnpm nx build:web:for-dashboard @novu/web",

Check warning on line 11 in apps/dashboard/package.json

View workflow job for this annotation

GitHub Actions / Spell check

Unknown word (novu)
"lint": "eslint .",
"preview": "vite preview",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:debug": "playwright test --debug",
"test:e2e:install": "playwright install --with-deps",
"test:e2e:codegen": "playwright codegen",

Check warning on line 18 in apps/dashboard/package.json

View workflow job for this annotation

GitHub Actions / Spell check

Unknown word (codegen)

Check warning on line 18 in apps/dashboard/package.json

View workflow job for this annotation

GitHub Actions / Spell check

Unknown word (codegen)
"test:e2e:show-report": "npx playwright show-report",
"test:e2e:merge-report": "playwright merge-reports --reporter html"
},
Expand All @@ -23,9 +23,9 @@
"@clerk/clerk-react": "^5.2.5",
"@codemirror/lang-liquid": "^6.2.1",
"@hookform/resolvers": "^3.9.0",
"@lezer/highlight": "^1.2.1",

Check warning on line 26 in apps/dashboard/package.json

View workflow job for this annotation

GitHub Actions / Spell check

Unknown word (lezer)
"@novu/react": "workspace:*",

Check warning on line 27 in apps/dashboard/package.json

View workflow job for this annotation

GitHub Actions / Spell check

Unknown word (novu)
"@novu/shared": "workspace:*",

Check warning on line 28 in apps/dashboard/package.json

View workflow job for this annotation

GitHub Actions / Spell check

Unknown word (novu)
"@radix-ui/react-accordion": "^1.2.1",
"@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-avatar": "^1.1.1",
Expand All @@ -43,6 +43,9 @@
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.3",
"@rjsf/core": "^5.22.3",

Check warning on line 46 in apps/dashboard/package.json

View workflow job for this annotation

GitHub Actions / Spell check

Unknown word (rjsf)
"@rjsf/utils": "^5.20.0",

Check warning on line 47 in apps/dashboard/package.json

View workflow job for this annotation

GitHub Actions / Spell check

Unknown word (rjsf)
"@rjsf/validator-ajv8": "^5.17.1",

Check warning on line 48 in apps/dashboard/package.json

View workflow job for this annotation

GitHub Actions / Spell check

Unknown word (rjsf)
"@segment/analytics-next": "^1.73.0",
"@sentry/react": "^8.35.0",
"@tanstack/react-query": "^5.59.6",
Expand Down
8 changes: 4 additions & 4 deletions apps/dashboard/src/components/workflow-editor/nodes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export const EmailNode = ({ data }: NodeProps<NodeType>) => {
const Icon = STEP_TYPE_TO_ICON[StepTypeEnum.EMAIL];

return (
<Link to={`step/${data.stepSlug}`}>
<Link to={buildRoute(ROUTES.CONFIGURE_STEP, { stepSlug: data.stepSlug ?? '' })}>
<StepNode data={data}>
<NodeHeader type={StepTypeEnum.EMAIL}>
<NodeIcon variant={STEP_TYPE_TO_COLOR[StepTypeEnum.EMAIL]}>
Expand All @@ -79,7 +79,7 @@ export const SmsNode = (props: NodeProps<NodeType>) => {
const Icon = STEP_TYPE_TO_ICON[StepTypeEnum.SMS];

return (
<Link to={`step/${data.stepSlug}`}>
<Link to={buildRoute(ROUTES.CONFIGURE_STEP, { stepSlug: data.stepSlug ?? '' })}>
<StepNode data={data}>
<NodeHeader type={StepTypeEnum.SMS}>
<NodeIcon variant={STEP_TYPE_TO_COLOR[StepTypeEnum.SMS]}>
Expand Down Expand Up @@ -123,7 +123,7 @@ export const PushNode = (props: NodeProps<NodeType>) => {
const Icon = STEP_TYPE_TO_ICON[StepTypeEnum.PUSH];

return (
<Link to={`step/${data.stepSlug}`}>
<Link to={buildRoute(ROUTES.CONFIGURE_STEP, { stepSlug: data.stepSlug ?? '' })}>
<StepNode data={data}>
<NodeHeader type={StepTypeEnum.PUSH}>
<NodeIcon variant={STEP_TYPE_TO_COLOR[StepTypeEnum.PUSH]}>
Expand All @@ -145,7 +145,7 @@ export const ChatNode = (props: NodeProps<NodeType>) => {
const Icon = STEP_TYPE_TO_ICON[StepTypeEnum.CHAT];

return (
<Link to={`step/${data.stepSlug}`}>
<Link to={buildRoute(ROUTES.CONFIGURE_STEP, { stepSlug: data.stepSlug ?? '' })}>
<StepNode data={data}>
<NodeHeader type={StepTypeEnum.CHAT}>
<NodeIcon variant={STEP_TYPE_TO_COLOR[StepTypeEnum.CHAT]}>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { useState } from 'react';
import { RJSFSchema } from '@rjsf/utils';
import { RiArrowDownSLine, RiArrowUpSLine, RiInputField } from 'react-icons/ri';
import { type ControlsMetadata } from '@novu/shared';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/primitives/collapsible';
import { JsonForm } from './json-form';

export function CustomStepControls({ dataSchema }: { dataSchema: ControlsMetadata['dataSchema'] }) {
const [isEditorOpen, setIsEditorOpen] = useState(true);

if (!dataSchema) {
return null;
}

return (
<Collapsible
open={isEditorOpen}
onOpenChange={setIsEditorOpen}
className="bg-neutral-alpha-50 border-neutral-alpha-200 flex w-full flex-col gap-2 rounded-lg border p-2"
>
<CollapsibleTrigger className="flex w-full items-center justify-between text-sm">
<div className="flex items-center gap-1">
<RiInputField className="text-feature size-5" />
<span className="text-sm font-medium">Custom steps controls</span>
</div>

{isEditorOpen ? (
<RiArrowUpSLine className="text-neutral-alpha-400 size-5" />
) : (
<RiArrowDownSLine className="text-neutral-alpha-400 size-5" />
)}
</CollapsibleTrigger>

<CollapsibleContent>
<div className="bg-background rounded-md border border-dashed px-3 py-0">
<JsonForm schema={(dataSchema as RJSFSchema) || {}} variables={[]} />
</div>
</CollapsibleContent>
</Collapsible>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
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,
};

type JsonFormProps<TFormData = unknown> = Pick<
FormProps<TFormData>,
'onChange' | 'onSubmit' | 'onBlur' | 'schema' | 'formData' | 'tagName' | 'onError'
> & {
variables?: string[];
};

export function JsonForm(props: JsonFormProps) {
return (
<Form
tagName={'fieldset'}
className="[&_.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: [] }}
idSeparator={JSON_SCHEMA_FORM_ID_DELIMITER}
{...props}
/**
* TODO: Add support for Arrays and Nested Objects
*/
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
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';

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

const data = options.enumOptions?.map((option) => {
return {
label: option.label,
value: String(option.value),
};
});

const { control } = useFormContext();

return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem className="my-2 py-1">
<FormLabel>{capitalize(label)}</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange} disabled={readonly} required={required}>
<SelectTrigger className="group p-1.5 shadow-sm last:[&>svg]:hidden">
<SelectValue asChild>
<div className="flex items-center gap-2">
<span className="text-foreground text-sm">{field.value}</span>
</div>
</SelectValue>
</SelectTrigger>
<SelectContent>
{data?.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { type WidgetProps } from '@rjsf/utils';
import { useFormContext } from 'react-hook-form';
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/primitives/form/form';
import { Switch } from '@/components/primitives/switch';
import { capitalize } from '@/utils/string';

export function SwitchWidget(props: WidgetProps) {
const { label, readonly, name } = props;

const { control } = useFormContext();

return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem>
<div className="my-2 flex w-full items-center justify-between space-y-0 py-1">
<FormLabel className="cursor-pointer">{capitalize(label)}</FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} disabled={readonly} />
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { type WidgetProps } from '@rjsf/utils';
import { EditorView } from '@uiw/react-codemirror';
import { liquid } from '@codemirror/lang-liquid';
import { useFormContext } from 'react-hook-form';
import { Editor } from '@/components/primitives/editor';
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/primitives/form/form';
import { InputField } from '@/components/primitives/input';
import { capitalize } from '@/utils/string';

export function TextWidget(props: WidgetProps) {
const { label, readonly, name } = props;

const {
control,
formState: { errors },
} = useFormContext();

return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem className="my-2 w-full py-1">
<FormLabel>{capitalize(label)}</FormLabel>
<FormControl>
<InputField size="md" className="px-1" state={errors[name] ? 'error' : 'default'}>
<Editor
placeholder={capitalize(label)}
size="md"
id={label}
extensions={[
liquid({
variables: [{ type: 'variable', label: 'asdf' }],
}),
EditorView.lineWrapping,
]}
value={field.value}
onChange={(val) => field.onChange(val)}
readOnly={readonly}
/>
</InputField>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { ToastIcon } from '@/components/primitives/sonner';
import { useState } from 'react';
import { usePreviewStep } from '@/hooks/use-preview-step';
import useDebouncedEffect from '@/hooks/use-debounced-effect';
import { CustomStepControls } from '../controls/custom-step-controls';

const tabsContentClassName = 'h-full w-full px-3 py-3.5';

Expand Down Expand Up @@ -154,6 +155,7 @@ export const InAppTabs = ({ workflow, step }: { workflow: WorkflowResponseDto; s
<Separator />
<TabsContent value="editor" className={tabsContentClassName}>
<InAppEditor uiSchema={uiSchema} />
<CustomStepControls dataSchema={dataSchema} />
</TabsContent>
<TabsContent value="preview" className={tabsContentClassName}>
<InAppEditorPreview value={editorValue} onChange={setEditorValue} />
Expand Down
5 changes: 4 additions & 1 deletion apps/dashboard/src/utils/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ type ZodValue =
| z.ZodEffects<z.ZodTypeAny>
| z.ZodDefault<z.ZodTypeAny>
| z.ZodEnum<[string, ...string[]]>
| z.ZodOptional<z.ZodTypeAny>;
| z.ZodOptional<z.ZodTypeAny>
| z.ZodBoolean;

const handleStringFormat = ({ value, key, format }: { value: z.ZodString; key: string; format: string }) => {
if (format === 'email') {
Expand Down Expand Up @@ -122,6 +123,8 @@ export const buildDynamicZodSchema = (obj: JSONSchema): z.AnyZodObject => {
});
} else if (type === 'string') {
zodValue = handleStringType({ key, requiredFields, format, pattern, enumValues, defaultValue });
} else if (type === 'boolean') {
zodValue = z.boolean(isRequired ? { message: `${capitalize(key)} is required` } : undefined);
Comment on lines +126 to +127
Copy link
Member Author

Choose a reason for hiding this comment

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

Added boolean support

} else {
zodValue = z.number(isRequired ? { message: `${capitalize(key)} is required` } : undefined);
if (defaultValue) {
Expand Down
Loading
Loading