Skip to content
Merged
194 changes: 179 additions & 15 deletions apps/blade/src/app/admin/forms/[slug]/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { ArrowLeft, Loader2, Plus, Save } from "lucide-react";
import { ArrowLeft, Loader2, Plus, Save, Users } from "lucide-react";

import type {
FormType,
Expand All @@ -31,6 +31,16 @@
} from "@forge/consts/knight-hacks";
import { Button } from "@forge/ui/button";
import { Card } from "@forge/ui/card";
import { Checkbox } from "@forge/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@forge/ui/dialog";
import { Input } from "@forge/ui/input";
import { Label } from "@forge/ui/label";
import { Switch } from "@forge/ui/switch";
Expand Down Expand Up @@ -61,6 +71,10 @@
onDuplicateInstruction,
onClick,
onForceSave,
onMoveUp,
onMoveDown,
canMoveUp,
canMoveDown,
error,
}: {
item: UIQuestion | UIInstruction;
Expand All @@ -73,6 +87,10 @@
onDuplicateInstruction: (i: UIInstruction) => void;
onClick: () => void;
onForceSave: () => void;
onMoveUp?: () => void;
onMoveDown?: () => void;
canMoveUp?: boolean;
canMoveDown?: boolean;
error?: string;
}) {
const { attributes, listeners, setNodeRef, transform, transition } =
Expand Down Expand Up @@ -102,6 +120,10 @@
onDelete={onDelete}
onDuplicate={onDuplicateInstruction}
dragHandleProps={listeners}
onMoveUp={onMoveUp}
onMoveDown={onMoveDown}
canMoveUp={canMoveUp}
canMoveDown={canMoveDown}
/>
) : (
<QuestionEditCard
Expand All @@ -113,6 +135,10 @@
onForceSave={onForceSave}
error={error}
dragHandleProps={listeners}
onMoveUp={onMoveUp}
onMoveDown={onMoveDown}
canMoveUp={canMoveUp}
canMoveDown={canMoveDown}
/>
)}
</div>
Expand Down Expand Up @@ -162,6 +188,8 @@
const [instructions, setInstructions] = useState<UIInstruction[]>([]);
const [duesOnly, setDuesOnly] = useState(false);
const [allowResubmission, setAllowResubmission] = useState(true);
const [responseRoleIds, setResponseRoleIds] = useState<string[]>([]);
const [responseRolesDialogOpen, setResponseRolesDialogOpen] = useState(false);
const [activeItemId, setActiveItemId] = useState<string | null>(null);

const [isLoading, setIsLoading] = useState(true);
Expand All @@ -177,6 +205,8 @@
{ retry: false, refetchOnWindowFocus: false },
);

const { data: allRoles = [] } = api.roles.getAllLinks.useQuery();

const updateFormMutation = api.forms.updateForm.useMutation({
onMutate: () => setSaveStatus("Saving..."),
onSuccess: () =>
Expand Down Expand Up @@ -227,8 +257,9 @@
},
duesOnly,
allowResubmission,
responseRoleIds,
} as any);
}, [

Check warning on line 262 in apps/blade/src/app/admin/forms/[slug]/client.tsx

View workflow job for this annotation

GitHub Actions / lint

React Hook React.useCallback has a missing dependency: 'responseRoleIds'. Either include it or remove the dependency array
formTitle,
formDescription,
formBanner,
Expand Down Expand Up @@ -265,6 +296,7 @@
setFormBanner(formData.formData.banner || "");
setDuesOnly(formData.duesOnly);
setAllowResubmission(formData.allowResubmission);
setResponseRoleIds((formData as any).responseRoleIds || []);

const loadedQuestions: UIQuestion[] = formData.formData.questions.map(
(q: FormQuestion & { order?: number }) => ({
Expand Down Expand Up @@ -292,12 +324,12 @@
// auto save trigger when toggle switches are changed
useEffect(() => {
if (!isLoading) handleSaveForm();
}, [duesOnly, allowResubmission, isLoading]); // removed handleSaveForm to prevent save-on-every-render
}, [duesOnly, allowResubmission, responseRoleIds, isLoading]); // removed handleSaveForm to prevent save-on-every-render

Check warning on line 327 in apps/blade/src/app/admin/forms/[slug]/client.tsx

View workflow job for this annotation

GitHub Actions / lint

React Hook useEffect has a missing dependency: 'handleSaveForm'. Either include it or remove the dependency array

// auto save when finishing editing an item (changing active card)
useEffect(() => {
if (!isLoading) handleSaveForm();
}, [activeItemId, isLoading]); // triggers when switching items or clicking off

Check warning on line 332 in apps/blade/src/app/admin/forms/[slug]/client.tsx

View workflow job for this annotation

GitHub Actions / lint

React Hook useEffect has a missing dependency: 'handleSaveForm'. Either include it or remove the dependency array

// Periodic auto-save every 40 seconds
useEffect(() => {
Expand Down Expand Up @@ -405,6 +437,44 @@
}),
);

const reorderItems = React.useCallback(
(itemId: string, direction: "up" | "down") => {
const combined = [...questions, ...instructions].sort(
(a, b) => (a.order ?? 999) - (b.order ?? 999),
);
const currentIndex = combined.findIndex((item) => item.id === itemId);

if (currentIndex === -1) return;

const newIndex = direction === "up" ? currentIndex - 1 : currentIndex + 1;

if (newIndex < 0 || newIndex >= combined.length) return;

const reordered = arrayMove(combined, currentIndex, newIndex);

// Update order and split back into separate arrays
const updatedItems = reordered.map((item, idx) => ({
...item,
order: idx,
}));
const newQuestions: UIQuestion[] = [];
const newInstructions: UIInstruction[] = [];

updatedItems.forEach((item) => {
if ("question" in item) {
newQuestions.push(item as UIQuestion);
} else {
newInstructions.push(item as UIInstruction);
}
});

setQuestions(newQuestions);
setInstructions(newInstructions);
setTimeout(() => handleSaveForm(), 100);
},
[questions, instructions, handleSaveForm],
);

const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
Expand Down Expand Up @@ -447,27 +517,36 @@

return (
<div
className="min-h-screen bg-primary/5 p-8 pb-32"
className="min-h-screen bg-primary/5 px-3 py-8 pb-32 md:px-8"
onClick={() => setActiveItemId(null)}
>
<div className="mx-auto max-w-3xl space-y-4">
<div className="flex flex-col items-center justify-between gap-4 rounded-xl border bg-card/50 p-4 shadow-sm backdrop-blur-sm md:flex-row">
<div className="flex items-center gap-4">
<div className="flex flex-col gap-3 rounded-xl border bg-card/50 p-3 shadow-sm backdrop-blur-sm md:flex-row md:items-center md:justify-between md:gap-4 md:p-4">
<div className="flex items-center gap-3 md:gap-4">
<Button
variant="ghost"
variant="primary"
size="icon"
onClick={() => router.push("/admin/forms")}
aria-label="Back to forms"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-muted-foreground">
<Save className="h-3 w-3" />
{saveStatus || "Synced with Database"}
<div className="flex items-center gap-2">
<Button
variant="primary"
size="icon"
onClick={handleSaveForm}
aria-label="Save form"
>
<Save className="h-3 w-3" />
</Button>
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{saveStatus || "Synced with Database"}
</span>
</div>
</div>

<div className="flex items-center gap-8">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:gap-6 lg:gap-8">
<div className="flex items-center gap-3">
<Switch
id="dues-only"
Expand All @@ -494,11 +573,90 @@
Allow Multiple Responses
</Label>
</div>
<Dialog
open={responseRolesDialogOpen}
onOpenChange={setResponseRolesDialogOpen}
>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Users className="h-4 w-4" />
Response Roles
{responseRoleIds.length > 0 && (
<span className="ml-1 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground">
{responseRoleIds.length}
</span>
)}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Configure Response Roles</DialogTitle>
<DialogDescription>
Select which roles can respond to this form. If no roles are
selected, anyone can respond.
</DialogDescription>
</DialogHeader>
<div className="max-h-[400px] overflow-y-auto py-4">
<div className="space-y-3">
{allRoles.map((role) => {
const isSelected = responseRoleIds.includes(role.id);
return (
<div
key={role.id}
className="flex items-center gap-3 rounded-md border p-3 hover:bg-muted/50"
>
<Checkbox
id={`role-${role.id}`}
checked={isSelected}
onCheckedChange={(checked) => {
if (checked) {
setResponseRoleIds([
...responseRoleIds,
role.id,
]);
} else {
setResponseRoleIds(
responseRoleIds.filter(
(id) => id !== role.id,
),
);
}
}}
/>
<Label
htmlFor={`role-${role.id}`}
className="flex-1 cursor-pointer text-sm font-normal"
>
{role.name}
</Label>
</div>
);
})}
{allRoles.length === 0 && (
<p className="text-sm text-muted-foreground">
No roles available. Create roles first.
</p>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setResponseRoleIds([])}
>
Clear All
</Button>
<Button onClick={() => setResponseRolesDialogOpen(false)}>
Done
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>

<Card className="overflow-hidden border-t-[12px] border-t-primary bg-card shadow-lg transition-all">
<div className="flex flex-col gap-2 p-8">
<div className="flex flex-col gap-2 px-4 py-8 md:px-8">
<Input
className="h-auto border-none p-0 text-4xl font-extrabold focus-visible:ring-0"
placeholder="Form Title"
Expand Down Expand Up @@ -542,8 +700,10 @@
<div className="space-y-4">
{[...questions, ...instructions]
.sort((a, b) => (a.order ?? 999) - (b.order ?? 999))
.map((item) => {
.map((item, index, sortedArray) => {
const isInstruction = "title" in item;
const canMoveUp = index > 0;
const canMoveDown = index < sortedArray.length - 1;
return (
<div
key={item.id}
Expand All @@ -560,6 +720,10 @@
onDuplicateInstruction={duplicateInstruction}
onClick={() => setActiveItemId(item.id)}
onForceSave={handleSaveForm}
onMoveUp={() => reorderItems(item.id, "up")}
onMoveDown={() => reorderItems(item.id, "down")}
canMoveUp={canMoveUp}
canMoveDown={canMoveDown}
error={
!isInstruction && duplicateIds.has(item.id)
? "Duplicate question title"
Expand All @@ -573,14 +737,14 @@
</SortableContext>
</DndContext>

<div className="flex justify-center gap-4 pt-8">
<div className="flex flex-col justify-center gap-3 pt-8 md:flex-row md:gap-4">
<Button
onClick={(e) => {
e.stopPropagation();
addQuestion();
}}
size="lg"
className="h-14 rounded-full px-10 text-lg font-bold shadow-2xl transition-all hover:scale-105 active:scale-95"
className="h-14 rounded-full px-6 text-lg font-bold shadow-2xl transition-all hover:scale-105 active:scale-95 md:px-10"
>
<Plus className="mr-3 h-6 w-6" /> Add Question
</Button>
Expand All @@ -591,7 +755,7 @@
}}
size="lg"
variant="secondary"
className="h-14 rounded-full px-10 text-lg font-bold shadow-2xl transition-all hover:scale-105 active:scale-95"
className="h-14 rounded-full px-6 text-lg font-bold shadow-2xl transition-all hover:scale-105 active:scale-95 md:px-10"
>
<Plus className="mr-3 h-6 w-6" /> Add Instruction
</Button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,11 @@ export function AllResponsesView({
{/* max-w-4xl mx-auto centers the charts and limits width */}
<div className="mx-auto max-w-4xl space-y-2 md:space-y-6">
{formData.questions.map((question) => {
// render pie chart for MULTIPLE_CHOICE or DROPDOWN questions
// render pie chart for MULTIPLE_CHOICE, DROPDOWN, or BOOLEAN questions
if (
question.type === "MULTIPLE_CHOICE" ||
question.type === "DROPDOWN"
question.type === "DROPDOWN" ||
question.type === "BOOLEAN"
) {
return (
<ResponsePieChart
Expand Down Expand Up @@ -71,17 +72,16 @@ export function AllResponsesView({
})}
</div>

{/* text responses section - for SHORT_ANSWER, PARAGRAPH, EMAIL, PHONE, BOOLEAN, and LINK questions */}
{/* text responses section - for SHORT_ANSWER, PARAGRAPH, EMAIL, PHONE, and LINK questions */}
{/* renders a separate table for each text-based question */}
<div className="mx-auto mt-3 max-w-4xl space-y-2 md:mt-8 md:space-y-6">
{formData.questions.map((question) => {
// render table for SHORT_ANSWER, PARAGRAPH, EMAIL, PHONE, BOOLEAN, or LINK questions
// render table for SHORT_ANSWER, PARAGRAPH, EMAIL, PHONE, or LINK questions
if (
question.type === "SHORT_ANSWER" ||
question.type === "PARAGRAPH" ||
question.type === "EMAIL" ||
question.type === "PHONE" ||
question.type === "BOOLEAN" ||
question.type === "LINK"
) {
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export function FileUploadResponsesTable({
return (
<Card>
<CardHeader>
<CardTitle>{question}</CardTitle>
<CardTitle className="whitespace-pre-line">{question}</CardTitle>
</CardHeader>
<CardContent>
<p className="py-8 text-center text-muted-foreground">
Expand All @@ -60,7 +60,7 @@ export function FileUploadResponsesTable({
return (
<Card>
<CardHeader>
<CardTitle>{question}</CardTitle>
<CardTitle className="whitespace-pre-line">{question}</CardTitle>
<p className="mt-1 text-sm text-muted-foreground">
{responses.length} {responses.length === 1 ? "response" : "responses"}
</p>
Expand Down
Loading
Loading