@jskit-ai/http-web 0.1.59 → 0.1.61
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/src/client/components/CrudAddEditScreen.vue +20 -8
- package/src/client/composables/records/useAddEdit.js +7 -2
- package/src/client/composables/runtime/operationUiHelpers.js +3 -1
- package/src/client/composables/runtime/useAddEditCore.js +36 -7
- package/src/client/composables/runtime/useEndpointResource.js +3 -2
- package/src/client/composables/runtime/useListCore.js +4 -9
- package/test/crudAddEditActions.test.js +312 -0
- package/test/requestTransportOptions.test.js +70 -1
- package/test/useAddEditCore.test.js +2 -0
- package/test/useListRouteInitialization.test.js +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/http-web",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.61",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test"
|
|
@@ -44,14 +44,14 @@
|
|
|
44
44
|
"./client/support/contractGuards": "./src/client/support/contractGuards.js"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@jskit-ai/resource-crud-core": "0.1.
|
|
47
|
+
"@jskit-ai/resource-crud-core": "0.1.158",
|
|
48
48
|
"@mdi/js": "^7.4.47"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
52
|
-
"@jskit-ai/kernel": "0.1.
|
|
53
|
-
"@jskit-ai/realtime": "0.1.
|
|
54
|
-
"@jskit-ai/shell-web": "0.1.
|
|
51
|
+
"@jskit-ai/http-runtime": "0.1.214",
|
|
52
|
+
"@jskit-ai/kernel": "0.1.216",
|
|
53
|
+
"@jskit-ai/realtime": "0.1.213",
|
|
54
|
+
"@jskit-ai/shell-web": "0.1.220",
|
|
55
55
|
"@tanstack/vue-query": "^5.90.5",
|
|
56
56
|
"vue": "^3.5.13",
|
|
57
57
|
"vue-router": "^5.0.4",
|
|
@@ -52,15 +52,27 @@ function resolveCancelTo(target = cancelTo.value) {
|
|
|
52
52
|
<template>
|
|
53
53
|
<section class="crud-screen crud-screen--operator crud-add-edit-form d-flex flex-column ga-4">
|
|
54
54
|
<div class="crud-add-edit-form__actions">
|
|
55
|
-
<
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
:
|
|
60
|
-
|
|
55
|
+
<slot
|
|
56
|
+
name="actions"
|
|
57
|
+
:screen="screen"
|
|
58
|
+
:mode="mode"
|
|
59
|
+
:form-runtime="formRuntime"
|
|
60
|
+
:form-state="formState"
|
|
61
|
+
:add-edit="addEdit"
|
|
62
|
+
:save-label="saveLabel"
|
|
63
|
+
:cancel-to="resolveCancelTo()"
|
|
64
|
+
:submit="addEdit.submit"
|
|
61
65
|
>
|
|
62
|
-
|
|
63
|
-
|
|
66
|
+
<v-btn v-if="cancelTo" color="primary" variant="outlined" :to="resolveCancelTo(cancelTo)">Cancel</v-btn>
|
|
67
|
+
<v-btn
|
|
68
|
+
color="primary"
|
|
69
|
+
variant="flat"
|
|
70
|
+
:disabled="addEdit.isSubmitDisabled"
|
|
71
|
+
@click="addEdit.submit"
|
|
72
|
+
>
|
|
73
|
+
{{ addEdit.isSaving ? "Saving…" : saveLabel }}
|
|
74
|
+
</v-btn>
|
|
75
|
+
</slot>
|
|
64
76
|
</div>
|
|
65
77
|
|
|
66
78
|
<v-sheet rounded="lg" border class="crud-add-edit-form__panel">
|
|
@@ -44,6 +44,7 @@ function useAddEdit({
|
|
|
44
44
|
buildRawPayload,
|
|
45
45
|
buildSavePayload,
|
|
46
46
|
onSaveSuccess,
|
|
47
|
+
validationFeedback = true,
|
|
47
48
|
requestQueryParams = null,
|
|
48
49
|
recordIdParam = "recordId",
|
|
49
50
|
routeParams = null,
|
|
@@ -148,6 +149,7 @@ function useAddEdit({
|
|
|
148
149
|
buildRawPayload,
|
|
149
150
|
buildSavePayload,
|
|
150
151
|
onSaveSuccess,
|
|
152
|
+
validationFeedback,
|
|
151
153
|
messages: effectiveMessages
|
|
152
154
|
});
|
|
153
155
|
|
|
@@ -155,7 +157,7 @@ function useAddEdit({
|
|
|
155
157
|
enabled: clearOnRouteChange,
|
|
156
158
|
route: routeContext.route,
|
|
157
159
|
feedback,
|
|
158
|
-
|
|
160
|
+
onClear: addEdit.resetValidation
|
|
159
161
|
});
|
|
160
162
|
|
|
161
163
|
const isInitialLoading = operationScope.isLoading(endpointResource.isInitialLoading);
|
|
@@ -165,7 +167,7 @@ function useAddEdit({
|
|
|
165
167
|
Boolean(!canSave.value || addEdit.saving.value || isRefetching.value)
|
|
166
168
|
);
|
|
167
169
|
const isSubmitDisabled = computed(() =>
|
|
168
|
-
Boolean(isInitialLoading.value || isRefetching.value || !canSave.value)
|
|
170
|
+
Boolean(isInitialLoading.value || isRefetching.value || !canSave.value || addEdit.saving.value)
|
|
169
171
|
);
|
|
170
172
|
const loadError = operationScope.loadError(endpointResource.loadError);
|
|
171
173
|
const isLoading = operationScope.isLoading(endpointResource.isLoading);
|
|
@@ -207,6 +209,9 @@ function useAddEdit({
|
|
|
207
209
|
isLoading,
|
|
208
210
|
isSaving: addEdit.saving,
|
|
209
211
|
fieldErrors: addEdit.fieldErrors,
|
|
212
|
+
validationAttempted: addEdit.validationAttempted,
|
|
213
|
+
validationErrors: addEdit.validationErrors,
|
|
214
|
+
resetValidation: addEdit.resetValidation,
|
|
210
215
|
message: addEdit.message,
|
|
211
216
|
messageType: addEdit.messageType,
|
|
212
217
|
submit: addEdit.submit,
|
|
@@ -106,7 +106,8 @@ function setupRouteChangeCleanup({
|
|
|
106
106
|
enabled = true,
|
|
107
107
|
route = null,
|
|
108
108
|
feedback = null,
|
|
109
|
-
fieldBag = null
|
|
109
|
+
fieldBag = null,
|
|
110
|
+
onClear = null
|
|
110
111
|
} = {}) {
|
|
111
112
|
if (!enabled) {
|
|
112
113
|
return;
|
|
@@ -117,6 +118,7 @@ function setupRouteChangeCleanup({
|
|
|
117
118
|
() => {
|
|
118
119
|
feedback?.clear?.();
|
|
119
120
|
fieldBag?.clear?.();
|
|
121
|
+
onClear?.();
|
|
120
122
|
}
|
|
121
123
|
);
|
|
122
124
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useQueryClient } from "@tanstack/vue-query";
|
|
2
|
+
import { computed, ref } from "vue";
|
|
2
3
|
import { resolveFieldErrors } from "@jskit-ai/http-runtime/client";
|
|
3
4
|
import { validateOperationInput } from "./operationValidationHelpers.js";
|
|
4
5
|
import { watchResourceModelState } from "./modelStateHelpers.js";
|
|
@@ -15,6 +16,7 @@ function useAddEditCore({
|
|
|
15
16
|
buildRawPayload,
|
|
16
17
|
buildSavePayload,
|
|
17
18
|
onSaveSuccess,
|
|
19
|
+
validationFeedback = true,
|
|
18
20
|
messages = {}
|
|
19
21
|
} = {}) {
|
|
20
22
|
const queryClient = useQueryClient();
|
|
@@ -30,29 +32,50 @@ function useAddEditCore({
|
|
|
30
32
|
}
|
|
31
33
|
});
|
|
32
34
|
|
|
33
|
-
const
|
|
35
|
+
const submitting = ref(false);
|
|
36
|
+
const saving = computed(() => Boolean(submitting.value || resource?.isSaving?.value));
|
|
34
37
|
const fieldErrors = fieldBag?.errors;
|
|
35
38
|
const message = feedback?.message;
|
|
36
39
|
const messageType = feedback?.messageType;
|
|
40
|
+
const validationAttempted = ref(false);
|
|
41
|
+
const validationErrors = computed(() => {
|
|
42
|
+
if (!validationAttempted.value || !input) {
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Revalidate without changing server field errors or reporting feedback.
|
|
47
|
+
const result = validateOperationInput({ input, rawPayload: resolveRawPayload() });
|
|
48
|
+
return result.ok ? {} : resolveFieldErrors(result.failure);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
function resolveRawPayload() {
|
|
52
|
+
return typeof buildRawPayload === "function" ? buildRawPayload(model, {
|
|
53
|
+
queryClient,
|
|
54
|
+
resource
|
|
55
|
+
}) : {};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function resetValidation() {
|
|
59
|
+
validationAttempted.value = false;
|
|
60
|
+
fieldBag?.clear?.();
|
|
61
|
+
}
|
|
37
62
|
|
|
38
63
|
async function submit() {
|
|
39
|
-
if (!canSave?.value || saving
|
|
64
|
+
if (!canSave?.value || saving.value) {
|
|
40
65
|
return;
|
|
41
66
|
}
|
|
42
67
|
|
|
43
68
|
feedback?.clear?.();
|
|
44
69
|
fieldBag?.clear?.();
|
|
70
|
+
validationAttempted.value = true;
|
|
45
71
|
|
|
46
|
-
const rawPayload =
|
|
47
|
-
queryClient,
|
|
48
|
-
resource
|
|
49
|
-
}) : {};
|
|
72
|
+
const rawPayload = resolveRawPayload();
|
|
50
73
|
|
|
51
74
|
const validationResult = validateOperationInput({
|
|
52
75
|
input,
|
|
53
76
|
rawPayload,
|
|
54
77
|
fieldBag,
|
|
55
|
-
feedback,
|
|
78
|
+
feedback: validationFeedback === false ? null : feedback,
|
|
56
79
|
validationMessage: String(messages.validation || "Validation failed.")
|
|
57
80
|
});
|
|
58
81
|
if (!validationResult.ok) {
|
|
@@ -69,6 +92,7 @@ function useAddEditCore({
|
|
|
69
92
|
: parsedInput;
|
|
70
93
|
|
|
71
94
|
try {
|
|
95
|
+
submitting.value = true;
|
|
72
96
|
const queryKeySnapshot = queryKey?.value;
|
|
73
97
|
const payload = await resource.save(savePayload);
|
|
74
98
|
|
|
@@ -97,6 +121,8 @@ function useAddEditCore({
|
|
|
97
121
|
} catch (error) {
|
|
98
122
|
fieldBag?.apply?.(resolveFieldErrors(error));
|
|
99
123
|
feedback?.error?.(error, String(messages.saveError || "Unable to save."));
|
|
124
|
+
} finally {
|
|
125
|
+
submitting.value = false;
|
|
100
126
|
}
|
|
101
127
|
}
|
|
102
128
|
|
|
@@ -105,6 +131,9 @@ function useAddEditCore({
|
|
|
105
131
|
fieldErrors,
|
|
106
132
|
message,
|
|
107
133
|
messageType,
|
|
134
|
+
validationAttempted,
|
|
135
|
+
validationErrors,
|
|
136
|
+
resetValidation,
|
|
108
137
|
submit
|
|
109
138
|
});
|
|
110
139
|
}
|
|
@@ -91,7 +91,7 @@ function useEndpointResource({
|
|
|
91
91
|
|
|
92
92
|
const query = useQuery({
|
|
93
93
|
queryKey,
|
|
94
|
-
queryFn: () => {
|
|
94
|
+
queryFn: ({ signal }) => {
|
|
95
95
|
const requestPath = normalizedPath.value;
|
|
96
96
|
if (!requestPath) {
|
|
97
97
|
throw new Error("Resource path is required.");
|
|
@@ -102,7 +102,8 @@ function useEndpointResource({
|
|
|
102
102
|
method: readMethod,
|
|
103
103
|
query: readQuery,
|
|
104
104
|
transport
|
|
105
|
-
})
|
|
105
|
+
}),
|
|
106
|
+
signal
|
|
106
107
|
});
|
|
107
108
|
},
|
|
108
109
|
enabled: queryEnabled,
|
|
@@ -79,20 +79,15 @@ function useListCore({
|
|
|
79
79
|
queryKey,
|
|
80
80
|
initialPageParam,
|
|
81
81
|
enabled: queryEnabled,
|
|
82
|
-
queryFn: async ({ pageParam }) => {
|
|
82
|
+
queryFn: async ({ pageParam, signal }) => {
|
|
83
83
|
const requestPath = normalizedPath.value;
|
|
84
84
|
if (!requestPath) {
|
|
85
85
|
throw new Error("List path is required.");
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
requestOptions,
|
|
92
|
-
transport,
|
|
93
|
-
pageParam
|
|
94
|
-
})
|
|
95
|
-
);
|
|
88
|
+
const options = buildListRequestOptions({ requestOptions, transport, pageParam });
|
|
89
|
+
options.signal = options.signal ? AbortSignal.any([signal, options.signal]) : signal;
|
|
90
|
+
return activeClient.request(requestPath, options);
|
|
96
91
|
},
|
|
97
92
|
getNextPageParam,
|
|
98
93
|
selectItems,
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { registerHooks } from "node:module";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import { compileScript, parse } from "@vue/compiler-sfc";
|
|
6
|
+
import { QueryClient, VueQueryPlugin } from "@tanstack/vue-query";
|
|
7
|
+
import { computed, createRenderer, defineComponent, h, nextTick, ref, unref } from "vue";
|
|
8
|
+
import { createMemoryHistory, createRouter, useRoute } from "vue-router";
|
|
9
|
+
import { createSchema } from "json-rest-schema";
|
|
10
|
+
import { useCrudAddEditScreen } from "../src/client/composables/useCrudAddEditScreen.js";
|
|
11
|
+
|
|
12
|
+
const componentUrl = new URL("../src/client/components/CrudAddEditScreen.vue", import.meta.url).href;
|
|
13
|
+
const hooks = registerHooks({
|
|
14
|
+
load(url, context, nextLoad) {
|
|
15
|
+
if (url !== componentUrl) return nextLoad(url, context);
|
|
16
|
+
const { descriptor } = parse(readFileSync(new URL(url), "utf8"));
|
|
17
|
+
return {
|
|
18
|
+
format: "module",
|
|
19
|
+
shortCircuit: true,
|
|
20
|
+
source: compileScript(descriptor, { id: "crud-add-edit-actions", inlineTemplate: true }).content
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
const { default: CrudAddEditScreen } = await import(componentUrl);
|
|
25
|
+
hooks.deregister();
|
|
26
|
+
|
|
27
|
+
async function fixture(t, { actions, onSaveSuccess, request, ...options } = {}) {
|
|
28
|
+
const node = (type, text = "") => ({ type, text, props: {}, children: [], parent: null });
|
|
29
|
+
const renderer = createRenderer({
|
|
30
|
+
createElement: node,
|
|
31
|
+
createText: (text) => node("text", text),
|
|
32
|
+
createComment: () => node("comment"),
|
|
33
|
+
setText: (item, text) => { item.text = text; },
|
|
34
|
+
setElementText: (item, text) => { item.text = text; item.children = []; },
|
|
35
|
+
patchProp: (item, key, _previous, value) => { item.props[key] = value; },
|
|
36
|
+
parentNode: (item) => item.parent,
|
|
37
|
+
nextSibling: (item) => item.parent?.children[item.parent.children.indexOf(item) + 1] ?? null,
|
|
38
|
+
insert(item, parent, anchor) {
|
|
39
|
+
if (item.parent) item.parent.children.splice(item.parent.children.indexOf(item), 1);
|
|
40
|
+
item.parent = parent;
|
|
41
|
+
const index = anchor ? parent.children.indexOf(anchor) : -1;
|
|
42
|
+
if (index < 0) parent.children.push(item); else parent.children.splice(index, 0, item);
|
|
43
|
+
},
|
|
44
|
+
remove(item) {
|
|
45
|
+
if (item.parent) item.parent.children.splice(item.parent.children.indexOf(item), 1);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
const router = createRouter({
|
|
49
|
+
history: createMemoryHistory(),
|
|
50
|
+
routes: [{ path: "/books/:bookId?", component: { render: () => h("div") } }]
|
|
51
|
+
});
|
|
52
|
+
await router.push("/books/new?source=list");
|
|
53
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
54
|
+
const canSave = ref(true);
|
|
55
|
+
const reports = [];
|
|
56
|
+
const calls = [];
|
|
57
|
+
let screen;
|
|
58
|
+
const app = renderer.createApp({
|
|
59
|
+
setup() {
|
|
60
|
+
screen = useCrudAddEditScreen({
|
|
61
|
+
mode: "new",
|
|
62
|
+
saveLabel: "Save book",
|
|
63
|
+
cancelTo: "/books/:bookId",
|
|
64
|
+
preserveCancelQuery: true,
|
|
65
|
+
formFields: [{ key: "title", type: "string" }, { key: "author", type: "string" }],
|
|
66
|
+
input: {
|
|
67
|
+
schema: createSchema({
|
|
68
|
+
title: { type: "string", required: true, minLength: 1, messages: { minLength: "Title is required." } },
|
|
69
|
+
author: { type: "string", required: true, minLength: 1, messages: { minLength: "Author is required." } }
|
|
70
|
+
}),
|
|
71
|
+
mode: "replace"
|
|
72
|
+
},
|
|
73
|
+
addEditOptions: {
|
|
74
|
+
readEnabled: false,
|
|
75
|
+
writeMethod: "POST",
|
|
76
|
+
recordIdParam: "bookId",
|
|
77
|
+
client: {
|
|
78
|
+
async request(path, requestOptions) {
|
|
79
|
+
calls.push({ path, ...requestOptions });
|
|
80
|
+
return request ? request(path, requestOptions) : { id: calls.length, ...requestOptions.body };
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
adapter: {
|
|
84
|
+
useOperationScope() {
|
|
85
|
+
return {
|
|
86
|
+
routeContext: { route: useRoute(), currentSurfaceId: ref("admin") },
|
|
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
|
+
},
|
|
98
|
+
onSaveSuccess: onSaveSuccess ? (payload, context) => onSaveSuccess(screen, payload, context) : undefined,
|
|
99
|
+
...options
|
|
100
|
+
},
|
|
101
|
+
saveSuccess: { navigateToView: false, navigateToList: false }
|
|
102
|
+
});
|
|
103
|
+
return () => h(CrudAddEditScreen, { screen }, {
|
|
104
|
+
fields: ({ formState }) => h("input", {
|
|
105
|
+
value: formState.title,
|
|
106
|
+
onInput: (event) => { formState.title = event.target.value; }
|
|
107
|
+
}),
|
|
108
|
+
...(actions ? { actions } : {})
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
app.use(router);
|
|
113
|
+
app.use(VueQueryPlugin, { queryClient });
|
|
114
|
+
app.provide("jskit.shell-web.runtime.web-error.client", {
|
|
115
|
+
report(value) { reports.push(value); },
|
|
116
|
+
dismiss() {}
|
|
117
|
+
});
|
|
118
|
+
for (const [name, tag] of [
|
|
119
|
+
["VBtn", "button"], ["VForm", "form"], ["VSheet", "div"], ["VRow", "div"], ["VSkeletonLoader", "div"]
|
|
120
|
+
]) {
|
|
121
|
+
app.component(name, defineComponent({ setup: (_props, { slots }) => () => h(tag, {}, slots.default?.()) }));
|
|
122
|
+
}
|
|
123
|
+
const root = node("root");
|
|
124
|
+
app.mount(root);
|
|
125
|
+
t.after(() => { app.unmount(); queryClient.clear(); });
|
|
126
|
+
const all = (item = root) => [item, ...item.children.flatMap((child) => all(child))];
|
|
127
|
+
const button = (label) => all().find((item) => item.type === "button" &&
|
|
128
|
+
all(item).some((child) => child.text.trim() === label));
|
|
129
|
+
return { screen, router, queryClient, canSave, reports, calls, all, button };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
test("default form actions retain Cancel, Save, validation feedback, and keyboard submission", async (t) => {
|
|
133
|
+
const { screen, reports, calls, all, button } = await fixture(t);
|
|
134
|
+
const { addEdit, formState } = screen;
|
|
135
|
+
assert.deepEqual(button("Cancel").props.to, { path: "/books/new", query: { source: "list" } });
|
|
136
|
+
assert.equal(button("Save book").props.disabled, false);
|
|
137
|
+
assert.equal(addEdit.validationAttempted, false);
|
|
138
|
+
assert.deepEqual(addEdit.validationErrors, {});
|
|
139
|
+
|
|
140
|
+
await button("Save book").props.onClick();
|
|
141
|
+
assert.equal(calls.length, 0);
|
|
142
|
+
assert.equal(addEdit.validationAttempted, true);
|
|
143
|
+
assert.deepEqual(addEdit.validationErrors, { title: "Title is required.", author: "Author is required." });
|
|
144
|
+
assert.equal(reports.length, 1);
|
|
145
|
+
assert.equal(reports[0].cause.code, "validation_failed");
|
|
146
|
+
|
|
147
|
+
formState.title = " A book ";
|
|
148
|
+
assert.deepEqual(addEdit.validationErrors, { author: "Author is required." });
|
|
149
|
+
formState.author = "An author";
|
|
150
|
+
assert.deepEqual(addEdit.validationErrors, {});
|
|
151
|
+
assert.equal(reports.length, 1);
|
|
152
|
+
|
|
153
|
+
let prevented = false;
|
|
154
|
+
await all().find((item) => item.type === "form").props.onSubmit({ preventDefault() { prevented = true; } });
|
|
155
|
+
assert.equal(prevented, true);
|
|
156
|
+
assert.equal(calls.length, 1);
|
|
157
|
+
assert.equal(calls[0].method, "POST");
|
|
158
|
+
assert.deepEqual(calls[0].body, { title: "A book", author: "An author" });
|
|
159
|
+
assert.equal(reports.at(-1).severity, "success");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("actions slot supports local guidance and save-and-add-another through onSaveSuccess", async (t) => {
|
|
163
|
+
let actionScope;
|
|
164
|
+
let saved;
|
|
165
|
+
const activeAction = ref("");
|
|
166
|
+
const { screen, reports, calls, all, button, queryClient, router } = await fixture(t, {
|
|
167
|
+
validationFeedback: false,
|
|
168
|
+
actions(scope) {
|
|
169
|
+
actionScope = scope;
|
|
170
|
+
return ["Save", "Save and add another"].map((label) => h("div", {}, [
|
|
171
|
+
h("button", {
|
|
172
|
+
onClick() { activeAction.value = label; return scope.submit(); },
|
|
173
|
+
disabled: scope.addEdit.isSubmitDisabled
|
|
174
|
+
}, label),
|
|
175
|
+
activeAction.value === label && scope.addEdit.validationAttempted
|
|
176
|
+
? h("p", { "data-action": label }, Object.values(scope.addEdit.validationErrors).join(" "))
|
|
177
|
+
: null
|
|
178
|
+
]));
|
|
179
|
+
},
|
|
180
|
+
onSaveSuccess(currentScreen, payload, context) {
|
|
181
|
+
saved = { payload, context };
|
|
182
|
+
Object.assign(currentScreen.formState, { title: "", author: "" });
|
|
183
|
+
currentScreen.addEdit.resetValidation();
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
const { addEdit, formState } = screen;
|
|
187
|
+
assert.equal(button("Save book"), undefined);
|
|
188
|
+
assert.equal(button("Cancel"), undefined);
|
|
189
|
+
assert.equal(actionScope.screen, screen);
|
|
190
|
+
assert.equal(actionScope.formState, formState);
|
|
191
|
+
assert.equal(actionScope.formRuntime, screen.formRuntime);
|
|
192
|
+
assert.equal(actionScope.addEdit, addEdit);
|
|
193
|
+
assert.equal(actionScope.mode, "new");
|
|
194
|
+
assert.equal(actionScope.saveLabel, "Save book");
|
|
195
|
+
assert.deepEqual(actionScope.cancelTo, { path: "/books/new", query: { source: "list" } });
|
|
196
|
+
|
|
197
|
+
await button("Save and add another").props.onClick();
|
|
198
|
+
assert.equal(reports.length, 0);
|
|
199
|
+
assert.equal(calls.length, 0);
|
|
200
|
+
assert.equal(saved, undefined);
|
|
201
|
+
assert.equal(addEdit.validationErrors.title, "Title is required.");
|
|
202
|
+
assert.deepEqual(screen.resolveFieldErrors("title"), ["Title is required."]);
|
|
203
|
+
await nextTick();
|
|
204
|
+
assert.equal(all().find((item) => item.type === "p").props["data-action"], "Save and add another");
|
|
205
|
+
assert.equal(all().find((item) => item.type === "p").text, "Title is required. Author is required.");
|
|
206
|
+
formState.title = "First book";
|
|
207
|
+
await nextTick();
|
|
208
|
+
assert.equal(all().find((item) => item.type === "p").text, "Author is required.");
|
|
209
|
+
await button("Save").props.onClick();
|
|
210
|
+
await nextTick();
|
|
211
|
+
assert.equal(all().find((item) => item.type === "p").props["data-action"], "Save");
|
|
212
|
+
Object.assign(formState, { title: "First book", author: "An author" });
|
|
213
|
+
assert.deepEqual(addEdit.validationErrors, {});
|
|
214
|
+
await button("Save and add another").props.onClick();
|
|
215
|
+
await nextTick();
|
|
216
|
+
|
|
217
|
+
assert.equal(calls.length, 1);
|
|
218
|
+
assert.equal(saved.payload.title, "First book");
|
|
219
|
+
assert.equal(saved.context.queryClient, queryClient);
|
|
220
|
+
assert.deepEqual(saved.context.parsed, { title: "First book", author: "An author" });
|
|
221
|
+
assert.equal(formState.title, "");
|
|
222
|
+
assert.equal(addEdit.validationAttempted, false);
|
|
223
|
+
assert.deepEqual(addEdit.validationErrors, {});
|
|
224
|
+
assert.deepEqual(screen.resolveFieldErrors("title"), []);
|
|
225
|
+
assert.equal(reports.length, 1);
|
|
226
|
+
assert.equal(reports[0].severity, "success");
|
|
227
|
+
assert.equal(router.currentRoute.value.path, "/books/new");
|
|
228
|
+
|
|
229
|
+
await all().find((item) => item.type === "form").props.onSubmit({ preventDefault() {} });
|
|
230
|
+
assert.equal(calls.length, 1);
|
|
231
|
+
assert.equal(addEdit.validationAttempted, true);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("permission gating and duplicate-submit protection cover the request and success callback", async (t) => {
|
|
235
|
+
const response = Promise.withResolvers();
|
|
236
|
+
const success = Promise.withResolvers();
|
|
237
|
+
const { screen, calls, canSave, button } = await fixture(t, {
|
|
238
|
+
request: () => response.promise,
|
|
239
|
+
onSaveSuccess: () => success.promise
|
|
240
|
+
});
|
|
241
|
+
const { addEdit, formState } = screen;
|
|
242
|
+
canSave.value = false;
|
|
243
|
+
await nextTick();
|
|
244
|
+
assert.equal(button("Save book").props.disabled, true);
|
|
245
|
+
await addEdit.submit();
|
|
246
|
+
assert.equal(calls.length, 0);
|
|
247
|
+
assert.equal(addEdit.validationAttempted, false);
|
|
248
|
+
|
|
249
|
+
canSave.value = true;
|
|
250
|
+
Object.assign(formState, { title: "Book", author: "Author" });
|
|
251
|
+
const pending = addEdit.submit();
|
|
252
|
+
await addEdit.submit();
|
|
253
|
+
await nextTick();
|
|
254
|
+
assert.equal(calls.length, 1);
|
|
255
|
+
assert.equal(button("Saving…").props.disabled, true);
|
|
256
|
+
assert.equal(addEdit.isFieldLocked, true);
|
|
257
|
+
response.resolve({ id: 1, title: "Book", author: "Author" });
|
|
258
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
259
|
+
assert.equal(addEdit.resource.isSaving.value, false);
|
|
260
|
+
assert.equal(addEdit.isSaving, true);
|
|
261
|
+
await addEdit.submit();
|
|
262
|
+
assert.equal(calls.length, 1);
|
|
263
|
+
success.resolve();
|
|
264
|
+
await pending;
|
|
265
|
+
assert.equal(addEdit.isSaving, false);
|
|
266
|
+
assert.equal(addEdit.isSubmitDisabled, false);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("local validation feedback opt-out preserves permission, network, and server failure feedback", async (t) => {
|
|
270
|
+
for (const error of [
|
|
271
|
+
Object.assign(new Error("Permission denied."), { status: 403 }),
|
|
272
|
+
new TypeError("Failed to fetch"),
|
|
273
|
+
Object.assign(new Error("Title is already taken."), { status: 422, fieldErrors: { title: "Title is already taken." } }),
|
|
274
|
+
Object.assign(new Error("Server unavailable."), { status: 503 })
|
|
275
|
+
]) {
|
|
276
|
+
await t.test(error.message, async (t) => {
|
|
277
|
+
let succeeded = false;
|
|
278
|
+
const { screen, reports } = await fixture(t, {
|
|
279
|
+
validationFeedback: false,
|
|
280
|
+
request() { throw error; },
|
|
281
|
+
onSaveSuccess() { succeeded = true; }
|
|
282
|
+
});
|
|
283
|
+
Object.assign(screen.formState, { title: "Book", author: "Author" });
|
|
284
|
+
await screen.addEdit.submit();
|
|
285
|
+
assert.equal(succeeded, false);
|
|
286
|
+
assert.equal(reports.length, 1);
|
|
287
|
+
assert.equal(reports[0].cause, error);
|
|
288
|
+
assert.equal(screen.addEdit.isSaving, false);
|
|
289
|
+
assert.equal(screen.formState.title, "Book");
|
|
290
|
+
assert.deepEqual(screen.addEdit.validationErrors, {});
|
|
291
|
+
if (error.fieldErrors) {
|
|
292
|
+
screen.formState.author = "Another author";
|
|
293
|
+
assert.deepEqual(screen.addEdit.validationErrors, {});
|
|
294
|
+
assert.deepEqual(screen.resolveFieldErrors("title"), [error.fieldErrors.title]);
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("route cleanup resets validation attempts unless clearOnRouteChange is disabled", async (t) => {
|
|
301
|
+
for (const clearOnRouteChange of [true, false]) {
|
|
302
|
+
await t.test(String(clearOnRouteChange), async (t) => {
|
|
303
|
+
const { screen, router } = await fixture(t, { clearOnRouteChange, validationFeedback: false });
|
|
304
|
+
await screen.addEdit.submit();
|
|
305
|
+
await router.push("/books/other");
|
|
306
|
+
await nextTick();
|
|
307
|
+
assert.equal(screen.addEdit.validationAttempted, !clearOnRouteChange);
|
|
308
|
+
assert.equal(Object.keys(screen.addEdit.validationErrors).length, clearOnRouteChange ? 0 : 2);
|
|
309
|
+
if (clearOnRouteChange) assert.deepEqual(screen.resolveFieldErrors("title"), []);
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
});
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
configureHttpWebClient,
|
|
15
15
|
resetHttpWebClientForTests
|
|
16
16
|
} from "../src/client/lib/httpClient.js";
|
|
17
|
-
import { buildListRequestOptions } from "../src/client/composables/runtime/useListCore.js";
|
|
17
|
+
import { buildListRequestOptions, useListCore } from "../src/client/composables/runtime/useListCore.js";
|
|
18
18
|
import {
|
|
19
19
|
resolveOperationRealtimeOptions
|
|
20
20
|
} from "../src/client/composables/useRealtimeQueryInvalidation.js";
|
|
@@ -293,6 +293,75 @@ test("endpoint resource reads attach request recovery metadata to query options"
|
|
|
293
293
|
});
|
|
294
294
|
});
|
|
295
295
|
|
|
296
|
+
for (const kind of ["endpoint", "list", "list with caller signal"]) {
|
|
297
|
+
test(`repeated ${kind} refreshes abort superseded HTTP requests`, async (t) => {
|
|
298
|
+
const paginated = kind !== "endpoint";
|
|
299
|
+
const caller = kind === "list with caller signal" ? new AbortController() : null;
|
|
300
|
+
const queryClient = new QueryClient();
|
|
301
|
+
const queryKey = ["endpoint-cancellation"];
|
|
302
|
+
const requests = [];
|
|
303
|
+
const active = new Set();
|
|
304
|
+
let resource;
|
|
305
|
+
const payload = (revision) => paginated
|
|
306
|
+
? { pages: [{ items: [revision] }], pageParams: [null] }
|
|
307
|
+
: { revision };
|
|
308
|
+
queryClient.setQueryData(queryKey, payload(0));
|
|
309
|
+
const app = createSSRApp({
|
|
310
|
+
setup() {
|
|
311
|
+
resource = (paginated ? useListCore : useEndpointResource)({
|
|
312
|
+
queryKey,
|
|
313
|
+
path: "/api/endpoint-cancellation",
|
|
314
|
+
requestOptions: caller ? { signal: caller.signal } : null,
|
|
315
|
+
queryOptions: { staleTime: Infinity, retry: false },
|
|
316
|
+
client: {
|
|
317
|
+
request(_path, { signal }) {
|
|
318
|
+
return new Promise((resolve, reject) => {
|
|
319
|
+
const request = { signal, resolve };
|
|
320
|
+
requests.push(request);
|
|
321
|
+
active.add(request);
|
|
322
|
+
signal?.addEventListener("abort", () => {
|
|
323
|
+
active.delete(request);
|
|
324
|
+
reject(new DOMException("Query cancelled", "AbortError"));
|
|
325
|
+
}, { once: true });
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
return () => h("div");
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
app.use(VueQueryPlugin, { queryClient });
|
|
334
|
+
t.after(() => {
|
|
335
|
+
queryClient.clear();
|
|
336
|
+
for (const request of requests) request.resolve({ revision: -1 });
|
|
337
|
+
});
|
|
338
|
+
await renderToString(app);
|
|
339
|
+
|
|
340
|
+
const refreshes = [];
|
|
341
|
+
for (let revision = 1; revision <= 10; revision += 1) {
|
|
342
|
+
// SSR has no mounted observer; include its cached query in invalidation.
|
|
343
|
+
refreshes.push(revision % 2 === 0
|
|
344
|
+
? queryClient.invalidateQueries({ queryKey, refetchType: "all" })
|
|
345
|
+
: resource.reload());
|
|
346
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
347
|
+
assert.equal(requests.length, revision);
|
|
348
|
+
assert.equal(active.size, 1, "only the latest refresh should keep an HTTP request alive");
|
|
349
|
+
}
|
|
350
|
+
assert.equal(requests[0].signal.aborted, true);
|
|
351
|
+
assert.equal(requests.at(-1).signal.aborted, false);
|
|
352
|
+
requests.at(-1).resolve(paginated ? { items: [10] } : { revision: 10 });
|
|
353
|
+
await Promise.all(refreshes);
|
|
354
|
+
assert.deepEqual(queryClient.getQueryData(queryKey), payload(10));
|
|
355
|
+
if (caller) {
|
|
356
|
+
const pending = resource.reload();
|
|
357
|
+
caller.abort();
|
|
358
|
+
assert.equal(requests.at(-1).signal.aborted, true);
|
|
359
|
+
await pending;
|
|
360
|
+
assert.equal(resource.query.error.value.name, "AbortError");
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
296
365
|
test("endpoint resources use the configured http-web HTTP client by default", async () => {
|
|
297
366
|
const queryClient = new QueryClient();
|
|
298
367
|
const calls = [];
|
|
@@ -117,6 +117,8 @@ test("useAddEditCore snapshots the cache key after payload normalization", async
|
|
|
117
117
|
await runtime.submit();
|
|
118
118
|
scope.stop();
|
|
119
119
|
|
|
120
|
+
assert.equal(runtime.validationAttempted.value, true);
|
|
121
|
+
assert.deepEqual(runtime.validationErrors.value, {});
|
|
120
122
|
assert.equal(model.status, "published");
|
|
121
123
|
assert.equal(queryClient.getQueryData(["products", "draft"]), undefined);
|
|
122
124
|
assert.deepEqual(queryClient.getQueryData(["products", "normalized"]), payload);
|
|
@@ -256,10 +256,14 @@ test("route-synchronized lists make one initial request with the hydrated route
|
|
|
256
256
|
});
|
|
257
257
|
|
|
258
258
|
assert.equal(runtime.calls.length, 1);
|
|
259
|
+
const { signal } = runtime.calls[0].options;
|
|
260
|
+
assert.ok(signal instanceof AbortSignal);
|
|
261
|
+
assert.equal(signal.aborted, false);
|
|
259
262
|
assert.deepEqual(runtime.calls[0], {
|
|
260
263
|
path: "/contacts",
|
|
261
264
|
options: {
|
|
262
265
|
method: "GET",
|
|
266
|
+
signal,
|
|
263
267
|
query: {
|
|
264
268
|
currentness: "archived",
|
|
265
269
|
limit: 20
|