@kahitsan/ksui 0.16.0 → 0.17.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kahitsan/ksui",
3
- "version": "0.16.0",
3
+ "version": "0.17.1",
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 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.
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 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.
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
- // 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={...} />.
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
- // 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).
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,35 @@ import { ResourceForm } from "./ResourceForm";
40
40
  import { ResourceDetail } from "./ResourceDetail";
41
41
 
42
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.
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
- 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
- };
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
+ /**
67
+ * Render extra header actions before the built-in create button. A function
68
+ * (not a pre-created element) so it runs inside this component's render scope —
69
+ * a pre-created element would re-run outside it if the host object is rebuilt.
70
+ */
71
+ headerActions?: () => JSX.Element;
63
72
  }
64
73
 
65
74
  export interface ResourcePageProps<T extends ResourceRow> {
@@ -71,26 +80,54 @@ export interface ResourcePageProps<T extends ResourceRow> {
71
80
 
72
81
  type RefetchApi = { refetch: () => void; resetAndRefetch: () => void };
73
82
 
74
- export function ResourcePage<T extends ResourceRow>(props: ResourcePageProps<T>) {
83
+ export function ResourcePage<T extends ResourceRow>(
84
+ props: ResourcePageProps<T>
85
+ ) {
75
86
  const spec = props.spec;
76
87
  const ep = endpoints(spec);
77
- const doFetch = props.fetchImpl ?? ((...a: Parameters<typeof fetch>) => fetch(...a));
88
+ const doFetch =
89
+ props.fetchImpl ?? ((...a: Parameters<typeof fetch>) => fetch(...a));
78
90
 
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);
91
+ // Read the host config ONCE so the component is robust against a consumer
92
+ // passing an inline `host={{...}}` literal (which Solid re-evaluates on each
93
+ // `props.host.*` access — re-instantiating headerActions outside render scope).
94
+ const {
95
+ PageShell,
96
+ can: hostCan,
97
+ requestInit: hostRequestInit,
98
+ refetchKey,
99
+ headerActions,
100
+ } = props.host;
101
+ const can = (key: string) => hostCan?.(key) ?? true;
102
+ const canView = () => can(spec.permissions.view);
103
+ const canEdit = () => spec.permissions.edit.some(can);
104
+ const canDelete = () => can(spec.permissions.delete);
85
105
 
86
- const [filterState, setFilterState] = createStore<Record<string, string>>(initialFilterState(spec));
106
+ /** Merge the host's per-request init (headers/credentials) with method + body. */
107
+ function reqInit(extra?: RequestInit): RequestInit {
108
+ const base = hostRequestInit?.() ?? {};
109
+ return {
110
+ ...base,
111
+ ...extra,
112
+ headers: {
113
+ ...(base.headers as Record<string, string> | undefined),
114
+ ...(extra?.headers as Record<string, string> | undefined),
115
+ },
116
+ };
117
+ }
118
+
119
+ const [filterState, setFilterState] = createStore<Record<string, string>>(
120
+ initialFilterState(spec)
121
+ );
87
122
  let refetchFn: RefetchApi | undefined;
88
123
 
89
124
  const [detailRow, setDetailRow] = createSignal<ResourceRow | null>(null);
90
125
  const [editing, setEditing] = createSignal(false);
91
126
  const [createOpen, setCreateOpen] = createSignal(false);
92
127
 
93
- const [form, setForm] = createStore<Record<string, string>>(emptyFormValues(spec));
128
+ const [form, setForm] = createStore<Record<string, string>>(
129
+ emptyFormValues(spec)
130
+ );
94
131
  const [saving, setSaving] = createSignal(false);
95
132
  const [error, setError] = createSignal("");
96
133
  const setValue = (key: string, value: string) => setForm(key, value);
@@ -104,14 +141,9 @@ export function ResourcePage<T extends ResourceRow>(props: ResourcePageProps<T>)
104
141
  setError("");
105
142
  }
106
143
 
107
- function wsHeaders(): Record<string, string> {
108
- const ws = activeWorkspace();
109
- return ws ? { "X-Workspace-Id": String(ws.ws_id) } : {};
110
- }
111
-
112
144
  async function openDetail(id: number) {
113
145
  try {
114
- const res = await doFetch(ep.one(id), { credentials: "include", headers: wsHeaders() });
146
+ const res = await doFetch(ep.one(id), reqInit());
115
147
  if (res.ok) {
116
148
  setDetailRow(await res.json());
117
149
  setEditing(false);
@@ -128,7 +160,12 @@ export function ResourcePage<T extends ResourceRow>(props: ResourcePageProps<T>)
128
160
  setEditing(true);
129
161
  }
130
162
 
131
- async function submit(method: "POST" | "PUT", url: string, fallback: string, onOk: (row: ResourceRow) => void) {
163
+ async function submit(
164
+ method: "POST" | "PUT",
165
+ url: string,
166
+ fallback: string,
167
+ onOk: (row: ResourceRow) => void
168
+ ) {
132
169
  const msg = validateForm(spec, form);
133
170
  if (msg) {
134
171
  setError(msg);
@@ -137,12 +174,14 @@ export function ResourcePage<T extends ResourceRow>(props: ResourcePageProps<T>)
137
174
  setSaving(true);
138
175
  setError("");
139
176
  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
- });
177
+ const res = await doFetch(
178
+ url,
179
+ reqInit({
180
+ method,
181
+ headers: { "Content-Type": "application/json" },
182
+ body: JSON.stringify(formToBody(spec, form)),
183
+ })
184
+ );
146
185
  // create allows the idempotent-200 path; both treat non-ok as an error
147
186
  if (!res.ok && !(method === "POST" && res.status === 200)) {
148
187
  const err = await res.json().catch(() => ({}));
@@ -168,10 +207,15 @@ export function ResourcePage<T extends ResourceRow>(props: ResourcePageProps<T>)
168
207
  async function handleUpdate() {
169
208
  const row = detailRow();
170
209
  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
- });
210
+ await submit(
211
+ "PUT",
212
+ ep.one(row.id),
213
+ spec.labels.updateErrorFallback,
214
+ (updated) => {
215
+ if (updated && typeof updated.id === "number") setDetailRow(updated);
216
+ setEditing(false);
217
+ }
218
+ );
175
219
  }
176
220
 
177
221
  async function handleArchive(id: number) {
@@ -185,7 +229,7 @@ export function ResourcePage<T extends ResourceRow>(props: ResourcePageProps<T>)
185
229
  )
186
230
  return;
187
231
  try {
188
- await doFetch(ep.one(id), { method: "DELETE", credentials: "include", headers: wsHeaders() });
232
+ await doFetch(ep.one(id), reqInit({ method: "DELETE" }));
189
233
  setDetailRow(null);
190
234
  refetchFn?.refetch();
191
235
  } catch {
@@ -195,7 +239,7 @@ export function ResourcePage<T extends ResourceRow>(props: ResourcePageProps<T>)
195
239
 
196
240
  async function handleRestore(id: number) {
197
241
  try {
198
- const res = await doFetch(ep.restore(id), { method: "PATCH", credentials: "include", headers: wsHeaders() });
242
+ const res = await doFetch(ep.restore(id), reqInit({ method: "PATCH" }));
199
243
  if (res.ok) {
200
244
  setDetailRow(await res.json());
201
245
  refetchFn?.refetch();
@@ -219,13 +263,7 @@ export function ResourcePage<T extends ResourceRow>(props: ResourcePageProps<T>)
219
263
  subtitle={spec.subtitle}
220
264
  actions={
221
265
  <>
222
- <Show when={spec.share}>
223
- {(s) =>
224
- PageShareButton ? (
225
- <PageShareButton module={s().module} moduleLabel={s().moduleLabel} />
226
- ) : null
227
- }
228
- </Show>
266
+ {headerActions?.()}
229
267
  <Show when={canEdit()}>
230
268
  <Button
231
269
  intent="primary"
@@ -244,10 +282,12 @@ export function ResourcePage<T extends ResourceRow>(props: ResourcePageProps<T>)
244
282
  }
245
283
  >
246
284
  <DataTable<ResourceRow>
247
- refetchKey={() => activeWorkspace()?.ws_id}
248
- fetchFn={async (params: FetchParams): Promise<FetchResult<ResourceRow>> => {
285
+ refetchKey={refetchKey}
286
+ fetchFn={async (
287
+ params: FetchParams
288
+ ): Promise<FetchResult<ResourceRow>> => {
249
289
  const q = buildListQuery(spec, params, filterState);
250
- const res = await doFetch(`${ep.list}?${q}`, { credentials: "include", headers: wsHeaders() });
290
+ const res = await doFetch(`${ep.list}?${q}`, reqInit());
251
291
  return res.json();
252
292
  }}
253
293
  columns={columns}
@@ -271,10 +311,14 @@ export function ResourcePage<T extends ResourceRow>(props: ResourcePageProps<T>)
271
311
  ) : (
272
312
  <select
273
313
  value={filterState[f.param]}
274
- onChange={(e) => setFilterState(f.param, e.currentTarget.value)}
314
+ onChange={(e) =>
315
+ setFilterState(f.param, e.currentTarget.value)
316
+ }
275
317
  class="rounded-lg border border-zinc-800/50 bg-zinc-900 px-3 py-1.5 text-xs text-zinc-400 cursor-pointer"
276
318
  >
277
- <For each={f.options}>{(o) => <option value={o.value}>{o.label}</option>}</For>
319
+ <For each={f.options}>
320
+ {(o) => <option value={o.value}>{o.label}</option>}
321
+ </For>
278
322
  </select>
279
323
  )
280
324
  }
@@ -298,7 +342,9 @@ export function ResourcePage<T extends ResourceRow>(props: ResourcePageProps<T>)
298
342
  >
299
343
  <div data-testid={`${spec.testIdPrefix}-create-modal`}>
300
344
  <div class="flex items-center justify-between mb-6">
301
- <h2 class="text-lg font-semibold text-zinc-100">{spec.labels.createTitle}</h2>
345
+ <h2 class="text-lg font-semibold text-zinc-100">
346
+ {spec.labels.createTitle}
347
+ </h2>
302
348
  <button
303
349
  onClick={() => {
304
350
  setCreateOpen(false);
@@ -340,7 +386,9 @@ export function ResourcePage<T extends ResourceRow>(props: ResourcePageProps<T>)
340
386
  <div data-testid={`${spec.testIdPrefix}-detail-modal`}>
341
387
  <div class="flex items-center justify-between mb-6">
342
388
  <h2 class="text-lg font-semibold text-zinc-100">
343
- {editing() ? spec.labels.editTitle : String(row()[spec.labels.titleField] ?? "")}
389
+ {editing()
390
+ ? spec.labels.editTitle
391
+ : String(row()[spec.labels.titleField] ?? "")}
344
392
  </h2>
345
393
  <div class="flex items-center gap-2">
346
394
  <Show when={!editing() && canEdit()}>
@@ -387,7 +435,10 @@ export function ResourcePage<T extends ResourceRow>(props: ResourcePageProps<T>)
387
435
  </div>
388
436
  </div>
389
437
 
390
- <Show when={editing()} fallback={<ResourceDetail rows={spec.detail} row={row()} />}>
438
+ <Show
439
+ when={editing()}
440
+ fallback={<ResourceDetail rows={spec.detail} row={row()} />}
441
+ >
391
442
  <ResourceForm
392
443
  spec={spec}
393
444
  values={form}
@@ -1,6 +1,5 @@
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.
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
- // 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).
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/payees". CRUD endpoints derive from it. */
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
- /** PageShareButton wiring. */
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. "payees" → payees-add-btn, payees-row-3). */
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. 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.
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. Matches payees' "Name is required" exactly.
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
- // 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={...}/>.
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 actionsis 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,