@aeratic/pagetailor 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -5
- package/bin/cli.js +10 -3
- package/package.json +1 -1
- package/src/agent.js +107 -11
- package/src/dev.js +49 -62
- package/src/files.js +157 -0
- package/src/history.js +4 -8
- package/src/index.js +2 -1
- package/src/loader.js +2 -0
- package/src/proposals.js +6 -1
- package/src/resolver.js +6 -4
- package/src/studio/App.jsx +21 -0
- package/src/studio/Preview.jsx +71 -0
- package/src/studio/Studio.jsx +40 -0
- package/src/studio/entry.jsx +5 -0
- package/src/studio/tabs/AppTab.jsx +85 -0
- package/src/studio/tabs/ComponentsTab.jsx +143 -0
- package/src/studio/tabs/DataTab.jsx +123 -0
- package/src/studio/tabs/PagesTab.jsx +222 -0
- package/src/studio/tabs/PeopleTab.jsx +211 -0
- package/src/studio/tabs/TokensTab.jsx +97 -0
- package/src/studio/ui.jsx +118 -0
- package/src/yaml-write.js +45 -0
package/src/index.js
CHANGED
|
@@ -14,5 +14,6 @@ export async function dev(...args) {
|
|
|
14
14
|
}
|
|
15
15
|
export { writeRegistryFile } from "./registry-file.js";
|
|
16
16
|
export { tokensToCss } from "./tokens.js";
|
|
17
|
-
export { describeCatalog, describePage, buildAgentPrompt, parseAgentReply, runAgent } from "./agent.js";
|
|
17
|
+
export { describeCatalog, describePage, buildAgentPrompt, parseAgentReply, runAgent, buildAskPrompt, runAsk, keepAnswer, pinAnswer } from "./agent.js";
|
|
18
18
|
export { anthropicComplete } from "./llm.js";
|
|
19
|
+
export { readProjectFile, writeProjectFile, deleteProjectFile, listFileHistory, undoFile, scaffoldSource, scaffoldComponent, readProject } from "./files.js";
|
package/src/loader.js
CHANGED
|
@@ -13,6 +13,8 @@ export async function loadYaml(path) {
|
|
|
13
13
|
return value;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
export function forgetYaml(path) { cache.delete(path); }
|
|
17
|
+
|
|
16
18
|
export async function exists(path) { try { await stat(path); return true; } catch { return false; } }
|
|
17
19
|
|
|
18
20
|
export async function loadProject(root) {
|
package/src/proposals.js
CHANGED
|
@@ -12,6 +12,9 @@ const msg = e => typeof e === "string" ? e : `${e.path}: ${e.message}`;
|
|
|
12
12
|
async function gate(root, name, override, role) {
|
|
13
13
|
if (!override?.extends) return ["override.extends is required"];
|
|
14
14
|
const project = await loadProject(root);
|
|
15
|
+
const roles = project.manifest.roles ?? [];
|
|
16
|
+
if (role != null && !roles.includes(role)) return [`unknown role "${role}"`];
|
|
17
|
+
if (name.startsWith("role-") && !roles.includes(name.slice(5))) return [`unknown role "${name.slice(5)}"`];
|
|
15
18
|
let page; try { page = await loadPage(root, override.extends); } catch { return [`page "${override.extends}" not found`]; }
|
|
16
19
|
const target = name.startsWith("role-") ? name.slice(5) : role ?? null;
|
|
17
20
|
let tree = page.layout;
|
|
@@ -24,7 +27,9 @@ async function pendingEntry(root, name, version, approverRole) {
|
|
|
24
27
|
const entry = (await listHistory(root, name)).find(e => e.version === version);
|
|
25
28
|
if (!entry || entry.status !== "pending") return { errors: [`${name} v${version} is not pending`] };
|
|
26
29
|
const page = entry.override?.extends;
|
|
27
|
-
const
|
|
30
|
+
const manifest = (await loadProject(root)).manifest;
|
|
31
|
+
if (approverRole == null || !(manifest.roles ?? []).includes(approverRole)) return { errors: [`unknown role "${approverRole ?? "none"}"`] };
|
|
32
|
+
const owners = [].concat(manifest.owners?.[page] ?? []);
|
|
28
33
|
if (!owners.includes(approverRole)) return { errors: [`role ${approverRole ?? "none"} cannot approve changes to page ${page}`] };
|
|
29
34
|
return { entry };
|
|
30
35
|
}
|
package/src/resolver.js
CHANGED
|
@@ -14,7 +14,9 @@ const fingerprint = (root, page, user, role) => Promise.all([
|
|
|
14
14
|
].map(f => mtime(join(root, f)))).then(a => Math.max(...a));
|
|
15
15
|
|
|
16
16
|
// YAML page + overrides + data -> json-render flat spec { root, elements, state }
|
|
17
|
-
|
|
17
|
+
// opts.tree: an in-memory layout (e.g. an agent answer), no page file, no overrides, no cache
|
|
18
|
+
export async function resolve(root, page, { user, role, sources, tree, fresh = false, maxAge = 0 } = {}) {
|
|
19
|
+
if (tree) return build(root, page, { user, role, sources, tree });
|
|
18
20
|
const key = `${root}|${page}|${user}|${role}`, fp = await fingerprint(root, page, user, role);
|
|
19
21
|
const hit = cache.get(key);
|
|
20
22
|
if (!fresh && hit && hit.fp === fp && (!maxAge || Date.now() - hit.at < maxAge)) return hit.result;
|
|
@@ -23,12 +25,12 @@ export async function resolve(root, page, { user, role, sources, fresh = false,
|
|
|
23
25
|
return result;
|
|
24
26
|
}
|
|
25
27
|
|
|
26
|
-
async function build(root, page, { user, role, sources }) {
|
|
28
|
+
async function build(root, page, { user, role, sources, tree: given }) {
|
|
27
29
|
const project = await loadProject(root);
|
|
28
30
|
const fetchers = sources ?? await loadSources(root);
|
|
29
|
-
let tree = (await loadPage(root, page)).layout;
|
|
31
|
+
let tree = given ?? (await loadPage(root, page)).layout;
|
|
30
32
|
const errors = [];
|
|
31
|
-
for (const ov of await loadOverrides(root, page, user, role)) {
|
|
33
|
+
if (!given) for (const ov of await loadOverrides(root, page, user, role)) {
|
|
32
34
|
const r = applyOverride(tree, ov); tree = r.tree; errors.push(...r.errors);
|
|
33
35
|
}
|
|
34
36
|
errors.push(...validateTree(tree, project, { role: role ?? null }));
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { Preview } from "./Preview.jsx";
|
|
3
|
+
import { Studio } from "./Studio.jsx";
|
|
4
|
+
import { bar } from "./ui.jsx";
|
|
5
|
+
|
|
6
|
+
// Top level: "Preview" (the rendered app) | "Studio" (forms over the project files).
|
|
7
|
+
export function App({ registry }) {
|
|
8
|
+
const [view, setView] = useState(new URLSearchParams(location.search).get("view") === "studio" ? "studio" : "preview");
|
|
9
|
+
const go = v => { const q = new URLSearchParams(location.search); q.set("view", v); history.replaceState(null, "", "?" + q); setView(v); };
|
|
10
|
+
// file changes: the preview reloads the tab, the studio refreshes its data without losing drafts
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
const es = new EventSource("/events");
|
|
13
|
+
es.onmessage = () => { if (view === "preview") location.reload(); else window.dispatchEvent(new Event("pt:files-changed")); };
|
|
14
|
+
return () => es.close();
|
|
15
|
+
}, [view]);
|
|
16
|
+
const tab = v => <button onClick={() => go(v)} style={{ fontWeight: view === v ? 700 : 400, border: "none", background: "none", cursor: "pointer", borderBottom: view === v ? "2px solid var(--pt-color-primary)" : "2px solid transparent", padding: "4px 8px" }}>{v === "preview" ? "Preview" : "Studio"}</button>;
|
|
17
|
+
return <>
|
|
18
|
+
<nav style={{ ...bar, padding: "4px 16px", gap: 4 }}>{tab("preview")}{tab("studio")}</nav>
|
|
19
|
+
{view === "preview" ? <Preview registry={registry} /> : <Studio registry={registry} />}
|
|
20
|
+
</>;
|
|
21
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from "react";
|
|
2
|
+
import { Page, EditorContext, useEditor } from "../client.jsx";
|
|
3
|
+
|
|
4
|
+
// The rendered app with user/role selectors, the reorder editor and the two agent boxes.
|
|
5
|
+
export function Preview({ registry }) {
|
|
6
|
+
const q = new URLSearchParams(location.search);
|
|
7
|
+
const [page, setPage] = useState(q.get("page") || "__home__");
|
|
8
|
+
const [user, setUser] = useState(q.get("user") || "");
|
|
9
|
+
const [role, setRole] = useState(q.get("role") || "");
|
|
10
|
+
const [state, setState] = useState(null);
|
|
11
|
+
const load = useCallback(() => fetch(`/api/page/${page}?user=${user}&role=${role}`).then(r => r.json()).then(setState), [page, user, role]);
|
|
12
|
+
useEffect(() => { load(); }, [load]);
|
|
13
|
+
// keep page/user/role in the URL: the file watcher reloads the tab after an override is written
|
|
14
|
+
useEffect(() => { const u = new URLSearchParams(location.search); u.set("page", page); u.set("user", user); u.set("role", role); history.replaceState(null, "", "?" + u); }, [page, user, role]);
|
|
15
|
+
const editor = useEditor({ page: state?.page, user, role, why: "reordered in the editor", onApplied: r => { if (!r.pending) load(); } });
|
|
16
|
+
const [ask, setAsk] = useState(""), [agent, setAgent] = useState(null);
|
|
17
|
+
const sendAsk = async e => {
|
|
18
|
+
e.preventDefault(); if (!ask.trim()) return;
|
|
19
|
+
setAgent({ text: "thinking…" });
|
|
20
|
+
try {
|
|
21
|
+
const r = await (await fetch("/api/agent", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ page: state.page, user, role: role || undefined, request: ask }) })).json();
|
|
22
|
+
if (r.ok) setAgent({ text: (r.proposal.pending ? "sent for approval: " : "applied: ") + (r.reply.why ?? "") });
|
|
23
|
+
else setAgent({ error: true, text: r.refuse ?? (r.errors ?? [r.error ?? "agent failed"]).join("; ") });
|
|
24
|
+
} catch (err) { setAgent({ error: true, text: err.message }); }
|
|
25
|
+
};
|
|
26
|
+
const [question, setQuestion] = useState(""), [answer, setAnswer] = useState(null), [answerStatus, setAnswerStatus] = useState(null);
|
|
27
|
+
const post = (path, b) => fetch(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(b) }).then(r => r.json());
|
|
28
|
+
const sendQuestion = async e => {
|
|
29
|
+
e.preventDefault(); if (!question.trim()) return;
|
|
30
|
+
setAnswer(null); setAnswerStatus({ text: "composing…" });
|
|
31
|
+
try {
|
|
32
|
+
const r = await post("/api/ask", { user, role: role || undefined, request: question });
|
|
33
|
+
if (r.ok) { setAnswer(r); setAnswerStatus(null); }
|
|
34
|
+
else setAnswerStatus({ error: true, text: r.refuse ?? (r.errors ?? [r.error ?? "ask failed"]).join("; ") });
|
|
35
|
+
} catch (err) { setAnswerStatus({ error: true, text: err.message }); }
|
|
36
|
+
};
|
|
37
|
+
const answerBody = () => ({ user, role: role || undefined, title: answer.title, tree: answer.tree, why: answer.why });
|
|
38
|
+
const keep = async () => {
|
|
39
|
+
const r = await post("/api/ask/keep", answerBody());
|
|
40
|
+
if (r.ok) { setAnswer(null); setAnswerStatus(null); setPage(r.page); } else setAnswerStatus({ error: true, text: (r.errors ?? [r.error]).join("; ") });
|
|
41
|
+
};
|
|
42
|
+
const pin = async () => {
|
|
43
|
+
const r = await post("/api/ask/pin", answerBody());
|
|
44
|
+
setAnswerStatus(r.ok ? { text: r.pending ? "sent for approval" : "applied to home" } : { error: true, text: (r.errors ?? [r.error]).join("; ") });
|
|
45
|
+
};
|
|
46
|
+
if (!state) return null;
|
|
47
|
+
const pages = state.manifest.pages, roles = state.manifest.roles ?? [];
|
|
48
|
+
return <>
|
|
49
|
+
<header style={{ display: "flex", gap: 12, alignItems: "center", padding: "10px 16px", borderBottom: "1px solid var(--pt-color-border)", background: "var(--pt-color-surface)", fontSize: 13 }}>
|
|
50
|
+
<strong>{state.manifest.app}</strong>
|
|
51
|
+
<label>page <select value={state.page} onChange={e => setPage(e.target.value)}>{pages.map(p => <option key={p}>{p}</option>)}</select></label>
|
|
52
|
+
<label>user <input value={user} placeholder="giulia" onChange={e => setUser(e.target.value)} style={{ width: 90 }} /></label>
|
|
53
|
+
<label>role <select value={role} onChange={e => setRole(e.target.value)}><option value="">(none)</option>{roles.map(r => <option key={r}>{r}</option>)}</select></label>
|
|
54
|
+
<button onClick={() => editor.setEditing(!editor.editing)} style={{ fontWeight: editor.editing ? 700 : 400 }}>{editor.editing ? "Done editing" : "Edit layout"}</button>
|
|
55
|
+
{editor.status === "saving" && <span>saving…</span>}
|
|
56
|
+
{editor.status === "pending" && <span>sent for approval</span>}
|
|
57
|
+
{editor.status === "error" && <span style={{ color: "var(--pt-color-danger)" }}>{editor.errors.join("; ")}</span>}
|
|
58
|
+
<form onSubmit={sendAsk} style={{ display: "flex", gap: 6 }}><input value={ask} placeholder="Change this page…" onChange={e => setAsk(e.target.value)} style={{ width: 220 }} /><button type="submit">Ask</button></form>
|
|
59
|
+
<form onSubmit={sendQuestion} style={{ display: "flex", gap: 6 }}><input value={question} placeholder="Ask…" onChange={e => setQuestion(e.target.value)} style={{ width: 220 }} /><button type="submit">Ask</button></form>
|
|
60
|
+
{agent && <span style={{ color: agent.error ? "var(--pt-color-danger)" : undefined }}>{agent.text}</span>}
|
|
61
|
+
{state.errors.length > 0 && <span style={{ color: "var(--pt-color-danger)" }}>{state.errors.length} problem(s), see console</span>}
|
|
62
|
+
</header>
|
|
63
|
+
{(answer || answerStatus) && <div style={{ display: "flex", gap: 12, alignItems: "center", padding: "8px 16px", borderBottom: "1px solid var(--pt-color-border)", fontSize: 13 }}>
|
|
64
|
+
{answer && <><strong>{answer.title}</strong><span>{answer.why}</span><button onClick={keep}>Keep as page</button><button onClick={pin}>Pin to home</button><button onClick={() => { setAnswer(null); setAnswerStatus(null); }}>Back</button></>}
|
|
65
|
+
{answerStatus && <span style={{ color: answerStatus.error ? "var(--pt-color-danger)" : undefined }}>{answerStatus.text}</span>}
|
|
66
|
+
</div>}
|
|
67
|
+
<main style={{ padding: 16, maxWidth: 1100, margin: "0 auto" }}>{answer
|
|
68
|
+
? <Page resolved={{ ...state, spec: answer.resolved.spec, errors: answer.resolved.errors }} registry={registry} />
|
|
69
|
+
: <EditorContext.Provider value={{ editing: editor.editing, propose: editor.propose }}><Page resolved={state} registry={registry} /></EditorContext.Provider>}</main>
|
|
70
|
+
</>;
|
|
71
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from "react";
|
|
2
|
+
import { AppTab } from "./tabs/AppTab.jsx";
|
|
3
|
+
import { DataTab } from "./tabs/DataTab.jsx";
|
|
4
|
+
import { ComponentsTab } from "./tabs/ComponentsTab.jsx";
|
|
5
|
+
import { TokensTab } from "./tabs/TokensTab.jsx";
|
|
6
|
+
import { PeopleTab } from "./tabs/PeopleTab.jsx";
|
|
7
|
+
import { PagesTab } from "./tabs/PagesTab.jsx";
|
|
8
|
+
import { bar } from "./ui.jsx";
|
|
9
|
+
|
|
10
|
+
// Tabs over the project files. Each tab gets { project, reload, registry }; later tabs add entries here.
|
|
11
|
+
const TABS = [
|
|
12
|
+
{ id: "app", label: "App", Component: AppTab },
|
|
13
|
+
{ id: "pages", label: "Pages", Component: PagesTab },
|
|
14
|
+
{ id: "data", label: "Data", Component: DataTab },
|
|
15
|
+
{ id: "components", label: "Components", Component: ComponentsTab },
|
|
16
|
+
{ id: "tokens", label: "Tokens", Component: TokensTab },
|
|
17
|
+
{ id: "people", label: "People", Component: PeopleTab },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
export function useProject() {
|
|
21
|
+
const [project, setProject] = useState(null), [error, setError] = useState(null);
|
|
22
|
+
const reload = useCallback(() => fetch("/api/project").then(r => r.json()).then(p => { setProject(p); setError(null); }, e => setError(e.message)), []);
|
|
23
|
+
useEffect(() => { reload(); const on = () => reload(); window.addEventListener("pt:files-changed", on); return () => window.removeEventListener("pt:files-changed", on); }, [reload]);
|
|
24
|
+
return { project, reload, error };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function Studio({ registry }) {
|
|
28
|
+
const [tab, setTab] = useState(new URLSearchParams(location.search).get("tab") || "app");
|
|
29
|
+
const { project, reload, error } = useProject();
|
|
30
|
+
const go = id => { const q = new URLSearchParams(location.search); q.set("tab", id); history.replaceState(null, "", "?" + q); setTab(id); };
|
|
31
|
+
const current = TABS.find(t => t.id === tab) ?? TABS[0];
|
|
32
|
+
return <>
|
|
33
|
+
<header style={{ ...bar, background: "var(--pt-color-surface)" }}>
|
|
34
|
+
<strong>{project?.manifest?.app ?? "Studio"}</strong>
|
|
35
|
+
{TABS.map(t => <button key={t.id} onClick={() => go(t.id)} style={{ fontWeight: current.id === t.id ? 700 : 400 }}>{t.label}</button>)}
|
|
36
|
+
{error && <span style={{ color: "var(--pt-color-danger)" }}>{error}</span>}
|
|
37
|
+
</header>
|
|
38
|
+
{project && <current.Component project={project} reload={reload} registry={registry} />}
|
|
39
|
+
</>;
|
|
40
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Field, Select, Toggle, ListEditor, YamlPane, SaveBar, useFileDraft, api, errorText, danger, section, h } from "../ui.jsx";
|
|
3
|
+
|
|
4
|
+
const SLUG = /^[a-z0-9-]+$/;
|
|
5
|
+
|
|
6
|
+
// manifest.yaml: name, home, navigation, pages (with their files), roles and page owners.
|
|
7
|
+
export function AppTab({ project, reload }) {
|
|
8
|
+
const file = useFileDraft("manifest");
|
|
9
|
+
const [note, setNote] = useState(null);
|
|
10
|
+
const m = file.draft;
|
|
11
|
+
if (!m) return null;
|
|
12
|
+
const pages = m.pages ?? [], roles = m.roles ?? [];
|
|
13
|
+
const ownersOf = p => [].concat(m.owners?.[p] ?? []);
|
|
14
|
+
|
|
15
|
+
const addPage = async slug => {
|
|
16
|
+
if (!SLUG.test(slug)) { setNote(`"${slug}": use lowercase letters, digits and dashes`); return false; }
|
|
17
|
+
if (pages.includes(slug)) { setNote(`page "${slug}" already exists`); return false; }
|
|
18
|
+
const r = await api(`/api/files/pages/${slug}`, "PUT", { value: { page: slug, layout: { ref: "layout/stack@1.0.0" } }, why: `new page ${slug}` });
|
|
19
|
+
if (!r.ok) { setNote(errorText(r)); return false; }
|
|
20
|
+
const next = { ...structuredClone(m), pages: [...pages, slug] };
|
|
21
|
+
file.setDraft(next);
|
|
22
|
+
const s = await file.save(next);
|
|
23
|
+
setNote(s.ok ? null : errorText(s)); reload();
|
|
24
|
+
};
|
|
25
|
+
const removePage = async i => {
|
|
26
|
+
const slug = pages[i];
|
|
27
|
+
if (slug === m.home) { setNote(`"${slug}" is the home page: choose another home first`); return; }
|
|
28
|
+
const r = await api(`/api/files/pages/${slug}`, "DELETE");
|
|
29
|
+
if (!r.ok) { setNote(errorText(r)); return; }
|
|
30
|
+
const next = structuredClone(m);
|
|
31
|
+
next.pages = pages.filter(p => p !== slug);
|
|
32
|
+
if (next.owners) delete next.owners[slug];
|
|
33
|
+
file.setDraft(next);
|
|
34
|
+
const s = await file.save(next);
|
|
35
|
+
setNote(s.ok ? null : errorText(s)); reload();
|
|
36
|
+
};
|
|
37
|
+
const addRole = role => {
|
|
38
|
+
if (!SLUG.test(role)) { setNote(`"${role}": use lowercase letters, digits and dashes`); return false; }
|
|
39
|
+
if (roles.includes(role)) { setNote(`role "${role}" already exists`); return false; }
|
|
40
|
+
setNote(null); file.update(d => { d.roles = [...(d.roles ?? []), role]; });
|
|
41
|
+
};
|
|
42
|
+
const removeRole = i => {
|
|
43
|
+
const role = roles[i];
|
|
44
|
+
const usedBy = [
|
|
45
|
+
...pages.filter(p => ownersOf(p).includes(role)).map(p => `owner of page ${p}`),
|
|
46
|
+
...Object.entries(project.catalog ?? {}).filter(([, s]) => (s.roles ?? []).includes(role)).map(([n]) => `data source ${n}`),
|
|
47
|
+
];
|
|
48
|
+
if (usedBy.length) { setNote(`role "${role}" is used: ${usedBy.join(", ")}`); return; }
|
|
49
|
+
setNote(null); file.update(d => { d.roles = d.roles.filter(r => r !== role); });
|
|
50
|
+
};
|
|
51
|
+
const setOwner = (page, role, on) => file.update(d => {
|
|
52
|
+
const cur = [].concat(d.owners?.[page] ?? []);
|
|
53
|
+
const next = on ? [...cur, role] : cur.filter(r => r !== role);
|
|
54
|
+
d.owners ??= {};
|
|
55
|
+
if (next.length) d.owners[page] = next; else delete d.owners[page];
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return <div style={{ display: "flex", flexDirection: "column", minHeight: "calc(100vh - 90px)" }}>
|
|
59
|
+
<main style={{ padding: 16, maxWidth: 800, width: "100%", boxSizing: "border-box", margin: "0 auto", flex: 1 }}>
|
|
60
|
+
<div style={section}>
|
|
61
|
+
<Field label="name" value={m.app} onChange={v => file.update(d => { d.app = v; })} />
|
|
62
|
+
<Select label="home" value={m.home} options={pages} onChange={v => file.update(d => { d.home = v; })} />
|
|
63
|
+
<Select label="navigation" value={m.nav?.position ?? "left"} options={["left", "top"]} onChange={v => file.update(d => { d.nav = { ...(d.nav ?? {}), position: v }; })} />
|
|
64
|
+
</div>
|
|
65
|
+
{note && <p style={{ ...danger, fontSize: 13 }}>{note}</p>}
|
|
66
|
+
<div style={section}>
|
|
67
|
+
<h3 style={h}>Pages</h3>
|
|
68
|
+
<ListEditor items={pages} addLabel="+ page" addPlaceholder="slug" onAdd={addPage} onRemove={removePage}
|
|
69
|
+
render={p => <>
|
|
70
|
+
<strong style={{ minWidth: 120 }}>{p}{p === m.home ? " (home)" : ""}</strong>
|
|
71
|
+
<span style={{ color: "var(--pt-color-muted)" }}>owners</span>
|
|
72
|
+
{roles.map(r => <Toggle key={r} label={r} checked={ownersOf(p).includes(r)} onChange={on => setOwner(p, r, on)} />)}
|
|
73
|
+
</>} />
|
|
74
|
+
</div>
|
|
75
|
+
<div style={section}>
|
|
76
|
+
<h3 style={h}>Roles</h3>
|
|
77
|
+
<ListEditor items={roles} addLabel="+ role" addPlaceholder="role" onAdd={addRole} onRemove={removeRole} />
|
|
78
|
+
</div>
|
|
79
|
+
</main>
|
|
80
|
+
<div style={{ position: "sticky", bottom: 0, background: "var(--pt-color-background)" }}>
|
|
81
|
+
<SaveBar file={file} />
|
|
82
|
+
<YamlPane name="manifest" value={m} />
|
|
83
|
+
</div>
|
|
84
|
+
</div>;
|
|
85
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Field, Toggle, ListEditor, YamlPane, SaveBar, useFileDraft, api, danger, section, h } from "../ui.jsx";
|
|
3
|
+
import { typeSuggestions, pagesUsing, valuesToText, textToValues } from "./DataTab.jsx";
|
|
4
|
+
|
|
5
|
+
export const COMPONENT_NAME = /^[a-z0-9-]+\/[a-z0-9-]+$/;
|
|
6
|
+
export const SEMVER = /^\d+\.\d+\.\d+$/;
|
|
7
|
+
export const PROP_TYPES = ["string", "number", "boolean", "text", "string[]", "number[]", "enum"];
|
|
8
|
+
|
|
9
|
+
// Same naming as files.js pascalName (kept here so the tab has no server import).
|
|
10
|
+
export const pascalName = name => String(name).split(/[^a-zA-Z0-9]+/).filter(Boolean).map(s => s[0].toUpperCase() + s.slice(1)).join("");
|
|
11
|
+
|
|
12
|
+
// { type, optional, values } <-> registry notation like "enum[a,b]?" or "string?".
|
|
13
|
+
export function propToNotation({ type, optional, values }) {
|
|
14
|
+
const base = type === "enum" ? `enum[${[].concat(values ?? []).join(",")}]` : type;
|
|
15
|
+
return optional ? `${base}?` : base;
|
|
16
|
+
}
|
|
17
|
+
export function notationToProp(str) {
|
|
18
|
+
let s = String(str ?? "").trim();
|
|
19
|
+
const optional = s.endsWith("?");
|
|
20
|
+
if (optional) s = s.slice(0, -1).trim();
|
|
21
|
+
const m = /^enum\[(.*)\]$/.exec(s);
|
|
22
|
+
if (m) return { type: "enum", optional, values: m[1].split(",").map(v => v.trim()).filter(Boolean) };
|
|
23
|
+
return { type: PROP_TYPES.includes(s) ? s : "string", optional };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const semverKey = v => v.split(".").map(Number);
|
|
27
|
+
export function latestVersion(versions) {
|
|
28
|
+
return Object.keys(versions ?? {}).filter(v => SEMVER.test(v)).sort((a, b) => {
|
|
29
|
+
const x = semverKey(a), y = semverKey(b);
|
|
30
|
+
return x[0] - y[0] || x[1] - y[1] || x[2] - y[2];
|
|
31
|
+
}).at(-1);
|
|
32
|
+
}
|
|
33
|
+
// Next minor of the latest version, as a suggestion for "+ version".
|
|
34
|
+
export function nextVersion(versions) {
|
|
35
|
+
const l = latestVersion(versions);
|
|
36
|
+
if (!l) return "1.0.0";
|
|
37
|
+
const [a, b] = semverKey(l);
|
|
38
|
+
return `${a}.${b + 1}.0`;
|
|
39
|
+
}
|
|
40
|
+
export const newComponentEntry = name => ({ description: "", props: {}, versions: { "1.0.0": { impl: `./${pascalName(name)}.jsx` } } });
|
|
41
|
+
|
|
42
|
+
// components/registry.yaml: components, what data they accept, their props and versions.
|
|
43
|
+
export function ComponentsTab({ project, reload }) {
|
|
44
|
+
const file = useFileDraft("components/registry");
|
|
45
|
+
const [note, setNote] = useState(null), [info, setInfo] = useState(null), [open, setOpen] = useState(null);
|
|
46
|
+
const [fresh, setFresh] = useState([]); // added since load: scaffolded on the next Save
|
|
47
|
+
const reg = file.draft;
|
|
48
|
+
if (!reg) return null;
|
|
49
|
+
const names = Object.keys(reg);
|
|
50
|
+
const types = typeSuggestions(project.catalog, reg);
|
|
51
|
+
|
|
52
|
+
const add = name => {
|
|
53
|
+
if (!COMPONENT_NAME.test(name)) { setNote(`"${name}": use group/name with lowercase letters, digits and dashes`); return false; }
|
|
54
|
+
if (name in reg) { setNote(`component "${name}" already exists`); return false; }
|
|
55
|
+
setNote(null); setOpen(name); setFresh(f => [...f, name]);
|
|
56
|
+
file.update(d => { d[name] = newComponentEntry(name); });
|
|
57
|
+
};
|
|
58
|
+
const remove = async i => {
|
|
59
|
+
const name = names[i];
|
|
60
|
+
const used = await pagesUsing(project.pages, n => typeof n.ref === "string" && n.ref.split("@")[0] === name);
|
|
61
|
+
if (used.length) { setNote(`component "${name}" is used by page ${used.join(", ")}`); return; }
|
|
62
|
+
setNote(null); setFresh(f => f.filter(n => n !== name)); file.update(d => { delete d[name]; });
|
|
63
|
+
};
|
|
64
|
+
const save = async () => {
|
|
65
|
+
const r = await file.save();
|
|
66
|
+
if (!r.ok) return;
|
|
67
|
+
const todo = fresh.filter(n => n in reg); setFresh([]);
|
|
68
|
+
const made = [];
|
|
69
|
+
for (const name of todo) {
|
|
70
|
+
const s = await api("/api/scaffold/component", "POST", { name, props: reg[name].props ?? {}, container: !!reg[name].container });
|
|
71
|
+
if (s.created) made.push(`components/${pascalName(name)}.jsx`); else if (s.error) setNote(s.error);
|
|
72
|
+
}
|
|
73
|
+
if (made.length) setInfo(`created ${made.join(", ")}, open it to write the React`);
|
|
74
|
+
reload();
|
|
75
|
+
};
|
|
76
|
+
const set = (name, fn) => file.update(d => { fn(d[name]); });
|
|
77
|
+
const props = name => Object.entries(reg[name].props ?? {});
|
|
78
|
+
const setProp = (name, key, patch) => set(name, x => { x.props[key] = propToNotation({ ...notationToProp(x.props[key]), ...patch }); });
|
|
79
|
+
|
|
80
|
+
const addVersion = name => v => {
|
|
81
|
+
if (!SEMVER.test(v)) { setNote(`"${v}": use a version like 1.2.0`); return false; }
|
|
82
|
+
if (v in (reg[name].versions ?? {})) { setNote(`version ${v} already exists`); return false; }
|
|
83
|
+
const impl = reg[name].versions?.[latestVersion(reg[name].versions)]?.impl ?? `./${pascalName(name)}.jsx`;
|
|
84
|
+
setNote(null); set(name, x => { x.versions = { ...(x.versions ?? {}), [v]: { impl } }; });
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const row = name => {
|
|
88
|
+
const e = reg[name];
|
|
89
|
+
return <div style={{ width: "100%" }}>
|
|
90
|
+
<button onClick={() => setOpen(open === name ? null : name)} style={{ border: "none", background: "none", cursor: "pointer", padding: 0 }}>{open === name ? "▾" : "▸"} <strong>{name}</strong></button>
|
|
91
|
+
<span style={{ color: "var(--pt-color-muted)", marginLeft: 8 }}>{e.container ? "container" : e.accepts}</span>
|
|
92
|
+
{open === name && <div style={{ padding: "8px 0 0 16px" }}>
|
|
93
|
+
<Field label="description" value={e.description} onChange={v => set(name, x => { x.description = v; })} />
|
|
94
|
+
<label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 13, marginBottom: 12 }}>accepts
|
|
95
|
+
<input list="pt-component-types" value={e.accepts ?? ""} disabled={!!e.container} onChange={ev => set(name, x => { if (ev.target.value) x.accepts = ev.target.value; else delete x.accepts; })} style={{ maxWidth: 320 }} />
|
|
96
|
+
</label>
|
|
97
|
+
<div style={{ marginBottom: 12 }}>
|
|
98
|
+
<Toggle label="container (has children, accepts no data)" checked={e.container} onChange={on => set(name, x => { if (on) { x.container = true; delete x.accepts; } else delete x.container; })} />
|
|
99
|
+
</div>
|
|
100
|
+
<div style={{ marginBottom: 12 }}>
|
|
101
|
+
<span style={{ fontSize: 13 }}>props</span>
|
|
102
|
+
<ListEditor items={props(name)} addLabel="+ prop" addPlaceholder="prop name"
|
|
103
|
+
onAdd={p => {
|
|
104
|
+
if (!/^[A-Za-z_$][\w$]*$/.test(p) || ["data", "names", "__pt"].includes(p)) { setNote(`"${p}": not a valid prop name`); return false; }
|
|
105
|
+
if (p in (e.props ?? {})) { setNote(`prop "${p}" already exists`); return false; }
|
|
106
|
+
setNote(null); set(name, x => { x.props = { ...(x.props ?? {}), [p]: "string?" }; });
|
|
107
|
+
}}
|
|
108
|
+
onRemove={i => set(name, x => { delete x.props[props(name)[i][0]]; })}
|
|
109
|
+
render={([p, notation]) => { const t = notationToProp(notation); return <>
|
|
110
|
+
<strong style={{ minWidth: 100 }}>{p}</strong>
|
|
111
|
+
<select value={t.type} onChange={ev => setProp(name, p, { type: ev.target.value, values: ev.target.value === "enum" ? (t.values ?? []) : undefined })}>
|
|
112
|
+
{PROP_TYPES.map(o => <option key={o} value={o}>{o}</option>)}
|
|
113
|
+
</select>
|
|
114
|
+
<Toggle label="optional" checked={t.optional} onChange={on => setProp(name, p, { optional: on })} />
|
|
115
|
+
{t.type === "enum" && <input value={valuesToText(t.values)} placeholder="values, comma separated" onChange={ev => setProp(name, p, { values: textToValues(ev.target.value) })} style={{ width: 200 }} />}
|
|
116
|
+
<code style={{ color: "var(--pt-color-muted)" }}>{notation}</code>
|
|
117
|
+
</>; }} />
|
|
118
|
+
</div>
|
|
119
|
+
<div>
|
|
120
|
+
<span style={{ fontSize: 13 }}>versions</span>
|
|
121
|
+
<ListEditor items={Object.entries(e.versions ?? {})} addLabel="+ version" addPlaceholder={nextVersion(e.versions)} onAdd={addVersion(name)}
|
|
122
|
+
render={([v, x]) => <><strong style={{ minWidth: 100 }}>{v}</strong><code>{x?.impl}</code></>} />
|
|
123
|
+
</div>
|
|
124
|
+
</div>}
|
|
125
|
+
</div>;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
return <div style={{ display: "flex", flexDirection: "column", minHeight: "calc(100vh - 90px)" }}>
|
|
129
|
+
<main style={{ padding: 16, maxWidth: 800, width: "100%", boxSizing: "border-box", margin: "0 auto", flex: 1 }}>
|
|
130
|
+
<datalist id="pt-component-types">{types.map(t => <option key={t} value={t} />)}</datalist>
|
|
131
|
+
{note && <p style={{ ...danger, fontSize: 13 }}>{note}</p>}
|
|
132
|
+
{info && <p style={{ fontSize: 13 }}>{info}</p>}
|
|
133
|
+
<div style={section}>
|
|
134
|
+
<h3 style={h}>Components</h3>
|
|
135
|
+
<ListEditor items={names} addLabel="+ component" addPlaceholder="group/name, e.g. kpi/delta" onAdd={add} onRemove={remove} render={row} />
|
|
136
|
+
</div>
|
|
137
|
+
</main>
|
|
138
|
+
<div style={{ position: "sticky", bottom: 0, background: "var(--pt-color-background)" }}>
|
|
139
|
+
<SaveBar file={{ ...file, save, dirty: file.dirty || fresh.length > 0 }} />
|
|
140
|
+
<YamlPane name="registry" value={reg} />
|
|
141
|
+
</div>
|
|
142
|
+
</div>;
|
|
143
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Field, Toggle, ListEditor, YamlPane, SaveBar, useFileDraft, api, danger, section, h } from "../ui.jsx";
|
|
3
|
+
|
|
4
|
+
export const SOURCE_NAME = /^[a-z0-9_.]+$/;
|
|
5
|
+
export const BASE_TYPES = ["money", "number", "string", "timeseries<money>", "timeseries<number>", "table<any>", "list<task>"];
|
|
6
|
+
|
|
7
|
+
// Suggestions for returns/accepts: the base list plus every type already used in catalog and registry.
|
|
8
|
+
export function typeSuggestions(catalog = {}, registry = {}) {
|
|
9
|
+
const used = [...Object.values(catalog ?? {}).map(s => s?.returns), ...Object.values(registry ?? {}).map(c => c?.accepts)];
|
|
10
|
+
return [...new Set([...BASE_TYPES, ...used.filter(t => typeof t === "string" && t)])];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// roles: ["*"] means everyone; toggling a single role drops "*".
|
|
14
|
+
export function rolesToggle(roles, role, on) {
|
|
15
|
+
const cur = [].concat(roles ?? []).filter(r => r !== "*");
|
|
16
|
+
if (role === "*") return on ? ["*"] : [];
|
|
17
|
+
return on ? [...new Set([...cur, role])] : cur.filter(r => r !== role);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const valuesToText = values => [].concat(values ?? []).join(", ");
|
|
21
|
+
export const textToValues = text => String(text).split(",").map(v => v.trim()).filter(Boolean);
|
|
22
|
+
|
|
23
|
+
// Every node of a page tree (layout plus children, either array or named map).
|
|
24
|
+
export function* walkNodes(node) {
|
|
25
|
+
if (!node || typeof node !== "object") return;
|
|
26
|
+
yield node;
|
|
27
|
+
const kids = node.children;
|
|
28
|
+
for (const c of Array.isArray(kids) ? kids : Object.values(kids ?? {})) yield* walkNodes(c);
|
|
29
|
+
}
|
|
30
|
+
export const sourcesUsedIn = page => [...walkNodes(page?.layout)].map(n => n.data?.source).filter(Boolean);
|
|
31
|
+
|
|
32
|
+
// Pages (by slug) whose tree matches pred(node); reads every page file.
|
|
33
|
+
export async function pagesUsing(slugs, pred) {
|
|
34
|
+
const hits = [];
|
|
35
|
+
for (const slug of slugs ?? []) {
|
|
36
|
+
const r = await api(`/api/files/pages/${slug}`);
|
|
37
|
+
if ([...walkNodes(r.value?.layout)].some(pred)) hits.push(slug);
|
|
38
|
+
}
|
|
39
|
+
return hits;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// data-catalog.yaml: typed data sources, their params and the roles allowed to read them.
|
|
43
|
+
export function DataTab({ project, reload }) {
|
|
44
|
+
const file = useFileDraft("data-catalog");
|
|
45
|
+
const [note, setNote] = useState(null), [info, setInfo] = useState(null), [open, setOpen] = useState(null);
|
|
46
|
+
const [fresh, setFresh] = useState([]); // added since load: scaffolded on the next Save
|
|
47
|
+
const c = file.draft;
|
|
48
|
+
if (!c) return null;
|
|
49
|
+
const names = Object.keys(c), roles = project.manifest?.roles ?? [];
|
|
50
|
+
const types = typeSuggestions(c, project.registry);
|
|
51
|
+
|
|
52
|
+
const add = name => {
|
|
53
|
+
if (!SOURCE_NAME.test(name)) { setNote(`"${name}": use lowercase letters, digits, dots and underscores`); return false; }
|
|
54
|
+
if (name in c) { setNote(`source "${name}" already exists`); return false; }
|
|
55
|
+
setNote(null); setOpen(name); setFresh(f => [...f, name]);
|
|
56
|
+
file.update(d => { d[name] = { description: "", returns: "", roles: ["*"] }; });
|
|
57
|
+
};
|
|
58
|
+
const remove = async i => {
|
|
59
|
+
const name = names[i];
|
|
60
|
+
const used = await pagesUsing(project.pages, n => n.data?.source === name);
|
|
61
|
+
if (used.length) { setNote(`source "${name}" is used by page ${used.join(", ")}`); return; }
|
|
62
|
+
setNote(null); setFresh(f => f.filter(n => n !== name)); file.update(d => { delete d[name]; });
|
|
63
|
+
};
|
|
64
|
+
const save = async () => {
|
|
65
|
+
const r = await file.save();
|
|
66
|
+
if (!r.ok) return;
|
|
67
|
+
let stub = false;
|
|
68
|
+
const todo = fresh.filter(n => n in c); setFresh([]);
|
|
69
|
+
for (const name of todo) {
|
|
70
|
+
const s = await api("/api/scaffold/source", "POST", { name });
|
|
71
|
+
if (s.created) stub = true; else if (s.error) setNote(s.error);
|
|
72
|
+
}
|
|
73
|
+
if (stub) setInfo("stub added to data/sources.mjs");
|
|
74
|
+
reload();
|
|
75
|
+
};
|
|
76
|
+
const set = (name, fn) => file.update(d => { fn(d[name]); });
|
|
77
|
+
const params = name => Object.entries(c[name].params ?? {});
|
|
78
|
+
|
|
79
|
+
const row = name => {
|
|
80
|
+
const s = c[name], everyone = [].concat(s.roles ?? []).includes("*");
|
|
81
|
+
return <div style={{ width: "100%" }}>
|
|
82
|
+
<button onClick={() => setOpen(open === name ? null : name)} style={{ border: "none", background: "none", cursor: "pointer", padding: 0 }}>{open === name ? "▾" : "▸"} <strong>{name}</strong></button>
|
|
83
|
+
<span style={{ color: "var(--pt-color-muted)", marginLeft: 8 }}>{s.returns}</span>
|
|
84
|
+
{open === name && <div style={{ padding: "8px 0 0 16px" }}>
|
|
85
|
+
<Field label="description" value={s.description} onChange={v => set(name, x => { x.description = v; })} />
|
|
86
|
+
<label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 13, marginBottom: 12 }}>returns
|
|
87
|
+
<input list="pt-data-types" value={s.returns ?? ""} onChange={e => set(name, x => { x.returns = e.target.value; })} style={{ maxWidth: 320 }} />
|
|
88
|
+
</label>
|
|
89
|
+
<div style={{ marginBottom: 12 }}>
|
|
90
|
+
<span style={{ fontSize: 13 }}>params</span>
|
|
91
|
+
<ListEditor items={params(name)} addLabel="+ param" addPlaceholder="param name"
|
|
92
|
+
onAdd={p => { if (!/^[A-Za-z_][\w]*$/.test(p)) { setNote(`"${p}": not a valid param name`); return false; } set(name, x => { x.params = { ...(x.params ?? {}), [p]: [] }; }); }}
|
|
93
|
+
onRemove={i => set(name, x => { delete x.params[params(name)[i][0]]; if (!Object.keys(x.params).length) delete x.params; })}
|
|
94
|
+
render={([p, vals]) => <>
|
|
95
|
+
<strong style={{ minWidth: 100 }}>{p}</strong>
|
|
96
|
+
<input value={valuesToText(vals)} placeholder="allowed values, comma separated" onChange={e => set(name, x => { x.params[p] = textToValues(e.target.value); })} style={{ width: 260 }} />
|
|
97
|
+
</>} />
|
|
98
|
+
</div>
|
|
99
|
+
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" }}>
|
|
100
|
+
<span style={{ color: "var(--pt-color-muted)", fontSize: 13 }}>roles</span>
|
|
101
|
+
<Toggle label="everyone (*)" checked={everyone} onChange={on => set(name, x => { x.roles = rolesToggle(x.roles, "*", on); })} />
|
|
102
|
+
{!everyone && roles.map(r => <Toggle key={r} label={r} checked={[].concat(s.roles ?? []).includes(r)} onChange={on => set(name, x => { x.roles = rolesToggle(x.roles, r, on); })} />)}
|
|
103
|
+
</div>
|
|
104
|
+
</div>}
|
|
105
|
+
</div>;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
return <div style={{ display: "flex", flexDirection: "column", minHeight: "calc(100vh - 90px)" }}>
|
|
109
|
+
<main style={{ padding: 16, maxWidth: 800, width: "100%", boxSizing: "border-box", margin: "0 auto", flex: 1 }}>
|
|
110
|
+
<datalist id="pt-data-types">{types.map(t => <option key={t} value={t} />)}</datalist>
|
|
111
|
+
{note && <p style={{ ...danger, fontSize: 13 }}>{note}</p>}
|
|
112
|
+
{info && <p style={{ fontSize: 13 }}>{info}</p>}
|
|
113
|
+
<div style={section}>
|
|
114
|
+
<h3 style={h}>Data sources</h3>
|
|
115
|
+
<ListEditor items={names} addLabel="+ source" addPlaceholder="name, e.g. sales.total" onAdd={add} onRemove={remove} render={row} />
|
|
116
|
+
</div>
|
|
117
|
+
</main>
|
|
118
|
+
<div style={{ position: "sticky", bottom: 0, background: "var(--pt-color-background)" }}>
|
|
119
|
+
<SaveBar file={{ ...file, save, dirty: file.dirty || fresh.length > 0 }} />
|
|
120
|
+
<YamlPane name="data-catalog" value={c} />
|
|
121
|
+
</div>
|
|
122
|
+
</div>;
|
|
123
|
+
}
|