diff --git a/apps/blade/src/app/admin/forms/[slug]/client.tsx b/apps/blade/src/app/admin/forms/[slug]/client.tsx index 46cc489c..502c301e 100644 --- a/apps/blade/src/app/admin/forms/[slug]/client.tsx +++ b/apps/blade/src/app/admin/forms/[slug]/client.tsx @@ -22,7 +22,7 @@ import { 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, @@ -31,6 +31,16 @@ import type { } 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"; @@ -61,6 +71,10 @@ function SortableItem({ onDuplicateInstruction, onClick, onForceSave, + onMoveUp, + onMoveDown, + canMoveUp, + canMoveDown, error, }: { item: UIQuestion | UIInstruction; @@ -73,6 +87,10 @@ function SortableItem({ onDuplicateInstruction: (i: UIInstruction) => void; onClick: () => void; onForceSave: () => void; + onMoveUp?: () => void; + onMoveDown?: () => void; + canMoveUp?: boolean; + canMoveDown?: boolean; error?: string; }) { const { attributes, listeners, setNodeRef, transform, transition } = @@ -102,6 +120,10 @@ function SortableItem({ onDelete={onDelete} onDuplicate={onDuplicateInstruction} dragHandleProps={listeners} + onMoveUp={onMoveUp} + onMoveDown={onMoveDown} + canMoveUp={canMoveUp} + canMoveDown={canMoveDown} /> ) : ( )} @@ -162,6 +188,8 @@ export function EditorClient({ const [instructions, setInstructions] = useState([]); const [duesOnly, setDuesOnly] = useState(false); const [allowResubmission, setAllowResubmission] = useState(true); + const [responseRoleIds, setResponseRoleIds] = useState([]); + const [responseRolesDialogOpen, setResponseRolesDialogOpen] = useState(false); const [activeItemId, setActiveItemId] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -177,6 +205,8 @@ export function EditorClient({ { retry: false, refetchOnWindowFocus: false }, ); + const { data: allRoles = [] } = api.roles.getAllLinks.useQuery(); + const updateFormMutation = api.forms.updateForm.useMutation({ onMutate: () => setSaveStatus("Saving..."), onSuccess: () => @@ -227,6 +257,7 @@ export function EditorClient({ }, duesOnly, allowResubmission, + responseRoleIds, } as any); }, [ formTitle, @@ -265,6 +296,7 @@ export function EditorClient({ 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 }) => ({ @@ -292,7 +324,7 @@ export function EditorClient({ // 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 // auto save when finishing editing an item (changing active card) useEffect(() => { @@ -405,6 +437,44 @@ export function EditorClient({ }), ); + 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) { @@ -447,27 +517,36 @@ export function EditorClient({ return (
setActiveItemId(null)} >
-
-
+
+
-
- - {saveStatus || "Synced with Database"} +
+ + + {saveStatus || "Synced with Database"} +
-
+
+ + + + + + + Configure Response Roles + + Select which roles can respond to this form. If no roles are + selected, anyone can respond. + + +
+
+ {allRoles.map((role) => { + const isSelected = responseRoleIds.includes(role.id); + return ( +
+ { + if (checked) { + setResponseRoleIds([ + ...responseRoleIds, + role.id, + ]); + } else { + setResponseRoleIds( + responseRoleIds.filter( + (id) => id !== role.id, + ), + ); + } + }} + /> + +
+ ); + })} + {allRoles.length === 0 && ( +

+ No roles available. Create roles first. +

+ )} +
+
+ + + + +
+
-
+
{[...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 (
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" @@ -573,14 +737,14 @@ export function EditorClient({ -
+
@@ -591,7 +755,7 @@ export function EditorClient({ }} 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" > Add Instruction diff --git a/apps/blade/src/app/admin/forms/[slug]/responses/_components/AllResponsesView.tsx b/apps/blade/src/app/admin/forms/[slug]/responses/_components/AllResponsesView.tsx index acb8734e..3573fd11 100644 --- a/apps/blade/src/app/admin/forms/[slug]/responses/_components/AllResponsesView.tsx +++ b/apps/blade/src/app/admin/forms/[slug]/responses/_components/AllResponsesView.tsx @@ -31,10 +31,11 @@ export function AllResponsesView({ {/* max-w-4xl mx-auto centers the charts and limits width */}
{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 ( - {/* 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 */}
{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 ( diff --git a/apps/blade/src/app/admin/forms/[slug]/responses/_components/FileUploadResponsesTable.tsx b/apps/blade/src/app/admin/forms/[slug]/responses/_components/FileUploadResponsesTable.tsx index 8158a785..cb10f7fd 100644 --- a/apps/blade/src/app/admin/forms/[slug]/responses/_components/FileUploadResponsesTable.tsx +++ b/apps/blade/src/app/admin/forms/[slug]/responses/_components/FileUploadResponsesTable.tsx @@ -46,7 +46,7 @@ export function FileUploadResponsesTable({ return ( - {question} + {question}

@@ -60,7 +60,7 @@ export function FileUploadResponsesTable({ return ( - {question} + {question}

{responses.length} {responses.length === 1 ? "response" : "responses"}

diff --git a/apps/blade/src/app/admin/forms/[slug]/responses/_components/PerUserResponsesView.tsx b/apps/blade/src/app/admin/forms/[slug]/responses/_components/PerUserResponsesView.tsx index aa7b12fa..44890b6e 100644 --- a/apps/blade/src/app/admin/forms/[slug]/responses/_components/PerUserResponsesView.tsx +++ b/apps/blade/src/app/admin/forms/[slug]/responses/_components/PerUserResponsesView.tsx @@ -214,7 +214,7 @@ export function PerUserResponsesView({ return (
-

+

{question.question} {!question.optional && ( * @@ -234,7 +234,13 @@ export function PerUserResponsesView({ {formatResponseValue(answer)} ) : ( -

+

{formatResponseValue(answer)}

)} diff --git a/apps/blade/src/app/admin/forms/[slug]/responses/_components/ResponseBarChart.tsx b/apps/blade/src/app/admin/forms/[slug]/responses/_components/ResponseBarChart.tsx index 88f277b9..e5cf2da5 100644 --- a/apps/blade/src/app/admin/forms/[slug]/responses/_components/ResponseBarChart.tsx +++ b/apps/blade/src/app/admin/forms/[slug]/responses/_components/ResponseBarChart.tsx @@ -73,7 +73,9 @@ export function ResponseBarChart({ {/* question text as card title */} - {question} + + {question} + {/* show total number of responses */}

{responses.length} {responses.length === 1 ? "response" : "responses"} diff --git a/apps/blade/src/app/admin/forms/[slug]/responses/_components/ResponseHorizontalBarChart.tsx b/apps/blade/src/app/admin/forms/[slug]/responses/_components/ResponseHorizontalBarChart.tsx index 4d939a3f..10d7be57 100644 --- a/apps/blade/src/app/admin/forms/[slug]/responses/_components/ResponseHorizontalBarChart.tsx +++ b/apps/blade/src/app/admin/forms/[slug]/responses/_components/ResponseHorizontalBarChart.tsx @@ -88,7 +88,9 @@ export function ResponseHorizontalBarChart({ {/* question text as card title */} - {question} + + {question} + {/* show total number of responses */}

{responses.length} {responses.length === 1 ? "response" : "responses"} diff --git a/apps/blade/src/app/admin/forms/[slug]/responses/_components/ResponsePieChart.tsx b/apps/blade/src/app/admin/forms/[slug]/responses/_components/ResponsePieChart.tsx index aecb6184..bba25b2a 100644 --- a/apps/blade/src/app/admin/forms/[slug]/responses/_components/ResponsePieChart.tsx +++ b/apps/blade/src/app/admin/forms/[slug]/responses/_components/ResponsePieChart.tsx @@ -34,12 +34,22 @@ export function ResponsePieChart({ if (answer !== undefined && answer !== null) { // increment count for this answer let answerStr: string; - if (typeof answer === "string") { - answerStr = answer; + if (typeof answer === "boolean") { + // convert boolean to "Yes" or "No" for display + answerStr = answer ? "Yes" : "No"; + } else if (typeof answer === "string") { + // convert "true"/"false" strings to "Yes"/"No" for boolean questions + if (answer === "true") { + answerStr = "Yes"; + } else if (answer === "false") { + answerStr = "No"; + } else { + answerStr = answer; + } } else if (typeof answer === "object") { answerStr = JSON.stringify(answer); } else { - // for primitive types (number, boolean, etc.) - safe to stringify + // for primitive types (number, etc.) - safe to stringify // eslint-disable-next-line @typescript-eslint/no-base-to-string answerStr = String(answer); } @@ -77,7 +87,9 @@ export function ResponsePieChart({ {/* question text as card title */} - {question} + + {question} + {/* show total number of responses */}

{responses.length} {responses.length === 1 ? "response" : "responses"} diff --git a/apps/blade/src/app/admin/forms/[slug]/responses/_components/ResponsesTable.tsx b/apps/blade/src/app/admin/forms/[slug]/responses/_components/ResponsesTable.tsx index 49d7342e..9f90636c 100644 --- a/apps/blade/src/app/admin/forms/[slug]/responses/_components/ResponsesTable.tsx +++ b/apps/blade/src/app/admin/forms/[slug]/responses/_components/ResponsesTable.tsx @@ -35,7 +35,7 @@ export function ResponsesTable({ question, responses }: ResponsesTableProps) { return ( - {question} + {question}

@@ -49,7 +49,7 @@ export function ResponsesTable({ question, responses }: ResponsesTableProps) { return ( - {question} + {question} {/* show total response count */}

{responses.length} {responses.length === 1 ? "response" : "responses"} @@ -94,8 +94,10 @@ export function ResponsesTable({ question, responses }: ResponsesTableProps) { ); } catch { - // Not a valid URL, just display as string - displayValue = answer; + // Not a valid URL, just display as string (preserve newlines) + displayValue = ( + {answer} + ); } } else if (typeof answer === "object") { displayValue = JSON.stringify(answer); diff --git a/apps/blade/src/app/forms/[formName]/_components/question-response-card.tsx b/apps/blade/src/app/forms/[formName]/_components/question-response-card.tsx index fbe9fc9a..631dc891 100644 --- a/apps/blade/src/app/forms/[formName]/_components/question-response-card.tsx +++ b/apps/blade/src/app/forms/[formName]/_components/question-response-card.tsx @@ -7,6 +7,7 @@ import Image from "next/image"; import { FileUp, Loader2, X } from "lucide-react"; import type { QuestionValidator } from "@forge/consts/knight-hacks"; +import { getDropdownOptionsFromConst } from "@forge/consts/knight-hacks"; import { Button } from "@forge/ui/button"; import { Card } from "@forge/ui/card"; import { Checkbox } from "@forge/ui/checkbox"; @@ -14,6 +15,7 @@ import { DatePicker } from "@forge/ui/date-picker"; import { Input } from "@forge/ui/input"; import { Label } from "@forge/ui/label"; import { RadioGroup, RadioGroupItem } from "@forge/ui/radio-group"; +import { ResponsiveComboBox } from "@forge/ui/responsive-combo-box"; import { Select, SelectContent, @@ -22,6 +24,7 @@ import { SelectValue, } from "@forge/ui/select"; import { Slider } from "@forge/ui/slider"; +import { Textarea } from "@forge/ui/textarea"; import { TimePicker } from "@forge/ui/time-picker"; import { toast } from "@forge/ui/toast"; @@ -55,7 +58,7 @@ export function QuestionResponseCard({ {/* Header */}

-

+

{question.question} {isRequired && *}

@@ -106,19 +109,61 @@ function QuestionBody({ formId?: string; }) { switch (question.type) { - case "SHORT_ANSWER": - case "PARAGRAPH": + case "SHORT_ANSWER": { + const currentValue = (value as string) || ""; + const maxLength = 150; + const charCount = currentValue.length; + const isOverLimit = charCount > maxLength; + return ( -
+
onChange(e.target.value)} disabled={disabled} className="rounded-none border-x-0 border-b border-t-0 border-gray-300 bg-transparent px-0 shadow-none outline-none focus-visible:border-b-2 focus-visible:border-primary focus-visible:ring-0" /> +
+ + {charCount}/{maxLength} + +
+
+ ); + } + case "PARAGRAPH": { + const currentValue = (value as string) || ""; + const maxLength = 750; + const charCount = currentValue.length; + const isOverLimit = charCount > maxLength; + + return ( +
+