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
185 changes: 123 additions & 62 deletions apps/blade/src/app/admin/forms/[slug]/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import { Loader2, Plus, Save } from "lucide-react";

import type {
FormType,
InstructionValidator,
QuestionValidator,
} from "@forge/consts/knight-hacks";
Expand All @@ -33,11 +34,16 @@
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";

import type { MatchingType } from "./linker";
import type { ProcedureMeta } from "~/lib/utils";
import { InstructionEditCard } from "~/components/admin/forms/instruction-edit-card";
import { QuestionEditCard } from "~/components/admin/forms/question-edit-card";
import { api } from "~/trpc/react";
import { ConnectionViewer } from "./con-viewer";
import ListMatcher from "./linker";

type FormQuestion = z.infer<typeof QuestionValidator>;
type FormInstruction = z.infer<typeof InstructionValidator>;
Expand Down Expand Up @@ -111,7 +117,39 @@
);
}

export function EditorClient({ slug }: { slug: string }) {
function ConnectionsTab(props: {
procs: Record<string, ProcedureMeta>;
slug: string;
id: string;
formData: FormType;
}) {
const questions = props.formData.questions.map((q) => q.question);
const { data: connections } = api.forms.getConnections.useQuery({
id: props.id,
});
return (
<>
<ListMatcher procs={props.procs} form={{ questions, id: props.id }} />
{connections?.map((con) => {
return (
<ConnectionViewer
key={con.id}
form_slug={props.slug}
matching={con as MatchingType & { id: string }}
/>
);
})}
</>
);
}

export function EditorClient({
procs,
slug,
}: {
procs: Record<string, ProcedureMeta>;
slug: string;
}) {
const router = useRouter();

const [formTitle, setFormTitle] = useState("");
Expand Down Expand Up @@ -251,12 +289,12 @@
// auto save trigger when toggle switches are changed
useEffect(() => {
if (!isLoading) handleSaveForm();
}, [duesOnly, allowResubmission, isLoading]); // removed handleSaveForm to prevent save-on-every-render

Check warning on line 292 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 297 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 @@ -472,68 +510,91 @@
</div>
</Card>

<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={[...questions, ...instructions]}
strategy={verticalListSortingStrategy}
>
<div className="space-y-4">
{[...questions, ...instructions]
.sort((a, b) => (a.order ?? 999) - (b.order ?? 999))
.map((item) => {
const isInstruction = "title" in item;
return (
<div key={item.id} className={isInstruction ? "mt-8" : ""}>
<SortableItem
item={item}
isActive={activeItemId === item.id}
onUpdateQuestion={updateQuestion}
onUpdateInstruction={updateInstruction}
onDelete={deleteItem}
onDuplicateQuestion={duplicateQuestion}
onDuplicateInstruction={duplicateInstruction}
onClick={() => setActiveItemId(item.id)}
onForceSave={handleSaveForm}
error={
!isInstruction && duplicateIds.has(item.id)
? "Duplicate question title"
: undefined
}
/>
</div>
);
})}
<Tabs defaultValue="questions" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="questions">Questions</TabsTrigger>
<TabsTrigger value="connections">Connections</TabsTrigger>
</TabsList>

<TabsContent value="questions" className="mt-4">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={[...questions, ...instructions]}
strategy={verticalListSortingStrategy}
>
<div className="space-y-4">
{[...questions, ...instructions]
.sort((a, b) => (a.order ?? 999) - (b.order ?? 999))
.map((item) => {
const isInstruction = "title" in item;
return (
<div
key={item.id}
className={isInstruction ? "mt-8" : ""}
>
<SortableItem
item={item}
isActive={activeItemId === item.id}
onUpdateQuestion={updateQuestion}
onUpdateInstruction={updateInstruction}
onDelete={deleteItem}
onDuplicateQuestion={duplicateQuestion}
onDuplicateInstruction={duplicateInstruction}
onClick={() => setActiveItemId(item.id)}
onForceSave={handleSaveForm}
error={
!isInstruction && duplicateIds.has(item.id)
? "Duplicate question title"
: undefined
}
/>
</div>
);
})}
</div>
</SortableContext>
</DndContext>

<div className="flex justify-center gap-4 pt-8">
<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"
>
<Plus className="mr-3 h-6 w-6" /> Add Question
</Button>
<Button
onClick={(e) => {
e.stopPropagation();
addInstruction();
}}
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"
>
<Plus className="mr-3 h-6 w-6" /> Add Instruction
</Button>
</div>
</SortableContext>
</DndContext>

<div className="flex justify-center gap-4 pt-8">
<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"
>
<Plus className="mr-3 h-6 w-6" /> Add Question
</Button>
<Button
onClick={(e) => {
e.stopPropagation();
addInstruction();
}}
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"
>
<Plus className="mr-3 h-6 w-6" /> Add Instruction
</Button>
</div>
</TabsContent>

<TabsContent value="connections" className="mt-4">
{formData && (
<ConnectionsTab
slug={formData.slugName}
procs={procs}
id={formData.id}
formData={formData.formData}
/>
)}
</TabsContent>
</Tabs>
</div>
</div>
);
Expand Down
144 changes: 144 additions & 0 deletions apps/blade/src/app/admin/forms/[slug]/con-viewer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { useState } from "react";
import { Trash2 } from "lucide-react";

import { Button } from "@forge/ui/button";
import { Card } from "@forge/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@forge/ui/dialog";
import { toast } from "@forge/ui/toast";

import type { MatchingType } from "./linker";
import { api } from "~/trpc/react";

export function ConnectionViewer({
matching,
form_slug,
}: {
matching: MatchingType & { id: string };
form_slug: string;
}) {
const [open, setOpen] = useState(false);
const utils = api.useUtils();

const handleDeleteCon = api.forms.deleteConnection.useMutation({
onSuccess() {
toast.success("Connection deleted");
setOpen(false);
},
onError() {
toast.error("Failed to delete connection");
},
async onSettled() {
await utils.forms.getConnections.invalidate();
},
});

const handleDelete = () => {
handleDeleteCon.mutate({ id: matching.id });
};

return (
<Card className="mb-6 overflow-hidden border-l-4 border-l-primary">
<div className="p-6">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-4">
<div className="flex items-center gap-3">
<div className="flex-1">
<div className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Procedure
</div>
<div className="text-lg font-bold">
{matching.proc || "No Procedure"}
</div>
</div>
<div className="text-2xl font-bold text-muted-foreground">→</div>
<div className="flex-1">
<div className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Form
</div>
<div className="text-lg font-bold">
{form_slug || "No Form"}
</div>
</div>
</div>

<div className="space-y-2">
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Field Mappings ({matching.connections.length})
</div>
<div className="space-y-2">
{matching.connections.map((conn, idx) => (
<div
key={idx}
className="flex items-center gap-2 rounded-md border bg-muted/30 p-3"
>
<div className="flex-1">
<div className="mb-0.5 text-xs text-muted-foreground">
Proc Field
</div>
<div className="font-mono text-sm font-semibold">
{conn.procField}
</div>
</div>
<div className="text-muted-foreground">→</div>
<div className="flex-1">
<div className="mb-0.5 text-xs text-muted-foreground">
Form Field
</div>
<div className="font-mono text-sm font-semibold">
{conn.formField || "Not Mapped"}
</div>
</div>
</div>
))}
</div>
</div>
</div>

<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Connection?</DialogTitle>
<DialogDescription>
This will permanently delete the connection between{" "}
<span className="font-semibold">
{matching.proc || "the procedure"}
</span>{" "}
and{" "}
<span className="font-semibold">
{form_slug || "the form"}
</span>
. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDelete}>
Delete Connection
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
</Card>
);
}
Loading
Loading