@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.
- package/fixtures/crud-list-date-filters/App.vue +107 -0
- package/fixtures/crud-list-date-filters/index.html +12 -0
- package/fixtures/crud-list-date-filters/main.js +24 -0
- package/fixtures/crud-list-date-filters/vite.config.mjs +18 -0
- package/package.json +150 -0
- package/src/client/bulkActions.js +47 -0
- package/src/client/components/CrudAddEditScreen.vue +186 -0
- package/src/client/components/CrudDeleteAction.vue +93 -0
- package/src/client/components/CrudListBulkActionSurface.vue +126 -0
- package/src/client/components/CrudListDateFilterControl.vue +188 -0
- package/src/client/components/CrudListFilterSurface.vue +388 -0
- package/src/client/components/CrudListRecordActionMenu.vue +120 -0
- package/src/client/components/CrudListScreen.vue +501 -0
- package/src/client/components/CrudViewScreen.vue +197 -0
- package/src/client/composables/crud/crudBindingSupport.js +75 -0
- package/src/client/composables/crud/crudHttpClientSupport.js +57 -0
- package/src/client/composables/crud/crudJsonApiTransportSupport.js +145 -0
- package/src/client/composables/crud/crudLookupFieldLabelSupport.js +151 -0
- package/src/client/composables/crud/crudLookupFieldRuntime.js +251 -0
- package/src/client/composables/crud/crudSchemaFormHelpers.js +464 -0
- package/src/client/composables/internal/crudListFilterLookupSupport.js +161 -0
- package/src/client/composables/internal/crudListParentTitleSupport.js +171 -0
- package/src/client/composables/internal/useOperationScope.js +144 -0
- package/src/client/composables/records/useAddEdit.js +224 -0
- package/src/client/composables/records/useCrudAddEdit.js +227 -0
- package/src/client/composables/records/useCrudList.js +101 -0
- package/src/client/composables/records/useCrudView.js +47 -0
- package/src/client/composables/records/useList.js +500 -0
- package/src/client/composables/records/useView.js +164 -0
- package/src/client/composables/runtime/addEditUiRuntime.js +130 -0
- package/src/client/composables/runtime/listUiRuntime.js +123 -0
- package/src/client/composables/runtime/modelStateHelpers.js +90 -0
- package/src/client/composables/runtime/operationAdapters.js +34 -0
- package/src/client/composables/runtime/operationUiHelpers.js +125 -0
- package/src/client/composables/runtime/operationValidationHelpers.js +51 -0
- package/src/client/composables/runtime/useAddEditCore.js +112 -0
- package/src/client/composables/runtime/useCommandCore.js +123 -0
- package/src/client/composables/runtime/useEndpointResource.js +199 -0
- package/src/client/composables/runtime/useFieldErrorBag.js +61 -0
- package/src/client/composables/runtime/useListCore.js +112 -0
- package/src/client/composables/runtime/useUiFeedback.js +98 -0
- package/src/client/composables/runtime/useViewCore.js +93 -0
- package/src/client/composables/runtime/viewUiRuntime.js +96 -0
- package/src/client/composables/support/errorMessageHelpers.js +66 -0
- package/src/client/composables/support/listQueryParamSupport.js +473 -0
- package/src/client/composables/support/listSearchSupport.js +70 -0
- package/src/client/composables/support/refValueHelpers.js +19 -0
- package/src/client/composables/support/requestQueryRuntimeSupport.js +141 -0
- package/src/client/composables/support/resourceLoadStateHelpers.js +208 -0
- package/src/client/composables/support/routeTemplateHelpers.js +285 -0
- package/src/client/composables/support/scopeHelpers.js +145 -0
- package/src/client/composables/useAccess.js +109 -0
- package/src/client/composables/useCommand.js +118 -0
- package/src/client/composables/useCrudAddEditScreen.js +88 -0
- package/src/client/composables/useCrudDeleteAction.js +160 -0
- package/src/client/composables/useCrudListBulkActions.js +147 -0
- package/src/client/composables/useCrudListFilterLookups.js +146 -0
- package/src/client/composables/useCrudListFilters.js +339 -0
- package/src/client/composables/useCrudListParentTitle.js +151 -0
- package/src/client/composables/useCrudListRowActions.js +144 -0
- package/src/client/composables/useCrudListScreen.js +237 -0
- package/src/client/composables/useCrudViewScreen.js +77 -0
- package/src/client/composables/usePagedCollection.js +181 -0
- package/src/client/composables/useRealtimeQueryInvalidation.js +156 -0
- package/src/client/composables/useScopeRuntime.js +135 -0
- package/src/client/filters.js +15 -0
- package/src/client/index.js +24 -0
- package/src/client/lib/httpClient.js +47 -0
- package/src/client/lib/permissions.js +34 -0
- package/src/client/rowActions.js +47 -0
- package/src/client/support/contractGuards.js +34 -0
- package/src/client/support/crudListDateFilterSupport.js +91 -0
- package/test/addEditUiRuntime.test.js +110 -0
- package/test/crudBindingSupport.test.js +110 -0
- package/test/crudHttpClientSupport.test.js +77 -0
- package/test/crudJsonApiTransportSupport.test.js +179 -0
- package/test/crudListBulkActionSurface.test.js +27 -0
- package/test/crudListDateFilterSupport.test.js +92 -0
- package/test/crudListDateFilterSurface.browser.test.js +182 -0
- package/test/crudListFilterSurface.test.js +90 -0
- package/test/crudLookupFieldRuntime.test.js +264 -0
- package/test/crudScreenComponents.test.js +110 -0
- package/test/errorIntentContract.test.js +31 -0
- package/test/errorMessageHelpers.test.js +28 -0
- package/test/exportsContract.test.js +68 -0
- package/test/listQueryParamSupport.test.js +223 -0
- package/test/listUiRuntime.test.js +102 -0
- package/test/operationValidationHelpers.test.js +64 -0
- package/test/packageBoundary.test.js +31 -0
- package/test/permissions.test.js +35 -0
- package/test/refValueHelpers.test.js +14 -0
- package/test/requestTransportOptions.test.js +457 -0
- package/test/resourceLoadStateHelpers.test.js +189 -0
- package/test/routeTemplateHelpers.test.js +85 -0
- package/test/scopeHelpers.test.js +57 -0
- package/test/useAddEditCore.test.js +124 -0
- package/test/useAddEditRequestQueryParams.test.js +190 -0
- package/test/useCrudAddEdit.test.js +415 -0
- package/test/useCrudDeleteAction.test.js +139 -0
- package/test/useCrudListBulkActions.test.js +65 -0
- package/test/useCrudListFilterLookups.test.js +114 -0
- package/test/useCrudListFilters.test.js +293 -0
- package/test/useCrudListParentTitle.test.js +262 -0
- package/test/useCrudListRowActions.test.js +77 -0
- package/test/useListRouteInitialization.test.js +337 -0
- package/test/useListSearchSupport.test.js +61 -0
- package/test/usePagedCollection.test.js +53 -0
- package/test/useViewRequestQueryParams.test.js +20 -0
- package/test/viewCoreLoading.test.js +44 -0
- package/test/viewUiRuntime.test.js +95 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
test("useCrudListRowActions manages per-record state and executes action context", async () => {
|
|
5
|
+
const { defineCrudListRowActions } = await import("@jskit-ai/http-web/client/rowActions");
|
|
6
|
+
const { useCrudListRowActions } = await import("@jskit-ai/http-web/client/composables/useCrudListRowActions");
|
|
7
|
+
const calls = [];
|
|
8
|
+
let finishRun = null;
|
|
9
|
+
const actions = defineCrudListRowActions([
|
|
10
|
+
{
|
|
11
|
+
key: "delete",
|
|
12
|
+
label: "Delete",
|
|
13
|
+
color: "error",
|
|
14
|
+
visible: ({ record }) => record.isOwnerRow !== true,
|
|
15
|
+
disabled: ({ record }) => record.locked === true,
|
|
16
|
+
loading: ({ record }) => record.pending === true,
|
|
17
|
+
async run(context) {
|
|
18
|
+
calls.push(context);
|
|
19
|
+
await new Promise((resolve) => {
|
|
20
|
+
finishRun = resolve;
|
|
21
|
+
});
|
|
22
|
+
return "deleted";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
]);
|
|
26
|
+
const runtime = useCrudListRowActions(actions, {
|
|
27
|
+
resolveRecordId: (record) => record.id,
|
|
28
|
+
resolveContext: () => ({
|
|
29
|
+
records: "records-token",
|
|
30
|
+
reload: "reload-token"
|
|
31
|
+
})
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
assert.equal(runtime.hasActions.value, true);
|
|
35
|
+
assert.equal(runtime.hasVisibleActionsFor({ id: "owner", isOwnerRow: true }, 0), false);
|
|
36
|
+
assert.equal(runtime.isActionDisabled("delete", { id: "locked", locked: true }, 1), true);
|
|
37
|
+
assert.equal(runtime.isActionLoading("delete", { id: "pending", pending: true }, 2), true);
|
|
38
|
+
|
|
39
|
+
const record = { id: "10", name: "Worker" };
|
|
40
|
+
assert.equal(runtime.isActionVisible("delete", record, 3), true);
|
|
41
|
+
assert.equal(runtime.isActionDisabled("delete", record, 3), false);
|
|
42
|
+
|
|
43
|
+
const result = runtime.execute("delete", record, 3);
|
|
44
|
+
assert.equal(runtime.isActionExecuting("delete", record, 3), true);
|
|
45
|
+
assert.equal(runtime.isActionLoading("delete", record, 3), true);
|
|
46
|
+
finishRun();
|
|
47
|
+
|
|
48
|
+
assert.equal(await result, "deleted");
|
|
49
|
+
assert.equal(runtime.isActionExecuting("delete", record, 3), false);
|
|
50
|
+
assert.equal(calls.length, 1);
|
|
51
|
+
assert.equal(calls[0].action.key, "delete");
|
|
52
|
+
assert.equal(calls[0].record, record);
|
|
53
|
+
assert.equal(calls[0].index, 3);
|
|
54
|
+
assert.equal(calls[0].recordId, "10");
|
|
55
|
+
assert.equal(calls[0].records, "records-token");
|
|
56
|
+
assert.equal(calls[0].reload, "reload-token");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("defineCrudListRowActions skips malformed and duplicate actions", async () => {
|
|
60
|
+
const { defineCrudListRowActions } = await import("@jskit-ai/http-web/client/rowActions");
|
|
61
|
+
|
|
62
|
+
const actions = defineCrudListRowActions([
|
|
63
|
+
null,
|
|
64
|
+
{ key: "delete", label: "Delete", color: "error" },
|
|
65
|
+
{ key: "delete", label: "Delete again" },
|
|
66
|
+
{ key: "missing-label" },
|
|
67
|
+
{ label: "Generated key" }
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
assert.deepEqual(
|
|
71
|
+
actions.map((action) => [action.key, action.label, action.color]),
|
|
72
|
+
[
|
|
73
|
+
["delete", "Delete", "error"],
|
|
74
|
+
["action-5", "Generated key", "primary"]
|
|
75
|
+
]
|
|
76
|
+
);
|
|
77
|
+
});
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { QueryClient, VueQueryPlugin } from "@tanstack/vue-query";
|
|
4
|
+
import {
|
|
5
|
+
computed,
|
|
6
|
+
createRenderer,
|
|
7
|
+
h,
|
|
8
|
+
unref
|
|
9
|
+
} from "vue";
|
|
10
|
+
import {
|
|
11
|
+
createMemoryHistory,
|
|
12
|
+
createRouter,
|
|
13
|
+
useRoute
|
|
14
|
+
} from "vue-router";
|
|
15
|
+
|
|
16
|
+
import { useList } from "../src/client/composables/records/useList.js";
|
|
17
|
+
import { useCrudListFilters } from "../src/client/composables/useCrudListFilters.js";
|
|
18
|
+
|
|
19
|
+
const CURRENTNESS_FILTERS = Object.freeze({
|
|
20
|
+
currentness: Object.freeze({
|
|
21
|
+
type: "enum",
|
|
22
|
+
defaultValue: "active",
|
|
23
|
+
options: Object.freeze([
|
|
24
|
+
Object.freeze({
|
|
25
|
+
label: "Active",
|
|
26
|
+
value: "active"
|
|
27
|
+
}),
|
|
28
|
+
Object.freeze({
|
|
29
|
+
label: "Archived",
|
|
30
|
+
value: "archived"
|
|
31
|
+
})
|
|
32
|
+
])
|
|
33
|
+
})
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
async function waitFor(check, message = "Expected condition was not met.") {
|
|
37
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
38
|
+
if (check()) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
assert.fail(message);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function createTestRenderer() {
|
|
48
|
+
function insert(child, parent, anchor = null) {
|
|
49
|
+
const siblings = parent.children || (parent.children = []);
|
|
50
|
+
const index = anchor ? siblings.indexOf(anchor) : -1;
|
|
51
|
+
if (index < 0) {
|
|
52
|
+
siblings.push(child);
|
|
53
|
+
} else {
|
|
54
|
+
siblings.splice(index, 0, child);
|
|
55
|
+
}
|
|
56
|
+
child.parent = parent;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function remove(child) {
|
|
60
|
+
const parent = child?.parent;
|
|
61
|
+
const index = parent?.children?.indexOf(child) ?? -1;
|
|
62
|
+
if (index >= 0) {
|
|
63
|
+
parent.children.splice(index, 1);
|
|
64
|
+
}
|
|
65
|
+
child.parent = null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return createRenderer({
|
|
69
|
+
patchProp(element, key, _previousValue, nextValue) {
|
|
70
|
+
element.properties[key] = nextValue;
|
|
71
|
+
},
|
|
72
|
+
insert,
|
|
73
|
+
remove,
|
|
74
|
+
createElement(type) {
|
|
75
|
+
return {
|
|
76
|
+
type,
|
|
77
|
+
properties: {},
|
|
78
|
+
children: [],
|
|
79
|
+
parent: null
|
|
80
|
+
};
|
|
81
|
+
},
|
|
82
|
+
createText(text) {
|
|
83
|
+
return {
|
|
84
|
+
type: "text",
|
|
85
|
+
text,
|
|
86
|
+
parent: null
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
createComment(text) {
|
|
90
|
+
return {
|
|
91
|
+
type: "comment",
|
|
92
|
+
text,
|
|
93
|
+
parent: null
|
|
94
|
+
};
|
|
95
|
+
},
|
|
96
|
+
setText(node, text) {
|
|
97
|
+
node.text = text;
|
|
98
|
+
},
|
|
99
|
+
setElementText(element, text) {
|
|
100
|
+
element.children = [{
|
|
101
|
+
type: "text",
|
|
102
|
+
text,
|
|
103
|
+
parent: element
|
|
104
|
+
}];
|
|
105
|
+
},
|
|
106
|
+
parentNode(node) {
|
|
107
|
+
return node?.parent || null;
|
|
108
|
+
},
|
|
109
|
+
nextSibling(node) {
|
|
110
|
+
const parent = node?.parent;
|
|
111
|
+
const index = parent?.children?.indexOf(node) ?? -1;
|
|
112
|
+
return index >= 0 ? parent.children[index + 1] || null : null;
|
|
113
|
+
},
|
|
114
|
+
querySelector() {
|
|
115
|
+
return null;
|
|
116
|
+
},
|
|
117
|
+
setScopeId() {},
|
|
118
|
+
cloneNode(node) {
|
|
119
|
+
return {
|
|
120
|
+
...node,
|
|
121
|
+
properties: {
|
|
122
|
+
...node.properties
|
|
123
|
+
},
|
|
124
|
+
children: [...(node.children || [])],
|
|
125
|
+
parent: null
|
|
126
|
+
};
|
|
127
|
+
},
|
|
128
|
+
insertStaticContent(content, parent, anchor) {
|
|
129
|
+
const node = {
|
|
130
|
+
type: "static",
|
|
131
|
+
text: content,
|
|
132
|
+
parent: null
|
|
133
|
+
};
|
|
134
|
+
insert(node, parent, anchor);
|
|
135
|
+
return [node, node];
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function createListTestAdapter() {
|
|
141
|
+
return Object.freeze({
|
|
142
|
+
useOperationScope() {
|
|
143
|
+
const route = useRoute();
|
|
144
|
+
|
|
145
|
+
return Object.freeze({
|
|
146
|
+
routeContext: Object.freeze({
|
|
147
|
+
route,
|
|
148
|
+
currentSurfaceId: computed(() => "admin")
|
|
149
|
+
}),
|
|
150
|
+
scopeParamValue: computed(() => ""),
|
|
151
|
+
normalizedOwnershipFilter: "none",
|
|
152
|
+
apiPath: computed(() => "/contacts"),
|
|
153
|
+
queryKey: computed(() => ["contacts"]),
|
|
154
|
+
queryCanRun(gate = true) {
|
|
155
|
+
return computed(() => Boolean(unref(gate)));
|
|
156
|
+
},
|
|
157
|
+
permissionGate() {
|
|
158
|
+
return computed(() => true);
|
|
159
|
+
},
|
|
160
|
+
loadError(error = "") {
|
|
161
|
+
return computed(() => String(unref(error) || ""));
|
|
162
|
+
},
|
|
163
|
+
isLoading(loading = false) {
|
|
164
|
+
return computed(() => Boolean(unref(loading)));
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function mountRouteSynchronizedList(initialLocation) {
|
|
172
|
+
const calls = [];
|
|
173
|
+
const client = {
|
|
174
|
+
async request(path, options) {
|
|
175
|
+
calls.push({
|
|
176
|
+
path,
|
|
177
|
+
options
|
|
178
|
+
});
|
|
179
|
+
return {
|
|
180
|
+
items: [],
|
|
181
|
+
nextCursor: null
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
const queryClient = new QueryClient({
|
|
186
|
+
defaultOptions: {
|
|
187
|
+
queries: {
|
|
188
|
+
retry: false
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
const router = createRouter({
|
|
193
|
+
history: createMemoryHistory(),
|
|
194
|
+
routes: [{
|
|
195
|
+
path: "/contacts",
|
|
196
|
+
component: {
|
|
197
|
+
render() {
|
|
198
|
+
return h("div");
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}]
|
|
202
|
+
});
|
|
203
|
+
const renderer = createTestRenderer();
|
|
204
|
+
let filterRuntime = null;
|
|
205
|
+
const app = renderer.createApp({
|
|
206
|
+
setup() {
|
|
207
|
+
filterRuntime = useCrudListFilters(CURRENTNESS_FILTERS);
|
|
208
|
+
useList({
|
|
209
|
+
adapter: createListTestAdapter(),
|
|
210
|
+
client,
|
|
211
|
+
queryKeyFactory: () => ["contacts"],
|
|
212
|
+
queryParams: filterRuntime.queryParams,
|
|
213
|
+
routeQueryValueResolvers: filterRuntime.routeQueryValueResolvers,
|
|
214
|
+
syncToRoute: {
|
|
215
|
+
enabled: true,
|
|
216
|
+
search: false,
|
|
217
|
+
queryParams: true
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
return () => h("div");
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
app.use(router);
|
|
225
|
+
app.use(VueQueryPlugin, {
|
|
226
|
+
queryClient
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
await router.push(initialLocation);
|
|
230
|
+
await router.isReady();
|
|
231
|
+
app.mount({
|
|
232
|
+
type: "root",
|
|
233
|
+
children: []
|
|
234
|
+
});
|
|
235
|
+
await waitFor(
|
|
236
|
+
() => calls.length > 0,
|
|
237
|
+
`Expected a list request for ${JSON.stringify(initialLocation)}.`
|
|
238
|
+
);
|
|
239
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
app,
|
|
243
|
+
calls,
|
|
244
|
+
filterRuntime,
|
|
245
|
+
queryClient,
|
|
246
|
+
router
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
test("route-synchronized lists make one initial request with the hydrated route filter", async () => {
|
|
251
|
+
const runtime = await mountRouteSynchronizedList({
|
|
252
|
+
path: "/contacts",
|
|
253
|
+
query: {
|
|
254
|
+
currentness: "archived"
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
assert.equal(runtime.calls.length, 1);
|
|
259
|
+
assert.deepEqual(runtime.calls[0], {
|
|
260
|
+
path: "/contacts",
|
|
261
|
+
options: {
|
|
262
|
+
method: "GET",
|
|
263
|
+
query: {
|
|
264
|
+
currentness: "archived",
|
|
265
|
+
limit: 20
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
runtime.app.unmount();
|
|
270
|
+
runtime.queryClient.clear();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("route-synchronized lists make one initial request with a declarative default", async () => {
|
|
274
|
+
const runtime = await mountRouteSynchronizedList("/contacts");
|
|
275
|
+
|
|
276
|
+
assert.equal(runtime.calls.length, 1);
|
|
277
|
+
assert.equal(runtime.calls[0].options.query.currentness, "active");
|
|
278
|
+
runtime.app.unmount();
|
|
279
|
+
runtime.queryClient.clear();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("invalid initial route filters fall back before the first list request", async () => {
|
|
283
|
+
const runtime = await mountRouteSynchronizedList({
|
|
284
|
+
path: "/contacts",
|
|
285
|
+
query: {
|
|
286
|
+
currentness: "not-a-valid-option"
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
assert.equal(runtime.calls.length, 1);
|
|
291
|
+
assert.equal(runtime.calls[0].options.query.currentness, "active");
|
|
292
|
+
runtime.app.unmount();
|
|
293
|
+
runtime.queryClient.clear();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("route navigation rehydrates filters and a later clear stays cleared", async () => {
|
|
297
|
+
const runtime = await mountRouteSynchronizedList({
|
|
298
|
+
path: "/contacts",
|
|
299
|
+
query: {
|
|
300
|
+
currentness: "archived"
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
await runtime.router.push({
|
|
305
|
+
path: "/contacts",
|
|
306
|
+
query: {
|
|
307
|
+
currentness: "active"
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
await waitFor(() => runtime.filterRuntime.values.currentness === "active");
|
|
311
|
+
await waitFor(() => runtime.calls.length === 2);
|
|
312
|
+
|
|
313
|
+
runtime.router.back();
|
|
314
|
+
await waitFor(() => runtime.router.currentRoute.value.query.currentness === "archived");
|
|
315
|
+
await waitFor(() => runtime.filterRuntime.values.currentness === "archived");
|
|
316
|
+
await waitFor(() => runtime.calls.length === 3);
|
|
317
|
+
|
|
318
|
+
runtime.router.forward();
|
|
319
|
+
await waitFor(() => runtime.router.currentRoute.value.query.currentness === "active");
|
|
320
|
+
await waitFor(() => runtime.filterRuntime.values.currentness === "active");
|
|
321
|
+
await waitFor(() => runtime.calls.length === 4);
|
|
322
|
+
|
|
323
|
+
runtime.filterRuntime.clearFilter("currentness");
|
|
324
|
+
await waitFor(() => !Object.hasOwn(runtime.router.currentRoute.value.query, "currentness"));
|
|
325
|
+
await waitFor(
|
|
326
|
+
() => runtime.calls.length >= 5,
|
|
327
|
+
`Expected one request after clearing the filter; received ${runtime.calls.length}.`
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
assert.equal(runtime.calls.length, 5, JSON.stringify(runtime.calls, null, 2));
|
|
331
|
+
assert.equal(runtime.filterRuntime.values.currentness, "");
|
|
332
|
+
assert.deepEqual(runtime.calls.at(-1).options.query, {
|
|
333
|
+
limit: 20
|
|
334
|
+
});
|
|
335
|
+
runtime.app.unmount();
|
|
336
|
+
runtime.queryClient.clear();
|
|
337
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
normalizeListSearchConfig,
|
|
5
|
+
matchesLocalSearch
|
|
6
|
+
} from "../src/client/composables/support/listSearchSupport.js";
|
|
7
|
+
|
|
8
|
+
test("normalizeListSearchConfig defaults to disabled query search", () => {
|
|
9
|
+
const config = normalizeListSearchConfig();
|
|
10
|
+
assert.equal(config.enabled, false);
|
|
11
|
+
assert.equal(config.mode, "query");
|
|
12
|
+
assert.equal(config.queryParam, "q");
|
|
13
|
+
assert.equal(config.label, "Search");
|
|
14
|
+
assert.equal(config.minLength, 1);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("normalizeListSearchConfig accepts query mode and fields", () => {
|
|
18
|
+
const config = normalizeListSearchConfig({
|
|
19
|
+
enabled: true,
|
|
20
|
+
mode: "query",
|
|
21
|
+
queryParam: "search",
|
|
22
|
+
fields: ["name", "name", "email"]
|
|
23
|
+
});
|
|
24
|
+
assert.equal(config.enabled, true);
|
|
25
|
+
assert.equal(config.mode, "query");
|
|
26
|
+
assert.equal(config.queryParam, "search");
|
|
27
|
+
assert.deepEqual(config.fields, ["name", "email"]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("normalizeListSearchConfig accepts explicit local mode", () => {
|
|
31
|
+
const config = normalizeListSearchConfig({
|
|
32
|
+
enabled: true,
|
|
33
|
+
mode: "local"
|
|
34
|
+
});
|
|
35
|
+
assert.equal(config.mode, "local");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("matchesLocalSearch checks configured fields", () => {
|
|
39
|
+
const result = matchesLocalSearch(
|
|
40
|
+
{
|
|
41
|
+
firstName: "Ana",
|
|
42
|
+
surname: "Marin",
|
|
43
|
+
phone: "0400"
|
|
44
|
+
},
|
|
45
|
+
"mar",
|
|
46
|
+
["surname"]
|
|
47
|
+
);
|
|
48
|
+
assert.equal(result, true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("matchesLocalSearch scans scalar values when fields are not configured", () => {
|
|
52
|
+
const result = matchesLocalSearch(
|
|
53
|
+
{
|
|
54
|
+
firstName: "Ana",
|
|
55
|
+
surname: "Marin",
|
|
56
|
+
count: 42
|
|
57
|
+
},
|
|
58
|
+
"42"
|
|
59
|
+
);
|
|
60
|
+
assert.equal(result, true);
|
|
61
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { trimInfinitePagesToFirst } from "../src/client/composables/usePagedCollection.js";
|
|
4
|
+
|
|
5
|
+
test("trimInfinitePagesToFirst leaves non-object payloads untouched", () => {
|
|
6
|
+
assert.equal(trimInfinitePagesToFirst(null), null);
|
|
7
|
+
assert.equal(trimInfinitePagesToFirst(""), "");
|
|
8
|
+
assert.equal(trimInfinitePagesToFirst(12), 12);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("trimInfinitePagesToFirst leaves single-page payload unchanged", () => {
|
|
12
|
+
const payload = Object.freeze({
|
|
13
|
+
pages: Object.freeze([{ items: [1, 2] }]),
|
|
14
|
+
pageParams: Object.freeze([null])
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
assert.equal(trimInfinitePagesToFirst(payload), payload);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("trimInfinitePagesToFirst truncates to the first page and first pageParam", () => {
|
|
21
|
+
const payload = {
|
|
22
|
+
pages: [
|
|
23
|
+
{ items: [1, 2] },
|
|
24
|
+
{ items: [3, 4] },
|
|
25
|
+
{ items: [5, 6] }
|
|
26
|
+
],
|
|
27
|
+
pageParams: [null, "cursor-1", "cursor-2"],
|
|
28
|
+
extra: true
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
assert.deepEqual(
|
|
32
|
+
trimInfinitePagesToFirst(payload),
|
|
33
|
+
{
|
|
34
|
+
pages: [{ items: [1, 2] }],
|
|
35
|
+
pageParams: [null],
|
|
36
|
+
extra: true
|
|
37
|
+
}
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("trimInfinitePagesToFirst injects null pageParam when missing", () => {
|
|
42
|
+
const payload = {
|
|
43
|
+
pages: [{ items: [1] }, { items: [2] }]
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
assert.deepEqual(
|
|
47
|
+
trimInfinitePagesToFirst(payload),
|
|
48
|
+
{
|
|
49
|
+
pages: [{ items: [1] }],
|
|
50
|
+
pageParams: [null]
|
|
51
|
+
}
|
|
52
|
+
);
|
|
53
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { buildRequestQueryObject } from "../src/client/composables/support/requestQueryRuntimeSupport.js";
|
|
4
|
+
|
|
5
|
+
test("buildRequestQueryObject preserves repeated keys as arrays deterministically", () => {
|
|
6
|
+
assert.deepEqual(
|
|
7
|
+
buildRequestQueryObject([
|
|
8
|
+
{ key: "include", values: ["vetId", "linkedUserId", "pets", "pets.breedId"] },
|
|
9
|
+
{ key: "limit", values: ["10"] }
|
|
10
|
+
]),
|
|
11
|
+
{
|
|
12
|
+
include: ["vetId", "linkedUserId", "pets", "pets.breedId"],
|
|
13
|
+
limit: "10"
|
|
14
|
+
}
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("buildRequestQueryObject returns an empty object when no request params are active", () => {
|
|
19
|
+
assert.deepEqual(buildRequestQueryObject([]), {});
|
|
20
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { ref } from "vue";
|
|
4
|
+
import { useViewCore } from "../src/client/composables/runtime/useViewCore.js";
|
|
5
|
+
|
|
6
|
+
test("useViewCore prefers resource isInitialLoading/isFetching signals when provided", () => {
|
|
7
|
+
const resource = {
|
|
8
|
+
data: ref({ id: 42 }),
|
|
9
|
+
isInitialLoading: ref(false),
|
|
10
|
+
isFetching: ref(true),
|
|
11
|
+
query: {
|
|
12
|
+
isPending: ref(true),
|
|
13
|
+
isFetching: ref(true),
|
|
14
|
+
error: ref(null)
|
|
15
|
+
},
|
|
16
|
+
loadError: ref("")
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const view = useViewCore({
|
|
20
|
+
resource
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
assert.equal(view.isLoading.value, false);
|
|
24
|
+
assert.equal(view.isFetching.value, true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("useViewCore falls back to query pending/fetching when resource-level loading refs are absent", () => {
|
|
28
|
+
const resource = {
|
|
29
|
+
data: ref(null),
|
|
30
|
+
query: {
|
|
31
|
+
isPending: ref(true),
|
|
32
|
+
isFetching: ref(false),
|
|
33
|
+
error: ref(null)
|
|
34
|
+
},
|
|
35
|
+
loadError: ref("")
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const view = useViewCore({
|
|
39
|
+
resource
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
assert.equal(view.isLoading.value, true);
|
|
43
|
+
assert.equal(view.isFetching.value, false);
|
|
44
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { ref } from "vue";
|
|
4
|
+
import { createViewUiRuntime } from "../src/client/composables/runtime/viewUiRuntime.js";
|
|
5
|
+
|
|
6
|
+
test("createViewUiRuntime resolves api/list/edit paths with nested params", () => {
|
|
7
|
+
const runtime = createViewUiRuntime({
|
|
8
|
+
recordIdParam: "addressId",
|
|
9
|
+
routeParams: ref({
|
|
10
|
+
userId: "user-7",
|
|
11
|
+
addressId: "addr-42"
|
|
12
|
+
}),
|
|
13
|
+
routePath: ref("/users/user-7/addresses/addr-42"),
|
|
14
|
+
apiUrlTemplate: "/crud/users/:userId/addresses/:addressId",
|
|
15
|
+
listUrlTemplate: "..",
|
|
16
|
+
editUrlTemplate: "./edit"
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
assert.equal(runtime.recordId.value, "addr-42");
|
|
20
|
+
assert.equal(runtime.apiSuffix.value, "/crud/users/user-7/addresses/addr-42");
|
|
21
|
+
assert.equal(runtime.listUrl.value, "/users/user-7/addresses");
|
|
22
|
+
assert.equal(runtime.editUrl.value, "/users/user-7/addresses/addr-42/edit");
|
|
23
|
+
assert.equal(runtime.resolveParams("./edit"), "/users/user-7/addresses/addr-42/edit");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("createViewUiRuntime resolves view links from the record pathname when route is nested", () => {
|
|
27
|
+
const runtime = createViewUiRuntime({
|
|
28
|
+
recordIdParam: "contactId",
|
|
29
|
+
routeParams: ref({
|
|
30
|
+
workspaceSlug: "dogandgroom",
|
|
31
|
+
contactId: "541841",
|
|
32
|
+
petId: "715528"
|
|
33
|
+
}),
|
|
34
|
+
routeParamNames: ref(["workspaceSlug", "contactId", "petId"]),
|
|
35
|
+
routePath: ref("/w/dogandgroom/admin/contacts/541841/pets/715528"),
|
|
36
|
+
listUrlTemplate: "..",
|
|
37
|
+
editUrlTemplate: "./edit"
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
assert.equal(runtime.listUrl.value, "/w/dogandgroom/admin/contacts");
|
|
41
|
+
assert.equal(runtime.editUrl.value, "/w/dogandgroom/admin/contacts/541841/edit");
|
|
42
|
+
assert.equal(runtime.resolveParams("./edit"), "/w/dogandgroom/admin/contacts/541841/edit");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("createViewUiRuntime uses route param order when repeated values are present", () => {
|
|
46
|
+
const runtime = createViewUiRuntime({
|
|
47
|
+
recordIdParam: "contactId",
|
|
48
|
+
routeParams: ref({
|
|
49
|
+
workspaceSlug: "123",
|
|
50
|
+
contactId: "123",
|
|
51
|
+
petId: "123"
|
|
52
|
+
}),
|
|
53
|
+
routeParamNames: ref(["workspaceSlug", "contactId", "petId"]),
|
|
54
|
+
routePath: ref("/w/123/admin/contacts/123/pets/123"),
|
|
55
|
+
editUrlTemplate: "./edit"
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
assert.equal(runtime.editUrl.value, "/w/123/admin/contacts/123/edit");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("createViewUiRuntime uses explicit routeRecordId when route params do not include id", () => {
|
|
62
|
+
const runtime = createViewUiRuntime({
|
|
63
|
+
recordIdParam: "addressId",
|
|
64
|
+
routeParams: ref({
|
|
65
|
+
userId: "user-7"
|
|
66
|
+
}),
|
|
67
|
+
routeRecordId: ref("new-id"),
|
|
68
|
+
apiUrlTemplate: "/crud/users/:userId/addresses/:addressId"
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
assert.equal(runtime.recordId.value, "new-id");
|
|
72
|
+
assert.equal(runtime.apiSuffix.value, "/crud/users/user-7/addresses/new-id");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("createViewUiRuntime returns empty api suffix when required params are missing", () => {
|
|
76
|
+
const runtime = createViewUiRuntime({
|
|
77
|
+
recordIdParam: "addressId",
|
|
78
|
+
routeParams: ref({
|
|
79
|
+
addressId: "42"
|
|
80
|
+
}),
|
|
81
|
+
apiUrlTemplate: "/crud/users/:userId/addresses/:addressId"
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
assert.equal(runtime.apiSuffix.value, "");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("createViewUiRuntime validates route parameter names", () => {
|
|
88
|
+
assert.throws(
|
|
89
|
+
() =>
|
|
90
|
+
createViewUiRuntime({
|
|
91
|
+
recordIdParam: "record-id"
|
|
92
|
+
}),
|
|
93
|
+
/recordIdParam "record-id" is invalid/
|
|
94
|
+
);
|
|
95
|
+
});
|