@jskit-ai/shell-web 0.1.36 → 0.1.38
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 +20 -3
- package/package.json +3 -2
- package/src/client/components/ShellErrorHost.vue +6 -9
- package/src/client/components/ShellSurfaceAwareMenuLinkItem.vue +4 -4
- package/src/client/components/ShellTabLinkItem.vue +4 -4
- package/src/client/composables/useShellLayoutState.js +7 -17
- package/src/client/error/index.js +1 -0
- package/src/client/error/inject.js +2 -73
- package/src/client/error/presentationDefaults.js +31 -0
- package/src/client/index.js +2 -0
- package/src/client/providers/ShellWebClientProvider.js +8 -1
- package/src/client/stores/useShellErrorPresentationStore.js +96 -0
- package/src/client/stores/useShellLayoutStore.js +34 -0
- package/src/client/support/menuLinkTarget.js +4 -4
- package/templates/src/components/menus/SurfaceAwareMenuLinkItem.vue +2 -2
- package/templates/src/components/menus/TabLinkItem.vue +2 -2
- package/templates/src/pages/home/settings/general/index.vue +37 -0
- package/templates/src/pages/home/settings/index.vue +2 -34
- package/templates/src/placement.js +19 -4
- package/test/errorStore.test.js +15 -0
- package/test/provider.test.js +21 -1
- package/test/settingsPlacementContract.test.js +44 -4
- package/test/useShellLayoutState.test.js +17 -0
package/package.descriptor.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export default Object.freeze({
|
|
2
2
|
packageVersion: 1,
|
|
3
3
|
packageId: "@jskit-ai/shell-web",
|
|
4
|
-
version: "0.1.
|
|
4
|
+
version: "0.1.38",
|
|
5
5
|
kind: "runtime",
|
|
6
6
|
description: "Web shell layout runtime with outlet-based placement contributions.",
|
|
7
7
|
dependsOn: [],
|
|
@@ -99,6 +99,14 @@ export default Object.freeze({
|
|
|
99
99
|
order: 100,
|
|
100
100
|
componentToken: "local.main.ui.surface-aware-menu-link-item",
|
|
101
101
|
source: "templates/src/placement.js"
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
id: "shell-web.home.settings.general",
|
|
105
|
+
target: "home-settings:primary-menu",
|
|
106
|
+
surfaces: ["home"],
|
|
107
|
+
order: 100,
|
|
108
|
+
componentToken: "local.main.ui.surface-aware-menu-link-item",
|
|
109
|
+
source: "templates/src/placement.js"
|
|
102
110
|
}
|
|
103
111
|
]
|
|
104
112
|
}
|
|
@@ -109,7 +117,7 @@ export default Object.freeze({
|
|
|
109
117
|
runtime: {
|
|
110
118
|
"@mdi/js": "^7.4.47",
|
|
111
119
|
"@tanstack/vue-query": "^5.90.5",
|
|
112
|
-
"@jskit-ai/kernel": "0.1.
|
|
120
|
+
"@jskit-ai/kernel": "0.1.39",
|
|
113
121
|
"vuetify": "^4.0.0"
|
|
114
122
|
},
|
|
115
123
|
dev: {}
|
|
@@ -275,9 +283,18 @@ export default Object.freeze({
|
|
|
275
283
|
toSurface: "home",
|
|
276
284
|
toSurfacePath: "settings/index.vue",
|
|
277
285
|
ownership: "app",
|
|
278
|
-
reason: "Install shell-driven home settings
|
|
286
|
+
reason: "Install shell-driven home settings redirect so the starter settings shell lands on a real child page.",
|
|
279
287
|
category: "shell-web",
|
|
280
288
|
id: "shell-web-page-home-settings"
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
from: "templates/src/pages/home/settings/general/index.vue",
|
|
292
|
+
toSurface: "home",
|
|
293
|
+
toSurfacePath: "settings/general/index.vue",
|
|
294
|
+
ownership: "app",
|
|
295
|
+
reason: "Install shell-driven general settings child page with a tiny browser-local shell preference example.",
|
|
296
|
+
category: "shell-web",
|
|
297
|
+
id: "shell-web-page-home-settings-general"
|
|
281
298
|
}
|
|
282
299
|
]
|
|
283
300
|
}
|
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.38",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test"
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"@mdi/js": "^7.4.47",
|
|
26
26
|
"@tanstack/vue-query": "^5.90.5",
|
|
27
|
-
"@jskit-ai/kernel": "0.1.
|
|
27
|
+
"@jskit-ai/kernel": "0.1.39",
|
|
28
|
+
"pinia": "^3.0.4",
|
|
28
29
|
"vuetify": "^4.0.0"
|
|
29
30
|
}
|
|
30
31
|
}
|
|
@@ -1,19 +1,16 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import { computed } from "vue";
|
|
3
3
|
import {
|
|
4
|
-
useShellWebErrorPresentationState,
|
|
5
4
|
useShellWebErrorRuntime
|
|
6
5
|
} from "../error/inject.js";
|
|
6
|
+
import { useShellErrorPresentationStore } from "../stores/useShellErrorPresentationStore.js";
|
|
7
7
|
|
|
8
8
|
const runtime = useShellWebErrorRuntime();
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const snackbarEntry = computed(() => state.value.channels.snackbar[0] || null);
|
|
15
|
-
const bannerEntries = computed(() => state.value.channels.banner || []);
|
|
16
|
-
const dialogEntry = computed(() => state.value.channels.dialog[0] || null);
|
|
9
|
+
const store = useShellErrorPresentationStore();
|
|
10
|
+
|
|
11
|
+
const snackbarEntry = computed(() => store.channels.snackbar[0] || null);
|
|
12
|
+
const bannerEntries = computed(() => store.channels.banner || []);
|
|
13
|
+
const dialogEntry = computed(() => store.channels.dialog[0] || null);
|
|
17
14
|
|
|
18
15
|
function resolveSeverityColor(severity = "error") {
|
|
19
16
|
const normalized = String(severity || "error").trim().toLowerCase();
|
|
@@ -27,11 +27,11 @@ const props = defineProps({
|
|
|
27
27
|
type: String,
|
|
28
28
|
default: ""
|
|
29
29
|
},
|
|
30
|
-
|
|
30
|
+
scopedSuffix: {
|
|
31
31
|
type: String,
|
|
32
32
|
default: "/"
|
|
33
33
|
},
|
|
34
|
-
|
|
34
|
+
unscopedSuffix: {
|
|
35
35
|
type: String,
|
|
36
36
|
default: "/"
|
|
37
37
|
},
|
|
@@ -61,8 +61,8 @@ const resolvedTo = computed(() => {
|
|
|
61
61
|
surface: props.surface,
|
|
62
62
|
currentSurfaceId: currentSurfaceId.value,
|
|
63
63
|
placementContext: placementContext.value,
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
scopedSuffix: props.scopedSuffix,
|
|
65
|
+
unscopedSuffix: props.unscopedSuffix,
|
|
66
66
|
routeParams: route.params || {},
|
|
67
67
|
resolvePagePath(relativePath, options = {}) {
|
|
68
68
|
return resolveShellLinkPath({
|
|
@@ -25,11 +25,11 @@ const props = defineProps({
|
|
|
25
25
|
type: String,
|
|
26
26
|
default: ""
|
|
27
27
|
},
|
|
28
|
-
|
|
28
|
+
scopedSuffix: {
|
|
29
29
|
type: String,
|
|
30
30
|
default: "/"
|
|
31
31
|
},
|
|
32
|
-
|
|
32
|
+
unscopedSuffix: {
|
|
33
33
|
type: String,
|
|
34
34
|
default: "/"
|
|
35
35
|
},
|
|
@@ -55,8 +55,8 @@ const resolvedTo = computed(() => {
|
|
|
55
55
|
surface: props.surface,
|
|
56
56
|
currentSurfaceId: currentSurfaceId.value,
|
|
57
57
|
placementContext: placementContext.value,
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
scopedSuffix: props.scopedSuffix,
|
|
59
|
+
unscopedSuffix: props.unscopedSuffix,
|
|
60
60
|
routeParams: route.params || {},
|
|
61
61
|
resolvePagePath(relativePath, options = {}) {
|
|
62
62
|
return resolveShellLinkPath({
|
|
@@ -1,16 +1,14 @@
|
|
|
1
|
-
import { computed
|
|
1
|
+
import { computed } from "vue";
|
|
2
|
+
import { storeToRefs } from "pinia";
|
|
2
3
|
import { useRoute } from "vue-router";
|
|
3
4
|
import { normalizeSurfaceId } from "@jskit-ai/kernel/shared/surface";
|
|
4
5
|
import { useWebPlacementContext } from "../placement/inject.js";
|
|
6
|
+
import { useShellLayoutStore } from "../stores/useShellLayoutStore.js";
|
|
5
7
|
import {
|
|
6
8
|
readPlacementSurfaceConfig,
|
|
7
9
|
resolveSurfaceDefinitionFromPlacementContext,
|
|
8
10
|
resolveSurfaceIdFromPlacementPathname
|
|
9
11
|
} from "../placement/surfaceContext.js";
|
|
10
|
-
import {
|
|
11
|
-
readDrawerDefaultOpenPreference,
|
|
12
|
-
writeDrawerDefaultOpenPreference
|
|
13
|
-
} from "./shellLayoutDrawerPreference.js";
|
|
14
12
|
|
|
15
13
|
function toSurfaceLabel(surfaceId = "") {
|
|
16
14
|
const normalizedSurfaceId = String(surfaceId || "").trim().toLowerCase();
|
|
@@ -25,17 +23,9 @@ function toSurfaceLabel(surfaceId = "") {
|
|
|
25
23
|
.join(" ");
|
|
26
24
|
}
|
|
27
25
|
|
|
28
|
-
const drawerDefaultOpen = ref(readDrawerDefaultOpenPreference());
|
|
29
|
-
const drawerOpen = ref(drawerDefaultOpen.value);
|
|
30
|
-
|
|
31
|
-
function setDrawerDefaultOpen(open) {
|
|
32
|
-
const normalized = Boolean(open);
|
|
33
|
-
drawerDefaultOpen.value = normalized;
|
|
34
|
-
drawerOpen.value = normalized;
|
|
35
|
-
writeDrawerDefaultOpenPreference(normalized);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
26
|
function useShellLayoutState(props = {}) {
|
|
27
|
+
const shellLayoutStore = useShellLayoutStore();
|
|
28
|
+
const { drawerDefaultOpen, drawerOpen } = storeToRefs(shellLayoutStore);
|
|
39
29
|
let route = null;
|
|
40
30
|
try {
|
|
41
31
|
route = useRoute();
|
|
@@ -46,7 +36,7 @@ function useShellLayoutState(props = {}) {
|
|
|
46
36
|
const { context: placementContext } = useWebPlacementContext();
|
|
47
37
|
|
|
48
38
|
function toggleDrawer() {
|
|
49
|
-
|
|
39
|
+
shellLayoutStore.toggleDrawer();
|
|
50
40
|
}
|
|
51
41
|
|
|
52
42
|
const resolvedSurface = computed(() => {
|
|
@@ -93,7 +83,7 @@ function useShellLayoutState(props = {}) {
|
|
|
93
83
|
return Object.freeze({
|
|
94
84
|
drawerDefaultOpen,
|
|
95
85
|
drawerOpen,
|
|
96
|
-
setDrawerDefaultOpen,
|
|
86
|
+
setDrawerDefaultOpen: shellLayoutStore.setDrawerDefaultOpen,
|
|
97
87
|
toggleDrawer,
|
|
98
88
|
resolvedSurface,
|
|
99
89
|
resolvedSurfaceLabel
|
|
@@ -1,19 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
|
-
inject
|
|
3
|
-
onBeforeUnmount,
|
|
4
|
-
onMounted,
|
|
5
|
-
shallowRef
|
|
2
|
+
inject
|
|
6
3
|
} from "vue";
|
|
7
4
|
|
|
8
|
-
const EMPTY_PRESENTATION_STATE = Object.freeze({
|
|
9
|
-
revision: 0,
|
|
10
|
-
channels: Object.freeze({
|
|
11
|
-
snackbar: Object.freeze([]),
|
|
12
|
-
banner: Object.freeze([]),
|
|
13
|
-
dialog: Object.freeze([])
|
|
14
|
-
})
|
|
15
|
-
});
|
|
16
|
-
|
|
17
5
|
const EMPTY_ERROR_RUNTIME = Object.freeze({
|
|
18
6
|
report() {
|
|
19
7
|
return Object.freeze({
|
|
@@ -60,24 +48,6 @@ const EMPTY_ERROR_RUNTIME = Object.freeze({
|
|
|
60
48
|
}
|
|
61
49
|
});
|
|
62
50
|
|
|
63
|
-
const EMPTY_PRESENTATION_STORE = Object.freeze({
|
|
64
|
-
getState() {
|
|
65
|
-
return EMPTY_PRESENTATION_STATE;
|
|
66
|
-
},
|
|
67
|
-
subscribe() {
|
|
68
|
-
return () => {};
|
|
69
|
-
},
|
|
70
|
-
present() {
|
|
71
|
-
throw new Error("Shell web error presentation store is not available.");
|
|
72
|
-
},
|
|
73
|
-
dismiss() {
|
|
74
|
-
return 0;
|
|
75
|
-
},
|
|
76
|
-
clear() {
|
|
77
|
-
return 0;
|
|
78
|
-
}
|
|
79
|
-
});
|
|
80
|
-
|
|
81
51
|
function useShellWebErrorRuntime({ required = false } = {}) {
|
|
82
52
|
const runtime = inject("jskit.shell-web.runtime.web-error.client", null);
|
|
83
53
|
if (runtime && typeof runtime.report === "function") {
|
|
@@ -91,48 +61,7 @@ function useShellWebErrorRuntime({ required = false } = {}) {
|
|
|
91
61
|
return EMPTY_ERROR_RUNTIME;
|
|
92
62
|
}
|
|
93
63
|
|
|
94
|
-
function useShellWebErrorPresentationStore({ required = false } = {}) {
|
|
95
|
-
const store = inject("jskit.shell-web.runtime.web-error.presentation-store.client", null);
|
|
96
|
-
if (store && typeof store.getState === "function" && typeof store.subscribe === "function") {
|
|
97
|
-
return store;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
if (required) {
|
|
101
|
-
throw new Error("Shell web error presentation store is not available in Vue injection context.");
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
return EMPTY_PRESENTATION_STORE;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function useShellWebErrorPresentationState({ required = false } = {}) {
|
|
108
|
-
const store = useShellWebErrorPresentationStore({ required });
|
|
109
|
-
const state = shallowRef(store.getState());
|
|
110
|
-
let unsubscribe = null;
|
|
111
|
-
|
|
112
|
-
onMounted(() => {
|
|
113
|
-
unsubscribe = store.subscribe((nextState) => {
|
|
114
|
-
state.value = nextState;
|
|
115
|
-
});
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
onBeforeUnmount(() => {
|
|
119
|
-
if (typeof unsubscribe === "function") {
|
|
120
|
-
unsubscribe();
|
|
121
|
-
unsubscribe = null;
|
|
122
|
-
}
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
return Object.freeze({
|
|
126
|
-
state,
|
|
127
|
-
store
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
|
|
131
64
|
export {
|
|
132
65
|
EMPTY_ERROR_RUNTIME,
|
|
133
|
-
|
|
134
|
-
EMPTY_PRESENTATION_STATE,
|
|
135
|
-
useShellWebErrorRuntime,
|
|
136
|
-
useShellWebErrorPresentationStore,
|
|
137
|
-
useShellWebErrorPresentationState
|
|
66
|
+
useShellWebErrorRuntime
|
|
138
67
|
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const EMPTY_PRESENTATION_STATE = Object.freeze({
|
|
2
|
+
revision: 0,
|
|
3
|
+
channels: Object.freeze({
|
|
4
|
+
snackbar: Object.freeze([]),
|
|
5
|
+
banner: Object.freeze([]),
|
|
6
|
+
dialog: Object.freeze([])
|
|
7
|
+
})
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const EMPTY_PRESENTATION_STORE = Object.freeze({
|
|
11
|
+
getState() {
|
|
12
|
+
return EMPTY_PRESENTATION_STATE;
|
|
13
|
+
},
|
|
14
|
+
subscribe() {
|
|
15
|
+
return () => {};
|
|
16
|
+
},
|
|
17
|
+
present() {
|
|
18
|
+
throw new Error("Shell web error presentation store is not available.");
|
|
19
|
+
},
|
|
20
|
+
dismiss() {
|
|
21
|
+
return 0;
|
|
22
|
+
},
|
|
23
|
+
clear() {
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export {
|
|
29
|
+
EMPTY_PRESENTATION_STATE,
|
|
30
|
+
EMPTY_PRESENTATION_STORE
|
|
31
|
+
};
|
package/src/client/index.js
CHANGED
|
@@ -14,6 +14,8 @@ export { default as ShellMenuLinkItem } from "./components/ShellMenuLinkItem.vue
|
|
|
14
14
|
export { default as ShellSurfaceAwareMenuLinkItem } from "./components/ShellSurfaceAwareMenuLinkItem.vue";
|
|
15
15
|
export { default as ShellTabLinkItem } from "./components/ShellTabLinkItem.vue";
|
|
16
16
|
export { useShellLayoutState } from "./composables/useShellLayoutState.js";
|
|
17
|
+
export { useShellLayoutStore } from "./stores/useShellLayoutStore.js";
|
|
18
|
+
export { useShellErrorPresentationStore } from "./stores/useShellErrorPresentationStore.js";
|
|
17
19
|
|
|
18
20
|
const clientProviders = Object.freeze([ShellWebClientProvider]);
|
|
19
21
|
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
createErrorPresentationStore
|
|
23
23
|
} from "../error/store.js";
|
|
24
24
|
import { createWebPlacementRuntime } from "../placement/runtime.js";
|
|
25
|
+
import { useShellErrorPresentationStore } from "../stores/useShellErrorPresentationStore.js";
|
|
25
26
|
import { buildSurfaceConfigContext } from "../placement/surfaceContext.js";
|
|
26
27
|
|
|
27
28
|
// Keep this constant for diagnostics, but keep import() below as a literal string so Vite can statically analyze it.
|
|
@@ -292,6 +293,12 @@ class ShellWebClientProvider {
|
|
|
292
293
|
if (!vueApp || typeof vueApp.provide !== "function" || typeof vueApp.use !== "function") {
|
|
293
294
|
return;
|
|
294
295
|
}
|
|
296
|
+
const pinia = app.make("jskit.client.pinia");
|
|
297
|
+
if (!pinia) {
|
|
298
|
+
throw new Error("ShellWebClientProvider requires Pinia installed in the client app.");
|
|
299
|
+
}
|
|
300
|
+
const errorPresentationStore = app.make("runtime.web-error.presentation-store.client");
|
|
301
|
+
useShellErrorPresentationStore(pinia).attachRuntimeStore(errorPresentationStore);
|
|
295
302
|
|
|
296
303
|
vueApp.use(VueQueryPlugin, {
|
|
297
304
|
queryClient: app.make("shell.web.query-client")
|
|
@@ -300,7 +307,7 @@ class ShellWebClientProvider {
|
|
|
300
307
|
vueApp.provide("jskit.shell-web.runtime.web-error.client", errorRuntime);
|
|
301
308
|
vueApp.provide(
|
|
302
309
|
"jskit.shell-web.runtime.web-error.presentation-store.client",
|
|
303
|
-
|
|
310
|
+
errorPresentationStore
|
|
304
311
|
);
|
|
305
312
|
|
|
306
313
|
installVueErrorBridge(vueApp, errorRuntime, logger);
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { computed, markRaw, shallowRef } from "vue";
|
|
2
|
+
import { defineStore } from "pinia";
|
|
3
|
+
import { EMPTY_PRESENTATION_STATE, EMPTY_PRESENTATION_STORE } from "../error/presentationDefaults.js";
|
|
4
|
+
|
|
5
|
+
function normalizePresentationState(nextState) {
|
|
6
|
+
if (!nextState || typeof nextState !== "object") {
|
|
7
|
+
return EMPTY_PRESENTATION_STATE;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
return nextState;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isPresentationRuntimeStore(value) {
|
|
14
|
+
return Boolean(
|
|
15
|
+
value &&
|
|
16
|
+
typeof value.getState === "function" &&
|
|
17
|
+
typeof value.subscribe === "function" &&
|
|
18
|
+
typeof value.present === "function" &&
|
|
19
|
+
typeof value.dismiss === "function" &&
|
|
20
|
+
typeof value.clear === "function"
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const useShellErrorPresentationStore = defineStore("jskit.shell-web.error-presentation", () => {
|
|
25
|
+
const runtimeStore = shallowRef(markRaw(EMPTY_PRESENTATION_STORE));
|
|
26
|
+
const presentationState = shallowRef(EMPTY_PRESENTATION_STATE);
|
|
27
|
+
let unsubscribe = null;
|
|
28
|
+
|
|
29
|
+
function setPresentationState(nextState) {
|
|
30
|
+
presentationState.value = normalizePresentationState(nextState);
|
|
31
|
+
return presentationState.value;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function detachRuntimeStore() {
|
|
35
|
+
if (typeof unsubscribe === "function") {
|
|
36
|
+
unsubscribe();
|
|
37
|
+
unsubscribe = null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function attachRuntimeStore(nextRuntimeStore = EMPTY_PRESENTATION_STORE) {
|
|
42
|
+
if (!isPresentationRuntimeStore(nextRuntimeStore)) {
|
|
43
|
+
throw new TypeError("useShellErrorPresentationStore.attachRuntimeStore requires an error presentation store.");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (runtimeStore.value === nextRuntimeStore) {
|
|
47
|
+
setPresentationState(nextRuntimeStore.getState());
|
|
48
|
+
return runtimeStore.value;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
detachRuntimeStore();
|
|
52
|
+
runtimeStore.value = markRaw(nextRuntimeStore);
|
|
53
|
+
setPresentationState(nextRuntimeStore.getState());
|
|
54
|
+
unsubscribe = nextRuntimeStore.subscribe((nextState) => {
|
|
55
|
+
setPresentationState(nextState);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return runtimeStore.value;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getState() {
|
|
62
|
+
return presentationState.value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function subscribe(listener) {
|
|
66
|
+
return runtimeStore.value.subscribe(listener);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function present(channel, payload = {}) {
|
|
70
|
+
return runtimeStore.value.present(channel, payload);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function dismiss(channel, presentationId = "") {
|
|
74
|
+
return runtimeStore.value.dismiss(channel, presentationId);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function clear(channel = "") {
|
|
78
|
+
return runtimeStore.value.clear(channel);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const revision = computed(() => Number(presentationState.value.revision || 0));
|
|
82
|
+
const channels = computed(() => presentationState.value.channels || EMPTY_PRESENTATION_STATE.channels);
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
runtimeStore,
|
|
86
|
+
presentationState,
|
|
87
|
+
revision,
|
|
88
|
+
channels,
|
|
89
|
+
attachRuntimeStore,
|
|
90
|
+
getState,
|
|
91
|
+
subscribe,
|
|
92
|
+
present,
|
|
93
|
+
dismiss,
|
|
94
|
+
clear
|
|
95
|
+
};
|
|
96
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { ref } from "vue";
|
|
2
|
+
import { defineStore } from "pinia";
|
|
3
|
+
import {
|
|
4
|
+
readDrawerDefaultOpenPreference,
|
|
5
|
+
writeDrawerDefaultOpenPreference
|
|
6
|
+
} from "../composables/shellLayoutDrawerPreference.js";
|
|
7
|
+
|
|
8
|
+
export const useShellLayoutStore = defineStore("jskit.shell-web.layout", () => {
|
|
9
|
+
const drawerDefaultOpen = ref(readDrawerDefaultOpenPreference());
|
|
10
|
+
const drawerOpen = ref(drawerDefaultOpen.value);
|
|
11
|
+
|
|
12
|
+
function setDrawerDefaultOpen(open) {
|
|
13
|
+
const normalized = Boolean(open);
|
|
14
|
+
drawerDefaultOpen.value = normalized;
|
|
15
|
+
drawerOpen.value = normalized;
|
|
16
|
+
writeDrawerDefaultOpenPreference(normalized);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function setDrawerOpen(open) {
|
|
20
|
+
drawerOpen.value = Boolean(open);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function toggleDrawer() {
|
|
24
|
+
drawerOpen.value = !drawerOpen.value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
drawerDefaultOpen,
|
|
29
|
+
drawerOpen,
|
|
30
|
+
setDrawerDefaultOpen,
|
|
31
|
+
setDrawerOpen,
|
|
32
|
+
toggleDrawer
|
|
33
|
+
};
|
|
34
|
+
});
|
|
@@ -59,15 +59,15 @@ function resolveMenuLinkTarget({
|
|
|
59
59
|
surface = "",
|
|
60
60
|
currentSurfaceId = "",
|
|
61
61
|
placementContext = null,
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
scopedSuffix = "/",
|
|
63
|
+
unscopedSuffix = "/",
|
|
64
64
|
routeParams = {},
|
|
65
65
|
resolvePagePath = null
|
|
66
66
|
} = {}) {
|
|
67
67
|
const explicitTarget = normalizeText(to);
|
|
68
68
|
const targetSurfaceId = resolveMenuLinkSurfaceId(surface, currentSurfaceId);
|
|
69
|
-
const
|
|
70
|
-
const suffixTemplate = normalizeText(
|
|
69
|
+
const scopedRouteRequired = surfaceRequiresWorkspaceFromPlacementContext(placementContext, targetSurfaceId);
|
|
70
|
+
const suffixTemplate = normalizeText(scopedRouteRequired ? scopedSuffix : unscopedSuffix) || "/";
|
|
71
71
|
const interpolatedSuffix = interpolateBracketParams(suffixTemplate, routeParams);
|
|
72
72
|
const resolvedSuffixTarget =
|
|
73
73
|
typeof resolvePagePath === "function" &&
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed } from "vue";
|
|
3
|
+
import { useShellLayoutState } from "@jskit-ai/shell-web/client/composables/useShellLayoutState";
|
|
4
|
+
|
|
5
|
+
const { drawerDefaultOpen, setDrawerDefaultOpen } = useShellLayoutState();
|
|
6
|
+
|
|
7
|
+
const drawerDefaultOpenModel = computed({
|
|
8
|
+
get() {
|
|
9
|
+
return drawerDefaultOpen.value;
|
|
10
|
+
},
|
|
11
|
+
set(value) {
|
|
12
|
+
setDrawerDefaultOpen(Boolean(value));
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<template>
|
|
18
|
+
<section class="d-flex flex-column ga-4">
|
|
19
|
+
<div>
|
|
20
|
+
<h2 class="text-h6 mb-2">General</h2>
|
|
21
|
+
<p class="text-body-2 text-medium-emphasis mb-0">These starter settings live in this browser only.</p>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<v-switch
|
|
25
|
+
v-model="drawerDefaultOpenModel"
|
|
26
|
+
color="primary"
|
|
27
|
+
inset
|
|
28
|
+
hide-details="auto"
|
|
29
|
+
label="Open navigation drawer by default"
|
|
30
|
+
/>
|
|
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
|
+
</section>
|
|
37
|
+
</template>
|
|
@@ -1,37 +1,5 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
const { drawerDefaultOpen, setDrawerDefaultOpen } = useShellLayoutState();
|
|
6
|
-
|
|
7
|
-
const drawerDefaultOpenModel = computed({
|
|
8
|
-
get() {
|
|
9
|
-
return drawerDefaultOpen.value;
|
|
10
|
-
},
|
|
11
|
-
set(value) {
|
|
12
|
-
setDrawerDefaultOpen(Boolean(value));
|
|
13
|
-
}
|
|
2
|
+
definePage({
|
|
3
|
+
redirect: (to) => `${String(to.path || "").replace(/\/$/, "")}/general`
|
|
14
4
|
});
|
|
15
5
|
</script>
|
|
16
|
-
|
|
17
|
-
<template>
|
|
18
|
-
<section class="d-flex flex-column ga-4">
|
|
19
|
-
<div>
|
|
20
|
-
<h2 class="text-h6 mb-2">Shell settings</h2>
|
|
21
|
-
<p class="text-body-2 text-medium-emphasis mb-0">These starter settings live in this browser only.</p>
|
|
22
|
-
</div>
|
|
23
|
-
|
|
24
|
-
<v-switch
|
|
25
|
-
v-model="drawerDefaultOpenModel"
|
|
26
|
-
color="primary"
|
|
27
|
-
inset
|
|
28
|
-
hide-details="auto"
|
|
29
|
-
label="Open navigation drawer by default"
|
|
30
|
-
/>
|
|
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
|
-
</section>
|
|
37
|
-
</template>
|
|
@@ -20,8 +20,8 @@ addPlacement({
|
|
|
20
20
|
props: {
|
|
21
21
|
label: "Home",
|
|
22
22
|
surface: "home",
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
scopedSuffix: "/",
|
|
24
|
+
unscopedSuffix: "/",
|
|
25
25
|
exact: true
|
|
26
26
|
}
|
|
27
27
|
});
|
|
@@ -35,7 +35,22 @@ addPlacement({
|
|
|
35
35
|
props: {
|
|
36
36
|
label: "Settings",
|
|
37
37
|
surface: "home",
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
scopedSuffix: "/settings",
|
|
39
|
+
unscopedSuffix: "/settings"
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
addPlacement({
|
|
44
|
+
id: "shell-web.home.settings.general",
|
|
45
|
+
target: "home-settings:primary-menu",
|
|
46
|
+
surfaces: ["home"],
|
|
47
|
+
order: 100,
|
|
48
|
+
componentToken: "local.main.ui.surface-aware-menu-link-item",
|
|
49
|
+
props: {
|
|
50
|
+
label: "General",
|
|
51
|
+
surface: "home",
|
|
52
|
+
scopedSuffix: "/settings/general",
|
|
53
|
+
unscopedSuffix: "/settings/general",
|
|
54
|
+
to: "./general"
|
|
40
55
|
}
|
|
41
56
|
});
|
package/test/errorStore.test.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
+
import { createPinia } from "pinia";
|
|
3
4
|
import { createErrorPresentationStore } from "../src/client/error/store.js";
|
|
5
|
+
import { useShellErrorPresentationStore } from "../src/client/stores/useShellErrorPresentationStore.js";
|
|
4
6
|
|
|
5
7
|
test("error presentation store keeps banner channel singleton", () => {
|
|
6
8
|
const store = createErrorPresentationStore({ now: () => 1000 });
|
|
@@ -24,3 +26,16 @@ test("error presentation store still queues snackbar channel entries", () => {
|
|
|
24
26
|
assert.equal(state.channels.snackbar[0].message, "One");
|
|
25
27
|
assert.equal(state.channels.snackbar[1].message, "Two");
|
|
26
28
|
});
|
|
29
|
+
|
|
30
|
+
test("shell error presentation Pinia store mirrors runtime presentation state", () => {
|
|
31
|
+
const pinia = createPinia();
|
|
32
|
+
const runtimeStore = createErrorPresentationStore({ now: () => 1000 });
|
|
33
|
+
const store = useShellErrorPresentationStore(pinia);
|
|
34
|
+
|
|
35
|
+
store.attachRuntimeStore(runtimeStore);
|
|
36
|
+
runtimeStore.present("snackbar", { message: "Hello" });
|
|
37
|
+
|
|
38
|
+
assert.equal(store.channels.snackbar.length, 1);
|
|
39
|
+
assert.equal(store.channels.snackbar[0].message, "Hello");
|
|
40
|
+
assert.equal(store.getState().channels.snackbar[0].message, "Hello");
|
|
41
|
+
});
|
package/test/provider.test.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
+
import { createPinia } from "pinia";
|
|
3
4
|
import { ShellWebClientProvider } from "../src/client/providers/ShellWebClientProvider.js";
|
|
5
|
+
import { useShellErrorPresentationStore } from "../src/client/stores/useShellErrorPresentationStore.js";
|
|
4
6
|
const CLIENT_APP_CONFIG_GLOBAL_KEY = "__JSKIT_CLIENT_APP_CONFIG__";
|
|
5
7
|
|
|
6
8
|
function setClientAppConfig(source = {}) {
|
|
@@ -17,9 +19,14 @@ function createAppDouble({ surfaceRuntime = null } = {}) {
|
|
|
17
19
|
const singletonInstances = new Map();
|
|
18
20
|
const provided = [];
|
|
19
21
|
const plugins = [];
|
|
22
|
+
const pinia = createPinia();
|
|
20
23
|
|
|
21
24
|
const vueApp = {
|
|
22
|
-
config: {
|
|
25
|
+
config: {
|
|
26
|
+
globalProperties: {
|
|
27
|
+
$pinia: pinia
|
|
28
|
+
}
|
|
29
|
+
},
|
|
23
30
|
use(plugin, options) {
|
|
24
31
|
plugins.push({ plugin, options });
|
|
25
32
|
return this;
|
|
@@ -33,6 +40,7 @@ function createAppDouble({ surfaceRuntime = null } = {}) {
|
|
|
33
40
|
singletons,
|
|
34
41
|
provided,
|
|
35
42
|
plugins,
|
|
43
|
+
pinia,
|
|
36
44
|
vueApp,
|
|
37
45
|
singleton(token, factory) {
|
|
38
46
|
singletons.set(token, factory);
|
|
@@ -41,6 +49,9 @@ function createAppDouble({ surfaceRuntime = null } = {}) {
|
|
|
41
49
|
if (token === "jskit.client.vue.app") {
|
|
42
50
|
return true;
|
|
43
51
|
}
|
|
52
|
+
if (token === "jskit.client.pinia") {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
44
55
|
if (token === "jskit.client.surface.runtime") {
|
|
45
56
|
return Boolean(surfaceRuntime);
|
|
46
57
|
}
|
|
@@ -50,6 +61,9 @@ function createAppDouble({ surfaceRuntime = null } = {}) {
|
|
|
50
61
|
if (token === "jskit.client.vue.app") {
|
|
51
62
|
return vueApp;
|
|
52
63
|
}
|
|
64
|
+
if (token === "jskit.client.pinia") {
|
|
65
|
+
return pinia;
|
|
66
|
+
}
|
|
53
67
|
if (token === "jskit.client.surface.runtime" && surfaceRuntime) {
|
|
54
68
|
return surfaceRuntime;
|
|
55
69
|
}
|
|
@@ -103,6 +117,12 @@ test("shell web client provider binds runtime and injects it into Vue app", asyn
|
|
|
103
117
|
const errorStore = providedByKey.get("jskit.shell-web.runtime.web-error.presentation-store.client");
|
|
104
118
|
assert.equal(typeof errorStore.getState, "function");
|
|
105
119
|
assert.equal(typeof errorStore.present, "function");
|
|
120
|
+
|
|
121
|
+
const errorPresentationStore = useShellErrorPresentationStore(app.pinia);
|
|
122
|
+
assert.equal(errorPresentationStore.revision, 0);
|
|
123
|
+
assert.equal(typeof errorPresentationStore.present, "function");
|
|
124
|
+
errorStore.present("banner", { message: "Hello" });
|
|
125
|
+
assert.equal(errorPresentationStore.channels.banner[0].message, "Hello");
|
|
106
126
|
});
|
|
107
127
|
|
|
108
128
|
test("shell web client provider resolves surface config from client app config", async () => {
|
|
@@ -39,9 +39,20 @@ test("shell-web home settings template exposes surface-derived settings outlets"
|
|
|
39
39
|
assert.match(source, /<RouterView \/>/);
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
-
test("shell-web settings landing page
|
|
42
|
+
test("shell-web settings landing page redirects to the starter child page", async () => {
|
|
43
43
|
const source = await readFile(path.join(PACKAGE_DIR, "templates", "src", "pages", "home", "settings", "index.vue"), "utf8");
|
|
44
44
|
|
|
45
|
+
assert.match(source, /definePage/);
|
|
46
|
+
assert.match(source, /redirect:/);
|
|
47
|
+
assert.match(source, /\/general/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("shell-web settings general child page exposes a tiny browser-local drawer preference", async () => {
|
|
51
|
+
const source = await readFile(
|
|
52
|
+
path.join(PACKAGE_DIR, "templates", "src", "pages", "home", "settings", "general", "index.vue"),
|
|
53
|
+
"utf8"
|
|
54
|
+
);
|
|
55
|
+
|
|
45
56
|
assert.match(source, /useShellLayoutState/);
|
|
46
57
|
assert.match(source, /drawerDefaultOpen/);
|
|
47
58
|
assert.match(source, /setDrawerDefaultOpen/);
|
|
@@ -55,10 +66,15 @@ test("shell-web placement template seeds default Home and Settings drawer naviga
|
|
|
55
66
|
assert.match(source, /id: "shell-web\.home\.menu\.home"/);
|
|
56
67
|
assert.match(source, /target: "shell-layout:primary-menu"/);
|
|
57
68
|
assert.match(source, /label: "Home"/);
|
|
58
|
-
assert.match(source, /
|
|
69
|
+
assert.match(source, /unscopedSuffix: "\/"/);
|
|
59
70
|
assert.match(source, /id: "shell-web\.home\.menu\.settings"/);
|
|
60
71
|
assert.match(source, /label: "Settings"/);
|
|
61
|
-
assert.match(source, /
|
|
72
|
+
assert.match(source, /unscopedSuffix: "\/settings"/);
|
|
73
|
+
assert.match(source, /id: "shell-web\.home\.settings\.general"/);
|
|
74
|
+
assert.match(source, /target: "home-settings:primary-menu"/);
|
|
75
|
+
assert.match(source, /label: "General"/);
|
|
76
|
+
assert.match(source, /unscopedSuffix: "\/settings\/general"/);
|
|
77
|
+
assert.match(source, /to: "\.\/general"/);
|
|
62
78
|
});
|
|
63
79
|
|
|
64
80
|
test("shell-web descriptor metadata advertises home settings outlets, default drawer links, and installs the scaffold page", () => {
|
|
@@ -96,6 +112,20 @@ test("shell-web descriptor metadata advertises home settings outlets, default dr
|
|
|
96
112
|
]
|
|
97
113
|
);
|
|
98
114
|
|
|
115
|
+
assert.deepEqual(
|
|
116
|
+
readContributions("home-settings:primary-menu"),
|
|
117
|
+
[
|
|
118
|
+
{
|
|
119
|
+
id: "shell-web.home.settings.general",
|
|
120
|
+
target: "home-settings:primary-menu",
|
|
121
|
+
surfaces: ["home"],
|
|
122
|
+
order: 100,
|
|
123
|
+
componentToken: "local.main.ui.surface-aware-menu-link-item",
|
|
124
|
+
source: "templates/src/placement.js"
|
|
125
|
+
}
|
|
126
|
+
]
|
|
127
|
+
);
|
|
128
|
+
|
|
99
129
|
assert.deepEqual(findFileMutation("shell-web-page-home-settings-shell"), {
|
|
100
130
|
from: "templates/src/pages/home/settings.vue",
|
|
101
131
|
toSurface: "home",
|
|
@@ -111,10 +141,20 @@ test("shell-web descriptor metadata advertises home settings outlets, default dr
|
|
|
111
141
|
toSurface: "home",
|
|
112
142
|
toSurfacePath: "settings/index.vue",
|
|
113
143
|
ownership: "app",
|
|
114
|
-
reason: "Install shell-driven home settings
|
|
144
|
+
reason: "Install shell-driven home settings redirect so the starter settings shell lands on a real child page.",
|
|
115
145
|
category: "shell-web",
|
|
116
146
|
id: "shell-web-page-home-settings"
|
|
117
147
|
});
|
|
148
|
+
|
|
149
|
+
assert.deepEqual(findFileMutation("shell-web-page-home-settings-general"), {
|
|
150
|
+
from: "templates/src/pages/home/settings/general/index.vue",
|
|
151
|
+
toSurface: "home",
|
|
152
|
+
toSurfacePath: "settings/general/index.vue",
|
|
153
|
+
ownership: "app",
|
|
154
|
+
reason: "Install shell-driven general settings child page with a tiny browser-local shell preference example.",
|
|
155
|
+
category: "shell-web",
|
|
156
|
+
id: "shell-web-page-home-settings-general"
|
|
157
|
+
});
|
|
118
158
|
});
|
|
119
159
|
|
|
120
160
|
test("shell-web home starter page relies on drawer navigation instead of dead feature buttons", async () => {
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
+
import { createPinia } from "pinia";
|
|
3
4
|
import {
|
|
4
5
|
SHELL_LAYOUT_DRAWER_DEFAULT_OPEN_STORAGE_KEY,
|
|
5
6
|
readDrawerDefaultOpenPreference,
|
|
6
7
|
writeDrawerDefaultOpenPreference
|
|
7
8
|
} from "../src/client/composables/shellLayoutDrawerPreference.js";
|
|
9
|
+
import { useShellLayoutStore } from "../src/client/stores/useShellLayoutStore.js";
|
|
8
10
|
|
|
9
11
|
function createStorage(initial = {}) {
|
|
10
12
|
const values = new Map(Object.entries(initial));
|
|
@@ -48,3 +50,18 @@ test("writeDrawerDefaultOpenPreference persists normalized boolean strings", ()
|
|
|
48
50
|
writeDrawerDefaultOpenPreference(true, { storage });
|
|
49
51
|
assert.equal(storage.getItem(SHELL_LAYOUT_DRAWER_DEFAULT_OPEN_STORAGE_KEY), "true");
|
|
50
52
|
});
|
|
53
|
+
|
|
54
|
+
test("shell layout store keeps drawer state and default preference in sync", () => {
|
|
55
|
+
const pinia = createPinia();
|
|
56
|
+
const store = useShellLayoutStore(pinia);
|
|
57
|
+
|
|
58
|
+
store.setDrawerDefaultOpen(false);
|
|
59
|
+
assert.equal(store.drawerDefaultOpen, false);
|
|
60
|
+
assert.equal(store.drawerOpen, false);
|
|
61
|
+
|
|
62
|
+
store.toggleDrawer();
|
|
63
|
+
assert.equal(store.drawerOpen, true);
|
|
64
|
+
|
|
65
|
+
store.setDrawerOpen(false);
|
|
66
|
+
assert.equal(store.drawerOpen, false);
|
|
67
|
+
});
|