@jskit-ai/users-web 0.1.165 → 0.1.166

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.
@@ -3,7 +3,7 @@ import { HOME_COG_OUTLET } from "./src/shared/toolsOutletContracts.js";
3
3
  export default Object.freeze({
4
4
  packageVersion: 1,
5
5
  packageId: "@jskit-ai/users-web",
6
- version: "0.1.165",
6
+ version: "0.1.166",
7
7
  kind: "runtime",
8
8
  description: "Users web module: account/profile UI plus shared users web widgets.",
9
9
  dependsOn: [
@@ -287,9 +287,9 @@ export default Object.freeze({
287
287
  runtime: {
288
288
  "@mdi/js": "^7.4.47",
289
289
  "@jskit-ai/http-runtime": "0.1.146",
290
- "@jskit-ai/realtime": "0.1.144",
290
+ "@jskit-ai/realtime": "0.1.145",
291
291
  "@jskit-ai/kernel": "0.1.147",
292
- "@jskit-ai/shell-web": "0.1.148",
292
+ "@jskit-ai/shell-web": "0.1.149",
293
293
  "@jskit-ai/uploads-image-web": "0.1.123",
294
294
  "@jskit-ai/users-core": "0.1.161"
295
295
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/users-web",
3
- "version": "0.1.165",
3
+ "version": "0.1.166",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -22,6 +22,7 @@
22
22
  "./client/composables/useCommand": "./src/client/composables/useCommand.js",
23
23
  "./client/composables/useCrudAddEdit": "./src/client/composables/records/useCrudAddEdit.js",
24
24
  "./client/composables/useCrudAddEditScreen": "./src/client/composables/useCrudAddEditScreen.js",
25
+ "./client/composables/useCrudDeleteAction": "./src/client/composables/useCrudDeleteAction.js",
25
26
  "./client/composables/useCrudListBulkActions": "./src/client/composables/useCrudListBulkActions.js",
26
27
  "./client/composables/useCrudListRowActions": "./src/client/composables/useCrudListRowActions.js",
27
28
  "./client/composables/crudLookupFieldRuntime": "./src/client/composables/crud/crudLookupFieldRuntime.js",
@@ -49,8 +50,8 @@
49
50
  "@mdi/js": "^7.4.47",
50
51
  "@jskit-ai/http-runtime": "0.1.146",
51
52
  "@jskit-ai/kernel": "0.1.147",
52
- "@jskit-ai/realtime": "0.1.144",
53
- "@jskit-ai/shell-web": "0.1.148",
53
+ "@jskit-ai/realtime": "0.1.145",
54
+ "@jskit-ai/shell-web": "0.1.149",
54
55
  "@jskit-ai/uploads-image-web": "0.1.123",
55
56
  "@jskit-ai/users-core": "0.1.161"
56
57
  },
@@ -50,6 +50,7 @@ const resolvedDescription = computed(() =>
50
50
  >
51
51
  Back to {{ resourcePluralTitle }}
52
52
  </v-btn>
53
+ <slot name="actions" :screen="screen" :view="view" />
53
54
  <v-btn
54
55
  v-if="editLocation"
55
56
  color="primary"
@@ -90,6 +90,14 @@ function inferCrudJsonApiTransport(resource = null, { mode = "", operationName =
90
90
  });
91
91
  }
92
92
 
93
+ if (normalizedMode === "delete") {
94
+ return Object.freeze({
95
+ kind: "jsonapi-resource",
96
+ responseType: resourceType,
97
+ responseKind: "record"
98
+ });
99
+ }
100
+
93
101
  if (normalizedMode === "add-edit") {
94
102
  return Object.freeze({
95
103
  kind: "jsonapi-resource",
@@ -69,12 +69,20 @@ function toTimeInputValue(value) {
69
69
  return "";
70
70
  }
71
71
 
72
- const twentyFourHourMatch = normalized.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/u);
72
+ const twentyFourHourMatch = normalized.match(/^(\d{1,2}):(\d{2})(?::(\d{2})(\.\d+)?)?$/u);
73
73
  if (twentyFourHourMatch) {
74
74
  const hours = Number(twentyFourHourMatch[1]);
75
75
  const minutes = Number(twentyFourHourMatch[2]);
76
- if (hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59) {
77
- return `${padDateTimePart(hours)}:${padDateTimePart(minutes)}`;
76
+ const seconds = Number(twentyFourHourMatch[3] || 0);
77
+ if (
78
+ hours >= 0 && hours <= 23 &&
79
+ minutes >= 0 && minutes <= 59 &&
80
+ seconds >= 0 && seconds <= 59
81
+ ) {
82
+ const secondsValue = twentyFourHourMatch[3]
83
+ ? `:${padDateTimePart(seconds)}${twentyFourHourMatch[4] || ""}`
84
+ : "";
85
+ return `${padDateTimePart(hours)}:${padDateTimePart(minutes)}${secondsValue}`;
78
86
  }
79
87
  return normalized;
80
88
  }
@@ -103,16 +111,30 @@ function toDateTimeLocalInputValue(value) {
103
111
  return "";
104
112
  }
105
113
 
114
+ const sourceText = typeof value === "string" ? value.trim() : "";
115
+ const sourceFraction = sourceText.match(/T\d{2}:\d{2}:\d{2}\.(\d+)/u)?.[1] || "";
106
116
  const date = value instanceof Date ? value : new Date(value);
107
117
  if (Number.isNaN(date.getTime())) {
108
118
  return String(value);
109
119
  }
110
120
 
111
- return [
121
+ const minuteValue = [
112
122
  date.getFullYear(),
113
123
  padDateTimePart(date.getMonth() + 1),
114
124
  padDateTimePart(date.getDate())
115
125
  ].join("-") + `T${padDateTimePart(date.getHours())}:${padDateTimePart(date.getMinutes())}`;
126
+ if (sourceFraction && !/^0+$/u.test(sourceFraction)) {
127
+ return `${minuteValue}:${padDateTimePart(date.getSeconds())}.${sourceFraction}`;
128
+ }
129
+
130
+ const milliseconds = date.getMilliseconds();
131
+ if (milliseconds > 0) {
132
+ return `${minuteValue}:${padDateTimePart(date.getSeconds())}.${String(milliseconds).padStart(3, "0")}`;
133
+ }
134
+ if (date.getSeconds() > 0) {
135
+ return `${minuteValue}:${padDateTimePart(date.getSeconds())}`;
136
+ }
137
+ return minuteValue;
116
138
  }
117
139
 
118
140
  function toDateInputValue(value) {
@@ -146,7 +168,31 @@ function toDateInputValue(value) {
146
168
  return normalized;
147
169
  }
148
170
 
149
- function toIsoUtcDateTimeValue(value) {
171
+ function applyDateTimePrecision(isoValue, temporalPrecision) {
172
+ if (!Number.isInteger(temporalPrecision) || temporalPrecision < 0) {
173
+ return isoValue;
174
+ }
175
+
176
+ const match = String(isoValue || "").match(/^(.*?)(?:\.(\d+))?Z$/u);
177
+ if (!match?.[2]) {
178
+ return isoValue;
179
+ }
180
+
181
+ const fraction = match[2];
182
+ if (fraction.length <= temporalPrecision) {
183
+ return isoValue;
184
+ }
185
+
186
+ const excess = fraction.slice(temporalPrecision);
187
+ if (!/^0+$/u.test(excess)) {
188
+ return isoValue;
189
+ }
190
+
191
+ const retained = fraction.slice(0, temporalPrecision);
192
+ return `${match[1]}${retained ? `.${retained}` : ""}Z`;
193
+ }
194
+
195
+ function toIsoUtcDateTimeValue(value, temporalPrecision) {
150
196
  const normalized = String(value ?? "").trim();
151
197
  if (!normalized) {
152
198
  return "";
@@ -157,7 +203,12 @@ function toIsoUtcDateTimeValue(value) {
157
203
  return normalized;
158
204
  }
159
205
 
160
- return date.toISOString();
206
+ const sourceFraction = normalized.match(/T\d{2}:\d{2}:\d{2}\.(\d+)/u)?.[1] || "";
207
+ const dateIsoValue = date.toISOString();
208
+ const preciseIsoValue = sourceFraction
209
+ ? dateIsoValue.replace(/\.\d{3}Z$/u, `.${sourceFraction}Z`)
210
+ : dateIsoValue;
211
+ return applyDateTimePrecision(preciseIsoValue, temporalPrecision);
161
212
  }
162
213
 
163
214
  function resolveFormFieldInitialValue(field = {}) {
@@ -278,7 +329,7 @@ function buildCrudFormPayload(fields = [], model = {}) {
278
329
  }
279
330
 
280
331
  if (fieldFormat === "date-time") {
281
- const normalizedValue = toIsoUtcDateTimeValue(rawValue);
332
+ const normalizedValue = toIsoUtcDateTimeValue(rawValue, field.temporalPrecision);
282
333
  if (!normalizedValue) {
283
334
  if (clearAsNull) {
284
335
  payload[fieldKey] = null;
@@ -0,0 +1,160 @@
1
+ import { computed, proxyRefs, ref, unref } from "vue";
2
+ import { useRouter } from "vue-router";
3
+ import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
4
+ import { resolveCrudJsonApiTransport } from "./crud/crudJsonApiTransportSupport.js";
5
+ import { toQueryErrorMessage } from "./support/errorMessageHelpers.js";
6
+ import { useCommand } from "./useCommand.js";
7
+
8
+ function requireCrudDeleteOperation(resource = null) {
9
+ const operation = resource?.operations?.delete;
10
+ if (!operation || typeof operation !== "object" || Array.isArray(operation)) {
11
+ throw new TypeError("useCrudDeleteAction requires resource.operations.delete.");
12
+ }
13
+ if (normalizeText(operation.method).toUpperCase() !== "DELETE") {
14
+ throw new TypeError("useCrudDeleteAction requires resource.operations.delete.method to be DELETE.");
15
+ }
16
+ return operation;
17
+ }
18
+
19
+ function requireCrudViewScreen(screen = null) {
20
+ if (!screen || typeof screen !== "object" || typeof screen?.view?.resolveParams !== "function") {
21
+ throw new TypeError("useCrudDeleteAction requires a useCrudViewScreen() result.");
22
+ }
23
+ return screen;
24
+ }
25
+
26
+ function resolveDeleteApiSuffix(screen, apiUrlTemplate = "") {
27
+ const template = normalizeText(unref(apiUrlTemplate));
28
+ return template ? normalizeText(screen.view.resolveParams(template)) : "";
29
+ }
30
+
31
+ function resolveListLocation(screen) {
32
+ return unref(screen?.listLocation) || null;
33
+ }
34
+
35
+ function useCrudDeleteAction({
36
+ screen = null,
37
+ resource = null,
38
+ resourceNamespace = "",
39
+ apiUrlTemplate = "",
40
+ access = "auto",
41
+ client = null,
42
+ router: routerOverride = null,
43
+ fallbackDeleteError = "Unable to delete record."
44
+ } = {}) {
45
+ const resolvedScreen = requireCrudViewScreen(screen);
46
+ const deleteOperation = requireCrudDeleteOperation(resource);
47
+ const namespace = normalizeText(resourceNamespace, {
48
+ fallback: resource?.namespace
49
+ });
50
+ if (!namespace) {
51
+ throw new TypeError("useCrudDeleteAction requires resourceNamespace or resource.namespace.");
52
+ }
53
+
54
+ const activeRouter = routerOverride || useRouter();
55
+ if (!activeRouter || typeof activeRouter.push !== "function") {
56
+ throw new TypeError("useCrudDeleteAction requires an installed Vue Router.");
57
+ }
58
+
59
+ const isOpen = ref(false);
60
+ const error = ref("");
61
+ const deleteApiSuffix = computed(() => resolveDeleteApiSuffix(resolvedScreen, apiUrlTemplate));
62
+
63
+ async function handleDeleteSuccess(_response, context = {}) {
64
+ await context?.queryClient?.invalidateQueries?.({
65
+ queryKey: ["ui-generator", namespace]
66
+ });
67
+
68
+ const listLocation = resolveListLocation(resolvedScreen);
69
+ if (!listLocation) {
70
+ throw new Error("Deleted the record, but the generated list route is unavailable.");
71
+ }
72
+
73
+ isOpen.value = false;
74
+ await activeRouter.push(listLocation);
75
+ }
76
+
77
+ function handleDeleteError(cause) {
78
+ error.value = toQueryErrorMessage(
79
+ cause,
80
+ fallbackDeleteError,
81
+ "Unable to delete record."
82
+ );
83
+ }
84
+
85
+ const command = useCommand({
86
+ access,
87
+ apiSuffix: deleteApiSuffix,
88
+ writeMethod: deleteOperation.method,
89
+ client,
90
+ transport: resolveCrudJsonApiTransport(undefined, resource, {
91
+ mode: "delete"
92
+ }),
93
+ placementSource: `ui-generator.${namespace}.view.delete`,
94
+ fallbackRunError: fallbackDeleteError,
95
+ onRunSuccess: handleDeleteSuccess,
96
+ onRunError: handleDeleteError,
97
+ suppressSuccessMessage: true
98
+ });
99
+
100
+ const isDeleting = computed(() => Boolean(command.isRunning));
101
+ const canDelete = computed(() => {
102
+ const view = resolvedScreen.view;
103
+ return Boolean(
104
+ command.canRun &&
105
+ deleteApiSuffix.value &&
106
+ resolveListLocation(resolvedScreen) &&
107
+ unref(view.recordId) &&
108
+ unref(view.record) &&
109
+ !unref(view.isLoading) &&
110
+ !unref(view.isNotFound)
111
+ );
112
+ });
113
+
114
+ function request() {
115
+ if (!canDelete.value) {
116
+ return false;
117
+ }
118
+ error.value = "";
119
+ isOpen.value = true;
120
+ return true;
121
+ }
122
+
123
+ function cancel() {
124
+ if (isDeleting.value) {
125
+ return false;
126
+ }
127
+ error.value = "";
128
+ isOpen.value = false;
129
+ return true;
130
+ }
131
+
132
+ async function confirm() {
133
+ if (!isOpen.value || !canDelete.value || isDeleting.value) {
134
+ return null;
135
+ }
136
+
137
+ error.value = "";
138
+ try {
139
+ return await command.run();
140
+ } catch {
141
+ return null;
142
+ }
143
+ }
144
+
145
+ return proxyRefs({
146
+ isOpen,
147
+ isDeleting,
148
+ canDelete,
149
+ error,
150
+ request,
151
+ cancel,
152
+ confirm
153
+ });
154
+ }
155
+
156
+ export {
157
+ requireCrudDeleteOperation,
158
+ resolveDeleteApiSuffix,
159
+ useCrudDeleteAction
160
+ };
@@ -11,6 +11,7 @@ export {
11
11
  normalizeCrudApiAccess,
12
12
  resolveCrudHttpClient
13
13
  } from "./composables/crud/crudHttpClientSupport.js";
14
+ export { useCrudDeleteAction } from "./composables/useCrudDeleteAction.js";
14
15
 
15
16
  const clientProviders = Object.freeze([UsersWebClientProvider]);
16
17
 
@@ -69,6 +69,19 @@ test("inferCrudJsonApiTransport infers record request/response transport for CRU
69
69
  );
70
70
  });
71
71
 
72
+ test("inferCrudJsonApiTransport infers record response transport for CRUD delete", () => {
73
+ assert.deepEqual(
74
+ inferCrudJsonApiTransport(resource, {
75
+ mode: "delete"
76
+ }),
77
+ {
78
+ kind: "jsonapi-resource",
79
+ responseType: "pets",
80
+ responseKind: "record"
81
+ }
82
+ );
83
+ });
84
+
72
85
  test("inferCrudLookupJsonApiTransport infers collection transport from lookup namespace", () => {
73
86
  assert.deepEqual(
74
87
  inferCrudLookupJsonApiTransport({
@@ -45,6 +45,7 @@ test("CRUD screen components own generated list/view/form chrome centrally", asy
45
45
  assert.match(viewSource, /generated-ui-screen generated-ui-screen--operator ui-generator-view-element/);
46
46
  assert.match(viewSource, /ui-generator-view-panel/);
47
47
  assert.match(viewSource, /@click="view\.refresh"/);
48
+ assert.match(viewSource, /<slot name="actions" :screen="screen" :view="view" \/>/);
48
49
  assert.match(viewSource, /<slot name="before-fields"/);
49
50
  assert.match(viewSource, /<slot name="fields"/);
50
51
  assert.match(viewSource, /<slot name="after-fields"/);
@@ -87,13 +88,15 @@ test("CRUD screen composables are importable package APIs", async () => {
87
88
  viewModule,
88
89
  addEditModule,
89
90
  rowActionsModule,
90
- rowActionsRuntimeModule
91
+ rowActionsRuntimeModule,
92
+ deleteActionModule
91
93
  ] = await Promise.all([
92
94
  import("@jskit-ai/users-web/client/composables/useCrudListScreen"),
93
95
  import("@jskit-ai/users-web/client/composables/useCrudViewScreen"),
94
96
  import("@jskit-ai/users-web/client/composables/useCrudAddEditScreen"),
95
97
  import("@jskit-ai/users-web/client/rowActions"),
96
- import("@jskit-ai/users-web/client/composables/useCrudListRowActions")
98
+ import("@jskit-ai/users-web/client/composables/useCrudListRowActions"),
99
+ import("@jskit-ai/users-web/client/composables/useCrudDeleteAction")
97
100
  ]);
98
101
 
99
102
  assert.equal(typeof listModule.useCrudListScreen, "function");
@@ -101,4 +104,5 @@ test("CRUD screen composables are importable package APIs", async () => {
101
104
  assert.equal(typeof addEditModule.useCrudAddEditScreen, "function");
102
105
  assert.equal(typeof rowActionsModule.defineCrudListRowActions, "function");
103
106
  assert.equal(typeof rowActionsRuntimeModule.useCrudListRowActions, "function");
107
+ assert.equal(typeof deleteActionModule.useCrudDeleteAction, "function");
104
108
  });
@@ -33,6 +33,7 @@ test("users-web exports are explicit and aligned with production/template usage"
33
33
  "./client/composables/useView",
34
34
  "./client/composables/useCrudAddEdit",
35
35
  "./client/composables/useCrudAddEditScreen",
36
+ "./client/composables/useCrudDeleteAction",
36
37
  "./client/composables/useCrudListBulkActions",
37
38
  "./client/composables/useCrudListRowActions",
38
39
  "./client/composables/useCrudListFilterLookups",
@@ -108,6 +108,29 @@ test("buildCrudFormPayload and applyCrudPayloadToForm round-trip date-time field
108
108
  assert.equal(form.scheduledAt, "2024-01-02T03:04");
109
109
  });
110
110
 
111
+ test("date-time form values honor strict temporal precision without losing fractional tails", () => {
112
+ const noFraction = buildCrudFormPayload(
113
+ [{ key: "scheduledAt", type: "string", format: "date-time", temporalPrecision: 0 }],
114
+ { scheduledAt: "2024-01-02T03:04" }
115
+ );
116
+ assert.match(noFraction.scheduledAt, /T\d{2}:\d{2}:00Z$/u);
117
+ assert.doesNotMatch(noFraction.scheduledAt, /\.000Z$/u);
118
+
119
+ const microseconds = buildCrudFormPayload(
120
+ [{ key: "scheduledAt", type: "string", format: "date-time", temporalPrecision: 6 }],
121
+ { scheduledAt: "2024-01-02T03:04:05.123456" }
122
+ );
123
+ assert.match(microseconds.scheduledAt, /T\d{2}:\d{2}:05\.123456Z$/u);
124
+
125
+ const form = reactive({ scheduledAt: "" });
126
+ applyCrudPayloadToForm(
127
+ [{ key: "scheduledAt", type: "string", format: "date-time", temporalPrecision: 6 }],
128
+ form,
129
+ { scheduledAt: "2024-01-02T03:04:05.123456Z" }
130
+ );
131
+ assert.match(form.scheduledAt, /T\d{2}:\d{2}:05\.123456$/u);
132
+ });
133
+
111
134
  test("applyCrudPayloadToForm normalizes date values for HTML date inputs", () => {
112
135
  const fields = [
113
136
  { key: "publishedOn", type: "string", format: "date" },
@@ -137,7 +160,7 @@ test("applyCrudPayloadToForm normalizes date values for HTML date inputs", () =>
137
160
  });
138
161
  });
139
162
 
140
- test("buildCrudFormPayload normalizes time fields to canonical HH:MM", () => {
163
+ test("buildCrudFormPayload preserves strict time precision", () => {
141
164
  const fields = [
142
165
  { key: "fromTime", type: "string", format: "time" },
143
166
  { key: "toTime", type: "string", format: "time" }
@@ -150,7 +173,7 @@ test("buildCrudFormPayload normalizes time fields to canonical HH:MM", () => {
150
173
 
151
174
  assert.deepEqual(payload, {
152
175
  fromTime: "18:13",
153
- toTime: "18:45"
176
+ toTime: "18:45:00"
154
177
  });
155
178
  });
156
179
 
@@ -224,7 +247,7 @@ test("buildCrudFormPayload preserves nullable booleans while keeping non-nullabl
224
247
  );
225
248
  });
226
249
 
227
- test("applyCrudPayloadToForm normalizes time fields for form inputs", () => {
250
+ test("applyCrudPayloadToForm preserves strict time values for form inputs", () => {
228
251
  const fields = [
229
252
  { key: "fromTime", type: "string", format: "time" },
230
253
  { key: "toTime", type: "string", format: "time" }
@@ -240,11 +263,34 @@ test("applyCrudPayloadToForm normalizes time fields for form inputs", () => {
240
263
  });
241
264
 
242
265
  assert.deepEqual(form, {
243
- fromTime: "18:13",
266
+ fromTime: "18:13:00",
244
267
  toTime: "18:45"
245
268
  });
246
269
  });
247
270
 
271
+ test("strict temporal form values round-trip seconds and fractions", () => {
272
+ const fields = [
273
+ { key: "fromTime", type: "string", format: "time", temporalPrecision: 6 },
274
+ { key: "scheduledAt", type: "string", format: "date-time", temporalPrecision: 3 }
275
+ ];
276
+ const form = reactive({
277
+ fromTime: "",
278
+ scheduledAt: ""
279
+ });
280
+
281
+ applyCrudPayloadToForm(fields, form, {
282
+ fromTime: "18:13:14.123456",
283
+ scheduledAt: "2026-08-13T07:08:09.123Z"
284
+ });
285
+
286
+ assert.equal(form.fromTime, "18:13:14.123456");
287
+ assert.match(form.scheduledAt, /^2026-08-13T\d{2}:\d{2}:09\.123$/u);
288
+ assert.deepEqual(buildCrudFormPayload(fields, form), {
289
+ fromTime: "18:13:14.123456",
290
+ scheduledAt: "2026-08-13T07:08:09.123Z"
291
+ });
292
+ });
293
+
248
294
  test("applyCrudPayloadToForm maps payload values into reactive form model", () => {
249
295
  const form = reactive({
250
296
  name: "",
@@ -0,0 +1,139 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { QueryClient, VueQueryPlugin } from "@tanstack/vue-query";
4
+ import { createSSRApp, h, nextTick, ref } from "vue";
5
+ import { renderToString } from "vue/server-renderer";
6
+ import { createMemoryHistory, createRouter } from "vue-router";
7
+ import {
8
+ requireCrudDeleteOperation,
9
+ useCrudDeleteAction
10
+ } from "../src/client/composables/useCrudDeleteAction.js";
11
+
12
+ const deleteResource = Object.freeze({
13
+ namespace: "notes",
14
+ operations: Object.freeze({
15
+ delete: Object.freeze({
16
+ method: "DELETE"
17
+ })
18
+ })
19
+ });
20
+
21
+ function createTestRouter() {
22
+ return createRouter({
23
+ history: createMemoryHistory(),
24
+ routes: [
25
+ { path: "/notes", component: { render: () => h("div") } },
26
+ { path: "/notes/:noteId", component: { render: () => h("div") } }
27
+ ]
28
+ });
29
+ }
30
+
31
+ function createScreen() {
32
+ return Object.freeze({
33
+ view: Object.freeze({
34
+ recordId: ref("42"),
35
+ record: ref({ id: "42", title: "Keep it simple" }),
36
+ isLoading: ref(false),
37
+ isNotFound: ref(false),
38
+ resolveParams(template = "") {
39
+ return String(template || "").replace(":noteId", "42");
40
+ }
41
+ }),
42
+ listLocation: ref({ path: "/notes" })
43
+ });
44
+ }
45
+
46
+ async function renderDeleteAction({ client, queryClient = new QueryClient() } = {}) {
47
+ const router = createTestRouter();
48
+ await router.push("/notes/42");
49
+ await router.isReady();
50
+
51
+ let deleteAction = null;
52
+ const app = createSSRApp({
53
+ setup() {
54
+ deleteAction = useCrudDeleteAction({
55
+ screen: createScreen(),
56
+ resource: deleteResource,
57
+ resourceNamespace: "notes",
58
+ apiUrlTemplate: "/notes/:noteId",
59
+ client
60
+ });
61
+ return () => h("div");
62
+ }
63
+ });
64
+ app.use(router);
65
+ app.use(VueQueryPlugin, { queryClient });
66
+ await renderToString(app);
67
+
68
+ return { deleteAction, queryClient, router };
69
+ }
70
+
71
+ test("useCrudDeleteAction confirms one DELETE, invalidates CRUD state, and returns to the list", async () => {
72
+ const requests = [];
73
+ const invalidations = [];
74
+ let finishRequest = null;
75
+ const queryClient = new QueryClient();
76
+ queryClient.invalidateQueries = async function invalidateQueries(options = {}) {
77
+ invalidations.push(options);
78
+ };
79
+ const pendingRequest = new Promise((resolve) => {
80
+ finishRequest = resolve;
81
+ });
82
+ const client = {
83
+ async request(path, options = {}) {
84
+ requests.push({ path, options });
85
+ return pendingRequest;
86
+ }
87
+ };
88
+ const { deleteAction, router } = await renderDeleteAction({ client, queryClient });
89
+
90
+ assert.equal(deleteAction.canDelete, true);
91
+ assert.equal(deleteAction.request(), true);
92
+ assert.equal(deleteAction.isOpen, true);
93
+
94
+ const confirmation = deleteAction.confirm();
95
+ await nextTick();
96
+ assert.equal(deleteAction.isDeleting, true);
97
+ assert.equal(requests.length, 1);
98
+ assert.match(requests[0].path, /\/notes\/42$/);
99
+ assert.equal(requests[0].options.method, "DELETE");
100
+ assert.equal(Object.hasOwn(requests[0].options, "body"), false);
101
+
102
+ finishRequest(null);
103
+ await confirmation;
104
+
105
+ assert.equal(deleteAction.isDeleting, false);
106
+ assert.equal(deleteAction.isOpen, false);
107
+ assert.equal(router.currentRoute.value.path, "/notes");
108
+ assert.deepEqual(invalidations, [
109
+ { queryKey: ["ui-generator", "notes"] }
110
+ ]);
111
+ });
112
+
113
+ test("useCrudDeleteAction keeps the record dialog open with useful error feedback", async () => {
114
+ const client = {
115
+ async request() {
116
+ throw new Error("Delete service unavailable.");
117
+ }
118
+ };
119
+ const { deleteAction, router } = await renderDeleteAction({ client });
120
+
121
+ deleteAction.request();
122
+ await deleteAction.confirm();
123
+
124
+ assert.equal(deleteAction.isOpen, true);
125
+ assert.equal(deleteAction.isDeleting, false);
126
+ assert.equal(deleteAction.error, "Delete service unavailable.");
127
+ assert.equal(router.currentRoute.value.path, "/notes/42");
128
+ });
129
+
130
+ test("useCrudDeleteAction rejects resources without the canonical DELETE operation", () => {
131
+ assert.throws(
132
+ () => requireCrudDeleteOperation({ operations: {} }),
133
+ /requires resource\.operations\.delete/
134
+ );
135
+ assert.throws(
136
+ () => requireCrudDeleteOperation({ operations: { delete: { method: "POST" } } }),
137
+ /method to be DELETE/
138
+ );
139
+ });