@jskit-ai/shell-web 0.1.208 → 0.1.209
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.
|
|
3
|
+
"version": "0.1.209",
|
|
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.
|
|
34
|
+
"@jskit-ai/kernel": "0.1.205",
|
|
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
|
|
110
|
-
|
|
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
|
-
|
|
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 (
|
|
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-
|
|
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
|
-
{{
|
|
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-
|
|
205
|
+
v-for="(action, index) in actionsFor(displayedSnackbarEntry)"
|
|
206
|
+
:key="index"
|
|
200
207
|
variant="text"
|
|
201
|
-
|
|
202
|
-
@click="runAction(displayedSnackbarEntry)"
|
|
208
|
+
min-height="48"
|
|
209
|
+
@click="runAction(displayedSnackbarEntry, action)"
|
|
203
210
|
>
|
|
204
|
-
{{
|
|
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-
|
|
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
|
-
{{
|
|
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
|
+
});
|