@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kahitsan/ksui",
3
- "version": "0.16.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 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,31 @@ 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
+ /** 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, 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);
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), { credentials: "include", headers: wsHeaders() });
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(url, {
141
- method,
142
- credentials: "include",
143
- headers: { "Content-Type": "application/json", ...wsHeaders() },
144
- body: JSON.stringify(formToBody(spec, form)),
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", credentials: "include", headers: wsHeaders() });
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", credentials: "include", headers: wsHeaders() });
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
- <Show when={spec.share}>
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={() => activeWorkspace()?.ws_id}
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}`, { credentials: "include", headers: wsHeaders() });
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 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,