@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/http-web",
3
- "version": "0.1.59",
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.156",
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.212",
52
- "@jskit-ai/kernel": "0.1.214",
53
- "@jskit-ai/realtime": "0.1.211",
54
- "@jskit-ai/shell-web": "0.1.218",
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
- <v-btn v-if="cancelTo" color="primary" variant="outlined" :to="resolveCancelTo(cancelTo)">Cancel</v-btn>
56
- <v-btn
57
- color="primary"
58
- variant="flat"
59
- :disabled="addEdit.isSubmitDisabled"
60
- @click="addEdit.submit"
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
- {{ addEdit.isSaving ? "Saving…" : saveLabel }}
63
- </v-btn>
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
- fieldBag
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 saving = resource?.isSaving;
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?.value) {
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 = typeof buildRawPayload === "function" ? buildRawPayload(model, {
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
- return activeClient.request(
89
- requestPath,
90
- buildListRequestOptions({
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