diff --git a/app/(main)/processes/[id]/content.tsx b/app/(main)/processes/[id]/content.tsx index 524a7575..5dd6a380 100644 --- a/app/(main)/processes/[id]/content.tsx +++ b/app/(main)/processes/[id]/content.tsx @@ -10,6 +10,7 @@ import { ArrowLeft, ChevronDown, ChevronUp, + GitBranch, Loader2, Pencil, Plus, @@ -19,6 +20,7 @@ import { import { z } from 'zod/v4'; import { + addBranchStep, addProcessStep, deleteProcessStep, deleteProcessTemplate, @@ -28,7 +30,7 @@ import { updateProcessTemplate, } from '@/prisma/services/process-templates'; -import { ProcessTemplateWithSteps } from '@/lib/types'; +import { ProcessStepNode, ProcessTemplateWithSequence } from '@/lib/types'; import { handleError, isError } from '@/lib/utils'; import { EmptyState } from '@/components/empty-state'; @@ -48,7 +50,19 @@ import { FormDialog } from '@/components/ui/form-dialog'; import { FormInput } from '@/components/ui/form-input'; import { FormTextarea } from '@/components/ui/form-textarea'; -type LocalStep = { id: string; name: string; description: string }; +type LocalStepNode = { + id: string; + name: string; + description: string | null; + previousStepId: string | null; + branches: LocalStepSequence[]; +}; +type LocalStepSequence = LocalStepNode[]; + +type AddFormState = + | { type: 'root' } + | { type: 'branch'; parentStepId: string } + | null; const stepSchema = z.object({ name: z @@ -68,27 +82,107 @@ const templateSchema = z.object({ }); type TemplateFormData = z.infer; +function toLocalNode(node: ProcessStepNode): LocalStepNode { + return { + id: node.id, + name: node.name, + description: node.description, + previousStepId: node.previousStepId, + branches: node.branches.map((branch) => branch.map(toLocalNode)), + }; +} + +function AddStepForm({ + label, + isMutating, + onAdd, + onCancel, +}: { + label: string; + isMutating: boolean; + onAdd: (data: StepFormData) => Promise; + onCancel: () => void; +}) { + const form = useForm({ + resolver: zodResolver(stepSchema), + defaultValues: { name: '', description: '' }, + }); + + return ( + + +
+

{label}

+ name="name" label="Name" autoFocus /> + + name="description" + label="Description" + placeholder="Optional" + /> +
+ + +
+ +
+
+ ); +} + function StepCard({ step, idx, total, isMutating, + isDeleted, + addFormState, onEdit, onDelete, onMove, + onAddBranch, + onCancelAdd, + onAddBranchSubmit, }: { - step: LocalStep; + step: LocalStepNode; idx: number; total: number; isMutating: boolean; + isDeleted: boolean; + addFormState: AddFormState; onEdit: (id: string, data: StepFormData) => Promise; onDelete: (id: string) => Promise; onMove: (id: string, dir: 'up' | 'down') => Promise; + onAddBranch: (stepId: string) => void; + onCancelAdd: () => void; + onAddBranchSubmit: ( + siblingStepId: string, + data: StepFormData, + ) => Promise; }) { const [editing, setEditing] = useState(false); const form = useForm({ resolver: zodResolver(stepSchema), - defaultValues: { name: step.name, description: step.description }, + defaultValues: { name: step.name, description: step.description ?? '' }, }); async function onSubmit(data: StepFormData) { @@ -96,6 +190,11 @@ function StepCard({ setEditing(false); } + const showBranchForm = + addFormState !== null && + addFormState.type === 'branch' && + addFormState.parentStepId === step.id; + if (editing) return ( @@ -140,141 +239,223 @@ function StepCard({ ); return ( - -
-
- - {idx + 1} - -
-

{step.name}

- {step.description && ( -

- {step.description} -

- )} +
+ +
+
+ + {idx + 1} + +
+

{step.name}

+ {step.description && ( +

+ {step.description} +

+ )} +
+ {!isDeleted && ( +
+ + + + + +
+ )}
-
- - - - + + + {step.branches.length > 0 && ( +
+ {step.branches.map((branch, branchIdx) => ( +
+

+ Branch {branchIdx + 1} +

+ +
+ ))}
-
-
+ )} + + {showBranchForm && ( +
+
+

+ New Branch +

+ onAddBranchSubmit(step.id, data)} + onCancel={onCancelAdd} + /> +
+
+ )} +
); } -function AddStepCard({ +function SequenceList({ + sequence, isMutating, - onAdd, - onCancel, + isDeleted, + addFormState, + onEdit, + onDelete, + onMove, + onAddBranch, + onCancelAdd, + onAddBranchSubmit, }: { + sequence: LocalStepSequence; isMutating: boolean; - onAdd: (data: StepFormData) => Promise; - onCancel: () => void; + isDeleted: boolean; + addFormState: AddFormState; + onEdit: (id: string, data: StepFormData) => Promise; + onDelete: (id: string) => Promise; + onMove: (id: string, dir: 'up' | 'down') => Promise; + onAddBranch: (stepId: string) => void; + onCancelAdd: () => void; + onAddBranchSubmit: ( + siblingStepId: string, + data: StepFormData, + ) => Promise; }) { - const form = useForm({ - resolver: zodResolver(stepSchema), - defaultValues: { name: '', description: '' }, - }); - return ( - - -
-

New Step

- name="name" label="Name" autoFocus /> - - name="description" - label="Description" - placeholder="Optional" - /> -
- - -
- -
-
+
+ {sequence.map((step, idx) => ( + + ))} +
); } -export function Content({ template }: { template: ProcessTemplateWithSteps }) { +export function Content({ + template, +}: { + template: ProcessTemplateWithSequence; +}) { const router = useRouter(); const isDeleted = template.deletedAt !== null; - const [steps, setSteps] = useState( - template.steps.map((s) => ({ - id: s.id, - name: s.name, - description: s.description ?? '', - })), + const [rootSequence, setRootSequence] = useState( + template.rootSequence.map(toLocalNode), ); const [isMutating, setIsMutating] = useState(false); - const [showAddForm, setShowAddForm] = useState(false); + const [addFormState, setAddFormState] = useState(null); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const [isRestoring, setIsRestoring] = useState(false); + function updateNodeInSequence( + seq: LocalStepSequence, + stepId: string, + updater: (node: LocalStepNode) => LocalStepNode, + ): LocalStepSequence { + return seq.map((node) => { + if (node.id === stepId) return updater(node); + return { + ...node, + branches: node.branches.map((branch) => + updateNodeInSequence(branch, stepId, updater), + ), + }; + }); + } + + function removeNodeFromSequence( + seq: LocalStepSequence, + stepId: string, + ): LocalStepSequence { + return seq + .filter((node) => node.id !== stepId) + .map((node) => ({ + ...node, + branches: node.branches.map((branch) => + removeNodeFromSequence(branch, stepId), + ), + })); + } + async function handleEditStep(stepId: string, data: StepFormData) { setIsMutating(true); - const prev = [...steps]; - setSteps((s) => - s.map((step) => (step.id === stepId ? { ...step, ...data } : step)), + const prev = rootSequence; + setRootSequence((seq) => + updateNodeInSequence(seq, stepId, (node) => ({ + ...node, + name: data.name, + description: data.description || null, + })), ); try { await handleError( @@ -289,7 +470,7 @@ export function Content({ template }: { template: ProcessTemplateWithSteps }) { error: 'Failed to update step', }, onSuccess: () => router.refresh(), - onError: () => setSteps(prev), + onError: () => setRootSequence(prev), }, ); } finally { @@ -299,8 +480,8 @@ export function Content({ template }: { template: ProcessTemplateWithSteps }) { async function handleDeleteStep(stepId: string) { setIsMutating(true); - const prev = [...steps]; - setSteps((s) => s.filter((step) => step.id !== stepId)); + const prev = rootSequence; + setRootSequence((seq) => removeNodeFromSequence(seq, stepId)); try { await handleError(deleteProcessStep(stepId, template.id), { toast: { @@ -309,7 +490,7 @@ export function Content({ template }: { template: ProcessTemplateWithSteps }) { error: 'Failed to delete step', }, onSuccess: () => router.refresh(), - onError: () => setSteps(prev), + onError: () => setRootSequence(prev), }); } finally { setIsMutating(false); @@ -318,14 +499,6 @@ export function Content({ template }: { template: ProcessTemplateWithSteps }) { async function handleMoveStep(stepId: string, direction: 'up' | 'down') { setIsMutating(true); - const prev = [...steps]; - const idx = steps.findIndex((s) => s.id === stepId); - const swapIdx = direction === 'up' ? idx - 1 : idx + 1; - if (swapIdx >= 0 && swapIdx < steps.length) { - const next = [...steps]; - [next[idx], next[swapIdx]] = [next[swapIdx], next[idx]]; - setSteps(next); - } try { await handleError(moveProcessStep(template.id, stepId, direction), { toast: { @@ -334,14 +507,13 @@ export function Content({ template }: { template: ProcessTemplateWithSteps }) { error: 'Failed to move step', }, onSuccess: () => router.refresh(), - onError: () => setSteps(prev), }); } finally { setIsMutating(false); } } - async function handleAddStep(data: StepFormData) { + async function handleAddRootStep(data: StepFormData) { setIsMutating(true); try { await handleError( @@ -356,7 +528,35 @@ export function Content({ template }: { template: ProcessTemplateWithSteps }) { error: 'Failed to add step', }, onSuccess: () => { - setShowAddForm(false); + setAddFormState(null); + router.refresh(); + }, + }, + ); + } finally { + setIsMutating(false); + } + } + + async function handleAddBranchStep( + siblingStepId: string, + data: StepFormData, + ) { + setIsMutating(true); + try { + await handleError( + addBranchStep(template.id, siblingStepId, { + name: data.name, + description: data.description || undefined, + }), + { + toast: { + loading: 'Adding branch...', + success: 'Branch added', + error: 'Failed to add branch', + }, + onSuccess: () => { + setAddFormState(null); router.refresh(); }, }, @@ -413,6 +613,8 @@ export function Content({ template }: { template: ProcessTemplateWithSteps }) { return !isError(result); } + const isEmpty = rootSequence.length === 0 && addFormState === null; + return (
setShowAddForm(true)} - disabled={showAddForm || isMutating} + onClick={() => setAddFormState({ type: 'root' })} + disabled={addFormState !== null || isMutating} > Add Step @@ -504,29 +706,34 @@ export function Content({ template }: { template: ProcessTemplateWithSteps }) { />
- {steps.length === 0 && !showAddForm && ( + {isEmpty && ( )} - {steps.map((step, idx) => ( - - ))} - {showAddForm && !isDeleted && ( - + setAddFormState({ type: 'branch', parentStepId: stepId }) + } + onCancelAdd={() => setAddFormState(null)} + onAddBranchSubmit={handleAddBranchStep} + /> + + {addFormState?.type === 'root' && !isDeleted && ( + setShowAddForm(false)} + onAdd={handleAddRootStep} + onCancel={() => setAddFormState(null)} /> )}
diff --git a/app/(main)/processes/[id]/page.tsx b/app/(main)/processes/[id]/page.tsx index 82e06040..6a9c7b67 100644 --- a/app/(main)/processes/[id]/page.tsx +++ b/app/(main)/processes/[id]/page.tsx @@ -1,7 +1,7 @@ import { Metadata } from 'next'; import { notFound } from 'next/navigation'; -import { getProcessTemplate } from '@/prisma/services/process-templates'; +import { getProcessTemplateAsSequence } from '@/prisma/services/process-templates'; import { Content } from './content'; @@ -13,7 +13,7 @@ export default async function ProcessTemplatePage({ params: Promise<{ id: string }>; }) { const { id } = await params; - const template = await getProcessTemplate(id); + const template = await getProcessTemplateAsSequence(id); if (!template) notFound(); // Use updatedAt as key so the component remounts (resetting local step state) // whenever the server confirms a step mutation via router.refresh() diff --git a/package-lock.json b/package-lock.json index 181a4edd..dae566ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vaultz", - "version": "2.5.0", + "version": "2.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vaultz", - "version": "2.5.0", + "version": "2.6.0", "dependencies": { "@hookform/resolvers": "^5.2.2", "@prisma/adapter-pg": "^7.5.0", diff --git a/prisma/services/process-templates.ts b/prisma/services/process-templates.ts index ec77d9b2..9433050b 100644 --- a/prisma/services/process-templates.ts +++ b/prisma/services/process-templates.ts @@ -196,30 +196,17 @@ export async function addProcessStep( export async function addBranchStep( templateId: string, - siblingStepId: string, + parentStepId: string, data: { name: string; description?: string }, ): Promise> { - const sibling = await prisma.processStep.findUnique({ - where: { id: siblingStepId }, - }); - if (!sibling) return { error: 'Step not found' }; - const step = await prisma.$transaction(async (tx) => { - const lastSibling = await tx.processStep.findFirst({ - where: { - templateId, - deletedAt: null, - parentStepId: sibling.parentStepId, - next: null, - }, - }); const s = await tx.processStep.create({ data: { templateId, name: data.name, description: data.description || null, - previousStepId: lastSibling?.id ?? null, - parentStepId: sibling.parentStepId, + previousStepId: null, + parentStepId, }, }); await tx.processTemplate.update({