From 735e1047c42db5fa550dbe6fd330b54c181a43d0 Mon Sep 17 00:00:00 2001 From: Chris Pardy Date: Fri, 01 May 2026 11:56:09 +0000 Subject: [PATCH] feat(dashboard): scripts grouped by trigger family + lexicon targeting panel Frontend for the trigger-keyed scripts subsystem (commit a086830). The dashboard now exposes: - `/dashboard/settings/scripts` — grouped list of all scripts by trigger family (Record events, XRPC handlers, Label arrivals). The script's `id` IS its trigger string; the table makes that visible in monospace and links to the detail page. - `/dashboard/settings/scripts/new` — create form. The trigger id is composed via a `(kind, suffix)` picker (kind dropdown for the prefix family + free-text suffix for the NSID). The Lua body prefills with a `handle()` skeleton. The form accepts a `?id=` URL param so the lexicon detail page can deep-link with a candidate id pre-filled. - `/dashboard/settings/scripts/[id]` — detail/edit. The id is the PK so it's locked here; rename = delete + recreate. The header shows the cascade hint for record events ("`record.create:` fires only on this action; cascades to `record.index:` if absent."). The lexicon detail page gets a new "Scripts targeting this lexicon" panel that lists the trigger ids relevant to the lexicon's type: - Record-type lexicons show the four `record.*` slots (`record.index` / `record.create` / `record.update` / `record.delete`) with Edit/Create links. - XRPC lexicons show the matching `xrpc.{query,procedure}` slot. Existing scripts link straight to the detail page; missing rows link to the New Script page pre-filled with the trigger id. Operators get a one-click path from a lexicon to the scripts that affect it. The legacy `index_hook` editor on the lexicon page is left in place for now — operators with existing data can still see and edit the column, even though the runtime no longer reads it. A follow-up commit can drop the editor once we're confident no one relies on it. New: `web/src/types/scripts.ts` (Script, UpsertScriptBody, PatchScriptBody, TriggerKind / TriggerFamily helpers, parseTriggerId, DEFAULT_SCRIPT_BODY). Sidebar gets a Scripts nav item under Integrations (`scripts:read`-gated). Signed-off-by: Chris Pardy --- src/admin/backfill.rs | 14 ++++++++------ web/src/app/dashboard/lexicons/[id]/lexicon-detail.tsx | 273 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------------------------------------------------------------------- web/src/app/dashboard/settings/scripts/[id]/page.tsx | 11 +++++++++++ web/src/app/dashboard/settings/scripts/[id]/script-detail.tsx | 188 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/src/app/dashboard/settings/scripts/new/page.tsx | 125 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/src/app/dashboard/settings/scripts/page.tsx | 181 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/src/app/dashboard/settings/scripts/script-form.tsx | 315 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/src/components/app-sidebar.tsx | 7 +++++++ web/src/lib/api.ts | 45 +++++++++++++++++++++++++++++++++++++++++++++ web/src/types/scripts.ts | 135 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 10 file(s) changed, 1196 insertion(s)(+), 98 deletion(s)(-) diff --git a/src/admin/backfill.rs b/src/admin/backfill.rs --- a/src/admin/backfill.rs +++ b/src/admin/backfill.rs @@ -1349,12 +1349,14 @@ let uri = format!("at://{did}/{collection}/{rkey}"); let rec_to_store = match crate::lua::run_record_event_script( state, - collection, - "create", - &uri, - did, - &rkey, - Some(&entry.value), + crate::lua::RecordEventPayload { + nsid: collection, + action: "create", + uri: &uri, + did, + rkey: &rkey, + record: Some(&entry.value), + }, ) .await { diff --git a/web/src/app/dashboard/lexicons/[id]/lexicon-detail.tsx b/web/src/app/dashboard/lexicons/[id]/lexicon-detail.tsx --- a/web/src/app/dashboard/lexicons/[id]/lexicon-detail.tsx +++ b/web/src/app/dashboard/lexicons/[id]/lexicon-detail.tsx @@ -1,6 +1,8 @@ "use client"; import { useCallback, useEffect, useState } from "react"; +import { IconPlus } from "@tabler/icons-react"; +import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { useCurrentUser } from "@/hooks/use-current-user"; @@ -9,26 +11,22 @@ import { deleteLexicon, deleteNetworkLexicon, getLexicon, + getScripts, uploadLexicon, } from "@/lib/api"; import type { LexiconDetail } from "@/types/lexicons"; -import { - indexHookScript, - procedureScript, - queryScript, -} from "@/lib/lua-templates"; -import { useLuaCompletions } from "@/hooks/use-lua-completions"; +import type { Script, TriggerKind } from "@/types/scripts"; import { SiteHeader } from "@/components/site-header"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; export default function LexiconDetailPage() { const pathname = usePathname(); @@ -38,51 +36,39 @@ ); const { hasPermission } = useCurrentUser(); const router = useRouter(); const [lexicon, setLexicon] = useState(null); + // All scripts in the system — we filter to those targeting this + // lexicon's id below. Best-effort: render an empty panel if the + // scripts call fails so the rest of the page still works. + const [scripts, setScripts] = useState([]); const [error, setError] = useState(null); const [deleting, setDeleting] = useState(false); const [saving, setSaving] = useState(false); - // Editable text state + // Editable text state. The lexicon page used to edit `script` and + // `index_hook` columns inline via lua editors; those columns are + // now managed via the Scripts subsystem (see "Scripts targeting + // this lexicon" panel below). We pass the existing values through + // unchanged on save so legacy data isn't accidentally NULLed. const [jsonText, setJsonText] = useState(""); - const [luaText, setLuaText] = useState(""); const [originalJson, setOriginalJson] = useState(""); - const [originalLua, setOriginalLua] = useState(""); - const [hookText, setHookText] = useState(""); - const [originalHook, setOriginalHook] = useState(""); - const [showHookEditor, setShowHookEditor] = useState(false); const [tokenCost, setTokenCost] = useState(""); const [originalTokenCost, setOriginalTokenCost] = useState(""); - const { luaCompletions, collections } = useLuaCompletions(jsonText); const load = useCallback(() => { + // Fire both requests in parallel; the scripts list is best-effort. + getScripts() + .then(setScripts) + .catch(() => setScripts([])); getLexicon(id) .then((lex) => { setLexicon(lex); const json = JSON.stringify(lex.lexicon_json, null, 2); setJsonText(json); setOriginalJson(json); - - // If lexicon has no script but is a query/procedure, auto-generate one - if ( - !lex.script && - (lex.lexicon_type === "query" || lex.lexicon_type === "procedure") - ) { - const generated = - lex.lexicon_type === "procedure" - ? procedureScript(lex.target_collection ?? "") - : queryScript(lex.target_collection ?? ""); - setLuaText(generated); - // Set originalLua to "" so isDirty becomes true, prompting user to save - setOriginalLua(""); - } else { - setLuaText(lex.script ?? ""); - setOriginalLua(lex.script ?? ""); - } - setHookText(lex.index_hook ?? ""); - setOriginalHook(lex.index_hook ?? ""); - setShowHookEditor(!!lex.index_hook); setTokenCost(lex.token_cost != null ? String(lex.token_cost) : ""); - setOriginalTokenCost(lex.token_cost != null ? String(lex.token_cost) : ""); + setOriginalTokenCost( + lex.token_cost != null ? String(lex.token_cost) : "", + ); }) .catch((e) => setError(e instanceof Error ? e.message : String(e))); }, [id]); @@ -92,10 +78,7 @@ load(); }, [load]); const isDirty = - jsonText !== originalJson || - luaText !== originalLua || - hookText !== originalHook || - tokenCost !== originalTokenCost; + jsonText !== originalJson || tokenCost !== originalTokenCost; async function handleSave() { if (!lexicon) return; @@ -106,8 +89,11 @@ const lexiconJson = JSON.parse(jsonText); await uploadLexicon({ lexicon_json: lexiconJson, backfill: lexicon.backfill, - script: luaText || undefined, - index_hook: hookText || undefined, + // Preserve any legacy script / index_hook values verbatim — we + // no longer edit them here, but leaving them out of the body + // would NULL the columns on upsert and lose data. + script: lexicon.script ?? undefined, + index_hook: lexicon.index_hook ?? undefined, token_cost: tokenCost ? Number(tokenCost) : null, }); load(); @@ -157,12 +143,28 @@ ); } const isNetwork = lexicon.source === "network"; - const showLua = - lexicon.has_script || - lexicon.lexicon_type === "query" || - lexicon.lexicon_type === "procedure"; const isRecord = lexicon.lexicon_type === "record"; - const showHook = isRecord && (showHookEditor || !!lexicon.index_hook); + + // Triggers that target this lexicon's id, given its type. + // + // Record-type lexicons get the four `record.*` slots (the cascade + // wildcard `record.index` listed first as the most common starting + // point, then the three action-specific slots), plus `labeler.apply` + // for "react to labels arriving on records of this type." + // XRPC lexicons get the matching `xrpc.{query,procedure}` slot only. + const targetingTriggers: { kind: TriggerKind; label: string }[] = isRecord + ? [ + { kind: "record.index", label: "Default handler (any action)" }, + { kind: "record.create", label: "On create" }, + { kind: "record.update", label: "On update" }, + { kind: "record.delete", label: "On delete" }, + { kind: "labeler.apply", label: "On label applied" }, + ] + : lexicon.lexicon_type === "query" + ? [{ kind: "xrpc.query", label: "Query handler" }] + : lexicon.lexicon_type === "procedure" + ? [{ kind: "xrpc.procedure", label: "Procedure handler" }] + : []; return (
@@ -244,20 +246,30 @@ />
)} + + {/* Trigger-keyed scripts that target this lexicon. Each row + either links to the existing script or to the New Script + page pre-filled with the trigger id. */} + {targetingTriggers.length > 0 && ( + + )} - {/* Code Panels */} + {/* JSON editor only — scripts (record-event handlers, XRPC + handlers, label-arrival handlers) are managed via the + "Scripts targeting this lexicon" panel above. The legacy + `script` / `index_hook` columns on the lexicons table are + preserved as-is on save but no longer edited here. */} {/* Actions */} @@ -273,40 +285,6 @@ )}
- {hasPermission("lexicons:create") && isRecord && !showHook && ( - - - - - - - { - "An Index Hook is a Lua script that runs automatically whenever a record in this collection is created, updated, or deleted on the network." - } - - - - )} - {hasPermission("lexicons:create") && isRecord && showHook && ( - - )} - {hasPermission("lexicons:create") && (
); } + +/** + * Lists scripts targeting this lexicon and offers a "+ New" dropdown + * for the slots that don't yet have a script. + * + * Existing scripts (those whose trigger id matches one of `entries`) + * appear as rows linking to the Scripts detail page. Missing slots + * are surfaced via a single "+ New script" dropdown so the operator + * picks the kind of handler they want without seeing four "Create" + * buttons stacked. The dropdown hides when every slot is taken. + */ +function ScriptsTargetingPanel({ + lexiconId, + scripts, + entries, + canManage, +}: { + lexiconId: string; + scripts: Script[]; + entries: { kind: TriggerKind; label: string }[]; + canManage: boolean; +}) { + const byId = new Map(scripts.map((s) => [s.id, s])); + const existing = entries + .map((e) => ({ ...e, triggerId: `${e.kind}:${lexiconId}` })) + .filter((e) => byId.has(e.triggerId)); + const available = entries + .map((e) => ({ ...e, triggerId: `${e.kind}:${lexiconId}` })) + .filter((e) => !byId.has(e.triggerId)); + + return ( +
+
+
+

+ Scripts targeting this lexicon +

+

+ Each row is a{" "} + + trigger + {" "} + the dispatcher resolves at firing time. +

+
+ {canManage && available.length > 0 && ( + + + + + + {available.map(({ kind, label, triggerId }) => ( + + + {label} + + {triggerId} + + + + ))} + + + )} +
+ {existing.length === 0 ? ( +

+ No scripts yet. + {canManage && available.length > 0 && ( + <> Use the “New script” menu to add one. + )} +

+ ) : ( +
    + {existing.map(({ kind, label, triggerId }) => ( +
  • + + {label} + + {triggerId} + + + +
  • + ))} +
+ )} +
+ ); +} diff --git a/web/src/app/dashboard/settings/scripts/[id]/page.tsx b/web/src/app/dashboard/settings/scripts/[id]/page.tsx new file mode 100644 --- /dev/null +++ b/web/src/app/dashboard/settings/scripts/[id]/page.tsx @@ -0,0 +1,11 @@ +import ScriptDetail from "./script-detail"; + +// https://github.com/vercel/next.js/issues/71862 +// Returning [] fails with output:"export", so provide a dummy param. +export async function generateStaticParams() { + return [{ id: "_" }]; +} + +export default function ScriptDetailPage() { + return ; +} diff --git a/web/src/app/dashboard/settings/scripts/[id]/script-detail.tsx b/web/src/app/dashboard/settings/scripts/[id]/script-detail.tsx new file mode 100644 --- /dev/null +++ b/web/src/app/dashboard/settings/scripts/[id]/script-detail.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { usePathname, useRouter } from "next/navigation"; + +import { useCurrentUser } from "@/hooks/use-current-user"; +import { deleteScript, getScript, patchScript } from "@/lib/api"; +import type { Script, TriggerFamily } from "@/types/scripts"; +import { + TRIGGER_KIND_LABELS, + familyOf, + parseTriggerId, +} from "@/types/scripts"; +import { SiteHeader } from "@/components/site-header"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; + +import { + ScriptForm, + type ScriptFormState, + stateFromScript, +} from "../script-form"; + +export default function ScriptDetail() { + const pathname = usePathname(); + // The `[id]` route segment carries the URL-encoded trigger id. + // Decode once so all downstream calls see the canonical id (which + // contains `:` and `.`). + const id = decodeURIComponent( + pathname.split("/").filter(Boolean).pop() ?? "", + ); + const { hasPermission } = useCurrentUser(); + const router = useRouter(); + const [script, setScript] = useState