@kahitsan/ksui 0.39.2 → 0.39.4

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.39.2",
3
+ "version": "0.39.4",
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,6 +1,7 @@
1
1
  // The picker pages the list in from the server (page/limit) and delegates the
2
2
  // search to it, so these assert the request contract as well as the rendering.
3
3
  import { describe, expect, it, vi } from "vitest";
4
+ import { createSignal } from "solid-js";
4
5
  import { fireEvent, render, waitFor } from "@solidjs/testing-library";
5
6
  import VoucherPicker, { type VoucherOption } from "./VoucherPicker";
6
7
 
@@ -37,6 +38,7 @@ function voucher(over: Partial<VoucherOption> & Pick<VoucherOption, "id" | "code
37
38
  value: 20,
38
39
  max_discount_amount: null,
39
40
  applicable_packages: null,
41
+ applicable_package_lineages: null,
40
42
  minimum_purchase: 0,
41
43
  valid_from: null,
42
44
  valid_until: null,
@@ -379,6 +381,266 @@ describe("VoucherPicker dialog", () => {
379
381
  );
380
382
  });
381
383
 
384
+ it("accepts a voucher when each package matches by aligned lineage", async () => {
385
+ mockPagedFetch([
386
+ voucher({
387
+ id: 35,
388
+ code: "LINEAGEOK",
389
+ applicable_packages: [99],
390
+ applicable_package_lineages: ["day-pass"],
391
+ }),
392
+ ]);
393
+ const { getByTestId } = render(() => (
394
+ <VoucherPicker
395
+ selected={null}
396
+ onChange={vi.fn()}
397
+ subtotal={1000}
398
+ packageIds={[1]}
399
+ packageLineages={["day-pass"]}
400
+ />
401
+ ));
402
+ fireEvent.click(getByTestId("voucher-picker-trigger"));
403
+ await waitFor(() => expect(getByTestId("voucher-picker-result-35")).toBeTruthy());
404
+ });
405
+
406
+ it("accepts mixed carts when every item matches one of several allowed pricing eras", async () => {
407
+ mockPagedFetch([
408
+ voucher({
409
+ id: 55,
410
+ code: "MULTI_ERA",
411
+ applicable_package_lineages: ["day-pass", "single-use"],
412
+ }),
413
+ ]);
414
+ const { getByTestId } = render(() => (
415
+ <VoucherPicker
416
+ selected={null}
417
+ onChange={vi.fn()}
418
+ subtotal={1000}
419
+ packageIds={[1, 2, 3]}
420
+ packageLineages={["day-pass", "single-use", "day-pass"]}
421
+ />
422
+ ));
423
+ fireEvent.click(getByTestId("voucher-picker-trigger"));
424
+ await waitFor(() => expect(getByTestId("voucher-picker-result-55")).toBeTruthy());
425
+ });
426
+
427
+ it("rejects a mixed ID and lineage cart when one item matches neither", async () => {
428
+ mockPagedFetch([
429
+ voucher({
430
+ id: 56,
431
+ code: "MIXED_NEITHER",
432
+ applicable_packages: [1],
433
+ applicable_package_lineages: ["day-pass"],
434
+ }),
435
+ ]);
436
+ const { getByTestId, queryByTestId } = render(() => (
437
+ <VoucherPicker
438
+ selected={null}
439
+ onChange={vi.fn()}
440
+ subtotal={1000}
441
+ packageIds={[1, 2, 3]}
442
+ packageLineages={[null, "day-pass", "single-use"]}
443
+ />
444
+ ));
445
+ fireEvent.click(getByTestId("voucher-picker-trigger"));
446
+ await waitFor(() => expect(getByTestId("voucher-picker-inapplicable-56")).toBeTruthy());
447
+ expect(queryByTestId("voucher-picker-result-56")).toBeNull();
448
+ expect(getByTestId("voucher-picker-inapplicable-56").textContent).toContain(
449
+ "Doesn't cover every item",
450
+ );
451
+ });
452
+
453
+ it("does not keep a selected voucher selectable after reopening with a changed lineage", async () => {
454
+ const selected = voucher({
455
+ id: 57,
456
+ code: "CHANGED_ERA",
457
+ applicable_package_lineages: ["day-pass"],
458
+ });
459
+ mockPagedFetch([selected]);
460
+ const [packageLineage, setPackageLineage] = createSignal<string | null>("day-pass");
461
+ const { getByTestId, queryByTestId } = render(() => (
462
+ <VoucherPicker
463
+ selected={selected}
464
+ onChange={vi.fn()}
465
+ subtotal={1000}
466
+ packageIds={[1]}
467
+ packageLineages={[packageLineage()]}
468
+ />
469
+ ));
470
+
471
+ fireEvent.click(getByTestId("voucher-picker-trigger"));
472
+ await waitFor(() => expect(getByTestId("voucher-picker-result-57")).toBeTruthy());
473
+ fireEvent.click(getByTestId("voucher-picker-cancel"));
474
+
475
+ setPackageLineage("single-use");
476
+ fireEvent.click(getByTestId("voucher-picker-trigger"));
477
+ await waitFor(() => expect(getByTestId("voucher-picker-inapplicable-57")).toBeTruthy());
478
+ expect(queryByTestId("voucher-picker-result-57")).toBeNull();
479
+ expect(getByTestId("voucher-picker-confirm").hasAttribute("disabled")).toBe(true);
480
+ });
481
+
482
+ it("keeps Confirm disabled when selected voucher is inactive", async () => {
483
+ const selected = voucher({ id: 59, code: "DISABLED_SELECTED", is_active: false });
484
+ mockPagedFetch([selected]);
485
+ const onChange = vi.fn();
486
+ const { getByTestId } = render(() => (
487
+ <VoucherPicker
488
+ selected={selected}
489
+ onChange={onChange}
490
+ subtotal={1000}
491
+ packageIds={[]}
492
+ />
493
+ ));
494
+
495
+ fireEvent.click(getByTestId("voucher-picker-trigger"));
496
+ await waitFor(() => expect(getByTestId("voucher-picker-inapplicable-59")).toBeTruthy());
497
+ expect(getByTestId("voucher-picker-confirm").hasAttribute("disabled")).toBe(true);
498
+
499
+ fireEvent.click(getByTestId("voucher-picker-confirm"));
500
+ expect(onChange).not.toHaveBeenCalled();
501
+ });
502
+
503
+ it("lists enabled and disabled vouchers in their correct modal sections", async () => {
504
+ mockPagedFetch([
505
+ voucher({ id: 37, code: "ENABLED_LINEAGE", applicable_packages: [99], applicable_package_lineages: ["day-pass"] }),
506
+ voucher({ id: 38, code: "DISABLED_LINEAGE", is_active: false, applicable_packages: [99], applicable_package_lineages: ["day-pass"] }),
507
+ ]);
508
+ const { getByTestId, queryByTestId } = render(() => (
509
+ <VoucherPicker
510
+ selected={null}
511
+ onChange={vi.fn()}
512
+ subtotal={1000}
513
+ packageIds={[1]}
514
+ packageLineages={["day-pass"]}
515
+ />
516
+ ));
517
+ fireEvent.click(getByTestId("voucher-picker-trigger"));
518
+ await waitFor(() => expect(getByTestId("voucher-picker-result-37")).toBeTruthy());
519
+ expect(queryByTestId("voucher-picker-inapplicable-37")).toBeNull();
520
+ expect(getByTestId("voucher-picker-result-37").textContent).toContain("ENABLED_LINEAGE");
521
+ expect(queryByTestId("voucher-picker-result-38")).toBeNull();
522
+ expect(getByTestId("voucher-picker-inapplicable-38").textContent).toContain("DISABLED_LINEAGE");
523
+ expect(getByTestId("voucher-picker-inapplicable-38").textContent).toContain("Inactive");
524
+ });
525
+
526
+ it("lists lineage-disabled vouchers as visible but not selectable", async () => {
527
+ mockPagedFetch([
528
+ voucher({ id: 39, code: "DISABLED_LINEAGE", applicable_packages: [99], applicable_package_lineages: ["day-pass"] }),
529
+ ]);
530
+ const { getByTestId, queryByTestId } = render(() => (
531
+ <VoucherPicker
532
+ selected={null}
533
+ onChange={vi.fn()}
534
+ subtotal={1000}
535
+ packageIds={[1]}
536
+ packageLineages={["single-use"]}
537
+ />
538
+ ));
539
+ fireEvent.click(getByTestId("voucher-picker-trigger"));
540
+ await waitFor(() => expect(getByTestId("voucher-picker-inapplicable-39")).toBeTruthy());
541
+ expect(queryByTestId("voucher-picker-result-39")).toBeNull();
542
+ expect(getByTestId("voucher-picker-inapplicable-39").getAttribute("aria-disabled")).toBe("true");
543
+ expect(getByTestId("voucher-picker-inapplicable-39").textContent).toContain("DISABLED_LINEAGE");
544
+ expect(getByTestId("voucher-picker-confirm").hasAttribute("disabled")).toBe(true);
545
+ });
546
+
547
+ it("rejects a voucher when aligned package lineage does not match", async () => {
548
+ mockPagedFetch([
549
+ voucher({
550
+ id: 36,
551
+ code: "LINEAGEBAD",
552
+ applicable_packages: [99],
553
+ applicable_package_lineages: ["day-pass"],
554
+ }),
555
+ ]);
556
+ const { getByTestId, queryByTestId } = render(() => (
557
+ <VoucherPicker
558
+ selected={null}
559
+ onChange={vi.fn()}
560
+ subtotal={1000}
561
+ packageIds={[1]}
562
+ packageLineages={["single-use"]}
563
+ />
564
+ ));
565
+ fireEvent.click(getByTestId("voucher-picker-trigger"));
566
+ await waitFor(() => expect(getByTestId("voucher-picker-inapplicable-36")).toBeTruthy());
567
+ expect(queryByTestId("voucher-picker-result-36")).toBeNull();
568
+ expect(getByTestId("voucher-picker-inapplicable-36").textContent).toContain(
569
+ "Doesn't cover every item",
570
+ );
571
+ });
572
+ it.each([
573
+ {
574
+ name: "legacy ID-only package matching",
575
+ packageIds: [1],
576
+ packageLineages: undefined,
577
+ voucher: voucher({ id: 60, code: "LEGACY_ID", applicable_packages: [1] }),
578
+ result: true,
579
+ },
580
+ {
581
+ name: "empty lineages with legacy ID-only matching",
582
+ packageIds: [1],
583
+ packageLineages: [],
584
+ voucher: voucher({ id: 61, code: "EMPTY_LINEAGES", applicable_packages: [1] }),
585
+ result: true,
586
+ },
587
+ {
588
+ name: "mixed IDs and lineages",
589
+ packageIds: [1, 2],
590
+ packageLineages: [null, "day-pass"],
591
+ voucher: voucher({
592
+ id: 62,
593
+ code: "MIXED_OK",
594
+ applicable_packages: [1],
595
+ applicable_package_lineages: ["day-pass"],
596
+ }),
597
+ result: true,
598
+ },
599
+ {
600
+ name: "mixed cart with an uncovered package",
601
+ packageIds: [1, 2],
602
+ packageLineages: [null, "single-use"],
603
+ voucher: voucher({
604
+ id: 63,
605
+ code: "MIXED_BAD",
606
+ applicable_packages: [1],
607
+ applicable_package_lineages: ["day-pass"],
608
+ }),
609
+ result: false,
610
+ },
611
+ {
612
+ name: "null lineage without an allowed package ID",
613
+ packageIds: [2],
614
+ packageLineages: [null],
615
+ voucher: voucher({
616
+ id: 64,
617
+ code: "NULL_LINEAGE",
618
+ applicable_packages: [1],
619
+ applicable_package_lineages: ["day-pass"],
620
+ }),
621
+ result: false,
622
+ },
623
+ ])("handles $name", async ({ packageIds, packageLineages, voucher: candidate, result }) => {
624
+ mockPagedFetch([candidate]);
625
+ const { getByTestId, queryByTestId } = render(() => (
626
+ <VoucherPicker
627
+ selected={null}
628
+ onChange={vi.fn()}
629
+ subtotal={1000}
630
+ packageIds={packageIds}
631
+ packageLineages={packageLineages}
632
+ />
633
+ ));
634
+ fireEvent.click(getByTestId("voucher-picker-trigger"));
635
+ await waitFor(() =>
636
+ expect(
637
+ result
638
+ ? getByTestId(`voucher-picker-result-${candidate.id}`)
639
+ : getByTestId(`voucher-picker-inapplicable-${candidate.id}`),
640
+ ).toBeTruthy(),
641
+ );
642
+ expect(queryByTestId(`voucher-picker-result-${candidate.id}`) !== null).toBe(result);
643
+ });
382
644
  it("surfaces a load failure instead of rendering an empty list", async () => {
383
645
  vi.stubGlobal(
384
646
  "fetch",
@@ -398,6 +660,31 @@ describe("VoucherPicker dialog", () => {
398
660
  );
399
661
  });
400
662
 
663
+ it("surfaces a rejected voucher request", async () => {
664
+ vi.stubGlobal("fetch", vi.fn(() => Promise.reject(new Error("network down"))));
665
+ const { getByTestId } = render(() => (
666
+ <VoucherPicker selected={null} onChange={vi.fn()} subtotal={1000} packageIds={[]} />
667
+ ));
668
+ fireEvent.click(getByTestId("voucher-picker-trigger"));
669
+ await waitFor(() =>
670
+ expect(getByTestId("voucher-picker-popup").textContent).toContain("network down"),
671
+ );
672
+ });
673
+ it("surfaces a missing vouchers endpoint", async () => {
674
+ vi.stubGlobal(
675
+ "fetch",
676
+ vi.fn(async () => ({ ok: false, status: 404, json: async () => ({}) })) as unknown as typeof fetch,
677
+ );
678
+ const { getByTestId } = render(() => (
679
+ <VoucherPicker selected={null} onChange={vi.fn()} subtotal={1000} packageIds={[]} />
680
+ ));
681
+ fireEvent.click(getByTestId("voucher-picker-trigger"));
682
+ await waitFor(() =>
683
+ expect(getByTestId("voucher-picker-popup").textContent).toContain(
684
+ "Vouchers module isn't available",
685
+ ),
686
+ );
687
+ });
401
688
  it("reopening discards a pick that was staged but never confirmed", async () => {
402
689
  mockPagedFetch([voucher({ id: 40, code: "STAGED" }), voucher({ id: 41, code: "OTHER" })]);
403
690
  const onChange = vi.fn();
@@ -23,6 +23,7 @@ export interface VoucherOption {
23
23
  value: string | number | null;
24
24
  max_discount_amount: string | number | null;
25
25
  applicable_packages: number[] | null;
26
+ applicable_package_lineages?: string[] | null;
26
27
  minimum_purchase: string | number;
27
28
  valid_from: string | null;
28
29
  valid_until: string | null;
@@ -42,6 +43,7 @@ interface VoucherPickerProps {
42
43
  onChange: (next: VoucherOption | null) => void;
43
44
  subtotal: number;
44
45
  packageIds: number[];
46
+ packageLineages?: (string | null)[];
45
47
  disabled?: boolean;
46
48
  compact?: boolean;
47
49
  /** Same-shape endpoint override (defaults to the vouchers plugin's own API) —
@@ -104,6 +106,7 @@ function ineligibilityReason(
104
106
  voucher: VoucherOption,
105
107
  subtotal: number,
106
108
  packageIds: number[],
109
+ packageLineages: (string | null)[],
107
110
  todayIso: string,
108
111
  ): string | null {
109
112
  if (!voucher.is_active) return "Inactive";
@@ -116,10 +119,17 @@ function ineligibilityReason(
116
119
  if (usageExhausted(voucher)) return "Fully redeemed";
117
120
  if (asNumber(voucher.minimum_purchase) > subtotal)
118
121
  return `Needs ${formatCurrency(asNumber(voucher.minimum_purchase))} minimum`;
119
- if (voucher.applicable_packages && voucher.applicable_packages.length > 0) {
122
+ const allowedIds = voucher.applicable_packages ?? [];
123
+ const allowedLineages = voucher.applicable_package_lineages ?? [];
124
+ if (allowedIds.length > 0 || allowedLineages.length > 0) {
120
125
  if (packageIds.length === 0) return "Only for specific items";
121
- const allowed = new Set(voucher.applicable_packages);
122
- if (!packageIds.every((id) => allowed.has(id))) return "Doesn't cover every item";
126
+ const allowed = new Set(allowedIds);
127
+ if (packageIds.some((id, index) =>
128
+ !allowed.has(id) &&
129
+ !(packageLineages[index] != null && allowedLineages.includes(packageLineages[index]!))
130
+ )) {
131
+ return "Doesn't cover every item";
132
+ }
123
133
  }
124
134
  return null;
125
135
  }
@@ -129,9 +139,10 @@ function isApplicable(
129
139
  voucher: VoucherOption,
130
140
  subtotal: number,
131
141
  packageIds: number[],
142
+ packageLineages: (string | null)[],
132
143
  todayIso: string,
133
144
  ): boolean {
134
- return ineligibilityReason(voucher, subtotal, packageIds, todayIso) === null;
145
+ return ineligibilityReason(voucher, subtotal, packageIds, packageLineages, todayIso) === null;
135
146
  }
136
147
 
137
148
  function formatVoucherDescription(v: VoucherOption): string {
@@ -293,16 +304,16 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
293
304
 
294
305
  const applicable = createMemo(() => {
295
306
  const today_ = today();
296
- return vouchers().filter((v) => isApplicable(v, props.subtotal, props.packageIds, today_));
307
+ return vouchers().filter((v) => isApplicable(v, props.subtotal, props.packageIds, props.packageLineages ?? [], today_));
297
308
  });
298
309
 
299
310
  const inapplicable = createMemo(() => {
300
311
  const today_ = today();
301
312
  return vouchers()
302
- .filter((v) => !isApplicable(v, props.subtotal, props.packageIds, today_))
313
+ .filter((v) => !isApplicable(v, props.subtotal, props.packageIds, props.packageLineages ?? [], today_))
303
314
  .map((v) => ({
304
315
  voucher: v,
305
- reason: ineligibilityReason(v, props.subtotal, props.packageIds, today_) ?? "",
316
+ reason: ineligibilityReason(v, props.subtotal, props.packageIds, props.packageLineages ?? [], today_) ?? "",
306
317
  }));
307
318
  });
308
319
 
@@ -0,0 +1,175 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { fireEvent, render, screen } from "@solidjs/testing-library";
3
+ import type { JSX } from "solid-js";
4
+ import { ResourcePage } from "./ResourcePage";
5
+ import type { ResourceRow, ResourceUiSpec } from "./spec";
6
+
7
+ const ACTIVE: ResourceRow = { id: 1, name: "Active resource", archived: 1 };
8
+ const ARCHIVED: ResourceRow = { id: 2, name: "Archived resource", archived: false };
9
+
10
+ const SPEC: ResourceUiSpec = {
11
+ basePath: "/api/resources",
12
+ title: "Resources",
13
+ permissions: {
14
+ view: "resources.view",
15
+ create: "resources.create",
16
+ edit: ["resources.edit"],
17
+ delete: "resources.delete",
18
+ restore: "resources.restore",
19
+ },
20
+ softDeleteField: "archived",
21
+ columns: [
22
+ { key: "name", title: "Name", render: { type: "title" } },
23
+ ],
24
+ fields: [
25
+ { key: "name", label: "Name", type: "text", required: true, transform: "trim" },
26
+ ],
27
+ detail: [{ label: "Name", value: { type: "field", key: "name" } }],
28
+ labels: {
29
+ add: "Add resource",
30
+ createTitle: "Create resource",
31
+ createSubmit: "Create",
32
+ editTitle: "Edit resource",
33
+ editSubmit: "Save",
34
+ titleField: "name",
35
+ searchPlaceholder: "Search resources",
36
+ empty: "No resources",
37
+ noResults: "No matching resources",
38
+ createErrorFallback: "Create failed",
39
+ updateErrorFallback: "Update failed",
40
+ networkError: "Network failed",
41
+ archiveTitle: "Archive resource",
42
+ archiveMessage: "Archive this resource?",
43
+ archiveConfirm: "Archive",
44
+ },
45
+ testIdPrefix: "resources",
46
+ };
47
+
48
+ function host(can: (permission: string) => boolean) {
49
+ return {
50
+ PageShell: (props: { title: string; actions?: JSX.Element; children: JSX.Element }) => (
51
+ <section>
52
+ <h1>{props.title}</h1>
53
+ <div>{props.actions}</div>
54
+ {props.children}
55
+ </section>
56
+ ),
57
+ can,
58
+ };
59
+ }
60
+
61
+ function fetchFor(rows: ResourceRow[], detail = rows[0], failure?: { method: string; body: string }) {
62
+ return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
63
+ const url = String(input);
64
+ const method = init?.method ?? "GET";
65
+ if (failure && method === failure.method && url.endsWith(failure.body)) {
66
+ return new Response(JSON.stringify({ error: `${method} failed visibly` }), {
67
+ status: 403,
68
+ headers: { "Content-Type": "application/json" },
69
+ });
70
+ }
71
+ if (method === "GET" && url.includes("/api/resources/")) {
72
+ return new Response(JSON.stringify(detail), { status: 200 });
73
+ }
74
+ if (method === "GET") {
75
+ return new Response(JSON.stringify({ data: rows, total: rows.length }), { status: 200 });
76
+ }
77
+ return new Response(JSON.stringify(detail), { status: 200 });
78
+ });
79
+ }
80
+
81
+ async function openDetail(fetchImpl: ReturnType<typeof fetchFor>, rowName: string) {
82
+ await screen.findByText(rowName);
83
+ fireEvent.click(screen.getByText(rowName));
84
+ await screen.findByTestId("resources-detail-modal");
85
+ expect(fetchImpl).toHaveBeenCalledWith("/api/resources/1", expect.anything());
86
+ }
87
+
88
+ describe("ResourcePage permission gates and mutation failures", () => {
89
+ it("gates the page on view permission", async () => {
90
+ const fetchImpl = fetchFor([ACTIVE]);
91
+ render(() => <ResourcePage spec={SPEC} host={host(() => false)} fetchImpl={fetchImpl} />);
92
+ expect(screen.queryByText("Resources")).toBeNull();
93
+ await new Promise<void>((resolve) => queueMicrotask(resolve));
94
+ expect(fetchImpl).not.toHaveBeenCalled();
95
+ });
96
+
97
+ it("uses create permission for add action, separately from edit permission", async () => {
98
+ const fetchImpl = fetchFor([ACTIVE]);
99
+ render(() => (
100
+ <ResourcePage
101
+ spec={SPEC}
102
+ host={host((permission) => permission !== "resources.create")}
103
+ fetchImpl={fetchImpl}
104
+ />
105
+ ));
106
+ expect(screen.queryByRole("button", { name: "Add resource" })).toBeNull();
107
+ await openDetail(fetchImpl, ACTIVE.name as string);
108
+ expect(screen.getByLabelText("Edit")).toBeTruthy();
109
+ });
110
+
111
+ it("uses edit permission for edit action, separately from create permission", async () => {
112
+ const fetchImpl = fetchFor([ACTIVE]);
113
+ render(() => (
114
+ <ResourcePage
115
+ spec={SPEC}
116
+ host={host((permission) => permission !== "resources.edit")}
117
+ fetchImpl={fetchImpl}
118
+ />
119
+ ));
120
+ expect(screen.getByRole("button", { name: "Add resource" })).toBeTruthy();
121
+ await openDetail(fetchImpl, ACTIVE.name as string);
122
+ expect(screen.queryByRole("button", { name: "Edit" })).toBeNull();
123
+ });
124
+
125
+ it("uses restore permission for archived rows", async () => {
126
+ const fetchImpl = fetchFor([ARCHIVED], ARCHIVED);
127
+ render(() => (
128
+ <ResourcePage
129
+ spec={SPEC}
130
+ host={host((permission) => permission !== "resources.restore")}
131
+ fetchImpl={fetchImpl}
132
+ />
133
+ ));
134
+ await screen.findByText(ARCHIVED.name as string);
135
+ fireEvent.click(screen.getByText(ARCHIVED.name as string));
136
+ await screen.findByTestId("resources-detail-modal");
137
+ expect(screen.queryByTitle("Restore")).toBeNull();
138
+ });
139
+
140
+ it("does not use delete permission for restore affordance", async () => {
141
+ const fetchImpl = fetchFor([ARCHIVED], ARCHIVED);
142
+ render(() => (
143
+ <ResourcePage
144
+ spec={SPEC}
145
+ host={host((permission) => permission !== "resources.delete")}
146
+ fetchImpl={fetchImpl}
147
+ />
148
+ ));
149
+ await screen.findByText(ARCHIVED.name as string);
150
+ fireEvent.click(screen.getByText(ARCHIVED.name as string));
151
+ await screen.findByTestId("resources-detail-modal");
152
+ expect(screen.getByTitle("Restore")).toBeTruthy();
153
+ });
154
+
155
+ it("shows archive failure in detail modal", async () => {
156
+ const fetchImpl = fetchFor([ACTIVE], ACTIVE, { method: "DELETE", body: "/api/resources/1" });
157
+ render(() => <ResourcePage spec={SPEC} host={host(() => true)} fetchImpl={fetchImpl} />);
158
+ await openDetail(fetchImpl, ACTIVE.name as string);
159
+ fireEvent.click(screen.getByTitle("Archive"));
160
+ const confirmDialog = await screen.findByTestId("confirm-dialog");
161
+ fireEvent.click(confirmDialog.querySelector("button:last-child") as HTMLButtonElement);
162
+ expect(await screen.findByText("DELETE failed visibly")).toBeTruthy();
163
+ expect(screen.getByTestId("resources-detail-modal")).toBeTruthy();
164
+ });
165
+
166
+ it("shows restore failure in detail modal", async () => {
167
+ const fetchImpl = fetchFor([ARCHIVED], ARCHIVED, { method: "PATCH", body: "/api/resources/2/restore" });
168
+ render(() => <ResourcePage spec={SPEC} host={host(() => true)} fetchImpl={fetchImpl} />);
169
+ await screen.findByText(ARCHIVED.name as string);
170
+ fireEvent.click(screen.getByText(ARCHIVED.name as string));
171
+ await screen.findByTestId("resources-detail-modal");
172
+ fireEvent.click(screen.getByTitle("Restore"));
173
+ expect(await screen.findByText("PATCH failed visibly")).toBeTruthy();
174
+ });
175
+ });
@@ -100,8 +100,10 @@ export function ResourcePage<T extends ResourceRow>(
100
100
  } = props.host;
101
101
  const can = (key: string) => hostCan?.(key) ?? true;
102
102
  const canView = () => can(spec.permissions.view);
103
+ const canCreate = () => can(spec.permissions.create ?? spec.permissions.edit[0]);
103
104
  const canEdit = () => spec.permissions.edit.some(can);
104
105
  const canDelete = () => can(spec.permissions.delete);
106
+ const canRestore = () => can(spec.permissions.restore ?? spec.permissions.delete);
105
107
 
106
108
  /** Merge the host's per-request init (headers/credentials) with method + body. */
107
109
  function reqInit(extra?: RequestInit): RequestInit {
@@ -229,23 +231,31 @@ export function ResourcePage<T extends ResourceRow>(
229
231
  )
230
232
  return;
231
233
  try {
232
- await doFetch(ep.one(id), reqInit({ method: "DELETE" }));
234
+ const res = await doFetch(ep.one(id), reqInit({ method: "DELETE" }));
235
+ if (!res.ok) {
236
+ const err = await res.json().catch(() => ({}));
237
+ setError(err.error || "Failed to archive item");
238
+ return;
239
+ }
233
240
  setDetailRow(null);
234
241
  refetchFn?.refetch();
235
242
  } catch {
236
- /* ignore */
243
+ setError(spec.labels.networkError);
237
244
  }
238
245
  }
239
246
 
240
247
  async function handleRestore(id: number) {
241
248
  try {
242
249
  const res = await doFetch(ep.restore(id), reqInit({ method: "PATCH" }));
243
- if (res.ok) {
244
- setDetailRow(await res.json());
245
- refetchFn?.refetch();
250
+ if (!res.ok) {
251
+ const err = await res.json().catch(() => ({}));
252
+ setError(err.error || "Failed to restore item");
253
+ return;
246
254
  }
255
+ setDetailRow(await res.json());
256
+ refetchFn?.refetch();
247
257
  } catch {
248
- /* ignore */
258
+ setError(spec.labels.networkError);
249
259
  }
250
260
  }
251
261
 
@@ -264,7 +274,7 @@ export function ResourcePage<T extends ResourceRow>(
264
274
  actions={
265
275
  <>
266
276
  {headerActions?.()}
267
- <Show when={canEdit()}>
277
+ <Show when={canCreate()}>
268
278
  <Button
269
279
  intent="primary"
270
280
  variant="clip1"
@@ -403,7 +413,7 @@ export function ResourcePage<T extends ResourceRow>(
403
413
  <Pencil size={16} />
404
414
  </button>
405
415
  </Show>
406
- <Show when={!editing() && canDelete()}>
416
+ <Show when={!editing() && (row()[spec.softDeleteField] ? canDelete() : canRestore())}>
407
417
  {row()[spec.softDeleteField] ? (
408
418
  <button
409
419
  onClick={() => handleArchive(row().id)}
@@ -436,7 +446,11 @@ export function ResourcePage<T extends ResourceRow>(
436
446
  </button>
437
447
  </div>
438
448
  </div>
439
-
449
+ <Show when={!editing() && error()}>
450
+ <div role="alert" class="mb-4 text-sm text-ks-danger-fg">
451
+ {error()}
452
+ </div>
453
+ </Show>
440
454
  <Show
441
455
  when={editing()}
442
456
  fallback={<ResourceDetail rows={spec.detail} row={row()} />}
@@ -48,8 +48,10 @@ export function routeToResourceSpec(route: RouteSpec): ResourceUiSpec {
48
48
  ...(route.subtitle !== undefined ? { subtitle: route.subtitle } : {}),
49
49
  permissions: {
50
50
  view: route.permissions.view,
51
+ ...(route.permissions.create ? { create: route.permissions.create } : {}),
51
52
  edit: route.permissions.edit,
52
53
  delete: route.permissions.delete,
54
+ ...(route.permissions.restore ? { restore: route.permissions.restore } : {}),
53
55
  },
54
56
  softDeleteField: route.softDeleteField,
55
57
  testIdPrefix: route.testIdPrefix,
@@ -65,6 +65,28 @@ function vendorRoute() {
65
65
  }
66
66
 
67
67
  describe("routeToResourceSpec lowering", () => {
68
+ it("preserves distinct create and restore permissions", () => {
69
+ const lowered = routeToResourceSpec(
70
+ defineRoute({
71
+ ...vendorRoute(),
72
+ permissions: {
73
+ view: "vendors.view",
74
+ create: "vendors.create",
75
+ edit: ["vendors.edit"],
76
+ delete: "vendors.delete",
77
+ restore: "vendors.restore",
78
+ },
79
+ }),
80
+ );
81
+ expect(lowered.permissions).toEqual({
82
+ view: "vendors.view",
83
+ create: "vendors.create",
84
+ edit: ["vendors.edit"],
85
+ delete: "vendors.delete",
86
+ restore: "vendors.restore",
87
+ });
88
+ });
89
+
68
90
  it("lowers a built route to the hand-authored ResourceUiSpec shape", () => {
69
91
  const lowered = routeToResourceSpec(vendorRoute());
70
92
  const hand: ResourceUiSpec = {
@@ -91,8 +91,10 @@ export function action(
91
91
 
92
92
  export interface RoutePermissions {
93
93
  readonly view: string;
94
+ readonly create?: string;
94
95
  readonly edit: readonly string[];
95
96
  readonly delete: string;
97
+ readonly restore?: string;
96
98
  }
97
99
 
98
100
  /** Labels carried straight onto the lowered ResourceUiSpec.labels. */
@@ -184,8 +184,10 @@ export interface ResourceUiSpec {
184
184
  /** Permission keys passed to `host.can`. `edit` passes if ANY of its keys do. */
185
185
  readonly permissions: {
186
186
  readonly view: string;
187
+ readonly create?: string;
187
188
  readonly edit: readonly string[];
188
189
  readonly delete: string;
190
+ readonly restore?: string;
189
191
  };
190
192
  /** Soft-delete boolean field; drives the archive/restore affordance + status. */
191
193
  readonly softDeleteField: string;