@jskit-ai/shell-web 0.1.65 → 0.1.66
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.descriptor.mjs +74 -9
- package/package.json +8 -7
- package/src/client/components/ShellErrorHost.vue +88 -15
- package/src/client/components/ShellLayout.vue +551 -46
- package/src/client/components/ShellRouteTransition.vue +480 -0
- package/src/client/components/ShellTabLinkItem.vue +22 -6
- package/src/client/composables/useShellLayoutState.js +12 -1
- package/src/client/error/normalize.js +17 -0
- package/src/client/error/policy.js +25 -11
- package/src/client/error/runtime.js +2 -0
- package/src/client/index.js +1 -0
- package/src/client/providers/ShellWebClientProvider.js +163 -39
- package/src/client/stores/useShellLayoutStore.js +21 -1
- package/src/test/adaptiveShellSmoke.js +121 -0
- package/templates/expected-existing/src/pages/home/index.vue +40 -10
- package/templates/src/components/ShellLayout.vue +10 -86
- package/templates/src/components/menus/TabLinkItem.vue +4 -0
- package/templates/src/error.js +7 -1
- package/templates/src/pages/home/index.vue +64 -23
- package/templates/src/pages/home/settings/general/index.vue +12 -9
- package/templates/src/pages/home/settings.vue +68 -21
- package/templates/src/placementTopology.js +43 -2
- package/templates/tests/e2e/adaptive-shell.spec.ts +4 -0
- package/test/errorRuntime.test.js +42 -0
- package/test/linkItemScaffoldContract.test.js +9 -2
- package/test/placementRuntime.test.js +37 -0
- package/test/provider.test.js +97 -5
- package/test/settingsPlacementContract.test.js +205 -8
- package/test/useShellLayoutState.test.js +19 -0
|
@@ -28,34 +28,75 @@ const health = computed(() => {
|
|
|
28
28
|
</script>
|
|
29
29
|
|
|
30
30
|
<template>
|
|
31
|
-
<
|
|
32
|
-
<
|
|
33
|
-
<
|
|
34
|
-
<
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
<v-divider />
|
|
40
|
-
<v-card-text class="home-surface-card__body d-flex flex-column ga-3">
|
|
41
|
-
<div class="d-flex flex-wrap ga-3">
|
|
42
|
-
<v-chip color="secondary" variant="tonal" label>Route: /home</v-chip>
|
|
43
|
-
<v-chip color="info" variant="tonal" label>Health: {{ health }}</v-chip>
|
|
31
|
+
<section class="generated-ui-screen generated-ui-screen--app home-surface-screen d-flex flex-column ga-4">
|
|
32
|
+
<header class="home-surface-screen__header">
|
|
33
|
+
<div>
|
|
34
|
+
<p class="text-overline text-medium-emphasis mb-1">Home</p>
|
|
35
|
+
<h1 class="home-surface-screen__title">Ready</h1>
|
|
36
|
+
<p class="text-body-2 text-medium-emphasis mb-0">
|
|
37
|
+
Core services are available.
|
|
38
|
+
</p>
|
|
44
39
|
</div>
|
|
45
|
-
<
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
40
|
+
<v-btn color="primary" variant="flat" to="/home/settings/general">Settings</v-btn>
|
|
41
|
+
</header>
|
|
42
|
+
|
|
43
|
+
<v-sheet rounded="lg" border class="home-surface-screen__panel">
|
|
44
|
+
<div class="home-surface-screen__status">
|
|
45
|
+
<span class="text-caption text-medium-emphasis">Service health</span>
|
|
46
|
+
<strong>{{ health }}</strong>
|
|
47
|
+
</div>
|
|
48
|
+
<v-divider vertical class="d-none d-sm-block" />
|
|
49
|
+
<div class="home-surface-screen__status">
|
|
50
|
+
<span class="text-caption text-medium-emphasis">Route</span>
|
|
51
|
+
<strong>/home</strong>
|
|
52
|
+
</div>
|
|
53
|
+
</v-sheet>
|
|
54
|
+
</section>
|
|
51
55
|
</template>
|
|
52
56
|
|
|
53
57
|
<style scoped>
|
|
54
|
-
.
|
|
55
|
-
|
|
58
|
+
.generated-ui-screen {
|
|
59
|
+
--generated-ui-screen-title-size: clamp(1.5rem, 2.5vw, 2.25rem);
|
|
60
|
+
--generated-ui-screen-panel-padding: 1rem;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.home-surface-screen__header {
|
|
64
|
+
align-items: flex-start;
|
|
65
|
+
display: flex;
|
|
66
|
+
gap: 1rem;
|
|
67
|
+
justify-content: space-between;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.home-surface-screen__title {
|
|
71
|
+
font-size: var(--generated-ui-screen-title-size);
|
|
72
|
+
font-weight: 700;
|
|
73
|
+
letter-spacing: -0.03em;
|
|
74
|
+
line-height: 1.1;
|
|
75
|
+
margin: 0 0 0.4rem;
|
|
56
76
|
}
|
|
57
77
|
|
|
58
|
-
.home-surface-
|
|
59
|
-
|
|
78
|
+
.home-surface-screen__panel {
|
|
79
|
+
align-items: stretch;
|
|
80
|
+
display: flex;
|
|
81
|
+
flex-wrap: wrap;
|
|
82
|
+
gap: 1rem;
|
|
83
|
+
padding: var(--generated-ui-screen-panel-padding);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.home-surface-screen__status {
|
|
87
|
+
display: grid;
|
|
88
|
+
gap: 0.15rem;
|
|
89
|
+
min-width: 9rem;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
@media (max-width: 640px) {
|
|
93
|
+
.home-surface-screen__header {
|
|
94
|
+
flex-direction: column;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.home-surface-screen__header :deep(.v-btn) {
|
|
98
|
+
min-height: 48px;
|
|
99
|
+
width: 100%;
|
|
100
|
+
}
|
|
60
101
|
}
|
|
61
102
|
</style>
|
|
@@ -15,10 +15,12 @@ const drawerDefaultOpenModel = computed({
|
|
|
15
15
|
</script>
|
|
16
16
|
|
|
17
17
|
<template>
|
|
18
|
-
<section class="d-flex flex-column ga-4">
|
|
18
|
+
<section class="generated-ui-screen generated-ui-screen--settings settings-general-screen d-flex flex-column ga-4">
|
|
19
19
|
<div>
|
|
20
|
-
<h2 class="text-h6 mb-2">
|
|
21
|
-
<p class="text-body-2 text-medium-emphasis mb-0">
|
|
20
|
+
<h2 class="text-h6 mb-2">Navigation</h2>
|
|
21
|
+
<p class="text-body-2 text-medium-emphasis mb-0">
|
|
22
|
+
Choose the default behavior for wider screens. Phone layouts keep primary navigation in the bottom bar.
|
|
23
|
+
</p>
|
|
22
24
|
</div>
|
|
23
25
|
|
|
24
26
|
<v-switch
|
|
@@ -26,12 +28,13 @@ const drawerDefaultOpenModel = computed({
|
|
|
26
28
|
color="primary"
|
|
27
29
|
inset
|
|
28
30
|
hide-details="auto"
|
|
29
|
-
label="Open
|
|
31
|
+
label="Open drawer by default on wider screens"
|
|
30
32
|
/>
|
|
31
|
-
|
|
32
|
-
<p class="text-body-2 text-medium-emphasis mb-0">
|
|
33
|
-
This tiny example exists to prove that shell-level settings can work without auth or a database. Real apps will
|
|
34
|
-
usually replace it.
|
|
35
|
-
</p>
|
|
36
33
|
</section>
|
|
37
34
|
</template>
|
|
35
|
+
|
|
36
|
+
<style scoped>
|
|
37
|
+
.settings-general-screen :deep(.v-switch) {
|
|
38
|
+
min-height: 48px;
|
|
39
|
+
}
|
|
40
|
+
</style>
|
|
@@ -4,26 +4,73 @@ import { RouterView } from "vue-router";
|
|
|
4
4
|
</script>
|
|
5
5
|
|
|
6
6
|
<template>
|
|
7
|
-
<section class="settings-shell d-flex flex-column ga-4">
|
|
8
|
-
<
|
|
9
|
-
<
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
</v-card>
|
|
7
|
+
<section class="generated-ui-screen generated-ui-screen--settings settings-shell d-flex flex-column ga-4">
|
|
8
|
+
<header>
|
|
9
|
+
<p class="text-overline text-medium-emphasis mb-1">Settings</p>
|
|
10
|
+
<h1 class="settings-shell__title">Home settings</h1>
|
|
11
|
+
<p class="text-body-2 text-medium-emphasis mb-0">Configure shell behavior for this surface.</p>
|
|
12
|
+
</header>
|
|
13
|
+
|
|
14
|
+
<v-sheet rounded="lg" border class="settings-shell__panel">
|
|
15
|
+
<div class="settings-shell__body">
|
|
16
|
+
<nav class="settings-shell__nav" aria-label="Home settings sections">
|
|
17
|
+
<v-list nav density="comfortable" rounded="lg" border>
|
|
18
|
+
<ShellOutlet target="home-settings:primary-menu" />
|
|
19
|
+
</v-list>
|
|
20
|
+
</nav>
|
|
21
|
+
|
|
22
|
+
<main class="settings-shell__content">
|
|
23
|
+
<RouterView />
|
|
24
|
+
</main>
|
|
25
|
+
</div>
|
|
26
|
+
</v-sheet>
|
|
28
27
|
</section>
|
|
29
28
|
</template>
|
|
29
|
+
|
|
30
|
+
<style scoped>
|
|
31
|
+
.generated-ui-screen {
|
|
32
|
+
--generated-ui-screen-title-size: clamp(1.35rem, 2vw, 1.85rem);
|
|
33
|
+
--generated-ui-screen-panel-padding: 1rem;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.settings-shell__title {
|
|
37
|
+
font-size: var(--generated-ui-screen-title-size);
|
|
38
|
+
font-weight: 650;
|
|
39
|
+
letter-spacing: -0.02em;
|
|
40
|
+
line-height: 1.15;
|
|
41
|
+
margin: 0 0 0.35rem;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.settings-shell__panel {
|
|
45
|
+
overflow: hidden;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.settings-shell__body {
|
|
49
|
+
display: grid;
|
|
50
|
+
gap: 1rem;
|
|
51
|
+
grid-template-columns: minmax(12rem, 16rem) minmax(0, 1fr);
|
|
52
|
+
padding: var(--generated-ui-screen-panel-padding);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.settings-shell__content {
|
|
56
|
+
min-width: 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
@media (max-width: 960px) {
|
|
60
|
+
.settings-shell__body {
|
|
61
|
+
grid-template-columns: 1fr;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.settings-shell__nav :deep(.v-list) {
|
|
65
|
+
display: flex;
|
|
66
|
+
gap: 0.25rem;
|
|
67
|
+
overflow-x: auto;
|
|
68
|
+
scrollbar-width: thin;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.settings-shell__nav :deep(.v-list-item) {
|
|
72
|
+
flex: 0 0 auto;
|
|
73
|
+
min-height: 48px;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
</style>
|
|
@@ -11,6 +11,10 @@ const menuLinkRenderers = Object.freeze({
|
|
|
11
11
|
link: "local.main.ui.surface-aware-menu-link-item"
|
|
12
12
|
});
|
|
13
13
|
|
|
14
|
+
const bottomNavLinkRenderers = Object.freeze({
|
|
15
|
+
link: "local.main.ui.tab-link-item"
|
|
16
|
+
});
|
|
17
|
+
|
|
14
18
|
addPlacementTopology({
|
|
15
19
|
id: "shell.primary-nav",
|
|
16
20
|
description: "Primary top-level navigation for the current surface.",
|
|
@@ -18,8 +22,8 @@ addPlacementTopology({
|
|
|
18
22
|
default: true,
|
|
19
23
|
variants: {
|
|
20
24
|
compact: {
|
|
21
|
-
outlet: "shell-layout:primary-
|
|
22
|
-
renderers:
|
|
25
|
+
outlet: "shell-layout:primary-bottom-nav",
|
|
26
|
+
renderers: bottomNavLinkRenderers
|
|
23
27
|
},
|
|
24
28
|
medium: {
|
|
25
29
|
outlet: "shell-layout:primary-menu",
|
|
@@ -86,6 +90,43 @@ addPlacementTopology({
|
|
|
86
90
|
}
|
|
87
91
|
});
|
|
88
92
|
|
|
93
|
+
addPlacementTopology({
|
|
94
|
+
id: "shell.global-actions",
|
|
95
|
+
description: "Global surface actions that should stay outside primary navigation.",
|
|
96
|
+
surfaces: ["*"],
|
|
97
|
+
variants: {
|
|
98
|
+
compact: {
|
|
99
|
+
outlet: "shell-layout:top-right",
|
|
100
|
+
renderers: menuLinkRenderers
|
|
101
|
+
},
|
|
102
|
+
medium: {
|
|
103
|
+
outlet: "shell-layout:top-right",
|
|
104
|
+
renderers: menuLinkRenderers
|
|
105
|
+
},
|
|
106
|
+
expanded: {
|
|
107
|
+
outlet: "shell-layout:top-right",
|
|
108
|
+
renderers: menuLinkRenderers
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
addPlacementTopology({
|
|
114
|
+
id: "page.supporting-content",
|
|
115
|
+
description: "Supporting page content that opens as a bottom sheet on compact layouts and a side panel on wider layouts.",
|
|
116
|
+
surfaces: ["*"],
|
|
117
|
+
variants: {
|
|
118
|
+
compact: {
|
|
119
|
+
outlet: "shell-layout:supporting-bottom-sheet"
|
|
120
|
+
},
|
|
121
|
+
medium: {
|
|
122
|
+
outlet: "shell-layout:supporting-side-panel"
|
|
123
|
+
},
|
|
124
|
+
expanded: {
|
|
125
|
+
outlet: "shell-layout:supporting-side-panel"
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
89
130
|
addPlacementTopology({
|
|
90
131
|
id: "page.section-nav",
|
|
91
132
|
owner: "home-settings",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
+
import { createDefaultErrorPolicy } from "../src/client/error/policy.js";
|
|
3
4
|
import { createErrorRuntime } from "../src/client/error/runtime.js";
|
|
4
5
|
|
|
5
6
|
function createPresenter(id, {
|
|
@@ -25,6 +26,47 @@ function createPresenter(id, {
|
|
|
25
26
|
});
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
test("default error policy maps intent to presentation instead of status alone", () => {
|
|
30
|
+
const policy = createDefaultErrorPolicy();
|
|
31
|
+
|
|
32
|
+
assert.equal(policy({ intent: "resource-load", message: "Load failed" }).channel, "silent");
|
|
33
|
+
assert.equal(policy({ intent: "action-feedback", message: "Save failed" }).channel, "snackbar");
|
|
34
|
+
assert.equal(policy({ intent: "app-recoverable", message: "Offline" }).channel, "banner");
|
|
35
|
+
assert.equal(policy({ intent: "blocking", message: "Fatal" }).channel, "dialog");
|
|
36
|
+
assert.equal(policy({ blocking: true, message: "Fatal" }).channel, "dialog");
|
|
37
|
+
assert.equal(policy({ status: 500, message: "Server failed" }).channel, "snackbar");
|
|
38
|
+
assert.equal(
|
|
39
|
+
policy({ intent: "resource-load", channel: "banner", message: "Load failed" }).channel,
|
|
40
|
+
"banner"
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("error runtime treats resource load errors as silent by default", () => {
|
|
45
|
+
const calls = [];
|
|
46
|
+
const runtime = createErrorRuntime({
|
|
47
|
+
presenters: [
|
|
48
|
+
createPresenter("module.presenter", { calls })
|
|
49
|
+
],
|
|
50
|
+
moduleDefaultPresenterId: "module.presenter"
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const result = runtime.report({
|
|
54
|
+
kind: "resource-load",
|
|
55
|
+
message: "Unable to load records.",
|
|
56
|
+
status: 500,
|
|
57
|
+
action: {
|
|
58
|
+
label: "Retry",
|
|
59
|
+
handler() {}
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
assert.equal(result.skipped, true);
|
|
64
|
+
assert.equal(result.reason, "silent");
|
|
65
|
+
assert.equal(result.event.intent, "resource-load");
|
|
66
|
+
assert.equal(result.decision.channel, "silent");
|
|
67
|
+
assert.equal(calls.length, 0);
|
|
68
|
+
});
|
|
69
|
+
|
|
28
70
|
test("error runtime prefers policy presenter over app and module defaults", () => {
|
|
29
71
|
const calls = [];
|
|
30
72
|
const runtime = createErrorRuntime({
|
|
@@ -32,8 +32,13 @@ test("shell-web exports generic link-item components for app-owned shell wrapper
|
|
|
32
32
|
assert.match(clientIndexSource, /ShellMenuLinkItem/);
|
|
33
33
|
assert.match(clientIndexSource, /ShellSurfaceAwareMenuLinkItem/);
|
|
34
34
|
assert.match(clientIndexSource, /ShellTabLinkItem/);
|
|
35
|
+
assert.match(clientIndexSource, /ShellRouteTransition/);
|
|
35
36
|
|
|
36
37
|
const packageJson = JSON.parse(await readFile(path.join(PACKAGE_DIR, "package.json"), "utf8"));
|
|
38
|
+
assert.equal(
|
|
39
|
+
packageJson?.exports?.["./client/components/ShellRouteTransition"],
|
|
40
|
+
"./src/client/components/ShellRouteTransition.vue"
|
|
41
|
+
);
|
|
37
42
|
assert.equal(
|
|
38
43
|
packageJson?.exports?.["./client/components/ShellMenuLinkItem"],
|
|
39
44
|
"./src/client/components/ShellMenuLinkItem.vue"
|
|
@@ -150,8 +155,10 @@ test("shell-web generic link items support the expected shared route and icon be
|
|
|
150
155
|
assert.match(shellSurfaceAwareSource, /:exact="props\.exact"/);
|
|
151
156
|
assert.match(shellTabSource, /icon:\s*\{/);
|
|
152
157
|
assert.match(shellTabSource, /resolveMenuLinkIcon/);
|
|
153
|
-
assert.match(shellTabSource, /<v-
|
|
154
|
-
assert.match(shellTabSource,
|
|
158
|
+
assert.match(shellTabSource, /<v-btn/);
|
|
159
|
+
assert.match(shellTabSource, /stacked/);
|
|
160
|
+
assert.match(shellTabSource, /min-height:\s*48px/);
|
|
161
|
+
assert.match(shellTabSource, /<v-icon v-if="resolvedIcon" :icon="resolvedIcon" \/>/);
|
|
155
162
|
});
|
|
156
163
|
|
|
157
164
|
test("shell-web binds the local link-item wrapper tokens into MainClientProvider", () => {
|
|
@@ -108,6 +108,43 @@ test("web placement runtime resolves semantic targets through topology variants"
|
|
|
108
108
|
assert.equal(mediumEntries[0].componentToken, "component.menu");
|
|
109
109
|
});
|
|
110
110
|
|
|
111
|
+
test("web placement runtime accepts append-only topology objects", () => {
|
|
112
|
+
const app = createAppStub({
|
|
113
|
+
tokens: {
|
|
114
|
+
"component.menu": () => null
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const runtime = createWebPlacementRuntime({ app });
|
|
119
|
+
runtime.replacePlacementTopology({
|
|
120
|
+
placements: [
|
|
121
|
+
semanticTopologyEntry({
|
|
122
|
+
id: "shell.primary-nav",
|
|
123
|
+
compactRenderer: "component.menu",
|
|
124
|
+
mediumRenderer: "component.menu",
|
|
125
|
+
expandedRenderer: "component.menu"
|
|
126
|
+
})
|
|
127
|
+
]
|
|
128
|
+
});
|
|
129
|
+
runtime.replacePlacements([
|
|
130
|
+
definePlacement({
|
|
131
|
+
id: "test.home",
|
|
132
|
+
target: "shell.primary-nav",
|
|
133
|
+
kind: "link",
|
|
134
|
+
surfaces: ["app"],
|
|
135
|
+
order: 10
|
|
136
|
+
})
|
|
137
|
+
]);
|
|
138
|
+
runtime.setContext(createPlacementContext());
|
|
139
|
+
|
|
140
|
+
const entries = runtime.getPlacements({
|
|
141
|
+
surface: "app",
|
|
142
|
+
target: "shell-layout:primary-menu",
|
|
143
|
+
layoutClass: "expanded"
|
|
144
|
+
});
|
|
145
|
+
assert.deepEqual(entries.map((entry) => entry.id), ["test.home"]);
|
|
146
|
+
});
|
|
147
|
+
|
|
111
148
|
test("web placement runtime filters by surface/host/position, resolves component tokens, and sorts by order", () => {
|
|
112
149
|
const app = createAppStub({
|
|
113
150
|
tokens: {
|
package/test/provider.test.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
3
|
import { createPinia } from "pinia";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
ShellWebClientProvider,
|
|
6
|
+
resolveAppPlacementTopologyExport
|
|
7
|
+
} from "../src/client/providers/ShellWebClientProvider.js";
|
|
5
8
|
import { useShellErrorPresentationStore } from "../src/client/stores/useShellErrorPresentationStore.js";
|
|
6
9
|
const CLIENT_APP_CONFIG_GLOBAL_KEY = "__JSKIT_CLIENT_APP_CONFIG__";
|
|
7
10
|
|
|
@@ -14,7 +17,7 @@ function setClientAppConfig(source = {}) {
|
|
|
14
17
|
return normalized;
|
|
15
18
|
}
|
|
16
19
|
|
|
17
|
-
function createAppDouble({ surfaceRuntime = null } = {}) {
|
|
20
|
+
function createAppDouble({ surfaceRuntime = null, queryClient = null } = {}) {
|
|
18
21
|
const singletons = new Map();
|
|
19
22
|
const singletonInstances = new Map();
|
|
20
23
|
const provided = [];
|
|
@@ -61,6 +64,9 @@ function createAppDouble({ surfaceRuntime = null } = {}) {
|
|
|
61
64
|
if (token === "jskit.client.surface.runtime") {
|
|
62
65
|
return Boolean(surfaceRuntime);
|
|
63
66
|
}
|
|
67
|
+
if (token === "jskit.client.query-client") {
|
|
68
|
+
return Boolean(queryClient);
|
|
69
|
+
}
|
|
64
70
|
return singletons.has(token) || singletonInstances.has(token);
|
|
65
71
|
},
|
|
66
72
|
make(token) {
|
|
@@ -73,6 +79,9 @@ function createAppDouble({ surfaceRuntime = null } = {}) {
|
|
|
73
79
|
if (token === "jskit.client.surface.runtime" && surfaceRuntime) {
|
|
74
80
|
return surfaceRuntime;
|
|
75
81
|
}
|
|
82
|
+
if (token === "jskit.client.query-client" && queryClient) {
|
|
83
|
+
return queryClient;
|
|
84
|
+
}
|
|
76
85
|
if (singletonInstances.has(token)) {
|
|
77
86
|
return singletonInstances.get(token);
|
|
78
87
|
}
|
|
@@ -125,6 +134,32 @@ async function withFetchImplementation(fetchImplementation, callback) {
|
|
|
125
134
|
}
|
|
126
135
|
}
|
|
127
136
|
|
|
137
|
+
test("shell web client provider preserves append-only placement topology object exports", () => {
|
|
138
|
+
const warnings = [];
|
|
139
|
+
const topology = {
|
|
140
|
+
placements: [
|
|
141
|
+
{
|
|
142
|
+
id: "shell.primary-nav",
|
|
143
|
+
surfaces: ["*"],
|
|
144
|
+
variants: {
|
|
145
|
+
compact: { outlet: "shell-layout:primary-menu" },
|
|
146
|
+
medium: { outlet: "shell-layout:primary-menu" },
|
|
147
|
+
expanded: { outlet: "shell-layout:primary-menu" }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
]
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const resolved = resolveAppPlacementTopologyExport(topology, {
|
|
154
|
+
warn(payload, message) {
|
|
155
|
+
warnings.push({ payload, message });
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
assert.equal(resolved, topology);
|
|
160
|
+
assert.deepEqual(warnings, []);
|
|
161
|
+
});
|
|
162
|
+
|
|
128
163
|
test("shell web client provider binds runtime and injects it into Vue app", async () => {
|
|
129
164
|
await withFetchStub({ surfaceAccess: {} }, async () => {
|
|
130
165
|
const app = createAppDouble();
|
|
@@ -134,15 +169,15 @@ test("shell web client provider binds runtime and injects it into Vue app", asyn
|
|
|
134
169
|
assert.equal(app.singletons.has("runtime.web-placement.client"), true);
|
|
135
170
|
assert.equal(app.singletons.has("runtime.web-error.client"), true);
|
|
136
171
|
assert.equal(app.singletons.has("runtime.web-error.presentation-store.client"), true);
|
|
172
|
+
assert.equal(app.singletons.has("runtime.web-refresh.client"), true);
|
|
137
173
|
|
|
138
174
|
await provider.boot(app);
|
|
139
|
-
assert.equal(app.plugins.length,
|
|
140
|
-
assert.equal(typeof app.plugins[0].plugin.install, "function");
|
|
141
|
-
assert.equal(typeof app.plugins[0].options?.queryClient, "object");
|
|
175
|
+
assert.equal(app.plugins.length, 0);
|
|
142
176
|
|
|
143
177
|
const providedByKey = new Map(app.provided.map((entry) => [entry.key, entry.value]));
|
|
144
178
|
|
|
145
179
|
assert.equal(providedByKey.has("jskit.shell-web.runtime.web-placement.client"), true);
|
|
180
|
+
assert.equal(providedByKey.has("jskit.shell-web.runtime.web-refresh.client"), true);
|
|
146
181
|
assert.equal(providedByKey.has("jskit.shell-web.runtime.web-error.client"), true);
|
|
147
182
|
assert.equal(providedByKey.has("jskit.shell-web.runtime.web-error.presentation-store.client"), true);
|
|
148
183
|
|
|
@@ -156,6 +191,9 @@ test("shell web client provider binds runtime and injects it into Vue app", asyn
|
|
|
156
191
|
assert.equal(typeof errorRuntime.report, "function");
|
|
157
192
|
assert.equal(typeof errorRuntime.configure, "function");
|
|
158
193
|
|
|
194
|
+
const refreshRuntime = providedByKey.get("jskit.shell-web.runtime.web-refresh.client");
|
|
195
|
+
assert.equal(typeof refreshRuntime.refresh, "function");
|
|
196
|
+
|
|
159
197
|
const errorStore = providedByKey.get("jskit.shell-web.runtime.web-error.presentation-store.client");
|
|
160
198
|
assert.equal(typeof errorStore.getState, "function");
|
|
161
199
|
assert.equal(typeof errorStore.present, "function");
|
|
@@ -168,6 +206,60 @@ test("shell web client provider binds runtime and injects it into Vue app", asyn
|
|
|
168
206
|
});
|
|
169
207
|
});
|
|
170
208
|
|
|
209
|
+
test("shell refresh runtime refreshes bootstrap and active queries by default", async () => {
|
|
210
|
+
await withFetchStub({ surfaceAccess: { home: true } }, async () => {
|
|
211
|
+
const refetchCalls = [];
|
|
212
|
+
const queryClient = {
|
|
213
|
+
async refetchQueries(filters = {}, options = {}) {
|
|
214
|
+
refetchCalls.push({ filters, options });
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
const app = createAppDouble({ queryClient });
|
|
218
|
+
const provider = new ShellWebClientProvider();
|
|
219
|
+
provider.register(app);
|
|
220
|
+
|
|
221
|
+
const refreshRuntime = app.make("runtime.web-refresh.client");
|
|
222
|
+
const result = await refreshRuntime.refresh("test-refresh");
|
|
223
|
+
|
|
224
|
+
assert.equal(result.reason, "test-refresh");
|
|
225
|
+
assert.equal(result.bootstrapRefreshed, true);
|
|
226
|
+
assert.equal(result.queriesRefetched, true);
|
|
227
|
+
assert.equal(refetchCalls.length, 1);
|
|
228
|
+
assert.equal(refetchCalls[0].filters.type, "active");
|
|
229
|
+
assert.equal(refetchCalls[0].options.throwOnError, false);
|
|
230
|
+
assert.equal(refetchCalls[0].filters.predicate({}), true);
|
|
231
|
+
assert.equal(refetchCalls[0].filters.predicate({ meta: {} }), true);
|
|
232
|
+
assert.equal(refetchCalls[0].filters.predicate({ meta: { jskit: { refreshOnPull: true } } }), true);
|
|
233
|
+
assert.equal(refetchCalls[0].filters.predicate({ meta: { jskitRefresh: "pull" } }), true);
|
|
234
|
+
assert.equal(refetchCalls[0].filters.predicate({ meta: { jskit: { refreshOnPull: false } } }), false);
|
|
235
|
+
assert.equal(refetchCalls[0].filters.predicate({ meta: { jskitRefresh: false } }), false);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("shell refresh runtime reports recoverable retry errors as banners", async () => {
|
|
240
|
+
await withFetchStub({ surfaceAccess: { home: true } }, async () => {
|
|
241
|
+
const queryClient = {
|
|
242
|
+
async refetchQueries() {
|
|
243
|
+
throw new Error("Network unavailable");
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
const app = createAppDouble({ queryClient });
|
|
247
|
+
const provider = new ShellWebClientProvider();
|
|
248
|
+
provider.register(app);
|
|
249
|
+
|
|
250
|
+
const refreshRuntime = app.make("runtime.web-refresh.client");
|
|
251
|
+
const result = await refreshRuntime.refresh("test-refresh");
|
|
252
|
+
|
|
253
|
+
assert.equal(result.error instanceof Error, true);
|
|
254
|
+
|
|
255
|
+
const errorStore = app.make("runtime.web-error.presentation-store.client");
|
|
256
|
+
const state = errorStore.getState();
|
|
257
|
+
assert.equal(state.channels.banner.length, 1);
|
|
258
|
+
assert.equal(state.channels.banner[0].message, "Unable to refresh. Check the connection and try again.");
|
|
259
|
+
assert.equal(state.channels.banner[0].action.label, "Retry");
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
171
263
|
test("shell web client provider resolves surface config from client app config", async () => {
|
|
172
264
|
setClientAppConfig({
|
|
173
265
|
tenancyMode: "workspaces",
|