-
Notifications
You must be signed in to change notification settings - Fork 3.9k
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
Open
ChmaraX
wants to merge
20
commits into
next
Choose a base branch
from
nv-4575-implement-delay-step
base: next
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+432
−114
Open
Changes from 2 commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
146e9e2
feat(dashboard): add delay step
ChmaraX 4eae38e
Merge branch 'next' into nv-4575-implement-delay-step
ChmaraX b1c6f9f
feat(dashboard): add delay step
ChmaraX 5c1378e
feat(dashboard): add delay step
ChmaraX 07642c1
feat(dashboard): add delay step
ChmaraX c2b3c52
feat(dashboard): isolate the actual delay component from the sidebar
ChmaraX 2a08599
fix(dashboard): form styling
ChmaraX bdbf903
Merge branch 'next' into nv-4575-implement-delay-step
ChmaraX 4b6ea51
Merge branch 'next' into nv-4575-implement-delay-step
ChmaraX 688568b
feat(dashboard): delay step update
ChmaraX 0e45fc2
Merge branch 'next' into nv-4575-implement-delay-step
ChmaraX 54946fc
fix(dashboard): remove error border
ChmaraX 4c989cb
feat(dashboard): add step cache update
ChmaraX ef822ac
Merge branch 'next' into nv-4575-implement-delay-step
ChmaraX 4c59b6f
feat(dashboard): get step issues from workflow
ChmaraX 2bbe057
chore: spellcheck
ChmaraX b76624f
feat(dashboard): node step issue
ChmaraX 44f83c5
feat(dashboard): add ld flag for email, digest, delay (#7205)
ChmaraX a751111
feat(dashboard): make inline control values in single form
ChmaraX 2e89ea1
feat(dashboard): default unit value
ChmaraX File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
96 changes: 96 additions & 0 deletions
96
apps/dashboard/src/components/number-input-with-select.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,96 @@ | ||
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 { UseFormReturn } from 'react-hook-form'; | ||
import { useMemo } from 'react'; | ||
|
||
type InputWithSelectProps = { | ||
form: UseFormReturn<any>; | ||
inputName: string; | ||
selectName: string; | ||
options: string[]; | ||
defaultOption?: string; | ||
className?: string; | ||
placeholder?: string; | ||
isReadOnly?: boolean; | ||
}; | ||
|
||
export const NumberInputWithSelect = (props: InputWithSelectProps) => { | ||
ChmaraX marked this conversation as resolved.
Show resolved
Hide resolved
|
||
const { className, form, inputName, selectName, options, defaultOption, placeholder, isReadOnly } = props; | ||
|
||
const amount = form.getFieldState(`${inputName}`); | ||
const unit = form.getFieldState(`${selectName}`); | ||
const error = amount.error || unit.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 | ||
form.setValue(inputName, value.input, { shouldDirty: true }); | ||
form.setValue(selectName, 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={form.control} | ||
name={inputName} | ||
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: form.getValues(selectName) }); | ||
}} | ||
/> | ||
</FormControl> | ||
</FormItem> | ||
)} | ||
/> | ||
<FormField | ||
control={form.control} | ||
name={selectName} | ||
render={({ field }) => ( | ||
<FormItem> | ||
<FormControl> | ||
<Select | ||
onValueChange={(value) => { | ||
handleChange({ input: Number(form.getValues(inputName)), select: value }); | ||
}} | ||
defaultValue={defaultSelectedValue} | ||
disabled={isReadOnly} | ||
{...field} | ||
> | ||
<SelectTrigger className="h-7 w-auto translate-x-1 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
|
||
</> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
152 changes: 152 additions & 0 deletions
152
apps/dashboard/src/components/workflow-editor/steps/delay/delay-configure.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,152 @@ | ||
import { Separator } from '@/components/primitives/separator'; | ||
import { SidebarContent } from '@/components/side-navigation/Sidebar'; | ||
import { CommonFields } from '@/components/workflow-editor/steps/common-fields'; | ||
import { useBlocker, useParams } from 'react-router-dom'; | ||
import { flattenIssues } from '@/components/workflow-editor/step-utils'; | ||
import { useCallback, useEffect, useMemo } from 'react'; | ||
import { useStepEditorContext } from '@/components/workflow-editor/steps/hooks'; | ||
import { buildDefaultValues, buildDynamicZodSchema } from '@/utils/schema'; | ||
import { zodResolver } from '@hookform/resolvers/zod'; | ||
import { useForm } from 'react-hook-form'; | ||
import { NumberInputWithSelect } from '@/components/number-input-with-select'; | ||
import { FormLabel } from '@/components/primitives/form/form'; | ||
import { Form } from '@/components/primitives/form/form'; | ||
import { useWorkflowEditorContext } from '@/components/workflow-editor/hooks'; | ||
import debounce from 'lodash.debounce'; | ||
import { z } from 'zod'; | ||
import { TimeUnitEnum } from '@novu/shared'; | ||
import { useUpdateWorkflow } from '@/hooks/use-update-workflow'; | ||
import { showToast } from '@/components/primitives/sonner-helpers'; | ||
import { ToastIcon } from '@/components/primitives/sonner'; | ||
import { UnsavedChangesAlertDialog } from '@/components/unsaved-changes-alert-dialog'; | ||
import merge from 'lodash.merge'; | ||
|
||
const TOAST_CONFIG = { | ||
position: 'bottom-left' as const, | ||
classNames: { toast: 'ml-10 mb-4' }, | ||
}; | ||
|
||
const delayControlsSchema = z | ||
.object({ | ||
type: z.enum(['regular']).default('regular'), | ||
amount: z.number(), | ||
unit: z.nativeEnum(TimeUnitEnum), | ||
}) | ||
.strict(); | ||
ChmaraX marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
const defaultUnitValues = Object.values(TimeUnitEnum); | ||
|
||
export const DelayConfigure = () => { | ||
const { stepSlug = '' } = useParams<{ workflowSlug: string; stepSlug: string }>(); | ||
const { step, refetch } = useStepEditorContext(); | ||
ChmaraX marked this conversation as resolved.
Show resolved
Hide resolved
|
||
const { workflow, isReadOnly } = useWorkflowEditorContext(); | ||
const { uiSchema, dataSchema, values } = step?.controls ?? {}; | ||
|
||
const unitOptions = useMemo(() => (dataSchema?.properties?.unit as any)?.enum ?? defaultUnitValues, [dataSchema]); | ||
const schema = buildDynamicZodSchema(dataSchema ?? {}); | ||
const newFormValues = useMemo(() => merge(buildDefaultValues(uiSchema ?? {}), values), [uiSchema, values]); | ||
|
||
const form = useForm<z.infer<typeof delayControlsSchema>>({ | ||
resolver: zodResolver(schema), | ||
values: newFormValues as z.infer<typeof delayControlsSchema>, | ||
}); | ||
|
||
const { updateWorkflow, isPending } = useUpdateWorkflow({ | ||
onSuccess: () => { | ||
refetch(); | ||
showToast({ | ||
children: () => ( | ||
<> | ||
<ToastIcon variant="success" /> | ||
<span className="text-sm">Saved</span> | ||
</> | ||
), | ||
options: TOAST_CONFIG, | ||
}); | ||
}, | ||
onError: () => { | ||
showToast({ | ||
children: () => ( | ||
<> | ||
<ToastIcon variant="error" /> | ||
<span className="text-sm">Failed to save</span> | ||
</> | ||
), | ||
options: TOAST_CONFIG, | ||
}); | ||
}, | ||
}); | ||
|
||
useEffect(() => { | ||
const controlErrors = flattenIssues(step?.issues?.controls); | ||
Object.entries(controlErrors).forEach(([key, value]) => { | ||
form.setError(key as 'amount' | 'unit', { message: value }); | ||
}); | ||
}, [step, form]); | ||
|
||
const onSubmit = useCallback( | ||
async (data: z.infer<typeof delayControlsSchema>) => { | ||
console.log('submit', data); | ||
|
||
if (!workflow) { | ||
return false; | ||
} | ||
|
||
await updateWorkflow({ | ||
id: workflow._id, | ||
workflow: { | ||
...workflow, | ||
steps: workflow.steps.map((step) => | ||
step.slug === stepSlug ? { ...step, controlValues: { ...data } } : step | ||
), | ||
}, | ||
}); | ||
|
||
form.reset({ ...data }); | ||
}, | ||
[workflow, form, updateWorkflow, stepSlug] | ||
); | ||
|
||
const debouncedSave = useMemo(() => debounce(onSubmit, 800), [onSubmit]); | ||
|
||
// Cleanup debounce on unmount | ||
useEffect(() => () => debouncedSave.cancel(), [debouncedSave]); | ||
|
||
const blocker = useBlocker(() => form.formState.isDirty || isPending); | ||
|
||
return ( | ||
<> | ||
<SidebarContent> | ||
<CommonFields /> | ||
</SidebarContent> | ||
<Separator /> | ||
<SidebarContent> | ||
<Form {...form}> | ||
<form | ||
className="flex h-full flex-col gap-2" | ||
onChange={(e) => { | ||
e.preventDefault(); | ||
e.stopPropagation(); | ||
debouncedSave(form.getValues()); | ||
}} | ||
> | ||
<FormLabel tooltip="Delays workflow for the set time, then proceeds to the next step."> | ||
Delay execution by | ||
</FormLabel> | ||
<NumberInputWithSelect | ||
form={form} | ||
inputName="amount" | ||
selectName="unit" | ||
options={unitOptions} | ||
isReadOnly={isReadOnly} | ||
/> | ||
</form> | ||
</Form> | ||
</SidebarContent> | ||
<UnsavedChangesAlertDialog | ||
blocker={blocker} | ||
description="This editor form has some unsaved changes. Save progress before you leave." | ||
/> | ||
</> | ||
); | ||
}; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we need the default option prop? The unit prop should be the default option otherwise its
defaultOption[0]
.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We dictate the default selected value in ui schema coming from backend, so its needed.