@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,114 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ resolveLookupSelectedValues,
5
+ createLookupOptionsFromItems,
6
+ mergeSelectedLookupOptions,
7
+ resolveLookupOptionLabel
8
+ } from "../src/client/composables/internal/crudListFilterLookupSupport.js";
9
+
10
+ const supplierFilter = Object.freeze({
11
+ key: "supplierContactId",
12
+ type: "recordIdMany",
13
+ label: "Supplier",
14
+ lookup: Object.freeze({
15
+ apiSuffix: "/contacts",
16
+ valueKey: "id",
17
+ labelKey: "name"
18
+ })
19
+ });
20
+
21
+ test("lookup filter support normalizes selected record ids for single and many filters", () => {
22
+ assert.deepEqual(
23
+ resolveLookupSelectedValues(supplierFilter, ["7", "4", "7", ""]),
24
+ ["7", "4"]
25
+ );
26
+
27
+ assert.deepEqual(
28
+ resolveLookupSelectedValues(
29
+ {
30
+ ...supplierFilter,
31
+ type: "recordId"
32
+ },
33
+ 12
34
+ ),
35
+ ["12"]
36
+ );
37
+ });
38
+
39
+ test("lookup filter support builds option labels from custom resolvers and fallback lookup labels", () => {
40
+ const options = createLookupOptionsFromItems(
41
+ [
42
+ {
43
+ id: 7,
44
+ firstName: "Pollen",
45
+ lastName: "Partners"
46
+ },
47
+ {
48
+ id: 4,
49
+ name: "Harbor Storage"
50
+ }
51
+ ],
52
+ supplierFilter,
53
+ (item = {}) => {
54
+ if (item.id === 7) {
55
+ return `${item.firstName} ${item.lastName}`;
56
+ }
57
+
58
+ return "";
59
+ }
60
+ );
61
+
62
+ assert.deepEqual(options, [
63
+ {
64
+ value: "7",
65
+ label: "Pollen Partners",
66
+ record: {
67
+ id: 7,
68
+ firstName: "Pollen",
69
+ lastName: "Partners"
70
+ }
71
+ },
72
+ {
73
+ value: "4",
74
+ label: "Harbor Storage",
75
+ record: {
76
+ id: 4,
77
+ name: "Harbor Storage"
78
+ }
79
+ }
80
+ ]);
81
+ });
82
+
83
+ test("lookup filter support merges cached selected labels and resolves fallback labels", () => {
84
+ const currentOptions = [
85
+ {
86
+ value: "7",
87
+ label: "Pollen Partners",
88
+ record: {
89
+ id: 7,
90
+ name: "Pollen Partners"
91
+ }
92
+ }
93
+ ];
94
+ const cachedOptions = new Map([
95
+ ["12", {
96
+ value: "12",
97
+ label: "North Shed",
98
+ record: {
99
+ id: 12,
100
+ name: "North Shed"
101
+ }
102
+ }]
103
+ ]);
104
+
105
+ const mergedOptions = mergeSelectedLookupOptions(
106
+ currentOptions,
107
+ ["12", "7"],
108
+ cachedOptions
109
+ );
110
+
111
+ assert.deepEqual(mergedOptions.map((option) => option.value), ["12", "7"]);
112
+ assert.equal(resolveLookupOptionLabel(mergedOptions, cachedOptions, "12", "Storage"), "North Shed");
113
+ assert.equal(resolveLookupOptionLabel(mergedOptions, cachedOptions, "55", "Storage"), "Storage 55");
114
+ });
@@ -0,0 +1,293 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { formatDateOnlyDisplay } from "../src/client/support/crudListDateFilterSupport.js";
4
+
5
+ test("useCrudListFilters manages values, query params, chips, and presets", async () => {
6
+ const { useCrudListFilters } = await import("@jskit-ai/http-web/client/composables/useCrudListFilters");
7
+
8
+ const filters = useCrudListFilters(
9
+ {
10
+ onlyStaff: {
11
+ type: "flag",
12
+ label: "Staff"
13
+ },
14
+ status: {
15
+ type: "enumMany",
16
+ label: "Status",
17
+ options: [
18
+ { value: "active", label: "Active" },
19
+ { value: "archived", label: "Archived" }
20
+ ]
21
+ },
22
+ supplierContactId: {
23
+ type: "recordIdMany",
24
+ label: "Supplier"
25
+ },
26
+ arrivalDate: {
27
+ type: "dateRange",
28
+ label: "Arrival Date"
29
+ }
30
+ },
31
+ {
32
+ labelResolvers: {
33
+ supplierContactId(value) {
34
+ return value === "7" ? "Pollen Partners" : "";
35
+ }
36
+ },
37
+ presets: [
38
+ {
39
+ key: "needs-staff-review",
40
+ label: "Needs Staff Review",
41
+ values: {
42
+ onlyStaff: true,
43
+ status: ["archived"]
44
+ }
45
+ }
46
+ ]
47
+ }
48
+ );
49
+
50
+ filters.values.onlyStaff = true;
51
+ filters.values.status = ["active"];
52
+ filters.values.supplierContactId = ["7"];
53
+ filters.values.arrivalDate.from = "2026-04-01";
54
+
55
+ assert.equal(filters.queryParams.onlyStaff.value, true);
56
+ assert.deepEqual(filters.queryParams.status.value, ["active"]);
57
+ assert.equal(filters.queryParams.arrivalDate.value, "2026-04-01..");
58
+ assert.equal(filters.hasActiveFilters.value, true);
59
+ assert.deepEqual(
60
+ filters.activeChips.value.map((chip) => chip.label),
61
+ [
62
+ "Staff",
63
+ "Status: Active",
64
+ "Supplier: Pollen Partners",
65
+ `Arrival Date: from ${formatDateOnlyDisplay("2026-04-01")}`
66
+ ]
67
+ );
68
+
69
+ filters.clearChip(filters.activeChips.value.find((chip) => chip.filterKey === "supplierContactId"));
70
+ assert.deepEqual(filters.values.supplierContactId, []);
71
+
72
+ filters.applyPreset("needs-staff-review");
73
+ assert.equal(filters.values.onlyStaff, true);
74
+ assert.deepEqual(filters.values.status, ["archived"]);
75
+ assert.equal(filters.values.arrivalDate.from, "");
76
+
77
+ filters.clearFilters();
78
+ assert.equal(filters.values.onlyStaff, false);
79
+ assert.deepEqual(filters.values.status, []);
80
+ assert.equal(filters.hasActiveFilters.value, false);
81
+ });
82
+
83
+ test("useCrudListFilters supports dynamic presets and preset matching", async () => {
84
+ const { useCrudListFilters } = await import("@jskit-ai/http-web/client/composables/useCrudListFilters");
85
+ let today = "2026-04-18";
86
+
87
+ const filters = useCrudListFilters(
88
+ {
89
+ status: {
90
+ type: "enumMany",
91
+ label: "Status",
92
+ options: [
93
+ { value: "active", label: "Active" },
94
+ { value: "archived", label: "Archived" }
95
+ ]
96
+ },
97
+ arrivalDate: {
98
+ type: "dateRange",
99
+ label: "Arrival Date"
100
+ }
101
+ },
102
+ {
103
+ presets: [
104
+ {
105
+ key: "today",
106
+ label: "Today",
107
+ resolveValues() {
108
+ return {
109
+ arrivalDate: {
110
+ from: today,
111
+ to: today
112
+ }
113
+ };
114
+ }
115
+ },
116
+ {
117
+ key: "all-dates",
118
+ label: "All Dates",
119
+ values: {
120
+ arrivalDate: {
121
+ from: "",
122
+ to: ""
123
+ }
124
+ }
125
+ }
126
+ ]
127
+ }
128
+ );
129
+
130
+ filters.values.status = ["archived"];
131
+ filters.applyPreset("today", { mode: "merge" });
132
+
133
+ assert.equal(filters.values.arrivalDate.from, "2026-04-18");
134
+ assert.equal(filters.values.arrivalDate.to, "2026-04-18");
135
+ assert.equal(filters.queryParams.arrivalDate.value, "2026-04-18");
136
+ assert.deepEqual(filters.values.status, ["archived"]);
137
+ assert.equal(filters.matchesPreset("today"), true);
138
+ assert.equal(filters.matchesPreset("all-dates"), false);
139
+
140
+ today = "2026-04-19";
141
+ assert.equal(filters.matchesPreset("today"), false);
142
+
143
+ filters.applyPreset("all-dates", { mode: "merge" });
144
+ assert.equal(filters.values.arrivalDate.from, "");
145
+ assert.equal(filters.values.arrivalDate.to, "");
146
+ assert.deepEqual(filters.values.status, ["archived"]);
147
+ assert.equal(filters.matchesPreset("all-dates"), true);
148
+ });
149
+
150
+ test("useCrudListFilters preset matching ignores invalid extra enumMany values outside the shared contract", async () => {
151
+ const { useCrudListFilters } = await import("@jskit-ai/http-web/client/composables/useCrudListFilters");
152
+
153
+ const filters = useCrudListFilters(
154
+ {
155
+ status: {
156
+ type: "enumMany",
157
+ label: "Status",
158
+ options: [
159
+ { value: "active", label: "Active" },
160
+ { value: "archived", label: "Archived" }
161
+ ]
162
+ }
163
+ },
164
+ {
165
+ presets: [
166
+ {
167
+ key: "archived-only",
168
+ label: "Archived Only",
169
+ values: {
170
+ status: ["archived"]
171
+ }
172
+ }
173
+ ]
174
+ }
175
+ );
176
+
177
+ filters.values.status = ["archived", "bogus"];
178
+
179
+ assert.equal(filters.matchesPreset("archived-only"), true);
180
+ assert.deepEqual(filters.queryParams.status.value, ["archived"]);
181
+ assert.deepEqual(
182
+ filters.activeChips.value.map((chip) => chip.label),
183
+ ["Status: Archived"]
184
+ );
185
+ });
186
+
187
+ test("useCrudListFilters hydrates single-key range query params into structured values", async () => {
188
+ const { useCrudListFilters } = await import("@jskit-ai/http-web/client/composables/useCrudListFilters");
189
+
190
+ const filters = useCrudListFilters({
191
+ arrivalDate: {
192
+ type: "dateRange",
193
+ label: "Arrival Date"
194
+ },
195
+ weight: {
196
+ type: "numberRange",
197
+ label: "Weight"
198
+ }
199
+ });
200
+
201
+ filters.queryParams.arrivalDate.value = "..2026-04-30";
202
+ filters.queryParams.weight.value = "12.5..18";
203
+
204
+ assert.deepEqual(filters.values.arrivalDate, {
205
+ from: "",
206
+ to: "2026-04-30"
207
+ });
208
+ assert.deepEqual(filters.values.weight, {
209
+ min: "12.5",
210
+ max: "18"
211
+ });
212
+ });
213
+
214
+ test("useCrudListFilters preserves scalar, complete, partial, and cleared date contracts", async () => {
215
+ const { useCrudListFilters } = await import("@jskit-ai/http-web/client/composables/useCrudListFilters");
216
+ const filters = useCrudListFilters({
217
+ submittedOn: {
218
+ type: "date",
219
+ label: "Submitted"
220
+ },
221
+ arrivalDate: {
222
+ type: "dateRange",
223
+ label: "Arrival"
224
+ }
225
+ });
226
+
227
+ filters.queryParams.submittedOn.value = "2026-04-18";
228
+ filters.queryParams.arrivalDate.value = "2026-05-04..2026-05-10";
229
+
230
+ assert.equal(filters.values.submittedOn, "2026-04-18");
231
+ assert.deepEqual(filters.values.arrivalDate, {
232
+ from: "2026-05-04",
233
+ to: "2026-05-10"
234
+ });
235
+ assert.equal(filters.queryParams.submittedOn.value, "2026-04-18");
236
+ assert.equal(filters.queryParams.arrivalDate.value, "2026-05-04..2026-05-10");
237
+ assert.deepEqual(
238
+ filters.activeChips.value.map((chip) => chip.label),
239
+ [
240
+ `Submitted: ${formatDateOnlyDisplay("2026-04-18")}`,
241
+ `Arrival: ${formatDateOnlyDisplay("2026-05-04")} to ${formatDateOnlyDisplay("2026-05-10")}`
242
+ ]
243
+ );
244
+
245
+ filters.values.arrivalDate.to = "";
246
+ assert.equal(filters.queryParams.arrivalDate.value, "2026-05-04..");
247
+ assert.equal(
248
+ filters.activeChips.value.at(-1).label,
249
+ `Arrival: from ${formatDateOnlyDisplay("2026-05-04")}`
250
+ );
251
+
252
+ filters.clearFilter("submittedOn");
253
+ assert.equal(filters.values.submittedOn, "");
254
+ assert.equal(filters.queryParams.submittedOn.value, "");
255
+
256
+ filters.clearFilters();
257
+ assert.deepEqual(filters.values.arrivalDate, { from: "", to: "" });
258
+ assert.equal(filters.hasActiveFilters.value, false);
259
+ });
260
+
261
+ test("useCrudListFilters applies declarative defaults once while clear remains an explicit empty state", async () => {
262
+ const { useCrudListFilters } = await import("@jskit-ai/http-web/client/composables/useCrudListFilters");
263
+
264
+ const filters = useCrudListFilters({
265
+ currentness: {
266
+ type: "enum",
267
+ label: "Currentness",
268
+ options: [
269
+ { value: "active", label: "Active" },
270
+ { value: "archived", label: "Archived" }
271
+ ],
272
+ defaultValue: "active"
273
+ },
274
+ onlyStaff: {
275
+ type: "flag",
276
+ label: "Staff",
277
+ defaultValue: true
278
+ }
279
+ });
280
+
281
+ assert.equal(filters.values.currentness, "active");
282
+ assert.equal(filters.values.onlyStaff, true);
283
+ assert.equal(filters.queryParams.currentness.value, "active");
284
+ assert.equal(filters.queryParams.onlyStaff.value, true);
285
+
286
+ filters.clearFilter("currentness");
287
+ filters.clearFilter("onlyStaff");
288
+
289
+ assert.equal(filters.values.currentness, "");
290
+ assert.equal(filters.values.onlyStaff, false);
291
+ assert.equal(filters.queryParams.currentness.value, "");
292
+ assert.equal(filters.queryParams.onlyStaff.value, false);
293
+ });
@@ -0,0 +1,262 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createSchema } from "json-rest-schema";
4
+ import { resolveCrudListParentDescriptor, resolveCrudListParentRecordTitle, resolveCrudListParentTitleFromItems } from "../src/client/composables/internal/crudListParentTitleSupport.js";
5
+ import { useCrudListParentTitle } from "../src/client/composables/useCrudListParentTitle.js";
6
+
7
+ const contactChildResource = Object.freeze({
8
+ contract: {
9
+ lookup: {
10
+ containerKey: "lookups"
11
+ }
12
+ },
13
+ operations: {
14
+ view: {
15
+ output: {
16
+ schema: createSchema({
17
+ contactId: {
18
+ type: "integer",
19
+ relation: {
20
+ kind: "lookup",
21
+ namespace: "contacts",
22
+ valueKey: "id"
23
+ }
24
+ },
25
+ serviceId: {
26
+ type: "integer",
27
+ relation: {
28
+ kind: "lookup",
29
+ namespace: "services",
30
+ valueKey: "id"
31
+ }
32
+ }
33
+ })
34
+ }
35
+ }
36
+ }
37
+ });
38
+
39
+ test("resolveCrudListParentDescriptor selects the nearest lookup route parent", () => {
40
+ const descriptor = resolveCrudListParentDescriptor({
41
+ resource: contactChildResource,
42
+ route: {
43
+ matched: [
44
+ { path: "/w/:workspaceSlug/admin" },
45
+ { path: "/w/:workspaceSlug/admin/contacts/:contactId/availabilities" }
46
+ ],
47
+ params: {
48
+ workspaceSlug: "dogandgroom",
49
+ contactId: "538779"
50
+ }
51
+ },
52
+ recordIdParam: "availabilityRuleId"
53
+ });
54
+
55
+ assert.deepEqual(
56
+ descriptor,
57
+ {
58
+ fieldKey: "contactId",
59
+ routeParamKey: "contactId",
60
+ relationNamespace: "contacts",
61
+ entityLabel: "Contact",
62
+ labelKey: "",
63
+ fieldDescriptor: {
64
+ key: "contactId",
65
+ relation: {
66
+ kind: "lookup",
67
+ valueKey: "id",
68
+ labelKey: "",
69
+ containerKey: "lookups"
70
+ }
71
+ },
72
+ apiUrlTemplate: "/contacts/:contactId"
73
+ }
74
+ );
75
+ });
76
+
77
+ test("resolveCrudListParentTitleFromItems uses the hydrated lookup label", () => {
78
+ const descriptor = resolveCrudListParentDescriptor({
79
+ resource: contactChildResource,
80
+ route: {
81
+ matched: [{ path: "/w/:workspaceSlug/admin/contacts/:contactId/availabilities" }],
82
+ params: {
83
+ workspaceSlug: "dogandgroom",
84
+ contactId: "538779"
85
+ }
86
+ },
87
+ recordIdParam: "availabilityRuleId"
88
+ });
89
+
90
+ const title = resolveCrudListParentTitleFromItems(
91
+ [
92
+ {
93
+ id: 1,
94
+ contactId: 538779,
95
+ lookups: {
96
+ contactId: {
97
+ id: 538779,
98
+ firstName: "Jessica",
99
+ lastName: "Dickinson"
100
+ }
101
+ }
102
+ }
103
+ ],
104
+ descriptor
105
+ );
106
+
107
+ assert.equal(title, "Jessica Dickinson");
108
+ });
109
+
110
+ test("resolveCrudListParentTitleFromItems ignores raw lookup ids when no hydrated label is present", () => {
111
+ const descriptor = resolveCrudListParentDescriptor({
112
+ resource: contactChildResource,
113
+ route: {
114
+ matched: [{ path: "/w/:workspaceSlug/admin/contacts/:contactId/availabilities" }],
115
+ params: {
116
+ workspaceSlug: "dogandgroom",
117
+ contactId: "538779"
118
+ }
119
+ },
120
+ recordIdParam: "availabilityRuleId"
121
+ });
122
+
123
+ const title = resolveCrudListParentTitleFromItems(
124
+ [
125
+ {
126
+ id: 1,
127
+ contactId: 538779
128
+ }
129
+ ],
130
+ descriptor
131
+ );
132
+
133
+ assert.equal(title, "");
134
+ });
135
+
136
+ test("resolveCrudListParentRecordTitle falls back to entity label plus id", () => {
137
+ const title = resolveCrudListParentRecordTitle(
138
+ {
139
+ id: 538779
140
+ },
141
+ {
142
+ entityLabel: "Contact",
143
+ labelKey: ""
144
+ }
145
+ );
146
+
147
+ assert.equal(title, "Contact #538779");
148
+ });
149
+
150
+ test("resolveCrudListParentDescriptor supports parentRouteParamKey aliases", () => {
151
+ const descriptor = resolveCrudListParentDescriptor({
152
+ resource: {
153
+ operations: {
154
+ view: {
155
+ output: {
156
+ schema: createSchema({
157
+ staffContactId: {
158
+ type: "integer",
159
+ parentRouteParamKey: "contactId",
160
+ relation: {
161
+ kind: "lookup",
162
+ namespace: "contacts",
163
+ valueKey: "id"
164
+ }
165
+ }
166
+ })
167
+ }
168
+ }
169
+ }
170
+ },
171
+ route: {
172
+ matched: [{ path: "/w/:workspaceSlug/admin/contacts/:contactId/availabilities" }],
173
+ params: {
174
+ workspaceSlug: "dogandgroom",
175
+ contactId: "538779"
176
+ }
177
+ },
178
+ recordIdParam: "availabilityRuleId"
179
+ });
180
+
181
+ assert.equal(descriptor?.fieldKey, "staffContactId");
182
+ assert.equal(descriptor?.routeParamKey, "contactId");
183
+ });
184
+
185
+ test("useCrudListParentTitle loads the parent record when child rows only expose the raw parent id", () => {
186
+ const runtime = useCrudListParentTitle({
187
+ listRuntime: {
188
+ items: [
189
+ {
190
+ id: 1,
191
+ contactId: 538779
192
+ }
193
+ ],
194
+ isInitialLoading: false,
195
+ loadError: ""
196
+ },
197
+ resource: contactChildResource,
198
+ recordIdParam: "availabilityRuleId",
199
+ route: {
200
+ matched: [{ path: "/w/:workspaceSlug/admin/contacts/:contactId/availabilities" }],
201
+ params: {
202
+ workspaceSlug: "dogandgroom",
203
+ contactId: "538779"
204
+ }
205
+ },
206
+ viewRuntimeFactory: () => ({
207
+ record: {
208
+ id: 538779,
209
+ firstName: "Jessica",
210
+ lastName: "Dickinson"
211
+ },
212
+ isLoading: false,
213
+ loadError: ""
214
+ })
215
+ });
216
+
217
+ assert.equal(runtime.shouldLoadParentRecord, true);
218
+ assert.equal(runtime.title, "Jessica Dickinson");
219
+ });
220
+
221
+ test("useCrudListParentTitle requests the parent through JSON:API record transport", () => {
222
+ let capturedTransport = null;
223
+
224
+ useCrudListParentTitle({
225
+ listRuntime: {
226
+ items: [
227
+ {
228
+ id: 1,
229
+ contactId: 538779
230
+ }
231
+ ],
232
+ isInitialLoading: false,
233
+ loadError: ""
234
+ },
235
+ resource: contactChildResource,
236
+ recordIdParam: "availabilityRuleId",
237
+ route: {
238
+ matched: [{ path: "/w/:workspaceSlug/admin/contacts/:contactId/availabilities" }],
239
+ params: {
240
+ workspaceSlug: "dogandgroom",
241
+ contactId: "538779"
242
+ }
243
+ },
244
+ viewRuntimeFactory: (options = {}) => {
245
+ capturedTransport = options.transport;
246
+ return {
247
+ record: {
248
+ id: 538779,
249
+ fullName: "Jessica Dickinson"
250
+ },
251
+ isLoading: false,
252
+ loadError: ""
253
+ };
254
+ }
255
+ });
256
+
257
+ assert.deepEqual(capturedTransport, {
258
+ kind: "jsonapi-resource",
259
+ responseType: "contacts",
260
+ responseKind: "record"
261
+ });
262
+ });