diff --git a/core/app/components/FormBuilder/FormElement.tsx b/core/app/components/FormBuilder/FormElement.tsx
index cbc1c0ee7..c3314f2fe 100644
--- a/core/app/components/FormBuilder/FormElement.tsx
+++ b/core/app/components/FormBuilder/FormElement.tsx
@@ -25,7 +25,7 @@ type FormElementProps = {
};
export const FormElement = ({ element, index, isEditing, isDisabled }: FormElementProps) => {
- const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
+ const { attributes, listeners, isDragging, setNodeRef, transform, transition } = useSortable({
id: element.id,
disabled: isDisabled,
});
@@ -78,7 +78,8 @@ export const FormElement = ({ element, index, isEditing, isDisabled }: FormEleme
"group flex min-h-[76px] flex-1 flex-shrink-0 items-center justify-between gap-3 self-stretch rounded border border-l-[12px] border-solid border-gray-200 border-l-emerald-100 bg-white p-3 pr-4",
isEditing && "border-sky-500 border-l-blue-500",
isDisabled && "cursor-auto opacity-50",
- element.deleted && "border-l-red-200"
+ element.deleted && "border-l-red-200",
+ isDragging && "z-10 cursor-grabbing"
)}
>
@@ -110,7 +111,10 @@ export const FormElement = ({ element, index, isEditing, isDisabled }: FormEleme
aria-label="Drag handle"
disabled={isDisabled || element.deleted}
variant="ghost"
- className="p-1.5 opacity-0 group-focus-within:opacity-100 group-hover:opacity-100"
+ className={cn(
+ "p-1.5 opacity-0 group-focus-within:opacity-100 group-hover:opacity-100",
+ isDragging ? "cursor-grabbing" : "cursor-grab"
+ )}
{...listeners}
{...attributes}
tabIndex={0}
diff --git a/core/app/components/FormBuilder/SaveFormButton.tsx b/core/app/components/FormBuilder/SaveFormButton.tsx
index e34369398..7b7b1796e 100644
--- a/core/app/components/FormBuilder/SaveFormButton.tsx
+++ b/core/app/components/FormBuilder/SaveFormButton.tsx
@@ -3,6 +3,8 @@
import { Button } from "ui/button";
import { cn } from "utils";
+import { useIsChanged } from "./useIsChanged";
+
type Props = {
form: string;
className?: string;
@@ -10,6 +12,7 @@ type Props = {
};
export const SaveFormButton = ({ form, className, disabled }: Props) => {
+ const [isChanged] = useIsChanged();
return (
diff --git a/core/app/components/FormBuilder/actions.ts b/core/app/components/FormBuilder/actions.ts
index ceb54171e..db79748c8 100644
--- a/core/app/components/FormBuilder/actions.ts
+++ b/core/app/components/FormBuilder/actions.ts
@@ -1,18 +1,11 @@
"use server";
-import type { QueryCreator } from "kysely";
-
-import type {
- FormElementsId,
- FormsId,
- NewFormElements,
- NewFormElementToPubType,
- PublicSchema,
-} from "db/public";
-import { Capabilities, formElementsInitializerSchema, MembershipType } from "db/public";
+import type { FormElementsId, FormsId, NewFormElements, NewFormElementToPubType } from "db/public";
+import { Capabilities, MembershipType } from "db/public";
import { logger } from "logger";
import type { FormBuilderSchema } from "./types";
+import type { QB } from "~/lib/server/cache/types";
import { db } from "~/kysely/database";
import { isUniqueConstraintError } from "~/kysely/errors";
import { getLoginData } from "~/lib/authentication/loginData";
@@ -24,27 +17,30 @@ import { defineServerAction } from "~/lib/server/defineServerAction";
const upsertRelatedPubTypes = async (
values: NewFormElementToPubType[],
- deletedRelatedPubTypes: FormElementsId[]
+ deletedRelatedPubTypes: FormElementsId[],
+ trx = db
) => {
- db.transaction().execute(async (trx) => {
- const formElementIds = [...values.map((v) => v.A), ...deletedRelatedPubTypes];
-
- if (formElementIds.length) {
- // Delete old values
- await trx
- .deleteFrom("_FormElementToPubType")
- .where("A", "in", formElementIds)
- .execute();
- }
+ const formElementIds = [...values.map((v) => v.A), ...deletedRelatedPubTypes];
- // Insert new ones
- if (values.length) {
- await trx.insertInto("_FormElementToPubType").values(values).execute();
- }
- });
+ if (formElementIds.length) {
+ // Delete old values
+ await trx.deleteFrom("_FormElementToPubType").where("A", "in", formElementIds).execute();
+ }
+
+ // Insert new ones
+ if (values.length) {
+ await trx.insertInto("_FormElementToPubType").values(values).execute();
+ }
};
-export const saveForm = defineServerAction(async function saveForm(form: FormBuilderSchema) {
+export const saveForm = defineServerAction(async function saveForm(form: {
+ formId: FormsId;
+ upserts: NewFormElements[];
+ deletes: FormElementsId[];
+ relatedPubTypes: NewFormElementToPubType[];
+ deletedRelatedPubTypes: FormElementsId[];
+ access?: FormBuilderSchema["access"];
+}) {
const loginData = await getLoginData();
if (!loginData || !loginData.user) {
@@ -67,112 +63,60 @@ export const saveForm = defineServerAction(async function saveForm(form: FormBui
return ApiError.UNAUTHORIZED;
}
- const { elements, formId, access } = form;
- //todo: this logic determines what, if any updates to make. that should be determined on the
- //frontend so we can disable the save button if there are none
- const { upserts, deletes, relatedPubTypes, deletedRelatedPubTypes } = 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,
- ...element,
- id,
- })
- );
- if (element.relatedPubTypes) {
- for (const pubTypeId of element.relatedPubTypes) {
- acc.relatedPubTypes.push({ A: id, B: pubTypeId });
- }
- }
- } else if (element.updated) {
- acc.upserts.push(
- formElementsInitializerSchema.parse({
- ...element,
- formId,
- id: element.elementId,
- })
- ); // TODO: only update changed columns
- if (element.relatedPubTypes) {
- // If we are updating to an empty array, we should clear out all related pub types
- if (element.relatedPubTypes.length === 0) {
- 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 { formId, upserts, deletes, access, relatedPubTypes, deletedRelatedPubTypes } = form;
logger.info({ msg: "saving form", form, upserts, deletes });
- if (!upserts.length && !deletes.length) {
+ if (!upserts.length && !deletes.length && !access) {
return;
}
try {
- const deleteQuery = (db: QueryCreator
) =>
- db.deleteFrom("form_elements").where("form_elements.id", "in", deletes);
-
- const upsertQuery = (db: QueryCreator) =>
- db
- .insertInto("form_elements")
- .values(upserts)
- .onConflict((oc) =>
- oc.column("id").doUpdateSet((eb) => {
- const keys = Object.keys(upserts[0]) as (keyof NewFormElements)[];
- return Object.fromEntries(
- keys.map((key) => [key, eb.ref(`excluded.${key}`)])
- );
- })
+ const result = await db.transaction().execute(async (trx) => {
+ let query = trx as unknown;
+
+ if (upserts.length) {
+ query = (query as typeof trx).with("upserts", (db) =>
+ db
+ .insertInto("form_elements")
+ .values(upserts)
+ .onConflict((oc) =>
+ oc.column("id").doUpdateSet((eb) => {
+ const keys = Object.keys(upserts[0]) as (keyof NewFormElements)[];
+ return Object.fromEntries(
+ keys.map((key) => [key, eb.ref(`excluded.${key}`)])
+ );
+ })
+ )
);
+ }
- if (upserts.length && deletes.length) {
- await autoRevalidate(
- db
- .with("upserts", (db) => upsertQuery(db))
- .with("deletes", (db) =>
- // This isn't type safe, but it doesn't seem like there's any type safe way
- // to reuse a CTE in kysely right now
- deleteQuery(db as unknown as QueryCreator)
- )
- .updateTable("forms")
- .set({ access })
- .where("forms.id", "=", formId)
- ).executeTakeFirstOrThrow();
- } else if (deletes.length) {
- await autoRevalidate(
- db
- .with("deletes", (db) => deleteQuery(db))
- .updateTable("forms")
- .set({ access })
- .where("forms.id", "=", formId)
- ).executeTakeFirstOrThrow();
- } else if (upserts.length) {
- await autoRevalidate(
- db
- .with("upserts", (db) => upsertQuery(db))
+ if (deletes.length) {
+ query = (query as typeof trx).with("deletes", (db) =>
+ db.deleteFrom("form_elements").where("form_elements.id", "in", deletes)
+ );
+ }
+
+ if (access) {
+ query = (query as typeof trx)
.updateTable("forms")
.set({ access })
- .where("forms.id", "=", formId)
- ).executeTakeFirstOrThrow();
- }
+ .where("forms.id", "=", formId);
+ } else {
+ query = (query as typeof trx)
+ .selectFrom("forms")
+ .select("id")
+ .where("forms.id", "=", formId);
+ }
+
+ const result = await autoRevalidate(query as QB).executeTakeFirstOrThrow();
+
+ await upsertRelatedPubTypes(relatedPubTypes, deletedRelatedPubTypes, trx);
+
+ return result;
+ });
- await upsertRelatedPubTypes(relatedPubTypes, deletedRelatedPubTypes);
+ return {
+ success: true,
+ };
} catch (error) {
if (isUniqueConstraintError(error)) {
return { error: `An element with this label already exists. Choose a new name` };
diff --git a/core/app/components/FormBuilder/useIsChanged.tsx b/core/app/components/FormBuilder/useIsChanged.tsx
new file mode 100644
index 000000000..d36ea48ac
--- /dev/null
+++ b/core/app/components/FormBuilder/useIsChanged.tsx
@@ -0,0 +1,13 @@
+import { parseAsBoolean, useQueryState } from "nuqs";
+
+export const useIsChanged = () => {
+ const [isChanged, setIsChanged] = useQueryState(
+ "unsavedChanges",
+ parseAsBoolean.withDefault(false).withOptions({
+ history: "replace",
+ scroll: false,
+ })
+ );
+
+ return [isChanged, setIsChanged] as const;
+};
diff --git a/core/playwright/formBuilder.spec.ts b/core/playwright/formBuilder.spec.ts
index 063df6460..2545e7470 100644
--- a/core/playwright/formBuilder.spec.ts
+++ b/core/playwright/formBuilder.spec.ts
@@ -6,7 +6,7 @@ import type { Page } from "@playwright/test";
import { expect, test } from "@playwright/test";
-import { CoreSchemaType, ElementType, InputComponent, MemberRole } from "db/public";
+import { CoreSchemaType, ElementType, FormAccessType, InputComponent, MemberRole } from "db/public";
import type { PubFieldElement } from "~/app/components/forms/types";
import type { CommunitySeedOutput } from "~/prisma/seed/createSeed";
@@ -127,6 +127,12 @@ const seed = createSeed({
},
],
},
+ AccessForm: {
+ pubType: "Submission",
+ slug: "access-form",
+ elements: [],
+ access: FormAccessType.public,
+ },
},
});
@@ -229,7 +235,7 @@ test.describe("Submission buttons", () => {
page.on("request", (request) => {
if (request.method() === "POST" && request.url().includes(`forms/${FORM_SLUG}/edit`)) {
const data = request.postDataJSON();
- const buttons = data[0].elements.filter((e: any) => e.type === "button");
+ const buttons = data[0].upserts.filter((e: any) => e.type === "button");
const declineButton = buttons.find((b: any) => b.label === newData.label);
expect(declineButton.content).toEqual(newData.content);
}
@@ -273,12 +279,12 @@ test.describe("relationship fields", () => {
page.once("request", (request) => {
if (request.method() === "POST" && request.url().includes(`forms/${formSlug}/edit`)) {
const data = request.postDataJSON();
- const { elements } = data[0];
- const authorElement = elements.find(
+ const { upserts, relatedPubTypes } = data[0];
+ const authorElement = upserts.find(
(e: PubFieldElement) => "label" in e.config && e.config.label === "Role"
);
expect(authorElement.component).toEqual(InputComponent.textArea);
- expect(authorElement.relatedPubTypes).toEqual([pubType.id]);
+ expect(relatedPubTypes).toEqual([{ A: authorElement.id, B: pubType.id }]);
expect(authorElement.config).toMatchObject({
relationshipConfig: {
component: InputComponent.relationBlock,
@@ -343,8 +349,8 @@ test.describe("relationship fields", () => {
page.on("request", (request) => {
if (request.method() === "POST" && request.url().includes(`forms/${formSlug}/edit`)) {
const data = request.postDataJSON();
- const { elements } = data[0];
- const authorElement = elements.find(
+ const { upserts, relatedPubTypes } = data[0];
+ const authorElement = upserts.find(
(e: PubFieldElement) =>
"relationshipConfig" in e.config &&
e.config.relationshipConfig.label === "Authors"
@@ -397,4 +403,65 @@ test.describe("reordering fields", async () => {
// Make sure the form is returned in the same order it was saved in
await expect(elements).toHaveText(changedElements);
});
+
+ // TODO: ranking is considered to be different
+ test.skip("changing the order of fields and changing them back does not allow you to save", async () => {
+ const formEditPage = new FormsEditPage(
+ page,
+ community.community.slug,
+ community.forms["ReorderForm"].slug
+ );
+
+ await formEditPage.goto();
+
+ await page.getByRole("button", { name: "Drag handle" }).first().press(" ");
+ await page.keyboard.press("ArrowDown");
+ await page.keyboard.press(" ");
+
+ const disabled = await page.getByTestId("save-form-button").getAttribute("disabled");
+ expect(disabled).toBe(null);
+
+ await page.getByRole("button", { name: "Drag handle" }).first().press(" ");
+ await page.keyboard.press("ArrowDown");
+ await page.keyboard.press(" ");
+
+ const disabled2 = await page.getByTestId("save-form-button").getAttribute("disabled");
+ expect(disabled2).toBe("");
+ });
+});
+
+test.describe("changing access", () => {
+ test("can change access", async () => {
+ const formEditPage = new FormsEditPage(
+ page,
+ community.community.slug,
+ community.forms["AccessForm"].slug
+ );
+ await formEditPage.goto();
+
+ await page.getByTestId("select-form-access").click();
+ await page.getByTestId("select-form-access-public").click();
+
+ await test.step("should not be able to save form if nothing has changed", async () => {
+ const disabled = await page.getByTestId("save-form-button").getAttribute("disabled");
+ expect(disabled).toBe("");
+ });
+
+ await test.step("should be able to save form if access is changed", async () => {
+ await page.getByTestId("select-form-access").click();
+ await page.getByTestId("select-form-access-private").click();
+
+ await page.getByTestId("save-form-button").click();
+ await expect(
+ page.getByRole("status").filter({ hasText: "Form Successfully Saved" })
+ ).toHaveCount(1);
+ });
+
+ await test.step("changes should be persisted", async () => {
+ await formEditPage.goto();
+ const text = await page.getByTestId("select-form-access").textContent();
+
+ expect(text).toMatch(/private/i);
+ });
+ });
});