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:
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user