-
Notifications
You must be signed in to change notification settings - Fork 6
fix: improve formbuilder logic #1135
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
base: main
Are you sure you want to change the base?
Changes from all commits
415de67
a4f40ec
aff5b58
7880ec8
fecbf8f
f0d9c71
23a047b
d4189a9
89d5e4f
da15ed7
d64b132
d17abce
c72760b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -3,8 +3,7 @@ | |||||||||||||||||||||||||||
import type { DragEndEvent } from "@dnd-kit/core"; | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
import * as React from "react"; | ||||||||||||||||||||||||||||
import { useCallback, useReducer, useRef } from "react"; | ||||||||||||||||||||||||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"; | ||||||||||||||||||||||||||||
import { useCallback, useMemo, useReducer, useRef } from "react"; | ||||||||||||||||||||||||||||
import { DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; | ||||||||||||||||||||||||||||
import { restrictToParentElement, restrictToVerticalAxis } from "@dnd-kit/modifiers"; | ||||||||||||||||||||||||||||
import { | ||||||||||||||||||||||||||||
|
@@ -15,7 +14,14 @@ import { | |||||||||||||||||||||||||||
import { zodResolver } from "@hookform/resolvers/zod"; | ||||||||||||||||||||||||||||
import { useFieldArray, useForm } from "react-hook-form"; | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
import type { Stages } from "db/public"; | ||||||||||||||||||||||||||||
import type { | ||||||||||||||||||||||||||||
FormElementsId, | ||||||||||||||||||||||||||||
FormsId, | ||||||||||||||||||||||||||||
NewFormElements, | ||||||||||||||||||||||||||||
NewFormElementToPubType, | ||||||||||||||||||||||||||||
Stages, | ||||||||||||||||||||||||||||
} from "db/public"; | ||||||||||||||||||||||||||||
import { formElementsInitializerSchema } from "db/public"; | ||||||||||||||||||||||||||||
import { logger } from "logger"; | ||||||||||||||||||||||||||||
import { Form, FormControl, FormField, FormItem } from "ui/form"; | ||||||||||||||||||||||||||||
import { useUnsavedChangesWarning } from "ui/hooks"; | ||||||||||||||||||||||||||||
|
@@ -35,6 +41,7 @@ import { ElementPanel } from "./ElementPanel"; | |||||||||||||||||||||||||||
import { FormBuilderProvider } from "./FormBuilderContext"; | ||||||||||||||||||||||||||||
import { FormElement } from "./FormElement"; | ||||||||||||||||||||||||||||
import { formBuilderSchema, isButtonElement } from "./types"; | ||||||||||||||||||||||||||||
import { useIsChanged } from "./useIsChanged"; | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
const elementPanelReducer: React.Reducer<PanelState, PanelEvent> = (prevState, event) => { | ||||||||||||||||||||||||||||
const { eventName } = event; | ||||||||||||||||||||||||||||
|
@@ -107,13 +114,98 @@ type Props = { | |||||||||||||||||||||||||||
stages: Stages[]; | ||||||||||||||||||||||||||||
}; | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
/** | ||||||||||||||||||||||||||||
* Only sends the dirty fields to the server | ||||||||||||||||||||||||||||
*/ | ||||||||||||||||||||||||||||
const preparePayload = ({ | ||||||||||||||||||||||||||||
formValues, | ||||||||||||||||||||||||||||
defaultValues, | ||||||||||||||||||||||||||||
}: { | ||||||||||||||||||||||||||||
defaultValues: FormBuilderSchema; | ||||||||||||||||||||||||||||
formValues: FormBuilderSchema; | ||||||||||||||||||||||||||||
}) => { | ||||||||||||||||||||||||||||
const { upserts, deletes, relatedPubTypes, deletedRelatedPubTypes } = | ||||||||||||||||||||||||||||
formValues.elements.reduce<{ | ||||||||||||||||||||||||||||
upserts: NewFormElements[]; | ||||||||||||||||||||||||||||
deletes: FormElementsId[]; | ||||||||||||||||||||||||||||
relatedPubTypes: NewFormElementToPubType[]; | ||||||||||||||||||||||||||||
deletedRelatedPubTypes: FormElementsId[]; | ||||||||||||||||||||||||||||
}>( | ||||||||||||||||||||||||||||
(acc, element, index) => { | ||||||||||||||||||||||||||||
if (element.deleted) { | ||||||||||||||||||||||||||||
if (element.elementId) { | ||||||||||||||||||||||||||||
acc.deletes.push(element.elementId); | ||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||
} else if (!element.elementId) { | ||||||||||||||||||||||||||||
// Newly created elements have no elementId, so generate an id to use | ||||||||||||||||||||||||||||
const id = crypto.randomUUID() as FormElementsId; | ||||||||||||||||||||||||||||
acc.upserts.push( | ||||||||||||||||||||||||||||
formElementsInitializerSchema.parse({ | ||||||||||||||||||||||||||||
formId: formValues.formId, | ||||||||||||||||||||||||||||
...element, | ||||||||||||||||||||||||||||
id, | ||||||||||||||||||||||||||||
}) | ||||||||||||||||||||||||||||
); | ||||||||||||||||||||||||||||
if (element.relatedPubTypes) { | ||||||||||||||||||||||||||||
for (const pubTypeId of element.relatedPubTypes) { | ||||||||||||||||||||||||||||
acc.relatedPubTypes.push({ A: id, B: pubTypeId }); | ||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||
} else if (element.updated) { | ||||||||||||||||||||||||||||
// check whether the element is reeeaally updated minus the updated field | ||||||||||||||||||||||||||||
const { updated: _, id: _id, ...elementWithoutUpdated } = element; | ||||||||||||||||||||||||||||
const { updated, id, ...rest } = | ||||||||||||||||||||||||||||
defaultValues.elements.find((e) => e.elementId === element.elementId) ?? {}; | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
const defaultElement = rest as Omit<FormElementData, "updated" | "id">; | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
if (JSON.stringify(defaultElement) === JSON.stringify(elementWithoutUpdated)) { | ||||||||||||||||||||||||||||
return acc; | ||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||
Comment on lines
+162
to
+164
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. very sophisticated diffing. please recommend a better approach if you know one |
||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
acc.upserts.push( | ||||||||||||||||||||||||||||
formElementsInitializerSchema.parse({ | ||||||||||||||||||||||||||||
...element, | ||||||||||||||||||||||||||||
formId: formValues.formId, | ||||||||||||||||||||||||||||
id: element.elementId, | ||||||||||||||||||||||||||||
}) | ||||||||||||||||||||||||||||
); // TODO: only update changed columns | ||||||||||||||||||||||||||||
if (element.relatedPubTypes) { | ||||||||||||||||||||||||||||
// If we are updating to an empty array and there were related pub types before, we should clear out all related pub types | ||||||||||||||||||||||||||||
if ( | ||||||||||||||||||||||||||||
element.relatedPubTypes.length === 0 && | ||||||||||||||||||||||||||||
defaultElement.relatedPubTypes?.length | ||||||||||||||||||||||||||||
) { | ||||||||||||||||||||||||||||
Comment on lines
+175
to
+178
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i added this |
||||||||||||||||||||||||||||
acc.deletedRelatedPubTypes.push(element.elementId); | ||||||||||||||||||||||||||||
} else { | ||||||||||||||||||||||||||||
for (const pubTypeId of element.relatedPubTypes) { | ||||||||||||||||||||||||||||
acc.relatedPubTypes.push({ A: element.elementId, B: pubTypeId }); | ||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||
return acc; | ||||||||||||||||||||||||||||
}, | ||||||||||||||||||||||||||||
{ upserts: [], deletes: [], relatedPubTypes: [], deletedRelatedPubTypes: [] } | ||||||||||||||||||||||||||||
); | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
const access = formValues.access !== defaultValues.access ? formValues.access : undefined; | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
return { | ||||||||||||||||||||||||||||
formId: formValues.formId, | ||||||||||||||||||||||||||||
upserts, | ||||||||||||||||||||||||||||
deletes, | ||||||||||||||||||||||||||||
access, | ||||||||||||||||||||||||||||
relatedPubTypes, | ||||||||||||||||||||||||||||
deletedRelatedPubTypes, | ||||||||||||||||||||||||||||
}; | ||||||||||||||||||||||||||||
}; | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
export function FormBuilder({ pubForm, id, stages }: Props) { | ||||||||||||||||||||||||||||
const router = useRouter(); | ||||||||||||||||||||||||||||
const pathname = usePathname(); | ||||||||||||||||||||||||||||
const params = useSearchParams(); | ||||||||||||||||||||||||||||
const form = useForm<FormBuilderSchema>({ | ||||||||||||||||||||||||||||
resolver: zodResolver(formBuilderSchema), | ||||||||||||||||||||||||||||
values: { | ||||||||||||||||||||||||||||
const [isChanged, setIsChanged] = useIsChanged(); | ||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is a small custom platform/core/app/components/FormBuilder/useIsChanged.tsx Lines 1 to 13 in 415de67
|
||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
const defaultValues = useMemo(() => { | ||||||||||||||||||||||||||||
return { | ||||||||||||||||||||||||||||
elements: pubForm.elements.map((e) => { | ||||||||||||||||||||||||||||
// Do not include extra fields here | ||||||||||||||||||||||||||||
const { slug, id, fieldName, ...rest } = e; | ||||||||||||||||||||||||||||
|
@@ -122,7 +214,12 @@ export function FormBuilder({ pubForm, id, stages }: Props) { | |||||||||||||||||||||||||||
}), | ||||||||||||||||||||||||||||
access: pubForm.access, | ||||||||||||||||||||||||||||
formId: pubForm.id, | ||||||||||||||||||||||||||||
}, | ||||||||||||||||||||||||||||
}; | ||||||||||||||||||||||||||||
}, [pubForm]); | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
const form = useForm<FormBuilderSchema>({ | ||||||||||||||||||||||||||||
resolver: zodResolver(formBuilderSchema), | ||||||||||||||||||||||||||||
values: defaultValues, | ||||||||||||||||||||||||||||
}); | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
const sidebarRef = useRef(null); | ||||||||||||||||||||||||||||
|
@@ -145,22 +242,25 @@ export function FormBuilder({ pubForm, id, stages }: Props) { | |||||||||||||||||||||||||||
control: form.control, | ||||||||||||||||||||||||||||
}); | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
const formValues = form.getValues(); | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
useUnsavedChangesWarning(form.formState.isDirty); | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
const payload = useMemo( | ||||||||||||||||||||||||||||
() => preparePayload({ formValues, defaultValues }), | ||||||||||||||||||||||||||||
[formValues, defaultValues] | ||||||||||||||||||||||||||||
); | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
React.useEffect(() => { | ||||||||||||||||||||||||||||
const newParams = new URLSearchParams(params); | ||||||||||||||||||||||||||||
if (form.formState.isDirty) { | ||||||||||||||||||||||||||||
newParams.set("unsavedChanges", "true"); | ||||||||||||||||||||||||||||
} else { | ||||||||||||||||||||||||||||
newParams.delete("unsavedChanges"); | ||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||
router.replace(`${pathname}?${newParams.toString()}`, { scroll: false }); | ||||||||||||||||||||||||||||
}, [form.formState.isDirty, params]); | ||||||||||||||||||||||||||||
setIsChanged( | ||||||||||||||||||||||||||||
payload.upserts.length > 0 || payload.deletes.length > 0 || payload.access != null | ||||||||||||||||||||||||||||
); | ||||||||||||||||||||||||||||
}, [payload]); | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
const runSaveForm = useServerAction(saveForm); | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
const onSubmit = async (formData: FormBuilderSchema) => { | ||||||||||||||||||||||||||||
//TODO: only submit dirty fields | ||||||||||||||||||||||||||||
const result = await runSaveForm(formData); | ||||||||||||||||||||||||||||
const result = await runSaveForm(payload); | ||||||||||||||||||||||||||||
if (didSucceed(result)) { | ||||||||||||||||||||||||||||
toast({ | ||||||||||||||||||||||||||||
className: "rounded border-emerald-100 bg-emerald-50", | ||||||||||||||||||||||||||||
|
@@ -250,7 +350,7 @@ export function FormBuilder({ pubForm, id, stages }: Props) { | |||||||||||||||||||||||||||
dispatch={dispatch} | ||||||||||||||||||||||||||||
slug={pubForm.slug} | ||||||||||||||||||||||||||||
stages={stages} | ||||||||||||||||||||||||||||
isDirty={form.formState.isDirty} | ||||||||||||||||||||||||||||
isDirty={isChanged} | ||||||||||||||||||||||||||||
> | ||||||||||||||||||||||||||||
<Tabs defaultValue="builder" className="pr-[380px]"> | ||||||||||||||||||||||||||||
<div className="px-6"> | ||||||||||||||||||||||||||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,13 +3,16 @@ | |
import { Button } from "ui/button"; | ||
import { cn } from "utils"; | ||
|
||
import { useIsChanged } from "./useIsChanged"; | ||
|
||
type Props = { | ||
form: string; | ||
className?: string; | ||
disabled?: boolean; | ||
}; | ||
|
||
export const SaveFormButton = ({ form, className, disabled }: Props) => { | ||
const [isChanged] = useIsChanged(); | ||
return ( | ||
<Button | ||
variant="default" | ||
|
@@ -18,7 +21,7 @@ export const SaveFormButton = ({ form, className, disabled }: Props) => { | |
form={form} | ||
type="submit" | ||
data-testid="save-form-button" | ||
disabled={disabled} | ||
disabled={disabled != null ? disabled : !isChanged} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. maybe i should just remov the |
||
> | ||
Save | ||
</Button> | ||
|
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.
this logic does not need to happen server side, we can just do it client side. this is much faster