PlaybookDocumentCreateDialog.tsx6.5 KBView on GitHub 'use client';
import { useEffect, useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { useTRPC } from '@/providers/query-provider';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { FieldRows, FormActions, SelectField, TextField } from '@/components/ui/field';
import { Button } from '@/components/ui/button';
import type { PlaybookDocumentKind } from './HashMentionList';
export interface CreatedPlaybookDocument {
documentId: string;
path: string;
name: string;
kind: PlaybookDocumentKind;
}
/** What each kind is called, and what the dialog says it is for. */
const KIND_COPY: Record<PlaybookDocumentKind, { title: string; blurb: string; placeholder: string }> = {
board: {
title: 'New board',
blurb: 'A kanban whose cards are documents. The agent files, moves and reads them.',
placeholder: 'Customer roadmap',
},
table: {
title: 'New table',
blurb: 'A grid the agent fills column by column.',
placeholder: 'Pipeline review',
},
doc: {
title: 'New document',
blurb: 'A resource the playbook can reference by name.',
placeholder: 'Discovery guide',
},
};
/** The "no template" option's value. Never sent to the server — see `templateOptions`. */
const NO_TEMPLATE = '__none__';
/**
* The default selection for a board, and the one place this component knows a template id.
*
* It is a DEFAULT, not a requirement: a board asked for from a playbook is almost always a
* work board, and starting on "Empty board" means the first card an agent files lands in a
* lane nobody declared — which the adoption hook rescues, but by inventing a lane rather
* than using one someone chose. If the id ever stops existing the picker simply opens on
* "Empty board", because the option list comes from the server.
*/
const CUSTOMER_ROADMAP_DEFAULT = 'customer-roadmap';
interface PlaybookDocumentCreateDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
kind: PlaybookDocumentKind;
/** Which folder to create in — set from the cursor's scope card. */
scope: 'user' | 'org';
/** User AOP id, for the resource-document path. */
aopId?: string;
/**
* The playbook's owner, forwarded to the create mutation. Omitted means the
* signed-in user. Without it an admin creating from a teammate's playbook would
* get a document owned by themselves, referenced from the teammate's playbook.
*/
targetUserId?: string;
onCreated: (result: CreatedPlaybookDocument) => void | Promise<void>;
}
/**
* Names a new board / table / resource document and creates it, so a playbook can make the
* thing it is about to reference instead of sending you to the Files tree to make it first.
*
* A board additionally picks a TEMPLATE, and that is the only kind that does: an empty board
* is legal but has no axis, so every card lands in one flat column until somebody declares a
* field to group by — whereas an empty table gets its columns from the first write and an
* empty document is a document. The template list comes from the server rather than a copy
* here, so the picker cannot offer an id `createBoard` would refuse.
*/
export function PlaybookDocumentCreateDialog({
open,
onOpenChange,
kind,
scope,
aopId,
targetUserId,
onCreated,
}: PlaybookDocumentCreateDialogProps) {
const trpc = useTRPC();
const [name, setName] = useState('');
const [template, setTemplate] = useState<string>(NO_TEMPLATE);
const copy = KIND_COPY[kind];
const templates = useQuery({
...trpc.boards.templates.queryOptions(),
// Only a board offers one, so the other two kinds never pay for the round trip.
enabled: open && kind === 'board',
});
const { mutateAsync, isPending } = useMutation(trpc.aop.createPlaybookDocument.mutationOptions());
useEffect(() => {
if (!open) return;
setName('');
setTemplate(kind === 'board' ? CUSTOMER_ROADMAP_DEFAULT : NO_TEMPLATE);
}, [open, kind]);
const submit = async () => {
const trimmed = name.trim();
if (!trimmed) {
toast.error(`Give the ${kind === 'doc' ? 'document' : kind} a name`);
return;
}
try {
const result = await mutateAsync({
kind: kind === 'doc' ? 'document' : kind,
name: trimmed,
scope,
aopId,
template: kind === 'board' && template !== NO_TEMPLATE ? template : undefined,
targetUserId,
});
await onCreated({ ...result, kind });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Could not create it');
}
};
// radix `SelectItem` refuses an empty-string value (it uses '' as its own "nothing
// selected" sentinel), so "no template" carries a real one and is mapped back on submit.
const templateOptions = [
{ value: NO_TEMPLATE, label: 'Empty board' },
...(templates.data?.templates ?? []).map((t) => ({ value: t.id, label: t.name })),
];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent size="form">
<DialogHeader>
<DialogTitle>{copy.title}</DialogTitle>
<DialogDescription>{copy.blurb}</DialogDescription>
</DialogHeader>
<FieldRows bare>
<TextField
label="Name"
value={name}
placeholder={copy.placeholder}
autoFocus
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !isPending) {
e.preventDefault();
void submit();
}
}}
/>
{kind === 'board' && (
<SelectField
label="Start from"
value={template}
options={templateOptions}
hint={
templates.data?.templates.find((t) => t.id === template)?.description ??
'A starting set of fields and a view. The agent can change any of it afterwards.'
}
onValueChange={setTemplate}
/>
)}
</FieldRows>
<FormActions>
<Button onClick={() => void submit()} disabled={isPending}>
{isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Create'}
</Button>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isPending}>
Cancel
</Button>
</FormActions>
</DialogContent>
</Dialog>
);
}