@kahitsan/ksui 0.15.2 → 0.17.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/package.json +13 -2
- package/src/components/base/DataTable.test.tsx +51 -0
- package/src/components/base/DatePicker.test.tsx +51 -0
- package/src/components/base/Modal.test.tsx +71 -0
- package/src/components/composite/resource/ResourceDetail.tsx +33 -0
- package/src/components/composite/resource/ResourceForm.tsx +113 -0
- package/src/components/composite/resource/ResourcePage.tsx +416 -0
- package/src/components/composite/resource/cells.tsx +43 -0
- package/src/components/composite/resource/spec.test.ts +116 -0
- package/src/components/composite/resource/spec.ts +293 -0
- package/src/index.ts +13 -0
- package/src/test/setup.ts +18 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kahitsan/ksui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "ksui is a standalone set of SolidJS UI components for KahitSan/Hilinga and any SolidJS app. Published to the public npm registry and consumed as a normal dependency. Ships source under a `solid` export condition so the consumer's vite-plugin-solid compiles it with only solid-js externalized; it depends on nothing but solid-js + lucide-solid and injects its own CSS.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -32,6 +32,11 @@
|
|
|
32
32
|
],
|
|
33
33
|
"scripts": {
|
|
34
34
|
"typecheck": "tsc --noEmit",
|
|
35
|
+
"test": "vitest run",
|
|
36
|
+
"test:watch": "vitest",
|
|
37
|
+
"test:e2e": "playwright test",
|
|
38
|
+
"test:e2e:ui": "playwright test --ui",
|
|
39
|
+
"dev:e2e": "vite --config e2e/vite.config.ts",
|
|
35
40
|
"changeset": "changeset",
|
|
36
41
|
"version": "changeset version",
|
|
37
42
|
"release": "changeset publish"
|
|
@@ -50,8 +55,14 @@
|
|
|
50
55
|
},
|
|
51
56
|
"devDependencies": {
|
|
52
57
|
"@changesets/cli": "^2.31.0",
|
|
58
|
+
"@playwright/test": "^1.50.0",
|
|
59
|
+
"@solidjs/testing-library": "^0.8.0",
|
|
60
|
+
"jsdom": "^26.0.0",
|
|
53
61
|
"solid-js": "^1.9.0",
|
|
54
|
-
"typescript": "^5.6.0"
|
|
62
|
+
"typescript": "^5.6.0",
|
|
63
|
+
"vite": "^6.0.0",
|
|
64
|
+
"vite-plugin-solid": "^2.11.0",
|
|
65
|
+
"vitest": "^4.0.0"
|
|
55
66
|
},
|
|
56
67
|
"publishConfig": {
|
|
57
68
|
"access": "public"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { render, screen, fireEvent } from "@solidjs/testing-library";
|
|
3
|
+
import { DataTable, type DataTableColumn } from "./DataTable";
|
|
4
|
+
|
|
5
|
+
// DataTable is the most reused widget in every plugin's list view. This suite
|
|
6
|
+
// covers the CLIENT-SIDE mode (static data prop) only — server-side mode
|
|
7
|
+
// (fetchFn) requires async mocks and is covered by the transactions integration
|
|
8
|
+
// test. The dedup rule: a plugin UI test must NOT re-assert "columns render
|
|
9
|
+
// headers" or "pagination shows page 2" — those are owned here.
|
|
10
|
+
|
|
11
|
+
type Row = { id: number; name: string; amount: number };
|
|
12
|
+
|
|
13
|
+
const COLUMNS: DataTableColumn<Row>[] = [
|
|
14
|
+
{ data: "name" },
|
|
15
|
+
{ data: "amount", orderable: true },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const DATA: Row[] = [
|
|
19
|
+
{ id: 1, name: "Alpha", amount: 100 },
|
|
20
|
+
{ id: 2, name: "Beta", amount: 200 },
|
|
21
|
+
{ id: 3, name: "Gamma", amount: 300 },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
describe("DataTable (client-side mode)", () => {
|
|
25
|
+
it("renders column headers", () => {
|
|
26
|
+
render(() => <DataTable columns={COLUMNS} data={DATA} />);
|
|
27
|
+
expect(screen.getByText("Name")).toBeTruthy();
|
|
28
|
+
expect(screen.getByText("Amount")).toBeTruthy();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("renders all rows", () => {
|
|
32
|
+
render(() => <DataTable columns={COLUMNS} data={DATA} />);
|
|
33
|
+
expect(screen.getByText("Alpha")).toBeTruthy();
|
|
34
|
+
expect(screen.getByText("Beta")).toBeTruthy();
|
|
35
|
+
expect(screen.getByText("Gamma")).toBeTruthy();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("shows empty state when data is empty", () => {
|
|
39
|
+
render(() => (
|
|
40
|
+
<DataTable columns={COLUMNS} data={[]} emptyMessage="No records" />
|
|
41
|
+
));
|
|
42
|
+
expect(screen.getByText("No records")).toBeTruthy();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("respects default search input placeholder", () => {
|
|
46
|
+
render(() => (
|
|
47
|
+
<DataTable columns={COLUMNS} data={DATA} searchPlaceholder="Filter..." />
|
|
48
|
+
));
|
|
49
|
+
expect(screen.getByPlaceholderText("Filter...")).toBeTruthy();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { render, screen, fireEvent } from "@solidjs/testing-library";
|
|
3
|
+
import { createSignal } from "solid-js";
|
|
4
|
+
import DatePicker from "./DatePicker";
|
|
5
|
+
|
|
6
|
+
// DatePicker is the shared calendar popover used by every plugin's date input
|
|
7
|
+
// and by DataTable's date filter. The key behaviors: renders a trigger button
|
|
8
|
+
// labeled with the selected date (or placeholder), opens a calendar popover on
|
|
9
|
+
// click, and calls onChange when a day is selected. The dedup rule: plugin UI
|
|
10
|
+
// tests must NOT re-assert "calendar opens on click" or "selecting a day calls
|
|
11
|
+
// onChange" — those are owned here.
|
|
12
|
+
|
|
13
|
+
describe("DatePicker", () => {
|
|
14
|
+
it("renders trigger with placeholder when no value is selected", () => {
|
|
15
|
+
render(() => <DatePicker value={null} onChange={() => {}} />);
|
|
16
|
+
expect(screen.getByText("Pick date")).toBeTruthy();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("renders trigger with the selected date", () => {
|
|
20
|
+
// Use a date far from today so formatDateDisplay renders "Jun 15" (not "Today")
|
|
21
|
+
render(() => <DatePicker value="2026-06-15" onChange={() => {}} />);
|
|
22
|
+
expect(screen.getByText("Jun 15")).toBeTruthy();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("opens calendar popover on trigger click", async () => {
|
|
26
|
+
render(() => <DatePicker value={null} onChange={() => {}} />);
|
|
27
|
+
const trigger = screen.getByText("Pick date");
|
|
28
|
+
await fireEvent.click(trigger);
|
|
29
|
+
// Calendar grid should appear with day-of-week headers
|
|
30
|
+
expect(screen.getByText("Su")).toBeTruthy();
|
|
31
|
+
expect(screen.getByText("Mo")).toBeTruthy();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("calls onChange when a day is clicked", async () => {
|
|
35
|
+
const onChange = vi.fn();
|
|
36
|
+
render(() => <DatePicker value="2026-06-15" onChange={onChange} />);
|
|
37
|
+
// Open the popover
|
|
38
|
+
await fireEvent.click(screen.getByText("Jun 15"));
|
|
39
|
+
// Find and click a day (20 is visible in the June 2026 grid)
|
|
40
|
+
const day20 = screen.getByText("20", { exact: true });
|
|
41
|
+
await fireEvent.click(day20);
|
|
42
|
+
expect(onChange).toHaveBeenCalledOnce();
|
|
43
|
+
expect(onChange).toHaveBeenCalledWith("2026-06-20");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("renders disabled trigger when disabled prop is true", () => {
|
|
47
|
+
render(() => <DatePicker value={null} onChange={() => {}} disabled />);
|
|
48
|
+
const trigger = screen.getByText("Pick date").closest("button")!;
|
|
49
|
+
expect(trigger.disabled).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { render } from "@solidjs/testing-library";
|
|
3
|
+
import { Show, createSignal } from "solid-js";
|
|
4
|
+
import Modal from "./Modal";
|
|
5
|
+
|
|
6
|
+
// Modal is the most critical shared widget — it wraps a native <dialog> with
|
|
7
|
+
// focus trap, Escape handling, and backdrop-click dismissal. Every plugin's
|
|
8
|
+
// create/edit/void flow opens a Modal, so its behavior is the canonical
|
|
9
|
+
// dedup source: a plugin UI test must NOT re-assert that "Escape closes the
|
|
10
|
+
// modal" or "clicking the backdrop calls onClose".
|
|
11
|
+
//
|
|
12
|
+
// Modal has no `open` prop — mount === open, unmount === closed. Wrap in
|
|
13
|
+
// `<Show when={...}>` to control visibility (same pattern the plugins use).
|
|
14
|
+
|
|
15
|
+
describe("Modal", () => {
|
|
16
|
+
it("renders children inside a dialog", () => {
|
|
17
|
+
const { container } = render(() => (
|
|
18
|
+
<Modal onClose={() => {}}>
|
|
19
|
+
<p>modal content</p>
|
|
20
|
+
</Modal>
|
|
21
|
+
));
|
|
22
|
+
const dialog = container.querySelector("dialog");
|
|
23
|
+
expect(dialog).not.toBeNull();
|
|
24
|
+
expect(dialog!.getAttribute("aria-modal")).toBe("true");
|
|
25
|
+
expect(dialog!.textContent).toContain("modal content");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("calls onClose when Escape is pressed (dismissable by default)", () => {
|
|
29
|
+
const onClose = vi.fn();
|
|
30
|
+
const { container } = render(() => (
|
|
31
|
+
<Modal onClose={onClose}>
|
|
32
|
+
<p>content</p>
|
|
33
|
+
</Modal>
|
|
34
|
+
));
|
|
35
|
+
const dialog = container.querySelector("dialog")!;
|
|
36
|
+
dialog.dispatchEvent(new KeyboardEvent("cancel", { bubbles: true }));
|
|
37
|
+
expect(onClose).toHaveBeenCalledOnce();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("does NOT call onClose when dismissable=false", () => {
|
|
41
|
+
const onClose = vi.fn();
|
|
42
|
+
const { container } = render(() => (
|
|
43
|
+
<Modal onClose={onClose} dismissable={false}>
|
|
44
|
+
<p>content</p>
|
|
45
|
+
</Modal>
|
|
46
|
+
));
|
|
47
|
+
const dialog = container.querySelector("dialog")!;
|
|
48
|
+
dialog.dispatchEvent(new KeyboardEvent("cancel", { bubbles: true }));
|
|
49
|
+
expect(onClose).not.toHaveBeenCalled();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("sets the requested size as max-width on the card", () => {
|
|
53
|
+
const { container } = render(() => (
|
|
54
|
+
<Modal onClose={() => {}} size="sm">
|
|
55
|
+
<p>sized</p>
|
|
56
|
+
</Modal>
|
|
57
|
+
));
|
|
58
|
+
const card = container.querySelector(".ksui-modal-card")!;
|
|
59
|
+
expect(card.getAttribute("style")).toContain("max-width");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("applies danger tone class when tone='danger'", () => {
|
|
63
|
+
const { container } = render(() => (
|
|
64
|
+
<Modal onClose={() => {}} tone="danger">
|
|
65
|
+
<p>danger</p>
|
|
66
|
+
</Modal>
|
|
67
|
+
));
|
|
68
|
+
const card = container.querySelector(".ksui-modal-card")!;
|
|
69
|
+
expect(card.className).toContain("danger");
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// The read-only detail view (the non-editing face of the detail modal). Renders
|
|
2
|
+
// one DetailRow per declared detail row, deriving each value by its kind (raw
|
|
3
|
+
// field / enum label / status / formatted datetime).
|
|
4
|
+
import { For } from "solid-js";
|
|
5
|
+
import DetailRow from "../../base/DetailRow";
|
|
6
|
+
import type { ResourceRow, UiDetailRow, UiDetailValue } from "./spec";
|
|
7
|
+
|
|
8
|
+
function detailValue(row: ResourceRow, value: UiDetailValue): string | null {
|
|
9
|
+
const raw = row[value.key];
|
|
10
|
+
switch (value.type) {
|
|
11
|
+
case "enum": {
|
|
12
|
+
const key = String(raw ?? "");
|
|
13
|
+
return value.labels[key] || key;
|
|
14
|
+
}
|
|
15
|
+
case "status":
|
|
16
|
+
return raw ? value.active : value.inactive;
|
|
17
|
+
case "datetime":
|
|
18
|
+
return raw ? new Date(String(raw)).toLocaleString() : null;
|
|
19
|
+
case "field":
|
|
20
|
+
default:
|
|
21
|
+
return raw === null || raw === undefined ? null : String(raw);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function ResourceDetail(props: { rows: readonly UiDetailRow[]; row: ResourceRow }) {
|
|
26
|
+
return (
|
|
27
|
+
<div class="space-y-4">
|
|
28
|
+
<For each={props.rows}>
|
|
29
|
+
{(r) => <DetailRow label={r.label} value={detailValue(props.row, r.value)} />}
|
|
30
|
+
</For>
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// The create/edit form: one control per declared field (text / textarea /
|
|
2
|
+
// select) on the FormField + Button shell. Form state is owned by ResourcePage
|
|
3
|
+
// and passed in, so one instance backs both the create and edit modals.
|
|
4
|
+
import { For, Show } from "solid-js";
|
|
5
|
+
import FormField from "../../base/FormField";
|
|
6
|
+
import Button from "../../base/Button";
|
|
7
|
+
import { INPUT_CLASS } from "../../../utils/INPUT_CLASS";
|
|
8
|
+
import type { ResourceUiSpec, UiField } from "./spec";
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
export interface ResourceFormProps {
|
|
12
|
+
spec: ResourceUiSpec;
|
|
13
|
+
values: Record<string, string>;
|
|
14
|
+
setValue: (key: string, value: string) => void;
|
|
15
|
+
error: string;
|
|
16
|
+
saving: boolean;
|
|
17
|
+
submitLabel: string;
|
|
18
|
+
onSubmit: () => void;
|
|
19
|
+
onCancel: () => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function Field(props: { spec: ResourceUiSpec; field: UiField; value: string; setValue: (v: string) => void }) {
|
|
23
|
+
const f = props.field;
|
|
24
|
+
const testId =
|
|
25
|
+
f.type === "text" && (f as { required?: boolean }).required
|
|
26
|
+
? `${props.spec.testIdPrefix}-form-${f.key}`
|
|
27
|
+
: undefined;
|
|
28
|
+
return (
|
|
29
|
+
<FormField label={f.label}>
|
|
30
|
+
<Show when={f.type === "select"}>
|
|
31
|
+
<select
|
|
32
|
+
data-testid={`${props.spec.testIdPrefix}-form-${f.key}`}
|
|
33
|
+
value={props.value}
|
|
34
|
+
onChange={(e) => props.setValue(e.currentTarget.value)}
|
|
35
|
+
class={`${INPUT_CLASS} cursor-pointer`}
|
|
36
|
+
required={f.type === "select" ? f.required : undefined}
|
|
37
|
+
>
|
|
38
|
+
<For each={f.type === "select" ? f.options : []}>
|
|
39
|
+
{(o) => <option value={o.value}>{o.label}</option>}
|
|
40
|
+
</For>
|
|
41
|
+
</select>
|
|
42
|
+
</Show>
|
|
43
|
+
<Show when={f.type === "textarea"}>
|
|
44
|
+
<textarea
|
|
45
|
+
value={props.value}
|
|
46
|
+
onInput={(e) => props.setValue(e.currentTarget.value)}
|
|
47
|
+
class={`${INPUT_CLASS} resize-none`}
|
|
48
|
+
rows={f.type === "textarea" ? (f.rows ?? 3) : 3}
|
|
49
|
+
placeholder={f.type === "textarea" ? f.placeholder : undefined}
|
|
50
|
+
/>
|
|
51
|
+
</Show>
|
|
52
|
+
<Show when={f.type === "text"}>
|
|
53
|
+
<input
|
|
54
|
+
type="text"
|
|
55
|
+
data-testid={testId}
|
|
56
|
+
value={props.value}
|
|
57
|
+
onInput={(e) => props.setValue(e.currentTarget.value)}
|
|
58
|
+
class={INPUT_CLASS}
|
|
59
|
+
placeholder={f.type === "text" ? f.placeholder : undefined}
|
|
60
|
+
required={f.type === "text" ? f.required : undefined}
|
|
61
|
+
/>
|
|
62
|
+
</Show>
|
|
63
|
+
</FormField>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function ResourceForm(props: ResourceFormProps) {
|
|
68
|
+
return (
|
|
69
|
+
<form
|
|
70
|
+
onSubmit={(e) => {
|
|
71
|
+
e.preventDefault();
|
|
72
|
+
props.onSubmit();
|
|
73
|
+
}}
|
|
74
|
+
class="space-y-4"
|
|
75
|
+
>
|
|
76
|
+
<Show when={props.error}>
|
|
77
|
+
<div
|
|
78
|
+
data-testid={`${props.spec.testIdPrefix}-form-error`}
|
|
79
|
+
class="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-400"
|
|
80
|
+
>
|
|
81
|
+
{props.error}
|
|
82
|
+
</div>
|
|
83
|
+
</Show>
|
|
84
|
+
|
|
85
|
+
<For each={props.spec.fields}>
|
|
86
|
+
{(field) => (
|
|
87
|
+
<Field
|
|
88
|
+
spec={props.spec}
|
|
89
|
+
field={field}
|
|
90
|
+
value={props.values[field.key] ?? ""}
|
|
91
|
+
setValue={(v) => props.setValue(field.key, v)}
|
|
92
|
+
/>
|
|
93
|
+
)}
|
|
94
|
+
</For>
|
|
95
|
+
|
|
96
|
+
<div class="flex flex-col-reverse sm:flex-row gap-2 sm:justify-end">
|
|
97
|
+
<Button type="button" onClick={props.onCancel} intent="secondary" class="w-full sm:w-auto">
|
|
98
|
+
Cancel
|
|
99
|
+
</Button>
|
|
100
|
+
<Button
|
|
101
|
+
type="button"
|
|
102
|
+
onClick={props.onSubmit}
|
|
103
|
+
disabled={props.saving}
|
|
104
|
+
intent="primary"
|
|
105
|
+
class="gap-2 w-full sm:w-auto"
|
|
106
|
+
data-testid={`${props.spec.testIdPrefix}-form-submit`}
|
|
107
|
+
>
|
|
108
|
+
{props.submitLabel}
|
|
109
|
+
</Button>
|
|
110
|
+
</div>
|
|
111
|
+
</form>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
// A config-driven CRUD page: a list (DataTable) with search, filters and paging,
|
|
2
|
+
// plus create / view / edit / archive / restore modals — all described by one
|
|
3
|
+
// declarative `ResourceUiSpec` (columns, fields, filters, labels, REST endpoints).
|
|
4
|
+
// It talks to a REST resource exposing list / create / get / update / delete /
|
|
5
|
+
// restore over `basePath`.
|
|
6
|
+
//
|
|
7
|
+
// Everything application-specific is injected via the `host` prop — the page-shell
|
|
8
|
+
// layout, a permission check, per-request init (auth headers / credentials), a
|
|
9
|
+
// refetch trigger, and any extra header actions — so the component carries no
|
|
10
|
+
// app, transport, or auth assumptions of its own.
|
|
11
|
+
import { createSignal, For, Show, type Component, type JSX } from "solid-js";
|
|
12
|
+
import { createStore } from "solid-js/store";
|
|
13
|
+
import Plus from "lucide-solid/icons/plus";
|
|
14
|
+
import X from "lucide-solid/icons/x";
|
|
15
|
+
import Archive from "lucide-solid/icons/archive";
|
|
16
|
+
import ArchiveRestore from "lucide-solid/icons/archive-restore";
|
|
17
|
+
import Pencil from "lucide-solid/icons/pencil";
|
|
18
|
+
import SegmentedFilter from "../../base/SegmentedFilter";
|
|
19
|
+
import Button from "../../base/Button";
|
|
20
|
+
import Modal from "../../base/Modal";
|
|
21
|
+
import DataTable, {
|
|
22
|
+
type DataTableColumn,
|
|
23
|
+
type FetchParams,
|
|
24
|
+
type FetchResult,
|
|
25
|
+
} from "../../base/DataTable";
|
|
26
|
+
import { confirm } from "../../../utils/confirm";
|
|
27
|
+
import {
|
|
28
|
+
buildListQuery,
|
|
29
|
+
emptyFormValues,
|
|
30
|
+
endpoints,
|
|
31
|
+
formToBody,
|
|
32
|
+
initialFilterState,
|
|
33
|
+
rowToFormValues,
|
|
34
|
+
validateForm,
|
|
35
|
+
type ResourceRow,
|
|
36
|
+
type ResourceUiSpec,
|
|
37
|
+
} from "./spec";
|
|
38
|
+
import { renderCell } from "./cells";
|
|
39
|
+
import { ResourceForm } from "./ResourceForm";
|
|
40
|
+
import { ResourceDetail } from "./ResourceDetail";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Application-specific dependencies injected by the consumer. The component holds
|
|
44
|
+
* no transport, auth, tenancy or layout assumptions of its own — they all arrive
|
|
45
|
+
* here. Only `PageShell` is required; the rest default to permissive no-ops.
|
|
46
|
+
*/
|
|
47
|
+
export interface ResourcePageHost {
|
|
48
|
+
/** Page-shell layout: a heading area + an actions slot wrapping the body. */
|
|
49
|
+
PageShell: Component<{
|
|
50
|
+
title: string;
|
|
51
|
+
subtitle?: string;
|
|
52
|
+
actions?: JSX.Element;
|
|
53
|
+
children: JSX.Element;
|
|
54
|
+
}>;
|
|
55
|
+
/** Permission check against the spec's permission keys. Defaults to allow-all. */
|
|
56
|
+
can?: (permission: string) => boolean;
|
|
57
|
+
/**
|
|
58
|
+
* `RequestInit` merged into every request the page makes — the seam for auth
|
|
59
|
+
* headers, credentials, or any per-tenant header. Called per request so it can
|
|
60
|
+
* read live context. The component adds only `method` and (on writes) the JSON
|
|
61
|
+
* `Content-Type` + body on top of what this returns.
|
|
62
|
+
*/
|
|
63
|
+
requestInit?: () => RequestInit;
|
|
64
|
+
/** Reactive value; when it changes the list resets to page 1 and refetches. */
|
|
65
|
+
refetchKey?: () => unknown;
|
|
66
|
+
/** Extra action elements rendered in the header before the built-in create button. */
|
|
67
|
+
headerActions?: JSX.Element;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ResourcePageProps<T extends ResourceRow> {
|
|
71
|
+
spec: ResourceUiSpec;
|
|
72
|
+
host: ResourcePageHost;
|
|
73
|
+
/** Test seam: override the fetch implementation (defaults to window.fetch). */
|
|
74
|
+
fetchImpl?: typeof fetch;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type RefetchApi = { refetch: () => void; resetAndRefetch: () => void };
|
|
78
|
+
|
|
79
|
+
export function ResourcePage<T extends ResourceRow>(props: ResourcePageProps<T>) {
|
|
80
|
+
const spec = props.spec;
|
|
81
|
+
const ep = endpoints(spec);
|
|
82
|
+
const doFetch = props.fetchImpl ?? ((...a: Parameters<typeof fetch>) => fetch(...a));
|
|
83
|
+
|
|
84
|
+
const { PageShell } = props.host;
|
|
85
|
+
const can = (key: string) => props.host.can?.(key) ?? true;
|
|
86
|
+
const canView = () => can(spec.permissions.view);
|
|
87
|
+
const canEdit = () => spec.permissions.edit.some(can);
|
|
88
|
+
const canDelete = () => can(spec.permissions.delete);
|
|
89
|
+
|
|
90
|
+
/** Merge the host's per-request init (headers/credentials) with method + body. */
|
|
91
|
+
function reqInit(extra?: RequestInit): RequestInit {
|
|
92
|
+
const base = props.host.requestInit?.() ?? {};
|
|
93
|
+
return {
|
|
94
|
+
...base,
|
|
95
|
+
...extra,
|
|
96
|
+
headers: {
|
|
97
|
+
...(base.headers as Record<string, string> | undefined),
|
|
98
|
+
...(extra?.headers as Record<string, string> | undefined),
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const [filterState, setFilterState] = createStore<Record<string, string>>(initialFilterState(spec));
|
|
104
|
+
let refetchFn: RefetchApi | undefined;
|
|
105
|
+
|
|
106
|
+
const [detailRow, setDetailRow] = createSignal<ResourceRow | null>(null);
|
|
107
|
+
const [editing, setEditing] = createSignal(false);
|
|
108
|
+
const [createOpen, setCreateOpen] = createSignal(false);
|
|
109
|
+
|
|
110
|
+
const [form, setForm] = createStore<Record<string, string>>(emptyFormValues(spec));
|
|
111
|
+
const [saving, setSaving] = createSignal(false);
|
|
112
|
+
const [error, setError] = createSignal("");
|
|
113
|
+
const setValue = (key: string, value: string) => setForm(key, value);
|
|
114
|
+
|
|
115
|
+
function resetForm() {
|
|
116
|
+
setForm(emptyFormValues(spec));
|
|
117
|
+
setError("");
|
|
118
|
+
}
|
|
119
|
+
function populateForm(row: ResourceRow) {
|
|
120
|
+
setForm(rowToFormValues(spec, row));
|
|
121
|
+
setError("");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function openDetail(id: number) {
|
|
125
|
+
try {
|
|
126
|
+
const res = await doFetch(ep.one(id), reqInit());
|
|
127
|
+
if (res.ok) {
|
|
128
|
+
setDetailRow(await res.json());
|
|
129
|
+
setEditing(false);
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
/* ignore */
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function startEdit() {
|
|
137
|
+
const row = detailRow();
|
|
138
|
+
if (!row) return;
|
|
139
|
+
populateForm(row);
|
|
140
|
+
setEditing(true);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function submit(method: "POST" | "PUT", url: string, fallback: string, onOk: (row: ResourceRow) => void) {
|
|
144
|
+
const msg = validateForm(spec, form);
|
|
145
|
+
if (msg) {
|
|
146
|
+
setError(msg);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
setSaving(true);
|
|
150
|
+
setError("");
|
|
151
|
+
try {
|
|
152
|
+
const res = await doFetch(
|
|
153
|
+
url,
|
|
154
|
+
reqInit({
|
|
155
|
+
method,
|
|
156
|
+
headers: { "Content-Type": "application/json" },
|
|
157
|
+
body: JSON.stringify(formToBody(spec, form)),
|
|
158
|
+
}),
|
|
159
|
+
);
|
|
160
|
+
// create allows the idempotent-200 path; both treat non-ok as an error
|
|
161
|
+
if (!res.ok && !(method === "POST" && res.status === 200)) {
|
|
162
|
+
const err = await res.json().catch(() => ({}));
|
|
163
|
+
setError(err.error || fallback);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
onOk(await res.json().catch(() => ({})));
|
|
167
|
+
refetchFn?.refetch();
|
|
168
|
+
} catch {
|
|
169
|
+
setError(spec.labels.networkError);
|
|
170
|
+
} finally {
|
|
171
|
+
setSaving(false);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function handleCreate() {
|
|
176
|
+
await submit("POST", ep.create, spec.labels.createErrorFallback, () => {
|
|
177
|
+
setCreateOpen(false);
|
|
178
|
+
resetForm();
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function handleUpdate() {
|
|
183
|
+
const row = detailRow();
|
|
184
|
+
if (!row) return;
|
|
185
|
+
await submit("PUT", ep.one(row.id), spec.labels.updateErrorFallback, (updated) => {
|
|
186
|
+
if (updated && typeof updated.id === "number") setDetailRow(updated);
|
|
187
|
+
setEditing(false);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function handleArchive(id: number) {
|
|
192
|
+
if (
|
|
193
|
+
!(await confirm({
|
|
194
|
+
title: spec.labels.archiveTitle,
|
|
195
|
+
message: spec.labels.archiveMessage,
|
|
196
|
+
confirmLabel: spec.labels.archiveConfirm,
|
|
197
|
+
danger: true,
|
|
198
|
+
}))
|
|
199
|
+
)
|
|
200
|
+
return;
|
|
201
|
+
try {
|
|
202
|
+
await doFetch(ep.one(id), reqInit({ method: "DELETE" }));
|
|
203
|
+
setDetailRow(null);
|
|
204
|
+
refetchFn?.refetch();
|
|
205
|
+
} catch {
|
|
206
|
+
/* ignore */
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function handleRestore(id: number) {
|
|
211
|
+
try {
|
|
212
|
+
const res = await doFetch(ep.restore(id), reqInit({ method: "PATCH" }));
|
|
213
|
+
if (res.ok) {
|
|
214
|
+
setDetailRow(await res.json());
|
|
215
|
+
refetchFn?.refetch();
|
|
216
|
+
}
|
|
217
|
+
} catch {
|
|
218
|
+
/* ignore */
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const columns: DataTableColumn<ResourceRow>[] = spec.columns.map((c) => ({
|
|
223
|
+
data: c.key,
|
|
224
|
+
title: c.title,
|
|
225
|
+
orderable: c.orderable ?? false,
|
|
226
|
+
render: (_v, _t, row) => renderCell(spec, c, row, openDetail),
|
|
227
|
+
}));
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<Show when={canView()}>
|
|
231
|
+
<PageShell
|
|
232
|
+
title={spec.title}
|
|
233
|
+
subtitle={spec.subtitle}
|
|
234
|
+
actions={
|
|
235
|
+
<>
|
|
236
|
+
{props.host.headerActions}
|
|
237
|
+
<Show when={canEdit()}>
|
|
238
|
+
<Button
|
|
239
|
+
intent="primary"
|
|
240
|
+
variant="clip1"
|
|
241
|
+
icon={Plus}
|
|
242
|
+
data-testid={`${spec.testIdPrefix}-add-btn`}
|
|
243
|
+
onClick={() => {
|
|
244
|
+
resetForm();
|
|
245
|
+
setCreateOpen(true);
|
|
246
|
+
}}
|
|
247
|
+
>
|
|
248
|
+
{spec.labels.add}
|
|
249
|
+
</Button>
|
|
250
|
+
</Show>
|
|
251
|
+
</>
|
|
252
|
+
}
|
|
253
|
+
>
|
|
254
|
+
<DataTable<ResourceRow>
|
|
255
|
+
refetchKey={props.host.refetchKey}
|
|
256
|
+
fetchFn={async (params: FetchParams): Promise<FetchResult<ResourceRow>> => {
|
|
257
|
+
const q = buildListQuery(spec, params, filterState);
|
|
258
|
+
const res = await doFetch(`${ep.list}?${q}`, reqInit());
|
|
259
|
+
return res.json();
|
|
260
|
+
}}
|
|
261
|
+
columns={columns}
|
|
262
|
+
searching={true}
|
|
263
|
+
ordering={true}
|
|
264
|
+
paging={true}
|
|
265
|
+
searchPlaceholder={spec.labels.searchPlaceholder}
|
|
266
|
+
emptyMessage={spec.labels.empty}
|
|
267
|
+
noResultsMessage={spec.labels.noResults}
|
|
268
|
+
filters={
|
|
269
|
+
<div class="flex items-center gap-2 flex-wrap">
|
|
270
|
+
<For each={spec.filters ?? []}>
|
|
271
|
+
{(f) =>
|
|
272
|
+
f.type === "segmented" ? (
|
|
273
|
+
<SegmentedFilter
|
|
274
|
+
options={[...f.options]}
|
|
275
|
+
value={filterState[f.param]}
|
|
276
|
+
onChange={(v: string) => setFilterState(f.param, v)}
|
|
277
|
+
testIdPrefix={f.testIdPrefix}
|
|
278
|
+
/>
|
|
279
|
+
) : (
|
|
280
|
+
<select
|
|
281
|
+
value={filterState[f.param]}
|
|
282
|
+
onChange={(e) => setFilterState(f.param, e.currentTarget.value)}
|
|
283
|
+
class="rounded-lg border border-zinc-800/50 bg-zinc-900 px-3 py-1.5 text-xs text-zinc-400 cursor-pointer"
|
|
284
|
+
>
|
|
285
|
+
<For each={f.options}>{(o) => <option value={o.value}>{o.label}</option>}</For>
|
|
286
|
+
</select>
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
</For>
|
|
290
|
+
</div>
|
|
291
|
+
}
|
|
292
|
+
onRefetch={(api) => {
|
|
293
|
+
refetchFn = api;
|
|
294
|
+
}}
|
|
295
|
+
/>
|
|
296
|
+
</PageShell>
|
|
297
|
+
|
|
298
|
+
{/* Create modal */}
|
|
299
|
+
<Show when={createOpen()}>
|
|
300
|
+
<Modal
|
|
301
|
+
onClose={() => {
|
|
302
|
+
setCreateOpen(false);
|
|
303
|
+
resetForm();
|
|
304
|
+
}}
|
|
305
|
+
size="lg"
|
|
306
|
+
>
|
|
307
|
+
<div data-testid={`${spec.testIdPrefix}-create-modal`}>
|
|
308
|
+
<div class="flex items-center justify-between mb-6">
|
|
309
|
+
<h2 class="text-lg font-semibold text-zinc-100">{spec.labels.createTitle}</h2>
|
|
310
|
+
<button
|
|
311
|
+
onClick={() => {
|
|
312
|
+
setCreateOpen(false);
|
|
313
|
+
resetForm();
|
|
314
|
+
}}
|
|
315
|
+
class="text-zinc-500 hover:text-zinc-300 cursor-pointer"
|
|
316
|
+
aria-label="Close"
|
|
317
|
+
>
|
|
318
|
+
<X size={20} />
|
|
319
|
+
</button>
|
|
320
|
+
</div>
|
|
321
|
+
<ResourceForm
|
|
322
|
+
spec={spec}
|
|
323
|
+
values={form}
|
|
324
|
+
setValue={setValue}
|
|
325
|
+
error={error()}
|
|
326
|
+
saving={saving()}
|
|
327
|
+
submitLabel={spec.labels.createSubmit}
|
|
328
|
+
onSubmit={handleCreate}
|
|
329
|
+
onCancel={() => {
|
|
330
|
+
setCreateOpen(false);
|
|
331
|
+
resetForm();
|
|
332
|
+
}}
|
|
333
|
+
/>
|
|
334
|
+
</div>
|
|
335
|
+
</Modal>
|
|
336
|
+
</Show>
|
|
337
|
+
|
|
338
|
+
{/* Detail / edit modal */}
|
|
339
|
+
<Show when={detailRow()}>
|
|
340
|
+
{(row) => (
|
|
341
|
+
<Modal
|
|
342
|
+
onClose={() => {
|
|
343
|
+
setDetailRow(null);
|
|
344
|
+
setEditing(false);
|
|
345
|
+
}}
|
|
346
|
+
size="lg"
|
|
347
|
+
>
|
|
348
|
+
<div data-testid={`${spec.testIdPrefix}-detail-modal`}>
|
|
349
|
+
<div class="flex items-center justify-between mb-6">
|
|
350
|
+
<h2 class="text-lg font-semibold text-zinc-100">
|
|
351
|
+
{editing() ? spec.labels.editTitle : String(row()[spec.labels.titleField] ?? "")}
|
|
352
|
+
</h2>
|
|
353
|
+
<div class="flex items-center gap-2">
|
|
354
|
+
<Show when={!editing() && canEdit()}>
|
|
355
|
+
<button
|
|
356
|
+
onClick={startEdit}
|
|
357
|
+
class="text-zinc-500 hover:text-amber-400 cursor-pointer p-1"
|
|
358
|
+
title="Edit"
|
|
359
|
+
aria-label="Edit"
|
|
360
|
+
>
|
|
361
|
+
<Pencil size={16} />
|
|
362
|
+
</button>
|
|
363
|
+
</Show>
|
|
364
|
+
<Show when={!editing() && canDelete()}>
|
|
365
|
+
{row()[spec.softDeleteField] ? (
|
|
366
|
+
<button
|
|
367
|
+
onClick={() => handleArchive(row().id)}
|
|
368
|
+
class="text-zinc-500 hover:text-red-400 cursor-pointer p-1"
|
|
369
|
+
title="Archive"
|
|
370
|
+
aria-label="Archive"
|
|
371
|
+
>
|
|
372
|
+
<Archive size={16} />
|
|
373
|
+
</button>
|
|
374
|
+
) : (
|
|
375
|
+
<button
|
|
376
|
+
onClick={() => handleRestore(row().id)}
|
|
377
|
+
class="text-zinc-500 hover:text-emerald-400 cursor-pointer p-1"
|
|
378
|
+
title="Restore"
|
|
379
|
+
aria-label="Restore"
|
|
380
|
+
>
|
|
381
|
+
<ArchiveRestore size={16} />
|
|
382
|
+
</button>
|
|
383
|
+
)}
|
|
384
|
+
</Show>
|
|
385
|
+
<button
|
|
386
|
+
onClick={() => {
|
|
387
|
+
setDetailRow(null);
|
|
388
|
+
setEditing(false);
|
|
389
|
+
}}
|
|
390
|
+
class="text-zinc-500 hover:text-zinc-300 cursor-pointer p-1"
|
|
391
|
+
aria-label="Close"
|
|
392
|
+
>
|
|
393
|
+
<X size={20} />
|
|
394
|
+
</button>
|
|
395
|
+
</div>
|
|
396
|
+
</div>
|
|
397
|
+
|
|
398
|
+
<Show when={editing()} fallback={<ResourceDetail rows={spec.detail} row={row()} />}>
|
|
399
|
+
<ResourceForm
|
|
400
|
+
spec={spec}
|
|
401
|
+
values={form}
|
|
402
|
+
setValue={setValue}
|
|
403
|
+
error={error()}
|
|
404
|
+
saving={saving()}
|
|
405
|
+
submitLabel={spec.labels.editSubmit}
|
|
406
|
+
onSubmit={handleUpdate}
|
|
407
|
+
onCancel={() => setEditing(false)}
|
|
408
|
+
/>
|
|
409
|
+
</Show>
|
|
410
|
+
</div>
|
|
411
|
+
</Modal>
|
|
412
|
+
)}
|
|
413
|
+
</Show>
|
|
414
|
+
</Show>
|
|
415
|
+
);
|
|
416
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Column-cell rendering for the datatable. One pure function maps a column's
|
|
2
|
+
// declared render hint (title / enum / status / text) to its cell markup.
|
|
3
|
+
import type { JSX } from "solid-js";
|
|
4
|
+
import StatusPill from "../../base/StatusPill";
|
|
5
|
+
import type { ResourceRow, ResourceUiSpec, UiColumn } from "./spec";
|
|
6
|
+
|
|
7
|
+
/** Render a single cell for `column` from `row`. `onTitleClick` opens detail. */
|
|
8
|
+
export function renderCell(
|
|
9
|
+
spec: ResourceUiSpec,
|
|
10
|
+
column: UiColumn,
|
|
11
|
+
row: ResourceRow,
|
|
12
|
+
onTitleClick: (id: number) => void,
|
|
13
|
+
): JSX.Element | string {
|
|
14
|
+
const r = column.render;
|
|
15
|
+
const raw = row[column.key];
|
|
16
|
+
switch (r.type) {
|
|
17
|
+
case "title":
|
|
18
|
+
return (
|
|
19
|
+
<button
|
|
20
|
+
data-testid={`${spec.testIdPrefix}-row-${row.id}`}
|
|
21
|
+
class="text-left text-zinc-200 hover:text-amber-400 transition-colors cursor-pointer"
|
|
22
|
+
onClick={() => onTitleClick(row.id)}
|
|
23
|
+
>
|
|
24
|
+
{String(raw ?? "")}
|
|
25
|
+
</button>
|
|
26
|
+
);
|
|
27
|
+
case "enum": {
|
|
28
|
+
const key = String(raw ?? "");
|
|
29
|
+
return <span class="text-zinc-400 text-sm capitalize">{r.labels[key] || key}</span>;
|
|
30
|
+
}
|
|
31
|
+
case "status": {
|
|
32
|
+
const active = Boolean(raw);
|
|
33
|
+
const arm = active ? r.active : r.inactive;
|
|
34
|
+
return <StatusPill label={arm.label} tone={arm.tone} dot solid />;
|
|
35
|
+
}
|
|
36
|
+
case "text":
|
|
37
|
+
default: {
|
|
38
|
+
const cls = (r.type === "text" && r.muted) ? "text-zinc-500 text-sm" : "text-zinc-400 text-sm";
|
|
39
|
+
const text = raw === null || raw === undefined || raw === "" ? "—" : String(raw);
|
|
40
|
+
return <span class={cls}>{text}</span>;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Unit tests for the spec-driven UI runtime's PURE helpers (no DOM). They pin
|
|
2
|
+
// the request/validation/form behavior that ResourcePage relies on.
|
|
3
|
+
import { describe, it, expect } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
buildListQuery,
|
|
6
|
+
emptyFormValues,
|
|
7
|
+
endpoints,
|
|
8
|
+
formToBody,
|
|
9
|
+
initialFilterState,
|
|
10
|
+
rowToFormValues,
|
|
11
|
+
selectDefault,
|
|
12
|
+
cleanLabel,
|
|
13
|
+
validateForm,
|
|
14
|
+
type ResourceUiSpec,
|
|
15
|
+
type UiFieldSelect,
|
|
16
|
+
} from "./spec";
|
|
17
|
+
|
|
18
|
+
// A self-contained fixture exercising every helper branch (segmented + select
|
|
19
|
+
// filters, text/textarea/select fields, trim/trimOrNull transforms).
|
|
20
|
+
const spec: ResourceUiSpec = {
|
|
21
|
+
basePath: "/api/things",
|
|
22
|
+
title: "Things",
|
|
23
|
+
permissions: { view: "things.view", edit: ["things.create", "things.edit"], delete: "things.delete" },
|
|
24
|
+
softDeleteField: "is_active",
|
|
25
|
+
testIdPrefix: "things",
|
|
26
|
+
columns: [{ key: "name", title: "Name", render: { type: "title" } }],
|
|
27
|
+
fields: [
|
|
28
|
+
{ key: "name", label: "Name *", type: "text", required: true, transform: "trim" },
|
|
29
|
+
{ key: "kind", label: "Kind", type: "select", default: "vendor", options: [
|
|
30
|
+
{ value: "vendor", label: "Vendor" },
|
|
31
|
+
{ value: "customer", label: "Customer" },
|
|
32
|
+
] },
|
|
33
|
+
{ key: "category", label: "Category", type: "text", transform: "trimOrNull" },
|
|
34
|
+
{ key: "notes", label: "Notes", type: "textarea", transform: "trimOrNull" },
|
|
35
|
+
],
|
|
36
|
+
filters: [
|
|
37
|
+
{ type: "segmented", param: "status", options: ["active", "archived", "all"], default: "active", testIdPrefix: "things-status" },
|
|
38
|
+
{ type: "select", param: "kind", default: "", options: [
|
|
39
|
+
{ value: "", label: "All kinds" },
|
|
40
|
+
{ value: "vendor", label: "Vendors" },
|
|
41
|
+
] },
|
|
42
|
+
],
|
|
43
|
+
detail: [{ label: "Name", value: { type: "field", key: "name" } }],
|
|
44
|
+
labels: {
|
|
45
|
+
add: "Add Thing", createTitle: "New Thing", createSubmit: "Create", editTitle: "Edit Thing",
|
|
46
|
+
editSubmit: "Save", titleField: "name", searchPlaceholder: "Search…", empty: "None yet",
|
|
47
|
+
noResults: "No matches", createErrorFallback: "Failed to create", updateErrorFallback: "Failed to update",
|
|
48
|
+
networkError: "Network error", archiveTitle: "Archive?", archiveMessage: "Hidden.", archiveConfirm: "Archive",
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const baseParams = { page: 1, limit: 25, search: "", sortBy: null, sortDir: "asc" };
|
|
53
|
+
|
|
54
|
+
describe("endpoints", () => {
|
|
55
|
+
it("derives CRUD paths from basePath", () => {
|
|
56
|
+
const ep = endpoints(spec);
|
|
57
|
+
expect(ep.list).toBe("/api/things");
|
|
58
|
+
expect(ep.one(7)).toBe("/api/things/7");
|
|
59
|
+
expect(ep.restore(7)).toBe("/api/things/7/restore");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("buildListQuery", () => {
|
|
64
|
+
it("always carries paging/search/sort + segmented filter; omits empty select", () => {
|
|
65
|
+
const q = buildListQuery(spec, baseParams, initialFilterState(spec));
|
|
66
|
+
expect(q.get("page")).toBe("1");
|
|
67
|
+
expect(q.get("sortBy")).toBe("");
|
|
68
|
+
expect(q.get("status")).toBe("active");
|
|
69
|
+
expect(q.has("kind")).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
it("sends a select filter once it has a value", () => {
|
|
72
|
+
const q = buildListQuery(spec, baseParams, { status: "archived", kind: "vendor" });
|
|
73
|
+
expect(q.get("status")).toBe("archived");
|
|
74
|
+
expect(q.get("kind")).toBe("vendor");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("form values", () => {
|
|
79
|
+
it("empty form: '' for text/textarea, default option for select", () => {
|
|
80
|
+
expect(emptyFormValues(spec)).toEqual({ name: "", kind: "vendor", category: "", notes: "" });
|
|
81
|
+
});
|
|
82
|
+
it("selectDefault falls back to the first option", () => {
|
|
83
|
+
const f: UiFieldSelect = { type: "select", options: [{ value: "a", label: "A" }, { value: "b", label: "B" }] };
|
|
84
|
+
expect(selectDefault(f)).toBe("a");
|
|
85
|
+
});
|
|
86
|
+
it("rowToFormValues prefills from a row, null → ''", () => {
|
|
87
|
+
expect(rowToFormValues(spec, { id: 3, name: "X", kind: "vendor", category: null, notes: "hi" }))
|
|
88
|
+
.toEqual({ name: "X", kind: "vendor", category: "", notes: "hi" });
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("validateForm", () => {
|
|
93
|
+
it("flags the first required-but-empty field with a clean label", () => {
|
|
94
|
+
expect(validateForm(spec, { name: " ", kind: "vendor", category: "", notes: "" })).toBe("Name is required");
|
|
95
|
+
});
|
|
96
|
+
it("passes when required fields are filled", () => {
|
|
97
|
+
expect(validateForm(spec, { name: "X", kind: "vendor", category: "", notes: "" })).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
it("flags a required select left on an empty placeholder option", () => {
|
|
100
|
+
const s = { fields: [{ key: "kind", label: "Kind *", type: "select", required: true,
|
|
101
|
+
options: [{ value: "", label: "Choose…" }, { value: "a", label: "A" }] }] } as unknown as ResourceUiSpec;
|
|
102
|
+
expect(validateForm(s, { kind: "" })).toBe("Kind is required");
|
|
103
|
+
expect(validateForm(s, { kind: "a" })).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
it("cleanLabel strips a trailing required marker", () => {
|
|
106
|
+
expect(cleanLabel("Name *")).toBe("Name");
|
|
107
|
+
expect(cleanLabel("Notes")).toBe("Notes");
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("formToBody", () => {
|
|
112
|
+
it("trims required text, nulls empty optional, passes select raw", () => {
|
|
113
|
+
expect(formToBody(spec, { name: " X ", kind: "customer", category: " ", notes: " n " }))
|
|
114
|
+
.toEqual({ name: "X", kind: "customer", category: null, notes: "n" });
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// The declarative contract for `ResourcePage` plus its PURE helpers (no solid-js
|
|
2
|
+
// or component imports, so they unit-test under plain node). A `ResourceUiSpec`
|
|
3
|
+
// describes a CRUD page's columns, form fields, filters, labels and REST endpoints
|
|
4
|
+
// once; `ResourcePage` renders it. The shape is transport- and framework-agnostic
|
|
5
|
+
// — it names columns and fields, never how requests are authed or sent.
|
|
6
|
+
|
|
7
|
+
/** A row the runtime can render: any record with a numeric surrogate id. */
|
|
8
|
+
export interface ResourceRow {
|
|
9
|
+
id: number;
|
|
10
|
+
[key: string]: unknown;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// ---- columns ---------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
export type ColumnTone = "success" | "warning" | "danger" | "neutral";
|
|
16
|
+
|
|
17
|
+
/** Plain text cell; renders `—` when the value is null/empty. `muted` dims it. */
|
|
18
|
+
export interface UiColumnText {
|
|
19
|
+
readonly type: "text";
|
|
20
|
+
readonly muted?: boolean;
|
|
21
|
+
}
|
|
22
|
+
/** The clickable title cell that opens the detail modal. */
|
|
23
|
+
export interface UiColumnTitle {
|
|
24
|
+
readonly type: "title";
|
|
25
|
+
}
|
|
26
|
+
/** Enum value rendered through a label map (e.g. vendor → "Vendor"). */
|
|
27
|
+
export interface UiColumnEnum {
|
|
28
|
+
readonly type: "enum";
|
|
29
|
+
readonly labels: Readonly<Record<string, string>>;
|
|
30
|
+
}
|
|
31
|
+
/** A boolean column rendered as a ksui StatusPill (e.g. is_active → Active/Archived). */
|
|
32
|
+
export interface UiColumnStatus {
|
|
33
|
+
readonly type: "status";
|
|
34
|
+
readonly active: { readonly label: string; readonly tone: ColumnTone };
|
|
35
|
+
readonly inactive: { readonly label: string; readonly tone: ColumnTone };
|
|
36
|
+
}
|
|
37
|
+
export type UiColumnRender = UiColumnText | UiColumnTitle | UiColumnEnum | UiColumnStatus;
|
|
38
|
+
|
|
39
|
+
export interface UiColumn {
|
|
40
|
+
/** Row property this column reads (also the `data` key + sort field). */
|
|
41
|
+
readonly key: string;
|
|
42
|
+
readonly title: string;
|
|
43
|
+
readonly orderable?: boolean;
|
|
44
|
+
readonly render: UiColumnRender;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---- form fields -----------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
/** How a submitted string field is coerced into the request body. */
|
|
50
|
+
export type FieldTransform = "trim" | "trimOrNull";
|
|
51
|
+
|
|
52
|
+
export interface UiFieldText {
|
|
53
|
+
readonly type: "text";
|
|
54
|
+
readonly placeholder?: string;
|
|
55
|
+
readonly required?: boolean;
|
|
56
|
+
readonly transform: FieldTransform;
|
|
57
|
+
}
|
|
58
|
+
export interface UiFieldTextarea {
|
|
59
|
+
readonly type: "textarea";
|
|
60
|
+
readonly placeholder?: string;
|
|
61
|
+
readonly rows?: number;
|
|
62
|
+
readonly required?: boolean;
|
|
63
|
+
readonly transform: FieldTransform;
|
|
64
|
+
}
|
|
65
|
+
export interface UiFieldSelectOption {
|
|
66
|
+
readonly value: string;
|
|
67
|
+
readonly label: string;
|
|
68
|
+
}
|
|
69
|
+
export interface UiFieldSelect {
|
|
70
|
+
readonly type: "select";
|
|
71
|
+
readonly options: readonly UiFieldSelectOption[];
|
|
72
|
+
/** Default option value for a fresh create (defaults to the first option). */
|
|
73
|
+
readonly default?: string;
|
|
74
|
+
/**
|
|
75
|
+
* Reject an empty selection (a select that opens on an empty placeholder
|
|
76
|
+
* option). A select with a non-empty default never trips this; it matters
|
|
77
|
+
* for a future spec whose first option is a `""` "Choose…" placeholder.
|
|
78
|
+
*/
|
|
79
|
+
readonly required?: boolean;
|
|
80
|
+
}
|
|
81
|
+
export type UiField = { readonly key: string; readonly label: string } & (
|
|
82
|
+
| UiFieldText
|
|
83
|
+
| UiFieldTextarea
|
|
84
|
+
| UiFieldSelect
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// ---- filters ---------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
/** A segmented control whose value is ALWAYS sent as a query param. */
|
|
90
|
+
export interface UiFilterSegmented {
|
|
91
|
+
readonly type: "segmented";
|
|
92
|
+
readonly param: string;
|
|
93
|
+
readonly options: readonly string[];
|
|
94
|
+
readonly default: string;
|
|
95
|
+
readonly testIdPrefix: string;
|
|
96
|
+
}
|
|
97
|
+
/** A native select whose value is sent only when non-empty (`""` = no filter). */
|
|
98
|
+
export interface UiFilterSelect {
|
|
99
|
+
readonly type: "select";
|
|
100
|
+
readonly param: string;
|
|
101
|
+
readonly default: string;
|
|
102
|
+
readonly options: readonly UiFieldSelectOption[];
|
|
103
|
+
}
|
|
104
|
+
export type UiFilter = UiFilterSegmented | UiFilterSelect;
|
|
105
|
+
|
|
106
|
+
// ---- detail view -----------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
export type UiDetailValue =
|
|
109
|
+
| { readonly type: "field"; readonly key: string }
|
|
110
|
+
| { readonly type: "enum"; readonly key: string; readonly labels: Readonly<Record<string, string>> }
|
|
111
|
+
| { readonly type: "status"; readonly key: string; readonly active: string; readonly inactive: string }
|
|
112
|
+
| { readonly type: "datetime"; readonly key: string };
|
|
113
|
+
|
|
114
|
+
export interface UiDetailRow {
|
|
115
|
+
readonly label: string;
|
|
116
|
+
readonly value: UiDetailValue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---- the spec --------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
export interface ResourceUiSpec {
|
|
122
|
+
/** REST base, e.g. "/api/things"; the CRUD endpoints derive from it. */
|
|
123
|
+
readonly basePath: string;
|
|
124
|
+
/** Page title + framing. */
|
|
125
|
+
readonly title: string;
|
|
126
|
+
readonly subtitle?: string;
|
|
127
|
+
/** Permission keys passed to `host.can`. `edit` passes if ANY of its keys do. */
|
|
128
|
+
readonly permissions: {
|
|
129
|
+
readonly view: string;
|
|
130
|
+
readonly edit: readonly string[];
|
|
131
|
+
readonly delete: string;
|
|
132
|
+
};
|
|
133
|
+
/** Soft-delete boolean field; drives the archive/restore affordance + status. */
|
|
134
|
+
readonly softDeleteField: string;
|
|
135
|
+
readonly columns: readonly UiColumn[];
|
|
136
|
+
readonly fields: readonly UiField[];
|
|
137
|
+
readonly filters?: readonly UiFilter[];
|
|
138
|
+
readonly detail: readonly UiDetailRow[];
|
|
139
|
+
readonly labels: {
|
|
140
|
+
readonly add: string;
|
|
141
|
+
readonly createTitle: string;
|
|
142
|
+
readonly createSubmit: string;
|
|
143
|
+
readonly editTitle: string;
|
|
144
|
+
readonly editSubmit: string;
|
|
145
|
+
/** The detail-modal title falls back to this row field when not editing. */
|
|
146
|
+
readonly titleField: string;
|
|
147
|
+
readonly searchPlaceholder: string;
|
|
148
|
+
readonly empty: string;
|
|
149
|
+
readonly noResults: string;
|
|
150
|
+
readonly createErrorFallback: string;
|
|
151
|
+
readonly updateErrorFallback: string;
|
|
152
|
+
readonly networkError: string;
|
|
153
|
+
readonly archiveTitle: string;
|
|
154
|
+
readonly archiveMessage: string;
|
|
155
|
+
readonly archiveConfirm: string;
|
|
156
|
+
};
|
|
157
|
+
/** data-testid prefix (e.g. "things" → things-add-btn, things-row-3). */
|
|
158
|
+
readonly testIdPrefix: string;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---- derived endpoints -----------------------------------------------------
|
|
162
|
+
|
|
163
|
+
export interface ResourceEndpoints {
|
|
164
|
+
readonly list: string;
|
|
165
|
+
readonly create: string;
|
|
166
|
+
readonly one: (id: number) => string;
|
|
167
|
+
readonly restore: (id: number) => string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function endpoints(spec: ResourceUiSpec): ResourceEndpoints {
|
|
171
|
+
const base = spec.basePath;
|
|
172
|
+
return {
|
|
173
|
+
list: base,
|
|
174
|
+
create: base,
|
|
175
|
+
one: (id) => `${base}/${id}`,
|
|
176
|
+
restore: (id) => `${base}/${id}/restore`,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---- pure helpers (unit-tested) --------------------------------------------
|
|
181
|
+
|
|
182
|
+
/** The fixed columns the list request always carries. */
|
|
183
|
+
export interface ListParams {
|
|
184
|
+
readonly page: number;
|
|
185
|
+
readonly limit: number;
|
|
186
|
+
readonly search: string;
|
|
187
|
+
readonly sortBy: string | null;
|
|
188
|
+
readonly sortDir: string;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Build the list-request query string: page/limit/search/sortBy/sortDir are
|
|
193
|
+
* always present; a segmented filter is always sent; a select filter is sent
|
|
194
|
+
* only when its value is non-empty.
|
|
195
|
+
*/
|
|
196
|
+
export function buildListQuery(
|
|
197
|
+
spec: ResourceUiSpec,
|
|
198
|
+
params: ListParams,
|
|
199
|
+
filterState: Readonly<Record<string, string>>,
|
|
200
|
+
): URLSearchParams {
|
|
201
|
+
const q = new URLSearchParams({
|
|
202
|
+
page: String(params.page),
|
|
203
|
+
limit: String(params.limit),
|
|
204
|
+
search: params.search,
|
|
205
|
+
sortBy: params.sortBy || "",
|
|
206
|
+
sortDir: params.sortDir,
|
|
207
|
+
});
|
|
208
|
+
for (const f of spec.filters ?? []) {
|
|
209
|
+
const value = filterState[f.param] ?? f.default;
|
|
210
|
+
// Segmented filters are always sent; a select filter only when non-empty.
|
|
211
|
+
if (f.type === "segmented" || value) {
|
|
212
|
+
q.set(f.param, value);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return q;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The default filter state (each filter at its declared default). */
|
|
219
|
+
export function initialFilterState(spec: ResourceUiSpec): Record<string, string> {
|
|
220
|
+
const state: Record<string, string> = {};
|
|
221
|
+
for (const f of spec.filters ?? []) state[f.param] = f.default;
|
|
222
|
+
return state;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The default option value for a select field (explicit default ?? first option). */
|
|
226
|
+
export function selectDefault(field: UiFieldSelect): string {
|
|
227
|
+
return field.default ?? field.options[0]?.value ?? "";
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** A fresh, empty form: "" for text/textarea, the default option for a select. */
|
|
231
|
+
export function emptyFormValues(spec: ResourceUiSpec): Record<string, string> {
|
|
232
|
+
const values: Record<string, string> = {};
|
|
233
|
+
for (const f of spec.fields) {
|
|
234
|
+
values[f.key] = f.type === "select" ? selectDefault(f) : "";
|
|
235
|
+
}
|
|
236
|
+
return values;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Prefill the form from an existing row (edit mode). */
|
|
240
|
+
export function rowToFormValues(
|
|
241
|
+
spec: ResourceUiSpec,
|
|
242
|
+
row: Readonly<Record<string, unknown>>,
|
|
243
|
+
): Record<string, string> {
|
|
244
|
+
const values: Record<string, string> = {};
|
|
245
|
+
for (const f of spec.fields) {
|
|
246
|
+
const v = row[f.key];
|
|
247
|
+
values[f.key] = v === null || v === undefined ? "" : String(v);
|
|
248
|
+
}
|
|
249
|
+
return values;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Strip a trailing required-marker (" *") from a field label. */
|
|
253
|
+
export function cleanLabel(label: string): string {
|
|
254
|
+
const trimmed = label.trimEnd();
|
|
255
|
+
return (trimmed.endsWith("*") ? trimmed.slice(0, -1) : trimmed).trimEnd();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Validate the form. Returns the first required-but-empty field's error message,
|
|
260
|
+
* or null when valid (e.g. "Name is required").
|
|
261
|
+
*/
|
|
262
|
+
export function validateForm(
|
|
263
|
+
spec: ResourceUiSpec,
|
|
264
|
+
values: Readonly<Record<string, string>>,
|
|
265
|
+
): string | null {
|
|
266
|
+
for (const f of spec.fields) {
|
|
267
|
+
// text/textarea/select all carry an optional `required`; a select with a
|
|
268
|
+
// non-empty default never trips this, but a placeholder-first select can.
|
|
269
|
+
if (f.required && !(values[f.key] ?? "").trim()) {
|
|
270
|
+
return `${cleanLabel(f.label)} is required`;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Coerce form values into the request body per each field's transform. */
|
|
277
|
+
export function formToBody(
|
|
278
|
+
spec: ResourceUiSpec,
|
|
279
|
+
values: Readonly<Record<string, string>>,
|
|
280
|
+
): Record<string, string | null> {
|
|
281
|
+
const body: Record<string, string | null> = {};
|
|
282
|
+
for (const f of spec.fields) {
|
|
283
|
+
const raw = values[f.key] ?? "";
|
|
284
|
+
if (f.type === "select") {
|
|
285
|
+
body[f.key] = raw;
|
|
286
|
+
} else if (f.transform === "trim") {
|
|
287
|
+
body[f.key] = raw.trim();
|
|
288
|
+
} else {
|
|
289
|
+
body[f.key] = raw.trim() || null;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return body;
|
|
293
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -147,6 +147,19 @@ export type { VoucherOption } from "./components/composite/VoucherPicker";
|
|
|
147
147
|
|
|
148
148
|
export { default as NotFound, type NotFoundProps } from "./components/composite/NotFound";
|
|
149
149
|
|
|
150
|
+
// Config-driven CRUD page: a list/create/view/edit/archive page over a REST
|
|
151
|
+
// resource, described by one declarative ResourceUiSpec and rendered through
|
|
152
|
+
// ResourcePage (DataTable + Modal + FormField). Everything app-specific — the
|
|
153
|
+
// page-shell layout, a permission check, per-request init, a refetch trigger and
|
|
154
|
+
// any extra header actions — is injected via the `host` prop, so the component
|
|
155
|
+
// carries no app, transport or auth assumptions of its own.
|
|
156
|
+
export { ResourcePage } from "./components/composite/resource/ResourcePage";
|
|
157
|
+
export type {
|
|
158
|
+
ResourcePageProps,
|
|
159
|
+
ResourcePageHost,
|
|
160
|
+
} from "./components/composite/resource/ResourcePage";
|
|
161
|
+
export * from "./components/composite/resource/spec";
|
|
162
|
+
|
|
150
163
|
// ---------------------------------------------------------------------------
|
|
151
164
|
// Utils (not components)
|
|
152
165
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// ksui component test setup.
|
|
2
|
+
//
|
|
3
|
+
// Every component injects a <style id="STYLE_ID"> once per page via
|
|
4
|
+
// ensureStyle(). In jsdom each test gets a fresh document, so injection is
|
|
5
|
+
// idempotent — but we still strip any leftover <style> tags between tests
|
|
6
|
+
// as a safety net (e.g. shared-document jsdom mode if ever enabled).
|
|
7
|
+
//
|
|
8
|
+
// Portal content is portaled to document.body; cleanup between tests so a
|
|
9
|
+
// previous test's portal DOM doesn't leak into the next one's queries.
|
|
10
|
+
|
|
11
|
+
import { afterEach } from "vitest";
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
// Strip injected style tags
|
|
15
|
+
document.querySelectorAll("style").forEach((s) => s.remove());
|
|
16
|
+
// Strip portal content (solid-js portals append to document.body)
|
|
17
|
+
document.body.innerHTML = "";
|
|
18
|
+
});
|