@jskit-ai/users-web 0.2.20 → 0.2.22
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/CHANGELOG.md +5 -0
- package/fixtures/account-settings/main.js +50 -21
- package/fixtures/translated-app/main.js +1 -0
- package/package.json +10 -10
- package/patterns/account-settings/PATTERN.md +24 -3
- package/patterns/account-settings/example/src/components/account/settings/AccountSettingsNotificationsSection.vue +1 -1
- package/patterns/account-settings/example/src/components/account/settings/AccountSettingsPreferencesSection.vue +2 -1
- package/patterns/account-settings/example/src/components/account/settings/AccountSettingsProfileSection.vue +3 -2
- package/patterns/account-settings/example/src/pages/account/[section].vue +23 -0
- package/patterns/account-settings/example/src/pages/account.vue +4 -3
- package/src/client/components/AccountSettingsClientElement.vue +68 -61
- package/src/client/components/ProfileClientElement.vue +9 -3
- package/test/accountSettings.browser.test.js +114 -18
- package/test/accountSettingsPattern.test.js +24 -0
- package/test/settingsPlacementContract.test.js +3 -1
- package/test/translatedApp.browser.test.js +30 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
- Account settings sections are pages in Material 3's list-detail layout (shell-web's `ShellListDetail`): `/account` lists the sections and `/account/<section>` opens one, instead of `?section=` on one page. `AccountSettingsClientElement` reads the route's `section` param and links each section to its path, keeping `returnTo`; on compact and medium windows it shows the list or one section, whose Up returns to the list, and on expanded and large windows the list beside the open section (or the first section on the list's own page), where Done replaces Up as the way out. The `users/account-settings` pattern adds `src/pages/account/[section].vue`, with parent `"/"`. Upgrading: add that page beside `src/pages/account/index.vue`, and change links to `/account?section=<value>` into `/account/<value>`.
|
|
6
|
+
- Account settings have one way out: on a page with the shell's Up arrow (one that declares a parent) there is no Done action beside it, which read as a second back control. Done shows only where Up can't go back: a page without a parent, or a `returnTo` on another of the app's origins.
|
|
7
|
+
- Account settings use Material 3's label type and single-column form: the section list's labels are label-large (14px/500) instead of Vuetify's 13px nav title, and `ProfileClientElement`'s and the pattern's display name and email each take the form's full width, so a long email no longer truncates in a half-width column.
|
|
8
|
+
- The profile form's Save button, and the account-settings pattern's Save buttons, keep 16dp from the last field in every section; they touched it, as fields hide their empty details. The pattern's Avatar size select takes the same column width as the fields beside it.
|
|
9
|
+
- The account settings section list marks the selected section with Material 3's pill indicator in `secondary-container`, as the shell's navigation does, instead of a grey rectangle; its hover and pressed state layers are pills too.
|
|
5
10
|
- The account-settings pattern ships the account surface's layout, `src/pages/account.vue` (`ShellLayout` around `<RouterView />`, `meta.jskit.surface: "account"`), so the settings page's title, Up arrow and Done action show in the top app bar, with no navigation bar, rail or drawer on a surface that has no navigation. An app whose account page renders under no layout copies it; until then `jskit check` reports `nav/missing-shell-layout` on the page.
|
|
6
11
|
- The account settings page and the account-settings pattern's sections sit directly on the page, without bordered sheets, and the section list has no extra border or tint.
|
|
7
12
|
- The account settings page has no in-page title or Back button: it gives the shell's top app bar its title (`useShellPageTitle`) and a Done action (`ShellPageAction`) that returns to `returnTo`, another of the app's origins included, as Back did. Message keys: `jskit.users.accountSettings.back` is removed and `jskit.users.accountSettings.done` ("Done") is new. The pattern's section buttons no longer set a fixed height; they are Material 3's 40dp buttons inside material-web's 48dp touch target.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createApp, h, reactive, ref, shallowReactive } from "vue";
|
|
2
|
+
import { createPinia } from "pinia";
|
|
2
3
|
import { createRouter, createWebHistory, RouterView } from "vue-router";
|
|
3
4
|
import { QueryClient, VueQueryPlugin } from "@tanstack/vue-query";
|
|
4
5
|
import "vuetify/styles";
|
|
@@ -7,8 +8,15 @@ import { createMaterialVuetifyOptions } from "@jskit-ai/material-web/client";
|
|
|
7
8
|
import { createVuetify } from "vuetify";
|
|
8
9
|
import * as components from "vuetify/components";
|
|
9
10
|
import * as directives from "vuetify/directives";
|
|
11
|
+
import { silentLogger } from "@jskit-ai/kernel/shared/support/silentLogger";
|
|
12
|
+
import ShellLayout from "@jskit-ai/shell-web/client/components/ShellLayout";
|
|
10
13
|
import { SHELL_WEB_ERROR_RUNTIME_KEY } from "@jskit-ai/shell-web/client/error";
|
|
11
14
|
import { SHELL_WEB_PLACEMENT_RUNTIME_KEY } from "@jskit-ai/shell-web/client/placement";
|
|
15
|
+
import { createWebPlacementRuntime } from "../../../shell-web/src/client/placement/runtime.js";
|
|
16
|
+
import {
|
|
17
|
+
SHELL_NAVIGATION_RUNTIME_KEY,
|
|
18
|
+
createShellNavigationRuntime
|
|
19
|
+
} from "../../../shell-web/src/client/navigation/shellNavigationRuntime.js";
|
|
12
20
|
import AccountSettingsClientElement from "../../src/client/components/AccountSettingsClientElement.vue";
|
|
13
21
|
import ProfileClientElement from "../../src/client/components/ProfileClientElement.vue";
|
|
14
22
|
import { useAccountLocale } from "../../src/client/lib/accountLocale.js";
|
|
@@ -17,12 +25,27 @@ import PreferencesSection from "../../patterns/account-settings/example/src/comp
|
|
|
17
25
|
import ProfileSection from "../../patterns/account-settings/example/src/components/account/settings/AccountSettingsProfileSection.vue";
|
|
18
26
|
import SignInMethodsSection from "../../patterns/account-settings/example/src/components/account/settings/AccountSettingsSignInMethodsSection.vue";
|
|
19
27
|
|
|
28
|
+
// The real shell (its layout, placements and navigation) around the real account settings code; the error runtime is
|
|
29
|
+
// a double that records reports. The account surface is laid out as the account-settings pattern lays it out: /account
|
|
30
|
+
// lists the sections and /account/<section> opens one, whose parent is the list.
|
|
31
|
+
const sectionComponents = new Map();
|
|
20
32
|
function section(value, title, component, order) {
|
|
21
|
-
|
|
33
|
+
sectionComponents.set(`fixture.section.${value}`, component);
|
|
34
|
+
return {
|
|
35
|
+
id: `fixture.section.${value}`,
|
|
36
|
+
// The section outlet itself, as the app's topology resolves users-web's sections to it.
|
|
37
|
+
target: "account-settings:sections",
|
|
38
|
+
internal: true,
|
|
39
|
+
kind: "component",
|
|
40
|
+
surfaces: ["account"],
|
|
41
|
+
order,
|
|
42
|
+
componentToken: `fixture.section.${value}`,
|
|
43
|
+
props: { value, title, usesSharedRuntime: true }
|
|
44
|
+
};
|
|
22
45
|
}
|
|
23
46
|
|
|
24
|
-
|
|
25
|
-
|
|
47
|
+
const placement = createWebPlacementRuntime({ components: sectionComponents, logger: silentLogger });
|
|
48
|
+
placement.setContext({
|
|
26
49
|
surfaceConfig: {
|
|
27
50
|
tenancyMode: "none",
|
|
28
51
|
defaultSurfaceId: "home",
|
|
@@ -32,9 +55,7 @@ const context = {
|
|
|
32
55
|
account: { id: "account", enabled: true, pagesRoot: "account", routeBase: "/account", requiresWorkspace: false }
|
|
33
56
|
}
|
|
34
57
|
}
|
|
35
|
-
};
|
|
36
|
-
const listeners = new Set();
|
|
37
|
-
let revision = 1;
|
|
58
|
+
});
|
|
38
59
|
let placements = new URLSearchParams(window.location.search).has("empty")
|
|
39
60
|
? []
|
|
40
61
|
: [
|
|
@@ -43,16 +64,7 @@ let placements = new URLSearchParams(window.location.search).has("empty")
|
|
|
43
64
|
section("notifications", "Notifications", NotificationsSection, 300),
|
|
44
65
|
section("sign-in-methods", "Sign-in methods", SignInMethodsSection, 400)
|
|
45
66
|
];
|
|
46
|
-
|
|
47
|
-
getContext: () => context,
|
|
48
|
-
setContext: () => context,
|
|
49
|
-
getPlacements: () => placements,
|
|
50
|
-
getRevision: () => revision,
|
|
51
|
-
subscribe(listener) {
|
|
52
|
-
listeners.add(listener);
|
|
53
|
-
return () => listeners.delete(listener);
|
|
54
|
-
}
|
|
55
|
-
};
|
|
67
|
+
placement.replacePlacements(placements);
|
|
56
68
|
const errorRuntime = {
|
|
57
69
|
report(event) {
|
|
58
70
|
window.accountFixture.reports.push(event);
|
|
@@ -93,8 +105,7 @@ window.accountFixture = {
|
|
|
93
105
|
profileState,
|
|
94
106
|
placeSection() {
|
|
95
107
|
placements = [...placements, section("extra", "Extra", { render: () => h("p", "Extra section") }, 150)];
|
|
96
|
-
|
|
97
|
-
listeners.forEach((listener) => listener({ revision }));
|
|
108
|
+
placement.replacePlacements(placements);
|
|
98
109
|
}
|
|
99
110
|
};
|
|
100
111
|
|
|
@@ -126,14 +137,32 @@ const ProfileElementPage = {
|
|
|
126
137
|
const router = createRouter({
|
|
127
138
|
history: createWebHistory(),
|
|
128
139
|
routes: [
|
|
129
|
-
{ path: "/home", component: { render: () => h("p", { "data-testid": "home" }, "Home") } },
|
|
130
|
-
{
|
|
140
|
+
{ path: "/home", component: { render: () => h("p", { "data-testid": "home" }, "Home") }, meta: { jskit: { surface: "home" } } },
|
|
141
|
+
{
|
|
142
|
+
path: "/account",
|
|
143
|
+
component: RouterView,
|
|
144
|
+
meta: { jskit: { surface: "account" } },
|
|
145
|
+
children: [
|
|
146
|
+
// src/pages/account/index.vue: the section list, an inner page of home.
|
|
147
|
+
{ path: "", component: AccountPage, meta: { jskit: { parent: { surface: "home", path: "/" } } } },
|
|
148
|
+
// src/pages/account/[section].vue: one section, whose parent is the list.
|
|
149
|
+
{ path: ":section", component: AccountPage, meta: { jskit: { parent: "/" } } }
|
|
150
|
+
]
|
|
151
|
+
},
|
|
131
152
|
{ path: "/profile-element", component: ProfileElementPage }
|
|
132
153
|
]
|
|
133
154
|
});
|
|
134
|
-
const
|
|
155
|
+
const navigation = createShellNavigationRuntime({ router });
|
|
156
|
+
navigation.install();
|
|
157
|
+
const app = createApp({
|
|
158
|
+
render: () => h(components.VApp, null, {
|
|
159
|
+
default: () => h(ShellLayout, { surfaceLabel: "Fixture" }, { default: () => h(RouterView) })
|
|
160
|
+
})
|
|
161
|
+
});
|
|
135
162
|
app.provide(SHELL_WEB_PLACEMENT_RUNTIME_KEY, placement);
|
|
163
|
+
app.provide(SHELL_NAVIGATION_RUNTIME_KEY, navigation);
|
|
136
164
|
app.provide(SHELL_WEB_ERROR_RUNTIME_KEY, errorRuntime);
|
|
165
|
+
app.use(createPinia());
|
|
137
166
|
app.use(router);
|
|
138
167
|
app.use(VueQueryPlugin, { queryClient: new QueryClient({ defaultOptions: { queries: { retry: false } } }) });
|
|
139
168
|
app.use(createVuetify({ components, directives, ...createMaterialVuetifyOptions() }));
|
|
@@ -143,6 +143,7 @@ const router = createRouter({
|
|
|
143
143
|
{ path: "/home", component: HomePage },
|
|
144
144
|
{ path: "/errors", component: ErrorsPage },
|
|
145
145
|
{ path: "/account", component: AccountSettingsClientElement },
|
|
146
|
+
{ path: "/account/:section", component: AccountSettingsClientElement },
|
|
146
147
|
{ path: "/auth/login", component: DefaultLoginView },
|
|
147
148
|
{ path: "/auth/reset-password", component: DefaultResetPasswordView },
|
|
148
149
|
{ path: "/auth/signout", component: DefaultSignOutView }
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/users-web",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.22",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
|
-
"test": "
|
|
6
|
+
"test": "jskit test"
|
|
7
7
|
},
|
|
8
8
|
"exports": {
|
|
9
9
|
"./client": "./src/client/index.js",
|
|
@@ -13,15 +13,15 @@
|
|
|
13
13
|
"./locales/en.json": "./locales/en.json"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@jskit-ai/uploads-image-web": "0.2.
|
|
17
|
-
"@jskit-ai/users-core": "0.2.
|
|
16
|
+
"@jskit-ai/uploads-image-web": "0.2.12",
|
|
17
|
+
"@jskit-ai/users-core": "0.2.14"
|
|
18
18
|
},
|
|
19
19
|
"peerDependencies": {
|
|
20
|
-
"@jskit-ai/auth-web": "0.2.
|
|
21
|
-
"@jskit-ai/http-runtime": "0.2.
|
|
22
|
-
"@jskit-ai/http-web": "0.2.
|
|
23
|
-
"@jskit-ai/kernel": "0.2.
|
|
24
|
-
"@jskit-ai/shell-web": "0.2.
|
|
20
|
+
"@jskit-ai/auth-web": "0.2.17",
|
|
21
|
+
"@jskit-ai/http-runtime": "0.2.12",
|
|
22
|
+
"@jskit-ai/http-web": "0.2.20",
|
|
23
|
+
"@jskit-ai/kernel": "0.2.12",
|
|
24
|
+
"@jskit-ai/shell-web": "0.2.15",
|
|
25
25
|
"@tanstack/vue-query": "^5.90.5",
|
|
26
26
|
"vue": "^3.5.13",
|
|
27
27
|
"vue-router": "^5.0.4",
|
|
@@ -230,6 +230,6 @@
|
|
|
230
230
|
}
|
|
231
231
|
},
|
|
232
232
|
"devDependencies": {
|
|
233
|
-
"@jskit-ai/material-web": "0.1.
|
|
233
|
+
"@jskit-ai/material-web": "0.1.3"
|
|
234
234
|
}
|
|
235
235
|
}
|
|
@@ -38,6 +38,18 @@ app's language; the app does, for example
|
|
|
38
38
|
`watch(useAccountLocale(), (code) => { if (code) i18n.global.locale.value = code; }, { immediate: true })`.
|
|
39
39
|
Section titles may be plain text, a message key or `{ key, fallback }`.
|
|
40
40
|
|
|
41
|
+
Sections are pages of the account surface, in Material 3's list-detail layout
|
|
42
|
+
(shell-web's `ShellListDetail`): `/account` is the section list
|
|
43
|
+
(`src/pages/account/index.vue`) and `/account/<section>` one section
|
|
44
|
+
(`src/pages/account/[section].vue`, its `value`, such as
|
|
45
|
+
`/account/preferences`), both rendering `AccountSettingsClientElement`, which
|
|
46
|
+
reads the section from the route's `section` param. The section page's parent
|
|
47
|
+
is the list, the surface's `"/"`. A phone or medium window shows the list or one
|
|
48
|
+
section, and Up returns from the section to the list; an expanded or large
|
|
49
|
+
window shows the list beside the open section, or beside the first section on
|
|
50
|
+
the list's own page. A link to a section is its path, `/account/invites`, with
|
|
51
|
+
`returnTo` in the query for Done.
|
|
52
|
+
|
|
41
53
|
`runtime.signInMethods.methods` lists the auth provider's OAuth sign-in
|
|
42
54
|
methods as `{ provider, label, linked, canLink, canUnlink }`, read from the
|
|
43
55
|
settings security status. `link(provider)` leaves for the provider through
|
|
@@ -59,12 +71,19 @@ provider offers provider linking (`providerLinking` in its capabilities).
|
|
|
59
71
|
- The route and placement owner agree.
|
|
60
72
|
- The account surface renders in its own layout, `src/pages/account.vue`
|
|
61
73
|
(`ShellLayout` around `<RouterView />`, `meta.jskit.surface: "account"`), so
|
|
62
|
-
the settings page's title
|
|
74
|
+
the settings page's title and Up arrow are in the top app bar. Up is the
|
|
75
|
+
page's only way out; `AccountSettingsClientElement` adds a Done action only
|
|
76
|
+
where Up can't go back (no parent, a section beside the list on a wide
|
|
77
|
+
window, or a returnTo on another origin).
|
|
63
78
|
With no navigation items, the surface shows no navigation bar, rail or drawer.
|
|
79
|
+
- Each section has its own address, `/account/<section>`, served by
|
|
80
|
+
`src/pages/account/[section].vue` with parent `"/"`; nothing selects a
|
|
81
|
+
section through the query.
|
|
64
82
|
|
|
65
83
|
## Example files
|
|
66
84
|
|
|
67
|
-
`example/` contains the account surface's layout,
|
|
85
|
+
`example/` contains the account surface's layout, the section list's route
|
|
86
|
+
(`account/index.vue`), a section's route (`account/[section].vue`) and four
|
|
68
87
|
concrete settings sections.
|
|
69
88
|
Adapt their fields and copy while retaining the shared account runtime.
|
|
70
89
|
|
|
@@ -77,7 +96,9 @@ notification vocabulary while keeping server-owned validation authoritative.
|
|
|
77
96
|
|
|
78
97
|
Navigate away and back with warm cache, exercise browser back/forward, save and
|
|
79
98
|
clear fields, verify toast errors, inspect skeleton geometry, and test keyboard
|
|
80
|
-
operation at compact and expanded widths.
|
|
99
|
+
operation at compact and expanded widths. At 390 px check that the list and a
|
|
100
|
+
section show one at a time and Up returns from the section to the list; at
|
|
101
|
+
1280 px that the list shows beside the open section, with no Up.
|
|
81
102
|
|
|
82
103
|
## Avoid
|
|
83
104
|
|
|
@@ -35,11 +35,11 @@ const notifications = props.runtime.notifications;
|
|
|
35
35
|
color="primary"
|
|
36
36
|
hide-details
|
|
37
37
|
disabled
|
|
38
|
-
class="mb-4"
|
|
39
38
|
/>
|
|
40
39
|
<v-btn
|
|
41
40
|
type="submit"
|
|
42
41
|
color="primary"
|
|
42
|
+
class="mt-4"
|
|
43
43
|
:loading="notifications.isSaving.value"
|
|
44
44
|
:disabled="notifications.isRefreshing.value"
|
|
45
45
|
>
|
|
@@ -94,7 +94,7 @@ const preferences = props.runtime.preferences;
|
|
|
94
94
|
/>
|
|
95
95
|
</v-col>
|
|
96
96
|
|
|
97
|
-
<v-col cols="12" md="
|
|
97
|
+
<v-col cols="12" md="4">
|
|
98
98
|
<v-select
|
|
99
99
|
v-model.number="preferences.form.avatarSize"
|
|
100
100
|
label="Avatar size"
|
|
@@ -110,6 +110,7 @@ const preferences = props.runtime.preferences;
|
|
|
110
110
|
<v-btn
|
|
111
111
|
type="submit"
|
|
112
112
|
color="primary"
|
|
113
|
+
class="mt-4"
|
|
113
114
|
:loading="preferences.isSaving.value"
|
|
114
115
|
:disabled="preferences.isRefreshing.value"
|
|
115
116
|
>
|
|
@@ -51,7 +51,7 @@ const profile = props.runtime.profile;
|
|
|
51
51
|
</v-row>
|
|
52
52
|
|
|
53
53
|
<v-row>
|
|
54
|
-
<v-col cols="12"
|
|
54
|
+
<v-col cols="12">
|
|
55
55
|
<v-text-field
|
|
56
56
|
v-model="profile.form.displayName"
|
|
57
57
|
label="Display name"
|
|
@@ -63,7 +63,7 @@ const profile = props.runtime.profile;
|
|
|
63
63
|
/>
|
|
64
64
|
</v-col>
|
|
65
65
|
|
|
66
|
-
<v-col cols="12"
|
|
66
|
+
<v-col cols="12">
|
|
67
67
|
<v-text-field
|
|
68
68
|
v-model="profile.form.email"
|
|
69
69
|
label="Email"
|
|
@@ -79,6 +79,7 @@ const profile = props.runtime.profile;
|
|
|
79
79
|
<v-btn
|
|
80
80
|
type="submit"
|
|
81
81
|
color="primary"
|
|
82
|
+
class="mt-4"
|
|
82
83
|
:loading="profile.isSaving.value"
|
|
83
84
|
:disabled="profile.isDeletingAvatar.value || profile.isRefreshing.value"
|
|
84
85
|
>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<route lang="json">
|
|
2
|
+
{
|
|
3
|
+
"meta": {
|
|
4
|
+
"guard": {
|
|
5
|
+
"policy": "authenticated"
|
|
6
|
+
},
|
|
7
|
+
"jskit": {
|
|
8
|
+
"title": "Account settings",
|
|
9
|
+
"parent": "/"
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
</route>
|
|
14
|
+
|
|
15
|
+
<template>
|
|
16
|
+
<AccountSettingsClientElement />
|
|
17
|
+
</template>
|
|
18
|
+
|
|
19
|
+
<script setup>
|
|
20
|
+
// One section of account settings (/account/preferences). Its parent is the section list, the surface's "/", so on a
|
|
21
|
+
// phone Up returns to the list; a wide window shows the list beside the section.
|
|
22
|
+
import AccountSettingsClientElement from "@jskit-ai/users-web/client/components/AccountSettingsClientElement";
|
|
23
|
+
</script>
|
|
@@ -9,14 +9,15 @@
|
|
|
9
9
|
</route>
|
|
10
10
|
|
|
11
11
|
<script setup>
|
|
12
|
-
// The account surface's layout: the shell's top app bar carries the settings page's title
|
|
13
|
-
//
|
|
12
|
+
// The account surface's layout: the shell's top app bar carries the settings page's title and its Up arrow back to
|
|
13
|
+
// home, the page's only way out (a Done action shows only where Up can't go back, such as a returnTo on another of
|
|
14
|
+
// the app's origins). The surface has no navigation items, so the shell draws no navigation bar, rail or drawer.
|
|
14
15
|
import ShellLayout from "@/components/ShellLayout.vue";
|
|
15
16
|
import { RouterView } from "vue-router";
|
|
16
17
|
</script>
|
|
17
18
|
|
|
18
19
|
<template>
|
|
19
|
-
<ShellLayout
|
|
20
|
+
<ShellLayout>
|
|
20
21
|
<RouterView />
|
|
21
22
|
</ShellLayout>
|
|
22
23
|
</template>
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import { computed } from "vue";
|
|
3
|
-
import { useRoute
|
|
4
|
-
import
|
|
3
|
+
import { useRoute } from "vue-router";
|
|
4
|
+
import ShellListDetail from "@jskit-ai/shell-web/client/components/ShellListDetail";
|
|
5
5
|
import ShellPageAction from "@jskit-ai/shell-web/client/components/ShellPageAction";
|
|
6
6
|
import ShellPageActions from "@jskit-ai/shell-web/client/components/ShellPageActions";
|
|
7
7
|
import { useShellPageTitle } from "@jskit-ai/shell-web/client/composables/useShellPageTitle";
|
|
8
|
+
import { useShellWindowSizeClass } from "@jskit-ai/shell-web/client/composables/useShellWindowSizeClass";
|
|
9
|
+
import { usePaths } from "@jskit-ai/shell-web/client/navigation/usePaths";
|
|
10
|
+
import { useShellNavigation } from "@jskit-ai/shell-web/client/navigation/useShellNavigation";
|
|
8
11
|
import { useAccountSettingsRuntime } from "../composables/useAccountSettingsRuntime.js";
|
|
9
12
|
import { useAccountSettingsSections } from "../account-settings/sections.js";
|
|
10
13
|
import { useJskitText } from "../i18n.js";
|
|
@@ -12,12 +15,16 @@ import { useJskitText } from "../i18n.js";
|
|
|
12
15
|
const { t, text } = useJskitText();
|
|
13
16
|
const runtime = useAccountSettingsRuntime();
|
|
14
17
|
const route = useRoute();
|
|
15
|
-
const
|
|
18
|
+
const paths = usePaths();
|
|
16
19
|
const sections = useAccountSettingsSections();
|
|
17
|
-
// The app bar carries the page's title
|
|
18
|
-
//
|
|
20
|
+
// The app bar carries the page's title. Its Up arrow, on a page that declares a parent, is the way out; a Done action
|
|
21
|
+
// beside it would be a second back control. Done shows only where Up can't take the person back: a page without a
|
|
22
|
+
// parent, an open section beside the list on a wide window (the list is the parent, already in view), or a returnTo
|
|
23
|
+
// on another of the app's origins.
|
|
19
24
|
useShellPageTitle(() => t("jskit.users.accountSettings.title"));
|
|
25
|
+
const { canGoUp } = useShellNavigation();
|
|
20
26
|
const doneTarget = computed(() => runtime.backNavigationTarget.value);
|
|
27
|
+
const showsDone = computed(() => !canGoUp.value || !doneTarget.value.sameOrigin);
|
|
21
28
|
const sectionValues = computed(() => Object.freeze(sections.value.map((section) => section.value)));
|
|
22
29
|
const defaultSection = computed(() => {
|
|
23
30
|
if (sectionValues.value.includes("profile")) {
|
|
@@ -27,45 +34,31 @@ const defaultSection = computed(() => {
|
|
|
27
34
|
return sectionValues.value[0] || "";
|
|
28
35
|
});
|
|
29
36
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
37
|
+
// Sections are pages: the surface's "/" is the list (/account) and "/<section>" one section (/account/preferences),
|
|
38
|
+
// the app's src/pages/account/[section].vue, whose parent is the list. Compact and medium windows show one pane at a
|
|
39
|
+
// time, so the list leads to a section and Up returns to it; wide windows show the list beside the open section, or
|
|
40
|
+
// beside the first section on the list's own page.
|
|
41
|
+
const openSection = computed(() => {
|
|
42
|
+
const value = route.params?.section;
|
|
43
|
+
const section = String(Array.isArray(value) ? value[0] : value || "").trim().toLowerCase();
|
|
44
|
+
return sectionValues.value.includes(section) ? section : "";
|
|
45
|
+
});
|
|
46
|
+
const { isExpanded, isLarge } = useShellWindowSizeClass();
|
|
47
|
+
const shownSection = computed(() => {
|
|
48
|
+
const value = openSection.value || (isExpanded.value || isLarge.value ? defaultSection.value : "");
|
|
49
|
+
return sections.value.find((section) => section.value === value) || null;
|
|
50
|
+
});
|
|
34
51
|
|
|
35
|
-
|
|
36
|
-
|
|
52
|
+
// A section's page keeps the address's returnTo, where Done leads.
|
|
53
|
+
function sectionTarget(value) {
|
|
54
|
+
const returnTo = route.query?.returnTo;
|
|
55
|
+
return { path: paths.page(`/${value}`), query: returnTo ? { returnTo } : {} };
|
|
37
56
|
}
|
|
38
|
-
|
|
39
|
-
const activeTab = computed({
|
|
40
|
-
get() {
|
|
41
|
-
return readRouteSection();
|
|
42
|
-
},
|
|
43
|
-
set(nextValue) {
|
|
44
|
-
const normalizedSection = normalizeSection(nextValue);
|
|
45
|
-
const currentSection = readRouteSection();
|
|
46
|
-
if (!normalizedSection || normalizedSection === currentSection) {
|
|
47
|
-
return;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const nextQuery = {
|
|
51
|
-
...route.query
|
|
52
|
-
};
|
|
53
|
-
if (normalizedSection === defaultSection.value) {
|
|
54
|
-
delete nextQuery.section;
|
|
55
|
-
} else {
|
|
56
|
-
nextQuery.section = normalizedSection;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
void router.replace({
|
|
60
|
-
query: nextQuery
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
});
|
|
64
57
|
</script>
|
|
65
58
|
|
|
66
59
|
<template>
|
|
67
60
|
<section class="settings-view py-2 py-md-4">
|
|
68
|
-
<ShellPageActions>
|
|
61
|
+
<ShellPageActions v-if="showsDone">
|
|
69
62
|
<ShellPageAction
|
|
70
63
|
:label="t('jskit.users.accountSettings.done')"
|
|
71
64
|
:to="doneTarget.sameOrigin ? doneTarget.href : undefined"
|
|
@@ -99,36 +92,28 @@ const activeTab = computed({
|
|
|
99
92
|
</template>
|
|
100
93
|
<template v-else>
|
|
101
94
|
<v-progress-linear v-if="runtime.refreshingSettings.value" indeterminate class="mb-4" />
|
|
102
|
-
<
|
|
103
|
-
<
|
|
95
|
+
<ShellListDetail :detail-open="Boolean(openSection)">
|
|
96
|
+
<template #list>
|
|
104
97
|
<v-list nav class="settings-section-list">
|
|
105
98
|
<v-list-item
|
|
106
99
|
v-for="section in sections"
|
|
107
100
|
:key="section.value"
|
|
101
|
+
class="settings-section-list__item"
|
|
108
102
|
:title="text(section.title)"
|
|
109
|
-
:
|
|
110
|
-
|
|
103
|
+
:to="sectionTarget(section.value)"
|
|
104
|
+
:active="shownSection?.value === section.value"
|
|
111
105
|
/>
|
|
112
106
|
</v-list>
|
|
113
|
-
</
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
v-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
>
|
|
124
|
-
<component
|
|
125
|
-
:is="section.component"
|
|
126
|
-
v-bind="section.usesSharedRuntime ? { runtime } : undefined"
|
|
127
|
-
/>
|
|
128
|
-
</v-window-item>
|
|
129
|
-
</v-window>
|
|
130
|
-
</v-col>
|
|
131
|
-
</v-row>
|
|
107
|
+
</template>
|
|
108
|
+
<template #detail>
|
|
109
|
+
<div v-if="shownSection" :key="shownSection.value" class="settings-section-detail">
|
|
110
|
+
<component
|
|
111
|
+
:is="shownSection.component"
|
|
112
|
+
v-bind="shownSection.usesSharedRuntime ? { runtime } : undefined"
|
|
113
|
+
/>
|
|
114
|
+
</div>
|
|
115
|
+
</template>
|
|
116
|
+
</ShellListDetail>
|
|
132
117
|
</template>
|
|
133
118
|
</div>
|
|
134
119
|
</div>
|
|
@@ -149,6 +134,28 @@ const activeTab = computed({
|
|
|
149
134
|
padding: 1rem;
|
|
150
135
|
}
|
|
151
136
|
|
|
137
|
+
/* The selected section is Material 3's pill indicator in secondary-container, as the shell's navigation draws it, and
|
|
138
|
+
its hover and pressed state layers take the same pill. */
|
|
139
|
+
.settings-section-list__item {
|
|
140
|
+
--v-activated-opacity: 0;
|
|
141
|
+
border-radius: 9999px;
|
|
142
|
+
min-height: 56px;
|
|
143
|
+
color: rgb(var(--v-theme-on-surface-variant));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.settings-section-list__item.v-list-item--active {
|
|
147
|
+
background: rgb(var(--v-theme-secondary-container));
|
|
148
|
+
color: rgb(var(--v-theme-on-secondary-container));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/* Material 3's navigation item label (label-large), not Vuetify's 13px nav list title. */
|
|
152
|
+
.settings-section-list__item :deep(.v-list-item-title) {
|
|
153
|
+
font-size: 0.875rem;
|
|
154
|
+
font-weight: 500;
|
|
155
|
+
letter-spacing: 0.00625rem;
|
|
156
|
+
line-height: 1.25rem;
|
|
157
|
+
}
|
|
158
|
+
|
|
152
159
|
.settings-panel__state {
|
|
153
160
|
margin-inline: auto;
|
|
154
161
|
max-width: 30rem;
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
</v-row>
|
|
51
51
|
|
|
52
52
|
<v-row>
|
|
53
|
-
<v-col cols="12"
|
|
53
|
+
<v-col cols="12">
|
|
54
54
|
<v-text-field
|
|
55
55
|
v-model="state.profileForm.displayName"
|
|
56
56
|
:label="copyText.displayName"
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
:error-messages="state.profileFieldErrors.displayName"
|
|
61
61
|
/>
|
|
62
62
|
</v-col>
|
|
63
|
-
<v-col cols="12"
|
|
63
|
+
<v-col cols="12">
|
|
64
64
|
<v-text-field
|
|
65
65
|
v-model="state.profileForm.email"
|
|
66
66
|
:label="copyText.email"
|
|
@@ -72,7 +72,13 @@
|
|
|
72
72
|
/>
|
|
73
73
|
</v-col>
|
|
74
74
|
</v-row>
|
|
75
|
-
<v-btn
|
|
75
|
+
<v-btn
|
|
76
|
+
type="submit"
|
|
77
|
+
color="primary"
|
|
78
|
+
class="mt-4"
|
|
79
|
+
:loading="state.profileMutation.isPending.value"
|
|
80
|
+
:data-testid="uiTestIds.submitButton"
|
|
81
|
+
>
|
|
76
82
|
{{ copyText.saveProfile }}
|
|
77
83
|
</v-btn>
|
|
78
84
|
|
|
@@ -55,8 +55,8 @@ function document(attributes, type = "user-settings") {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/** One page with the settings API behind it; `routes` answers "METHOD /path", anything else is a test failure. */
|
|
58
|
-
async function openApp({ routes = {} } = {}) {
|
|
59
|
-
const page = await browser.newPage({ baseURL: runtime.baseURL, viewport
|
|
58
|
+
async function openApp({ routes = {}, viewport = { width: 1280, height: 900 } } = {}) {
|
|
59
|
+
const page = await browser.newPage({ baseURL: runtime.baseURL, viewport, reducedMotion: "reduce" });
|
|
60
60
|
page.setDefaultTimeout(15_000);
|
|
61
61
|
page.setDefaultNavigationTimeout(60_000);
|
|
62
62
|
const api = { requests: [], unexpected: [], errors: [], page };
|
|
@@ -123,13 +123,15 @@ test("the profile section loads, saves, shows a refused field and removes the av
|
|
|
123
123
|
}
|
|
124
124
|
});
|
|
125
125
|
const { page } = api;
|
|
126
|
-
|
|
126
|
+
// A section open beside the list: the list, its parent, is in view, so there is no Up, and Done returns to returnTo.
|
|
127
|
+
await page.goto("/account/profile?returnTo=/home");
|
|
127
128
|
const name = page.getByLabel("Display name");
|
|
128
129
|
await expect(name).toHaveValue("Ada");
|
|
129
130
|
await expect(page.getByLabel("Email", { exact: true })).toHaveValue("ada@example.com");
|
|
130
|
-
// The page's Done action (in the shell's app bar, or in place without one) returns to returnTo; there is no in-page h1.
|
|
131
131
|
await expect(page.getByRole("link", { name: "Done" })).toHaveAttribute("href", "/home");
|
|
132
|
-
await expect(page.
|
|
132
|
+
await expect(page.getByTestId("jskit-shell-back")).toHaveCount(0);
|
|
133
|
+
// The app bar's title is the page's one h1.
|
|
134
|
+
await expect(page.getByRole("heading", { level: 1 })).toHaveText("Account settings");
|
|
133
135
|
|
|
134
136
|
await name.fill(" Ada Lovelace ");
|
|
135
137
|
await page.getByRole("button", { name: "Save profile" }).click();
|
|
@@ -151,7 +153,58 @@ test("the profile section loads, saves, shows a refused field and removes the av
|
|
|
151
153
|
|
|
152
154
|
await page.getByRole("link", { name: "Done" }).click();
|
|
153
155
|
await expect(page.getByTestId("home")).toBeVisible();
|
|
156
|
+
|
|
157
|
+
// The list's own page is an inner page of home: Up is its only way out, with no Done beside it.
|
|
158
|
+
await page.goto("/account?returnTo=/home");
|
|
159
|
+
await expect(page.getByLabel("Display name")).toHaveValue("Ada");
|
|
160
|
+
await expect(page.getByTestId("jskit-shell-back")).toBeVisible();
|
|
161
|
+
await expect(page.getByRole("link", { name: "Done" })).toHaveCount(0);
|
|
162
|
+
await finish(api);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Material 3's list-detail layout: a phone shows the section list or one section, and Up returns from the section to
|
|
166
|
+
// the list and from the list to home; a wide window shows the list beside the open section, with no Up to the list.
|
|
167
|
+
test("a phone shows the section list and one section at a time, and Up returns to the list", options, async () => {
|
|
168
|
+
const api = await openApp({ viewport: { width: 390, height: 844 } });
|
|
169
|
+
const { page } = api;
|
|
170
|
+
const up = page.getByTestId("jskit-shell-back");
|
|
171
|
+
const sectionLinks = page.getByRole("link", { name: /^(Profile|Preferences|Notifications|Sign-in methods)$/u });
|
|
172
|
+
await page.goto("/account");
|
|
173
|
+
await expect(sectionLinks).toHaveCount(4);
|
|
174
|
+
await expect(page.getByLabel("Display name")).toHaveCount(0);
|
|
175
|
+
await expect(page.locator(".settings-section-list__item.v-list-item--active")).toHaveCount(0);
|
|
176
|
+
|
|
177
|
+
await page.getByRole("link", { name: "Preferences" }).click();
|
|
178
|
+
await page.waitForURL("**/account/preferences");
|
|
179
|
+
await expect(page.getByRole("button", { name: /Save preferences/u })).toBeVisible();
|
|
180
|
+
await expect(sectionLinks.first()).toBeHidden();
|
|
181
|
+
await expect(page.getByRole("link", { name: "Done" })).toHaveCount(0);
|
|
182
|
+
|
|
183
|
+
await up.click();
|
|
184
|
+
await page.waitForURL((url) => url.pathname === "/account");
|
|
185
|
+
await expect(sectionLinks.first()).toBeVisible();
|
|
186
|
+
await expect(page.getByRole("button", { name: /Save preferences/u })).toHaveCount(0);
|
|
187
|
+
|
|
188
|
+
// A section opened from a pasted link has the list as its parent too.
|
|
189
|
+
await page.goto("/account/notifications");
|
|
190
|
+
await expect(page.getByRole("button", { name: /Save notification settings/u })).toBeVisible();
|
|
191
|
+
await up.click();
|
|
192
|
+
await page.waitForURL((url) => url.pathname === "/account");
|
|
193
|
+
await up.click();
|
|
194
|
+
await expect(page.getByTestId("home")).toBeVisible();
|
|
154
195
|
await finish(api);
|
|
196
|
+
|
|
197
|
+
const wide = await openApp();
|
|
198
|
+
await wide.page.goto("/account");
|
|
199
|
+
await expect(wide.page.getByRole("link", { name: "Preferences" })).toBeVisible();
|
|
200
|
+
await expect(wide.page.getByLabel("Display name")).toBeVisible();
|
|
201
|
+
await expect(wide.page.locator(".settings-section-list__item.v-list-item--active")).toHaveText("Profile");
|
|
202
|
+
await wide.page.getByRole("link", { name: "Preferences" }).click();
|
|
203
|
+
await wide.page.waitForURL("**/account/preferences");
|
|
204
|
+
await expect(wide.page.getByRole("link", { name: "Profile" })).toBeVisible();
|
|
205
|
+
await expect(wide.page.getByRole("button", { name: /Save preferences/u })).toBeVisible();
|
|
206
|
+
await expect(wide.page.getByTestId("jskit-shell-back")).toHaveCount(0);
|
|
207
|
+
await finish(wide);
|
|
155
208
|
});
|
|
156
209
|
|
|
157
210
|
test("saved preferences follow into the theme, the stored preference and the account locale", options, async () => {
|
|
@@ -168,8 +221,8 @@ test("saved preferences follow into the theme, the stored preference and the acc
|
|
|
168
221
|
await expect(page.getByTestId("account-locale")).toHaveText("it-IT");
|
|
169
222
|
assert.equal(await page.evaluate(() => localStorage.getItem("jskit.themePreference")), "dark");
|
|
170
223
|
|
|
171
|
-
await page.getByRole("
|
|
172
|
-
await page.waitForURL("**/account
|
|
224
|
+
await page.getByRole("link", { name: "Preferences" }).click();
|
|
225
|
+
await page.waitForURL("**/account/preferences");
|
|
173
226
|
await page.getByRole("button", { name: /Save preferences/u }).click();
|
|
174
227
|
await expect.poll(() => lastReport(page)).toBe("Preferences updated.");
|
|
175
228
|
const saved = api.requests.find((request) => request.key === "PATCH /api/settings/preferences");
|
|
@@ -191,7 +244,7 @@ test("a refused preference shows its message", options, async () => {
|
|
|
191
244
|
}
|
|
192
245
|
});
|
|
193
246
|
const { page } = api;
|
|
194
|
-
await page.goto("/account
|
|
247
|
+
await page.goto("/account/preferences");
|
|
195
248
|
await page.getByRole("button", { name: /Save preferences/u }).click();
|
|
196
249
|
await expect(page.getByText("Theme is not allowed.")).toBeVisible();
|
|
197
250
|
// The old error array looped here for seconds before Vue gave up, so the page must also stay idle.
|
|
@@ -211,19 +264,61 @@ test("notifications save, and sign-in methods link through the provider and unli
|
|
|
211
264
|
}
|
|
212
265
|
});
|
|
213
266
|
const { page } = api;
|
|
214
|
-
await page.goto("/account
|
|
267
|
+
await page.goto("/account/notifications");
|
|
215
268
|
await page.getByRole("button", { name: /Save notification settings/u }).click();
|
|
216
269
|
await expect.poll(() => lastReport(page)).toBe("Notification settings updated.");
|
|
217
270
|
const saved = api.requests.find((request) => request.key === "PATCH /api/settings/notifications");
|
|
218
271
|
assert.deepEqual(saved.body.data.attributes, { productUpdates: true, accountActivity: false, securityAlerts: true });
|
|
219
272
|
|
|
220
|
-
await page.getByRole("
|
|
273
|
+
await page.getByRole("link", { name: "Sign-in methods" }).click();
|
|
221
274
|
await page.getByRole("button", { name: "Unlink" }).click();
|
|
222
275
|
await expect.poll(() => lastReport(page)).toBe("Sign-in method removed.");
|
|
223
276
|
await expect(page.getByRole("button", { name: "Unlink" })).toHaveCount(0);
|
|
224
277
|
await page.getByRole("button", { name: "Link" }).click();
|
|
225
278
|
await page.getByText("Provider").waitFor();
|
|
226
|
-
assert.equal(api.requests.at(-1).search, "?returnTo=%2Faccount%
|
|
279
|
+
assert.equal(api.requests.at(-1).search, "?returnTo=%2Faccount%2Fsign-in-methods");
|
|
280
|
+
await finish(api);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// Material 3 type and layout: the section list's labels and the buttons are label-large (14px/500), a switch sits
|
|
284
|
+
// after its label at the row's end, and each field has the form's full width, so a long email shows whole.
|
|
285
|
+
test("account settings use Material 3 label type, trailing switches and full-width fields", options, async () => {
|
|
286
|
+
const email = "ada.lovelace.analytical.engine@difference-engine.example.com";
|
|
287
|
+
const api = await openApp({
|
|
288
|
+
routes: { "GET /api/settings": document(settings({ profile: { ...settings().profile, email } })) }
|
|
289
|
+
});
|
|
290
|
+
const { page } = api;
|
|
291
|
+
await page.goto("/account");
|
|
292
|
+
await expect(page.getByLabel("Email", { exact: true })).toHaveValue(email);
|
|
293
|
+
const type = (locator) => locator.evaluate((element) => {
|
|
294
|
+
const style = window.getComputedStyle(element);
|
|
295
|
+
return [style.fontSize, style.fontWeight];
|
|
296
|
+
});
|
|
297
|
+
assert.deepEqual(await type(page.locator(".settings-section-list__item .v-list-item-title").first()), ["14px", "500"]);
|
|
298
|
+
assert.deepEqual(await type(page.getByRole("button", { name: "Save profile" })), ["14px", "500"]);
|
|
299
|
+
const fields = await page.evaluate(() => {
|
|
300
|
+
const form = document.querySelector(".settings-section-detail form");
|
|
301
|
+
const width = (element) => Math.round(element.getBoundingClientRect().width);
|
|
302
|
+
return [...form.querySelectorAll(".v-text-field")].map((field) => {
|
|
303
|
+
const input = field.querySelector("input");
|
|
304
|
+
return { width: width(field), form: width(form), whole: input.scrollWidth <= input.clientWidth };
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
assert.ok(fields.length >= 2, "the profile form has its fields");
|
|
308
|
+
for (const field of fields) {
|
|
309
|
+
assert.ok(field.width >= field.form - 48, `a field has the form's width: ${JSON.stringify(field)}`);
|
|
310
|
+
assert.equal(field.whole, true, `a field shows its value whole: ${JSON.stringify(field)}`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
await page.goto("/account/notifications");
|
|
314
|
+
const switches = await page.locator(".v-switch").evaluateAll((elements) => elements.map((element) => {
|
|
315
|
+
const label = element.querySelector(".v-label").getBoundingClientRect();
|
|
316
|
+
const control = element.querySelector(".v-selection-control__wrapper").getBoundingClientRect();
|
|
317
|
+
const row = element.getBoundingClientRect();
|
|
318
|
+
return { after: control.left >= label.right, atEnd: Math.abs(row.right - control.right) <= 1, labelAtStart: Math.abs(label.left - row.left) <= 1 };
|
|
319
|
+
}));
|
|
320
|
+
assert.ok(switches.length >= 3, "the notification switches render");
|
|
321
|
+
for (const position of switches) assert.deepEqual(position, { after: true, atEnd: true, labelAtStart: true });
|
|
227
322
|
await finish(api);
|
|
228
323
|
});
|
|
229
324
|
|
|
@@ -265,7 +360,7 @@ test("each section stays busy until its save or removal answers", options, async
|
|
|
265
360
|
await expect(removeAvatar).not.toHaveClass(loading);
|
|
266
361
|
await expect(saveProfile).toBeEnabled();
|
|
267
362
|
|
|
268
|
-
await page.getByRole("
|
|
363
|
+
await page.getByRole("link", { name: "Preferences" }).click();
|
|
269
364
|
const savePreferences = page.getByRole("button", { name: /Save preferences/u });
|
|
270
365
|
const disabledFields = page.locator(".v-input--disabled");
|
|
271
366
|
await savePreferences.click();
|
|
@@ -275,7 +370,7 @@ test("each section stays busy until its save or removal answers", options, async
|
|
|
275
370
|
await expect(savePreferences).not.toHaveClass(loading);
|
|
276
371
|
await expect(disabledFields).toHaveCount(0);
|
|
277
372
|
|
|
278
|
-
await page.getByRole("
|
|
373
|
+
await page.getByRole("link", { name: "Notifications" }).click();
|
|
279
374
|
const saveNotifications = page.getByRole("button", { name: /Save notification settings/u });
|
|
280
375
|
const productUpdates = page.getByLabel("Product updates");
|
|
281
376
|
await saveNotifications.click();
|
|
@@ -290,16 +385,17 @@ test("each section stays busy until its save or removal answers", options, async
|
|
|
290
385
|
test("the section list follows the address and the placements, and says when there is nothing to show", options, async () => {
|
|
291
386
|
const api = await openApp();
|
|
292
387
|
const { page } = api;
|
|
293
|
-
|
|
388
|
+
// A section no placement names opens none: the list, with the first section beside it on a wide window.
|
|
389
|
+
await page.goto("/account/unknown");
|
|
294
390
|
await expect(page.getByLabel("Display name")).toBeVisible();
|
|
295
391
|
|
|
296
392
|
await page.evaluate(() => window.accountFixture.placeSection());
|
|
297
|
-
await page.getByRole("
|
|
393
|
+
await page.getByRole("link", { name: "Extra" }).click();
|
|
298
394
|
await page.getByText("Extra section").waitFor();
|
|
299
|
-
await page.waitForURL("**/account
|
|
300
|
-
await page.getByRole("
|
|
395
|
+
await page.waitForURL("**/account/extra");
|
|
396
|
+
await page.getByRole("link", { name: "Profile" }).click();
|
|
301
397
|
await expect(page.getByLabel("Display name")).toBeVisible();
|
|
302
|
-
await page.waitForURL(
|
|
398
|
+
await page.waitForURL("**/account/profile");
|
|
303
399
|
await finish(api);
|
|
304
400
|
|
|
305
401
|
const empty = await openApp();
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
|
|
5
|
+
// The users/account-settings pattern lays its sections out as pages: the section list at the account surface's "/",
|
|
6
|
+
// an inner page of home, and one section at "/<section>", whose parent is the list, both rendering the element.
|
|
7
|
+
const PAGES = new URL("../patterns/account-settings/example/src/pages/", import.meta.url);
|
|
8
|
+
|
|
9
|
+
async function routeMeta(file) {
|
|
10
|
+
const source = await readFile(new URL(file, PAGES), "utf8");
|
|
11
|
+
const block = /<route lang="json">([\s\S]*?)<\/route>/u.exec(source);
|
|
12
|
+
assert.ok(block, `${file} declares its route meta`);
|
|
13
|
+
assert.match(source, /<AccountSettingsClientElement \/>/u, `${file} renders AccountSettingsClientElement`);
|
|
14
|
+
return JSON.parse(block[1]).meta;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test("the account-settings pattern's list and section pages declare their parents", async () => {
|
|
18
|
+
const list = await routeMeta("account/index.vue");
|
|
19
|
+
assert.deepEqual(list.jskit.parent, { surface: "home", path: "/" });
|
|
20
|
+
const section = await routeMeta("account/[section].vue");
|
|
21
|
+
assert.equal(section.jskit.parent, "/");
|
|
22
|
+
assert.deepEqual(section.guard, list.guard);
|
|
23
|
+
assert.equal(section.jskit.title, list.jskit.title);
|
|
24
|
+
});
|
|
@@ -90,7 +90,9 @@ test("package-owned account settings host is placement-backed rather than scaffo
|
|
|
90
90
|
);
|
|
91
91
|
|
|
92
92
|
assert.match(source, /useAccountSettingsSections/);
|
|
93
|
-
// The app bar carries the title and
|
|
93
|
+
// The app bar carries the title, and Done only where Up can't go back; the page keeps only its intro.
|
|
94
|
+
assert.match(source, /<ShellPageActions v-if="showsDone">/u);
|
|
95
|
+
assert.match(source, /const showsDone = computed\(\(\) => !canGoUp\.value \|\| !doneTarget\.value\.sameOrigin\);/u);
|
|
94
96
|
assert.match(source, /settings-panel__intro/);
|
|
95
97
|
assert.match(source, /useShellPageTitle/);
|
|
96
98
|
assert.doesNotMatch(source, /<h1/u);
|
|
@@ -295,6 +295,15 @@ test("the profile menu and a refused, then retried, sign-out show only the pseud
|
|
|
295
295
|
});
|
|
296
296
|
const { page } = api;
|
|
297
297
|
await api.open("/home");
|
|
298
|
+
// On a phone, where the name is hidden, the activator is a round icon button around the avatar.
|
|
299
|
+
await page.setViewportSize({ width: 390, height: 844 });
|
|
300
|
+
const activator = await page.locator(".auth-profile-widget__activator").evaluate((element) => {
|
|
301
|
+
const box = element.getBoundingClientRect();
|
|
302
|
+
return { width: box.width, height: box.height, radius: Number.parseFloat(window.getComputedStyle(element).borderTopLeftRadius) };
|
|
303
|
+
});
|
|
304
|
+
assert.equal(Math.round(activator.width), Math.round(activator.height), "The phone activator is as wide as it is tall.");
|
|
305
|
+
assert.ok(activator.radius >= activator.height / 2, `The phone activator's corner radius was ${activator.radius}px.`);
|
|
306
|
+
await page.setViewportSize({ width: 1280, height: 900 });
|
|
298
307
|
await page.locator(".auth-profile-widget__activator").click();
|
|
299
308
|
const signOut = page.getByRole("link", { name: shown("app.menu.signOut") });
|
|
300
309
|
await expect(signOut).toBeVisible();
|
|
@@ -371,9 +380,28 @@ test("account settings, their feedback and a refused field show only the pseudo-
|
|
|
371
380
|
await api.open("/account?returnTo=/home");
|
|
372
381
|
await expect(page.getByText(shown("jskit.users.accountSettings.profile.save"))).toBeVisible();
|
|
373
382
|
await assertTranslated(page, "account settings");
|
|
383
|
+
// The selected section carries Material 3's pill indicator in secondary-container, not a grey rectangle.
|
|
384
|
+
const selected = await page.locator(".settings-section-list .v-list-item--active").evaluate((element) => {
|
|
385
|
+
const probe = document.createElement("div");
|
|
386
|
+
probe.style.background = "rgb(var(--v-theme-secondary-container))";
|
|
387
|
+
document.body.append(probe);
|
|
388
|
+
const indicator = window.getComputedStyle(probe).backgroundColor;
|
|
389
|
+
probe.remove();
|
|
390
|
+
const style = window.getComputedStyle(element);
|
|
391
|
+
return { background: style.backgroundColor, indicator, radius: Number.parseFloat(style.borderTopLeftRadius), height: element.getBoundingClientRect().height };
|
|
392
|
+
});
|
|
393
|
+
assert.equal(selected.background, selected.indicator);
|
|
394
|
+
assert.ok(selected.radius >= selected.height / 2, `The selected section's corner radius was ${selected.radius}px.`);
|
|
395
|
+
// Save keeps 16dp from the last field above it.
|
|
396
|
+
const saveGap = await page.getByTestId("profile-submit-button").evaluate((button) => {
|
|
397
|
+
const row = button.previousElementSibling;
|
|
398
|
+
const fields = [...row.querySelectorAll(".v-input")].map((field) => field.getBoundingClientRect().bottom);
|
|
399
|
+
return button.getBoundingClientRect().top - Math.max(...fields);
|
|
400
|
+
});
|
|
401
|
+
assert.ok(saveGap >= 15.5, `Save sat ${saveGap}px below the last field.`);
|
|
374
402
|
|
|
375
|
-
await page.getByRole("
|
|
376
|
-
await page.waitForURL("
|
|
403
|
+
await page.getByRole("link", { name: shown("app.settings.preferences") }).click();
|
|
404
|
+
await page.waitForURL((url) => url.pathname === "/account/preferences");
|
|
377
405
|
await page.getByTestId("theme-select").click();
|
|
378
406
|
await expect(page.getByRole("option", { name: shown("jskit.users.accountSettings.preferences.theme.light") })).toBeVisible();
|
|
379
407
|
await assertTranslated(page, "the theme choices");
|