@kahitsan/ksui 0.15.1 → 0.16.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/Avatar.tsx +9 -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 +34 -0
- package/src/components/composite/resource/ResourceForm.tsx +114 -0
- package/src/components/composite/resource/ResourcePage.tsx +408 -0
- package/src/components/composite/resource/cells.tsx +44 -0
- package/src/components/composite/resource/spec.test.ts +116 -0
- package/src/components/composite/resource/spec.ts +305 -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.16.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"
|
|
@@ -20,11 +20,18 @@ const sizeMap: Record<string, number> = {
|
|
|
20
20
|
* people; use AccountAvatar directly for financial accounts.
|
|
21
21
|
*/
|
|
22
22
|
export default function Avatar(props: AvatarProps) {
|
|
23
|
+
// Getter-backed so name/image stay reactive: a parent rebinding them on a live
|
|
24
|
+
// Avatar (e.g. an in-session profile update) re-renders, rather than snapshotting
|
|
25
|
+
// the values once at component init.
|
|
23
26
|
const account: AvatarAccount = {
|
|
24
27
|
id: 0,
|
|
25
28
|
type: "user",
|
|
26
|
-
name
|
|
27
|
-
|
|
29
|
+
get name() {
|
|
30
|
+
return props.name;
|
|
31
|
+
},
|
|
32
|
+
get image() {
|
|
33
|
+
return props.image;
|
|
34
|
+
},
|
|
28
35
|
};
|
|
29
36
|
|
|
30
37
|
return <AccountAvatar account={account} size={sizeMap[props.size ?? "md"]} class={props.class} />;
|
|
@@ -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,34 @@
|
|
|
1
|
+
// The spec-driven read-only detail view (the non-editing face of the detail
|
|
2
|
+
// modal). Renders one ksui DetailRow per declared detail row, deriving each
|
|
3
|
+
// value by its kind (raw field / enum label / status / formatted datetime),
|
|
4
|
+
// reproducing hand-written payees' PayeeDetail.
|
|
5
|
+
import { For } from "solid-js";
|
|
6
|
+
import DetailRow from "../../base/DetailRow";
|
|
7
|
+
import type { ResourceRow, UiDetailRow, UiDetailValue } from "./spec";
|
|
8
|
+
|
|
9
|
+
function detailValue(row: ResourceRow, value: UiDetailValue): string | null {
|
|
10
|
+
const raw = row[value.key];
|
|
11
|
+
switch (value.type) {
|
|
12
|
+
case "enum": {
|
|
13
|
+
const key = String(raw ?? "");
|
|
14
|
+
return value.labels[key] || key;
|
|
15
|
+
}
|
|
16
|
+
case "status":
|
|
17
|
+
return raw ? value.active : value.inactive;
|
|
18
|
+
case "datetime":
|
|
19
|
+
return raw ? new Date(String(raw)).toLocaleString() : null;
|
|
20
|
+
case "field":
|
|
21
|
+
default:
|
|
22
|
+
return raw === null || raw === undefined ? null : String(raw);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function ResourceDetail(props: { rows: readonly UiDetailRow[]; row: ResourceRow }) {
|
|
27
|
+
return (
|
|
28
|
+
<div class="space-y-4">
|
|
29
|
+
<For each={props.rows}>
|
|
30
|
+
{(r) => <DetailRow label={r.label} value={detailValue(props.row, r.value)} />}
|
|
31
|
+
</For>
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// The spec-driven create/edit form. Renders one control per declared field
|
|
2
|
+
// (text / textarea / select), reproducing hand-written payees' markup, testids,
|
|
3
|
+
// and the ksui FormField + Button shell. Form state is owned by ResourcePage and
|
|
4
|
+
// passed in, so a single instance backs both the create and edit modals.
|
|
5
|
+
import { For, Show } from "solid-js";
|
|
6
|
+
import FormField from "../../base/FormField";
|
|
7
|
+
import Button from "../../base/Button";
|
|
8
|
+
import { INPUT_CLASS } from "../../../utils/INPUT_CLASS";
|
|
9
|
+
import type { ResourceUiSpec, UiField } from "./spec";
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
export interface ResourceFormProps {
|
|
13
|
+
spec: ResourceUiSpec;
|
|
14
|
+
values: Record<string, string>;
|
|
15
|
+
setValue: (key: string, value: string) => void;
|
|
16
|
+
error: string;
|
|
17
|
+
saving: boolean;
|
|
18
|
+
submitLabel: string;
|
|
19
|
+
onSubmit: () => void;
|
|
20
|
+
onCancel: () => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function Field(props: { spec: ResourceUiSpec; field: UiField; value: string; setValue: (v: string) => void }) {
|
|
24
|
+
const f = props.field;
|
|
25
|
+
const testId =
|
|
26
|
+
f.type === "text" && (f as { required?: boolean }).required
|
|
27
|
+
? `${props.spec.testIdPrefix}-form-${f.key}`
|
|
28
|
+
: undefined;
|
|
29
|
+
return (
|
|
30
|
+
<FormField label={f.label}>
|
|
31
|
+
<Show when={f.type === "select"}>
|
|
32
|
+
<select
|
|
33
|
+
data-testid={`${props.spec.testIdPrefix}-form-${f.key}`}
|
|
34
|
+
value={props.value}
|
|
35
|
+
onChange={(e) => props.setValue(e.currentTarget.value)}
|
|
36
|
+
class={`${INPUT_CLASS} cursor-pointer`}
|
|
37
|
+
required={f.type === "select" ? f.required : undefined}
|
|
38
|
+
>
|
|
39
|
+
<For each={f.type === "select" ? f.options : []}>
|
|
40
|
+
{(o) => <option value={o.value}>{o.label}</option>}
|
|
41
|
+
</For>
|
|
42
|
+
</select>
|
|
43
|
+
</Show>
|
|
44
|
+
<Show when={f.type === "textarea"}>
|
|
45
|
+
<textarea
|
|
46
|
+
value={props.value}
|
|
47
|
+
onInput={(e) => props.setValue(e.currentTarget.value)}
|
|
48
|
+
class={`${INPUT_CLASS} resize-none`}
|
|
49
|
+
rows={f.type === "textarea" ? (f.rows ?? 3) : 3}
|
|
50
|
+
placeholder={f.type === "textarea" ? f.placeholder : undefined}
|
|
51
|
+
/>
|
|
52
|
+
</Show>
|
|
53
|
+
<Show when={f.type === "text"}>
|
|
54
|
+
<input
|
|
55
|
+
type="text"
|
|
56
|
+
data-testid={testId}
|
|
57
|
+
value={props.value}
|
|
58
|
+
onInput={(e) => props.setValue(e.currentTarget.value)}
|
|
59
|
+
class={INPUT_CLASS}
|
|
60
|
+
placeholder={f.type === "text" ? f.placeholder : undefined}
|
|
61
|
+
required={f.type === "text" ? f.required : undefined}
|
|
62
|
+
/>
|
|
63
|
+
</Show>
|
|
64
|
+
</FormField>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function ResourceForm(props: ResourceFormProps) {
|
|
69
|
+
return (
|
|
70
|
+
<form
|
|
71
|
+
onSubmit={(e) => {
|
|
72
|
+
e.preventDefault();
|
|
73
|
+
props.onSubmit();
|
|
74
|
+
}}
|
|
75
|
+
class="space-y-4"
|
|
76
|
+
>
|
|
77
|
+
<Show when={props.error}>
|
|
78
|
+
<div
|
|
79
|
+
data-testid={`${props.spec.testIdPrefix}-form-error`}
|
|
80
|
+
class="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-400"
|
|
81
|
+
>
|
|
82
|
+
{props.error}
|
|
83
|
+
</div>
|
|
84
|
+
</Show>
|
|
85
|
+
|
|
86
|
+
<For each={props.spec.fields}>
|
|
87
|
+
{(field) => (
|
|
88
|
+
<Field
|
|
89
|
+
spec={props.spec}
|
|
90
|
+
field={field}
|
|
91
|
+
value={props.values[field.key] ?? ""}
|
|
92
|
+
setValue={(v) => props.setValue(field.key, v)}
|
|
93
|
+
/>
|
|
94
|
+
)}
|
|
95
|
+
</For>
|
|
96
|
+
|
|
97
|
+
<div class="flex flex-col-reverse sm:flex-row gap-2 sm:justify-end">
|
|
98
|
+
<Button type="button" onClick={props.onCancel} intent="secondary" class="w-full sm:w-auto">
|
|
99
|
+
Cancel
|
|
100
|
+
</Button>
|
|
101
|
+
<Button
|
|
102
|
+
type="button"
|
|
103
|
+
onClick={props.onSubmit}
|
|
104
|
+
disabled={props.saving}
|
|
105
|
+
intent="primary"
|
|
106
|
+
class="gap-2 w-full sm:w-auto"
|
|
107
|
+
data-testid={`${props.spec.testIdPrefix}-form-submit`}
|
|
108
|
+
>
|
|
109
|
+
{props.submitLabel}
|
|
110
|
+
</Button>
|
|
111
|
+
</div>
|
|
112
|
+
</form>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
// The spec-driven default-datatable page: list + create + detail/edit + archive,
|
|
2
|
+
// composed from a ResourceUiSpec onto the ksui DataTable/Modal/FormField shell.
|
|
3
|
+
// This is the generic runtime that reproduces a hand-written base plugin's UI
|
|
4
|
+
// (proved byte-for-behavior against payees). A base plugin's `ui/remote/index.tsx`
|
|
5
|
+
// shrinks to: build a spec, render <ResourcePage spec={...} host={...} />.
|
|
6
|
+
//
|
|
7
|
+
// ksui stays standalone — it never imports `@kserp/host-ui`. The host primitives
|
|
8
|
+
// (PageShell, PageShareButton, and the workspace/permission hooks) are INJECTED
|
|
9
|
+
// via the `host` prop; the plugin passes them from its host UI kit, where the
|
|
10
|
+
// hooks run inside the plugin's own component tree (correct reactive context).
|
|
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
|
+
* Host primitives injected by the plugin so ksui never imports `@kserp/host-ui`.
|
|
44
|
+
* The plugin's remote entry passes these from the host UI kit; the hooks are
|
|
45
|
+
* invoked at the top of ResourcePage's render, inside the plugin's component
|
|
46
|
+
* tree, so their reactive context resolves correctly.
|
|
47
|
+
*/
|
|
48
|
+
export interface ResourcePageHost {
|
|
49
|
+
PageShell: Component<{
|
|
50
|
+
title: string;
|
|
51
|
+
subtitle?: string;
|
|
52
|
+
actions?: JSX.Element;
|
|
53
|
+
children: JSX.Element;
|
|
54
|
+
}>;
|
|
55
|
+
PageShareButton?: Component<{ module: string; moduleLabel: string }>;
|
|
56
|
+
useActiveWorkspace: () => {
|
|
57
|
+
activeWorkspace: () => { ws_id: number | string } | null | undefined;
|
|
58
|
+
};
|
|
59
|
+
usePermissions: () => {
|
|
60
|
+
has: (code: string) => boolean;
|
|
61
|
+
hasAny: (...codes: string[]) => boolean;
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ResourcePageProps<T extends ResourceRow> {
|
|
66
|
+
spec: ResourceUiSpec;
|
|
67
|
+
host: ResourcePageHost;
|
|
68
|
+
/** Test seam: override the fetch implementation (defaults to window.fetch). */
|
|
69
|
+
fetchImpl?: typeof fetch;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type RefetchApi = { refetch: () => void; resetAndRefetch: () => void };
|
|
73
|
+
|
|
74
|
+
export function ResourcePage<T extends ResourceRow>(props: ResourcePageProps<T>) {
|
|
75
|
+
const spec = props.spec;
|
|
76
|
+
const ep = endpoints(spec);
|
|
77
|
+
const doFetch = props.fetchImpl ?? ((...a: Parameters<typeof fetch>) => fetch(...a));
|
|
78
|
+
|
|
79
|
+
const { PageShell, PageShareButton } = props.host;
|
|
80
|
+
const { activeWorkspace } = props.host.useActiveWorkspace();
|
|
81
|
+
const perms = props.host.usePermissions();
|
|
82
|
+
const canView = () => perms.has(spec.permissions.view);
|
|
83
|
+
const canEdit = () => perms.hasAny(...spec.permissions.edit);
|
|
84
|
+
const canDelete = () => perms.has(spec.permissions.delete);
|
|
85
|
+
|
|
86
|
+
const [filterState, setFilterState] = createStore<Record<string, string>>(initialFilterState(spec));
|
|
87
|
+
let refetchFn: RefetchApi | undefined;
|
|
88
|
+
|
|
89
|
+
const [detailRow, setDetailRow] = createSignal<ResourceRow | null>(null);
|
|
90
|
+
const [editing, setEditing] = createSignal(false);
|
|
91
|
+
const [createOpen, setCreateOpen] = createSignal(false);
|
|
92
|
+
|
|
93
|
+
const [form, setForm] = createStore<Record<string, string>>(emptyFormValues(spec));
|
|
94
|
+
const [saving, setSaving] = createSignal(false);
|
|
95
|
+
const [error, setError] = createSignal("");
|
|
96
|
+
const setValue = (key: string, value: string) => setForm(key, value);
|
|
97
|
+
|
|
98
|
+
function resetForm() {
|
|
99
|
+
setForm(emptyFormValues(spec));
|
|
100
|
+
setError("");
|
|
101
|
+
}
|
|
102
|
+
function populateForm(row: ResourceRow) {
|
|
103
|
+
setForm(rowToFormValues(spec, row));
|
|
104
|
+
setError("");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function wsHeaders(): Record<string, string> {
|
|
108
|
+
const ws = activeWorkspace();
|
|
109
|
+
return ws ? { "X-Workspace-Id": String(ws.ws_id) } : {};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function openDetail(id: number) {
|
|
113
|
+
try {
|
|
114
|
+
const res = await doFetch(ep.one(id), { credentials: "include", headers: wsHeaders() });
|
|
115
|
+
if (res.ok) {
|
|
116
|
+
setDetailRow(await res.json());
|
|
117
|
+
setEditing(false);
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
/* ignore */
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function startEdit() {
|
|
125
|
+
const row = detailRow();
|
|
126
|
+
if (!row) return;
|
|
127
|
+
populateForm(row);
|
|
128
|
+
setEditing(true);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function submit(method: "POST" | "PUT", url: string, fallback: string, onOk: (row: ResourceRow) => void) {
|
|
132
|
+
const msg = validateForm(spec, form);
|
|
133
|
+
if (msg) {
|
|
134
|
+
setError(msg);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
setSaving(true);
|
|
138
|
+
setError("");
|
|
139
|
+
try {
|
|
140
|
+
const res = await doFetch(url, {
|
|
141
|
+
method,
|
|
142
|
+
credentials: "include",
|
|
143
|
+
headers: { "Content-Type": "application/json", ...wsHeaders() },
|
|
144
|
+
body: JSON.stringify(formToBody(spec, form)),
|
|
145
|
+
});
|
|
146
|
+
// create allows the idempotent-200 path; both treat non-ok as an error
|
|
147
|
+
if (!res.ok && !(method === "POST" && res.status === 200)) {
|
|
148
|
+
const err = await res.json().catch(() => ({}));
|
|
149
|
+
setError(err.error || fallback);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
onOk(await res.json().catch(() => ({})));
|
|
153
|
+
refetchFn?.refetch();
|
|
154
|
+
} catch {
|
|
155
|
+
setError(spec.labels.networkError);
|
|
156
|
+
} finally {
|
|
157
|
+
setSaving(false);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function handleCreate() {
|
|
162
|
+
await submit("POST", ep.create, spec.labels.createErrorFallback, () => {
|
|
163
|
+
setCreateOpen(false);
|
|
164
|
+
resetForm();
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function handleUpdate() {
|
|
169
|
+
const row = detailRow();
|
|
170
|
+
if (!row) return;
|
|
171
|
+
await submit("PUT", ep.one(row.id), spec.labels.updateErrorFallback, (updated) => {
|
|
172
|
+
if (updated && typeof updated.id === "number") setDetailRow(updated);
|
|
173
|
+
setEditing(false);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function handleArchive(id: number) {
|
|
178
|
+
if (
|
|
179
|
+
!(await confirm({
|
|
180
|
+
title: spec.labels.archiveTitle,
|
|
181
|
+
message: spec.labels.archiveMessage,
|
|
182
|
+
confirmLabel: spec.labels.archiveConfirm,
|
|
183
|
+
danger: true,
|
|
184
|
+
}))
|
|
185
|
+
)
|
|
186
|
+
return;
|
|
187
|
+
try {
|
|
188
|
+
await doFetch(ep.one(id), { method: "DELETE", credentials: "include", headers: wsHeaders() });
|
|
189
|
+
setDetailRow(null);
|
|
190
|
+
refetchFn?.refetch();
|
|
191
|
+
} catch {
|
|
192
|
+
/* ignore */
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function handleRestore(id: number) {
|
|
197
|
+
try {
|
|
198
|
+
const res = await doFetch(ep.restore(id), { method: "PATCH", credentials: "include", headers: wsHeaders() });
|
|
199
|
+
if (res.ok) {
|
|
200
|
+
setDetailRow(await res.json());
|
|
201
|
+
refetchFn?.refetch();
|
|
202
|
+
}
|
|
203
|
+
} catch {
|
|
204
|
+
/* ignore */
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const columns: DataTableColumn<ResourceRow>[] = spec.columns.map((c) => ({
|
|
209
|
+
data: c.key,
|
|
210
|
+
title: c.title,
|
|
211
|
+
orderable: c.orderable ?? false,
|
|
212
|
+
render: (_v, _t, row) => renderCell(spec, c, row, openDetail),
|
|
213
|
+
}));
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<Show when={canView()}>
|
|
217
|
+
<PageShell
|
|
218
|
+
title={spec.title}
|
|
219
|
+
subtitle={spec.subtitle}
|
|
220
|
+
actions={
|
|
221
|
+
<>
|
|
222
|
+
<Show when={spec.share}>
|
|
223
|
+
{(s) =>
|
|
224
|
+
PageShareButton ? (
|
|
225
|
+
<PageShareButton module={s().module} moduleLabel={s().moduleLabel} />
|
|
226
|
+
) : null
|
|
227
|
+
}
|
|
228
|
+
</Show>
|
|
229
|
+
<Show when={canEdit()}>
|
|
230
|
+
<Button
|
|
231
|
+
intent="primary"
|
|
232
|
+
variant="clip1"
|
|
233
|
+
icon={Plus}
|
|
234
|
+
data-testid={`${spec.testIdPrefix}-add-btn`}
|
|
235
|
+
onClick={() => {
|
|
236
|
+
resetForm();
|
|
237
|
+
setCreateOpen(true);
|
|
238
|
+
}}
|
|
239
|
+
>
|
|
240
|
+
{spec.labels.add}
|
|
241
|
+
</Button>
|
|
242
|
+
</Show>
|
|
243
|
+
</>
|
|
244
|
+
}
|
|
245
|
+
>
|
|
246
|
+
<DataTable<ResourceRow>
|
|
247
|
+
refetchKey={() => activeWorkspace()?.ws_id}
|
|
248
|
+
fetchFn={async (params: FetchParams): Promise<FetchResult<ResourceRow>> => {
|
|
249
|
+
const q = buildListQuery(spec, params, filterState);
|
|
250
|
+
const res = await doFetch(`${ep.list}?${q}`, { credentials: "include", headers: wsHeaders() });
|
|
251
|
+
return res.json();
|
|
252
|
+
}}
|
|
253
|
+
columns={columns}
|
|
254
|
+
searching={true}
|
|
255
|
+
ordering={true}
|
|
256
|
+
paging={true}
|
|
257
|
+
searchPlaceholder={spec.labels.searchPlaceholder}
|
|
258
|
+
emptyMessage={spec.labels.empty}
|
|
259
|
+
noResultsMessage={spec.labels.noResults}
|
|
260
|
+
filters={
|
|
261
|
+
<div class="flex items-center gap-2 flex-wrap">
|
|
262
|
+
<For each={spec.filters ?? []}>
|
|
263
|
+
{(f) =>
|
|
264
|
+
f.type === "segmented" ? (
|
|
265
|
+
<SegmentedFilter
|
|
266
|
+
options={[...f.options]}
|
|
267
|
+
value={filterState[f.param]}
|
|
268
|
+
onChange={(v: string) => setFilterState(f.param, v)}
|
|
269
|
+
testIdPrefix={f.testIdPrefix}
|
|
270
|
+
/>
|
|
271
|
+
) : (
|
|
272
|
+
<select
|
|
273
|
+
value={filterState[f.param]}
|
|
274
|
+
onChange={(e) => setFilterState(f.param, e.currentTarget.value)}
|
|
275
|
+
class="rounded-lg border border-zinc-800/50 bg-zinc-900 px-3 py-1.5 text-xs text-zinc-400 cursor-pointer"
|
|
276
|
+
>
|
|
277
|
+
<For each={f.options}>{(o) => <option value={o.value}>{o.label}</option>}</For>
|
|
278
|
+
</select>
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
</For>
|
|
282
|
+
</div>
|
|
283
|
+
}
|
|
284
|
+
onRefetch={(api) => {
|
|
285
|
+
refetchFn = api;
|
|
286
|
+
}}
|
|
287
|
+
/>
|
|
288
|
+
</PageShell>
|
|
289
|
+
|
|
290
|
+
{/* Create modal */}
|
|
291
|
+
<Show when={createOpen()}>
|
|
292
|
+
<Modal
|
|
293
|
+
onClose={() => {
|
|
294
|
+
setCreateOpen(false);
|
|
295
|
+
resetForm();
|
|
296
|
+
}}
|
|
297
|
+
size="lg"
|
|
298
|
+
>
|
|
299
|
+
<div data-testid={`${spec.testIdPrefix}-create-modal`}>
|
|
300
|
+
<div class="flex items-center justify-between mb-6">
|
|
301
|
+
<h2 class="text-lg font-semibold text-zinc-100">{spec.labels.createTitle}</h2>
|
|
302
|
+
<button
|
|
303
|
+
onClick={() => {
|
|
304
|
+
setCreateOpen(false);
|
|
305
|
+
resetForm();
|
|
306
|
+
}}
|
|
307
|
+
class="text-zinc-500 hover:text-zinc-300 cursor-pointer"
|
|
308
|
+
aria-label="Close"
|
|
309
|
+
>
|
|
310
|
+
<X size={20} />
|
|
311
|
+
</button>
|
|
312
|
+
</div>
|
|
313
|
+
<ResourceForm
|
|
314
|
+
spec={spec}
|
|
315
|
+
values={form}
|
|
316
|
+
setValue={setValue}
|
|
317
|
+
error={error()}
|
|
318
|
+
saving={saving()}
|
|
319
|
+
submitLabel={spec.labels.createSubmit}
|
|
320
|
+
onSubmit={handleCreate}
|
|
321
|
+
onCancel={() => {
|
|
322
|
+
setCreateOpen(false);
|
|
323
|
+
resetForm();
|
|
324
|
+
}}
|
|
325
|
+
/>
|
|
326
|
+
</div>
|
|
327
|
+
</Modal>
|
|
328
|
+
</Show>
|
|
329
|
+
|
|
330
|
+
{/* Detail / edit modal */}
|
|
331
|
+
<Show when={detailRow()}>
|
|
332
|
+
{(row) => (
|
|
333
|
+
<Modal
|
|
334
|
+
onClose={() => {
|
|
335
|
+
setDetailRow(null);
|
|
336
|
+
setEditing(false);
|
|
337
|
+
}}
|
|
338
|
+
size="lg"
|
|
339
|
+
>
|
|
340
|
+
<div data-testid={`${spec.testIdPrefix}-detail-modal`}>
|
|
341
|
+
<div class="flex items-center justify-between mb-6">
|
|
342
|
+
<h2 class="text-lg font-semibold text-zinc-100">
|
|
343
|
+
{editing() ? spec.labels.editTitle : String(row()[spec.labels.titleField] ?? "")}
|
|
344
|
+
</h2>
|
|
345
|
+
<div class="flex items-center gap-2">
|
|
346
|
+
<Show when={!editing() && canEdit()}>
|
|
347
|
+
<button
|
|
348
|
+
onClick={startEdit}
|
|
349
|
+
class="text-zinc-500 hover:text-amber-400 cursor-pointer p-1"
|
|
350
|
+
title="Edit"
|
|
351
|
+
aria-label="Edit"
|
|
352
|
+
>
|
|
353
|
+
<Pencil size={16} />
|
|
354
|
+
</button>
|
|
355
|
+
</Show>
|
|
356
|
+
<Show when={!editing() && canDelete()}>
|
|
357
|
+
{row()[spec.softDeleteField] ? (
|
|
358
|
+
<button
|
|
359
|
+
onClick={() => handleArchive(row().id)}
|
|
360
|
+
class="text-zinc-500 hover:text-red-400 cursor-pointer p-1"
|
|
361
|
+
title="Archive"
|
|
362
|
+
aria-label="Archive"
|
|
363
|
+
>
|
|
364
|
+
<Archive size={16} />
|
|
365
|
+
</button>
|
|
366
|
+
) : (
|
|
367
|
+
<button
|
|
368
|
+
onClick={() => handleRestore(row().id)}
|
|
369
|
+
class="text-zinc-500 hover:text-emerald-400 cursor-pointer p-1"
|
|
370
|
+
title="Restore"
|
|
371
|
+
aria-label="Restore"
|
|
372
|
+
>
|
|
373
|
+
<ArchiveRestore size={16} />
|
|
374
|
+
</button>
|
|
375
|
+
)}
|
|
376
|
+
</Show>
|
|
377
|
+
<button
|
|
378
|
+
onClick={() => {
|
|
379
|
+
setDetailRow(null);
|
|
380
|
+
setEditing(false);
|
|
381
|
+
}}
|
|
382
|
+
class="text-zinc-500 hover:text-zinc-300 cursor-pointer p-1"
|
|
383
|
+
aria-label="Close"
|
|
384
|
+
>
|
|
385
|
+
<X size={20} />
|
|
386
|
+
</button>
|
|
387
|
+
</div>
|
|
388
|
+
</div>
|
|
389
|
+
|
|
390
|
+
<Show when={editing()} fallback={<ResourceDetail rows={spec.detail} row={row()} />}>
|
|
391
|
+
<ResourceForm
|
|
392
|
+
spec={spec}
|
|
393
|
+
values={form}
|
|
394
|
+
setValue={setValue}
|
|
395
|
+
error={error()}
|
|
396
|
+
saving={saving()}
|
|
397
|
+
submitLabel={spec.labels.editSubmit}
|
|
398
|
+
onSubmit={handleUpdate}
|
|
399
|
+
onCancel={() => setEditing(false)}
|
|
400
|
+
/>
|
|
401
|
+
</Show>
|
|
402
|
+
</div>
|
|
403
|
+
</Modal>
|
|
404
|
+
)}
|
|
405
|
+
</Show>
|
|
406
|
+
</Show>
|
|
407
|
+
);
|
|
408
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Column-cell rendering for the spec-driven datatable. One pure function maps a
|
|
2
|
+
// UiColumn's declared render hint to the exact JSX hand-written payees ships, so
|
|
3
|
+
// the generated table is byte-for-behavior identical.
|
|
4
|
+
import type { JSX } from "solid-js";
|
|
5
|
+
import StatusPill from "../../base/StatusPill";
|
|
6
|
+
import type { ResourceRow, ResourceUiSpec, UiColumn } from "./spec";
|
|
7
|
+
|
|
8
|
+
/** Render a single cell for `column` from `row`. `onTitleClick` opens detail. */
|
|
9
|
+
export function renderCell(
|
|
10
|
+
spec: ResourceUiSpec,
|
|
11
|
+
column: UiColumn,
|
|
12
|
+
row: ResourceRow,
|
|
13
|
+
onTitleClick: (id: number) => void,
|
|
14
|
+
): JSX.Element | string {
|
|
15
|
+
const r = column.render;
|
|
16
|
+
const raw = row[column.key];
|
|
17
|
+
switch (r.type) {
|
|
18
|
+
case "title":
|
|
19
|
+
return (
|
|
20
|
+
<button
|
|
21
|
+
data-testid={`${spec.testIdPrefix}-row-${row.id}`}
|
|
22
|
+
class="text-left text-zinc-200 hover:text-amber-400 transition-colors cursor-pointer"
|
|
23
|
+
onClick={() => onTitleClick(row.id)}
|
|
24
|
+
>
|
|
25
|
+
{String(raw ?? "")}
|
|
26
|
+
</button>
|
|
27
|
+
);
|
|
28
|
+
case "enum": {
|
|
29
|
+
const key = String(raw ?? "");
|
|
30
|
+
return <span class="text-zinc-400 text-sm capitalize">{r.labels[key] || key}</span>;
|
|
31
|
+
}
|
|
32
|
+
case "status": {
|
|
33
|
+
const active = Boolean(raw);
|
|
34
|
+
const arm = active ? r.active : r.inactive;
|
|
35
|
+
return <StatusPill label={arm.label} tone={arm.tone} dot solid />;
|
|
36
|
+
}
|
|
37
|
+
case "text":
|
|
38
|
+
default: {
|
|
39
|
+
const cls = (r.type === "text" && r.muted) ? "text-zinc-500 text-sm" : "text-zinc-400 text-sm";
|
|
40
|
+
const text = raw === null || raw === undefined || raw === "" ? "—" : String(raw);
|
|
41
|
+
return <span class={cls}>{text}</span>;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -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,305 @@
|
|
|
1
|
+
// Spec-driven default-datatable UI runtime — the declarative contract + its PURE
|
|
2
|
+
// helpers (no solid-js / ksui imports, so it unit-tests under plain node).
|
|
3
|
+
//
|
|
4
|
+
// Phase 2 P1 (UI half): a base plugin's list/create/edit/archive page is the
|
|
5
|
+
// data-shaped projection of its resource. This module is the UI mirror of the
|
|
6
|
+
// server-side `defineResource(spec)` runtime (kernel-base/resource/*): the SAME
|
|
7
|
+
// field/column declarations that drive the table + migration + CRUD routes also
|
|
8
|
+
// drive the page. Authored once as a `ResourceUiSpec`, rendered by `ResourcePage`
|
|
9
|
+
// (ResourcePage.tsx) into the exact ksui DataTable + Modal + FormField shell a
|
|
10
|
+
// hand-written base plugin ships.
|
|
11
|
+
//
|
|
12
|
+
// Built inside kplugin_payees as the make-or-break proof (byte-for-behavior ==
|
|
13
|
+
// hand-written payees). It imports only ksui + @kserp/host-ui + solid-js, so it
|
|
14
|
+
// lifts into the shared SDK / UI-kit later (that lift needs the plugin UI build
|
|
15
|
+
// to resolve the SDK — a vite alias + tsconfig.ui paths entry — out of scope here).
|
|
16
|
+
|
|
17
|
+
/** A row the runtime can render: any record with a numeric surrogate id. */
|
|
18
|
+
export interface ResourceRow {
|
|
19
|
+
id: number;
|
|
20
|
+
[key: string]: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ---- columns ---------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
export type ColumnTone = "success" | "warning" | "danger" | "neutral";
|
|
26
|
+
|
|
27
|
+
/** Plain text cell; renders `—` when the value is null/empty. `muted` dims it. */
|
|
28
|
+
export interface UiColumnText {
|
|
29
|
+
readonly type: "text";
|
|
30
|
+
readonly muted?: boolean;
|
|
31
|
+
}
|
|
32
|
+
/** The clickable title cell that opens the detail modal. */
|
|
33
|
+
export interface UiColumnTitle {
|
|
34
|
+
readonly type: "title";
|
|
35
|
+
}
|
|
36
|
+
/** Enum value rendered through a label map (e.g. vendor → "Vendor"). */
|
|
37
|
+
export interface UiColumnEnum {
|
|
38
|
+
readonly type: "enum";
|
|
39
|
+
readonly labels: Readonly<Record<string, string>>;
|
|
40
|
+
}
|
|
41
|
+
/** A boolean column rendered as a ksui StatusPill (e.g. is_active → Active/Archived). */
|
|
42
|
+
export interface UiColumnStatus {
|
|
43
|
+
readonly type: "status";
|
|
44
|
+
readonly active: { readonly label: string; readonly tone: ColumnTone };
|
|
45
|
+
readonly inactive: { readonly label: string; readonly tone: ColumnTone };
|
|
46
|
+
}
|
|
47
|
+
export type UiColumnRender = UiColumnText | UiColumnTitle | UiColumnEnum | UiColumnStatus;
|
|
48
|
+
|
|
49
|
+
export interface UiColumn {
|
|
50
|
+
/** Row property this column reads (also the `data` key + sort field). */
|
|
51
|
+
readonly key: string;
|
|
52
|
+
readonly title: string;
|
|
53
|
+
readonly orderable?: boolean;
|
|
54
|
+
readonly render: UiColumnRender;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---- form fields -----------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
/** How a submitted string field is coerced into the request body. */
|
|
60
|
+
export type FieldTransform = "trim" | "trimOrNull";
|
|
61
|
+
|
|
62
|
+
export interface UiFieldText {
|
|
63
|
+
readonly type: "text";
|
|
64
|
+
readonly placeholder?: string;
|
|
65
|
+
readonly required?: boolean;
|
|
66
|
+
readonly transform: FieldTransform;
|
|
67
|
+
}
|
|
68
|
+
export interface UiFieldTextarea {
|
|
69
|
+
readonly type: "textarea";
|
|
70
|
+
readonly placeholder?: string;
|
|
71
|
+
readonly rows?: number;
|
|
72
|
+
readonly required?: boolean;
|
|
73
|
+
readonly transform: FieldTransform;
|
|
74
|
+
}
|
|
75
|
+
export interface UiFieldSelectOption {
|
|
76
|
+
readonly value: string;
|
|
77
|
+
readonly label: string;
|
|
78
|
+
}
|
|
79
|
+
export interface UiFieldSelect {
|
|
80
|
+
readonly type: "select";
|
|
81
|
+
readonly options: readonly UiFieldSelectOption[];
|
|
82
|
+
/** Default option value for a fresh create (defaults to the first option). */
|
|
83
|
+
readonly default?: string;
|
|
84
|
+
/**
|
|
85
|
+
* Reject an empty selection (a select that opens on an empty placeholder
|
|
86
|
+
* option). A select with a non-empty default never trips this; it matters
|
|
87
|
+
* for a future spec whose first option is a `""` "Choose…" placeholder.
|
|
88
|
+
*/
|
|
89
|
+
readonly required?: boolean;
|
|
90
|
+
}
|
|
91
|
+
export type UiField = { readonly key: string; readonly label: string } & (
|
|
92
|
+
| UiFieldText
|
|
93
|
+
| UiFieldTextarea
|
|
94
|
+
| UiFieldSelect
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
// ---- filters ---------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
/** A segmented control whose value is ALWAYS sent as a query param. */
|
|
100
|
+
export interface UiFilterSegmented {
|
|
101
|
+
readonly type: "segmented";
|
|
102
|
+
readonly param: string;
|
|
103
|
+
readonly options: readonly string[];
|
|
104
|
+
readonly default: string;
|
|
105
|
+
readonly testIdPrefix: string;
|
|
106
|
+
}
|
|
107
|
+
/** A native select whose value is sent only when non-empty (`""` = no filter). */
|
|
108
|
+
export interface UiFilterSelect {
|
|
109
|
+
readonly type: "select";
|
|
110
|
+
readonly param: string;
|
|
111
|
+
readonly default: string;
|
|
112
|
+
readonly options: readonly UiFieldSelectOption[];
|
|
113
|
+
}
|
|
114
|
+
export type UiFilter = UiFilterSegmented | UiFilterSelect;
|
|
115
|
+
|
|
116
|
+
// ---- detail view -----------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
export type UiDetailValue =
|
|
119
|
+
| { readonly type: "field"; readonly key: string }
|
|
120
|
+
| { readonly type: "enum"; readonly key: string; readonly labels: Readonly<Record<string, string>> }
|
|
121
|
+
| { readonly type: "status"; readonly key: string; readonly active: string; readonly inactive: string }
|
|
122
|
+
| { readonly type: "datetime"; readonly key: string };
|
|
123
|
+
|
|
124
|
+
export interface UiDetailRow {
|
|
125
|
+
readonly label: string;
|
|
126
|
+
readonly value: UiDetailValue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---- the spec --------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
export interface ResourceUiSpec {
|
|
132
|
+
/** REST base, e.g. "/api/payees". CRUD endpoints derive from it. */
|
|
133
|
+
readonly basePath: string;
|
|
134
|
+
/** Page title + framing. */
|
|
135
|
+
readonly title: string;
|
|
136
|
+
readonly subtitle?: string;
|
|
137
|
+
/** PageShareButton wiring. */
|
|
138
|
+
readonly share?: { readonly module: string; readonly moduleLabel: string };
|
|
139
|
+
/** Capability codes. `edit` is satisfied by hasAny(...edit). */
|
|
140
|
+
readonly permissions: {
|
|
141
|
+
readonly view: string;
|
|
142
|
+
readonly edit: readonly string[];
|
|
143
|
+
readonly delete: string;
|
|
144
|
+
};
|
|
145
|
+
/** Soft-delete boolean field; drives the archive/restore affordance + status. */
|
|
146
|
+
readonly softDeleteField: string;
|
|
147
|
+
readonly columns: readonly UiColumn[];
|
|
148
|
+
readonly fields: readonly UiField[];
|
|
149
|
+
readonly filters?: readonly UiFilter[];
|
|
150
|
+
readonly detail: readonly UiDetailRow[];
|
|
151
|
+
readonly labels: {
|
|
152
|
+
readonly add: string;
|
|
153
|
+
readonly createTitle: string;
|
|
154
|
+
readonly createSubmit: string;
|
|
155
|
+
readonly editTitle: string;
|
|
156
|
+
readonly editSubmit: string;
|
|
157
|
+
/** The detail-modal title falls back to this row field when not editing. */
|
|
158
|
+
readonly titleField: string;
|
|
159
|
+
readonly searchPlaceholder: string;
|
|
160
|
+
readonly empty: string;
|
|
161
|
+
readonly noResults: string;
|
|
162
|
+
readonly createErrorFallback: string;
|
|
163
|
+
readonly updateErrorFallback: string;
|
|
164
|
+
readonly networkError: string;
|
|
165
|
+
readonly archiveTitle: string;
|
|
166
|
+
readonly archiveMessage: string;
|
|
167
|
+
readonly archiveConfirm: string;
|
|
168
|
+
};
|
|
169
|
+
/** data-testid prefix (e.g. "payees" → payees-add-btn, payees-row-3). */
|
|
170
|
+
readonly testIdPrefix: string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---- derived endpoints -----------------------------------------------------
|
|
174
|
+
|
|
175
|
+
export interface ResourceEndpoints {
|
|
176
|
+
readonly list: string;
|
|
177
|
+
readonly create: string;
|
|
178
|
+
readonly one: (id: number) => string;
|
|
179
|
+
readonly restore: (id: number) => string;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function endpoints(spec: ResourceUiSpec): ResourceEndpoints {
|
|
183
|
+
const base = spec.basePath;
|
|
184
|
+
return {
|
|
185
|
+
list: base,
|
|
186
|
+
create: base,
|
|
187
|
+
one: (id) => `${base}/${id}`,
|
|
188
|
+
restore: (id) => `${base}/${id}/restore`,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---- pure helpers (unit-tested) --------------------------------------------
|
|
193
|
+
|
|
194
|
+
/** The fixed columns the list request always carries. */
|
|
195
|
+
export interface ListParams {
|
|
196
|
+
readonly page: number;
|
|
197
|
+
readonly limit: number;
|
|
198
|
+
readonly search: string;
|
|
199
|
+
readonly sortBy: string | null;
|
|
200
|
+
readonly sortDir: string;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Build the list-request query string. Mirrors hand-written payees exactly:
|
|
205
|
+
* page/limit/search/sortBy/sortDir are always present; a segmented filter is
|
|
206
|
+
* always sent; a select filter is sent only when its value is non-empty.
|
|
207
|
+
*/
|
|
208
|
+
export function buildListQuery(
|
|
209
|
+
spec: ResourceUiSpec,
|
|
210
|
+
params: ListParams,
|
|
211
|
+
filterState: Readonly<Record<string, string>>,
|
|
212
|
+
): URLSearchParams {
|
|
213
|
+
const q = new URLSearchParams({
|
|
214
|
+
page: String(params.page),
|
|
215
|
+
limit: String(params.limit),
|
|
216
|
+
search: params.search,
|
|
217
|
+
sortBy: params.sortBy || "",
|
|
218
|
+
sortDir: params.sortDir,
|
|
219
|
+
});
|
|
220
|
+
for (const f of spec.filters ?? []) {
|
|
221
|
+
const value = filterState[f.param] ?? f.default;
|
|
222
|
+
// Segmented filters are always sent; a select filter only when non-empty.
|
|
223
|
+
if (f.type === "segmented" || value) {
|
|
224
|
+
q.set(f.param, value);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return q;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The default filter state (each filter at its declared default). */
|
|
231
|
+
export function initialFilterState(spec: ResourceUiSpec): Record<string, string> {
|
|
232
|
+
const state: Record<string, string> = {};
|
|
233
|
+
for (const f of spec.filters ?? []) state[f.param] = f.default;
|
|
234
|
+
return state;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The default option value for a select field (explicit default ?? first option). */
|
|
238
|
+
export function selectDefault(field: UiFieldSelect): string {
|
|
239
|
+
return field.default ?? field.options[0]?.value ?? "";
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** A fresh, empty form: "" for text/textarea, the default option for a select. */
|
|
243
|
+
export function emptyFormValues(spec: ResourceUiSpec): Record<string, string> {
|
|
244
|
+
const values: Record<string, string> = {};
|
|
245
|
+
for (const f of spec.fields) {
|
|
246
|
+
values[f.key] = f.type === "select" ? selectDefault(f) : "";
|
|
247
|
+
}
|
|
248
|
+
return values;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Prefill the form from an existing row (edit mode). */
|
|
252
|
+
export function rowToFormValues(
|
|
253
|
+
spec: ResourceUiSpec,
|
|
254
|
+
row: Readonly<Record<string, unknown>>,
|
|
255
|
+
): Record<string, string> {
|
|
256
|
+
const values: Record<string, string> = {};
|
|
257
|
+
for (const f of spec.fields) {
|
|
258
|
+
const v = row[f.key];
|
|
259
|
+
values[f.key] = v === null || v === undefined ? "" : String(v);
|
|
260
|
+
}
|
|
261
|
+
return values;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Strip a trailing required-marker (" *") from a field label. */
|
|
265
|
+
export function cleanLabel(label: string): string {
|
|
266
|
+
const trimmed = label.trimEnd();
|
|
267
|
+
return (trimmed.endsWith("*") ? trimmed.slice(0, -1) : trimmed).trimEnd();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Validate the form. Returns the first required-but-empty field's error message,
|
|
272
|
+
* or null when valid. Matches payees' "Name is required" exactly.
|
|
273
|
+
*/
|
|
274
|
+
export function validateForm(
|
|
275
|
+
spec: ResourceUiSpec,
|
|
276
|
+
values: Readonly<Record<string, string>>,
|
|
277
|
+
): string | null {
|
|
278
|
+
for (const f of spec.fields) {
|
|
279
|
+
// text/textarea/select all carry an optional `required`; a select with a
|
|
280
|
+
// non-empty default never trips this, but a placeholder-first select can.
|
|
281
|
+
if (f.required && !(values[f.key] ?? "").trim()) {
|
|
282
|
+
return `${cleanLabel(f.label)} is required`;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Coerce form values into the request body per each field's transform. */
|
|
289
|
+
export function formToBody(
|
|
290
|
+
spec: ResourceUiSpec,
|
|
291
|
+
values: Readonly<Record<string, string>>,
|
|
292
|
+
): Record<string, string | null> {
|
|
293
|
+
const body: Record<string, string | null> = {};
|
|
294
|
+
for (const f of spec.fields) {
|
|
295
|
+
const raw = values[f.key] ?? "";
|
|
296
|
+
if (f.type === "select") {
|
|
297
|
+
body[f.key] = raw;
|
|
298
|
+
} else if (f.transform === "trim") {
|
|
299
|
+
body[f.key] = raw.trim();
|
|
300
|
+
} else {
|
|
301
|
+
body[f.key] = raw.trim() || null;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return body;
|
|
305
|
+
}
|
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
|
+
// Spec-driven default-datatable runtime: a base plugin's list/create/edit/archive
|
|
151
|
+
// page expressed as a declarative ResourceUiSpec and rendered through ResourcePage
|
|
152
|
+
// (DataTable + Modal + FormField + the host shell). Host primitives (PageShell,
|
|
153
|
+
// PageShareButton, and the workspace/permission hooks) are INJECTED via the `host`
|
|
154
|
+
// prop, so ksui stays standalone — it never imports `@kserp/host-ui`. The plugin's
|
|
155
|
+
// remote entry shrinks to: build a ResourceUiSpec, render <ResourcePage host={...}/>.
|
|
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
|
+
});
|