@kahitsan/ksui 0.39.3 → 0.39.5
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/base/ExistingAttachmentTile.test.tsx +54 -0
- package/src/components/base/ExistingAttachmentTile.tsx +43 -8
- package/src/components/base/ImageViewer.test.tsx +25 -0
- package/src/components/base/ImageViewer.tsx +15 -5
- package/src/components/composite/AccountRadioPicker.tsx +6 -22
- package/src/components/composite/TransactionAccountFields.tsx +0 -3
- package/src/components/composite/TransactionForm.tsx +70 -66
- package/src/components/composite/VoucherPicker.test.tsx +240 -0
- package/src/components/composite/resource/ResourcePage.test.tsx +175 -0
- package/src/components/composite/resource/ResourcePage.tsx +23 -9
- package/src/components/composite/resource/route-adapter.ts +2 -0
- package/src/components/composite/resource/route-spec.test.ts +22 -0
- package/src/components/composite/resource/route-spec.ts +2 -0
- package/src/components/composite/resource/spec.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kahitsan/ksui",
|
|
3
|
-
"version": "0.39.
|
|
3
|
+
"version": "0.39.5",
|
|
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",
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { cleanup, render, screen } from "@solidjs/testing-library";
|
|
3
|
+
import { Suspense } from "solid-js";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import ExistingAttachmentTile from "./ExistingAttachmentTile";
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
cleanup();
|
|
9
|
+
vi.unstubAllGlobals();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("ExistingAttachmentTile", () => {
|
|
13
|
+
it("keeps its parent rendered while private attachment bytes load", async () => {
|
|
14
|
+
let resolveFetch!: (response: Response) => void;
|
|
15
|
+
vi.stubGlobal(
|
|
16
|
+
"fetch",
|
|
17
|
+
vi.fn(
|
|
18
|
+
() =>
|
|
19
|
+
new Promise<Response>((resolve) => {
|
|
20
|
+
resolveFetch = resolve;
|
|
21
|
+
}),
|
|
22
|
+
),
|
|
23
|
+
);
|
|
24
|
+
vi.stubGlobal("URL", {
|
|
25
|
+
...URL,
|
|
26
|
+
createObjectURL: vi.fn(() => "blob:attachment"),
|
|
27
|
+
revokeObjectURL: vi.fn(),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
render(() => (
|
|
31
|
+
<Suspense fallback={<p>Modal fallback</p>}>
|
|
32
|
+
<div data-testid="modal-shell">
|
|
33
|
+
<ExistingAttachmentTile
|
|
34
|
+
attachment={{
|
|
35
|
+
id: 1,
|
|
36
|
+
file_name: "receipt.jpg",
|
|
37
|
+
mime_type: "image/jpeg",
|
|
38
|
+
s3_link: null,
|
|
39
|
+
}}
|
|
40
|
+
rawHref="/api/attachments/1/raw"
|
|
41
|
+
testId="attachment"
|
|
42
|
+
/>
|
|
43
|
+
</div>
|
|
44
|
+
</Suspense>
|
|
45
|
+
));
|
|
46
|
+
|
|
47
|
+
expect(screen.getByTestId("modal-shell")).toBeTruthy();
|
|
48
|
+
expect(screen.queryByText("Modal fallback")).toBeNull();
|
|
49
|
+
expect(screen.getByText("Loading")).toBeTruthy();
|
|
50
|
+
|
|
51
|
+
resolveFetch(new Response(new Blob(["image"]), { status: 200 }));
|
|
52
|
+
expect(await screen.findByAltText("receipt.jpg")).toBeTruthy();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -10,14 +10,13 @@
|
|
|
10
10
|
// route and render the resulting blob: (the proxy/blob pattern). s3_link is then
|
|
11
11
|
// ignored for rendering; a spinner shows while the bytes stream.
|
|
12
12
|
|
|
13
|
-
import { Show, createSignal, type Component } from "solid-js";
|
|
13
|
+
import { Show, createEffect, createSignal, onCleanup, type Component } from "solid-js";
|
|
14
14
|
import Paperclip from "lucide-solid/icons/paperclip";
|
|
15
15
|
import X from "lucide-solid/icons/x";
|
|
16
16
|
import TriangleAlert from "lucide-solid/icons/triangle-alert";
|
|
17
17
|
import Loader2 from "lucide-solid/icons/loader-2";
|
|
18
18
|
import { confirm } from "../../utils/confirm";
|
|
19
19
|
import { attachmentUrl, isResolvableAttachment } from "../../utils/attachments";
|
|
20
|
-
import { createObjectUrlResource } from "../../utils/object-url-resource";
|
|
21
20
|
import ImageViewer from "./ImageViewer";
|
|
22
21
|
|
|
23
22
|
export interface ExistingAttachment {
|
|
@@ -40,17 +39,53 @@ interface Props {
|
|
|
40
39
|
}
|
|
41
40
|
|
|
42
41
|
export default function ExistingAttachmentTile(props: Props) {
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
42
|
+
// Keep attachment loading local. A pending createResource promise can suspend
|
|
43
|
+
// the nearest boundary and briefly unmount the transaction modal.
|
|
44
|
+
const [blob, setBlob] = createSignal<string | null>(null);
|
|
45
|
+
const [blobLoading, setBlobLoading] = createSignal(false);
|
|
46
|
+
let requestId = 0;
|
|
47
|
+
let currentUrl: string | null = null;
|
|
48
|
+
const revokeUrl = () => {
|
|
49
|
+
if (currentUrl) URL.revokeObjectURL(currentUrl);
|
|
50
|
+
currentUrl = null;
|
|
51
|
+
};
|
|
52
|
+
createEffect(() => {
|
|
53
|
+
const href = props.rawHref ?? null;
|
|
54
|
+
const id = ++requestId;
|
|
55
|
+
revokeUrl();
|
|
56
|
+
setBlob(null);
|
|
57
|
+
if (!href) {
|
|
58
|
+
setBlobLoading(false);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
setBlobLoading(true);
|
|
62
|
+
void fetch(href, { credentials: "include", ...props.rawInit })
|
|
63
|
+
.then(async (res) => (res.ok ? URL.createObjectURL(await res.blob()) : null))
|
|
64
|
+
.then((url) => {
|
|
65
|
+
if (id !== requestId) {
|
|
66
|
+
if (url) URL.revokeObjectURL(url);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
currentUrl = url;
|
|
70
|
+
setBlob(url);
|
|
71
|
+
})
|
|
72
|
+
.catch(() => {
|
|
73
|
+
if (id === requestId) setBlob(null);
|
|
74
|
+
})
|
|
75
|
+
.finally(() => {
|
|
76
|
+
if (id === requestId) setBlobLoading(false);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
onCleanup(() => {
|
|
80
|
+
requestId++;
|
|
81
|
+
revokeUrl();
|
|
82
|
+
});
|
|
48
83
|
const isBlobMode = () => props.rawHref != null;
|
|
49
84
|
const url = (): string | undefined =>
|
|
50
85
|
isBlobMode() ? (blob() ?? undefined) : attachmentUrl(props.attachment.s3_link);
|
|
51
86
|
const resolvable = () =>
|
|
52
87
|
isBlobMode() ? blob() != null : isResolvableAttachment(props.attachment.s3_link);
|
|
53
|
-
const loading = () => (isBlobMode() ?
|
|
88
|
+
const loading = () => (isBlobMode() ? blobLoading() : false);
|
|
54
89
|
const [viewerOpen, setViewerOpen] = createSignal(false);
|
|
55
90
|
|
|
56
91
|
const FallbackIcon = () => {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { cleanup, fireEvent, render } from "@solidjs/testing-library";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import ImageViewer from "./ImageViewer";
|
|
4
|
+
|
|
5
|
+
describe("ImageViewer", () => {
|
|
6
|
+
afterEach(() => cleanup());
|
|
7
|
+
|
|
8
|
+
it("reserves viewer space before the image loads", () => {
|
|
9
|
+
const showModal = vi.fn();
|
|
10
|
+
Object.defineProperty(HTMLDialogElement.prototype, "showModal", { configurable: true, value: showModal });
|
|
11
|
+
Object.defineProperty(HTMLDialogElement.prototype, "close", { configurable: true, value: vi.fn() });
|
|
12
|
+
const view = render(() => <ImageViewer src="blob:test" alt="Receipt" onClose={vi.fn()} />);
|
|
13
|
+
const frame = view.container.querySelector<HTMLElement>(".ksui-imgviewer-frame");
|
|
14
|
+
const image = view.container.querySelector<HTMLImageElement>("img");
|
|
15
|
+
|
|
16
|
+
expect(frame).toBeTruthy();
|
|
17
|
+
expect(frame?.className).toContain("ksui-imgviewer-frame");
|
|
18
|
+
expect(image?.style.opacity).toBe("0");
|
|
19
|
+
expect(showModal).toHaveBeenCalledTimes(1);
|
|
20
|
+
|
|
21
|
+
fireEvent.load(image!);
|
|
22
|
+
expect(image?.style.opacity).toBe("1");
|
|
23
|
+
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// with unscoped `ksui-imgviewer-*` class names. Mount === open, unmount === closed
|
|
14
14
|
// (wrap in `<Show when={…}>`).
|
|
15
15
|
|
|
16
|
-
import { onCleanup, onMount } from "solid-js";
|
|
16
|
+
import { createSignal, onCleanup, onMount } from "solid-js";
|
|
17
17
|
import X from "lucide-solid/icons/x";
|
|
18
18
|
import { lockPullToRefresh, unlockPullToRefresh } from "../../utils/dom";
|
|
19
19
|
import { injectCSS } from "../../utils/inject-css";
|
|
@@ -26,9 +26,10 @@ const STYLE_CSS = `
|
|
|
26
26
|
.ksui-imgviewer{position:fixed;inset:0;z-index:60;background:transparent;padding:0;margin:0;max-width:none;max-height:none;width:100vw;height:100vh;border:0;}
|
|
27
27
|
.ksui-imgviewer[open]{display:flex;align-items:center;justify-content:center;}
|
|
28
28
|
.ksui-imgviewer::backdrop{background:var(--ks-overlay, rgba(0,0,0,0.7));backdrop-filter:blur(2px);}
|
|
29
|
-
.ksui-imgviewer-
|
|
30
|
-
.ksui-imgviewer-
|
|
31
|
-
.ksui-imgviewer-close:
|
|
29
|
+
.ksui-imgviewer-frame{display:grid;place-items:center;width:96vw;height:96vh;}
|
|
30
|
+
.ksui-imgviewer-img{display:block;max-width:100%;max-height:100%;object-fit:contain;border-radius:0.25rem;box-shadow:0 25px 50px -12px color-mix(in srgb,var(--ks-overlay,rgba(0,0,0,0.7)) 80%,transparent);}
|
|
31
|
+
.ksui-imgviewer-close{position:absolute;top:1rem;right:1rem;display:flex;width:2.5rem;height:2.5rem;align-items:center;justify-content:center;border-radius:9999px;background:color-mix(in srgb,var(--ks-fg,#ffffff) 12%,transparent);color:var(--ks-fg,#ffffff);border:0;cursor:pointer;}
|
|
32
|
+
.ksui-imgviewer-close:hover{background:color-mix(in srgb,var(--ks-fg,#ffffff) 22%,transparent);}
|
|
32
33
|
`;
|
|
33
34
|
|
|
34
35
|
interface Props {
|
|
@@ -43,6 +44,7 @@ interface Props {
|
|
|
43
44
|
export default function ImageViewer(props: Props) {
|
|
44
45
|
injectCSS(STYLE_ID, STYLE_CSS);
|
|
45
46
|
let dialogEl: HTMLDialogElement | undefined;
|
|
47
|
+
const [loaded, setLoaded] = createSignal(false);
|
|
46
48
|
|
|
47
49
|
lockPullToRefresh();
|
|
48
50
|
onCleanup(unlockPullToRefresh);
|
|
@@ -89,7 +91,15 @@ export default function ImageViewer(props: Props) {
|
|
|
89
91
|
>
|
|
90
92
|
<X size={22} />
|
|
91
93
|
</button>
|
|
92
|
-
<
|
|
94
|
+
<div class="ksui-imgviewer-frame" aria-busy={!loaded()}>
|
|
95
|
+
<img
|
|
96
|
+
src={props.src}
|
|
97
|
+
alt={props.alt || ""}
|
|
98
|
+
class="ksui-imgviewer-img"
|
|
99
|
+
style={{ opacity: loaded() ? 1 : 0 }}
|
|
100
|
+
onLoad={() => setLoaded(true)}
|
|
101
|
+
/>
|
|
102
|
+
</div>
|
|
93
103
|
</dialog>
|
|
94
104
|
);
|
|
95
105
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { createEffect, Show, For } from "solid-js";
|
|
2
2
|
import AccountAvatar from "../base/AccountAvatar";
|
|
3
|
-
import Button from "../base/Button";
|
|
4
3
|
import type { PaymentAccountOption } from "./PaymentAccountPicker";
|
|
5
4
|
|
|
6
5
|
export interface AccountRadioPickerProps {
|
|
@@ -10,8 +9,6 @@ export interface AccountRadioPickerProps {
|
|
|
10
9
|
ariaLabel: string;
|
|
11
10
|
excludeId?: string;
|
|
12
11
|
autoDefault?: boolean;
|
|
13
|
-
compact?: boolean;
|
|
14
|
-
tone?: "income" | "expense" | "transfer";
|
|
15
12
|
}
|
|
16
13
|
|
|
17
14
|
export default function AccountRadioPicker(props: AccountRadioPickerProps) {
|
|
@@ -85,37 +82,24 @@ export default function AccountRadioPicker(props: AccountRadioPickerProps) {
|
|
|
85
82
|
aria-label={props.ariaLabel}
|
|
86
83
|
tabIndex={-1}
|
|
87
84
|
class="grid max-sm:grid-cols-2 sm:grid-cols-3 gap-2"
|
|
88
|
-
classList={{ "ks-transaction-account-picker": props.compact }}
|
|
89
85
|
onKeyDown={onKeyDown}
|
|
90
86
|
>
|
|
91
87
|
<For each={visible()}>
|
|
92
88
|
{(a, i) => {
|
|
93
89
|
const selected = () => props.value === a.id.toString();
|
|
94
|
-
const isTabStop = () => selected() || (
|
|
90
|
+
const isTabStop = () => selected() || currentIndex() === i();
|
|
95
91
|
return (
|
|
96
|
-
<
|
|
97
|
-
ref={(el
|
|
92
|
+
<button
|
|
93
|
+
ref={(el) => (buttonRefs[i()] = el)}
|
|
98
94
|
type="button"
|
|
99
95
|
role="radio"
|
|
100
96
|
aria-checked={selected()}
|
|
101
97
|
tabIndex={isTabStop() ? 0 : -1}
|
|
102
|
-
intent="secondary"
|
|
103
|
-
variant="clip1"
|
|
104
|
-
size="sm"
|
|
105
|
-
noGlow
|
|
106
|
-
noRipple
|
|
107
|
-
noScanline
|
|
108
98
|
onClick={() => props.onChange(a.id.toString())}
|
|
109
|
-
class="group flex items-center gap-2 px-3 py-3 text-left text-sm transition-colors cursor-pointer"
|
|
99
|
+
class="group flex items-center gap-2 rounded-lg border px-3 py-3 text-left text-sm transition-colors cursor-pointer ks-hud-clip-top-left-bottom-right"
|
|
110
100
|
classList={{
|
|
111
101
|
"border-[color-mix(in_srgb,var(--ks-accent,#fbbf24)_50%,transparent)] bg-[color-mix(in_srgb,var(--ks-accent,#fbbf24)_10%,transparent)] text-[var(--ks-accent-hover,#fcd34d)]":
|
|
112
|
-
selected()
|
|
113
|
-
"border-[color-mix(in_srgb,var(--ks-success,#10b981)_50%,transparent)] bg-[color-mix(in_srgb,var(--ks-success,#10b981)_10%,transparent)] text-[var(--ks-success-fg,#34d399)]":
|
|
114
|
-
selected() && props.compact && props.tone === "income",
|
|
115
|
-
"border-[color-mix(in_srgb,var(--ks-danger,#ef4444)_50%,transparent)] bg-[color-mix(in_srgb,var(--ks-danger,#ef4444)_10%,transparent)] text-[var(--ks-danger-fg,#f87171)]":
|
|
116
|
-
selected() && props.compact && props.tone === "expense",
|
|
117
|
-
"border-[color-mix(in_srgb,var(--ks-info,#38bdf8)_50%,transparent)] bg-[color-mix(in_srgb,var(--ks-info,#38bdf8)_10%,transparent)] text-[var(--ks-info,#38bdf8)]":
|
|
118
|
-
selected() && props.compact && props.tone === "transfer",
|
|
102
|
+
selected(),
|
|
119
103
|
"border-[var(--ks-border-strong,#3f3f46)] bg-[color-mix(in_srgb,var(--ks-surface-raised,#1a1a1a)_50%,transparent)] text-[var(--ks-fg-muted,#a1a1aa)] hover:border-[var(--ks-border-strong,#3f3f46)] hover:bg-[var(--ks-surface-raised,#1a1a1a)]":
|
|
120
104
|
!selected(),
|
|
121
105
|
}}
|
|
@@ -130,7 +114,7 @@ export default function AccountRadioPicker(props: AccountRadioPickerProps) {
|
|
|
130
114
|
}
|
|
131
115
|
/>
|
|
132
116
|
<span class="truncate">{a.name}</span>
|
|
133
|
-
</
|
|
117
|
+
</button>
|
|
134
118
|
);
|
|
135
119
|
}}
|
|
136
120
|
</For>
|
|
@@ -23,7 +23,6 @@ export interface TransactionAccountFieldsProps {
|
|
|
23
23
|
transferFeeAmount: string;
|
|
24
24
|
transferFeeEnabled: boolean;
|
|
25
25
|
allowTransferFee: boolean;
|
|
26
|
-
compact?: boolean;
|
|
27
26
|
}
|
|
28
27
|
|
|
29
28
|
/**
|
|
@@ -44,8 +43,6 @@ export default function TransactionAccountFields(props: TransactionAccountFields
|
|
|
44
43
|
value={
|
|
45
44
|
props.category === "sale" ? props.destAccount : props.sourceAccount
|
|
46
45
|
}
|
|
47
|
-
compact={props.compact}
|
|
48
|
-
tone={props.category === "sale" ? "income" : "expense"}
|
|
49
46
|
onChange={(v) => {
|
|
50
47
|
if (props.category === "sale") {
|
|
51
48
|
props.setDestAccount(v);
|
|
@@ -7,13 +7,7 @@
|
|
|
7
7
|
// advanced-fields toggle entirely, for a caller that locks `category` to one
|
|
8
8
|
// value and never needs EWT/sharing (e.g. a "record my own expense" surface).
|
|
9
9
|
|
|
10
|
-
import {
|
|
11
|
-
createEffect,
|
|
12
|
-
createResource,
|
|
13
|
-
createSignal,
|
|
14
|
-
Show,
|
|
15
|
-
For,
|
|
16
|
-
} from "solid-js";
|
|
10
|
+
import { createEffect, createSignal, Show, For } from "solid-js";
|
|
17
11
|
import X from "lucide-solid/icons/x";
|
|
18
12
|
import Upload from "lucide-solid/icons/upload";
|
|
19
13
|
import FileIcon from "lucide-solid/icons/file";
|
|
@@ -306,6 +300,9 @@ export default function TransactionForm(props: TransactionFormProps) {
|
|
|
306
300
|
.ks-transaction-compact-type-switcher{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:.375rem;margin:0 1.25rem .25rem;padding:.375rem;border-radius:.75rem;background:color-mix(in srgb,var(--ks-fg,#ffffff) 5%,transparent)}
|
|
307
301
|
.ks-transaction-compact-type-switcher>button{min-height:2.25rem;border:0;border-radius:.5rem;padding:.5rem .25rem}
|
|
308
302
|
.ks-transaction-compact-type-switcher>button.ks-transaction-type-active{background:color-mix(in srgb,var(--ks-primary,#c9a961) 16%,transparent);color:var(--ks-primary,#c9a961)}
|
|
303
|
+
.ks-transaction-compact-type-switcher>button.ks-transaction-type-sale.ks-transaction-type-active{background:color-mix(in srgb,var(--ks-success,#10b981) 16%,transparent);color:var(--ks-success-fg,#34d399)}
|
|
304
|
+
.ks-transaction-compact-type-switcher>button.ks-transaction-type-expense.ks-transaction-type-active{background:color-mix(in srgb,var(--ks-danger,#ef4444) 16%,transparent);color:var(--ks-danger-fg,#f87171)}
|
|
305
|
+
.ks-transaction-compact-type-switcher>button.ks-transaction-type-business.ks-transaction-type-active{background:color-mix(in srgb,var(--ks-info,#38bdf8) 16%,transparent);color:var(--ks-info,#38bdf8)}
|
|
309
306
|
.ks-transaction-compact-body{display:flex!important;flex:1 1 auto!important;flex-direction:column!important;width:100%!important;min-height:0!important;overflow-x:hidden!important;overflow-y:auto!important;padding:1rem 1.25rem 1.5rem!important;gap:1rem}
|
|
310
307
|
.ks-transaction-compact-body>div{width:100%!important;flex:0 0 auto!important}
|
|
311
308
|
.ks-transaction-compact-footer{flex:0 0 auto!important}
|
|
@@ -360,23 +357,43 @@ export default function TransactionForm(props: TransactionFormProps) {
|
|
|
360
357
|
return "expense";
|
|
361
358
|
return null;
|
|
362
359
|
};
|
|
363
|
-
const [subcategoryOptions] =
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
return data.subcategories;
|
|
360
|
+
const [subcategoryOptions, setSubcategoryOptions] = createSignal<
|
|
361
|
+
{ id: number; name: string }[] | undefined
|
|
362
|
+
>(undefined);
|
|
363
|
+
const subcategoryCache = new Map<
|
|
364
|
+
"income" | "expense",
|
|
365
|
+
{ id: number; name: string }[]
|
|
366
|
+
>();
|
|
367
|
+
let subcategoryRequestId = 0;
|
|
368
|
+
createEffect(() => {
|
|
369
|
+
const appliesTo = subcategoryAppliesTo();
|
|
370
|
+
const requestId = ++subcategoryRequestId;
|
|
371
|
+
if (!appliesTo) {
|
|
372
|
+
setSubcategoryOptions(undefined);
|
|
373
|
+
return;
|
|
378
374
|
}
|
|
379
|
-
|
|
375
|
+
const cached = subcategoryCache.get(appliesTo);
|
|
376
|
+
if (cached) {
|
|
377
|
+
setSubcategoryOptions(cached);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
setSubcategoryOptions(undefined);
|
|
381
|
+
void fetch(`/api/transactions/subcategories?applies_to=${appliesTo}`, {
|
|
382
|
+
credentials: "include",
|
|
383
|
+
})
|
|
384
|
+
.then(async (res) => {
|
|
385
|
+
if (!res.ok) return [] as { id: number; name: string }[];
|
|
386
|
+
const data = (await res.json()) as {
|
|
387
|
+
subcategories: { id: number; name: string }[];
|
|
388
|
+
};
|
|
389
|
+
return data.subcategories;
|
|
390
|
+
})
|
|
391
|
+
.catch(() => [] as { id: number; name: string }[])
|
|
392
|
+
.then((options) => {
|
|
393
|
+
subcategoryCache.set(appliesTo, options);
|
|
394
|
+
if (requestId === subcategoryRequestId) setSubcategoryOptions(options);
|
|
395
|
+
});
|
|
396
|
+
});
|
|
380
397
|
|
|
381
398
|
// True once the async resource has resolved at least once. Gates the
|
|
382
399
|
// SearchableSelect mount so the loading-state placeholder shows while the
|
|
@@ -471,7 +488,7 @@ export default function TransactionForm(props: TransactionFormProps) {
|
|
|
471
488
|
</div>
|
|
472
489
|
</Show>
|
|
473
490
|
|
|
474
|
-
<Show when={
|
|
491
|
+
<Show when={!props.simpleMode}>
|
|
475
492
|
<div class="ks-transaction-compact-type-switcher" role="tablist" aria-label="Transaction type">
|
|
476
493
|
<For each={categoryOptions()}>
|
|
477
494
|
{(cat) => (
|
|
@@ -726,48 +743,36 @@ export default function TransactionForm(props: TransactionFormProps) {
|
|
|
726
743
|
|
|
727
744
|
<Show when={subcategoryAppliesTo() !== null}>
|
|
728
745
|
<FormField label="Category">
|
|
729
|
-
<
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
label: opt.name,
|
|
749
|
-
}));
|
|
750
|
-
list.unshift({ value: "", label: "— Uncategorised —" });
|
|
751
|
-
if (
|
|
752
|
-
props.subcategory &&
|
|
753
|
-
!list.some((o) => o.value === props.subcategory)
|
|
754
|
-
) {
|
|
755
|
-
list.push({
|
|
756
|
-
value: props.subcategory,
|
|
757
|
-
label: props.subcategory,
|
|
758
|
-
});
|
|
759
|
-
}
|
|
760
|
-
return list;
|
|
761
|
-
})()}
|
|
762
|
-
onChange={(opt) =>
|
|
763
|
-
props.setSubcategory(opt ? String(opt.value) : "")
|
|
746
|
+
<SearchableSelect
|
|
747
|
+
triggerTestId="subcategory-select"
|
|
748
|
+
wrapperClass="relative w-full"
|
|
749
|
+
value={props.subcategory}
|
|
750
|
+
loading={!subcategoryOptionsReady()}
|
|
751
|
+
options={(() => {
|
|
752
|
+
const list = (subcategoryOptions() || []).map((opt) => ({
|
|
753
|
+
value: opt.name,
|
|
754
|
+
label: opt.name,
|
|
755
|
+
}));
|
|
756
|
+
list.unshift({ value: "", label: "— Uncategorised —" });
|
|
757
|
+
if (
|
|
758
|
+
props.subcategory &&
|
|
759
|
+
!list.some((o) => o.value === props.subcategory)
|
|
760
|
+
) {
|
|
761
|
+
list.push({
|
|
762
|
+
value: props.subcategory,
|
|
763
|
+
label: props.subcategory,
|
|
764
|
+
});
|
|
764
765
|
}
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
766
|
+
return list;
|
|
767
|
+
})()}
|
|
768
|
+
onChange={(opt) =>
|
|
769
|
+
props.setSubcategory(opt ? String(opt.value) : "")
|
|
770
|
+
}
|
|
771
|
+
placeholder="— Uncategorised —"
|
|
772
|
+
searchPlaceholder="Search categories…"
|
|
773
|
+
triggerClass="w-full bg-[color-mix(in_srgb,var(--ks-overlay-surface,#18181b)_60%,transparent)] border border-[color-mix(in_srgb,var(--ks-border,rgba(39,39,42,0.5))_60%,transparent)] px-3 py-3 text-sm text-[var(--ks-fg,#ffffff)] ks-hud-clip-button cursor-pointer focus:outline-none focus:border-[color-mix(in_srgb,var(--ks-focus-ring,#c9a961)_50%,transparent)] flex items-center justify-between gap-2"
|
|
774
|
+
triggerLabelClass="truncate text-left flex-1 min-w-0"
|
|
775
|
+
/>
|
|
771
776
|
<p class="text-[10px] text-[var(--ks-fg-subtle,#71717a)] mt-0.5">
|
|
772
777
|
Optional. Used for tax-prep classification.
|
|
773
778
|
</p>
|
|
@@ -803,7 +808,6 @@ export default function TransactionForm(props: TransactionFormProps) {
|
|
|
803
808
|
transferFeeAmount={props.transferFeeAmount}
|
|
804
809
|
transferFeeEnabled={props.transferFeeEnabled}
|
|
805
810
|
allowTransferFee={props.allowTransferFee}
|
|
806
|
-
compact={compact()}
|
|
807
811
|
/>
|
|
808
812
|
</div>
|
|
809
813
|
|
|
@@ -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
|
|
|
@@ -401,6 +402,148 @@ describe("VoucherPicker dialog", () => {
|
|
|
401
402
|
fireEvent.click(getByTestId("voucher-picker-trigger"));
|
|
402
403
|
await waitFor(() => expect(getByTestId("voucher-picker-result-35")).toBeTruthy());
|
|
403
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
|
+
|
|
404
547
|
it("rejects a voucher when aligned package lineage does not match", async () => {
|
|
405
548
|
mockPagedFetch([
|
|
406
549
|
voucher({
|
|
@@ -426,6 +569,78 @@ describe("VoucherPicker dialog", () => {
|
|
|
426
569
|
"Doesn't cover every item",
|
|
427
570
|
);
|
|
428
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
|
+
});
|
|
429
644
|
it("surfaces a load failure instead of rendering an empty list", async () => {
|
|
430
645
|
vi.stubGlobal(
|
|
431
646
|
"fetch",
|
|
@@ -445,6 +660,31 @@ describe("VoucherPicker dialog", () => {
|
|
|
445
660
|
);
|
|
446
661
|
});
|
|
447
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
|
+
});
|
|
448
688
|
it("reopening discards a pick that was staged but never confirmed", async () => {
|
|
449
689
|
mockPagedFetch([voucher({ id: 40, code: "STAGED" }), voucher({ id: 41, code: "OTHER" })]);
|
|
450
690
|
const onChange = vi.fn();
|
|
@@ -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
|
-
|
|
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
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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={
|
|
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;
|