338 lines
12 KiB
TypeScript
338 lines
12 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import * as React from "react";
|
||
|
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||
|
|
import { FileStack, Search, X } from "lucide-react";
|
||
|
|
|
||
|
|
import { api } from "@/lib/trpc";
|
||
|
|
import { cn } from "@/lib/utils";
|
||
|
|
import { Badge } from "@/components/ui/badge";
|
||
|
|
import { Button } from "@/components/ui/button";
|
||
|
|
import { Input } from "@/components/ui/input";
|
||
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||
|
|
import { Separator } from "@/components/ui/separator";
|
||
|
|
|
||
|
|
export type TemplateSchemaJson = {
|
||
|
|
properties?: {
|
||
|
|
name: string;
|
||
|
|
fieldType: string;
|
||
|
|
defaultValue?: unknown;
|
||
|
|
}[];
|
||
|
|
defaultContent?: string;
|
||
|
|
};
|
||
|
|
|
||
|
|
export type PickerTemplate = {
|
||
|
|
id: string;
|
||
|
|
workspaceId: string;
|
||
|
|
name: string;
|
||
|
|
targetType: string;
|
||
|
|
schema: TemplateSchemaJson | null;
|
||
|
|
isBuiltin?: boolean;
|
||
|
|
};
|
||
|
|
|
||
|
|
/** Preset templates for UI / offline use; merge with API `templates.list` when available. */
|
||
|
|
export const BUILTIN_TEMPLATES: PickerTemplate[] = [
|
||
|
|
{
|
||
|
|
id: "00000000-0000-4000-8000-000000000001",
|
||
|
|
workspaceId: "",
|
||
|
|
name: "Bug Report",
|
||
|
|
targetType: "task",
|
||
|
|
isBuiltin: true,
|
||
|
|
schema: {
|
||
|
|
properties: [
|
||
|
|
{ name: "Priority", fieldType: "select", defaultValue: "medium" },
|
||
|
|
{ name: "Severity", fieldType: "select", defaultValue: "major" },
|
||
|
|
{
|
||
|
|
name: "Steps to Reproduce",
|
||
|
|
fieldType: "textarea",
|
||
|
|
defaultValue: "",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "Expected Behavior",
|
||
|
|
fieldType: "textarea",
|
||
|
|
defaultValue: "",
|
||
|
|
},
|
||
|
|
],
|
||
|
|
defaultContent:
|
||
|
|
"## Summary\n\n## Steps to reproduce\n\n1. \n\n## Expected behavior\n\n",
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "00000000-0000-4000-8000-000000000002",
|
||
|
|
workspaceId: "",
|
||
|
|
name: "Meeting Notes",
|
||
|
|
targetType: "document",
|
||
|
|
isBuiltin: true,
|
||
|
|
schema: {
|
||
|
|
properties: [
|
||
|
|
{ name: "Date", fieldType: "date", defaultValue: "" },
|
||
|
|
{ name: "Attendees", fieldType: "text", defaultValue: "" },
|
||
|
|
{ name: "Agenda", fieldType: "textarea", defaultValue: "" },
|
||
|
|
{ name: "Action Items", fieldType: "textarea", defaultValue: "" },
|
||
|
|
],
|
||
|
|
defaultContent: "# Meeting\n\n## Agenda\n\n## Notes\n\n## Action items\n\n",
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "00000000-0000-4000-8000-000000000003",
|
||
|
|
workspaceId: "",
|
||
|
|
name: "Sprint",
|
||
|
|
targetType: "project",
|
||
|
|
isBuiltin: true,
|
||
|
|
schema: {
|
||
|
|
properties: [
|
||
|
|
{ name: "Sprint Goal", fieldType: "text", defaultValue: "" },
|
||
|
|
{ name: "Start Date", fieldType: "date", defaultValue: "" },
|
||
|
|
{ name: "End Date", fieldType: "date", defaultValue: "" },
|
||
|
|
{ name: "Velocity", fieldType: "number", defaultValue: null },
|
||
|
|
],
|
||
|
|
defaultContent: "## Sprint goal\n\n## Commitments\n\n",
|
||
|
|
},
|
||
|
|
},
|
||
|
|
];
|
||
|
|
|
||
|
|
const CREATE_SENTINEL = "__create__";
|
||
|
|
|
||
|
|
function schemaPreview(schema: TemplateSchemaJson | null | undefined): string {
|
||
|
|
const props = schema?.properties ?? [];
|
||
|
|
if (props.length === 0) {
|
||
|
|
return "No custom properties";
|
||
|
|
}
|
||
|
|
const names = props.slice(0, 6).map((p) => p.name);
|
||
|
|
const extra = props.length > 6 ? ` +${props.length - 6} more` : "";
|
||
|
|
return `${names.join(" · ")}${extra}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function propertyCount(schema: TemplateSchemaJson | null | undefined): number {
|
||
|
|
return schema?.properties?.length ?? 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
export type TemplatePickerProps = {
|
||
|
|
objectId: string;
|
||
|
|
objectType: string;
|
||
|
|
onSelect: (templateId: string) => void;
|
||
|
|
open: boolean;
|
||
|
|
onOpenChange: (open: boolean) => void;
|
||
|
|
};
|
||
|
|
|
||
|
|
export function TemplatePicker({
|
||
|
|
objectId,
|
||
|
|
objectType,
|
||
|
|
onSelect,
|
||
|
|
open,
|
||
|
|
onOpenChange,
|
||
|
|
}: TemplatePickerProps) {
|
||
|
|
const [search, setSearch] = React.useState("");
|
||
|
|
const [showAllTypes, setShowAllTypes] = React.useState(false);
|
||
|
|
|
||
|
|
const objectQuery = api.objects.getById.useQuery(
|
||
|
|
{ id: objectId },
|
||
|
|
{ enabled: open && Boolean(objectId) },
|
||
|
|
);
|
||
|
|
|
||
|
|
const objWorkspace = (objectQuery.data as unknown as { workspaceId?: string | null } | undefined)
|
||
|
|
?.workspaceId;
|
||
|
|
const workspaceId =
|
||
|
|
typeof objWorkspace === "string" && objWorkspace.length > 0 ? objWorkspace : undefined;
|
||
|
|
|
||
|
|
const listQuery = api.templates.list.useQuery(
|
||
|
|
{ workspaceId: workspaceId!, targetType: showAllTypes ? undefined : objectType },
|
||
|
|
{ enabled: open && Boolean(workspaceId) },
|
||
|
|
);
|
||
|
|
|
||
|
|
const merged = React.useMemo(() => {
|
||
|
|
const fromApi = listQuery.data?.templates ?? [];
|
||
|
|
const builtinFiltered = BUILTIN_TEMPLATES.filter(
|
||
|
|
(b) => showAllTypes || b.targetType === objectType,
|
||
|
|
);
|
||
|
|
const seen = new Set<string>();
|
||
|
|
const out: PickerTemplate[] = [];
|
||
|
|
for (const t of [...builtinFiltered, ...fromApi]) {
|
||
|
|
if (seen.has(t.id)) continue;
|
||
|
|
seen.add(t.id);
|
||
|
|
out.push({
|
||
|
|
id: t.id,
|
||
|
|
workspaceId: t.workspaceId,
|
||
|
|
name: t.name,
|
||
|
|
targetType: t.targetType,
|
||
|
|
schema: (t.schema as TemplateSchemaJson | null) ?? null,
|
||
|
|
isBuiltin: "isBuiltin" in t ? t.isBuiltin : false,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
return out;
|
||
|
|
}, [listQuery.data?.templates, objectType, showAllTypes]);
|
||
|
|
|
||
|
|
const filtered = React.useMemo(() => {
|
||
|
|
const q = search.trim().toLowerCase();
|
||
|
|
if (!q) return merged;
|
||
|
|
return merged.filter((t) => t.name.toLowerCase().includes(q));
|
||
|
|
}, [merged, search]);
|
||
|
|
|
||
|
|
const grouped = React.useMemo(() => {
|
||
|
|
if (!showAllTypes) {
|
||
|
|
return new Map([[objectType, filtered]]);
|
||
|
|
}
|
||
|
|
const m = new Map<string, PickerTemplate[]>();
|
||
|
|
const order = ["project", "task", "document", "whiteboard", "group", "workspace"];
|
||
|
|
for (const t of filtered) {
|
||
|
|
const k = t.targetType;
|
||
|
|
if (!m.has(k)) m.set(k, []);
|
||
|
|
m.get(k)!.push(t);
|
||
|
|
}
|
||
|
|
const keys = [...m.keys()].sort(
|
||
|
|
(a, b) => order.indexOf(a) - order.indexOf(b) || a.localeCompare(b),
|
||
|
|
);
|
||
|
|
return new Map(keys.map((k) => [k, m.get(k)!]));
|
||
|
|
}, [filtered, objectType, showAllTypes]);
|
||
|
|
|
||
|
|
React.useEffect(() => {
|
||
|
|
if (!open) {
|
||
|
|
setSearch("");
|
||
|
|
setShowAllTypes(false);
|
||
|
|
}
|
||
|
|
}, [open]);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
|
||
|
|
<DialogPrimitive.Portal>
|
||
|
|
<DialogPrimitive.Overlay
|
||
|
|
className={cn(
|
||
|
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||
|
|
)}
|
||
|
|
/>
|
||
|
|
<DialogPrimitive.Content
|
||
|
|
className={cn(
|
||
|
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-0 rounded-lg border bg-background p-0 shadow-lg duration-200",
|
||
|
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<div className="flex items-start justify-between gap-3 border-b px-4 py-3">
|
||
|
|
<div className="min-w-0 space-y-1">
|
||
|
|
<DialogPrimitive.Title className="text-lg font-semibold leading-none tracking-tight">
|
||
|
|
Use a template
|
||
|
|
</DialogPrimitive.Title>
|
||
|
|
<DialogPrimitive.Description className="text-sm text-muted-foreground">
|
||
|
|
Apply structure and default fields to this {objectType}.
|
||
|
|
</DialogPrimitive.Description>
|
||
|
|
</div>
|
||
|
|
<DialogPrimitive.Close asChild>
|
||
|
|
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0" aria-label="Close">
|
||
|
|
<X className="h-4 w-4" />
|
||
|
|
</Button>
|
||
|
|
</DialogPrimitive.Close>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="space-y-3 px-4 py-3">
|
||
|
|
<div className="relative">
|
||
|
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||
|
|
<Input
|
||
|
|
placeholder="Search templates…"
|
||
|
|
className="pl-9"
|
||
|
|
value={search}
|
||
|
|
onChange={(e) => setSearch(e.target.value)}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<label className="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground">
|
||
|
|
<input
|
||
|
|
type="checkbox"
|
||
|
|
className="rounded border-input"
|
||
|
|
checked={showAllTypes}
|
||
|
|
onChange={(e) => setShowAllTypes(e.target.checked)}
|
||
|
|
/>
|
||
|
|
Show all types
|
||
|
|
</label>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<ScrollArea className="max-h-[min(420px,55vh)] px-4">
|
||
|
|
<div className="space-y-4 pb-3 pr-3">
|
||
|
|
{listQuery.isPending && workspaceId ? (
|
||
|
|
<p className="text-sm text-muted-foreground">Loading templates…</p>
|
||
|
|
) : null}
|
||
|
|
{!workspaceId && objectQuery.isPending ? (
|
||
|
|
<p className="text-sm text-muted-foreground">Loading object…</p>
|
||
|
|
) : null}
|
||
|
|
{!workspaceId && objectQuery.isError ? (
|
||
|
|
<p className="text-sm text-destructive">Could not load workspace.</p>
|
||
|
|
) : null}
|
||
|
|
|
||
|
|
{Array.from(grouped.entries()).map(([typeKey, items], gi) => (
|
||
|
|
<React.Fragment key={typeKey}>
|
||
|
|
{gi > 0 && showAllTypes ? <Separator className="my-2" /> : null}
|
||
|
|
<div className="space-y-2">
|
||
|
|
{showAllTypes ? (
|
||
|
|
<div className="flex items-center gap-2 pt-1">
|
||
|
|
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||
|
|
{typeKey}
|
||
|
|
</span>
|
||
|
|
<Separator className="flex-1" />
|
||
|
|
</div>
|
||
|
|
) : null}
|
||
|
|
{items.length === 0 ? (
|
||
|
|
<p className="text-sm text-muted-foreground">
|
||
|
|
No templates match this filter.
|
||
|
|
</p>
|
||
|
|
) : (
|
||
|
|
items.map((t) => (
|
||
|
|
<div
|
||
|
|
key={t.id}
|
||
|
|
className="rounded-lg border bg-card p-3 shadow-sm transition-colors hover:bg-accent/40"
|
||
|
|
>
|
||
|
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||
|
|
<div className="min-w-0 space-y-1">
|
||
|
|
<div className="flex flex-wrap items-center gap-2">
|
||
|
|
<span className="font-medium leading-tight">{t.name}</span>
|
||
|
|
<Badge variant="secondary" className="text-[10px] uppercase">
|
||
|
|
{t.targetType}
|
||
|
|
</Badge>
|
||
|
|
{t.isBuiltin ? (
|
||
|
|
<Badge variant="outline" className="text-[10px]">
|
||
|
|
Built-in
|
||
|
|
</Badge>
|
||
|
|
) : null}
|
||
|
|
</div>
|
||
|
|
<p className="text-xs text-muted-foreground">
|
||
|
|
{propertyCount(t.schema)} properties ·{" "}
|
||
|
|
<span className="line-clamp-2">{schemaPreview(t.schema)}</span>
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
<Button
|
||
|
|
size="sm"
|
||
|
|
className="shrink-0"
|
||
|
|
onClick={() => {
|
||
|
|
onSelect(t.id);
|
||
|
|
onOpenChange(false);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
Use Template
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
))
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</React.Fragment>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</ScrollArea>
|
||
|
|
|
||
|
|
<div className="border-t px-4 py-3">
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
className="w-full gap-2"
|
||
|
|
onClick={() => {
|
||
|
|
onSelect(CREATE_SENTINEL);
|
||
|
|
onOpenChange(false);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<FileStack className="h-4 w-4" />
|
||
|
|
Create New Template
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</DialogPrimitive.Content>
|
||
|
|
</DialogPrimitive.Portal>
|
||
|
|
</DialogPrimitive.Root>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export { CREATE_SENTINEL as TEMPLATE_PICKER_CREATE_SENTINEL };
|