@kahitsan/ksui 0.16.0 → 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 +1 -1
- package/src/components/composite/resource/ResourceDetail.tsx +3 -4
- package/src/components/composite/resource/ResourceForm.tsx +3 -4
- package/src/components/composite/resource/ResourcePage.tsx +58 -50
- package/src/components/composite/resource/cells.tsx +2 -3
- package/src/components/composite/resource/spec.ts +12 -24
- package/src/index.ts +6 -6
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",
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
// The
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// reproducing hand-written payees' PayeeDetail.
|
|
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).
|
|
5
4
|
import { For } from "solid-js";
|
|
6
5
|
import DetailRow from "../../base/DetailRow";
|
|
7
6
|
import type { ResourceRow, UiDetailRow, UiDetailValue } from "./spec";
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
// The
|
|
2
|
-
//
|
|
3
|
-
// and
|
|
4
|
-
// passed in, so a single instance backs both the create and edit modals.
|
|
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.
|
|
5
4
|
import { For, Show } from "solid-js";
|
|
6
5
|
import FormField from "../../base/FormField";
|
|
7
6
|
import Button from "../../base/Button";
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
11
|
import { createSignal, For, Show, type Component, type JSX } from "solid-js";
|
|
12
12
|
import { createStore } from "solid-js/store";
|
|
13
13
|
import Plus from "lucide-solid/icons/plus";
|
|
@@ -40,26 +40,31 @@ import { ResourceForm } from "./ResourceForm";
|
|
|
40
40
|
import { ResourceDetail } from "./ResourceDetail";
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* tree, so their reactive context resolves correctly.
|
|
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.
|
|
47
46
|
*/
|
|
48
47
|
export interface ResourcePageHost {
|
|
48
|
+
/** Page-shell layout: a heading area + an actions slot wrapping the body. */
|
|
49
49
|
PageShell: Component<{
|
|
50
50
|
title: string;
|
|
51
51
|
subtitle?: string;
|
|
52
52
|
actions?: JSX.Element;
|
|
53
53
|
children: JSX.Element;
|
|
54
54
|
}>;
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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;
|
|
63
68
|
}
|
|
64
69
|
|
|
65
70
|
export interface ResourcePageProps<T extends ResourceRow> {
|
|
@@ -76,12 +81,24 @@ export function ResourcePage<T extends ResourceRow>(props: ResourcePageProps<T>)
|
|
|
76
81
|
const ep = endpoints(spec);
|
|
77
82
|
const doFetch = props.fetchImpl ?? ((...a: Parameters<typeof fetch>) => fetch(...a));
|
|
78
83
|
|
|
79
|
-
const { PageShell
|
|
80
|
-
const
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
|
|
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
|
+
}
|
|
85
102
|
|
|
86
103
|
const [filterState, setFilterState] = createStore<Record<string, string>>(initialFilterState(spec));
|
|
87
104
|
let refetchFn: RefetchApi | undefined;
|
|
@@ -104,14 +121,9 @@ export function ResourcePage<T extends ResourceRow>(props: ResourcePageProps<T>)
|
|
|
104
121
|
setError("");
|
|
105
122
|
}
|
|
106
123
|
|
|
107
|
-
function wsHeaders(): Record<string, string> {
|
|
108
|
-
const ws = activeWorkspace();
|
|
109
|
-
return ws ? { "X-Workspace-Id": String(ws.ws_id) } : {};
|
|
110
|
-
}
|
|
111
|
-
|
|
112
124
|
async function openDetail(id: number) {
|
|
113
125
|
try {
|
|
114
|
-
const res = await doFetch(ep.one(id),
|
|
126
|
+
const res = await doFetch(ep.one(id), reqInit());
|
|
115
127
|
if (res.ok) {
|
|
116
128
|
setDetailRow(await res.json());
|
|
117
129
|
setEditing(false);
|
|
@@ -137,12 +149,14 @@ export function ResourcePage<T extends ResourceRow>(props: ResourcePageProps<T>)
|
|
|
137
149
|
setSaving(true);
|
|
138
150
|
setError("");
|
|
139
151
|
try {
|
|
140
|
-
const res = await doFetch(
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
+
);
|
|
146
160
|
// create allows the idempotent-200 path; both treat non-ok as an error
|
|
147
161
|
if (!res.ok && !(method === "POST" && res.status === 200)) {
|
|
148
162
|
const err = await res.json().catch(() => ({}));
|
|
@@ -185,7 +199,7 @@ export function ResourcePage<T extends ResourceRow>(props: ResourcePageProps<T>)
|
|
|
185
199
|
)
|
|
186
200
|
return;
|
|
187
201
|
try {
|
|
188
|
-
await doFetch(ep.one(id), { method: "DELETE"
|
|
202
|
+
await doFetch(ep.one(id), reqInit({ method: "DELETE" }));
|
|
189
203
|
setDetailRow(null);
|
|
190
204
|
refetchFn?.refetch();
|
|
191
205
|
} catch {
|
|
@@ -195,7 +209,7 @@ export function ResourcePage<T extends ResourceRow>(props: ResourcePageProps<T>)
|
|
|
195
209
|
|
|
196
210
|
async function handleRestore(id: number) {
|
|
197
211
|
try {
|
|
198
|
-
const res = await doFetch(ep.restore(id), { method: "PATCH"
|
|
212
|
+
const res = await doFetch(ep.restore(id), reqInit({ method: "PATCH" }));
|
|
199
213
|
if (res.ok) {
|
|
200
214
|
setDetailRow(await res.json());
|
|
201
215
|
refetchFn?.refetch();
|
|
@@ -219,13 +233,7 @@ export function ResourcePage<T extends ResourceRow>(props: ResourcePageProps<T>)
|
|
|
219
233
|
subtitle={spec.subtitle}
|
|
220
234
|
actions={
|
|
221
235
|
<>
|
|
222
|
-
|
|
223
|
-
{(s) =>
|
|
224
|
-
PageShareButton ? (
|
|
225
|
-
<PageShareButton module={s().module} moduleLabel={s().moduleLabel} />
|
|
226
|
-
) : null
|
|
227
|
-
}
|
|
228
|
-
</Show>
|
|
236
|
+
{props.host.headerActions}
|
|
229
237
|
<Show when={canEdit()}>
|
|
230
238
|
<Button
|
|
231
239
|
intent="primary"
|
|
@@ -244,10 +252,10 @@ export function ResourcePage<T extends ResourceRow>(props: ResourcePageProps<T>)
|
|
|
244
252
|
}
|
|
245
253
|
>
|
|
246
254
|
<DataTable<ResourceRow>
|
|
247
|
-
refetchKey={
|
|
255
|
+
refetchKey={props.host.refetchKey}
|
|
248
256
|
fetchFn={async (params: FetchParams): Promise<FetchResult<ResourceRow>> => {
|
|
249
257
|
const q = buildListQuery(spec, params, filterState);
|
|
250
|
-
const res = await doFetch(`${ep.list}?${q}`,
|
|
258
|
+
const res = await doFetch(`${ep.list}?${q}`, reqInit());
|
|
251
259
|
return res.json();
|
|
252
260
|
}}
|
|
253
261
|
columns={columns}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
// Column-cell rendering for the
|
|
2
|
-
//
|
|
3
|
-
// the generated table is byte-for-behavior identical.
|
|
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.
|
|
4
3
|
import type { JSX } from "solid-js";
|
|
5
4
|
import StatusPill from "../../base/StatusPill";
|
|
6
5
|
import type { ResourceRow, ResourceUiSpec, UiColumn } from "./spec";
|
|
@@ -1,18 +1,8 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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).
|
|
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.
|
|
16
6
|
|
|
17
7
|
/** A row the runtime can render: any record with a numeric surrogate id. */
|
|
18
8
|
export interface ResourceRow {
|
|
@@ -129,14 +119,12 @@ export interface UiDetailRow {
|
|
|
129
119
|
// ---- the spec --------------------------------------------------------------
|
|
130
120
|
|
|
131
121
|
export interface ResourceUiSpec {
|
|
132
|
-
/** REST base, e.g. "/api/
|
|
122
|
+
/** REST base, e.g. "/api/things"; the CRUD endpoints derive from it. */
|
|
133
123
|
readonly basePath: string;
|
|
134
124
|
/** Page title + framing. */
|
|
135
125
|
readonly title: string;
|
|
136
126
|
readonly subtitle?: string;
|
|
137
|
-
/**
|
|
138
|
-
readonly share?: { readonly module: string; readonly moduleLabel: string };
|
|
139
|
-
/** Capability codes. `edit` is satisfied by hasAny(...edit). */
|
|
127
|
+
/** Permission keys passed to `host.can`. `edit` passes if ANY of its keys do. */
|
|
140
128
|
readonly permissions: {
|
|
141
129
|
readonly view: string;
|
|
142
130
|
readonly edit: readonly string[];
|
|
@@ -166,7 +154,7 @@ export interface ResourceUiSpec {
|
|
|
166
154
|
readonly archiveMessage: string;
|
|
167
155
|
readonly archiveConfirm: string;
|
|
168
156
|
};
|
|
169
|
-
/** data-testid prefix (e.g. "
|
|
157
|
+
/** data-testid prefix (e.g. "things" → things-add-btn, things-row-3). */
|
|
170
158
|
readonly testIdPrefix: string;
|
|
171
159
|
}
|
|
172
160
|
|
|
@@ -201,9 +189,9 @@ export interface ListParams {
|
|
|
201
189
|
}
|
|
202
190
|
|
|
203
191
|
/**
|
|
204
|
-
* Build the list-request query string
|
|
205
|
-
*
|
|
206
|
-
*
|
|
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.
|
|
207
195
|
*/
|
|
208
196
|
export function buildListQuery(
|
|
209
197
|
spec: ResourceUiSpec,
|
|
@@ -269,7 +257,7 @@ export function cleanLabel(label: string): string {
|
|
|
269
257
|
|
|
270
258
|
/**
|
|
271
259
|
* Validate the form. Returns the first required-but-empty field's error message,
|
|
272
|
-
* or null when valid.
|
|
260
|
+
* or null when valid (e.g. "Name is required").
|
|
273
261
|
*/
|
|
274
262
|
export function validateForm(
|
|
275
263
|
spec: ResourceUiSpec,
|
package/src/index.ts
CHANGED
|
@@ -147,12 +147,12 @@ export type { VoucherOption } from "./components/composite/VoucherPicker";
|
|
|
147
147
|
|
|
148
148
|
export { default as NotFound, type NotFoundProps } from "./components/composite/NotFound";
|
|
149
149
|
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
// (DataTable + Modal + FormField
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
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
156
|
export { ResourcePage } from "./components/composite/resource/ResourcePage";
|
|
157
157
|
export type {
|
|
158
158
|
ResourcePageProps,
|