@jskit-ai/users-web 0.2.9 → 0.2.11
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/fixtures/account-settings/index.html +12 -0
- package/fixtures/account-settings/main.js +140 -0
- package/fixtures/account-settings/vite.config.mjs +9 -0
- package/package.json +8 -8
- package/patterns/account-settings/example/src/components/account/settings/AccountSettingsPreferencesSection.vue +7 -7
- package/patterns/account-settings/example/src/components/account/settings/AccountSettingsProfileSection.vue +1 -1
- package/src/client/account-settings/sections.js +8 -18
- package/src/client/bootstrap/user-bootstrap-handler.js +31 -33
- package/src/client/components/ProfileClientElement.vue +17 -76
- package/src/client/composables/account-settings/accountSettingsAvatarUploadRuntime.js +11 -1
- package/src/client/composables/useAccountSettingsRuntime.js +18 -14
- package/src/client/lib/accountLocale.js +2 -2
- package/src/client/lib/profileElementPresentation.js +85 -0
- package/src/client/lib/theme.js +20 -46
- package/src/client/providers/UsersWebClientProvider.js +6 -0
- package/src/client/types.js +65 -0
- package/test/accountLocale.test.js +12 -5
- package/test/accountSettings.browser.test.js +395 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import {
|
|
2
|
+
normalizeObject,
|
|
3
|
+
normalizeOneOf
|
|
4
|
+
} from "@jskit-ai/kernel/shared/support/normalize";
|
|
5
|
+
|
|
6
|
+
// Default copy, by copy prop key; the copy prop overrides it.
|
|
7
|
+
const DEFAULT_COPY_KEYS = Object.freeze({
|
|
8
|
+
title: "jskit.users.accountSettings.profile.title",
|
|
9
|
+
previewSizePrefix: "jskit.users.accountSettings.profile.previewSizePrefix",
|
|
10
|
+
previewSizeSuffix: "jskit.users.accountSettings.profile.previewSizeSuffix",
|
|
11
|
+
replaceAvatar: "jskit.users.accountSettings.profile.replaceAvatar",
|
|
12
|
+
removeAvatar: "jskit.users.accountSettings.profile.removeAvatar",
|
|
13
|
+
selectedFilePrefix: "jskit.users.accountSettings.profile.selectedFilePrefix",
|
|
14
|
+
displayName: "jskit.users.accountSettings.profile.displayName",
|
|
15
|
+
email: "jskit.users.accountSettings.profile.email",
|
|
16
|
+
emailHint: "jskit.users.accountSettings.profile.emailHint",
|
|
17
|
+
saveProfile: "jskit.users.accountSettings.profile.save"
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function resolveProfileCopy(copy, t) {
|
|
21
|
+
return {
|
|
22
|
+
...Object.fromEntries(Object.entries(DEFAULT_COPY_KEYS).map(([name, key]) => [name, t(key)])),
|
|
23
|
+
...normalizeObject(copy)
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function resolveProfileVariant(value) {
|
|
28
|
+
const variant = normalizeObject(value);
|
|
29
|
+
return {
|
|
30
|
+
layout: normalizeOneOf(variant.layout, ["compact", "comfortable"], "comfortable"),
|
|
31
|
+
surface: normalizeOneOf(variant.surface, ["plain", "carded"], "carded"),
|
|
32
|
+
density: normalizeOneOf(variant.density, ["compact", "comfortable"], "comfortable"),
|
|
33
|
+
tone: normalizeOneOf(variant.tone, ["neutral", "emphasized"], "neutral")
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function resolveProfileFeatures(value) {
|
|
38
|
+
const features = normalizeObject(value);
|
|
39
|
+
return {
|
|
40
|
+
header: features.header !== false,
|
|
41
|
+
removeAvatar: features.removeAvatar !== false
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function resolveProfileUiClasses(ui) {
|
|
46
|
+
const classes = normalizeObject(normalizeObject(ui).classes);
|
|
47
|
+
return {
|
|
48
|
+
root: String(classes.root || "").trim(),
|
|
49
|
+
card: String(classes.card || "").trim()
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function resolveProfileUiTestIds(ui) {
|
|
54
|
+
const testIds = normalizeObject(normalizeObject(ui).testIds);
|
|
55
|
+
return {
|
|
56
|
+
root: String(testIds.root || "profile-client-element"),
|
|
57
|
+
card: String(testIds.card || "profile-client-card"),
|
|
58
|
+
submitButton: String(testIds.submitButton || "profile-submit-button"),
|
|
59
|
+
avatarReplaceButton: String(testIds.avatarReplaceButton || "profile-avatar-replace-button"),
|
|
60
|
+
avatarRemoveButton: String(testIds.avatarRemoveButton || "profile-avatar-remove-button")
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function resolveProfileRootClasses(variant, uiClasses) {
|
|
65
|
+
const classes = [
|
|
66
|
+
"profile-client-element",
|
|
67
|
+
`profile-client-element--layout-${variant.layout}`,
|
|
68
|
+
`profile-client-element--surface-${variant.surface}`,
|
|
69
|
+
`profile-client-element--density-${variant.density}`,
|
|
70
|
+
`profile-client-element--tone-${variant.tone}`
|
|
71
|
+
];
|
|
72
|
+
if (uiClasses.root) {
|
|
73
|
+
classes.push(uiClasses.root);
|
|
74
|
+
}
|
|
75
|
+
return classes;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export {
|
|
79
|
+
resolveProfileCopy,
|
|
80
|
+
resolveProfileFeatures,
|
|
81
|
+
resolveProfileRootClasses,
|
|
82
|
+
resolveProfileUiClasses,
|
|
83
|
+
resolveProfileUiTestIds,
|
|
84
|
+
resolveProfileVariant
|
|
85
|
+
};
|
package/src/client/lib/theme.js
CHANGED
|
@@ -12,18 +12,20 @@ function normalizeThemePreference(value) {
|
|
|
12
12
|
return THEME_PREFERENCE_SYSTEM;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
/** The page's window; null outside a browser. */
|
|
16
|
+
function pageWindow() {
|
|
17
|
+
return typeof window === "undefined" ? null : window;
|
|
18
|
+
}
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
/** Whether the system asks for a dark theme; false where it cannot say. */
|
|
21
|
+
function systemPrefersDark() {
|
|
22
|
+
const page = pageWindow();
|
|
23
|
+
// readable-check: allow method-probe - platform object: matchMedia exists only in a browser
|
|
24
|
+
return typeof page?.matchMedia === "function" && page.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
25
|
+
}
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
function resolveSystemThemeName({ prefersDark = systemPrefersDark() } = {}) {
|
|
28
|
+
return prefersDark ? THEME_PREFERENCE_DARK : THEME_PREFERENCE_LIGHT;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
function resolveThemeNameForPreference(themePreference, options = {}) {
|
|
@@ -44,12 +46,8 @@ function resolveThemePreferenceStorage(options = {}) {
|
|
|
44
46
|
return customStorage;
|
|
45
47
|
}
|
|
46
48
|
|
|
47
|
-
if (typeof window !== "object" || !window) {
|
|
48
|
-
return null;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
49
|
try {
|
|
52
|
-
return
|
|
50
|
+
return pageWindow()?.localStorage || null;
|
|
53
51
|
} catch {
|
|
54
52
|
return null;
|
|
55
53
|
}
|
|
@@ -57,7 +55,7 @@ function resolveThemePreferenceStorage(options = {}) {
|
|
|
57
55
|
|
|
58
56
|
function readPersistedThemePreference(options = {}) {
|
|
59
57
|
const storage = resolveThemePreferenceStorage(options);
|
|
60
|
-
if (!storage
|
|
58
|
+
if (!storage) {
|
|
61
59
|
return THEME_PREFERENCE_SYSTEM;
|
|
62
60
|
}
|
|
63
61
|
|
|
@@ -70,7 +68,7 @@ function readPersistedThemePreference(options = {}) {
|
|
|
70
68
|
|
|
71
69
|
function persistThemePreference(themePreference, options = {}) {
|
|
72
70
|
const storage = resolveThemePreferenceStorage(options);
|
|
73
|
-
if (!storage
|
|
71
|
+
if (!storage) {
|
|
74
72
|
return false;
|
|
75
73
|
}
|
|
76
74
|
|
|
@@ -108,38 +106,14 @@ function persistBootstrapThemePreference(payload = {}, options = {}) {
|
|
|
108
106
|
}
|
|
109
107
|
|
|
110
108
|
function resolveVuetifyThemeController(vueApp) {
|
|
111
|
-
|
|
112
|
-
return null;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const provides = vueApp._context?.provides;
|
|
116
|
-
if (!provides || typeof provides !== "object") {
|
|
117
|
-
return null;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const themeController = provides[ThemeSymbol];
|
|
121
|
-
if (
|
|
122
|
-
!themeController ||
|
|
123
|
-
typeof themeController !== "object" ||
|
|
124
|
-
typeof themeController.change !== "function" ||
|
|
125
|
-
!themeController.name
|
|
126
|
-
) {
|
|
127
|
-
return null;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
return themeController;
|
|
109
|
+
return vueApp._context.provides[ThemeSymbol] || null;
|
|
131
110
|
}
|
|
132
111
|
|
|
112
|
+
/**
|
|
113
|
+
* @param {import("vuetify").ThemeInstance} themeController
|
|
114
|
+
* @param {string} themeName
|
|
115
|
+
*/
|
|
133
116
|
function setVuetifyThemeName(themeController, themeName) {
|
|
134
|
-
if (
|
|
135
|
-
!themeController ||
|
|
136
|
-
typeof themeController !== "object" ||
|
|
137
|
-
typeof themeController.change !== "function" ||
|
|
138
|
-
!themeController.name
|
|
139
|
-
) {
|
|
140
|
-
return false;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
117
|
const normalizedThemeName = themeName === THEME_PREFERENCE_DARK ? THEME_PREFERENCE_DARK : THEME_PREFERENCE_LIGHT;
|
|
144
118
|
if (themeController.name.value === normalizedThemeName) {
|
|
145
119
|
return false;
|
|
@@ -9,6 +9,12 @@ const UsersWebClientProvider = defineProvider({
|
|
|
9
9
|
components: "client.components",
|
|
10
10
|
shell: "client.shell"
|
|
11
11
|
},
|
|
12
|
+
/**
|
|
13
|
+
* @param {{
|
|
14
|
+
* components: import("@jskit-ai/kernel/client/types").ClientComponents,
|
|
15
|
+
* shell: import("../types.js").UsersShell
|
|
16
|
+
* }} capabilities
|
|
17
|
+
*/
|
|
12
18
|
setup({ components, shell }) {
|
|
13
19
|
components.register("users.web.home.tools.widget", UsersHomeToolsWidget);
|
|
14
20
|
components.register("users.web.profile.element", ProfileClientElement);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// The values users-web's client hands out and receives across files.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The part of shell-web's placement runtime the bootstrap handler writes the signed-in user into.
|
|
5
|
+
* @typedef {object} UsersPlacementRuntime
|
|
6
|
+
* @property {() => Record<string, any>} getContext
|
|
7
|
+
* @property {(patch: Record<string, any>, options?: { source?: string }) => void} setContext
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The part of shell-web's placement runtime the account settings sections read.
|
|
12
|
+
* @typedef {object} AccountSectionsPlacementRuntime
|
|
13
|
+
* @property {() => number} getRevision
|
|
14
|
+
* @property {(listener: (event?: { revision?: number }) => void) => () => void} subscribe
|
|
15
|
+
* @property {(query: { surface: string, target: string, context: unknown }) => readonly Record<string, any>[]} getPlacements
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* What shell-web's bootstrap hands a payload handler.
|
|
20
|
+
* @typedef {object} UsersBootstrapContext
|
|
21
|
+
* @property {Record<string, any>} [payload]
|
|
22
|
+
* @property {unknown} [error]
|
|
23
|
+
* @property {UsersPlacementRuntime} placementRuntime
|
|
24
|
+
* @property {string} [source]
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The part of the `client.shell` capability users-web uses.
|
|
29
|
+
* @typedef {object} UsersShell
|
|
30
|
+
* @property {{ register: (handler: ReturnType<typeof import("./bootstrap/user-bootstrap-handler.js").createUsersBootstrapUserHandler>) => void }} bootstrapHandlers
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One line of feedback about an account action.
|
|
35
|
+
* @typedef {object} AccountFeedback
|
|
36
|
+
* @property {string} message
|
|
37
|
+
* @property {string} [severity]
|
|
38
|
+
* @property {string} [channel]
|
|
39
|
+
* @property {string} [dedupeKey]
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* What `ProfileClientElement` shows and edits.
|
|
44
|
+
* @typedef {object} ProfileElementState
|
|
45
|
+
* @property {{ displayName: string, email: string }} profileForm
|
|
46
|
+
* @property {{ avatarSize: number }} preferencesForm
|
|
47
|
+
* @property {{ effectiveUrl: string, hasUploadedAvatar: boolean }} profileAvatar
|
|
48
|
+
* @property {string} profileInitials
|
|
49
|
+
* @property {string} selectedAvatarFileName
|
|
50
|
+
* @property {{ displayName?: string }} profileFieldErrors
|
|
51
|
+
* @property {{ isPending: import("vue").Ref<boolean> }} profileMutation
|
|
52
|
+
* @property {{ isPending: import("vue").Ref<boolean> }} avatarDeleteMutation
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* What `ProfileClientElement` calls.
|
|
57
|
+
* @typedef {object} ProfileElementActions
|
|
58
|
+
* @property {(payload: { displayName: string }) => unknown} submitProfile
|
|
59
|
+
* @property {() => unknown} openAvatarEditor
|
|
60
|
+
* @property {() => unknown} submitAvatarDelete
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
/** @typedef {ReturnType<typeof import("./composables/useAccountSettingsRuntime.js").useAccountSettingsRuntime>} AccountSettingsRuntime */
|
|
64
|
+
|
|
65
|
+
export {};
|
|
@@ -78,19 +78,26 @@ test("preference option labels follow the app's vue-i18n locale", () => {
|
|
|
78
78
|
test("useAccountLocale follows the bootstrap payload's saved locale", async () => {
|
|
79
79
|
const handler = createUsersBootstrapUserHandler();
|
|
80
80
|
const accountLocale = useAccountLocale();
|
|
81
|
+
const placementRuntime = {
|
|
82
|
+
getContext() {
|
|
83
|
+
return {};
|
|
84
|
+
},
|
|
85
|
+
setContext() {}
|
|
86
|
+
};
|
|
81
87
|
|
|
82
88
|
await handler.applyBootstrapPayload({
|
|
83
|
-
payload: { session: { authenticated: true, userId: "7" }, userSettings: { locale: "it-it" } }
|
|
89
|
+
payload: { session: { authenticated: true, userId: "7" }, userSettings: { locale: "it-it" } },
|
|
90
|
+
placementRuntime
|
|
84
91
|
});
|
|
85
92
|
assert.equal(accountLocale.value, "it-IT");
|
|
86
93
|
|
|
87
|
-
await handler.handleBootstrapError({ error: { statusCode: 500 } });
|
|
94
|
+
await handler.handleBootstrapError({ error: { statusCode: 500 }, placementRuntime });
|
|
88
95
|
assert.equal(accountLocale.value, "it-IT");
|
|
89
96
|
|
|
90
|
-
await handler.handleBootstrapError({ error: { statusCode: 401 } });
|
|
97
|
+
await handler.handleBootstrapError({ error: { statusCode: 401 }, placementRuntime });
|
|
91
98
|
assert.equal(accountLocale.value, "");
|
|
92
99
|
|
|
93
|
-
await handler.applyBootstrapPayload({ payload: { session: { authenticated: true }, userSettings: { locale: "en" } } });
|
|
94
|
-
await handler.applyBootstrapPayload({ payload: { session: { authenticated: false }, userSettings: null } });
|
|
100
|
+
await handler.applyBootstrapPayload({ payload: { session: { authenticated: true }, userSettings: { locale: "en" } }, placementRuntime });
|
|
101
|
+
await handler.applyBootstrapPayload({ payload: { session: { authenticated: false }, userSettings: null }, placementRuntime });
|
|
95
102
|
assert.equal(accountLocale.value, "");
|
|
96
103
|
});
|