@jskit-ai/shell-web 0.1.208 → 0.1.210

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/shell-web",
3
- "version": "0.1.208",
3
+ "version": "0.1.210",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -31,7 +31,7 @@
31
31
  "@mdi/js": "^7.4.47"
32
32
  },
33
33
  "peerDependencies": {
34
- "@jskit-ai/kernel": "0.1.204",
34
+ "@jskit-ai/kernel": "0.1.206",
35
35
  "pinia": "^3.0.4",
36
36
  "vue": "^3.5.13",
37
37
  "vue-router": "^5.0.4",
@@ -106,13 +106,17 @@ function dismiss(entry) {
106
106
  store.dismiss(entry.channel, entry.id);
107
107
  }
108
108
 
109
- function runAction(entry) {
110
- if (!entry || !entry.action || typeof entry.action.handler !== "function") {
109
+ function actionsFor(entry) {
110
+ return entry ? [entry.action, ...entry.additionalActions].filter(Boolean) : [];
111
+ }
112
+
113
+ async function runAction(entry, action) {
114
+ if (!entry || typeof action?.handler !== "function") {
111
115
  return;
112
116
  }
113
117
 
114
118
  try {
115
- entry.action.handler(entry);
119
+ await action.handler(entry);
116
120
  } catch (error) {
117
121
  runtime.report({
118
122
  source: "shell-web.error-host.action",
@@ -123,7 +127,7 @@ function runAction(entry) {
123
127
  });
124
128
  }
125
129
 
126
- if (entry.action.dismissOnRun !== false) {
130
+ if (action.dismissOnRun !== false) {
127
131
  dismiss(entry);
128
132
  }
129
133
  }
@@ -171,13 +175,14 @@ function onSnackbarAfterLeave() {
171
175
  <span>{{ entry.message }}</span>
172
176
  <v-spacer />
173
177
  <v-btn
174
- v-if="entry.action"
178
+ v-for="(action, index) in actionsFor(entry)"
179
+ :key="index"
175
180
  variant="text"
176
181
  size="small"
177
182
  class="text-none"
178
- @click="runAction(entry)"
183
+ @click="runAction(entry, action)"
179
184
  >
180
- {{ entry.action.label }}
185
+ {{ action.label }}
181
186
  </v-btn>
182
187
  </div>
183
188
  </v-alert>
@@ -188,6 +193,7 @@ function onSnackbarAfterLeave() {
188
193
  :model-value="snackbarOpen"
189
194
  location="bottom end"
190
195
  :timeout="resolveTimeout(displayedSnackbarEntry)"
196
+ :vertical="actionsFor(displayedSnackbarEntry).length > 1"
191
197
  :color="displayedSnackbarEntry ? resolveSeverityColor(displayedSnackbarEntry.severity) : undefined"
192
198
  @update:model-value="onSnackbarModelValue"
193
199
  @after-leave="onSnackbarAfterLeave"
@@ -196,12 +202,13 @@ function onSnackbarAfterLeave() {
196
202
 
197
203
  <template #actions>
198
204
  <v-btn
199
- v-if="displayedSnackbarEntry?.action"
205
+ v-for="(action, index) in actionsFor(displayedSnackbarEntry)"
206
+ :key="index"
200
207
  variant="text"
201
- size="small"
202
- @click="runAction(displayedSnackbarEntry)"
208
+ min-height="48"
209
+ @click="runAction(displayedSnackbarEntry, action)"
203
210
  >
204
- {{ displayedSnackbarEntry.action.label }}
211
+ {{ action.label }}
205
212
  </v-btn>
206
213
  <v-btn
207
214
  v-if="displayedSnackbarEntry"
@@ -226,11 +233,12 @@ function onSnackbarAfterLeave() {
226
233
  <v-card-actions>
227
234
  <v-spacer />
228
235
  <v-btn
229
- v-if="dialogEntry.action"
236
+ v-for="(action, index) in actionsFor(dialogEntry)"
237
+ :key="index"
230
238
  variant="text"
231
- @click="runAction(dialogEntry)"
239
+ @click="runAction(dialogEntry, action)"
232
240
  >
233
- {{ dialogEntry.action.label }}
241
+ {{ action.label }}
234
242
  </v-btn>
235
243
  <v-btn
236
244
  color="primary"
@@ -79,6 +79,10 @@ function normalizeAction(value) {
79
79
  });
80
80
  }
81
81
 
82
+ function normalizeAdditionalActions(value) {
83
+ return Object.freeze(Array.isArray(value) ? value.map(normalizeAction).filter(Boolean) : []);
84
+ }
85
+
82
86
  export {
83
87
  ERROR_CHANNELS,
84
88
  ERROR_SEVERITIES,
@@ -89,5 +93,6 @@ export {
89
93
  normalizeSeverity,
90
94
  normalizeErrorIntent,
91
95
  normalizeNonNegativeInteger,
92
- normalizeAction
96
+ normalizeAction,
97
+ normalizeAdditionalActions
93
98
  };
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  normalizeAction,
3
+ normalizeAdditionalActions,
3
4
  normalizeChannel,
4
5
  normalizeErrorIntent,
5
6
  normalizeSeverity,
@@ -53,6 +54,7 @@ function createDefaultErrorPolicy({
53
54
  severity: normalizeSeverity(event.severity, normalizedDefaultSeverity),
54
55
  presenterId: normalizeText(event.presenterId),
55
56
  action: normalizeAction(event.action),
57
+ additionalActions: normalizeAdditionalActions(event.additionalActions),
56
58
  persist: channel !== "snackbar",
57
59
  dedupeKey: normalizeText(event.dedupeKey)
58
60
  });
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  isRecord,
3
3
  normalizeAction,
4
+ normalizeAdditionalActions,
4
5
  normalizeChannel,
5
6
  normalizeErrorIntent,
6
7
  normalizeNonNegativeInteger,
@@ -49,6 +50,7 @@ function normalizeErrorEvent(rawEvent = {}) {
49
50
  channel: normalizeChannel(source.channel),
50
51
  presenterId: normalizeText(source.presenterId),
51
52
  action: normalizeAction(source.action),
53
+ additionalActions: normalizeAdditionalActions(source.additionalActions),
52
54
  persist: typeof source.persist === "boolean" ? source.persist : null,
53
55
  blocking: source.blocking === true,
54
56
  dedupeKey: normalizeText(source.dedupeKey),
@@ -71,6 +73,7 @@ function normalizePolicyDecision(policyDecision = {}, event = {}) {
71
73
  message: normalizeText(source.message || event.userMessage || event.message, "Request failed."),
72
74
  severity: normalizeSeverity(source.severity || event.severity, "error"),
73
75
  action: normalizeAction(source.action || event.action),
76
+ additionalActions: normalizeAdditionalActions(source.additionalActions ?? event.additionalActions),
74
77
  persist:
75
78
  typeof source.persist === "boolean"
76
79
  ? source.persist
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  normalizeAction,
3
+ normalizeAdditionalActions,
3
4
  normalizeSeverity,
4
5
  normalizeText
5
6
  } from "./normalize.js";
@@ -24,6 +25,7 @@ function cloneEntry(entry = {}) {
24
25
  severity: String(entry.severity || "error").trim(),
25
26
  persist: Boolean(entry.persist),
26
27
  action: entry.action || null,
28
+ additionalActions: entry.additionalActions,
27
29
  presenterId: String(entry.presenterId || "").trim(),
28
30
  dedupeKey: String(entry.dedupeKey || "").trim(),
29
31
  timestamp: Number(entry.timestamp || 0)
@@ -80,6 +82,7 @@ function createErrorPresentationStore({
80
82
  severity: normalizeSeverity(payload.severity, "error"),
81
83
  persist: typeof payload.persist === "boolean" ? payload.persist : normalizedChannel !== "snackbar",
82
84
  action: normalizeAction(payload.action),
85
+ additionalActions: normalizeAdditionalActions(payload.additionalActions),
83
86
  presenterId: normalizeText(payload.presenterId),
84
87
  dedupeKey: normalizeText(payload.dedupeKey),
85
88
  timestamp: Number(now())
@@ -0,0 +1,113 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { readFileSync } from "node:fs";
4
+ import { registerHooks } from "node:module";
5
+ import { compileScript, parse } from "@vue/compiler-sfc";
6
+ import { createRenderer, defineComponent, h, nextTick } from "vue";
7
+ import { createPinia } from "pinia";
8
+ import { createErrorRuntime } from "../src/client/error/runtime.js";
9
+ import { createDefaultMaterialErrorPresenters } from "../src/client/error/presenters.js";
10
+ import { createErrorPresentationStore } from "../src/client/error/store.js";
11
+ import { useShellErrorPresentationStore } from "../src/client/stores/useShellErrorPresentationStore.js";
12
+
13
+ const componentUrl = new URL("../src/client/components/ShellErrorHost.vue", import.meta.url).href;
14
+ const hooks = registerHooks({
15
+ load(url, context, nextLoad) {
16
+ if (url !== componentUrl) return nextLoad(url, context);
17
+ const { descriptor } = parse(readFileSync(new URL(url), "utf8"));
18
+ return { format: "module", shortCircuit: true,
19
+ source: compileScript(descriptor, { id: "error-host", inlineTemplate: true }).content };
20
+ }
21
+ });
22
+ const { default: ShellErrorHost } = await import(componentUrl);
23
+ hooks.deregister();
24
+
25
+ function fixture(t) {
26
+ const node = (type, text = "") => ({ type, text, props: {}, children: [], parent: null });
27
+ const renderer = createRenderer({
28
+ createElement: node, createText: (text) => node("text", text), createComment: () => node("comment"),
29
+ setText: (item, text) => { item.text = text; },
30
+ setElementText: (item, text) => { item.text = text; item.children = []; },
31
+ patchProp: (item, key, _previous, value) => { item.props[key] = value; },
32
+ parentNode: (item) => item.parent,
33
+ nextSibling: (item) => item.parent?.children[item.parent.children.indexOf(item) + 1] ?? null,
34
+ insert(item, parent, anchor) {
35
+ if (item.parent) item.parent.children.splice(item.parent.children.indexOf(item), 1);
36
+ item.parent = parent;
37
+ const index = anchor ? parent.children.indexOf(anchor) : -1;
38
+ if (index < 0) parent.children.push(item); else parent.children.splice(index, 0, item);
39
+ },
40
+ remove(item) { if (item.parent) item.parent.children.splice(item.parent.children.indexOf(item), 1); }
41
+ });
42
+ const store = createErrorPresentationStore();
43
+ const runtime = createErrorRuntime({
44
+ presenters: createDefaultMaterialErrorPresenters({ store }),
45
+ defaultPresenterId: "material.snackbar"
46
+ });
47
+ const app = renderer.createApp(ShellErrorHost);
48
+ const pinia = createPinia();
49
+ app.use(pinia);
50
+ useShellErrorPresentationStore(pinia).attachRuntimeStore(store);
51
+ app.provide("jskit.shell-web.runtime.web-error.client", runtime);
52
+ for (const name of ["VSnackbar", "VDialog", "VCard", "VCardTitle", "VCardText", "VCardActions", "VSpacer", "VAlert", "VBtn"]) {
53
+ app.component(name, defineComponent({ setup: (_props, { slots }) => () =>
54
+ h(name, {}, [slots.default?.(), slots.actions?.()]) }));
55
+ }
56
+ const root = node("root");
57
+ app.mount(root);
58
+ t.after(() => app.unmount());
59
+ const all = (item = root) => [item, ...item.children.flatMap((child) => all(child))];
60
+ const button = (label) => all().find((item) => item.type === "VBtn" &&
61
+ all(item).some((child) => child.text.trim() === label));
62
+ return { runtime, store, all, button };
63
+ }
64
+
65
+ test("both snackbar actions survive the real runtime and render with independent handlers", async (t) => {
66
+ const { runtime, store, all, button } = fixture(t);
67
+ const calls = [];
68
+ runtime.report({ message: "Delivered for booking", severity: "success",
69
+ action: { label: "#142", handler: () => calls.push("booking"), dismissOnRun: false },
70
+ additionalActions: [null, { label: "invalid" }, {
71
+ label: " View message ", handler: async () => calls.push("message")
72
+ }]
73
+ });
74
+ await nextTick();
75
+ const entry = store.getState().channels.snackbar[0];
76
+ assert.deepEqual(entry.additionalActions.map((action) => action.label), ["View message"]);
77
+ assert.ok(Object.isFrozen(entry.additionalActions));
78
+ assert.ok(Object.isFrozen(entry.additionalActions[0]));
79
+ assert.equal(all().find((item) => item.type === "VSnackbar").props.vertical, true);
80
+ await button("#142").props.onClick();
81
+ assert.deepEqual(calls, ["booking"]);
82
+ assert.equal(store.getState().channels.snackbar.length, 1);
83
+ await button("View message").props.onClick();
84
+ assert.deepEqual(calls, ["booking", "message"]);
85
+ assert.equal(store.getState().channels.snackbar.length, 0);
86
+ });
87
+
88
+ test("additional-only actions work and rejected handlers use shared error reporting", async (t) => {
89
+ const { runtime, store, button } = fixture(t);
90
+ runtime.report({ message: "Delivered", additionalActions: [{ label: "View message",
91
+ handler: async () => { throw new Error("Navigation failed"); }
92
+ }] });
93
+ await nextTick();
94
+ assert.equal(store.getState().channels.snackbar[0].action, null);
95
+ await button("View message").props.onClick();
96
+ assert.equal(store.getState().channels.snackbar.length, 0);
97
+ assert.equal(store.getState().channels.dialog[0].message, "Error action failed.");
98
+ });
99
+
100
+ test("policies can suppress additional actions and single actions still render", async (t) => {
101
+ const { runtime, store, button, all } = fixture(t);
102
+ runtime.setPolicy(() => ({ channel: "snackbar", additionalActions: [] }));
103
+ let invoked = false;
104
+ runtime.report({ message: "Saved", action: { label: "Undo", handler: () => { invoked = true; } },
105
+ additionalActions: [{ label: "Hidden", handler() {} }] });
106
+ await nextTick();
107
+ assert.deepEqual(store.getState().channels.snackbar[0].additionalActions, []);
108
+ assert.equal(button("Hidden"), undefined);
109
+ assert.equal(all().find((item) => item.type === "VSnackbar").props.vertical, false);
110
+ await button("Undo").props.onClick();
111
+ assert.equal(invoked, true);
112
+ assert.equal(store.getState().channels.snackbar.length, 0);
113
+ });