From 4c45386720ed40ddeb8b8db6b55fcb55e3f35204 Mon Sep 17 00:00:00 2001 From: Trezy Date: Wed, 27 May 2026 10:54:22 -0500 Subject: [PATCH] refactor: cleanup script styles to match better with the rest of the dashboard Signed-off-by: Trezy --- ...0260527000000_script_description_limit.sql | 2 + ...0260527000000_script_description_limit.sql | 19 + src/admin/scripts.rs | 17 + .../lexicons/[id]/lexicon-detail.tsx | 38 +- .../app/dashboard/settings/scripts/page.tsx | 383 ++++++++++++------ .../settings/scripts/script-form.tsx | 1 + web/src/components/app-sidebar.tsx | 12 +- 7 files changed, 317 insertions(+), 155 deletions(-) create mode 100644 migrations/postgres/20260527000000_script_description_limit.sql create mode 100644 migrations/sqlite/20260527000000_script_description_limit.sql diff --git a/migrations/postgres/20260527000000_script_description_limit.sql b/migrations/postgres/20260527000000_script_description_limit.sql new file mode 100644 index 0000000..6dd1ff9 --- /dev/null +++ b/migrations/postgres/20260527000000_script_description_limit.sql @@ -0,0 +1,2 @@ +ALTER TABLE scripts + ADD CONSTRAINT scripts_description_length CHECK (length(description) <= 300); diff --git a/migrations/sqlite/20260527000000_script_description_limit.sql b/migrations/sqlite/20260527000000_script_description_limit.sql new file mode 100644 index 0000000..4c880aa --- /dev/null +++ b/migrations/sqlite/20260527000000_script_description_limit.sql @@ -0,0 +1,19 @@ +-- SQLite doesn't support ADD CONSTRAINT on existing tables, so we +-- recreate with the check inline. + +CREATE TABLE scripts_new ( + id TEXT PRIMARY KEY, + body TEXT NOT NULL, + description TEXT CHECK (length(description) <= 300), + script_type TEXT NOT NULL DEFAULT 'lua', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +INSERT INTO scripts_new (id, body, description, script_type, created_at, updated_at) +SELECT id, body, substr(description, 1, 300), script_type, created_at, updated_at +FROM scripts; + +DROP TABLE scripts; + +ALTER TABLE scripts_new RENAME TO scripts; diff --git a/src/admin/scripts.rs b/src/admin/scripts.rs index aed2800..e8cca63 100644 --- a/src/admin/scripts.rs +++ b/src/admin/scripts.rs @@ -31,6 +31,8 @@ use crate::lua::{ParsedTrigger, ScriptLanguage}; use super::auth::UserAuth; use super::permissions::Permission; +const MAX_DESCRIPTION_LEN: usize = 300; + // --------------------------------------------------------------------------- // Wire types // --------------------------------------------------------------------------- @@ -162,6 +164,14 @@ pub(super) async fn upsert( // Validate the trigger id grammar up-front (400 with a clear message). let _trigger = ParsedTrigger::parse(&body.id).map_err(AppError::BadRequest)?; + if let Some(ref desc) = body.description + && desc.len() > MAX_DESCRIPTION_LEN + { + return Err(AppError::BadRequest(format!( + "description must be at most {MAX_DESCRIPTION_LEN} characters" + ))); + } + let script_type = body.script_type.unwrap_or_default(); validate_body_for_type(&body.body, script_type)?; @@ -257,6 +267,13 @@ pub(super) async fn patch( let lang = body.script_type.unwrap_or_default(); validate_body_for_type(new_body, lang)?; } + if let Some(Some(ref desc)) = body.description + && desc.len() > MAX_DESCRIPTION_LEN + { + return Err(AppError::BadRequest(format!( + "description must be at most {MAX_DESCRIPTION_LEN} characters" + ))); + } // Existence check + fetch current values. let existing = fetch_one(&state, &id).await?; diff --git a/web/src/app/dashboard/lexicons/[id]/lexicon-detail.tsx b/web/src/app/dashboard/lexicons/[id]/lexicon-detail.tsx index aad8c9e..0dd4721 100644 --- a/web/src/app/dashboard/lexicons/[id]/lexicon-detail.tsx +++ b/web/src/app/dashboard/lexicons/[id]/lexicon-detail.tsx @@ -288,11 +288,11 @@ function ScriptsTargetingPanel({ canManage: boolean; }) { const byId = new Map(scripts.map((s) => [s.id, s])); - const slots = entries.map((e) => ({ - ...e, - triggerId: `${e.kind}:${lexiconId}`, - exists: byId.has(`${e.kind}:${lexiconId}`), - })); + const slots = entries.map((e) => { + const triggerId = `${e.kind}:${lexiconId}`; + const script = byId.get(triggerId); + return { ...e, triggerId, script }; + }); return (
@@ -301,9 +301,9 @@ function ScriptsTargetingPanel({
    - {slots.map(({ kind, label, triggerId, exists }) => ( + {slots.map(({ kind, label, triggerId, script }) => (
  • - {exists ? ( + {script ? ( {label}
-
- {triggerId} -
+ {script.description && ( +
+ {script.description} +
+ )} ) : canManage ? ( @@ -324,24 +326,14 @@ function ScriptsTargetingPanel({ className="group flex items-start gap-3 rounded-md border border-dashed p-3 transition-colors hover:bg-accent/50" > -
-
- {label} -
-
- {triggerId} -
+
+ {label}
) : (
-
-
{label}
-
- {triggerId} -
-
+
{label}
)} diff --git a/web/src/app/dashboard/settings/scripts/page.tsx b/web/src/app/dashboard/settings/scripts/page.tsx index 526bd29..36e993a 100644 --- a/web/src/app/dashboard/settings/scripts/page.tsx +++ b/web/src/app/dashboard/settings/scripts/page.tsx @@ -1,33 +1,51 @@ "use client"; +import { + type ColumnDef, + type ColumnFiltersState, + type PaginationState, + type SortingState, + type VisibilityState, + getCoreRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table"; import { useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { ExternalLink, Eye, Trash2 } from "lucide-react"; import { useCurrentUser } from "@/hooks/use-current-user"; import { deleteScript, getScripts } from "@/lib/api"; -import type { Script, TriggerFamily } from "@/types/scripts"; +import type { Script } from "@/types/scripts"; import { TRIGGER_FAMILY_LABELS, TRIGGER_KIND_LABELS, + type TriggerFamily, + type TriggerKind, familyOf, parseTriggerId, } from "@/types/scripts"; +import { DataTable } from "@/components/data-table/data-table"; +import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"; +import { DataTableToolbar } from "@/components/data-table/data-table-toolbar"; import { SiteHeader } from "@/components/site-header"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -const FAMILY_ORDER: TriggerFamily[] = ["record", "xrpc", "labeler"]; +interface ScriptRow extends Script { + suffix: string; + kind: TriggerKind | null; + family: TriggerFamily | null; +} export default function ScriptsPage() { const { hasPermission } = useCurrentUser(); + const router = useRouter(); const [scripts, setScripts] = useState([]); const [error, setError] = useState(null); @@ -41,24 +59,19 @@ export default function ScriptsPage() { load(); }, [load]); - const grouped = useMemo(() => { - const map: Record = { - record: [], - xrpc: [], - labeler: [], - }; - for (const s of scripts) { - const parsed = parseTriggerId(s.id); - // Group unparseable rows under their best-effort family — tolerant - // because the operator may have created them via direct DB access. - const fam: TriggerFamily = parsed ? familyOf(parsed.kind) : "record"; - map[fam].push(s); - } - for (const fam of FAMILY_ORDER) { - map[fam].sort((a, b) => a.id.localeCompare(b.id)); - } - return map; - }, [scripts]); + const rows = useMemo( + () => + scripts.map((s) => { + const parsed = parseTriggerId(s.id); + return { + ...s, + suffix: parsed?.suffix ?? s.id, + kind: parsed?.kind ?? null, + family: parsed ? familyOf(parsed.kind) : null, + }; + }), + [scripts], + ); async function handleDelete(id: string) { if (!confirm(`Delete script '${id}'?`)) return; @@ -70,111 +83,229 @@ export default function ScriptsPage() { } } + const columns = useMemo[]>( + () => [ + { + id: "suffix", + accessorKey: "suffix", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {row.original.suffix} + ), + filterFn: "includesString", + enableColumnFilter: true, + enableSorting: true, + enableHiding: false, + meta: { + label: "Lexicon", + placeholder: "Filter by lexicon...", + variant: "text", + }, + }, + { + id: "kind", + accessorKey: "kind", + header: ({ column }) => ( + + ), + cell: ({ row }) => + row.original.kind ? ( + + {TRIGGER_KIND_LABELS[row.original.kind]} + + ) : ( + malformed + ), + filterFn: (row, columnId, filterValue) => { + if (!Array.isArray(filterValue) || filterValue.length === 0) + return true; + return filterValue.includes(row.getValue(columnId)); + }, + enableColumnFilter: true, + enableSorting: true, + meta: { + label: "Kind", + variant: "multiSelect", + options: ( + Object.entries(TRIGGER_KIND_LABELS) as [TriggerKind, string][] + ).map(([value, label]) => ({ value, label })), + }, + }, + { + id: "family", + accessorKey: "family", + header: ({ column }) => ( + + ), + cell: ({ row }) => + row.original.family ? ( + + {TRIGGER_FAMILY_LABELS[row.original.family]} + + ) : null, + filterFn: (row, columnId, filterValue) => { + if (!Array.isArray(filterValue) || filterValue.length === 0) + return true; + return filterValue.includes(row.getValue(columnId)); + }, + enableColumnFilter: true, + enableSorting: true, + meta: { + label: "Family", + variant: "multiSelect", + options: ( + Object.entries(TRIGGER_FAMILY_LABELS) as [TriggerFamily, string][] + ).map(([value, label]) => ({ value, label })), + }, + }, + { + id: "description", + accessorKey: "description", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.description ?? ""} + + ), + enableSorting: true, + }, + { + id: "updated_at", + accessorKey: "updated_at", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {new Date(row.original.updated_at).toLocaleString()} + + ), + enableSorting: true, + }, + { + id: "actions", + header: "", + cell: ({ row }) => ( +
+ {row.original.kind && ( + + )} + + {hasPermission("scripts:manage") && ( + + )} +
+ ), + enableSorting: false, + enableHiding: false, + }, + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [hasPermission], + ); + + const [sorting, setSorting] = useState([ + { id: "suffix", desc: false }, + ]); + const [columnFilters, setColumnFilters] = useState([]); + const [columnVisibility, setColumnVisibility] = useState({}); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 20, + }); + + const table = useReactTable({ + data: rows, + columns, + state: { + sorting, + columnFilters, + columnVisibility, + pagination, + columnPinning: { right: ["actions"] }, + }, + defaultColumn: { + enableColumnFilter: false, + }, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + onColumnVisibilityChange: setColumnVisibility, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + getRowId: (row) => row.id, + }); + return ( <> -
+
{error &&

{error}

} -
-

- Each row is a Lua script bound to a trigger. The script's{" "} - id IS its trigger string — - no separate name or binding step. The dispatcher resolves - scripts by id at firing time. -

- {hasPermission("scripts:manage") && ( - - )} -
- - {scripts.length === 0 && ( -
- No scripts yet. + + router.push( + `/dashboard/settings/scripts/${encodeURIComponent(row.id)}`, + ) + } + > + {hasPermission("scripts:manage") && ( - <> - {" "} - - Create one - - . - + )} -
- )} - - {FAMILY_ORDER.map((fam) => { - const rows = grouped[fam]; - if (rows.length === 0) return null; - return ( -
-

- {TRIGGER_FAMILY_LABELS[fam]} -

-
- - - - Trigger - Kind - Description - Updated - - - - - {rows.map((s) => { - const parsed = parseTriggerId(s.id); - return ( - - - - {s.id} - - - - {parsed ? ( - - {TRIGGER_KIND_LABELS[parsed.kind]} - - ) : ( - malformed - )} - - - {s.description ?? ""} - - - {new Date(s.updated_at).toLocaleString()} - - - {hasPermission("scripts:manage") && ( - - )} - - - ); - })} - -
-
-
- ); - })} + +
); diff --git a/web/src/app/dashboard/settings/scripts/script-form.tsx b/web/src/app/dashboard/settings/scripts/script-form.tsx index 8821666..f4f5862 100644 --- a/web/src/app/dashboard/settings/scripts/script-form.tsx +++ b/web/src/app/dashboard/settings/scripts/script-form.tsx @@ -156,6 +156,7 @@ export function ScriptForm({ id="description" value={state.description} onChange={(e) => onChange({ ...state, description: e.target.value })} + maxLength={300} rows={2} placeholder="What does this script do?" className="text-sm" diff --git a/web/src/components/app-sidebar.tsx b/web/src/components/app-sidebar.tsx index c2e8842..24ae152 100644 --- a/web/src/components/app-sidebar.tsx +++ b/web/src/components/app-sidebar.tsx @@ -88,6 +88,12 @@ const accessItems: NavItem[] = [ ]; const integrationItems: NavItem[] = [ + { + title: "Scripts", + url: "/dashboard/settings/scripts", + icon: IconCode, + requiredPermissions: ["scripts:read"], + }, { title: "Plugins", url: "/dashboard/settings/plugins", @@ -100,12 +106,6 @@ const integrationItems: NavItem[] = [ icon: IconTag, requiredPermissions: ["labelers:read"], }, - { - title: "Scripts", - url: "/dashboard/settings/scripts", - icon: IconCode, - requiredPermissions: ["scripts:read"], - }, ]; const systemItems: NavItem[] = [ -- 2.51.2