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>
  );
}