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): add delay step #7131

Merged
merged 25 commits into from
Dec 6, 2024
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
146e9e2
feat(dashboard): add delay step
ChmaraX Nov 26, 2024
4eae38e
Merge branch 'next' into nv-4575-implement-delay-step
ChmaraX Nov 26, 2024
b1c6f9f
feat(dashboard): add delay step
ChmaraX Nov 26, 2024
5c1378e
feat(dashboard): add delay step
ChmaraX Nov 26, 2024
07642c1
feat(dashboard): add delay step
ChmaraX Nov 26, 2024
c2b3c52
feat(dashboard): isolate the actual delay component from the sidebar
ChmaraX Nov 26, 2024
2a08599
fix(dashboard): form styling
ChmaraX Nov 26, 2024
bdbf903
Merge branch 'next' into nv-4575-implement-delay-step
ChmaraX Nov 28, 2024
4b6ea51
Merge branch 'next' into nv-4575-implement-delay-step
ChmaraX Dec 2, 2024
688568b
feat(dashboard): delay step update
ChmaraX Dec 3, 2024
0e45fc2
Merge branch 'next' into nv-4575-implement-delay-step
ChmaraX Dec 3, 2024
54946fc
fix(dashboard): remove error border
ChmaraX Dec 3, 2024
4c989cb
feat(dashboard): add step cache update
ChmaraX Dec 3, 2024
ef822ac
Merge branch 'next' into nv-4575-implement-delay-step
ChmaraX Dec 3, 2024
4c59b6f
feat(dashboard): get step issues from workflow
ChmaraX Dec 3, 2024
2bbe057
chore: spellcheck
ChmaraX Dec 3, 2024
b76624f
feat(dashboard): node step issue
ChmaraX Dec 3, 2024
44f83c5
feat(dashboard): add ld flag for email, digest, delay (#7205)
ChmaraX Dec 3, 2024
a751111
feat(dashboard): make inline control values in single form
ChmaraX Dec 4, 2024
2e89ea1
feat(dashboard): default unit value
ChmaraX Dec 4, 2024
c5adc57
feat(dashboard): minor improvements
ChmaraX Dec 5, 2024
77548aa
Merge branch 'next' into nv-4575-implement-delay-step
ChmaraX Dec 5, 2024
143e5ba
feat(dashboard): minor changes
ChmaraX Dec 5, 2024
68ad9c1
feat: minor fixes
ChmaraX Dec 5, 2024
b59f853
feat: minor fixes
ChmaraX Dec 6, 2024
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
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
export const DelayTimeControlZodSchema = z
.object({
type: z.enum(['regular']).default('regular'),
amount: z.number(),
amount: z.number().min(1),
unit: z.nativeEnum(TimeUnitEnum),
})
.strict();
Expand All @@ -26,15 +26,15 @@ export const delayUiSchema: UiSchema = {
properties: {
amount: {
component: UiComponentEnum.DELAY_AMOUNT,
placeholder: '30',
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We probably can't have it like this, see this thread for more info.

placeholder: null,
},
unit: {
component: UiComponentEnum.DELAY_UNIT,
placeholder: DigestUnitEnum.SECONDS,
},
type: {
component: UiComponentEnum.DELAY_TYPE,
placeholder: null,
placeholder: 'regular',
},
},
};
104 changes: 104 additions & 0 deletions apps/dashboard/src/components/number-input-with-select.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { cn } from '@/utils/ui';
import { FormControl, FormField, FormItem, FormMessagePure } from '@/components/primitives/form/form';
import { Input } from '@/components/primitives/input';
import { InputFieldPure } from '@/components/primitives/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/primitives/select';
import { useFormContext } from 'react-hook-form';
import { useMemo } from 'react';

type InputWithSelectProps = {
fields: {
Copy link
Contributor

Choose a reason for hiding this comment

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

Nested in props is always 👿 . how about using two flat props?

  1. unit: 'seconds' | 'minutes' | 'hours' | 'months'
  2. amount: number

This should simplify the logic of the handleChange a lot.

Copy link
Contributor

Choose a reason for hiding this comment

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

@SokratisVidros this is what we agreed before with @desiprisg and we use this pattern in other places where the component is using two form fields

Copy link
Contributor

Choose a reason for hiding this comment

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

Isn't a flat structure much easier to work with?

inputKey: string;
selectKey: string;
};
options: string[];
defaultOption?: string;
ChmaraX marked this conversation as resolved.
Show resolved Hide resolved
className?: string;
placeholder?: string;
isReadOnly?: boolean;
};

export const NumberInputWithSelect = ({
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
export const NumberInputWithSelect = ({
export const DurationInput = ({

Copy link
Contributor Author

@ChmaraX ChmaraX Dec 4, 2024

Choose a reason for hiding this comment

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

Its a generic component not necessarily a duration, it can be also 10 horses.

Lets see how it evolves with the digest and we will incrementally adjust this component, by either making it primitive or whatever we need - I dont want to make it too generic too early since I dont know what will digest need.

Copy link
Contributor

Choose a reason for hiding this comment

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

The it's an AmountInput!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I prefer NumberInputWithSelect because its immediately clear from the name what it is. For AmountInput I would need to open the component and check.

That said I changed it to AmountInput.

fields,
options,
defaultOption,
className,
placeholder,
isReadOnly,
}: InputWithSelectProps) => {
const { getFieldState, setValue, getValues, control } = useFormContext();
ChmaraX marked this conversation as resolved.
Show resolved Hide resolved

const input = getFieldState(`${fields.inputKey}`);
const select = getFieldState(`${fields.selectKey}`);
const error = input.error || select.error;

const defaultSelectedValue = useMemo(() => {
return defaultOption ?? options[0];
}, [defaultOption, options]);

const handleChange = (value: { input: number; select: string }) => {
// we want to always set both values and treat it as a single input
setValue(fields.inputKey, value.input, { shouldDirty: true });
setValue(fields.selectKey, value.select, { shouldDirty: true });
};

return (
<>
<InputFieldPure className="h-7 rounded-lg border pr-0">
ChmaraX marked this conversation as resolved.
Show resolved Hide resolved
<FormField
control={control}
name={fields.inputKey}
render={({ field }) => (
<FormItem className="w-full overflow-hidden">
<FormControl>
<Input
type="number"
ChmaraX marked this conversation as resolved.
Show resolved Hide resolved
className={cn(
'min-w-[20ch] [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
className
)}
placeholder={placeholder}
disabled={isReadOnly}
{...field}
onChange={(e) => {
handleChange({ input: Number(e.target.value), select: getValues(fields.selectKey) });
}}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={control}
name={fields.selectKey}
render={({ field }) => (
<FormItem>
<FormControl>
<Select
onValueChange={(value) => {
handleChange({ input: Number(getValues(fields.inputKey)), select: value });
}}
defaultValue={defaultSelectedValue}
disabled={isReadOnly}
{...field}
>
<SelectTrigger className="h-7 w-auto translate-x-0.5 gap-1 rounded-l-none border-l bg-neutral-50 p-2 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
</InputFieldPure>
<FormMessagePure error={error ? String(error.message) : undefined} />
ChmaraX marked this conversation as resolved.
Show resolved Hide resolved
</>
);
};
4 changes: 2 additions & 2 deletions apps/dashboard/src/components/primitives/form/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,8 @@ const FormMessagePure = React.forwardRef<
className={formMessageVariants({ variant: error ? 'error' : 'default', className })}
{...props}
>
{error ? <RiErrorWarningFill className="size-4" /> : <RiInformationFill className="size-4" />}
<span className="mt-[1px] text-xs leading-3">{body}</span>
<span>{error ? <RiErrorWarningFill className="size-4" /> : <RiInformationFill className="size-4" />}</span>
<span className="mt-[1px] text-xs leading-4">{body}</span>
ChmaraX marked this conversation as resolved.
Show resolved Hide resolved
</p>
);
});
Expand Down
13 changes: 11 additions & 2 deletions apps/dashboard/src/components/workflow-editor/add-step-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { Badge } from '../primitives/badge';
import { cn } from '@/utils/ui';
import { StepTypeEnum } from '@/utils/enums';
import { STEP_TYPE_TO_COLOR } from '@/utils/color';
import { useFeatureFlag } from '@/hooks/use-feature-flag';
import { FeatureFlagsKeysEnum } from '@novu/shared';

const MenuGroup = ({ children }: { children: ReactNode }) => {
return <div className="flex flex-col">{children}</div>;
Expand Down Expand Up @@ -41,7 +43,7 @@ const MenuItem = ({

return (
<span
onClick={onClick}
onClick={!disabled ? onClick : undefined}
ChmaraX marked this conversation as resolved.
Show resolved Hide resolved
className={cn(
'shadow-xs text-foreground-600 hover:bg-accent flex cursor-pointer items-center gap-2 rounded-lg p-1.5',
{
Expand Down Expand Up @@ -73,6 +75,7 @@ export const AddStepMenu = ({
onMenuItemClick: (stepType: StepTypeEnum) => void;
}) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const isDelayDigestEmailEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_ND_DELAY_DIGEST_EMAIL_ENABLED);
ChmaraX marked this conversation as resolved.
Show resolved Hide resolved

const handleMenuItemClick = (stepType: StepTypeEnum) => {
onMenuItemClick(stepType);
Expand Down Expand Up @@ -121,7 +124,13 @@ export const AddStepMenu = ({
<MenuTitle>Action Steps</MenuTitle>
<MenuItemsGroup>
<MenuItem stepType={StepTypeEnum.DIGEST}>Digest</MenuItem>
<MenuItem stepType={StepTypeEnum.DELAY}>Delay</MenuItem>
<MenuItem
stepType={StepTypeEnum.DELAY}
disabled={!isDelayDigestEmailEnabled}
onClick={() => handleMenuItemClick(StepTypeEnum.DELAY)}
>
Delay
</MenuItem>
</MenuItemsGroup>
</MenuGroup>
</div>
Expand Down
1 change: 1 addition & 0 deletions apps/dashboard/src/components/workflow-editor/nodes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ export const DelayNode = (props: NodeProps<NodeType>) => {
<NodeName>{data.name || 'Delay Step'}</NodeName>
</NodeHeader>
<NodeBody>{data.content || 'You have been invited to the Novu party on "commentSnippet"'}</NodeBody>
{data.error && <NodeError>{data.error}</NodeError>}
<Handle isConnectable={false} className={handleClassName} type="target" position={Position.Top} id="a" />
<Handle isConnectable={false} className={handleClassName} type="source" position={Position.Bottom} id="b" />
</StepNode>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { InAppSubject } from '@/components/workflow-editor/steps/in-app/in-app-s
import { InAppBody } from '@/components/workflow-editor/steps/in-app/in-app-body';
import { InAppAvatar } from '@/components/workflow-editor/steps/in-app/in-app-avatar';
import { InAppRedirect } from '@/components/workflow-editor/steps/in-app/in-app-redirect';
import { DelayAmount } from '@/components/workflow-editor/steps/delay/delay-amount';

export const getComponentByType = ({ component }: { component?: UiComponentEnum }) => {
switch (component) {
Expand All @@ -23,6 +24,11 @@ export const getComponentByType = ({ component }: { component?: UiComponentEnum
case UiComponentEnum.URL_TEXT_BOX: {
return <InAppRedirect />;
}
case UiComponentEnum.DELAY_AMOUNT:
case UiComponentEnum.DELAY_UNIT:
case UiComponentEnum.DELAY_TYPE: {
return <DelayAmount />;
}
default: {
return null;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { useCallback, useEffect, useMemo } from 'react';
import merge from 'lodash.merge';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import type { StepDataDto, StepIssuesDto, UpdateWorkflowDto, WorkflowResponseDto } from '@novu/shared';
import { StepTypeEnum } from '@novu/shared';

import { flattenIssues, updateStepControlValuesInWorkflow } from '@/components/workflow-editor/step-utils';
import { buildDefaultValues, buildDefaultValuesOfDataSchema, buildDynamicZodSchema } from '@/utils/schema';
import { Form } from '@/components/primitives/form/form';
import { useFormAutosave } from '@/hooks/use-form-autosave';
import { SaveFormContext } from '@/components/workflow-editor/steps/save-form-context';
import { DelayForm } from '@/components/workflow-editor/steps/delay/delay-form';

const STEP_TYPE_TO_INLINE_FORM: Record<StepTypeEnum, (args: StepInlineFormProps) => React.JSX.Element | null> = {
ChmaraX marked this conversation as resolved.
Show resolved Hide resolved
[StepTypeEnum.DELAY]: DelayForm,
[StepTypeEnum.IN_APP]: () => null,
[StepTypeEnum.EMAIL]: () => null,
[StepTypeEnum.SMS]: () => null,
[StepTypeEnum.CHAT]: () => null,
[StepTypeEnum.PUSH]: () => null,
[StepTypeEnum.CUSTOM]: () => null,
[StepTypeEnum.TRIGGER]: () => null,
[StepTypeEnum.DIGEST]: () => null,
};

// Use the UI Schema to build the default values if it exists else use the data schema (code-first approach) values
const calculateDefaultValues = (step: StepDataDto) => {
if (Object.keys(step.controls.uiSchema ?? {}).length !== 0) {
return merge(buildDefaultValues(step.controls.uiSchema ?? {}), step.controls.values);
}

return merge(buildDefaultValuesOfDataSchema(step.controls.dataSchema ?? {}), step.controls.values);
};

export type StepInlineFormProps = {
workflow: WorkflowResponseDto;
step: StepDataDto;
};

type ConfigureStepInlineFormProps = StepInlineFormProps & {
issues?: StepIssuesDto;
update: (data: UpdateWorkflowDto) => void;
updateStepCache: (step: Partial<StepDataDto>) => void;
};

export const ConfigureStepInlineForm = (props: ConfigureStepInlineFormProps) => {
const { workflow, step, issues, update, updateStepCache } = props;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

we are drilling the props quite deep, we should think about using the hooks here, but I left it like this for consistency.

const schema = useMemo(() => buildDynamicZodSchema(step.controls.dataSchema ?? {}), [step.controls.dataSchema]);

const defaultValues = useMemo(() => {
return calculateDefaultValues(step);
}, [step]);

const form = useForm({
resolver: zodResolver(schema),
defaultValues,
shouldFocusError: false,
});

const { onBlur, saveForm } = useFormAutosave({
previousData: defaultValues,
form,
save: (data) => {
update(updateStepControlValuesInWorkflow(workflow, step, data));
updateStepCache({ ...step, controls: { ...step.controls, values: data } });
},
});

const setIssuesFromStep = useCallback(() => {
const stepIssues = flattenIssues(issues?.controls);
Object.entries(stepIssues).forEach(([key, value]) => {
form.setError(key as string, { message: value });
});
}, [form, issues]);

useEffect(() => {
setIssuesFromStep();
}, [setIssuesFromStep]);

const InlineForm = STEP_TYPE_TO_INLINE_FORM[step.type];

const value = useMemo(() => ({ saveForm }), [saveForm]);

return (
<Form {...form}>
<form className="flex h-full flex-col" onBlur={onBlur}>
<SaveFormContext.Provider value={value}>
<InlineForm workflow={workflow} step={step} />
</SaveFormContext.Provider>
</form>
</Form>
);
};
Loading
Loading