@jskit-ai/http-web 0.1.92 → 0.1.93
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 +6 -6
- package/patterns/crud-screen-set/PATTERN.md +11 -1
- package/src/client/bulkActions.js +13 -40
- package/src/client/components/CrudAddEditScreen.vue +1 -9
- package/src/client/components/CrudListBulkActionSurface.vue +2 -5
- package/src/client/components/CrudListFilterControls.vue +175 -0
- package/src/client/components/CrudListFilterSurface.vue +5 -248
- package/src/client/components/CrudListScreen.vue +1 -28
- package/src/client/components/CrudViewScreen.vue +1 -9
- package/src/client/composables/crud/crudJsonApiTransportSupport.js +20 -42
- package/src/client/composables/crud/crudLookupFieldLabelSupport.js +2 -13
- package/src/client/composables/crud/crudLookupFieldRuntime.js +2 -27
- package/src/client/composables/crud/crudSchemaFormHelpers.js +1 -7
- package/src/client/composables/internal/crudListFilterLookupSupport.js +2 -1
- package/src/client/composables/internal/useOperationScope.js +5 -7
- package/src/client/composables/records/useAddEdit.js +12 -11
- package/src/client/composables/records/useCrudAddEdit.js +74 -22
- package/src/client/composables/records/useCrudList.js +0 -6
- package/src/client/composables/records/useCrudView.js +0 -6
- package/src/client/composables/records/useList.js +2 -8
- package/src/client/composables/records/useView.js +3 -8
- package/src/client/composables/runtime/addEditUiRuntime.js +12 -60
- package/src/client/composables/runtime/listUiRuntime.js +11 -36
- package/src/client/composables/runtime/useFieldErrorBag.js +3 -3
- package/src/client/composables/runtime/viewUiRuntime.js +14 -62
- package/src/client/composables/support/errorMessageHelpers.js +6 -48
- package/src/client/composables/support/resourceLoadStateHelpers.js +67 -156
- package/src/client/composables/support/routeTemplateHelpers.js +57 -0
- package/src/client/composables/useAccess.js +12 -17
- package/src/client/composables/useCommand.js +0 -1
- package/src/client/composables/useCrudDeleteAction.js +7 -5
- package/src/client/composables/useCrudListBulkActions.js +9 -21
- package/src/client/composables/useCrudListFilterLookups.js +1 -3
- package/src/client/composables/useCrudListParentTitle.js +0 -2
- package/src/client/composables/useCrudListRowActions.js +8 -24
- package/src/client/composables/useCrudListScreen.js +4 -5
- package/src/client/composables/useCrudViewScreen.js +2 -3
- package/src/client/composables/useRealtimeQueryInvalidation.js +3 -10
- package/src/client/composables/useScopeRuntime.js +2 -5
- package/src/client/rowActions.js +13 -40
- package/src/client/support/crudListActionSupport.js +54 -0
- package/test/crudAddEditActions.test.js +59 -19
- package/test/crudListFilterSurface.test.js +7 -4
- package/test/listUiRuntime.test.js +3 -1
- package/test/requestTransportOptions.test.js +24 -0
- package/test/resourceLoadStateHelpers.test.js +32 -0
- package/test/useAccess.test.js +62 -0
- package/test/useCrudDeleteAction.test.js +57 -2
- package/test/useCrudListScreen.test.js +45 -0
- package/test/useListRouteInitialization.test.js +5 -44
- package/src/client/composables/runtime/operationAdapters.js +0 -34
|
@@ -4,9 +4,10 @@ import { registerHooks } from "node:module";
|
|
|
4
4
|
import test from "node:test";
|
|
5
5
|
import { compileScript, parse } from "@vue/compiler-sfc";
|
|
6
6
|
import { QueryClient, VueQueryPlugin } from "@tanstack/vue-query";
|
|
7
|
-
import {
|
|
8
|
-
import { createMemoryHistory, createRouter
|
|
7
|
+
import { createRenderer, defineComponent, h, nextTick, ref } from "vue";
|
|
8
|
+
import { createMemoryHistory, createRouter } from "vue-router";
|
|
9
9
|
import { createSchema } from "json-rest-schema";
|
|
10
|
+
import { SHELL_WEB_PLACEMENT_RUNTIME_KEY } from "@jskit-ai/shell-web/client/placement";
|
|
10
11
|
import { useCrudAddEditScreen } from "../src/client/composables/useCrudAddEditScreen.js";
|
|
11
12
|
|
|
12
13
|
const componentUrl = new URL("../src/client/components/CrudAddEditScreen.vue", import.meta.url).href;
|
|
@@ -24,7 +25,7 @@ const hooks = registerHooks({
|
|
|
24
25
|
const { default: CrudAddEditScreen } = await import(componentUrl);
|
|
25
26
|
hooks.deregister();
|
|
26
27
|
|
|
27
|
-
async function fixture(t, { actions, onSaveSuccess, request, ...options } = {}) {
|
|
28
|
+
async function fixture(t, { actions, onSaveSuccess, request, screenResource = null, ...options } = {}) {
|
|
28
29
|
const node = (type, text = "") => ({ type, text, props: {}, children: [], parent: null });
|
|
29
30
|
const renderer = createRenderer({
|
|
30
31
|
createElement: node,
|
|
@@ -51,7 +52,18 @@ async function fixture(t, { actions, onSaveSuccess, request, ...options } = {})
|
|
|
51
52
|
});
|
|
52
53
|
await router.push("/books/new?source=list");
|
|
53
54
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
54
|
-
|
|
55
|
+
// Save permission comes from the web placement context, as in a real shell.
|
|
56
|
+
let placementContext = Object.freeze({ permissions: ["books.save"] });
|
|
57
|
+
const placementListeners = new Set();
|
|
58
|
+
const canSave = {
|
|
59
|
+
get value() {
|
|
60
|
+
return placementContext.permissions.includes("books.save");
|
|
61
|
+
},
|
|
62
|
+
set value(allowed) {
|
|
63
|
+
placementContext = Object.freeze({ permissions: allowed ? ["books.save"] : [] });
|
|
64
|
+
for (const listener of placementListeners) listener();
|
|
65
|
+
}
|
|
66
|
+
};
|
|
55
67
|
const reports = [];
|
|
56
68
|
const calls = [];
|
|
57
69
|
let screen;
|
|
@@ -59,6 +71,7 @@ async function fixture(t, { actions, onSaveSuccess, request, ...options } = {})
|
|
|
59
71
|
setup() {
|
|
60
72
|
screen = useCrudAddEditScreen({
|
|
61
73
|
mode: "new",
|
|
74
|
+
resource: screenResource,
|
|
62
75
|
saveLabel: "Save book",
|
|
63
76
|
cancelTo: "/books/:bookId",
|
|
64
77
|
preserveCancelQuery: true,
|
|
@@ -80,21 +93,10 @@ async function fixture(t, { actions, onSaveSuccess, request, ...options } = {})
|
|
|
80
93
|
return request ? request(path, requestOptions) : { id: calls.length, ...requestOptions.body };
|
|
81
94
|
}
|
|
82
95
|
},
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
scopeParamValue: ref(""),
|
|
88
|
-
normalizedOwnershipFilter: "none",
|
|
89
|
-
apiPath: ref("/api/books"),
|
|
90
|
-
queryKey: ref(["books", "new"]),
|
|
91
|
-
queryCanRun: () => ref(false),
|
|
92
|
-
permissionGate: (key) => key === "save" ? canSave : ref(true),
|
|
93
|
-
loadError: (value) => computed(() => unref(value)),
|
|
94
|
-
isLoading: (value) => computed(() => unref(value))
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
},
|
|
96
|
+
apiSuffix: "/books",
|
|
97
|
+
queryKeyFactory: () => ["books", "new"],
|
|
98
|
+
ownershipFilter: "public",
|
|
99
|
+
savePermissions: ["books.save"],
|
|
98
100
|
onSaveSuccess: onSaveSuccess ? (payload, context) => onSaveSuccess(screen, payload, context) : undefined,
|
|
99
101
|
...options
|
|
100
102
|
},
|
|
@@ -111,6 +113,16 @@ async function fixture(t, { actions, onSaveSuccess, request, ...options } = {})
|
|
|
111
113
|
});
|
|
112
114
|
app.use(router);
|
|
113
115
|
app.use(VueQueryPlugin, { queryClient });
|
|
116
|
+
app.provide(SHELL_WEB_PLACEMENT_RUNTIME_KEY, {
|
|
117
|
+
getContext: () => placementContext,
|
|
118
|
+
getPlacements: () => [],
|
|
119
|
+
setContext: () => placementContext,
|
|
120
|
+
subscribe(listener) {
|
|
121
|
+
placementListeners.add(listener);
|
|
122
|
+
return () => placementListeners.delete(listener);
|
|
123
|
+
},
|
|
124
|
+
getRevision: () => 0
|
|
125
|
+
});
|
|
114
126
|
app.provide("jskit.shell-web.runtime.web-error.client", {
|
|
115
127
|
report(value) { reports.push(value); },
|
|
116
128
|
dismiss() {}
|
|
@@ -310,3 +322,31 @@ test("route cleanup resets validation attempts unless clearOnRouteChange is disa
|
|
|
310
322
|
});
|
|
311
323
|
}
|
|
312
324
|
});
|
|
325
|
+
|
|
326
|
+
test("a save failure reports the resource's saveError from both the endpoint and the feedback", async (t) => {
|
|
327
|
+
const { screen, reports } = await fixture(t, {
|
|
328
|
+
screenResource: { messages: { saveError: "Could not save the book." } },
|
|
329
|
+
request() { throw Object.assign(new Error(""), { status: 500 }); }
|
|
330
|
+
});
|
|
331
|
+
Object.assign(screen.formState, { title: "Book", author: "Author" });
|
|
332
|
+
await screen.addEdit.submit();
|
|
333
|
+
|
|
334
|
+
assert.equal(screen.addEdit.resource.saveError.value, "Could not save the book.");
|
|
335
|
+
assert.equal(reports.length, 1);
|
|
336
|
+
assert.equal(reports[0].message, "Could not save the book.");
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("addEditOptions cannot become a second source for top-level form facts", async (t) => {
|
|
340
|
+
for (const [key, topLevel] of [
|
|
341
|
+
["resource", "resource"],
|
|
342
|
+
["input", "input"],
|
|
343
|
+
["buildRawPayload", "buildPayload"],
|
|
344
|
+
["mapLoadedToModel", "mapPayloadToModel"]
|
|
345
|
+
]) {
|
|
346
|
+
await assert.rejects(
|
|
347
|
+
fixture(t, { [key]: key === "resource" || key === "input" ? {} : () => ({}) }),
|
|
348
|
+
new RegExp(`takes ${topLevel} as a top-level option, not addEditOptions\\.${key}`)
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
await assert.rejects(fixture(t, { listUrlTemplat: "/books" }), /does not support addEditOptions\.listUrlTemplat/);
|
|
352
|
+
});
|
|
@@ -26,10 +26,13 @@ test("http-web exposes client-side CRUD list filter definition helpers", async (
|
|
|
26
26
|
});
|
|
27
27
|
|
|
28
28
|
test("CrudListFilterSurface provides adaptive controls without owning server semantics", async () => {
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
const componentsDir = path.join(PACKAGE_DIR, "src", "client", "components");
|
|
30
|
+
const surfaceSource = await readFile(path.join(componentsDir, "CrudListFilterSurface.vue"), "utf8");
|
|
31
|
+
const controlsSource = await readFile(path.join(componentsDir, "CrudListFilterControls.vue"), "utf8");
|
|
32
|
+
const source = `${surfaceSource}\n${controlsSource}`;
|
|
33
|
+
|
|
34
|
+
assert.match(surfaceSource, /<CrudListFilterControls[^>]*:runtime="runtime"[^>]*\/>/);
|
|
35
|
+
assert.match(surfaceSource, /<CrudListFilterControls[^>]*\bstacked\b[^>]*\/>/);
|
|
33
36
|
|
|
34
37
|
assert.match(source, /defineProps/);
|
|
35
38
|
assert.match(source, /useDisplay/);
|
|
@@ -15,7 +15,9 @@ test("createListUiRuntime resolves row keys and relative route templates from st
|
|
|
15
15
|
editUrlTemplate: "./:contactId/edit"
|
|
16
16
|
});
|
|
17
17
|
|
|
18
|
-
assert.equal(runtime.
|
|
18
|
+
assert.equal(runtime.hasViewUrl, true);
|
|
19
|
+
assert.equal(runtime.hasEditUrl, true);
|
|
20
|
+
assert.equal(Object.hasOwn(runtime, "actionColumnCount"), false);
|
|
19
21
|
assert.equal(runtime.resolveRowKey(items.value[0], 0), "abc 123");
|
|
20
22
|
assert.equal(runtime.resolveParams("./new"), "/w/acme/admin/contacts/new");
|
|
21
23
|
assert.equal(runtime.resolveViewUrl(items.value[0]), "/w/acme/admin/contacts/abc%20123");
|
|
@@ -293,6 +293,30 @@ test("endpoint resource reads attach request recovery metadata to query options"
|
|
|
293
293
|
});
|
|
294
294
|
});
|
|
295
295
|
|
|
296
|
+
test("realtime invalidation honours a function-form enabled option like the other operation options", async () => {
|
|
297
|
+
const subscribed = [];
|
|
298
|
+
const socket = {
|
|
299
|
+
on(eventName) { subscribed.push(eventName); },
|
|
300
|
+
off() {}
|
|
301
|
+
};
|
|
302
|
+
const app = createSSRApp({
|
|
303
|
+
setup() {
|
|
304
|
+
useEndpointResource({
|
|
305
|
+
queryKey: ["realtime-disabled"],
|
|
306
|
+
path: "/api/realtime-disabled",
|
|
307
|
+
client: { async request() { return {}; } },
|
|
308
|
+
realtime: { event: "records.changed", enabled: () => false }
|
|
309
|
+
});
|
|
310
|
+
return () => h("div");
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
app.use(VueQueryPlugin, { queryClient: new QueryClient() });
|
|
314
|
+
app.provide("jskit.realtime.runtime.client.socket", socket);
|
|
315
|
+
await renderToString(app);
|
|
316
|
+
|
|
317
|
+
assert.deepEqual(subscribed, []);
|
|
318
|
+
});
|
|
319
|
+
|
|
296
320
|
for (const kind of ["endpoint", "list", "list with caller signal"]) {
|
|
297
321
|
test(`repeated ${kind} refreshes abort superseded HTTP requests`, async (t) => {
|
|
298
322
|
const paginated = kind !== "endpoint";
|
|
@@ -187,3 +187,35 @@ test("mergeRequestRecoveryQueryMeta preserves disabled recovery metadata", () =>
|
|
|
187
187
|
}
|
|
188
188
|
);
|
|
189
189
|
});
|
|
190
|
+
|
|
191
|
+
test("request recovery accepts only its short option names", () => {
|
|
192
|
+
assert.deepEqual(buildRequestRecoveryMeta({ enabled: false, label: "Ignored" }), {
|
|
193
|
+
jskit: { requestRecovery: false }
|
|
194
|
+
});
|
|
195
|
+
assert.deepEqual(buildRequestRecoveryMeta({ dedupeWindowMs: "-5" }, { label: "Default" }), {
|
|
196
|
+
jskit: { requestRecoveryLabel: "Default", requestRecoveryDedupeWindowMs: 0 }
|
|
197
|
+
});
|
|
198
|
+
assert.throws(
|
|
199
|
+
() => buildRequestRecoveryMeta({ requestRecoveryLabel: "Long name" }),
|
|
200
|
+
/requestRecovery\.requestRecoveryLabel is not supported/
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("composable defaults fill only the recovery hints the query options leave unset", () => {
|
|
205
|
+
assert.deepEqual(
|
|
206
|
+
mergeRequestRecoveryQueryMeta(
|
|
207
|
+
{ meta: { jskit: { requestRecoveryDedupeWindowMs: 250 } } },
|
|
208
|
+
null,
|
|
209
|
+
{ label: "Default label", method: "get", dedupeWindowMs: 5000 }
|
|
210
|
+
),
|
|
211
|
+
{
|
|
212
|
+
meta: {
|
|
213
|
+
jskit: {
|
|
214
|
+
requestRecoveryDedupeWindowMs: 250,
|
|
215
|
+
requestRecoveryLabel: "Default label",
|
|
216
|
+
requestRecoveryMethod: "GET"
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
);
|
|
221
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createSSRApp, h } from "vue";
|
|
4
|
+
import { renderToString } from "vue/server-renderer";
|
|
5
|
+
import { SHELL_WEB_PLACEMENT_RUNTIME_KEY } from "@jskit-ai/shell-web/client/placement";
|
|
6
|
+
import { useAccess } from "../src/client/composables/useAccess.js";
|
|
7
|
+
|
|
8
|
+
async function renderAccess(placementContext, options = {}) {
|
|
9
|
+
let access = null;
|
|
10
|
+
const app = createSSRApp({
|
|
11
|
+
setup() {
|
|
12
|
+
access = useAccess(options);
|
|
13
|
+
return () => h("div");
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
app.provide(SHELL_WEB_PLACEMENT_RUNTIME_KEY, {
|
|
17
|
+
getContext: () => placementContext,
|
|
18
|
+
getPlacements: () => [],
|
|
19
|
+
setContext: () => placementContext,
|
|
20
|
+
subscribe: () => () => {},
|
|
21
|
+
getRevision: () => 0
|
|
22
|
+
});
|
|
23
|
+
await renderToString(app);
|
|
24
|
+
return access;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test("useAccess reads permissions from the placement context only", async () => {
|
|
28
|
+
const access = await renderAccess({ permissions: ["workspace.members.view"] });
|
|
29
|
+
|
|
30
|
+
assert.deepEqual(access.permissions.value, ["workspace.members.view"]);
|
|
31
|
+
assert.equal(access.can("workspace.members.view"), true);
|
|
32
|
+
assert.equal(access.canAny(["workspace.members.manage", "workspace.members.view"]), true);
|
|
33
|
+
assert.equal(access.canAll(["workspace.members.manage", "workspace.members.view"]), false);
|
|
34
|
+
assert.equal(access.awaitingPlacementPermissions.value, false);
|
|
35
|
+
assert.equal(access.permissionsUnavailableError.value, "");
|
|
36
|
+
assert.deepEqual(Object.keys(access).sort(), [
|
|
37
|
+
"accessMode",
|
|
38
|
+
"accessRequired",
|
|
39
|
+
"awaitingPlacementPermissions",
|
|
40
|
+
"can",
|
|
41
|
+
"canAll",
|
|
42
|
+
"canAny",
|
|
43
|
+
"permissions",
|
|
44
|
+
"permissionsUnavailableError"
|
|
45
|
+
]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("useAccess awaits placement permissions until the context carries them", async () => {
|
|
49
|
+
const access = await renderAccess({});
|
|
50
|
+
|
|
51
|
+
assert.deepEqual(access.permissions.value, []);
|
|
52
|
+
assert.equal(access.can("workspace.members.view"), false);
|
|
53
|
+
assert.equal(access.awaitingPlacementPermissions.value, true);
|
|
54
|
+
assert.equal(access.permissionsUnavailableError.value, "Permissions are unavailable in placement context.");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("useAccess reports nothing when disabled", async () => {
|
|
58
|
+
const access = await renderAccess({}, { enabled: false });
|
|
59
|
+
|
|
60
|
+
assert.equal(access.awaitingPlacementPermissions.value, false);
|
|
61
|
+
assert.equal(access.permissionsUnavailableError.value, "");
|
|
62
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
-
import { QueryClient, VueQueryPlugin } from "@tanstack/vue-query";
|
|
4
|
-
import { createSSRApp, h, nextTick, ref } from "vue";
|
|
3
|
+
import { QueryClient, QueryObserver, VueQueryPlugin } from "@tanstack/vue-query";
|
|
4
|
+
import { createSSRApp, effectScope, h, nextTick, ref, watch } from "vue";
|
|
5
5
|
import { renderToString } from "vue/server-renderer";
|
|
6
6
|
import { createMemoryHistory, createRouter } from "vue-router";
|
|
7
7
|
import { configureHttpWebClient, resetHttpWebClientForTests } from "../src/client/lib/httpClient.js";
|
|
@@ -111,6 +111,61 @@ test("useCrudDeleteAction confirms one DELETE, invalidates CRUD state, and retur
|
|
|
111
111
|
]);
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
+
test("useCrudDeleteAction leaves the deleted record's view before CRUD state is refetched", async () => {
|
|
115
|
+
const queryClient = new QueryClient({
|
|
116
|
+
defaultOptions: { queries: { retry: false } }
|
|
117
|
+
});
|
|
118
|
+
const viewQueryKey = ["crud", "notes", "view", "42"];
|
|
119
|
+
let viewFetches = 0;
|
|
120
|
+
let recordDeleted = false;
|
|
121
|
+
const viewObserver = new QueryObserver(queryClient, {
|
|
122
|
+
queryKey: viewQueryKey,
|
|
123
|
+
async queryFn() {
|
|
124
|
+
viewFetches += 1;
|
|
125
|
+
if (recordDeleted) {
|
|
126
|
+
throw Object.assign(new Error("Record not found."), { status: 404 });
|
|
127
|
+
}
|
|
128
|
+
return { id: "42" };
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
const stopObservingView = viewObserver.subscribe(() => {});
|
|
132
|
+
await queryClient.fetchQuery({ queryKey: viewQueryKey, queryFn: () => ({ id: "42" }) });
|
|
133
|
+
viewFetches = 0;
|
|
134
|
+
|
|
135
|
+
const client = {
|
|
136
|
+
async request() {
|
|
137
|
+
recordDeleted = true;
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
const { deleteAction, router } = await renderDeleteAction({ client, queryClient });
|
|
142
|
+
|
|
143
|
+
// The view screen unmounts (stops observing its query) when the route leaves it,
|
|
144
|
+
// in the same scheduler flush that re-renders the router view.
|
|
145
|
+
const scope = effectScope();
|
|
146
|
+
scope.run(() => {
|
|
147
|
+
watch(() => router.currentRoute.value.path, (path) => {
|
|
148
|
+
if (path === "/notes") {
|
|
149
|
+
stopObservingView();
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
deleteAction.request();
|
|
156
|
+
await deleteAction.confirm();
|
|
157
|
+
|
|
158
|
+
assert.equal(router.currentRoute.value.path, "/notes");
|
|
159
|
+
assert.equal(viewFetches, 0);
|
|
160
|
+
assert.equal(queryClient.getQueryState(viewQueryKey)?.isInvalidated, true);
|
|
161
|
+
assert.equal(queryClient.getQueryState(viewQueryKey)?.error, null);
|
|
162
|
+
} finally {
|
|
163
|
+
scope.stop();
|
|
164
|
+
stopObservingView();
|
|
165
|
+
queryClient.clear();
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
114
169
|
test("useCrudDeleteAction keeps the record dialog open with useful error feedback", async () => {
|
|
115
170
|
const client = {
|
|
116
171
|
async request() {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createSSRApp, h } from "vue";
|
|
4
|
+
import { renderToString } from "vue/server-renderer";
|
|
5
|
+
import { createMemoryHistory, createRouter } from "vue-router";
|
|
6
|
+
import { QueryClient, VueQueryPlugin } from "@tanstack/vue-query";
|
|
7
|
+
import { useCrudListScreen } from "../src/client/composables/useCrudListScreen.js";
|
|
8
|
+
|
|
9
|
+
async function renderListScreen(options) {
|
|
10
|
+
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: "/books", component: { render: () => h("div") } }] });
|
|
11
|
+
await router.push("/books");
|
|
12
|
+
const queryClient = new QueryClient();
|
|
13
|
+
let screen;
|
|
14
|
+
const app = createSSRApp({
|
|
15
|
+
setup() {
|
|
16
|
+
screen = useCrudListScreen({
|
|
17
|
+
resourceNamespace: "books",
|
|
18
|
+
apiSuffix: "/books",
|
|
19
|
+
readEnabled: false,
|
|
20
|
+
client: { async request() { return { items: [], nextCursor: null }; } },
|
|
21
|
+
...options
|
|
22
|
+
});
|
|
23
|
+
return () => h("div");
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
app.use(router);
|
|
27
|
+
app.use(VueQueryPlugin, { queryClient });
|
|
28
|
+
await renderToString(app);
|
|
29
|
+
queryClient.clear();
|
|
30
|
+
return screen;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test("list screen link availability is the list runtime's single answer", async () => {
|
|
34
|
+
const blank = await renderListScreen({ viewUrlTemplate: " ", editUrlTemplate: "" });
|
|
35
|
+
assert.equal(blank.hasViewUrl, false);
|
|
36
|
+
assert.equal(blank.hasEditUrl, false);
|
|
37
|
+
assert.equal(blank.hasViewUrl, blank.records.hasViewUrl);
|
|
38
|
+
|
|
39
|
+
const linked = await renderListScreen({ viewUrlTemplate: "/books/:recordId", editUrlTemplate: "/books/:recordId/edit" });
|
|
40
|
+
assert.equal(linked.hasViewUrl, true);
|
|
41
|
+
assert.equal(linked.hasEditUrl, true);
|
|
42
|
+
assert.equal(Object.hasOwn(linked.records, "actionColumnCount"), false);
|
|
43
|
+
assert.equal(Object.hasOwn(linked.records, "resolveFieldDisplay"), false);
|
|
44
|
+
assert.equal(linked.resolveRecordTitle({ name: "Dune" }), "Dune");
|
|
45
|
+
});
|
|
@@ -1,17 +1,8 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
3
|
import { QueryClient, VueQueryPlugin } from "@tanstack/vue-query";
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
createRenderer,
|
|
7
|
-
h,
|
|
8
|
-
unref
|
|
9
|
-
} from "vue";
|
|
10
|
-
import {
|
|
11
|
-
createMemoryHistory,
|
|
12
|
-
createRouter,
|
|
13
|
-
useRoute
|
|
14
|
-
} from "vue-router";
|
|
4
|
+
import { createRenderer, h } from "vue";
|
|
5
|
+
import { createMemoryHistory, createRouter } from "vue-router";
|
|
15
6
|
|
|
16
7
|
import { useList } from "../src/client/composables/records/useList.js";
|
|
17
8
|
import { useCrudListFilters } from "../src/client/composables/useCrudListFilters.js";
|
|
@@ -137,37 +128,6 @@ function createTestRenderer() {
|
|
|
137
128
|
});
|
|
138
129
|
}
|
|
139
130
|
|
|
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
131
|
async function mountRouteSynchronizedList(initialLocation) {
|
|
172
132
|
const calls = [];
|
|
173
133
|
const client = {
|
|
@@ -206,7 +166,8 @@ async function mountRouteSynchronizedList(initialLocation) {
|
|
|
206
166
|
setup() {
|
|
207
167
|
filterRuntime = useCrudListFilters(CURRENTNESS_FILTERS);
|
|
208
168
|
useList({
|
|
209
|
-
|
|
169
|
+
apiSuffix: "/contacts",
|
|
170
|
+
ownershipFilter: "public",
|
|
210
171
|
client,
|
|
211
172
|
queryKeyFactory: () => ["contacts"],
|
|
212
173
|
queryParams: filterRuntime.queryParams,
|
|
@@ -260,7 +221,7 @@ test("route-synchronized lists make one initial request with the hydrated route
|
|
|
260
221
|
assert.ok(signal instanceof AbortSignal);
|
|
261
222
|
assert.equal(signal.aborted, false);
|
|
262
223
|
assert.deepEqual(runtime.calls[0], {
|
|
263
|
-
path: "/contacts",
|
|
224
|
+
path: "/api/contacts",
|
|
264
225
|
options: {
|
|
265
226
|
method: "GET",
|
|
266
227
|
signal,
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import { useOperationScope } from "../internal/useOperationScope.js";
|
|
2
|
-
|
|
3
|
-
const WEB_OPERATION_ADAPTER_ID = "http-web-default";
|
|
4
|
-
|
|
5
|
-
function createWebOperationAdapter() {
|
|
6
|
-
return Object.freeze({
|
|
7
|
-
id: WEB_OPERATION_ADAPTER_ID,
|
|
8
|
-
useOperationScope(options = {}) {
|
|
9
|
-
return useOperationScope(options);
|
|
10
|
-
}
|
|
11
|
-
});
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
const defaultWebOperationAdapter = createWebOperationAdapter();
|
|
15
|
-
|
|
16
|
-
function resolveOperationAdapter(adapter, { context = "http-web operation adapter" } = {}) {
|
|
17
|
-
if (adapter == null) {
|
|
18
|
-
return defaultWebOperationAdapter;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
if (!adapter || typeof adapter !== "object" || Array.isArray(adapter)) {
|
|
22
|
-
throw new TypeError(`${context} must be an object when provided.`);
|
|
23
|
-
}
|
|
24
|
-
if (typeof adapter.useOperationScope !== "function") {
|
|
25
|
-
throw new TypeError(`${context} must expose useOperationScope(options).`);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
return adapter;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export {
|
|
32
|
-
createWebOperationAdapter,
|
|
33
|
-
resolveOperationAdapter
|
|
34
|
-
};
|