Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 65 additions & 40 deletions apps/blade/src/app/_components/admin/forms/editor/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";

Expand Down Expand Up @@ -194,6 +202,7 @@ export function EditorClient({

const [isLoading, setIsLoading] = useState(true);
const [saveStatus, setSaveStatus] = useState<string>("");
const [isClosed, setIsClosed] = useState(false);

const {
data: formData,
Expand Down Expand Up @@ -251,6 +260,7 @@ export function EditorClient({
allowResubmission,
allowEdit,
responseRoleIds,
isClosed,
});
}, [
isLoading,
Expand All @@ -267,6 +277,7 @@ export function EditorClient({
allowResubmission,
allowEdit,
responseRoleIds,
isClosed,
]);

const saveFormRef = React.useRef(handleSaveForm);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -555,43 +574,49 @@ export function EditorClient({

<div className="flex flex-col gap-3 md:flex-row md:items-center md:gap-6 lg:gap-3">
<div className="flex items-center gap-3">
<Switch
id="dues-only"
checked={duesOnly}
onCheckedChange={setDuesOnly}
/>
<Label
htmlFor="dues-only"
className="cursor-pointer text-sm font-bold"
>
Dues Only
</Label>
</div>
<div className="flex items-center gap-3">
<Switch
id="allow-resubmit"
checked={allowResubmission}
onCheckedChange={setAllowResubmission}
/>
<Label
htmlFor="allow-resubmit"
className="cursor-pointer text-sm font-bold"
>
Allow Multiple Responses
</Label>
</div>
<div className="flex items-center gap-3">
<Switch
id="allow-edit"
checked={allowEdit}
onCheckedChange={setAllowEdit}
/>
<Label
htmlFor="allow-edit"
className="cursor-pointer text-sm font-bold"
>
Allow Response Edit
</Label>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-2"
aria-label="Open form settings"
title="Form settings"
>
<CogIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-40">
<DropdownMenuGroup>
<DropdownMenuLabel>Form Settings</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={duesOnly}
onCheckedChange={setDuesOnly}
>
Dues Only
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={allowResubmission}
onCheckedChange={setAllowResubmission}
>
Allow Multiple Responses
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={allowEdit}
onCheckedChange={setAllowEdit}
>
Allow Response Edit
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={isClosed}
onCheckedChange={setIsClosed}
>
Form Closed
</DropdownMenuCheckboxItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Dialog
open={responseRolesDialogOpen}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,19 @@ export async function FormResponses() {
{new Date(formResponse.submittedAt).toLocaleString()}
</span>
</div>

{formResponse.isClosed && (
<span className="rounded-full bg-red-100 px-2 py-1 text-xs text-red-800">
Closed
</span>
)}
{formResponse.formSlug && (
<Button asChild size="sm">
<Link
href={`/forms/${encodeURIComponent(formResponse.formSlug)}/${formResponse.id}`}
>
{formResponse.allowEdit ? "Edit" : "View"}
{formResponse.allowEdit && !formResponse.isClosed
? "Edit"
: "View"}
</Link>
</Button>
)}
Expand Down
24 changes: 19 additions & 5 deletions apps/blade/src/app/_components/forms/form-responder-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -74,15 +74,12 @@ export function FormResponderWrapper({

const formId = formQuery.data.id;

// not found
if (existingResponseQuery.error)
return <div>Error Loading existing response</div>;

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
Expand All @@ -91,6 +88,21 @@ export function FormResponderWrapper({

const hasAlreadySubmitted = (existingResponseQuery.data?.length ?? 0) !== 0;

// Closed Gate
if (isClosed) {
return (
<div className="flex min-h-screen items-center justify-center bg-primary/5 p-6">
<Card className="max-w-md p-8 text-center">
<XCircle className="mx-auto mb-4 h-16 w-16 text-destructive" />
<h1 className="mb-2 text-2xl font-bold">Form Closed</h1>
<p className="text-muted-foreground">
This form is no longer accepting responses.
</p>
</Card>
</div>
);
}

// dues gate
if (isDuesOnly && !hasPaidDues) {
return (
Expand All @@ -106,6 +118,8 @@ export function FormResponderWrapper({
);
}

if (existingResponseQuery.error) return <div>Error Loading Form State</div>;

// already submitted gate
if (hasAlreadySubmitted && !allowResubmission) {
const existing = existingResponseQuery.data?.[0];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export function FormReviewWrapper({

const zodValidator = form.zodValidator;

const allowEdit = form.allowEdit;
const allowEdit = form.allowEdit && !form.isClosed;

// success
if (isSubmitted) {
Expand Down
60 changes: 60 additions & 0 deletions packages/api/src/routers/forms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -585,6 +599,7 @@ export const formsRouter = {
id: FormResponse.id,
hasSubmitted: sql<boolean>`true`,
allowEdit: FormsSchemas.allowEdit,
isClosed: FormsSchemas.isClosed,
})
.from(FormResponse)
.leftJoin(FormsSchemas, eq(FormResponse.form, FormsSchemas.id))
Expand All @@ -608,6 +623,7 @@ export const formsRouter = {
id: FormResponse.id,
hasSubmitted: sql<boolean>`true`,
allowEdit: FormsSchemas.allowEdit,
isClosed: FormsSchemas.isClosed,
})
.from(FormResponse)
.leftJoin(FormsSchemas, eq(FormResponse.form, FormsSchemas.id))
Expand All @@ -627,6 +643,7 @@ export const formsRouter = {
id: FormResponse.id,
hasSubmitted: sql<boolean>`true`,
allowEdit: FormsSchemas.allowEdit,
isClosed: FormsSchemas.isClosed,
})
.from(FormResponse)
.leftJoin(FormsSchemas, eq(FormResponse.form, FormsSchemas.id))
Expand Down Expand Up @@ -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;
1 change: 1 addition & 0 deletions packages/db/src/schemas/knight-hacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down