Skip to content

Commit 415de67

Browse files
committed
fix: improve client side logic for formbuilder
1 parent 0b64e8d commit 415de67

File tree

3 files changed

+102
-22
lines changed

3 files changed

+102
-22
lines changed

Diff for: core/app/components/FormBuilder/FormBuilder.tsx

+85-21
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@
33
import type { DragEndEvent } from "@dnd-kit/core";
44

55
import * as React from "react";
6-
import { useCallback, useReducer, useRef } from "react";
7-
import { usePathname, useRouter, useSearchParams } from "next/navigation";
6+
import { useCallback, useMemo, useReducer, useRef } from "react";
87
import { DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
98
import { restrictToParentElement, restrictToVerticalAxis } from "@dnd-kit/modifiers";
109
import {
@@ -15,7 +14,8 @@ import {
1514
import { zodResolver } from "@hookform/resolvers/zod";
1615
import { useFieldArray, useForm } from "react-hook-form";
1716

18-
import type { Stages } from "db/public";
17+
import type { FormElementsId, NewFormElements, Stages } from "db/public";
18+
import { formElementsInitializerSchema } from "db/public";
1919
import { logger } from "logger";
2020
import { Form, FormControl, FormField, FormItem } from "ui/form";
2121
import { useUnsavedChangesWarning } from "ui/hooks";
@@ -35,6 +35,7 @@ import { ElementPanel } from "./ElementPanel";
3535
import { FormBuilderProvider } from "./FormBuilderContext";
3636
import { FormElement } from "./FormElement";
3737
import { formBuilderSchema, isButtonElement } from "./types";
38+
import { useIsChanged } from "./useIsChanged";
3839

3940
const elementPanelReducer: React.Reducer<PanelState, PanelEvent> = (prevState, event) => {
4041
const { eventName } = event;
@@ -107,13 +108,68 @@ type Props = {
107108
stages: Stages[];
108109
};
109110

111+
/**
112+
* Only sends the dirty fields to the server
113+
*/
114+
const preparePayload = ({
115+
formValues,
116+
defaultValues,
117+
}: {
118+
defaultValues: Omit<FormBuilderSchema, "id">;
119+
formValues: FormBuilderSchema;
120+
}) => {
121+
const { upserts, deletes } = formValues.elements.reduce<{
122+
upserts: NewFormElements[];
123+
deletes: FormElementsId[];
124+
}>(
125+
(acc, element, index) => {
126+
if (element.deleted) {
127+
if (element.elementId) {
128+
acc.deletes.push(element.elementId);
129+
}
130+
} else if (!element.elementId) {
131+
// Newly created elements have no elementId
132+
acc.upserts.push(
133+
formElementsInitializerSchema.parse({ formId: formValues.formId, ...element })
134+
);
135+
} else if (element.updated) {
136+
// check whether the element is reeeaally updated minus the updated field
137+
const { updated: _, id: _id, ...elementWithoutUpdated } = element;
138+
const { updated, id, ...defaultElement } =
139+
defaultValues.elements.find((e) => e.elementId === element.elementId) ?? {};
140+
141+
if (JSON.stringify(defaultElement) === JSON.stringify(elementWithoutUpdated)) {
142+
return acc;
143+
}
144+
145+
acc.upserts.push(
146+
formElementsInitializerSchema.parse({
147+
...element,
148+
formId: formValues.formId,
149+
id: element.elementId,
150+
})
151+
); // TODO: only update changed columns
152+
}
153+
return acc;
154+
},
155+
{ upserts: [], deletes: [] }
156+
);
157+
158+
const access = formValues.access !== defaultValues.access ? formValues.access : undefined;
159+
160+
return {
161+
formId: formValues.formId,
162+
upserts,
163+
deletes,
164+
access,
165+
};
166+
};
167+
110168
export function FormBuilder({ pubForm, id, stages }: Props) {
111-
const router = useRouter();
112-
const pathname = usePathname();
113-
const params = useSearchParams();
114-
const form = useForm<FormBuilderSchema>({
115-
resolver: zodResolver(formBuilderSchema),
116-
values: {
169+
const [isChanged, setIsChanged] = useIsChanged();
170+
171+
const defaultValues = useMemo(() => {
172+
return {
117173
elements: pubForm.elements.map((e) => {
118174
// Do not include extra fields here
119175
const { slug, id, fieldName, ...rest } = e;
@@ -122,7 +178,12 @@ export function FormBuilder({ pubForm, id, stages }: Props) {
122178
}),
123179
access: pubForm.access,
124180
formId: pubForm.id,
125-
},
181+
};
182+
}, [pubForm]);
183+
184+
const form = useForm<FormBuilderSchema>({
185+
resolver: zodResolver(formBuilderSchema),
186+
values: defaultValues,
126187
});
127188

128189
const sidebarRef = useRef(null);
@@ -145,22 +206,25 @@ export function FormBuilder({ pubForm, id, stages }: Props) {
145206
control: form.control,
146207
});
147208

209+
const formValues = form.getValues();
210+
148211
useUnsavedChangesWarning(form.formState.isDirty);
149212

213+
const payload = useMemo(
214+
() => preparePayload({ formValues, defaultValues }),
215+
[formValues, defaultValues]
216+
);
217+
150218
React.useEffect(() => {
151-
const newParams = new URLSearchParams(params);
152-
if (form.formState.isDirty) {
153-
newParams.set("unsavedChanges", "true");
154-
} else {
155-
newParams.delete("unsavedChanges");
156-
}
157-
router.replace(`${pathname}?${newParams.toString()}`, { scroll: false });
158-
}, [form.formState.isDirty, params]);
219+
setIsChanged(
220+
payload.upserts.length > 0 || payload.deletes.length > 0 || payload.access != null
221+
);
222+
}, [payload]);
159223

160224
const runSaveForm = useServerAction(saveForm);
225+
161226
const onSubmit = async (formData: FormBuilderSchema) => {
162-
//TODO: only submit dirty fields
163-
const result = await runSaveForm(formData);
227+
const result = await runSaveForm(payload);
164228
if (didSucceed(result)) {
165229
toast({
166230
className: "rounded border-emerald-100 bg-emerald-50",
@@ -250,7 +314,7 @@ export function FormBuilder({ pubForm, id, stages }: Props) {
250314
dispatch={dispatch}
251315
slug={pubForm.slug}
252316
stages={stages}
253-
isDirty={form.formState.isDirty}
317+
isDirty={isChanged}
254318
>
255319
<Tabs defaultValue="builder" className="pr-[380px]">
256320
<div className="px-6">

Diff for: core/app/components/FormBuilder/SaveFormButton.tsx

+4-1
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@
33
import { Button } from "ui/button";
44
import { cn } from "utils";
55

6+
import { useIsChanged } from "./useIsChanged";
7+
68
type Props = {
79
form: string;
810
className?: string;
911
disabled?: boolean;
1012
};
1113

1214
export const SaveFormButton = ({ form, className, disabled }: Props) => {
15+
const [isChanged] = useIsChanged();
1316
return (
1417
<Button
1518
variant="default"
@@ -18,7 +21,7 @@ export const SaveFormButton = ({ form, className, disabled }: Props) => {
1821
form={form}
1922
type="submit"
2023
data-testid="save-form-button"
21-
disabled={disabled}
24+
disabled={!isChanged}
2225
>
2326
Save
2427
</Button>

Diff for: core/app/components/FormBuilder/useIsChanged.tsx

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { parseAsBoolean, useQueryState } from "nuqs";
2+
3+
export const useIsChanged = () => {
4+
const [isChanged, setIsChanged] = useQueryState(
5+
"unsavedChanges",
6+
parseAsBoolean.withDefault(false).withOptions({
7+
history: "replace",
8+
scroll: false,
9+
})
10+
);
11+
12+
return [isChanged, setIsChanged] as const;
13+
};

0 commit comments

Comments
 (0)