@kahitsan/ksui 0.17.0 → 0.18.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.17.0",
3
+ "version": "0.18.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",
@@ -48,4 +48,14 @@ describe("DataTable (client-side mode)", () => {
48
48
  ));
49
49
  expect(screen.getByPlaceholderText("Filter...")).toBeTruthy();
50
50
  });
51
+
52
+ it("respects pageLength: only that many rows render on page 1 (client-side)", () => {
53
+ render(() => (
54
+ <DataTable columns={COLUMNS} data={DATA} paging={true} pageLength={2} />
55
+ ));
56
+ // page size 2 over 3 rows → page 1 shows the first two, the third is on page 2.
57
+ expect(screen.getByText("Alpha")).toBeTruthy();
58
+ expect(screen.getByText("Beta")).toBeTruthy();
59
+ expect(screen.queryByText("Gamma")).toBeNull();
60
+ });
51
61
  });
@@ -63,8 +63,12 @@ export interface ResourcePageHost {
63
63
  requestInit?: () => RequestInit;
64
64
  /** Reactive value; when it changes the list resets to page 1 and refetches. */
65
65
  refetchKey?: () => unknown;
66
- /** Extra action elements rendered in the header before the built-in create button. */
67
- headerActions?: JSX.Element;
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;
68
72
  }
69
73
 
70
74
  export interface ResourcePageProps<T extends ResourceRow> {
@@ -76,20 +80,32 @@ export interface ResourcePageProps<T extends ResourceRow> {
76
80
 
77
81
  type RefetchApi = { refetch: () => void; resetAndRefetch: () => void };
78
82
 
79
- export function ResourcePage<T extends ResourceRow>(props: ResourcePageProps<T>) {
83
+ export function ResourcePage<T extends ResourceRow>(
84
+ props: ResourcePageProps<T>
85
+ ) {
80
86
  const spec = props.spec;
81
87
  const ep = endpoints(spec);
82
- const doFetch = props.fetchImpl ?? ((...a: Parameters<typeof fetch>) => fetch(...a));
88
+ const doFetch =
89
+ props.fetchImpl ?? ((...a: Parameters<typeof fetch>) => fetch(...a));
83
90
 
84
- const { PageShell } = props.host;
85
- const can = (key: string) => props.host.can?.(key) ?? true;
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;
86
102
  const canView = () => can(spec.permissions.view);
87
103
  const canEdit = () => spec.permissions.edit.some(can);
88
104
  const canDelete = () => can(spec.permissions.delete);
89
105
 
90
106
  /** Merge the host's per-request init (headers/credentials) with method + body. */
91
107
  function reqInit(extra?: RequestInit): RequestInit {
92
- const base = props.host.requestInit?.() ?? {};
108
+ const base = hostRequestInit?.() ?? {};
93
109
  return {
94
110
  ...base,
95
111
  ...extra,
@@ -100,14 +116,18 @@ export function ResourcePage<T extends ResourceRow>(props: ResourcePageProps<T>)
100
116
  };
101
117
  }
102
118
 
103
- const [filterState, setFilterState] = createStore<Record<string, string>>(initialFilterState(spec));
119
+ const [filterState, setFilterState] = createStore<Record<string, string>>(
120
+ initialFilterState(spec)
121
+ );
104
122
  let refetchFn: RefetchApi | undefined;
105
123
 
106
124
  const [detailRow, setDetailRow] = createSignal<ResourceRow | null>(null);
107
125
  const [editing, setEditing] = createSignal(false);
108
126
  const [createOpen, setCreateOpen] = createSignal(false);
109
127
 
110
- const [form, setForm] = createStore<Record<string, string>>(emptyFormValues(spec));
128
+ const [form, setForm] = createStore<Record<string, string>>(
129
+ emptyFormValues(spec)
130
+ );
111
131
  const [saving, setSaving] = createSignal(false);
112
132
  const [error, setError] = createSignal("");
113
133
  const setValue = (key: string, value: string) => setForm(key, value);
@@ -140,7 +160,12 @@ export function ResourcePage<T extends ResourceRow>(props: ResourcePageProps<T>)
140
160
  setEditing(true);
141
161
  }
142
162
 
143
- 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
+ ) {
144
169
  const msg = validateForm(spec, form);
145
170
  if (msg) {
146
171
  setError(msg);
@@ -155,7 +180,7 @@ export function ResourcePage<T extends ResourceRow>(props: ResourcePageProps<T>)
155
180
  method,
156
181
  headers: { "Content-Type": "application/json" },
157
182
  body: JSON.stringify(formToBody(spec, form)),
158
- }),
183
+ })
159
184
  );
160
185
  // create allows the idempotent-200 path; both treat non-ok as an error
161
186
  if (!res.ok && !(method === "POST" && res.status === 200)) {
@@ -182,10 +207,15 @@ export function ResourcePage<T extends ResourceRow>(props: ResourcePageProps<T>)
182
207
  async function handleUpdate() {
183
208
  const row = detailRow();
184
209
  if (!row) return;
185
- await submit("PUT", ep.one(row.id), spec.labels.updateErrorFallback, (updated) => {
186
- if (updated && typeof updated.id === "number") setDetailRow(updated);
187
- setEditing(false);
188
- });
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
+ );
189
219
  }
190
220
 
191
221
  async function handleArchive(id: number) {
@@ -233,7 +263,7 @@ export function ResourcePage<T extends ResourceRow>(props: ResourcePageProps<T>)
233
263
  subtitle={spec.subtitle}
234
264
  actions={
235
265
  <>
236
- {props.host.headerActions}
266
+ {headerActions?.()}
237
267
  <Show when={canEdit()}>
238
268
  <Button
239
269
  intent="primary"
@@ -252,8 +282,10 @@ export function ResourcePage<T extends ResourceRow>(props: ResourcePageProps<T>)
252
282
  }
253
283
  >
254
284
  <DataTable<ResourceRow>
255
- refetchKey={props.host.refetchKey}
256
- fetchFn={async (params: FetchParams): Promise<FetchResult<ResourceRow>> => {
285
+ refetchKey={refetchKey}
286
+ fetchFn={async (
287
+ params: FetchParams
288
+ ): Promise<FetchResult<ResourceRow>> => {
257
289
  const q = buildListQuery(spec, params, filterState);
258
290
  const res = await doFetch(`${ep.list}?${q}`, reqInit());
259
291
  return res.json();
@@ -262,6 +294,8 @@ export function ResourcePage<T extends ResourceRow>(props: ResourcePageProps<T>)
262
294
  searching={true}
263
295
  ordering={true}
264
296
  paging={true}
297
+ pageLength={spec.pageLength}
298
+ lengthMenu={spec.lengthMenu ? [...spec.lengthMenu] : undefined}
265
299
  searchPlaceholder={spec.labels.searchPlaceholder}
266
300
  emptyMessage={spec.labels.empty}
267
301
  noResultsMessage={spec.labels.noResults}
@@ -279,10 +313,14 @@ export function ResourcePage<T extends ResourceRow>(props: ResourcePageProps<T>)
279
313
  ) : (
280
314
  <select
281
315
  value={filterState[f.param]}
282
- onChange={(e) => setFilterState(f.param, e.currentTarget.value)}
316
+ onChange={(e) =>
317
+ setFilterState(f.param, e.currentTarget.value)
318
+ }
283
319
  class="rounded-lg border border-zinc-800/50 bg-zinc-900 px-3 py-1.5 text-xs text-zinc-400 cursor-pointer"
284
320
  >
285
- <For each={f.options}>{(o) => <option value={o.value}>{o.label}</option>}</For>
321
+ <For each={f.options}>
322
+ {(o) => <option value={o.value}>{o.label}</option>}
323
+ </For>
286
324
  </select>
287
325
  )
288
326
  }
@@ -306,7 +344,9 @@ export function ResourcePage<T extends ResourceRow>(props: ResourcePageProps<T>)
306
344
  >
307
345
  <div data-testid={`${spec.testIdPrefix}-create-modal`}>
308
346
  <div class="flex items-center justify-between mb-6">
309
- <h2 class="text-lg font-semibold text-zinc-100">{spec.labels.createTitle}</h2>
347
+ <h2 class="text-lg font-semibold text-zinc-100">
348
+ {spec.labels.createTitle}
349
+ </h2>
310
350
  <button
311
351
  onClick={() => {
312
352
  setCreateOpen(false);
@@ -348,7 +388,9 @@ export function ResourcePage<T extends ResourceRow>(props: ResourcePageProps<T>)
348
388
  <div data-testid={`${spec.testIdPrefix}-detail-modal`}>
349
389
  <div class="flex items-center justify-between mb-6">
350
390
  <h2 class="text-lg font-semibold text-zinc-100">
351
- {editing() ? spec.labels.editTitle : String(row()[spec.labels.titleField] ?? "")}
391
+ {editing()
392
+ ? spec.labels.editTitle
393
+ : String(row()[spec.labels.titleField] ?? "")}
352
394
  </h2>
353
395
  <div class="flex items-center gap-2">
354
396
  <Show when={!editing() && canEdit()}>
@@ -395,7 +437,10 @@ export function ResourcePage<T extends ResourceRow>(props: ResourcePageProps<T>)
395
437
  </div>
396
438
  </div>
397
439
 
398
- <Show when={editing()} fallback={<ResourceDetail rows={spec.detail} row={row()} />}>
440
+ <Show
441
+ when={editing()}
442
+ fallback={<ResourceDetail rows={spec.detail} row={row()} />}
443
+ >
399
444
  <ResourceForm
400
445
  spec={spec}
401
446
  values={form}
@@ -156,6 +156,13 @@ export interface ResourceUiSpec {
156
156
  };
157
157
  /** data-testid prefix (e.g. "things" → things-add-btn, things-row-3). */
158
158
  readonly testIdPrefix: string;
159
+ /** Initial rows-per-page for the list (DataTable `pageLength`). When omitted,
160
+ * DataTable's own default (10) applies. A host can lower a resolved user/workspace
161
+ * preference in here (the platform's route-settings `pageSize`). */
162
+ readonly pageLength?: number;
163
+ /** The rows-per-page options offered in the list's page-size menu (DataTable
164
+ * `lengthMenu`). When omitted, DataTable's default ([10, 25, 50, 100]) applies. */
165
+ readonly lengthMenu?: readonly number[];
159
166
  }
160
167
 
161
168
  // ---- derived endpoints -----------------------------------------------------