refactor(web): route-driven modal hook and CRUD conventions

Extract useRouteDrivenModal for the five list editors, move AddTriggerDialog into its own file, and document modal vs full-page CRUD patterns in CONTRIBUTING.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-22 00:48:44 +07:00
co-authored by Cursor
parent 3d25d8a614
commit 96c4cc7b47
10 changed files with 375 additions and 306 deletions
+42
View File
@@ -0,0 +1,42 @@
# Contributing (web UI)
Conventions for editors, dialogs, and routing in `packages/web`.
## Entity editing patterns
### Modal + route deep-link (simple entities)
Use a list page with a route-driven editor modal for:
- Secrets (`/secrets`, `/secrets/new`, `/secrets/:owner/:name/edit`)
- Variables
- Profiles
- Auth profiles (`/auth`, …)
- Users
Prefer [`useRouteDrivenModal`](src/hooks/useRouteDrivenModal.js) to open/close the modal from the URL, keep a single `openedRouteKey` guard, and navigate back to the list path on close.
Navigate to `/…/new` or `/…/:id/edit` from list actions; do not open the editor with local state alone when a deep-link route exists.
### Full-page editors
Scripts and workflows use dedicated full-page editors (not list+modal). Keep create/edit as their own routes and leave the list page for browsing only.
### Known exception: Responses
The Responses page may keep inline editing (no modal / no full-page editor). Treat that as intentional; do not refactor it to modal+route unless product requirements change.
## Components
| Use | For |
|---|---|
| `ConfirmDialog` | Deletes and other yes/no confirmations |
| `FormInput` / `FormSelect` | Form fields in modals and pages |
| `Modal` | Custom dialog chrome when `ConfirmDialog` is not enough |
## Naming: `*Dialog` vs `*Modal`
- **`*Dialog`** — confirmations and pickers (e.g. `ConfirmDialog`, `AddTriggerDialog`, `AddScriptDialog`, `DuplicateWorkflowDialog`)
- **`*Modal`** — entity editors (e.g. `SecretEditorModal`, `ProfileEditorModal`, `AuthEditorModal`)
When adding a new overlay, pick the suffix from the table above. Do not rename existing `*EditorModal` components solely for consistency with older code.
@@ -0,0 +1,68 @@
import { useState } from "react";
export function AddTriggerDialog({ open, onClose, onPick }) {
const [kind, setKind] = useState("HTTP");
if (!open) return null;
return (
<dialog className="modal modal-open">
<div className="modal-box">
<h3 className="font-bold text-lg">Add trigger</h3>
<div className="form-control mt-3">
<label className="label cursor-pointer justify-start gap-3">
<input
type="radio"
name="trig-kind"
className="radio radio-sm"
checked={kind === "HTTP"}
onChange={() => setKind("HTTP")}
/>
<span>
HTTP — webhook at <span className="font-mono">/u/owner/path</span>
</span>
</label>
<label className="label cursor-pointer justify-start gap-3">
<input
type="radio"
name="trig-kind"
className="radio radio-sm"
checked={kind === "cron"}
onChange={() => setKind("cron")}
/>
<span>Cron — run on a schedule</span>
</label>
<label className="label cursor-pointer justify-start gap-3">
<input
type="radio"
name="trig-kind"
className="radio radio-sm"
checked={kind === "workflow"}
onChange={() => setKind("workflow")}
/>
<span>Workflow — callable by other workflows</span>
</label>
</div>
<div className="modal-action">
<button type="button" className="btn" onClick={onClose}>
Cancel
</button>
<button
type="button"
className="btn btn-primary"
onClick={() => {
onPick(kind);
setKind("HTTP");
}}
>
Add
</button>
</div>
</div>
<form method="dialog" className="modal-backdrop">
<button type="button" onClick={onClose}>
close
</button>
</form>
</dialog>
);
}
@@ -519,68 +519,3 @@ function FailureAlertFields({ trigger, disabled, onChange, alertDestinations })
</div> </div>
); );
} }
export function AddTriggerDialog({ open, onClose, onPick }) {
const [kind, setKind] = useState("HTTP");
if (!open) return null;
return (
<dialog className="modal modal-open">
<div className="modal-box">
<h3 className="font-bold text-lg">Add trigger</h3>
<div className="form-control mt-3">
<label className="label cursor-pointer justify-start gap-3">
<input
type="radio"
name="trig-kind"
className="radio radio-sm"
checked={kind === "HTTP"}
onChange={() => setKind("HTTP")}
/>
<span>HTTP — webhook at <span className="font-mono">/u/owner/path</span></span>
</label>
<label className="label cursor-pointer justify-start gap-3">
<input
type="radio"
name="trig-kind"
className="radio radio-sm"
checked={kind === "cron"}
onChange={() => setKind("cron")}
/>
<span>Cron — run on a schedule</span>
</label>
<label className="label cursor-pointer justify-start gap-3">
<input
type="radio"
name="trig-kind"
className="radio radio-sm"
checked={kind === "workflow"}
onChange={() => setKind("workflow")}
/>
<span>Workflow — callable by other workflows</span>
</label>
</div>
<div className="modal-action">
<button type="button" className="btn" onClick={onClose}>
Cancel
</button>
<button
type="button"
className="btn btn-primary"
onClick={() => {
onPick(kind);
setKind("HTTP");
}}
>
Add
</button>
</div>
</div>
<form method="dialog" className="modal-backdrop">
<button type="button" onClick={onClose}>
close
</button>
</form>
</dialog>
);
}
@@ -15,7 +15,8 @@ import {
} from "@dnd-kit/sortable"; } from "@dnd-kit/sortable";
import { LuPlus } from "react-icons/lu"; import { LuPlus } from "react-icons/lu";
import { newCronTrigger, newHttpTrigger, newWorkflowTrigger } from "../../lib/workflow-doc.js"; import { newCronTrigger, newHttpTrigger, newWorkflowTrigger } from "../../lib/workflow-doc.js";
import { AddTriggerDialog, TriggerCard } from "./TriggerCard.jsx"; import { AddTriggerDialog } from "./AddTriggerDialog.jsx";
import { TriggerCard } from "./TriggerCard.jsx";
export function TriggersTab({ export function TriggersTab({
doc, doc,
@@ -0,0 +1,86 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
const EMPTY_DEPS = Object.freeze([]);
/**
* Open/close an editor modal driven by list / new / edit routes.
*
* Pages supply route flags, list path, and builders for the editor payload.
* The hook owns `editor` state, the opened-route guard ref, and navigation
* back to the list when closing from a deep-linked route.
*
* @param {object} options
* @param {boolean} options.isNewRoute
* @param {boolean} options.isEditRoute
* @param {string | (() => string)} options.listPath
* @param {() => string} [options.newRouteKey] defaults to `"new"`
* @param {() => boolean} [options.canOpenNew] return false to wait (e.g. owners loading)
* @param {() => void} [options.onBeforeOpenNew] side effects before opening (e.g. sync filter)
* @param {() => object} options.buildNewEditor
* @param {() => string} options.editRouteKey
* @param {() => boolean} [options.canOpenEdit] return false to wait / prepare (e.g. sync owner)
* @param {() => void} [options.onOpenEdit] after key is claimed (e.g. highlight row)
* @param {() => object} options.buildEditEditor
* @param {unknown[]} [options.newDeps] extra deps for the new-route effect
* @param {unknown[]} [options.editDeps] extra deps for the edit-route effect
*/
export function useRouteDrivenModal({
isNewRoute,
isEditRoute,
listPath,
newRouteKey = () => "new",
canOpenNew,
onBeforeOpenNew,
buildNewEditor,
editRouteKey,
canOpenEdit,
onOpenEdit,
buildEditEditor,
newDeps = EMPTY_DEPS,
editDeps = EMPTY_DEPS,
}) {
const navigate = useNavigate();
const [editor, setEditor] = useState(null);
const openedRouteKey = useRef(null);
function resolveListPath() {
return typeof listPath === "function" ? listPath() : listPath;
}
function closeEditor() {
setEditor(null);
openedRouteKey.current = null;
if (isNewRoute || isEditRoute) {
navigate(resolveListPath(), { replace: true });
}
}
useEffect(() => {
if (!isNewRoute) return;
const key = newRouteKey();
if (openedRouteKey.current === key) return;
if (canOpenNew && !canOpenNew()) return;
onBeforeOpenNew?.();
openedRouteKey.current = key;
setEditor(buildNewEditor());
// Callers pass explicit newDeps; builders close over latest render values.
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentional
}, [isNewRoute, ...newDeps]);
useEffect(() => {
if (!isEditRoute) {
if (!isNewRoute) openedRouteKey.current = null;
return;
}
if (canOpenEdit && !canOpenEdit()) return;
const key = editRouteKey();
if (openedRouteKey.current === key) return;
openedRouteKey.current = key;
onOpenEdit?.();
setEditor(buildEditEditor());
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentional
}, [isEditRoute, isNewRoute, ...editDeps]);
return { editor, setEditor, closeEditor, openedRouteKey };
}
+16 -34
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react"; import { useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom"; import { useLocation, useNavigate, useParams } from "react-router-dom";
import { LuEye, LuEyeOff, LuPencil, LuPlus, LuTrash2 } from "react-icons/lu"; import { LuEye, LuEyeOff, LuPencil, LuPlus, LuTrash2 } from "react-icons/lu";
import { errorMessage } from "../api/client.js"; import { errorMessage } from "../api/client.js";
@@ -9,6 +9,7 @@ import {
} from "../api/hooks.js"; } from "../api/hooks.js";
import { AuthEditorModal } from "../components/AuthEditorModal.jsx"; import { AuthEditorModal } from "../components/AuthEditorModal.jsx";
import { ConfirmDialog } from "../components/ConfirmDialog.jsx"; import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js";
import { formatTime } from "../lib/format.jsx"; import { formatTime } from "../lib/format.jsx";
function CredDisplay({ field, fieldKey, authId, cache, onRevealed }) { function CredDisplay({ field, fieldKey, authId, cache, onRevealed }) {
@@ -149,43 +150,24 @@ export function AuthProfilesPage() {
const { data: auths = [], isLoading } = useHttpAuths(); const { data: auths = [], isLoading } = useHttpAuths();
const del = useDeleteHttpAuth(); const del = useDeleteHttpAuth();
const [editor, setEditor] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null);
/** @type {[Record<string, Record<string, string>>, Function]} */ /** @type {[Record<string, Record<string, string>>, Function]} */
const [revealCache, setRevealCache] = useState({}); const [revealCache, setRevealCache] = useState({});
const openedRouteKey = useRef(null);
function closeEditor() { const { editor, closeEditor } = useRouteDrivenModal({
setEditor(null); isNewRoute,
openedRouteKey.current = null; isEditRoute,
if (isNewRoute || isEditRoute) { listPath: "/auth",
navigate("/auth", { replace: true }); buildNewEditor: () => ({ mode: "add" }),
} editRouteKey: () => `edit:${routeName}`,
} canOpenEdit: () => !isLoading,
buildEditEditor: () => {
useEffect(() => { const auth = auths.find((a) => a.name === routeName);
if (!isNewRoute) return; if (!auth) return { mode: "add" };
if (openedRouteKey.current === "new") return; return { mode: "edit", auth };
openedRouteKey.current = "new"; },
setEditor({ mode: "add" }); editDeps: [routeName, isLoading, auths],
}, [isNewRoute]); });
useEffect(() => {
if (!isEditRoute) {
if (!isNewRoute) openedRouteKey.current = null;
return;
}
if (isLoading) return;
const key = `edit:${routeName}`;
if (openedRouteKey.current === key) return;
openedRouteKey.current = key;
const auth = auths.find((a) => a.name === routeName);
if (!auth) {
setEditor({ mode: "add" });
return;
}
setEditor({ mode: "edit", auth });
}, [isEditRoute, isNewRoute, routeName, isLoading, auths]);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
+59 -68
View File
@@ -7,6 +7,7 @@ import { FormSelect } from "../components/FormControls.jsx";
import { Modal } from "../components/Modal.jsx"; import { Modal } from "../components/Modal.jsx";
import { ProfileEditorModal } from "../components/ProfileEditorModal.jsx"; import { ProfileEditorModal } from "../components/ProfileEditorModal.jsx";
import { ScriptIcon } from "../components/ScriptIcon.jsx"; import { ScriptIcon } from "../components/ScriptIcon.jsx";
import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js";
import { formatTime } from "../lib/format.jsx"; import { formatTime } from "../lib/format.jsx";
function previewConfig(config) { function previewConfig(config) {
@@ -42,84 +43,74 @@ export function ProfilesPage() {
); );
const { data: profiles = [], isLoading } = useProfiles(ownerFilter || undefined); const { data: profiles = [], isLoading } = useProfiles(ownerFilter || undefined);
const del = useDeleteProfile(); const del = useDeleteProfile();
const [editor, setEditor] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null);
const [highlightName, setHighlightName] = useState(() => routeName || ""); const [highlightName, setHighlightName] = useState(() => routeName || "");
const highlightRef = useRef(null); const highlightRef = useRef(null);
const openedRouteKey = useRef(null);
const listPath = ownerFilter const listPath = ownerFilter
? `/profiles?owner=${encodeURIComponent(ownerFilter)}` ? `/profiles?owner=${encodeURIComponent(ownerFilter)}`
: "/profiles"; : "/profiles";
function closeEditor() { const { editor, closeEditor } = useRouteDrivenModal({
setEditor(null); isNewRoute,
openedRouteKey.current = null; isEditRoute,
if (isNewRoute || isEditRoute) { listPath,
navigate(listPath, { replace: true }); newRouteKey: () => `new:${params.get("owner") || ""}:${params.get("name") || ""}`,
} canOpenNew: () => Boolean(params.get("owner") || ownerFilter || owners.length > 0),
} onBeforeOpenNew: () => {
if (params.get("owner")) setOwnerFilter(params.get("owner"));
useEffect(() => { },
if (!isNewRoute) return; buildNewEditor: () => {
const key = `new:${params.get("owner") || ""}:${params.get("name") || ""}`; const owner = params.get("owner") || ownerFilter || owners[0] || "default";
if (openedRouteKey.current === key) return; return {
if (!params.get("owner") && !ownerFilter && owners.length === 0) return; mode: "add",
const owner = params.get("owner") || ownerFilter || owners[0] || "default";
if (params.get("owner")) setOwnerFilter(params.get("owner"));
openedRouteKey.current = key;
setEditor({
mode: "add",
initial: {
owner,
name: params.get("name") || "",
script: params.get("script") || "",
config: {},
description: "",
},
});
}, [isNewRoute, params, owners, ownerFilter]);
useEffect(() => {
if (!isEditRoute) {
if (!isNewRoute) openedRouteKey.current = null;
return;
}
if (ownerFilter !== routeOwner) {
setOwnerFilter(routeOwner);
return;
}
if (isLoading) return;
const key = `edit:${routeOwner}/${routeName}`;
if (openedRouteKey.current === key) return;
openedRouteKey.current = key;
setHighlightName(routeName);
const row = profiles.find((p) => p.owner === routeOwner && p.name === routeName);
if (row) {
setEditor({
mode: "edit",
initial: { initial: {
owner: row.owner, owner,
name: row.name, name: params.get("name") || "",
script: row.script, script: params.get("script") || "",
config: row.config, config: {},
description: row.description, description: "",
}, },
usageCount: row.usageCount ?? 0, };
}); },
return; editRouteKey: () => `edit:${routeOwner}/${routeName}`,
} canOpenEdit: () => {
setEditor({ if (ownerFilter !== routeOwner) {
mode: "add", setOwnerFilter(routeOwner);
initial: { return false;
owner: routeOwner, }
name: routeName, return !isLoading;
script: "", },
config: {}, onOpenEdit: () => setHighlightName(routeName),
description: "", buildEditEditor: () => {
}, const row = profiles.find((p) => p.owner === routeOwner && p.name === routeName);
}); if (row) {
}, [isEditRoute, isNewRoute, routeOwner, routeName, ownerFilter, isLoading, profiles]); return {
mode: "edit",
initial: {
owner: row.owner,
name: row.name,
script: row.script,
config: row.config,
description: row.description,
},
usageCount: row.usageCount ?? 0,
};
}
return {
mode: "add",
initial: {
owner: routeOwner,
name: routeName,
script: "",
config: {},
description: "",
},
};
},
newDeps: [params, owners, ownerFilter],
editDeps: [routeOwner, routeName, ownerFilter, isLoading, profiles],
});
useEffect(() => { useEffect(() => {
if (!highlightName || isLoading) return; if (!highlightName || isLoading) return;
+31 -40
View File
@@ -6,6 +6,7 @@ import { useDeleteSecret, useOwners, useSecrets } from "../api/hooks.js";
import { ConfirmDialog } from "../components/ConfirmDialog.jsx"; import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
import { FormSelect } from "../components/FormControls.jsx"; import { FormSelect } from "../components/FormControls.jsx";
import { SecretEditorModal } from "../components/SecretEditorModal.jsx"; import { SecretEditorModal } from "../components/SecretEditorModal.jsx";
import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js";
import { formatTime } from "../lib/format.jsx"; import { formatTime } from "../lib/format.jsx";
export function SecretsPage() { export function SecretsPage() {
@@ -22,56 +23,46 @@ export function SecretsPage() {
); );
const { data: secrets = [], isLoading } = useSecrets(ownerFilter || undefined); const { data: secrets = [], isLoading } = useSecrets(ownerFilter || undefined);
const del = useDeleteSecret(); const del = useDeleteSecret();
const [editor, setEditor] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null);
const [highlightName, setHighlightName] = useState(() => routeName || ""); const [highlightName, setHighlightName] = useState(() => routeName || "");
const highlightRef = useRef(null); const highlightRef = useRef(null);
const openedRouteKey = useRef(null);
const listPath = ownerFilter const listPath = ownerFilter
? `/secrets?owner=${encodeURIComponent(ownerFilter)}` ? `/secrets?owner=${encodeURIComponent(ownerFilter)}`
: "/secrets"; : "/secrets";
function closeEditor() { const { editor, closeEditor } = useRouteDrivenModal({
setEditor(null); isNewRoute,
openedRouteKey.current = null; isEditRoute,
if (isNewRoute || isEditRoute) { listPath,
navigate(listPath, { replace: true }); newRouteKey: () => `new:${params.get("owner") || ""}:${params.get("name") || ""}`,
} canOpenNew: () => Boolean(params.get("owner") || ownerFilter || owners.length > 0),
} onBeforeOpenNew: () => {
if (params.get("owner")) setOwnerFilter(params.get("owner"));
useEffect(() => { },
if (!isNewRoute) return; buildNewEditor: () => {
const key = `new:${params.get("owner") || ""}:${params.get("name") || ""}`; const owner = params.get("owner") || ownerFilter || owners[0] || "default";
if (openedRouteKey.current === key) return; return {
if (!params.get("owner") && !ownerFilter && owners.length === 0) return; mode: "add",
const owner = params.get("owner") || ownerFilter || owners[0] || "default"; initial: { owner, name: params.get("name") || "" },
if (params.get("owner")) setOwnerFilter(params.get("owner")); };
openedRouteKey.current = key; },
setEditor({ editRouteKey: () => `edit:${routeOwner}/${routeName}`,
mode: "add", canOpenEdit: () => {
initial: { owner, name: params.get("name") || "" }, if (ownerFilter !== routeOwner) {
}); setOwnerFilter(routeOwner);
}, [isNewRoute, params, owners, ownerFilter]); return false;
}
useEffect(() => { return true;
if (!isEditRoute) { },
if (!isNewRoute) openedRouteKey.current = null; onOpenEdit: () => setHighlightName(routeName),
return; buildEditEditor: () => ({
}
if (ownerFilter !== routeOwner) {
setOwnerFilter(routeOwner);
return;
}
const key = `edit:${routeOwner}/${routeName}`;
if (openedRouteKey.current === key) return;
openedRouteKey.current = key;
setHighlightName(routeName);
setEditor({
mode: "replace", mode: "replace",
initial: { owner: routeOwner, name: routeName }, initial: { owner: routeOwner, name: routeName },
}); }),
}, [isEditRoute, isNewRoute, routeOwner, routeName, ownerFilter]); newDeps: [params, owners, ownerFilter],
editDeps: [routeOwner, routeName, ownerFilter],
});
useEffect(() => { useEffect(() => {
if (!highlightName || isLoading) return; if (!highlightName || isLoading) return;
+16 -34
View File
@@ -1,10 +1,11 @@
import { useEffect, useRef, useState } from "react"; import { useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom"; import { useLocation, useNavigate, useParams } from "react-router-dom";
import { LuPencil, LuPlus, LuTrash2 } from "react-icons/lu"; import { LuPencil, LuPlus, LuTrash2 } from "react-icons/lu";
import { errorMessage } from "../api/client.js"; import { errorMessage } from "../api/client.js";
import { useDeleteUser, useUsers } from "../api/hooks.js"; import { useDeleteUser, useUsers } from "../api/hooks.js";
import { ConfirmDialog } from "../components/ConfirmDialog.jsx"; import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
import { UserEditorModal } from "../components/UserEditorModal.jsx"; import { UserEditorModal } from "../components/UserEditorModal.jsx";
import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js";
export function UsersPage() { export function UsersPage() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -15,41 +16,22 @@ export function UsersPage() {
const { data: users = [], isLoading } = useUsers(); const { data: users = [], isLoading } = useUsers();
const del = useDeleteUser(); const del = useDeleteUser();
const [editor, setEditor] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null);
const openedRouteKey = useRef(null);
function closeEditor() { const { editor, closeEditor } = useRouteDrivenModal({
setEditor(null); isNewRoute,
openedRouteKey.current = null; isEditRoute,
if (isNewRoute || isEditRoute) { listPath: "/users",
navigate("/users", { replace: true }); buildNewEditor: () => ({ mode: "add" }),
} editRouteKey: () => `edit:${routeUsername}`,
} canOpenEdit: () => !isLoading,
buildEditEditor: () => {
useEffect(() => { const user = users.find((u) => u.username === routeUsername);
if (!isNewRoute) return; if (!user) return { mode: "add" };
if (openedRouteKey.current === "new") return; return { mode: "edit", user };
openedRouteKey.current = "new"; },
setEditor({ mode: "add" }); editDeps: [routeUsername, isLoading, users],
}, [isNewRoute]); });
useEffect(() => {
if (!isEditRoute) {
if (!isNewRoute) openedRouteKey.current = null;
return;
}
if (isLoading) return;
const key = `edit:${routeUsername}`;
if (openedRouteKey.current === key) return;
openedRouteKey.current = key;
const user = users.find((u) => u.username === routeUsername);
if (!user) {
setEditor({ mode: "add" });
return;
}
setEditor({ mode: "edit", user });
}, [isEditRoute, isNewRoute, routeUsername, isLoading, users]);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
+55 -64
View File
@@ -6,6 +6,7 @@ import { useDeleteVariable, useOwners, useVariables } from "../api/hooks.js";
import { ConfirmDialog } from "../components/ConfirmDialog.jsx"; import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
import { FormSelect } from "../components/FormControls.jsx"; import { FormSelect } from "../components/FormControls.jsx";
import { VariableEditorModal } from "../components/VariableEditorModal.jsx"; import { VariableEditorModal } from "../components/VariableEditorModal.jsx";
import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js";
import { formatTime } from "../lib/format.jsx"; import { formatTime } from "../lib/format.jsx";
function displayValue(value) { function displayValue(value) {
@@ -27,80 +28,70 @@ export function VariablesPage() {
); );
const { data: variables = [], isLoading } = useVariables(ownerFilter || undefined); const { data: variables = [], isLoading } = useVariables(ownerFilter || undefined);
const del = useDeleteVariable(); const del = useDeleteVariable();
const [editor, setEditor] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null);
const [highlightName, setHighlightName] = useState(() => routeName || ""); const [highlightName, setHighlightName] = useState(() => routeName || "");
const highlightRef = useRef(null); const highlightRef = useRef(null);
const openedRouteKey = useRef(null);
const listPath = ownerFilter const listPath = ownerFilter
? `/variables?owner=${encodeURIComponent(ownerFilter)}` ? `/variables?owner=${encodeURIComponent(ownerFilter)}`
: "/variables"; : "/variables";
function closeEditor() { const { editor, closeEditor } = useRouteDrivenModal({
setEditor(null); isNewRoute,
openedRouteKey.current = null; isEditRoute,
if (isNewRoute || isEditRoute) { listPath,
navigate(listPath, { replace: true }); newRouteKey: () => `new:${params.get("owner") || ""}:${params.get("name") || ""}`,
} canOpenNew: () => Boolean(params.get("owner") || ownerFilter || owners.length > 0),
} onBeforeOpenNew: () => {
if (params.get("owner")) setOwnerFilter(params.get("owner"));
useEffect(() => { },
if (!isNewRoute) return; buildNewEditor: () => {
const key = `new:${params.get("owner") || ""}:${params.get("name") || ""}`; const owner = params.get("owner") || ownerFilter || owners[0] || "default";
if (openedRouteKey.current === key) return; return {
if (!params.get("owner") && !ownerFilter && owners.length === 0) return; mode: "add",
const owner = params.get("owner") || ownerFilter || owners[0] || "default";
if (params.get("owner")) setOwnerFilter(params.get("owner"));
openedRouteKey.current = key;
setEditor({
mode: "add",
initial: {
owner,
name: params.get("name") || "",
type: "string",
value: "",
},
});
}, [isNewRoute, params, owners, ownerFilter]);
useEffect(() => {
if (!isEditRoute) {
if (!isNewRoute) openedRouteKey.current = null;
return;
}
if (ownerFilter !== routeOwner) {
setOwnerFilter(routeOwner);
return;
}
if (isLoading) return;
const key = `edit:${routeOwner}/${routeName}`;
if (openedRouteKey.current === key) return;
openedRouteKey.current = key;
setHighlightName(routeName);
const row = variables.find((v) => v.owner === routeOwner && v.name === routeName);
if (row) {
setEditor({
mode: "edit",
initial: { initial: {
owner: row.owner, owner,
name: row.name, name: params.get("name") || "",
type: row.type, type: "string",
value: row.value, value: "",
}, },
}); };
return; },
} editRouteKey: () => `edit:${routeOwner}/${routeName}`,
setEditor({ canOpenEdit: () => {
mode: "add", if (ownerFilter !== routeOwner) {
initial: { setOwnerFilter(routeOwner);
owner: routeOwner, return false;
name: routeName, }
type: "string", return !isLoading;
value: "", },
}, onOpenEdit: () => setHighlightName(routeName),
}); buildEditEditor: () => {
}, [isEditRoute, isNewRoute, routeOwner, routeName, ownerFilter, isLoading, variables]); const row = variables.find((v) => v.owner === routeOwner && v.name === routeName);
if (row) {
return {
mode: "edit",
initial: {
owner: row.owner,
name: row.name,
type: row.type,
value: row.value,
},
};
}
return {
mode: "add",
initial: {
owner: routeOwner,
name: routeName,
type: "string",
value: "",
},
};
},
newDeps: [params, owners, ownerFilter],
editDeps: [routeOwner, routeName, ownerFilter, isLoading, variables],
});
useEffect(() => { useEffect(() => {
if (!highlightName || isLoading) return; if (!highlightName || isLoading) return;