Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
111 changes: 111 additions & 0 deletions app/(authenticated)/forms/_components/form-actions-cell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"use client";

import * as React from "react";
import { Pencil, Trash2 } from "lucide-react";
import { toast } from "sonner";

import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { deleteForm } from "../actions";
import type { FormListItem } from "../queries";

export function FormActionsCell({
form,
onEdit,
}: {
form: FormListItem;
onEdit: (form: FormListItem) => void;
}) {
const [deleteOpen, setDeleteOpen] = React.useState(false);
const [pending, startTransition] = React.useTransition();

const handleDelete = () => {
const fd = new FormData();
fd.set("form_id", form.id);
startTransition(async () => {
const result = await deleteForm(null, fd);
if (result?.errors?._form) {
toast.error("Could not delete form", {
description: result.errors._form.join(" "),
});
return;
}
if (result?.message) {
toast.success(result.message);
setDeleteOpen(false);
}
});
};

return (
<>
<div className="flex items-center justify-end gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
aria-label="Edit form"
onClick={() => onEdit(form)}
>
<Pencil />
</Button>
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
aria-label="Delete form"
onClick={() => setDeleteOpen(true)}
>
<Trash2 />
</Button>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</div>

<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent className="sm:max-w-md">
<AlertDialogHeader className="place-items-start text-left sm:place-items-start sm:text-left">
<AlertDialogTitle>Delete form?</AlertDialogTitle>
<AlertDialogDescription>
{form.attachedServiceCount > 0
? `This form is attached to ${form.attachedServiceCount} service(s). Detach it from those services before deleting.`
: `Permanently delete "${form.name}" and all of its questions. This cannot be undone.`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="sm:flex-row sm:justify-end">
<AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={pending || form.attachedServiceCount > 0}
onClick={(e) => {
e.preventDefault();
handleDelete();
}}
>
{pending ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
287 changes: 287 additions & 0 deletions app/(authenticated)/forms/_components/form-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
"use client";

import * as React from "react";
import { useActionState } from "react";
import { Plus } from "lucide-react";

import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";

import {
createForm,
loadFormForEdit,
updateForm,
type FormActionState,
} from "../actions";
import type { FormListItem } from "../queries";
import { FormQuestionsList } from "./form-questions-list";
import {
emptyQuestion,
type DraftQuestion,
questionsForSubmit,
} from "./form-question-shared";
import { QuestionEditDialog } from "./question-edit-dialog";

type Props =
| { mode: "add" }
| {
mode: "edit";
form: FormListItem | null;
open: boolean;
onOpenChange: (open: boolean) => void;
};

function FieldError({ messages }: { messages?: string[] }) {
if (!messages?.length) return null;
return (
<ul className="flex flex-col gap-0.5 text-xs text-destructive">
{messages.map((m, i) => (
<li key={i}>{m}</li>
))}
</ul>
);
}

export function FormDialog(props: Props) {
const isEdit = props.mode === "edit";
const form = isEdit ? props.form : null;

const [name, setName] = React.useState("");
const [questions, setQuestions] = React.useState<DraftQuestion[]>([]);
const [loading, setLoading] = React.useState(false);
const [editingIndex, setEditingIndex] = React.useState<number | null>(null);
const [pendingNewQuestion, setPendingNewQuestion] =
React.useState<DraftQuestion | null>(null);
const [questionDialogOpen, setQuestionDialogOpen] = React.useState(false);

const boundFormAction = React.useCallback(
(prev: FormActionState, formData: FormData) => {
formData.set("questions", JSON.stringify(questionsForSubmit(questions)));
return isEdit ? updateForm(prev, formData) : createForm(prev, formData);
},
[isEdit, questions],
);

const [state, formAction, pending] = useActionState<
FormActionState,
FormData
>(boundFormAction, null);

React.useEffect(() => {
if (!isEdit || !form?.id || !props.open) return;

let cancelled = false;
setLoading(true);
loadFormForEdit(form.id)
.then((data) => {
if (cancelled || !data) return;
setName(data.name);
setQuestions(
data.questions.map((q) => ({
clientId: crypto.randomUUID(),
type: q.type,
prompt: q.prompt,
options: q.options ?? undefined,
})),
);
})
.finally(() => {
if (!cancelled) setLoading(false);
});

return () => {
cancelled = true;
};
}, [isEdit, form?.id, isEdit ? props.open : false]);

Check warning on line 107 in app/(authenticated)/forms/_components/form-dialog.tsx

View workflow job for this annotation

GitHub Actions / test

React Hook React.useEffect has a complex expression in the dependency array. Extract it to a separate variable so it can be statically checked

Check warning on line 107 in app/(authenticated)/forms/_components/form-dialog.tsx

View workflow job for this annotation

GitHub Actions / test

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bad practice.

   const open = isEdit ? props.open : false
   const onOpenChange = isEdit ? props.onOpenChange : undefined;
Suggested change
}, [isEdit, form?.id, isEdit ? props.open : false]);
}, [isEdit, form?.id, open]);


const closeRef = React.useRef<HTMLButtonElement>(null);
const prevState = React.useRef<FormActionState>(null);
React.useEffect(() => {
if (state === prevState.current) return;
prevState.current = state;
if (state?.message && !state.errors) {
if (isEdit && props.mode === "edit") {
props.onOpenChange(false);
} else {
closeRef.current?.click();
setName("");
setQuestions([]);
}
}
}, [state, isEdit, props]);

const errors = state?.errors;

const dialogControl = isEdit
? { open: props.open, onOpenChange: props.onOpenChange }
: {};

const showForm = !isEdit || (form !== null && !loading);

const openQuestionEditor = (index: number) => {
setPendingNewQuestion(null);
setEditingIndex(index);
setQuestionDialogOpen(true);
};

const handleQuestionDialogOpenChange = (open: boolean) => {
setQuestionDialogOpen(open);
if (!open) {
setPendingNewQuestion(null);
setEditingIndex(null);
}
};

const addQuestion = () => {
setEditingIndex(questions.length);
setPendingNewQuestion(emptyQuestion());
setQuestionDialogOpen(true);
};

const saveQuestion = (updated: DraftQuestion) => {
if (pendingNewQuestion) {
setQuestions((prev) => [...prev, updated]);
setPendingNewQuestion(null);
} else if (editingIndex !== null) {
setQuestions((prev) =>
prev.map((q, i) => (i === editingIndex ? updated : q)),
);
}
setEditingIndex(null);
};

const deleteQuestion = () => {
if (pendingNewQuestion) {
setPendingNewQuestion(null);
setEditingIndex(null);
return;
}
if (editingIndex === null) return;
setQuestions((prev) => prev.filter((_, i) => i !== editingIndex));
setEditingIndex(null);
};

const editingQuestion =
pendingNewQuestion ??
(editingIndex !== null ? (questions[editingIndex] ?? null) : null);
const isEditingNewQuestion = pendingNewQuestion !== null;

return (
<>
<Dialog {...dialogControl}>
{!isEdit && (
<DialogTrigger asChild>
<Button>
<Plus />
Add form
</Button>
</DialogTrigger>
)}
<DialogContent className="flex max-h-[90vh] w-full max-w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl">
<DialogHeader className="shrink-0 border-b border-border px-4 py-4">
<DialogTitle>{isEdit ? "Edit form" : "New form"}</DialogTitle>
<DialogDescription>
{isEdit
? form && form.attachedServiceCount > 0
? `Used by ${form.attachedServiceCount} service(s). Changes apply everywhere this form is attached.`
: "Update the form name and questions."
: "Create a reusable form for kid services."}
</DialogDescription>
</DialogHeader>

{loading && isEdit ? (
<div className="flex flex-1 items-center justify-center px-4 py-12">
<Spinner className="size-8 text-muted-foreground" />
</div>
) : showForm ? (
<form
key={form?.id ?? "new"}
action={formAction}
className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
>
{form && (
<input type="hidden" name="form_id" value={form.id} />
)}

<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 py-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="name">Name</Label>
<Input
id="name"
name="name"
required
value={name}
onChange={(e) => setName(e.target.value)}
/>
<FieldError messages={errors?.name} />
</div>

<div className="flex min-w-0 flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<Label>Questions</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={addQuestion}
>
<Plus className="size-4" />
Add question
</Button>
</div>
<FormQuestionsList
questions={questions}
onChange={setQuestions}
onEdit={openQuestionEditor}
/>
<FieldError messages={errors?.questions} />
</div>

<FieldError messages={errors?._form} />
</div>

<DialogFooter className="mx-0 mb-0 mt-0 shrink-0 flex-row justify-end gap-2 border-t border-border bg-muted/30 px-4 py-4">
<DialogClose ref={closeRef} asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</DialogClose>
<Button type="submit" disabled={pending}>
{pending
? isEdit
? "Saving..."
: "Creating..."
: isEdit
? "Save changes"
: "Create form"}
</Button>
</DialogFooter>
</form>
) : null}
</DialogContent>
</Dialog>

<QuestionEditDialog
open={questionDialogOpen}
onOpenChange={handleQuestionDialogOpenChange}
questionIndex={editingIndex}
question={editingQuestion}
onSave={saveQuestion}
onDelete={deleteQuestion}
canDelete={isEditingNewQuestion || questions.length > 1}
/>
</>
);
}
Loading
Loading