Files
jerapah-flow/packages/web/src/pages/ResponsesPage.jsx
T
nsrbandCursor db884808f2 refactor: split oversized modules and start server src/stores layout
Consolidate format helpers under lib/format, split React Query hooks by domain, extract AuthPicker/ConfigRefHint and HTTP trigger routing, demote file-private exports, and move KV/secrets/variables/profiles/http-auths stores under src/stores with root re-exports.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 06:16:38 +07:00

248 lines
8.2 KiB
React

import { useState } from "react";
import { LuPencil, LuPlus, LuTrash2, LuX } from "react-icons/lu";
import { errorMessage } from "../api/client.js";
import {
useDeleteHttpPage,
useHttpPages,
useUpsertHttpPage,
} from "../api/hooks.js";
import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
import { formatTime } from "../lib/format";
const emptyForm = {
name: "",
content: "",
mime: "html",
status: 200,
kind: "response",
};
function kindLabel(kind) {
return kind === "template" ? "HTML template" : "HTTP response";
}
export function ResponsesPage() {
const { data: pages = [], isLoading } = useHttpPages();
const upsert = useUpsertHttpPage();
const del = useDeleteHttpPage();
const [mode, setMode] = useState(null);
const [form, setForm] = useState(emptyForm);
const [confirmDelete, setConfirmDelete] = useState(null);
function openAdd() {
setMode("add");
setForm(emptyForm);
}
function openEdit(p) {
setMode("edit");
setForm({
name: p.name,
content: p.content,
mime: p.mime,
status: p.status,
kind: p.kind ?? "response",
system: Boolean(p.system),
});
}
function closeForm() {
setMode(null);
setForm(emptyForm);
}
function onSubmit(e) {
e.preventDefault();
upsert.mutate(
{
name: form.name,
content: form.content,
mime: form.kind === "template" ? "html" : form.mime,
status: Number(form.status) || 200,
kind: form.kind,
},
{ onSuccess: closeForm },
);
}
return (
<div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<h1 className="text-xl font-semibold">Responses</h1>
<button type="button" className="btn btn-primary btn-sm" onClick={openAdd}>
<LuPlus className="size-4" />
Add
</button>
</div>
<p className="text-sm opacity-70">
Named HTML/JSON pages for HTTP trigger responses, or HTML templates for Mustache
rendering in workflows. HTTP responses use{" "}
<code className="font-mono text-xs">response: name</code>; templates use{" "}
<code className="font-mono text-xs">render-template.js</code> with{" "}
<code className="font-mono text-xs">config.template: name</code>.
</p>
{isLoading ? (
<span className="loading loading-spinner" />
) : pages.length === 0 ? (
<p className="text-sm opacity-60">No response pages yet.</p>
) : (
<div className="overflow-x-auto">
<table className="table table-sm">
<thead>
<tr>
<th>Name</th>
<th>Kind</th>
<th>Mime</th>
<th>Status</th>
<th>Updated</th>
<th />
</tr>
</thead>
<tbody>
{pages.map((p) => (
<tr key={p.id} className="hover">
<td className="font-mono">
{p.name}
{p.system ? (
<span className="badge badge-ghost badge-xs ml-2">system</span>
) : null}
</td>
<td>{kindLabel(p.kind ?? "response")}</td>
<td>{p.mime}</td>
<td>{p.kind === "template" ? "—" : p.status}</td>
<td className="whitespace-nowrap">{formatTime(p.updated_at)}</td>
<td className="text-right whitespace-nowrap">
<button
type="button"
className="btn btn-ghost btn-xs"
title="Edit"
aria-label="Edit"
onClick={() => openEdit(p)}
>
<LuPencil className="size-4" />
</button>
<button
type="button"
className="btn btn-ghost btn-xs text-error"
title={p.system ? "System pages cannot be deleted" : "Delete"}
aria-label={p.system ? "System pages cannot be deleted" : "Delete"}
disabled={p.system}
onClick={() => setConfirmDelete(p)}
>
<LuTrash2 className="size-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{mode ? (
<form
onSubmit={onSubmit}
className="fieldset bg-base-100 border-base-300 rounded-box max-w-2xl border p-4"
>
<div className="flex items-center justify-between">
<legend className="fieldset-legend">
{mode === "add" ? "New page" : `Edit ${form.name}`}
</legend>
<button
type="button"
className="btn btn-ghost btn-sm btn-square"
onClick={closeForm}
aria-label="Close"
>
<LuX className="size-4" />
</button>
</div>
<label className="label">Name</label>
<input
className="input w-full font-mono"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
required
pattern="[A-Za-z0-9._-]+"
title="Letters, numbers, dots, underscores, hyphens"
disabled={mode === "edit"}
/>
<label className="label">Kind</label>
<select
className="select w-full"
value={form.kind}
onChange={(e) =>
setForm({
...form,
kind: e.target.value,
mime: e.target.value === "template" ? "html" : form.mime,
})
}
disabled={mode === "edit" && form.system}
>
<option value="response">HTTP response</option>
<option value="template">HTML template</option>
</select>
{form.kind === "template" ? (
<p className="text-xs opacity-70">
Templates use Mustache syntax (<code>{"{{title}}"}</code>,{" "}
<code>{"{{#items}}"}</code>) and are rendered by{" "}
<code className="font-mono">render-template.js</code>.
</p>
) : null}
{form.kind === "response" ? (
<>
<label className="label">Mime</label>
<select
className="select w-full"
value={form.mime}
onChange={(e) => setForm({ ...form, mime: e.target.value })}
>
<option value="html">html</option>
<option value="json">json</option>
</select>
<label className="label">HTTP status</label>
<input
type="number"
className="input w-full"
value={form.status}
onChange={(e) => setForm({ ...form, status: e.target.value })}
min={100}
max={599}
required
/>
</>
) : null}
<label className="label">Content</label>
<textarea
className="textarea w-full font-mono text-sm min-h-40"
value={form.content}
onChange={(e) => setForm({ ...form, content: e.target.value })}
required
/>
{upsert.isError ? (
<p className="text-error text-sm">{errorMessage(upsert.error)}</p>
) : null}
<button type="submit" className="btn btn-primary mt-2" disabled={upsert.isPending}>
Save
</button>
</form>
) : null}
<ConfirmDialog
open={Boolean(confirmDelete)}
title={confirmDelete ? `Delete ${confirmDelete.name}?` : ""}
message="Workflows that reference this page will fail validation on next save."
error={del.isError ? errorMessage(del.error) : null}
loading={del.isPending}
confirmDisabled={Boolean(confirmDelete?.system)}
onCancel={() => setConfirmDelete(null)}
onConfirm={() =>
del.mutate(confirmDelete.id, { onSuccess: () => setConfirmDelete(null) })
}
/>
</div>
);
}