@jskit-ai/users-web 0.1.67 → 0.1.68

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_TOOLS_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.67",
6
+ version: "0.1.68",
7
7
  kind: "runtime",
8
8
  description: "Users web module: account/profile UI plus shared users web widgets.",
9
9
  dependsOn: [
@@ -96,7 +96,7 @@ export default Object.freeze({
96
96
  },
97
97
  {
98
98
  subpath: "./client/account-settings/sections",
99
- summary: "Exports account settings section extension seam helpers."
99
+ summary: "Exports placement-backed account settings section helpers."
100
100
  }
101
101
  ],
102
102
  containerTokens: {
@@ -115,6 +115,11 @@ export default Object.freeze({
115
115
  defaultLinkComponentToken: HOME_TOOLS_OUTLET.defaultLinkComponentToken,
116
116
  surfaces: ["home"],
117
117
  source: "src/client/components/UsersHomeToolsWidget.vue"
118
+ },
119
+ {
120
+ target: "account-settings:sections",
121
+ surfaces: ["account"],
122
+ source: "templates/src/components/account/settings/AccountSettingsClientElement.vue"
118
123
  }
119
124
  ],
120
125
  contributions: [
@@ -154,12 +159,12 @@ export default Object.freeze({
154
159
  runtime: {
155
160
  "@tanstack/vue-query": "5.92.12",
156
161
  "@mdi/js": "^7.4.47",
157
- "@jskit-ai/http-runtime": "0.1.51",
158
- "@jskit-ai/realtime": "0.1.51",
159
- "@jskit-ai/kernel": "0.1.52",
160
- "@jskit-ai/shell-web": "0.1.51",
161
- "@jskit-ai/uploads-image-web": "0.1.30",
162
- "@jskit-ai/users-core": "0.1.62",
162
+ "@jskit-ai/http-runtime": "0.1.52",
163
+ "@jskit-ai/realtime": "0.1.52",
164
+ "@jskit-ai/kernel": "0.1.53",
165
+ "@jskit-ai/shell-web": "0.1.52",
166
+ "@jskit-ai/uploads-image-web": "0.1.31",
167
+ "@jskit-ai/users-core": "0.1.63",
163
168
  vuetify: "^4.0.0"
164
169
  },
165
170
  dev: {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/users-web",
3
- "version": "0.1.67",
3
+ "version": "0.1.68",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -35,12 +35,12 @@
35
35
  "dependencies": {
36
36
  "@tanstack/vue-query": "5.92.12",
37
37
  "@mdi/js": "^7.4.47",
38
- "@jskit-ai/http-runtime": "0.1.51",
39
- "@jskit-ai/kernel": "0.1.52",
40
- "@jskit-ai/realtime": "0.1.51",
41
- "@jskit-ai/shell-web": "0.1.51",
42
- "@jskit-ai/uploads-image-web": "0.1.30",
43
- "@jskit-ai/users-core": "0.1.62",
38
+ "@jskit-ai/http-runtime": "0.1.52",
39
+ "@jskit-ai/kernel": "0.1.53",
40
+ "@jskit-ai/realtime": "0.1.52",
41
+ "@jskit-ai/shell-web": "0.1.52",
42
+ "@jskit-ai/uploads-image-web": "0.1.31",
43
+ "@jskit-ai/users-core": "0.1.63",
44
44
  "vuetify": "^4.0.0"
45
45
  }
46
46
  }
@@ -1,21 +1,31 @@
1
- import { inject } from "vue";
1
+ import {
2
+ computed,
3
+ inject,
4
+ onBeforeUnmount,
5
+ onMounted,
6
+ ref
7
+ } from "vue";
8
+ import { useWebPlacementContext } from "@jskit-ai/shell-web/client/placement";
2
9
 
3
- const ACCOUNT_SETTINGS_SECTIONS_INJECTION_KEY = Symbol("users-web.account-settings.sections");
4
- const ACCOUNT_SETTINGS_SECTION_REGISTRY_TAG = "users.web.account-settings.sections";
10
+ const ACCOUNT_SETTINGS_SECTION_TARGET = "account-settings:sections";
5
11
  const EMPTY_ACCOUNT_SETTINGS_SECTIONS = Object.freeze([]);
6
12
  const RESERVED_ACCOUNT_SETTINGS_SECTION_VALUES = Object.freeze([
7
13
  "profile",
8
14
  "preferences",
9
15
  "notifications"
10
16
  ]);
17
+ const WEB_PLACEMENT_RUNTIME_INJECTION_KEY = "jskit.shell-web.runtime.web-placement.client";
11
18
 
12
19
  function normalizeAccountSettingsSectionEntry(entry = null) {
13
20
  if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
14
21
  return null;
15
22
  }
16
23
 
17
- const value = String(entry.value || "").trim().toLowerCase();
18
- const title = String(entry.title || "").trim();
24
+ const props = entry?.props && typeof entry.props === "object" && !Array.isArray(entry.props)
25
+ ? entry.props
26
+ : {};
27
+ const value = String(props.value || entry.value || "").trim().toLowerCase();
28
+ const title = String(props.title || entry.title || "").trim();
19
29
  const component = entry.component;
20
30
  if (!value || !title || !component) {
21
31
  return null;
@@ -26,7 +36,7 @@ function normalizeAccountSettingsSectionEntry(entry = null) {
26
36
  title,
27
37
  component,
28
38
  order: Number.isFinite(Number(entry.order)) ? Number(entry.order) : 500,
29
- usesSharedRuntime: entry.usesSharedRuntime === true
39
+ usesSharedRuntime: props.usesSharedRuntime === true || entry.usesSharedRuntime === true
30
40
  });
31
41
  }
32
42
 
@@ -51,6 +61,7 @@ function resolveAccountSettingsSections(entries = []) {
51
61
  if (!resolved || seen.has(resolved.value)) {
52
62
  continue;
53
63
  }
64
+
54
65
  seen.add(resolved.value);
55
66
  normalized.push(resolved);
56
67
  }
@@ -59,12 +70,49 @@ function resolveAccountSettingsSections(entries = []) {
59
70
  }
60
71
 
61
72
  function useAccountSettingsSections() {
62
- return inject(ACCOUNT_SETTINGS_SECTIONS_INJECTION_KEY, EMPTY_ACCOUNT_SETTINGS_SECTIONS);
73
+ const placementRuntime = inject(WEB_PLACEMENT_RUNTIME_INJECTION_KEY, null);
74
+ const { context: placementContext } = useWebPlacementContext();
75
+ const revision = ref(
76
+ placementRuntime && typeof placementRuntime.getRevision === "function"
77
+ ? placementRuntime.getRevision()
78
+ : 0
79
+ );
80
+ let unsubscribe = null;
81
+
82
+ onMounted(() => {
83
+ if (!placementRuntime || typeof placementRuntime.subscribe !== "function") {
84
+ return;
85
+ }
86
+ unsubscribe = placementRuntime.subscribe((event = {}) => {
87
+ const nextRevision = Number(event.revision);
88
+ revision.value = Number.isInteger(nextRevision) ? nextRevision : revision.value + 1;
89
+ });
90
+ });
91
+
92
+ onBeforeUnmount(() => {
93
+ if (typeof unsubscribe === "function") {
94
+ unsubscribe();
95
+ unsubscribe = null;
96
+ }
97
+ });
98
+
99
+ return computed(() => {
100
+ void revision.value;
101
+ if (!placementRuntime || typeof placementRuntime.getPlacements !== "function") {
102
+ return EMPTY_ACCOUNT_SETTINGS_SECTIONS;
103
+ }
104
+
105
+ const placements = placementRuntime.getPlacements({
106
+ surface: "account",
107
+ target: ACCOUNT_SETTINGS_SECTION_TARGET,
108
+ context: placementContext.value
109
+ });
110
+ return resolveAccountSettingsSections(placements);
111
+ });
63
112
  }
64
113
 
65
114
  export {
66
- ACCOUNT_SETTINGS_SECTIONS_INJECTION_KEY,
67
- ACCOUNT_SETTINGS_SECTION_REGISTRY_TAG,
115
+ ACCOUNT_SETTINGS_SECTION_TARGET,
68
116
  EMPTY_ACCOUNT_SETTINGS_SECTIONS,
69
117
  RESERVED_ACCOUNT_SETTINGS_SECTION_VALUES,
70
118
  normalizeAccountSettingsSectionEntry,
@@ -1,6 +1,5 @@
1
1
  import UsersHomeToolsWidget from "../components/UsersHomeToolsWidget.vue";
2
2
  import ProfileClientElement from "../components/ProfileClientElement.vue";
3
- import { bootUsersWebClientProvider } from "./bootUsersWebClientProvider.js";
4
3
 
5
4
  class UsersWebClientProvider {
6
5
  static id = "users.web.client";
@@ -14,10 +13,6 @@ class UsersWebClientProvider {
14
13
  app.singleton("users.web.home.tools.widget", () => UsersHomeToolsWidget);
15
14
  app.singleton("users.web.profile.element", () => ProfileClientElement);
16
15
  }
17
-
18
- async boot(app) {
19
- await bootUsersWebClientProvider(app);
20
- }
21
16
  }
22
17
 
23
18
  export { UsersWebClientProvider };
@@ -32,7 +32,7 @@ const sections = computed(() => {
32
32
  }
33
33
  ];
34
34
 
35
- for (const entry of extensionSections) {
35
+ for (const entry of extensionSections.value) {
36
36
  nextSections.push(entry);
37
37
  }
38
38
 
@@ -1,79 +1,92 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import {
4
- ACCOUNT_SETTINGS_SECTION_REGISTRY_TAG,
4
+ ACCOUNT_SETTINGS_SECTION_TARGET,
5
+ normalizeAccountSettingsSectionEntry,
5
6
  resolveAccountSettingsSections
6
7
  } from "../src/client/account-settings/sections.js";
7
- import { bootUsersWebClientProvider } from "../src/client/providers/bootUsersWebClientProvider.js";
8
8
 
9
- test("resolveAccountSettingsSections normalizes, deduplicates, and sorts tagged account section entries", () => {
9
+ test("account settings sections use the standard placement target", () => {
10
+ assert.equal(ACCOUNT_SETTINGS_SECTION_TARGET, "account-settings:sections");
11
+ });
12
+
13
+ test("resolveAccountSettingsSections normalizes, deduplicates, and sorts placement-backed account section entries", () => {
10
14
  const SectionA = {};
11
15
  const SectionB = {};
12
16
 
13
17
  const resolved = resolveAccountSettingsSections([
14
- { value: "notifications", title: "Ignore duplicate", component: SectionA, order: 999 },
15
- { value: "invites", title: "Invites", component: SectionA, order: 400 },
16
- { value: "profile", title: "Broken missing component" },
17
- { value: "security", title: "Security", component: SectionB, order: 350 },
18
- { value: "invites", title: "Second duplicate", component: SectionB, order: 100 }
19
- ]);
20
-
21
- assert.deepEqual(
22
- resolved.map((entry) => ({ value: entry.value, title: entry.title, order: entry.order })),
23
- [
24
- { value: "security", title: "Security", order: 350 },
25
- { value: "invites", title: "Invites", order: 400 }
26
- ]
27
- );
28
- });
29
-
30
- test("bootUsersWebClientProvider provides normalized account section extensions", async () => {
31
- const SectionComponent = {};
32
- const provided = new Map();
33
- let resolvedTagName = "";
34
-
35
- await bootUsersWebClientProvider({
36
- make(token) {
37
- if (token === "jskit.client.vue.app") {
38
- return {
39
- provide(key, value) {
40
- provided.set(key, value);
41
- }
42
- };
18
+ {
19
+ id: "users.notifications.duplicate",
20
+ order: 999,
21
+ component: SectionA,
22
+ props: {
23
+ value: "notifications",
24
+ title: "Ignore duplicate"
25
+ }
26
+ },
27
+ {
28
+ id: "workspaces.invites",
29
+ order: 400,
30
+ component: SectionA,
31
+ props: {
32
+ value: "invites",
33
+ title: "Invites"
43
34
  }
44
- throw new Error(`Unexpected token: ${token}`);
45
35
  },
46
- resolveTag(tagName) {
47
- resolvedTagName = tagName;
48
- return [
49
- {
50
- value: "invites",
51
- title: "Invites",
52
- component: SectionComponent,
53
- order: 400,
54
- usesSharedRuntime: false
55
- }
56
- ];
36
+ {
37
+ id: "invalid.missing-component",
38
+ order: 250,
39
+ props: {
40
+ value: "profile",
41
+ title: "Broken missing component"
42
+ }
43
+ },
44
+ {
45
+ id: "security.section",
46
+ order: 350,
47
+ component: SectionB,
48
+ props: {
49
+ value: "security",
50
+ title: "Security",
51
+ usesSharedRuntime: true
52
+ }
53
+ },
54
+ {
55
+ id: "workspaces.invites.duplicate",
56
+ order: 100,
57
+ component: SectionB,
58
+ props: {
59
+ value: "invites",
60
+ title: "Second duplicate"
61
+ }
57
62
  }
58
- });
63
+ ]);
59
64
 
60
- assert.equal(resolvedTagName, ACCOUNT_SETTINGS_SECTION_REGISTRY_TAG);
61
- const [providedSections] = [...provided.values()];
62
- assert.equal(Array.isArray(providedSections), true);
63
65
  assert.deepEqual(
64
- providedSections.map((entry) => ({
66
+ resolved.map((entry) => ({
65
67
  value: entry.value,
66
68
  title: entry.title,
67
69
  order: entry.order,
68
70
  usesSharedRuntime: entry.usesSharedRuntime
69
71
  })),
70
72
  [
71
- {
72
- value: "invites",
73
- title: "Invites",
74
- order: 400,
75
- usesSharedRuntime: false
76
- }
73
+ { value: "security", title: "Security", order: 350, usesSharedRuntime: true },
74
+ { value: "invites", title: "Invites", order: 400, usesSharedRuntime: false }
77
75
  ]
78
76
  );
79
77
  });
78
+
79
+ test("normalizeAccountSettingsSectionEntry rejects malformed placement entries", () => {
80
+ assert.equal(normalizeAccountSettingsSectionEntry(null), null);
81
+ assert.equal(normalizeAccountSettingsSectionEntry({}), null);
82
+ assert.equal(
83
+ normalizeAccountSettingsSectionEntry({
84
+ component: {},
85
+ props: {
86
+ value: "",
87
+ title: "Broken"
88
+ }
89
+ }),
90
+ null
91
+ );
92
+ });
@@ -92,6 +92,16 @@ test("users-web descriptor metadata advertises home tools outlet and standard ho
92
92
  }
93
93
  ]
94
94
  );
95
+ assert.deepEqual(
96
+ readOutlets("account-settings:sections"),
97
+ [
98
+ {
99
+ target: "account-settings:sections",
100
+ surfaces: ["account"],
101
+ source: "templates/src/components/account/settings/AccountSettingsClientElement.vue"
102
+ }
103
+ ]
104
+ );
95
105
 
96
106
  expectContribution("users.profile.menu.settings", {
97
107
  target: "auth-profile-menu:primary-menu",
@@ -1,28 +0,0 @@
1
- import {
2
- ACCOUNT_SETTINGS_SECTIONS_INJECTION_KEY,
3
- ACCOUNT_SETTINGS_SECTION_REGISTRY_TAG,
4
- resolveAccountSettingsSections
5
- } from "../account-settings/sections.js";
6
-
7
- async function bootUsersWebClientProvider(app) {
8
- if (!app || typeof app.make !== "function") {
9
- throw new Error("bootUsersWebClientProvider requires application make().");
10
- }
11
-
12
- const vueApp = app.make("jskit.client.vue.app");
13
- if (!vueApp || typeof vueApp.provide !== "function") {
14
- throw new Error("bootUsersWebClientProvider requires jskit.client.vue.app provide().");
15
- }
16
-
17
- const extensionSections =
18
- typeof app.resolveTag === "function"
19
- ? app.resolveTag(ACCOUNT_SETTINGS_SECTION_REGISTRY_TAG)
20
- : [];
21
-
22
- vueApp.provide(
23
- ACCOUNT_SETTINGS_SECTIONS_INJECTION_KEY,
24
- resolveAccountSettingsSections(extensionSections)
25
- );
26
- }
27
-
28
- export { bootUsersWebClientProvider };