diff --git a/apps/blade/src/app/_components/admin/forms/editor/client.tsx b/apps/blade/src/app/_components/admin/forms/editor/client.tsx index f87972a1b..3eecdca26 100644 --- a/apps/blade/src/app/_components/admin/forms/editor/client.tsx +++ b/apps/blade/src/app/_components/admin/forms/editor/client.tsx @@ -22,7 +22,7 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { ArrowLeft, Loader2, Plus, Save, Users } from "lucide-react"; +import { ArrowLeft, CogIcon, Loader2, Plus, Save, Users } from "lucide-react"; import type { FORMS } from "@forge/consts"; import { Button } from "@forge/ui/button"; @@ -37,9 +37,17 @@ import { DialogTitle, DialogTrigger, } from "@forge/ui/dialog"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@forge/ui/dropdown-menu"; import { Input } from "@forge/ui/input"; import { Label } from "@forge/ui/label"; -import { Switch } from "@forge/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@forge/ui/tabs"; import { Textarea } from "@forge/ui/textarea"; @@ -194,6 +202,7 @@ export function EditorClient({ const [isLoading, setIsLoading] = useState(true); const [saveStatus, setSaveStatus] = useState(""); + const [isClosed, setIsClosed] = useState(false); const { data: formData, @@ -251,6 +260,7 @@ export function EditorClient({ allowResubmission, allowEdit, responseRoleIds, + isClosed, }); }, [ isLoading, @@ -267,6 +277,7 @@ export function EditorClient({ allowResubmission, allowEdit, responseRoleIds, + isClosed, ]); const saveFormRef = React.useRef(handleSaveForm); @@ -299,6 +310,7 @@ export function EditorClient({ setAllowResubmission(formData.allowResubmission); setAllowEdit(formData.allowEdit); setResponseRoleIds(formData.responseRoleIds); + setIsClosed(formData.isClosed); const formQuestions = Array.isArray(loadedFormData.questions) ? loadedFormData.questions @@ -331,7 +343,14 @@ export function EditorClient({ // auto save trigger when toggle switches are changed useEffect(() => { if (!isLoading) saveFormRef.current(); - }, [duesOnly, allowResubmission, responseRoleIds, isLoading, allowEdit]); + }, [ + duesOnly, + allowResubmission, + responseRoleIds, + isLoading, + allowEdit, + isClosed, + ]); // auto save when finishing editing an item (changing active card) useEffect(() => { @@ -555,43 +574,49 @@ export function EditorClient({
- - -
-
- - -
-
- - + + + + + + + Form Settings + + + Dues Only + + + Allow Multiple Responses + + + Allow Response Edit + + + Form Closed + + + +
- + {formResponse.isClosed && ( + + Closed + + )} {formResponse.formSlug && ( )} diff --git a/apps/blade/src/app/_components/forms/form-responder-client.tsx b/apps/blade/src/app/_components/forms/form-responder-client.tsx index aeb2ccba3..8c637d99e 100644 --- a/apps/blade/src/app/_components/forms/form-responder-client.tsx +++ b/apps/blade/src/app/_components/forms/form-responder-client.tsx @@ -38,7 +38,7 @@ export function FormResponderWrapper({ const existingResponseQuery = api.forms.getUserResponse.useQuery( { form: formIdGate }, - { enabled: !!formIdGate }, + { enabled: !!formIdGate && !formQuery.data?.isClosed }, ); const submitResponse = api.forms.createResponse.useMutation({ @@ -74,15 +74,12 @@ export function FormResponderWrapper({ const formId = formQuery.data.id; - // not found - if (existingResponseQuery.error) - return
Error Loading existing response
; - const form = formQuery.data.formData as FORMS.FormType; const zodValidator = formQuery.data.zodValidator; const isDuesOnly = formQuery.data.duesOnly; const allowResubmission = formQuery.data.allowResubmission; const allowEdit = formQuery.data.allowEdit; + const isClosed = formQuery.data.isClosed; const duesCheckFailed = !!duesQuery.error; const hasPaidDues = duesCheckFailed @@ -91,6 +88,21 @@ export function FormResponderWrapper({ const hasAlreadySubmitted = (existingResponseQuery.data?.length ?? 0) !== 0; + // Closed Gate + if (isClosed) { + return ( +
+ + +

Form Closed

+

+ This form is no longer accepting responses. +

+
+
+ ); + } + // dues gate if (isDuesOnly && !hasPaidDues) { return ( @@ -106,6 +118,8 @@ export function FormResponderWrapper({ ); } + if (existingResponseQuery.error) return
Error Loading Form State
; + // already submitted gate if (hasAlreadySubmitted && !allowResubmission) { const existing = existingResponseQuery.data?.[0]; diff --git a/apps/blade/src/app/_components/forms/form-view-edit-client.tsx b/apps/blade/src/app/_components/forms/form-view-edit-client.tsx index 2a45e2652..f317ca349 100644 --- a/apps/blade/src/app/_components/forms/form-view-edit-client.tsx +++ b/apps/blade/src/app/_components/forms/form-view-edit-client.tsx @@ -74,7 +74,7 @@ export function FormReviewWrapper({ const zodValidator = form.zodValidator; - const allowEdit = form.allowEdit; + const allowEdit = form.allowEdit && !form.isClosed; // success if (isSubmitted) { diff --git a/packages/api/src/routers/forms.ts b/packages/api/src/routers/forms.ts index 86c5195b3..4298d3f3f 100644 --- a/packages/api/src/routers/forms.ts +++ b/packages/api/src/routers/forms.ts @@ -357,6 +357,13 @@ export const formsRouter = { }); } + if (form.isClosed) { + throw new TRPCError({ + message: "This form is closed and no longer accepting responses", + code: "FORBIDDEN", + }); + } + const responseRoles = await db .select({ roleId: FormResponseRoles.roleId }) .from(FormResponseRoles) @@ -476,6 +483,13 @@ export const formsRouter = { where: (t, { eq }) => eq(t.id, existingResponse.form), }); + if (form?.isClosed) { + throw new TRPCError({ + message: "This form is closed and no longer accepting responses", + code: "FORBIDDEN", + }); + } + if (!form?.allowEdit) { throw new TRPCError({ message: "This form does not allow editing responses", @@ -585,6 +599,7 @@ export const formsRouter = { id: FormResponse.id, hasSubmitted: sql`true`, allowEdit: FormsSchemas.allowEdit, + isClosed: FormsSchemas.isClosed, }) .from(FormResponse) .leftJoin(FormsSchemas, eq(FormResponse.form, FormsSchemas.id)) @@ -608,6 +623,7 @@ export const formsRouter = { id: FormResponse.id, hasSubmitted: sql`true`, allowEdit: FormsSchemas.allowEdit, + isClosed: FormsSchemas.isClosed, }) .from(FormResponse) .leftJoin(FormsSchemas, eq(FormResponse.form, FormsSchemas.id)) @@ -627,6 +643,7 @@ export const formsRouter = { id: FormResponse.id, hasSubmitted: sql`true`, allowEdit: FormsSchemas.allowEdit, + isClosed: FormsSchemas.isClosed, }) .from(FormResponse) .leftJoin(FormsSchemas, eq(FormResponse.form, FormsSchemas.id)) @@ -1312,4 +1329,47 @@ export const formsRouter = { return { canEdit: hasSectionRole }; }), + toggleFormClosed: permProcedure + .input( + z.object({ + slug_name: z.string(), + }), + ) + .mutation(async ({ input, ctx }) => { + controlPerms.or(["EDIT_FORMS"], ctx); + + // Get the form + const form = await db.query.FormsSchemas.findFirst({ + where: (t, { eq }) => + eq(t.slugName, decodeURIComponent(input.slug_name)), + }); + + // Validate if we got the form + if (!form) { + throw new TRPCError({ message: "Form not found", code: "NOT_FOUND" }); + } + + // Update the forms isClosed state + const [updatedForm] = await db + .update(FormsSchemas) + .set({ isClosed: sql`NOT ${FormsSchemas.isClosed}` }) + .where(eq(FormsSchemas.id, form.id)) + .returning({ + isClosed: FormsSchemas.isClosed, + name: FormsSchemas.name, + }); + + if (!updatedForm) { + throw new TRPCError({ message: "Form not found", code: "NOT_FOUND" }); + } + + await log({ + title: `Form ${updatedForm.isClosed ? "closed" : "opened"}`, + message: `**Form:** ${updatedForm.name}`, + color: updatedForm.isClosed ? "uhoh_red" : "success_green", + userId: ctx.session.user.discordUserId, + }); + + return { isClosed: updatedForm.isClosed }; + }), } satisfies TRPCRouterRecord; diff --git a/packages/db/src/schemas/knight-hacks.ts b/packages/db/src/schemas/knight-hacks.ts index adade6209..bcb310505 100644 --- a/packages/db/src/schemas/knight-hacks.ts +++ b/packages/db/src/schemas/knight-hacks.ts @@ -495,6 +495,7 @@ export const FormsSchemas = createTable("form_schemas", (t) => ({ sectionId: t .uuid() .references(() => FormSections.id, { onDelete: "set null" }), + isClosed: t.boolean().notNull().default(false), })); export type Form = typeof FormsSchemas.$inferSelect;