@jskit-ai/http-web 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/fixtures/crud-list-date-filters/App.vue +107 -0
  2. package/fixtures/crud-list-date-filters/index.html +12 -0
  3. package/fixtures/crud-list-date-filters/main.js +24 -0
  4. package/fixtures/crud-list-date-filters/vite.config.mjs +18 -0
  5. package/package.json +150 -0
  6. package/src/client/bulkActions.js +47 -0
  7. package/src/client/components/CrudAddEditScreen.vue +186 -0
  8. package/src/client/components/CrudDeleteAction.vue +93 -0
  9. package/src/client/components/CrudListBulkActionSurface.vue +126 -0
  10. package/src/client/components/CrudListDateFilterControl.vue +188 -0
  11. package/src/client/components/CrudListFilterSurface.vue +388 -0
  12. package/src/client/components/CrudListRecordActionMenu.vue +120 -0
  13. package/src/client/components/CrudListScreen.vue +501 -0
  14. package/src/client/components/CrudViewScreen.vue +197 -0
  15. package/src/client/composables/crud/crudBindingSupport.js +75 -0
  16. package/src/client/composables/crud/crudHttpClientSupport.js +57 -0
  17. package/src/client/composables/crud/crudJsonApiTransportSupport.js +145 -0
  18. package/src/client/composables/crud/crudLookupFieldLabelSupport.js +151 -0
  19. package/src/client/composables/crud/crudLookupFieldRuntime.js +251 -0
  20. package/src/client/composables/crud/crudSchemaFormHelpers.js +464 -0
  21. package/src/client/composables/internal/crudListFilterLookupSupport.js +161 -0
  22. package/src/client/composables/internal/crudListParentTitleSupport.js +171 -0
  23. package/src/client/composables/internal/useOperationScope.js +144 -0
  24. package/src/client/composables/records/useAddEdit.js +224 -0
  25. package/src/client/composables/records/useCrudAddEdit.js +227 -0
  26. package/src/client/composables/records/useCrudList.js +101 -0
  27. package/src/client/composables/records/useCrudView.js +47 -0
  28. package/src/client/composables/records/useList.js +500 -0
  29. package/src/client/composables/records/useView.js +164 -0
  30. package/src/client/composables/runtime/addEditUiRuntime.js +130 -0
  31. package/src/client/composables/runtime/listUiRuntime.js +123 -0
  32. package/src/client/composables/runtime/modelStateHelpers.js +90 -0
  33. package/src/client/composables/runtime/operationAdapters.js +34 -0
  34. package/src/client/composables/runtime/operationUiHelpers.js +125 -0
  35. package/src/client/composables/runtime/operationValidationHelpers.js +51 -0
  36. package/src/client/composables/runtime/useAddEditCore.js +112 -0
  37. package/src/client/composables/runtime/useCommandCore.js +123 -0
  38. package/src/client/composables/runtime/useEndpointResource.js +199 -0
  39. package/src/client/composables/runtime/useFieldErrorBag.js +61 -0
  40. package/src/client/composables/runtime/useListCore.js +112 -0
  41. package/src/client/composables/runtime/useUiFeedback.js +98 -0
  42. package/src/client/composables/runtime/useViewCore.js +93 -0
  43. package/src/client/composables/runtime/viewUiRuntime.js +96 -0
  44. package/src/client/composables/support/errorMessageHelpers.js +66 -0
  45. package/src/client/composables/support/listQueryParamSupport.js +473 -0
  46. package/src/client/composables/support/listSearchSupport.js +70 -0
  47. package/src/client/composables/support/refValueHelpers.js +19 -0
  48. package/src/client/composables/support/requestQueryRuntimeSupport.js +141 -0
  49. package/src/client/composables/support/resourceLoadStateHelpers.js +208 -0
  50. package/src/client/composables/support/routeTemplateHelpers.js +285 -0
  51. package/src/client/composables/support/scopeHelpers.js +145 -0
  52. package/src/client/composables/useAccess.js +109 -0
  53. package/src/client/composables/useCommand.js +118 -0
  54. package/src/client/composables/useCrudAddEditScreen.js +88 -0
  55. package/src/client/composables/useCrudDeleteAction.js +160 -0
  56. package/src/client/composables/useCrudListBulkActions.js +147 -0
  57. package/src/client/composables/useCrudListFilterLookups.js +146 -0
  58. package/src/client/composables/useCrudListFilters.js +339 -0
  59. package/src/client/composables/useCrudListParentTitle.js +151 -0
  60. package/src/client/composables/useCrudListRowActions.js +144 -0
  61. package/src/client/composables/useCrudListScreen.js +237 -0
  62. package/src/client/composables/useCrudViewScreen.js +77 -0
  63. package/src/client/composables/usePagedCollection.js +181 -0
  64. package/src/client/composables/useRealtimeQueryInvalidation.js +156 -0
  65. package/src/client/composables/useScopeRuntime.js +135 -0
  66. package/src/client/filters.js +15 -0
  67. package/src/client/index.js +24 -0
  68. package/src/client/lib/httpClient.js +47 -0
  69. package/src/client/lib/permissions.js +34 -0
  70. package/src/client/rowActions.js +47 -0
  71. package/src/client/support/contractGuards.js +34 -0
  72. package/src/client/support/crudListDateFilterSupport.js +91 -0
  73. package/test/addEditUiRuntime.test.js +110 -0
  74. package/test/crudBindingSupport.test.js +110 -0
  75. package/test/crudHttpClientSupport.test.js +77 -0
  76. package/test/crudJsonApiTransportSupport.test.js +179 -0
  77. package/test/crudListBulkActionSurface.test.js +27 -0
  78. package/test/crudListDateFilterSupport.test.js +92 -0
  79. package/test/crudListDateFilterSurface.browser.test.js +182 -0
  80. package/test/crudListFilterSurface.test.js +90 -0
  81. package/test/crudLookupFieldRuntime.test.js +264 -0
  82. package/test/crudScreenComponents.test.js +110 -0
  83. package/test/errorIntentContract.test.js +31 -0
  84. package/test/errorMessageHelpers.test.js +28 -0
  85. package/test/exportsContract.test.js +68 -0
  86. package/test/listQueryParamSupport.test.js +223 -0
  87. package/test/listUiRuntime.test.js +102 -0
  88. package/test/operationValidationHelpers.test.js +64 -0
  89. package/test/packageBoundary.test.js +31 -0
  90. package/test/permissions.test.js +35 -0
  91. package/test/refValueHelpers.test.js +14 -0
  92. package/test/requestTransportOptions.test.js +457 -0
  93. package/test/resourceLoadStateHelpers.test.js +189 -0
  94. package/test/routeTemplateHelpers.test.js +85 -0
  95. package/test/scopeHelpers.test.js +57 -0
  96. package/test/useAddEditCore.test.js +124 -0
  97. package/test/useAddEditRequestQueryParams.test.js +190 -0
  98. package/test/useCrudAddEdit.test.js +415 -0
  99. package/test/useCrudDeleteAction.test.js +139 -0
  100. package/test/useCrudListBulkActions.test.js +65 -0
  101. package/test/useCrudListFilterLookups.test.js +114 -0
  102. package/test/useCrudListFilters.test.js +293 -0
  103. package/test/useCrudListParentTitle.test.js +262 -0
  104. package/test/useCrudListRowActions.test.js +77 -0
  105. package/test/useListRouteInitialization.test.js +337 -0
  106. package/test/useListSearchSupport.test.js +61 -0
  107. package/test/usePagedCollection.test.js +53 -0
  108. package/test/useViewRequestQueryParams.test.js +20 -0
  109. package/test/viewCoreLoading.test.js +44 -0
  110. package/test/viewUiRuntime.test.js +95 -0
@@ -0,0 +1,182 @@
1
+ import assert from "node:assert/strict";
2
+ import path from "node:path";
3
+ import process from "node:process";
4
+ import test from "node:test";
5
+ import { fileURLToPath } from "node:url";
6
+ import { chromium } from "@playwright/test";
7
+ import {
8
+ createChromiumLaunchOptions,
9
+ startViteFixture,
10
+ stopProcess
11
+ } from "../../../tooling/testUtils/browserFixture.mjs";
12
+
13
+ const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
14
+ const PACKAGE_ROOT = path.resolve(TEST_DIRECTORY, "..");
15
+ const FIXTURE_ROOT = path.join(PACKAGE_ROOT, "fixtures", "crud-list-date-filters");
16
+ const RUN_BROWSER_TEST = process.env.JSKIT_HTTP_WEB_DATE_FILTER_VISUAL_INTEGRATION === "1";
17
+ const VIEWPORT_WIDTHS = Object.freeze([375, 768, 1280, 1440]);
18
+ const QUERY = "?status=active&submittedOn=2026-04-18&arrivalDate=2026-05-04..2026-05-10";
19
+
20
+ async function readContract(page) {
21
+ return JSON.parse(await page.getByTestId("date-filter-contract").textContent());
22
+ }
23
+
24
+ async function openCompactFilters(page) {
25
+ const openButton = page.getByRole("button", { name: /^Filters/u });
26
+ if (await openButton.isVisible()) {
27
+ await openButton.click();
28
+ await page.getByRole("dialog").waitFor({ state: "visible" });
29
+ }
30
+ }
31
+
32
+ async function assertResponsiveDateControls(page, width) {
33
+ await page.setViewportSize({ width, height: 900 });
34
+ await page.goto(`/${QUERY}`, {
35
+ waitUntil: "networkidle"
36
+ });
37
+ await openCompactFilters(page);
38
+
39
+ for (const name of ["Submitted date", "Arrival from", "Arrival to"]) {
40
+ const field = page.getByLabel(name, { exact: true });
41
+ await field.waitFor({ state: "visible" });
42
+ const box = await field.locator("xpath=ancestor::*[contains(@class, 'v-input')][1]").boundingBox();
43
+ assert.ok(box, `${name} is missing a visible field at ${width}px.`);
44
+ assert.ok(box.width >= 220, `${name} is only ${box.width}px wide at ${width}px.`);
45
+ assert.ok(box.x >= 0 && box.x + box.width <= width, `${name} overflows at ${width}px.`);
46
+ }
47
+
48
+ const layout = await page.evaluate(() => ({
49
+ clientWidth: document.documentElement.clientWidth,
50
+ scrollWidth: document.documentElement.scrollWidth,
51
+ clippedFields: Array.from(document.querySelectorAll(".crud-list-date-filter-control .v-field"))
52
+ .filter((field) => field.scrollWidth > field.clientWidth + 1)
53
+ .length,
54
+ visibleCalendarIcons: Array.from(document.querySelectorAll(
55
+ ".crud-list-date-filter-control .v-field__append-inner"
56
+ )).filter((icon) => {
57
+ const rect = icon.getBoundingClientRect();
58
+ return rect.width > 0 && rect.right <= window.innerWidth;
59
+ }).length
60
+ }));
61
+ assert.equal(layout.scrollWidth, layout.clientWidth, `Horizontal overflow at ${width}px.`);
62
+ assert.equal(layout.clippedFields, 0, `A date field is clipped at ${width}px.`);
63
+ assert.equal(layout.visibleCalendarIcons, 3, `Calendar affordance missing at ${width}px.`);
64
+
65
+ const screenshot = await page.screenshot({ fullPage: true });
66
+ assert.ok(screenshot.length > 5_000, `Visual evidence was empty at ${width}px.`);
67
+ }
68
+
69
+ async function selectPickerDayWithKeyboard(picker, day) {
70
+ const dayButton = picker
71
+ .locator(".v-date-picker-month__day-btn")
72
+ .filter({ hasText: new RegExp(`^${day}$`, "u") })
73
+ .first();
74
+ await dayButton.focus();
75
+ await dayButton.press("Space");
76
+ }
77
+
78
+ test("CRUD date filters render and operate without clipping across responsive layouts", {
79
+ skip: RUN_BROWSER_TEST
80
+ ? false
81
+ : "set JSKIT_HTTP_WEB_DATE_FILTER_VISUAL_INTEGRATION=1 to run the browser visual regression",
82
+ timeout: 180_000
83
+ }, async () => {
84
+ const viteRuntime = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
85
+ let browser = null;
86
+
87
+ try {
88
+ browser = await chromium.launch(createChromiumLaunchOptions());
89
+ const context = await browser.newContext({
90
+ baseURL: viteRuntime.baseURL,
91
+ locale: "en-US"
92
+ });
93
+ const page = await context.newPage();
94
+
95
+ for (const width of VIEWPORT_WIDTHS) {
96
+ await assertResponsiveDateControls(page, width);
97
+ }
98
+
99
+ await page.setViewportSize({ width: 1280, height: 900 });
100
+ await page.goto(`/${QUERY}`, { waitUntil: "networkidle" });
101
+ assert.deepEqual(await readContract(page), {
102
+ values: {
103
+ submittedOn: "2026-04-18",
104
+ arrivalDate: { from: "2026-05-04", to: "2026-05-10" }
105
+ },
106
+ query: {
107
+ submittedOn: "2026-04-18",
108
+ arrivalDate: "2026-05-04..2026-05-10"
109
+ }
110
+ });
111
+ await page.getByText("Submitted date: Apr 18, 2026", { exact: true }).waitFor();
112
+ await page.getByText("Arrival: May 4, 2026 to May 10, 2026", { exact: true }).waitFor();
113
+
114
+ const submittedField = page.getByLabel("Submitted date", { exact: true });
115
+ await submittedField.focus();
116
+ await submittedField.press("Enter");
117
+ const submittedPicker = page.getByRole("dialog", { name: "Submitted date calendar" });
118
+ await submittedPicker.waitFor({ state: "visible" });
119
+ await selectPickerDayWithKeyboard(submittedPicker, 21);
120
+ assert.equal((await readContract(page)).values.submittedOn, "2026-04-21");
121
+ assert.equal((await readContract(page)).query.submittedOn, "2026-04-21");
122
+ assert.equal(await submittedField.evaluate((element) => element === document.activeElement), true);
123
+
124
+ const arrivalToField = page.getByLabel("Arrival to", { exact: true });
125
+ await arrivalToField.focus();
126
+ await arrivalToField.press("ArrowDown");
127
+ const arrivalToPicker = page.getByRole("dialog", { name: "Arrival to calendar" });
128
+ await arrivalToPicker.waitFor({ state: "visible" });
129
+ await selectPickerDayWithKeyboard(arrivalToPicker, 12);
130
+ assert.deepEqual((await readContract(page)).values.arrivalDate, {
131
+ from: "2026-05-04",
132
+ to: "2026-05-12"
133
+ });
134
+ assert.equal((await readContract(page)).query.arrivalDate, "2026-05-04..2026-05-12");
135
+
136
+ await arrivalToField.focus();
137
+ await arrivalToField.press("Enter");
138
+ const clearButton = page
139
+ .getByRole("dialog", { name: "Arrival to calendar" })
140
+ .getByRole("button", { name: "Clear", exact: true });
141
+ await clearButton.focus();
142
+ await clearButton.press("Space");
143
+ assert.deepEqual((await readContract(page)).values.arrivalDate, {
144
+ from: "2026-05-04",
145
+ to: ""
146
+ });
147
+ assert.equal((await readContract(page)).query.arrivalDate, "2026-05-04..");
148
+ assert.equal(await arrivalToField.evaluate((element) => element === document.activeElement), true);
149
+
150
+ const arrivalFromField = page.getByLabel("Arrival from", { exact: true });
151
+ await arrivalFromField.focus();
152
+ await arrivalFromField.press(" ");
153
+ await page.getByRole("dialog", { name: "Arrival from calendar" }).waitFor();
154
+ await page.keyboard.press("Escape");
155
+ assert.equal(await arrivalFromField.evaluate((element) => element === document.activeElement), true);
156
+
157
+ const clearAll = page.getByRole("button", { name: "Clear all", exact: true });
158
+ await clearAll.focus();
159
+ await clearAll.press("Space");
160
+ assert.deepEqual((await readContract(page)).values, {
161
+ submittedOn: "",
162
+ arrivalDate: { from: "", to: "" }
163
+ });
164
+
165
+ await page.setViewportSize({ width: 375, height: 812 });
166
+ await page.reload({ waitUntil: "networkidle" });
167
+ const openFilters = page.getByRole("button", { name: /^Filters/u });
168
+ const openButtonBox = await openFilters.boundingBox();
169
+ assert.ok(openButtonBox && openButtonBox.height >= 48);
170
+ await openFilters.click();
171
+ const mobileSheet = page.getByRole("dialog");
172
+ await mobileSheet.waitFor({ state: "visible" });
173
+ const mobileSheetBox = await mobileSheet.boundingBox();
174
+ assert.ok(mobileSheetBox && mobileSheetBox.width <= 375);
175
+ await page.getByLabel("Arrival from", { exact: true }).waitFor({ state: "visible" });
176
+ } finally {
177
+ if (browser) {
178
+ await browser.close();
179
+ }
180
+ await stopProcess(viteRuntime);
181
+ }
182
+ });
@@ -0,0 +1,90 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import test from "node:test";
5
+ import { fileURLToPath } from "node:url";
6
+ import { compileScript, compileTemplate, parse } from "@vue/compiler-sfc";
7
+
8
+ const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
9
+ const PACKAGE_DIR = path.resolve(TEST_DIRECTORY, "..");
10
+
11
+ test("http-web exposes client-side CRUD list filter definition helpers", async () => {
12
+ const { defineCrudListFilters } = await import("@jskit-ai/http-web/client/filters");
13
+
14
+ const filters = defineCrudListFilters({
15
+ status: {
16
+ type: "enum",
17
+ label: "Status",
18
+ options: [
19
+ { value: "active", label: "Active" }
20
+ ]
21
+ }
22
+ });
23
+
24
+ assert.equal(filters.status.queryKey, "status");
25
+ assert.equal(filters.status.options[0].label, "Active");
26
+ });
27
+
28
+ test("CrudListFilterSurface provides adaptive controls without owning server semantics", async () => {
29
+ const source = await readFile(
30
+ path.join(PACKAGE_DIR, "src", "client", "components", "CrudListFilterSurface.vue"),
31
+ "utf8"
32
+ );
33
+
34
+ assert.match(source, /defineProps/);
35
+ assert.match(source, /useDisplay/);
36
+ assert.match(source, /v-if="shouldRender"/);
37
+ assert.match(source, /filterEntries/);
38
+ assert.match(source, /runtimeValues\[filter\.key\]/);
39
+ assert.match(source, /activeChips/);
40
+ assert.match(source, /clearChip/);
41
+ assert.match(source, /clearFilters/);
42
+ assert.match(source, /v-dialog/);
43
+ assert.match(source, /min-height:\s*48px/);
44
+ assert.match(source, /CrudListDateFilterControl/);
45
+ assert.match(source, /crud-list-filter-surface__range--date/);
46
+ assert.match(source, /grid-column:\s*span 2/);
47
+ assert.doesNotMatch(source, /type="date"/);
48
+ assert.doesNotMatch(source, /useCrudList\(/);
49
+ assert.doesNotMatch(source, /apiSuffix|server|repository/);
50
+ });
51
+
52
+ test("CrudListDateFilterControl composes stable Vuetify picker components accessibly", async () => {
53
+ const componentPath = path.join(
54
+ PACKAGE_DIR,
55
+ "src",
56
+ "client",
57
+ "components",
58
+ "CrudListDateFilterControl.vue"
59
+ );
60
+ const source = await readFile(componentPath, "utf8");
61
+
62
+ assert.match(source, /<v-menu/);
63
+ assert.match(source, /<v-text-field/);
64
+ assert.match(source, /<v-date-picker/);
65
+ assert.match(source, /append-inner-icon="\$calendar"/);
66
+ assert.match(source, /:aria-label="label"/);
67
+ assert.match(source, /aria-haspopup="dialog"/);
68
+ assert.match(source, /v-bind="\{ \.\.\.attrs, \.\.\.activatorProps \}"/);
69
+ assert.match(source, /\["Enter", " ", "ArrowDown"\]/);
70
+ assert.match(source, /role="dialog"/);
71
+ assert.match(source, />\s*Clear\s*</u);
72
+ assert.match(source, />\s*Close\s*</u);
73
+ assert.doesNotMatch(source, /VDateInput|vuetify\/labs|type="date"/);
74
+
75
+ const { descriptor, errors: parseErrors } = parse(source, { filename: componentPath });
76
+ assert.deepEqual(parseErrors, []);
77
+ const script = compileScript(descriptor, {
78
+ id: "crud-list-date-filter-control"
79
+ });
80
+ const compiled = compileTemplate({
81
+ id: "crud-list-date-filter-control",
82
+ filename: componentPath,
83
+ source: descriptor.template.content,
84
+ scoped: descriptor.styles.some((style) => style.scoped),
85
+ compilerOptions: {
86
+ bindingMetadata: script.bindings
87
+ }
88
+ });
89
+ assert.deepEqual(compiled.errors, []);
90
+ });
@@ -0,0 +1,264 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ resolveLookupItemLabel,
5
+ resolveLookupFieldDisplayValue,
6
+ resolveRecordTitle
7
+ } from "../src/client/composables/crud/crudLookupFieldLabelSupport.js";
8
+
9
+ test("resolveLookupItemLabel composes name + surname", () => {
10
+ assert.equal(
11
+ resolveLookupItemLabel(
12
+ {
13
+ name: "South",
14
+ surname: "Clinic"
15
+ },
16
+ "name"
17
+ ),
18
+ "South Clinic"
19
+ );
20
+ });
21
+
22
+ test("resolveLookupItemLabel composes firstName + surname", () => {
23
+ assert.equal(
24
+ resolveLookupItemLabel(
25
+ {
26
+ firstName: "Ana",
27
+ surname: "Marin"
28
+ },
29
+ "name"
30
+ ),
31
+ "Ana Marin"
32
+ );
33
+ });
34
+
35
+ test("resolveLookupItemLabel composes firstName + lastName", () => {
36
+ assert.equal(
37
+ resolveLookupItemLabel(
38
+ {
39
+ firstName: "Ana",
40
+ lastName: "Marin"
41
+ },
42
+ "name"
43
+ ),
44
+ "Ana Marin"
45
+ );
46
+ });
47
+
48
+ test("resolveLookupItemLabel falls back to explicit labelKey", () => {
49
+ assert.equal(
50
+ resolveLookupItemLabel(
51
+ {
52
+ clinicName: "Harbor Vet"
53
+ },
54
+ "clinicName"
55
+ ),
56
+ "Harbor Vet"
57
+ );
58
+ });
59
+
60
+ test("resolveLookupItemLabel resolves name when surname is missing", () => {
61
+ assert.equal(
62
+ resolveLookupItemLabel(
63
+ {
64
+ name: "Harbor Vet"
65
+ },
66
+ ""
67
+ ),
68
+ "Harbor Vet"
69
+ );
70
+ });
71
+
72
+ test("resolveLookupItemLabel returns empty when no label fields match", () => {
73
+ assert.equal(resolveLookupItemLabel({ id: 42 }, "name"), "");
74
+ });
75
+
76
+ test("resolveRecordTitle composes name-like fields before fallback key", () => {
77
+ assert.equal(
78
+ resolveRecordTitle(
79
+ {
80
+ firstName: "Ana",
81
+ lastName: "Marin",
82
+ title: "Ignored"
83
+ },
84
+ {
85
+ fallbackKey: "title",
86
+ defaultValue: "Record"
87
+ }
88
+ ),
89
+ "Ana Marin"
90
+ );
91
+ });
92
+
93
+ test("resolveRecordTitle falls back to provided field key", () => {
94
+ assert.equal(
95
+ resolveRecordTitle(
96
+ {
97
+ title: "Harbor Visit"
98
+ },
99
+ {
100
+ fallbackKey: "title",
101
+ defaultValue: "Record"
102
+ }
103
+ ),
104
+ "Harbor Visit"
105
+ );
106
+ });
107
+
108
+ test('resolveRecordTitle falls back to "-" when no title data exists', () => {
109
+ assert.equal(resolveRecordTitle({}, { fallbackKey: "title", defaultValue: "" }), "-");
110
+ });
111
+
112
+ test("resolveLookupFieldDisplayValue returns hydrated label for lookup fields", () => {
113
+ assert.equal(
114
+ resolveLookupFieldDisplayValue(
115
+ {
116
+ vetId: 17,
117
+ lookups: {
118
+ vetId: {
119
+ id: 17,
120
+ name: "Harbor Vet"
121
+ }
122
+ }
123
+ },
124
+ {
125
+ key: "vetId",
126
+ relation: {
127
+ kind: "lookup",
128
+ valueKey: "id",
129
+ labelKey: "name"
130
+ }
131
+ }
132
+ ),
133
+ "Harbor Vet"
134
+ );
135
+ });
136
+
137
+ test("resolveLookupFieldDisplayValue falls back to hydrated valueKey", () => {
138
+ assert.equal(
139
+ resolveLookupFieldDisplayValue(
140
+ {
141
+ vetId: 17,
142
+ lookups: {
143
+ vetId: {
144
+ id: 99
145
+ }
146
+ }
147
+ },
148
+ {
149
+ key: "vetId",
150
+ relation: {
151
+ kind: "lookup",
152
+ valueKey: "id",
153
+ labelKey: "name"
154
+ }
155
+ }
156
+ ),
157
+ 99
158
+ );
159
+ });
160
+
161
+ test("resolveLookupFieldDisplayValue falls back to raw id when lookup is not hydrated", () => {
162
+ assert.equal(
163
+ resolveLookupFieldDisplayValue(
164
+ {
165
+ vetId: 17
166
+ },
167
+ {
168
+ key: "vetId",
169
+ relation: {
170
+ kind: "lookup",
171
+ valueKey: "id",
172
+ labelKey: "name"
173
+ }
174
+ }
175
+ ),
176
+ 17
177
+ );
178
+ });
179
+
180
+ test("resolveLookupFieldDisplayValue supports custom lookup container key", () => {
181
+ assert.equal(
182
+ resolveLookupFieldDisplayValue(
183
+ {
184
+ vetId: 17,
185
+ lookupData: {
186
+ vetId: {
187
+ id: 17,
188
+ name: "Harbor Vet"
189
+ }
190
+ }
191
+ },
192
+ {
193
+ key: "vetId",
194
+ relation: {
195
+ kind: "lookup",
196
+ containerKey: "lookupData",
197
+ valueKey: "id",
198
+ labelKey: "name"
199
+ }
200
+ }
201
+ ),
202
+ "Harbor Vet"
203
+ );
204
+ });
205
+
206
+ test("resolveLookupFieldDisplayValue falls back to alias-keyed lookups for relationship-backed fields", () => {
207
+ assert.equal(
208
+ resolveLookupFieldDisplayValue(
209
+ {
210
+ vetId: "17",
211
+ lookups: {
212
+ vet: {
213
+ id: "17",
214
+ name: "Harbor Vet"
215
+ }
216
+ }
217
+ },
218
+ {
219
+ key: "vetId",
220
+ relation: {
221
+ kind: "lookup",
222
+ valueKey: "id",
223
+ labelKey: "name"
224
+ }
225
+ }
226
+ ),
227
+ "Harbor Vet"
228
+ );
229
+ });
230
+
231
+ test("resolveLookupFieldDisplayValue returns raw value for non-lookup fields", () => {
232
+ assert.equal(
233
+ resolveLookupFieldDisplayValue(
234
+ {
235
+ firstName: "Ana"
236
+ },
237
+ {
238
+ key: "firstName"
239
+ }
240
+ ),
241
+ "Ana"
242
+ );
243
+ });
244
+
245
+ test("resolveLookupFieldDisplayValue supports scalar lookup descriptor arguments", () => {
246
+ assert.equal(
247
+ resolveLookupFieldDisplayValue(
248
+ {
249
+ vetId: 17,
250
+ lookups: {
251
+ vetId: {
252
+ id: 17,
253
+ name: "Harbor Vet"
254
+ }
255
+ }
256
+ },
257
+ "vetId",
258
+ "lookup",
259
+ "id",
260
+ "name"
261
+ ),
262
+ "Harbor Vet"
263
+ );
264
+ });
@@ -0,0 +1,110 @@
1
+ import assert from "node:assert/strict";
2
+ import path from "node:path";
3
+ import test from "node:test";
4
+ import { readFile } from "node:fs/promises";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
8
+ const PACKAGE_DIR = path.resolve(TEST_DIRECTORY, "..");
9
+
10
+ async function readComponent(name) {
11
+ return readFile(path.join(PACKAGE_DIR, "src", "client", "components", name), "utf8");
12
+ }
13
+
14
+ async function readClientFile(...parts) {
15
+ return readFile(path.join(PACKAGE_DIR, "src", "client", ...parts), "utf8");
16
+ }
17
+
18
+ test("CRUD screen components own generated list/view/form chrome centrally", async () => {
19
+ const listSource = await readComponent("CrudListScreen.vue");
20
+ const viewSource = await readComponent("CrudViewScreen.vue");
21
+ const addEditSource = await readComponent("CrudAddEditScreen.vue");
22
+ const deleteActionSource = await readComponent("CrudDeleteAction.vue");
23
+
24
+ assert.match(listSource, /CrudListBulkActionSurface/);
25
+ assert.match(listSource, /CrudListFilterSurface/);
26
+ assert.match(listSource, /CrudListRecordActionMenu/);
27
+ assert.match(listSource, /class="ui-generator-list-cards"/);
28
+ assert.match(listSource, /class="ui-generator-list-table"/);
29
+ assert.match(listSource, /class="ui-generator-list-fab"/);
30
+ assert.doesNotMatch(listSource, /ui-generator-list-cards d-md-none/);
31
+ assert.doesNotMatch(listSource, /ui-generator-list-table d-none d-md-block/);
32
+ assert.doesNotMatch(listSource, /class="ui-generator-list-fab d-md-none"/);
33
+ assert.match(listSource, /\.ui-generator-list-table\s*\{\s*display:\s*none;/);
34
+ assert.match(
35
+ listSource,
36
+ /@media \(min-width: 960px\)[\s\S]*\.ui-generator-list-cards\s*\{\s*display:\s*none;[\s\S]*\.ui-generator-list-table\s*\{\s*display:\s*block;[\s\S]*\.ui-generator-list-fab\s*\{\s*display:\s*none;/
37
+ );
38
+ assert.match(listSource, /button-label="More"/);
39
+ assert.match(listSource, /selectableRows/);
40
+ assert.match(
41
+ listSource,
42
+ /\.ui-generator-list-element :deep\(\.v-btn\)\s*\{\s*min-height:\s*48px;/
43
+ );
44
+ assert.match(listSource, /<slot[\s\S]*name="card-fields"/);
45
+ assert.match(listSource, /<slot name="table-header"/);
46
+ assert.match(listSource, /<slot[\s\S]*name="table-row"/);
47
+ assert.match(listSource, /:row="row"/);
48
+
49
+ assert.match(viewSource, /generated-ui-screen generated-ui-screen--operator ui-generator-view-element/);
50
+ assert.match(viewSource, /ui-generator-view-panel/);
51
+ assert.match(viewSource, /@click="view\.refresh"/);
52
+ assert.match(viewSource, /<slot name="actions" :screen="screen" :view="view" \/>/);
53
+ assert.match(viewSource, /<slot name="before-fields"/);
54
+ assert.match(viewSource, /<slot name="fields"/);
55
+ assert.match(viewSource, /<slot name="after-fields"/);
56
+ assert.match(viewSource, /supporting-content/);
57
+
58
+ assert.match(addEditSource, /generated-ui-screen generated-ui-screen--operator ui-generator-add-edit-form/);
59
+ assert.match(addEditSource, /addEdit\.canRetryLoad/);
60
+ assert.match(addEditSource, /@click="addEdit\.refresh"/);
61
+ assert.match(addEditSource, /<slot[\s\S]*name="fields"/);
62
+
63
+ assert.match(deleteActionSource, /@click="action\.request"/);
64
+ assert.match(deleteActionSource, /@click="action\.confirm"/);
65
+ assert.match(deleteActionSource, /@click="closeDialog"/);
66
+ assert.doesNotMatch(deleteActionSource, /\bactivator=/);
67
+ });
68
+
69
+ test("CRUD screen composables expose generated page extension inputs", async () => {
70
+ const listSource = await readClientFile("composables", "useCrudListScreen.js");
71
+ const viewSource = await readClientFile("composables", "useCrudViewScreen.js");
72
+
73
+ assert.match(listSource, /listRowActions = \[\]/);
74
+ assert.match(listSource, /syntheticRows = null/);
75
+ assert.match(listSource, /readEnabled = true/);
76
+ assert.match(listSource, /useCrudListRowActions/);
77
+ assert.match(listSource, /selectableRows/);
78
+ assert.match(listSource, /readEnabled,\n\s+requestRecoveryLabel/);
79
+
80
+ assert.match(viewSource, /requestQueryParams = null/);
81
+ assert.match(viewSource, /readEnabled = true/);
82
+ assert.match(viewSource, /queryKeyFactory = null/);
83
+ assert.match(viewSource, /requestQueryParams/);
84
+ assert.match(viewSource, /readEnabled/);
85
+ });
86
+
87
+ test("CRUD screen composables are importable package APIs", async () => {
88
+ const [
89
+ listModule,
90
+ viewModule,
91
+ addEditModule,
92
+ rowActionsModule,
93
+ rowActionsRuntimeModule,
94
+ deleteActionModule
95
+ ] = await Promise.all([
96
+ import("@jskit-ai/http-web/client/composables/useCrudListScreen"),
97
+ import("@jskit-ai/http-web/client/composables/useCrudViewScreen"),
98
+ import("@jskit-ai/http-web/client/composables/useCrudAddEditScreen"),
99
+ import("@jskit-ai/http-web/client/rowActions"),
100
+ import("@jskit-ai/http-web/client/composables/useCrudListRowActions"),
101
+ import("@jskit-ai/http-web/client/composables/useCrudDeleteAction")
102
+ ]);
103
+
104
+ assert.equal(typeof listModule.useCrudListScreen, "function");
105
+ assert.equal(typeof viewModule.useCrudViewScreen, "function");
106
+ assert.equal(typeof addEditModule.useCrudAddEditScreen, "function");
107
+ assert.equal(typeof rowActionsModule.defineCrudListRowActions, "function");
108
+ assert.equal(typeof rowActionsRuntimeModule.useCrudListRowActions, "function");
109
+ assert.equal(typeof deleteActionModule.useCrudDeleteAction, "function");
110
+ });
@@ -0,0 +1,31 @@
1
+ import assert from "node:assert/strict";
2
+ import path from "node:path";
3
+ import test from "node:test";
4
+ import { readFile } from "node:fs/promises";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
8
+ const PACKAGE_DIR = path.resolve(TEST_DIRECTORY, "..");
9
+
10
+ async function readClientSource(relativePath) {
11
+ return readFile(path.join(PACKAGE_DIR, "src", "client", relativePath), "utf8");
12
+ }
13
+
14
+ test("http-web reports load errors as local resource-load intent by default", async () => {
15
+ const source = await readClientSource("composables/runtime/operationUiHelpers.js");
16
+
17
+ assert.match(source, /loadChannel = ""/);
18
+ assert.match(source, /notFoundChannel = ""/);
19
+ assert.match(source, /intent = "resource-load"/);
20
+ assert.match(source, /intent: "resource-load"/);
21
+ assert.doesNotMatch(source, /loadChannel = "banner"/);
22
+ });
23
+
24
+ test("http-web action feedback lets shell policy choose snackbar presentation", async () => {
25
+ const source = await readClientSource("composables/runtime/useUiFeedback.js");
26
+
27
+ assert.match(source, /successChannel = ""/);
28
+ assert.match(source, /errorChannel = ""/);
29
+ assert.match(source, /intent: "action-feedback"/);
30
+ assert.doesNotMatch(source, /errorChannel = "banner"/);
31
+ });