feat(SPA): profile page, display name and the new account menu
This commit is contained in:
1 parent
55525c06d6
commit
b0f7b7892b
29 files changed
+3120
-423
No files matched your search
@@ -20,12 +20,14 @@ Feature: DashboardShell wordmark, sidebar collapse and the UserMenu
|
||||
to the sidebar's own collapsed state, with items Home, Articles, Users and Integration, in that order
|
||||
And the sidebar's #footer slot renders a UserMenu component, not the plain
|
||||
<span>Log out</span> text the shell renders today
|
||||
And UserMenu is a UDropdownMenu :content="{ side: 'top', align: 'start' }" anchored to a
|
||||
ghost UButton block containing a UAvatar and the session's username
|
||||
And the UAvatar's initials are the first two characters of the username, upper-cased (for
|
||||
example "marta" gives "MA"), and the avatar is aria-hidden because the username is shown
|
||||
next to it
|
||||
And UserMenu's dropdown groups, top to bottom, are: an account label (the username); a
|
||||
And UserMenu is the account menu specified in features/profile_page.feature, anchored to a
|
||||
ghost UButton block containing a UAvatar and the session's name (the display name when not
|
||||
empty, else the username)
|
||||
And the UAvatar's initials follow features/profile_page.feature (the first letters of the
|
||||
first two words of a display name, the first two characters of a single word or of a
|
||||
username, upper-cased; for example "marta" gives "MA"), and the avatar is aria-hidden
|
||||
because the name is shown next to it
|
||||
And UserMenu's groups, top to bottom, are: a header (the name and the email); Profile; a
|
||||
checkbox group of System, Light and Dark bound to useColorMode().preference, with the
|
||||
current preference's item checked; then Log out, in its own trailing group
|
||||
And Log out calls POST /v1/auth/logout inside a try/finally with a loading flag (no catch),
|
||||
@@ -53,15 +55,16 @@ Feature: DashboardShell wordmark, sidebar collapse and the UserMenu
|
||||
Then the sidebar returns to its :default-size="14" expanded width
|
||||
And aria-expanded reflects the expanded state
|
||||
|
||||
Scenario: UserMenu's dropdown lists the account label, color-mode checkboxes, then Log out
|
||||
Given the session's username is "marta"
|
||||
Scenario: UserMenu lists the header, Profile, color-mode checkboxes, then Log out
|
||||
Given the session's username is "marta" and its email is "marta@renew.example"
|
||||
When the user activates the UserMenu trigger in the sidebar footer
|
||||
Then the dropdown opens with a UAvatar showing initials "MA" and the trigger's own username
|
||||
text "marta"
|
||||
And the first group is the account label showing "marta"
|
||||
And the second group is a checkbox group with items System, Light and Dark, and exactly the
|
||||
Then the menu opens with a UAvatar showing initials "MA" and the trigger's own name text
|
||||
"marta"
|
||||
And the first group is the header showing "marta" and "marta@renew.example"
|
||||
And the next item is "Profile"
|
||||
And the following group is a checkbox group with items System, Light and Dark, and exactly the
|
||||
item matching the current useColorMode().preference is checked
|
||||
And the third and final group contains only "Log out", separated from the color-mode group
|
||||
And the final group contains only "Log out", separated from the color-mode group
|
||||
|
||||
Scenario Outline: Selecting a color-mode item updates the preference and its checked state
|
||||
Given the user has opened the UserMenu dropdown
|
||||
@@ -91,11 +94,11 @@ Feature: DashboardShell wordmark, sidebar collapse and the UserMenu
|
||||
And the header keeps an accessible name for the brand mark (an aria-label or sr-only text
|
||||
reading "RenewCMS" on the logo or its wrapper), so collapsing the rail is not a pure a11y
|
||||
regression
|
||||
And the UserMenu trigger in the sidebar footer still shows the UAvatar, but the username text
|
||||
And the UserMenu trigger in the sidebar footer still shows the UAvatar, but the name text
|
||||
next to it is hidden
|
||||
And the UserMenu trigger keeps an accessible name that includes the username (an aria-label
|
||||
And the UserMenu trigger keeps an accessible name that includes the name (an aria-label
|
||||
such as "marta — account menu", or a tooltip plus aria-label), matching the same
|
||||
tooltip/aria-label spirit the collapsed rail's UNavigationMenu items already use per
|
||||
docs/design/components.md's DashboardShell a11y note
|
||||
And activating the collapsed UserMenu trigger still opens the same dropdown, with the same
|
||||
account label, color-mode checkbox group and Log out item as the expanded state
|
||||
And activating the collapsed UserMenu trigger still opens the same menu, with the same
|
||||
header, Profile, color-mode checkbox group and Log out item as the expanded state
|
||||
@@ -18,8 +18,9 @@ Feature: Home screen
|
||||
Default, Empty and Error alike): icon plug, title "Connect your website", text "Your site
|
||||
reads published articles from RenewCMS. Share this guide with whoever builds it.", and a
|
||||
link "Open the guide" navigating to /integration
|
||||
And a greeting reading "{greeting}, {username}" sits above the subtitle in every state
|
||||
(Loading, Default, Empty and Error alike); it is computed from the session's username and
|
||||
And a greeting reading "{greeting}, {name}" sits above the subtitle in every state
|
||||
(Loading, Default, Empty and Error alike); name is the session's display name when it is
|
||||
not empty and the session's username otherwise; it is computed from that name and
|
||||
the device's local hour via Date.getHours(), needs no request of its own since the session
|
||||
is already resolved by the app-boot flow before HomePage ever renders, and so never has its
|
||||
own loading or error state
|
||||
@@ -109,7 +110,7 @@ Feature: Home screen
|
||||
second request made for either
|
||||
|
||||
Scenario Outline: The greeting matches the hour of day
|
||||
Given the session's username is "marta"
|
||||
Given the session's username is "marta" and it has no display name
|
||||
And the device's local hour is <hour>
|
||||
When the user views /home
|
||||
Then the greeting above the subtitle reads "<greeting>, marta"
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
Feature: Profile page, display name and the account menu
|
||||
|
||||
As a verified RenewCMS user in the shared workspace
|
||||
I want /profile to let me set an optional display name, see my read-only account details and
|
||||
email myself a password reset link, and I want the sidebar account chip to open a menu that
|
||||
leads there
|
||||
So that people are named by a human name across the back-office, while logging in still uses the
|
||||
username, and I always have a way to reach Profile, switch theme and log out
|
||||
|
||||
Background:
|
||||
Given /profile is a verified route (meta.auth "verified", meta.title "Profile") rendered as
|
||||
ProfilePage inside DashboardShell, per docs/design/routes.md and docs/design/screens/profile.md
|
||||
And the endpoints already exist and this slice wires the SPA to them with no Go change:
|
||||
GET /v1/auth/me (returns id, username, email, is_verified, display_name), PATCH /v1/user/me
|
||||
(features/user_display_name.feature) and POST /v1/auth/password-reset
|
||||
(features/request_password_reset.feature)
|
||||
And useApi's getMe and the Session type gain email and displayName; a missing or null
|
||||
display_name in a response is read as ""; the session store gains an action that replaces
|
||||
the session's displayName from the display_name of a PATCH /v1/user/me response
|
||||
And the PageHeader shows breadcrumb "Profile" (mobile title "Profile"), the page title
|
||||
"Profile" and the subtitle "Your account on this RenewCMS workspace."; none of the four
|
||||
navigation items (Home, Articles, Users, Integration) has aria-current="page" on /profile
|
||||
And the page is at most 720 px wide with three ProfileCards in this order: Display name,
|
||||
Account, Password; cards are separated by 20 px, have 20 px padding (16 px on mobile) and
|
||||
inputs are 40 px high (44 px on mobile, 16 px text)
|
||||
And the Display name card has the title "Display name", the text "Shown instead of your
|
||||
username across the back-office and in the emails RenewCMS sends. Leave it empty to use your
|
||||
username.", one input labelled "Display name (optional)" with placeholder "e.g. Marta Alves",
|
||||
and a help row under the input: the field error on the left (icon circle-alert, 12 px, error
|
||||
color) and the counter "n / 60" on the right in the mono style; there is no maxlength, so
|
||||
typing past 60 is possible and shows the error
|
||||
And the Display name card's footer strip (top border, --ui-bg-muted, 12 px status text on the
|
||||
left, Save button on the right, 32 px high on desktop and 44 px on mobile) shows "Unsaved
|
||||
changes" in the warning color while the draft is dirty and no save is in flight; otherwise
|
||||
it shows "You appear as “{display name}”." when a display name is saved, or "You appear as
|
||||
“{username}”, your username." when none is
|
||||
And the draft is the trimmed field value (String.prototype.trim); it is dirty when it differs
|
||||
from the session's displayName; it is valid when its length counted in Unicode code points
|
||||
(Array.from(value).length, the same count as the API's utf8.RuneCountInString, not bytes,
|
||||
not UTF-16 units and not grapheme clusters) is 60 or fewer; the counter shows that count
|
||||
and turns red (error color) above 60
|
||||
And Save is enabled (full opacity) while the draft is dirty and valid, or while a save is in
|
||||
flight; otherwise it is disabled at 40 % opacity; pressing Enter in the field activates Save
|
||||
only when Save is enabled, and does nothing otherwise
|
||||
And activating Save sends PATCH /v1/user/me with JSON {"display_name": "<trimmed draft>"};
|
||||
while it is in flight the field is disabled and dimmed, the button reads "Saving…" with a
|
||||
loader-circle icon, and a second submit is ignored
|
||||
And a 200 response replaces session.displayName with the response's display_name at once (the
|
||||
server's value, not the typed one), sets the field to that value, and raises a toast:
|
||||
color success, icon i-lucide-circle-check, id "display-name-saved", title "Display name
|
||||
saved", description "You now appear as {display name} across RenewCMS and in emails."
|
||||
And the display name is shown wherever a person is named, read from the session: the sidebar
|
||||
chip and the account menu (name and initials) and the Home greeting "{greeting}, {name}",
|
||||
where name is the display name when not empty and the username otherwise; the Users list
|
||||
reads display_name from its own GET /v1/user/ items and is not changed by this slice
|
||||
And any non-2xx or failed PATCH keeps the typed value and the dirty state; a 401 and a 403
|
||||
are handled entirely by useApi's global handling
|
||||
(features/api_client_global_error_handling.feature)
|
||||
And the Account card has the title "Account", the text "Set when your account was created. You
|
||||
log in with your username, even after adding a display name.", and two disabled inputs with
|
||||
a lock icon at the right: "Username" (the session's username) and "Email" (the session's
|
||||
email), in two columns on desktop (minmax(0,1fr) minmax(0,1.4fr)) and stacked on mobile
|
||||
And the Password card has the title "Password", the text "To change it, we'll email a reset
|
||||
link to {email}. The link works once, within 1 hour.", a disabled input labelled "Password"
|
||||
showing "••••••••••••" with a lock icon, and, beside it (stacked on mobile), a secondary
|
||||
button "Send reset link" with the mail icon; the note under them exists only once a request
|
||||
has been made
|
||||
And activating the button sends POST /v1/auth/password-reset with JSON {"email": "<session
|
||||
email>"}, with no confirmation step; while in flight the button reads "Sending…" with a
|
||||
loader-circle icon and is disabled; the note is role="status", 16 px icon, 600-weight title
|
||||
and the text on a soft background
|
||||
And leaving /profile while the draft is dirty (a route change, including Log out) opens a
|
||||
ConfirmDialog, except when the redirect is caused by a 401 handled by useApi
|
||||
(authRedirectPending); leaving while the draft is not dirty, or after a successful save,
|
||||
opens nothing
|
||||
And the UserMenu chip in the sidebar footer no longer logs out on click: it opens the account
|
||||
menu; the chip is 40 px high with a 26 px sand (#d4c5b0) initials avatar with ink text, the
|
||||
name (display name, else username) with truncation, and a chevrons-up-down icon; it has
|
||||
aria-haspopup="menu", aria-expanded reflecting the open state, and title "Account"
|
||||
And while the menu is open, or while the current route is /profile, the chip takes --rc-sand
|
||||
with #111111 text
|
||||
And on desktop (lg and up) the menu is a 224 px popover above the chip aligned to its left
|
||||
edge with --rc-shadow-pop; below lg the account row in the navigation drawer closes the
|
||||
drawer and opens the menu as a bottom sheet with a grab handle over an --rc-overlay scrim;
|
||||
Escape or a click outside closes either, and focus returns to the chip; arrow keys move
|
||||
between items
|
||||
And the menu content, top to bottom, is: a header (the name in semibold, and the email below
|
||||
it in muted 12 px on desktop; on mobile a 36 px initials avatar, the name at 15 px semibold
|
||||
and the email at 14 px muted), a "Profile" item (icon circle-user, highlighted with
|
||||
--ui-bg-elevated while the route is /profile), a separator, the three checkbox items
|
||||
"System", "Light" and "Dark" bound to useColorMode().preference with the current one
|
||||
checked (kept from the shipped menu), a separator, and "Log out" (icon log-out); items are
|
||||
32 px on desktop and 48 px at 15 px text on mobile
|
||||
And activating "Profile" navigates to /profile and closes the menu (when already on /profile
|
||||
it only closes); activating a theme item sets the preference and closes the menu;
|
||||
activating "Log out" behaves as today: POST /v1/auth/logout in a try/finally, then
|
||||
session.clear() and a navigation to /login
|
||||
And avatar initials are the uppercased first Unicode character of each of the first two words
|
||||
of the display name; a display name with a single word, and a username, give their first two
|
||||
characters, uppercased
|
||||
And in the collapsed sidebar rail the chip shows only the avatar, keeps its accessible name
|
||||
"{name} — account menu" and opens the same menu
|
||||
And this slice does not touch the invitation or accept-invite work, does not change the
|
||||
Users page, and does not add a way to change the username, email or password inside the page
|
||||
|
||||
Scenario Outline: Route guards for /profile
|
||||
Given the session is <session>
|
||||
When the user navigates to /profile
|
||||
Then <outcome>
|
||||
|
||||
Examples:
|
||||
| session | outcome |
|
||||
| resolved, verified | ProfilePage renders |
|
||||
| resolved, unverified | the app redirects to /register/pending |
|
||||
| resolved with no session (401 from /auth/me) | the app redirects to /login?redirect=/profile&reason=expired |
|
||||
|
||||
Scenario: Default — the usual first visit with no display name
|
||||
Given the session is verified with id 1, username "marta", email "marta@renew.example" and no
|
||||
display name
|
||||
When the user navigates to /profile
|
||||
Then the breadcrumb reads "Profile" (mobile title "Profile")
|
||||
And the Display name field is empty, shows the placeholder "e.g. Marta Alves", and the counter
|
||||
reads "0 / 60"
|
||||
And the footer reads "You appear as “marta”, your username."
|
||||
And Save is disabled
|
||||
And the Username input shows "marta" and the Email input shows "marta@renew.example", both
|
||||
disabled
|
||||
And the Password card text reads "To change it, we'll email a reset link to
|
||||
marta@renew.example. The link works once, within 1 hour."
|
||||
And the button reads "Send reset link", no note is shown, and no request other than the
|
||||
session's was sent
|
||||
|
||||
Scenario: Loading — opened by URL before /auth/me has returned
|
||||
Given GET /v1/auth/me has not yet resolved
|
||||
When the user opens /profile directly by URL
|
||||
Then the breadcrumb reads "Profile", and the page title and subtitle are shown
|
||||
And three skeleton cards replace the real cards (one with a title bar and one input, one with a
|
||||
title bar and two inputs, one with a title bar and one input), 16 px apart, in an
|
||||
aria-busy="true" container
|
||||
And AppBoot is not shown
|
||||
When GET /v1/auth/me then resolves 200 for the verified user "marta"
|
||||
Then the skeleton cards are replaced by the Default state
|
||||
|
||||
Scenario: Opened from the menu, the cached user renders at once
|
||||
Given the user is on /home with a resolved session for "marta"
|
||||
When the user activates "Profile" in the account menu
|
||||
Then the Default state renders with no skeleton, and GET /v1/auth/me is not called again
|
||||
|
||||
Scenario: Editing — typing turns Save on
|
||||
Given the user is on /profile in the Default state
|
||||
When the user types "Marta Alves" into Display name
|
||||
Then the counter reads "11 / 60" and the footer reads "Unsaved changes" in the warning color
|
||||
And Save is enabled
|
||||
And no request has been sent
|
||||
|
||||
Scenario Outline: The counter counts Unicode characters after trimming
|
||||
Given the user is on /profile in the Default state
|
||||
When the user types <value> into Display name
|
||||
Then the counter reads "<count> / 60"
|
||||
And the counter is <color> and Save is <save>
|
||||
|
||||
Examples:
|
||||
| value | count | color | save |
|
||||
| "Marta Alves" | 11 | muted | enabled |
|
||||
| " Marta Alves " | 11 | muted | enabled |
|
||||
| 60 repetitions of "a" | 60 | muted | enabled |
|
||||
| 61 repetitions of "a" | 61 | red | disabled |
|
||||
| 60 repetitions of "😀" | 60 | muted | enabled |
|
||||
| 61 repetitions of "😀" | 61 | red | disabled |
|
||||
| "Cafe" followed by U+0301 | 5 | muted | enabled |
|
||||
| 5 spaces, 60 "a", 5 spaces | 60 | muted | enabled |
|
||||
|
||||
Scenario: Too long — over 60 characters shows a field error and keeps Save off
|
||||
Given the user is on /profile in the Default state
|
||||
When the user types "Marta Alves, Head of Renovations, Brussels and Wallonia office"
|
||||
Then the counter reads "62 / 60" in the error color
|
||||
And the help row shows the error "Use 60 characters or fewer." with the circle-alert icon
|
||||
And the field has the error border
|
||||
And the footer reads "Unsaved changes" in the warning color
|
||||
And Save is disabled
|
||||
When the user presses Enter in the field
|
||||
Then PATCH /v1/user/me is never sent
|
||||
When the user shortens the value to "Marta Alves"
|
||||
Then the error disappears, the counter reads "11 / 60" and Save is enabled
|
||||
|
||||
Scenario Outline: Dirty is measured on the trimmed draft against the saved name
|
||||
Given the session's display name is "<saved>"
|
||||
When the user sets Display name to <typed>, read literally as described in the column
|
||||
Then Save is <save>
|
||||
And the footer reads "<footer>"
|
||||
|
||||
Examples:
|
||||
| saved | typed | save | footer |
|
||||
| | 3 spaces | disabled | You appear as “marta”, your username. |
|
||||
| Marta Alves | Marta Alves | disabled | You appear as “Marta Alves”. |
|
||||
| Marta Alves | Marta Alves and a space | disabled | You appear as “Marta Alves”. |
|
||||
| Marta Alves | nothing | enabled | Unsaved changes |
|
||||
| Marta Alves | Marta, 2 spaces, Alves | enabled | Unsaved changes |
|
||||
|
||||
Scenario: Enter saves when Save is enabled
|
||||
Given the user is on /profile and has typed "Marta Alves"
|
||||
When the user presses Enter in the field
|
||||
Then PATCH /v1/user/me is sent once with JSON {"display_name": "Marta Alves"}
|
||||
|
||||
Scenario: Saving — the field is disabled and a second submit is ignored
|
||||
Given the user is on /profile and has typed " Marta Alves " and PATCH /v1/user/me has not
|
||||
resolved
|
||||
When the user activates Save
|
||||
Then PATCH /v1/user/me is sent with JSON {"display_name": "Marta Alves"}
|
||||
And the field is disabled and dimmed, and the button reads "Saving…" with a loader-circle icon
|
||||
When the user presses Enter or activates Save again
|
||||
Then no second request is sent
|
||||
|
||||
Scenario: Saved — a 200 updates the session, the chip, the menu, the footer and raises a toast
|
||||
Given the user is on /profile and Save has been activated with "Marta Alves"
|
||||
When PATCH /v1/user/me resolves 200 with id 1, username "marta" and display_name "Marta Alves"
|
||||
Then session.displayName is "Marta Alves"
|
||||
And the sidebar chip reads "Marta Alves" with initials "MA"
|
||||
And the field shows "Marta Alves" and is enabled, the counter reads "11 / 60"
|
||||
And the footer reads "You appear as “Marta Alves”." and Save is disabled
|
||||
And a toast appears: color success, icon i-lucide-circle-check, id "display-name-saved", title
|
||||
"Display name saved", description "You now appear as Marta Alves across RenewCMS and in
|
||||
emails."
|
||||
And no leave-page confirmation opens when the user then navigates away
|
||||
|
||||
Scenario: The server's value wins over what was typed
|
||||
Given the user has typed " Marta Alves " and activated Save
|
||||
When PATCH /v1/user/me resolves 200 with display_name "Marta Alves"
|
||||
Then the field, the chip and the toast all use "Marta Alves"
|
||||
|
||||
Scenario: Clearing the display name goes back to the username
|
||||
Given the session's display name is "Marta Alves" and the username is "marta"
|
||||
When the user empties Display name and activates Save
|
||||
Then PATCH /v1/user/me is sent with JSON {"display_name": ""}
|
||||
And when it resolves 200 with display_name "" the sidebar chip reads "marta" with initials "MA"
|
||||
And the footer reads "You appear as “marta”, your username."
|
||||
And a toast appears: color success, icon i-lucide-circle-check, id "display-name-saved", title
|
||||
"Display name removed", description "You now appear as marta, your username."
|
||||
|
||||
Scenario Outline: Save failed — a 500 or a network failure keeps the text and offers a retry
|
||||
Given the user is on /profile and Save has been activated with "Marta Alves"
|
||||
When PATCH /v1/user/me <failure>
|
||||
Then an error alert (role="alert", icon circle-x) in the Display name card reads "Couldn't save
|
||||
your display name. Your text is still here. Try again in a moment."
|
||||
And the field still shows "Marta Alves" and is enabled
|
||||
And the footer reads "Unsaved changes" and Save is enabled
|
||||
And session.displayName is unchanged and no toast is raised
|
||||
When the user activates Save again
|
||||
Then the alert disappears while the request is in flight
|
||||
And PATCH /v1/user/me is sent again with the same body
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| resolves with a 500 response |
|
||||
| never reaches the server |
|
||||
|
||||
Scenario Outline: A 400 from the API
|
||||
Given the user is on /profile and Save has been activated with "Marta Alves"
|
||||
When PATCH /v1/user/me resolves 400 with body "<body>"
|
||||
Then <effect>
|
||||
|
||||
Examples:
|
||||
| body | effect |
|
||||
| Display name must be 60 characters or fewer. | the field shows the error "Use 60 characters or fewer." and Save is disabled |
|
||||
| invalid request body | the Save failed alert is shown and the text is kept |
|
||||
|
||||
Scenario: Leaving with unsaved changes asks for confirmation
|
||||
Given the user is on /profile with the draft "Marta Alves" (dirty)
|
||||
When the user activates "Users" in the navigation
|
||||
Then a ConfirmDialog (neutral tone) opens, titled "Leave without saving?" with the text "Your
|
||||
display name change hasn't been saved."
|
||||
And its actions are "Discard changes" and "Keep editing"
|
||||
When the user activates "Keep editing"
|
||||
Then the dialog closes and the app stays on /profile with the draft intact
|
||||
When the user tries to leave again and activates "Discard changes"
|
||||
Then the app navigates to /users
|
||||
And the display name saved in the session is unchanged
|
||||
|
||||
Scenario: No confirmation when nothing is unsaved
|
||||
Given the user is on /profile in the Default state
|
||||
When the user activates "Users" in the navigation
|
||||
Then the app navigates to /users with no dialog
|
||||
|
||||
Scenario: A 401 while saving redirects without the leave dialog
|
||||
Given the user is on /profile with the draft "Marta Alves"
|
||||
When PATCH /v1/user/me resolves 401
|
||||
Then the app redirects to /login?redirect=/profile&reason=expired
|
||||
And no leave-page dialog opens
|
||||
|
||||
Scenario: Account menu — opened on desktop
|
||||
Given the user is on /home on desktop with the session "marta", email "marta@renew.example",
|
||||
no display name
|
||||
When the user activates the account chip
|
||||
Then the chip has aria-expanded="true" and the sand fill with ink text
|
||||
And a 224 px popover opens above the chip, aligned to its left edge
|
||||
And its header shows "marta" in semibold and "marta@renew.example" in muted 12 px
|
||||
And below it are "Profile" (circle-user), a separator, "System", "Light", "Dark" (the
|
||||
current preference checked), a separator, and "Log out" (log-out)
|
||||
And "Profile" is not highlighted
|
||||
|
||||
Scenario: Account menu — opened from the drawer on mobile
|
||||
Given the user is on /home on a viewport below lg and the navigation drawer is open
|
||||
When the user activates the account row in the drawer
|
||||
Then the drawer closes and a bottom sheet with a grab handle opens over the --rc-overlay scrim,
|
||||
with the Home page behind it
|
||||
And its header shows a 36 px avatar "MA", the name "marta" at 15 px semibold and the email
|
||||
"marta@renew.example" muted
|
||||
And its items are 48 px high: "Profile", a separator, "System", "Light", "Dark", a separator,
|
||||
"Log out"
|
||||
|
||||
Scenario Outline: Closing the menu
|
||||
Given the account menu is open
|
||||
When <action>
|
||||
Then the menu closes, the chip has aria-expanded="false", and focus is on the chip
|
||||
|
||||
Examples:
|
||||
| action |
|
||||
| the user presses Escape |
|
||||
| the user clicks outside the menu |
|
||||
|
||||
Scenario: Menu items
|
||||
Given the account menu is open on /home
|
||||
When the user activates "Profile"
|
||||
Then the app navigates to /profile and the menu closes
|
||||
Given the account menu is open on /profile
|
||||
Then "Profile" is highlighted with --ui-bg-elevated
|
||||
When the user activates "Profile"
|
||||
Then the menu closes and the app stays on /profile with no new request
|
||||
When the user opens the menu and activates "Log out"
|
||||
Then POST /v1/auth/logout is sent, then the session is cleared and the app navigates to /login
|
||||
|
||||
Scenario: The chip is sand while on /profile even with the menu closed
|
||||
Given the user is on /profile with the menu closed
|
||||
Then the chip has the --rc-sand fill and #111111 text, and aria-expanded="false"
|
||||
When the user navigates to /home
|
||||
Then the chip has a transparent fill
|
||||
|
||||
Scenario: Theme choice stays in the menu
|
||||
Given the account menu is open and useColorMode().preference is "system"
|
||||
When the user activates "Dark"
|
||||
Then useColorMode().preference is "dark"
|
||||
And the menu closes
|
||||
And reopening it shows "Dark" checked and "System" not checked
|
||||
|
||||
Scenario Outline: Avatar initials
|
||||
Given the session has username "<username>" and display name "<display>"
|
||||
Then the chip and the menu header show the initials "<initials>" and the name "<shown>"
|
||||
|
||||
Examples:
|
||||
| username | display | initials | shown |
|
||||
| marta | | MA | marta |
|
||||
| marta | Marta Alves | MA | Marta Alves |
|
||||
| marta | Marta Alves Costa | MA | Marta Alves Costa |
|
||||
| ines | Inès Duarte | ID | Inès Duarte |
|
||||
| elodie | Élodie Martin | ÉM | Élodie Martin |
|
||||
| marta | Marta | MA | Marta |
|
||||
| m.alves | | M. | m.alves |
|
||||
|
||||
Scenario: The collapsed rail keeps the menu and an accessible name
|
||||
Given the sidebar is collapsed to its icon rail and the display name is "Marta Alves"
|
||||
Then the chip shows only the avatar "MA" and has the accessible name "Marta Alves — account menu"
|
||||
When the user activates it
|
||||
Then the same menu opens
|
||||
|
||||
Scenario Outline: The Home greeting uses the display name, else the username
|
||||
Given the session has username "marta" and display name "<display>"
|
||||
When the user views /home at a local hour of 9
|
||||
Then the greeting's name is "<name>"
|
||||
|
||||
Examples:
|
||||
| display | name |
|
||||
| | marta |
|
||||
| Marta Alves | Marta Alves |
|
||||
|
||||
Scenario: A saved display name appears on Home without a reload
|
||||
Given the user saved the display name "Marta Alves" on /profile
|
||||
When the user navigates to /home
|
||||
Then the greeting's name is "Marta Alves" and the chip reads "Marta Alves"
|
||||
|
||||
Scenario: Reset: sending — no confirmation, the button is busy
|
||||
Given the user is on /profile with the session's display name "Marta Alves"
|
||||
When the user activates "Send reset link"
|
||||
Then POST /v1/auth/password-reset is sent with JSON {"email": "marta@renew.example"}
|
||||
And the button reads "Sending…" with a loader-circle icon and is disabled
|
||||
And no note is shown, and the sidebar chip still reads "Marta Alves"
|
||||
|
||||
Scenario: Reset: sent — the note stays and "Send again" sends a new link
|
||||
Given "Send reset link" has been activated
|
||||
When POST /v1/auth/password-reset resolves 200
|
||||
Then the button reads "Send again" with the mail icon and is enabled
|
||||
And a success note (role="status", icon mail-check) shows the title "Reset link sent" and the
|
||||
text "Check marta@renew.example. The link works once, within 1 hour."
|
||||
When the user activates "Send again"
|
||||
Then POST /v1/auth/password-reset is sent again with the same body
|
||||
And the note is hidden while it is in flight
|
||||
When it resolves 200
|
||||
Then the "Reset link sent" note is shown again
|
||||
When the user navigates away and back to /profile
|
||||
Then the button reads "Send reset link" and no note is shown
|
||||
|
||||
Scenario Outline: Reset: too many requests — the button is disabled for the Retry-After window
|
||||
Given "Send reset link" has been activated
|
||||
When POST /v1/auth/password-reset resolves 429 with <header>
|
||||
Then a warning note (role="status", icon clock-alert) shows the title "Too many requests" and
|
||||
the text "You've asked for several links recently. Wait a few minutes, then try again."
|
||||
And the button reads "Send reset link", is disabled and is at 40 % opacity
|
||||
When <seconds> seconds elapse
|
||||
Then the button is enabled and the warning note is gone
|
||||
|
||||
Examples:
|
||||
| header | seconds |
|
||||
| a Retry-After of 42 | 42 |
|
||||
| a Retry-After of 300 | 300 |
|
||||
| no Retry-After header | 300 |
|
||||
| a Retry-After of "soon" | 300 |
|
||||
| a Retry-After of 0 | 300 |
|
||||
|
||||
Scenario Outline: Reset: failed — a 500 or a network failure leaves the button pressable
|
||||
Given "Send reset link" has been activated
|
||||
When POST /v1/auth/password-reset <failure>
|
||||
Then an error note (role="status", icon circle-x) shows the title "Couldn't send the link" and
|
||||
the text "Check your connection and try again."
|
||||
And the button reads "Send reset link" and is enabled
|
||||
When the user activates it and POST /v1/auth/password-reset resolves 200
|
||||
Then the note becomes "Reset link sent" and the button reads "Send again"
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| resolves with a 500 response |
|
||||
| never reaches the server |
|
||||
|
||||
Scenario: Display names in the Users list
|
||||
Given the session is "marta" with display name "Marta Alves"
|
||||
And GET /v1/user/ resolves 200 with the 5-item Default fixture of users_screen.feature, where
|
||||
user id 1 "marta" has display_name "Marta Alves" and user id 3 "ines" has display_name
|
||||
"Inès Duarte"
|
||||
When the user navigates to /users
|
||||
Then the breadcrumb reads "Users" (mobile title "Users") and the sidebar chip reads "Marta Alves"
|
||||
And the "marta" row shows "Marta Alves" with "marta" beside it in muted text and the "you" tag
|
||||
and an Accepted badge
|
||||
And the "ines" row shows "Inès Duarte" with "ines" beside it
|
||||
And the "jonas" row shows "jonas" alone
|
||||
@@ -0,0 +1,164 @@
|
||||
# QA: Profile page, display name and the account menu
|
||||
|
||||
## Implementation note for coder (informational, not a QA assertion)
|
||||
|
||||
There is no Go change and no HTTP-level Go section: PATCH /v1/user/me, GET /v1/auth/me and POST
|
||||
/v1/auth/password-reset are already covered (features/user_display_name.qa.md,
|
||||
features/get_current_user.qa.md, features/request_password_reset.qa.md).
|
||||
|
||||
Amended specs (their Vitest tests must be updated in the same change): dashboard_shell_branding.feature
|
||||
(UserMenu label, initials, groups and the collapsed aria-label), home.feature (greeting name),
|
||||
plus session.spec.ts, useApi.spec.ts (getMe maps email and display_name, absent means ""),
|
||||
UserMenu.spec.ts, DashboardShell.spec.ts and the HomeView spec.
|
||||
|
||||
The Saved toast uses `useToast().add({ id: 'display-name-saved', color: 'success', icon:
|
||||
'i-lucide-circle-check', title: 'Display name saved', description: 'You now appear as {name}
|
||||
across RenewCMS and in emails.' })`. The 429 countdown reuses `useRetryAfter`; the 300 s fallback
|
||||
applies when `ApiError.retryAfter` is undefined or 0.
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Mock `useApi`'s `request` and `getMe` per case; no real backend. Mount ProfilePage inside
|
||||
DashboardShell with a resolved verified session (id 1, username "marta", email
|
||||
"marta@renew.example", displayName "") and a test router at /profile. Use fake timers only for the
|
||||
countdown. No test freezes the system date; the Home greeting cases mock the hour as
|
||||
home.qa.md does and assert the name part only. Run the layout cases at a desktop and a
|
||||
below-lg width (mock `matchMedia`).
|
||||
|
||||
1. **Route guards, Examples-driven** - verified renders; unverified redirects to
|
||||
/register/pending; 401 redirects to /login?redirect=/profile&reason=expired.
|
||||
2. **Default** - breadcrumb "Profile", title, subtitle, empty field with placeholder, "0 / 60",
|
||||
footer "You appear as “marta”, your username.", Save disabled, Username and Email inputs
|
||||
disabled with their values, Password text with the email, "Send reset link", no note; no
|
||||
nav item has aria-current.
|
||||
3. **Loading** - unresolved GET /v1/auth/me; assert three skeleton cards, aria-busy, no AppBoot;
|
||||
resolve and assert Default.
|
||||
4. **From the menu** - navigate from /home via "Profile"; assert no skeleton and no second
|
||||
GET /v1/auth/me.
|
||||
5. **Editing** - type "Marta Alves"; assert "11 / 60", "Unsaved changes", Save enabled, no request.
|
||||
6. **Counter, Examples-driven** - each table row, including the 60/61 emoji rows and "Cafe" +
|
||||
U+0301 (5); assert count, color class and Save state.
|
||||
7. **Too long** - the 62-character value; assert "62 / 60", the error with circle-alert, "Unsaved
|
||||
changes", Save disabled, Enter sends nothing; shorten and assert the error clears.
|
||||
8. **Dirty rules, Examples-driven** - each row.
|
||||
9. **Enter saves** - assert one PATCH with body `{"display_name":"Marta Alves"}`.
|
||||
10. **Saving** - unresolved PATCH with a padded value; assert the trimmed body, disabled and dimmed
|
||||
field, "Saving…", and that Enter and a second click send nothing.
|
||||
11. **Saved** - resolve 200; assert session.displayName, chip "Marta Alves" with "MA", field value,
|
||||
"11 / 60", footer, Save disabled, the toast arguments, and no leave dialog after navigating.
|
||||
12. **Server value wins** - resolve with a trimmed display_name; assert all consumers use it.
|
||||
13. **Clearing** - saved "Marta Alves", empty the field, save; assert body `{"display_name":""}`,
|
||||
chip "marta", footer, and the "Display name removed" toast.
|
||||
14. **Save failed, Examples-driven** - `ApiError(500)` and `NetworkError`; assert the alert copy,
|
||||
the kept value, the "Unsaved changes" footer, Save enabled, the session unchanged and no toast;
|
||||
save again and assert the alert clears during the request and PATCH is sent again.
|
||||
15. **400, Examples-driven** - `ApiError(400)` with the display-name text gives the field error;
|
||||
with another body gives the Save failed alert.
|
||||
16. **Leave guard** - dirty draft, navigate to /users; assert ConfirmDialog props (title, text,
|
||||
"Keep editing" as confirm and "Discard changes" as cancel, neutral tone); Keep editing stays;
|
||||
Discard leaves and leaves the session name unchanged. Also assert: clean draft leaves with no
|
||||
dialog; a 401 during PATCH redirects with no dialog (authRedirectPending); Log out while dirty
|
||||
shows the dialog.
|
||||
17. **Menu, desktop** - click the chip; assert aria-haspopup, aria-expanded, title "Account", the
|
||||
sand class, the header name and email, the item order (Profile, System, Light, Dark, Log out)
|
||||
with the two separators, and Profile not highlighted.
|
||||
18. **Menu, mobile** - below lg, open the drawer and activate the account row; assert the drawer
|
||||
closed, the sheet with its grab handle, the 36 px avatar header and the same item order.
|
||||
19. **Closing** - Escape and an outside click; assert closed, aria-expanded false, focus on the
|
||||
chip; ArrowDown and ArrowUp move between items.
|
||||
20. **Menu items** - Profile navigates and closes; on /profile it is highlighted, closes with no
|
||||
navigation; Log out calls POST /v1/auth/logout, clears the session and goes to /login.
|
||||
21. **Chip fill** - sand on /profile with the menu closed; not sand on /home.
|
||||
22. **Theme** - activating "Dark" sets `useColorMode().preference`, closes the menu, and reopening
|
||||
shows "Dark" checked.
|
||||
23. **Initials, Examples-driven** - each row, including "Élodie Martin" giving "ÉM", a single
|
||||
word giving two letters, and username "m.alves" giving "M." (first two characters).
|
||||
24. **Collapsed rail** - avatar only, aria-label "Marta Alves — account menu", same menu.
|
||||
25. **Home greeting, Examples-driven** - no display name gives "marta"; "Marta Alves" gives
|
||||
"Marta Alves"; then save on /profile and navigate to /home and assert the new name. Do not
|
||||
assert the greeting word or the hour.
|
||||
26. **Reset: sending** - unresolved POST; assert body `{"email":"marta@renew.example"}`, "Sending…",
|
||||
disabled, no note.
|
||||
27. **Reset: sent and Send again** - 200 gives "Send again" and the mail-check note with the exact
|
||||
copy; "Send again" hides the note during the request, sends the same body, and on 200 shows it
|
||||
again; remounting resets to idle.
|
||||
28. **Reset: 429, Examples-driven** - reject with `ApiError(429, <retryAfter>)`; assert the warning
|
||||
note copy, disabled button reading "Send reset link"; advance fake timers by the table's
|
||||
seconds minus 1 and assert still disabled, then by 1 and assert enabled with the note gone.
|
||||
Rows: 42, 300, undefined (300), 0 (300).
|
||||
29. **Reset: failed, Examples-driven** - `ApiError(500)` and `NetworkError`; assert the error note
|
||||
copy, enabled button, and that a retry resolving 200 gives the sent note.
|
||||
30. **Users list** - mount UsersPage with session display name "Marta Alves" and the users_screen
|
||||
Default items where marta and ines carry display names; assert the marta row shows
|
||||
"Marta Alves" then "marta" and the "you" tag, ines shows "Inès Duarte" then "ines", jonas
|
||||
shows alone, and the chip reads "Marta Alves".
|
||||
|
||||
## Visual (task visual:check -- profile, and -- home; desktop and mobile, light and dark)
|
||||
|
||||
Reference: `docs/design/screens/profile.md`, board group "Profile" in `docs/design/source/RenewCMS
|
||||
Screens Back-office.dc.html`, exact values in `docs/design/source/AppFrame.dc.html`. Add each
|
||||
state's setup to `web/visual/states.ts` with the board's own sample data. The gate is hard: every
|
||||
capture must be within 10 %. Base session mock: GET /v1/auth/me 200 with id 1, username
|
||||
"marta", email "marta@renew.example", is_verified true, display_name "" (state-specific
|
||||
overrides below).
|
||||
|
||||
- [ ] Account menu: /home with `now: boardNow` and the home Default fixtures, session as above,
|
||||
menu opened by clicking the chip (on mobile: open the drawer, then the account row). Name
|
||||
"marta", initials "MA", email "marta@renew.example". The theme items kept by user decision
|
||||
are not drawn on the board, so the mobile capture is expected to exceed 10 %. The user has
|
||||
approved a per-state `exception` for it, to be added with `approvedBy` only after the
|
||||
real difference is measured and reported to the user with its cause.
|
||||
- [ ] Loading: /profile with GET /v1/auth/me hanging; three skeleton cards, 16 px apart.
|
||||
- [ ] Default, no display name: empty field, "0 / 60", the username footer, Save at 40 %.
|
||||
- [ ] Editing: type "Marta Alves"; field focused; "11 / 60"; "Unsaved changes"; Save enabled.
|
||||
- [ ] Too long: type "Marta Alves, Head of Renovations, Brussels and Wallonia office"; "62 / 60"
|
||||
red; error "Use 60 characters or fewer."; Save at 40 %.
|
||||
- [ ] Saving: type "Marta Alves", press Save with PATCH hanging; disabled dimmed field, "Saving…".
|
||||
- [ ] Saved: PATCH 200 with display_name "Marta Alves"; chip "Marta Alves" "MA"; toast "Display name
|
||||
saved" / "You now appear as Marta Alves across RenewCMS and in emails."
|
||||
- [ ] Save failed: PATCH 500 after typing "Marta Alves"; the error alert in the card; "Unsaved changes".
|
||||
- [ ] Reset: sending: session display_name "Marta Alves"; POST /v1/auth/password-reset hanging;
|
||||
"Sending…".
|
||||
- [ ] Reset: sent: same session; POST 200; "Send again" and the "Reset link sent" note.
|
||||
- [ ] Reset: too many requests (429): POST 429 with header `Retry-After: 300`; disabled button at
|
||||
40 % and the "Too many requests" note.
|
||||
- [ ] Reset: failed: POST 500; the "Couldn't send the link" note.
|
||||
- [ ] Display names in the Users list: /users, session display_name "Marta Alves"; GET /v1/user/
|
||||
with the five board rows (sam.oliveira@renew.example pending 27 Sep 2026, p.huber@gmail.com
|
||||
expired 3 Aug 2026, ines "Inès Duarte" 18 Jun 2026, jonas 2 Apr 2026, marta "Marta Alves"
|
||||
12 Mar 2026 with "you"). Clock-sensitive rendering is skipped per repo rules.
|
||||
- [ ] `task visual:check -- home` still PASSes for Default, Loading, Empty and Error. The session
|
||||
fixture has display_name "", so the greeting name is "marta". Extend the fixture with
|
||||
`email` and `display_name` (it must tolerate them being absent).
|
||||
- [ ] Layout from the board frame, not components.md: content column 720 px max; cards separated by
|
||||
20 px; the footer strip has the status text left and Save right (32 px high on desktop, 44 px
|
||||
on mobile); the account inputs are two columns on desktop and stacked on mobile; the
|
||||
password input and button are a row (button aligned to the bottom edge) on desktop and
|
||||
stacked, full width, on mobile.
|
||||
- [ ] Not covered by screenshots: keyboard and focus order (Tab reaches the field, Save, "Send
|
||||
reset link"; Escape returns focus to the chip; focus returns to the field after a failed
|
||||
save), hover and active states, motion (popover and sheet, skeleton pulse 1.6 s, both
|
||||
stopped under reduced motion).
|
||||
- [ ] Not drawn, no visual state: the leave-page dialog, the "Display name removed" toast, the
|
||||
400 variants. Vitest only (cases 13, 15, 16).
|
||||
|
||||
## Manual verification
|
||||
|
||||
- **Real round trips against `task dev`.** Vitest only mocks `useApi`. Save a display name and
|
||||
confirm the chip, menu, Home greeting and Users list show it after a reload; clear it and
|
||||
confirm the username returns; confirm login still works only with the username.
|
||||
- **Unicode limit.** Paste 60 and 61 emoji and a combining-mark name; the client counter and
|
||||
the API (`utf8.RuneCountInString`) agree at the boundary.
|
||||
- **Real reset email** (needs real or sandboxed SMTP, `SMTP_HOST` and related vars). "Send reset
|
||||
link" delivers a mail to the account's own address, the link ends on the reset-confirm screen,
|
||||
and a completed reset invalidates the current session (`token_version`). "Send again"
|
||||
invalidates the older link.
|
||||
- **Real 429.** Exceed `AUTH_RATE_LIMIT_MAX_ATTEMPTS` within `AUTH_RATE_LIMIT_WINDOW_SECONDS` and
|
||||
confirm the button re-enables when the real Retry-After elapses. The limiter budget is shared
|
||||
with login.
|
||||
- **API quirk, out of scope.** POST /v1/auth/password-reset returns 200 even if the mail cannot be
|
||||
sent (the handler discards the use-case error), so with SMTP down the page still shows "Reset
|
||||
link sent". Reported, not fixed here.
|
||||
- **Mobile sheet and toasts on a real device.** The drawer-to-sheet handoff, scrim, safe-area
|
||||
padding, and the toast placement (bottom center on mobile).
|
||||
- **Theme persistence.** The choice made from the menu survives a reload.
|
||||
@@ -0,0 +1,150 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { ColorModePreference } from '@/composables/useColorMode'
|
||||
import { colorModeOptions, useAccountMenu } from '@/composables/useAccountMenu'
|
||||
|
||||
const ITEM_SELECTOR = '[role="menuitem"], [role="menuitemcheckbox"]'
|
||||
const ITEM_CLASS = 'h-12 justify-start gap-3 px-4 text-[0.9375rem] text-highlighted'
|
||||
|
||||
const props = defineProps<{
|
||||
username: string
|
||||
displayName?: string
|
||||
email?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
logout: []
|
||||
}>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const {
|
||||
colorMode,
|
||||
name,
|
||||
initials,
|
||||
onProfile,
|
||||
goToProfile: navigateToProfile,
|
||||
} = useAccountMenu(() => props)
|
||||
const menu = ref<HTMLElement | null>(null)
|
||||
|
||||
function goToProfile() {
|
||||
open.value = false
|
||||
navigateToProfile()
|
||||
}
|
||||
|
||||
function chooseColorMode(value: ColorModePreference) {
|
||||
colorMode.preference = value
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function logOut() {
|
||||
open.value = false
|
||||
emit('logout')
|
||||
}
|
||||
|
||||
function moveFocus(event: KeyboardEvent) {
|
||||
const items = Array.from(menu.value?.querySelectorAll<HTMLElement>(ITEM_SELECTOR) ?? [])
|
||||
if (items.length === 0) {
|
||||
return
|
||||
}
|
||||
const current = items.indexOf(document.activeElement as HTMLElement)
|
||||
const lastIndex = items.length - 1
|
||||
const targets: Record<string, number> = {
|
||||
ArrowDown: current >= lastIndex ? 0 : current + 1,
|
||||
ArrowUp: current <= 0 ? lastIndex : current - 1,
|
||||
Home: 0,
|
||||
End: lastIndex,
|
||||
}
|
||||
const target = targets[event.key]
|
||||
if (target === undefined) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
items[target]?.focus()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<USlideover
|
||||
v-model:open="open"
|
||||
side="bottom"
|
||||
title="Account"
|
||||
description="Your account menu"
|
||||
:close="false"
|
||||
:ui="{
|
||||
content: 'max-h-none',
|
||||
body: 'p-0 sm:p-0',
|
||||
header: 'sr-only',
|
||||
}"
|
||||
>
|
||||
<template #body>
|
||||
<div ref="menu" role="menu" aria-label="Account" class="pt-2 pb-6" @keydown="moveFocus">
|
||||
<div class="mx-auto mb-2 h-1 w-9 bg-accented" aria-hidden="true" />
|
||||
<div class="mb-1 flex items-center gap-3 border-b border-default px-4 pt-2 pb-3.5">
|
||||
<UAvatar
|
||||
aria-hidden="true"
|
||||
:text="initials"
|
||||
:ui="{
|
||||
root: 'size-9 bg-secondary-300',
|
||||
fallback: 'text-[0.8125rem] font-semibold text-primary-900',
|
||||
}"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<span class="truncate text-[0.9375rem] font-semibold text-highlighted">{{
|
||||
name
|
||||
}}</span>
|
||||
<span class="truncate text-muted">{{ email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
role="menuitem"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
block
|
||||
icon="i-lucide-circle-user"
|
||||
:class="[ITEM_CLASS, onProfile ? 'bg-elevated' : '']"
|
||||
:ui="{ leadingIcon: 'size-4.5' }"
|
||||
@click="goToProfile"
|
||||
>
|
||||
Profile
|
||||
</UButton>
|
||||
|
||||
<USeparator class="my-1" />
|
||||
|
||||
<UButton
|
||||
v-for="option in colorModeOptions"
|
||||
:key="option.value"
|
||||
role="menuitemcheckbox"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
block
|
||||
:aria-checked="colorMode.preference === option.value"
|
||||
:trailing-icon="
|
||||
colorMode.preference === option.value ? 'i-lucide-check' : undefined
|
||||
"
|
||||
:class="ITEM_CLASS"
|
||||
:ui="{ trailingIcon: 'ms-auto size-4.5' }"
|
||||
@click="chooseColorMode(option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</UButton>
|
||||
|
||||
<USeparator class="my-1" />
|
||||
|
||||
<UButton
|
||||
role="menuitem"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
block
|
||||
icon="i-lucide-log-out"
|
||||
:class="ITEM_CLASS"
|
||||
:ui="{ leadingIcon: 'size-4.5' }"
|
||||
@click="logOut"
|
||||
>
|
||||
Log out
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</USlideover>
|
||||
</template>
|
||||
@@ -7,7 +7,7 @@ const props = withDefaults(
|
||||
max: number
|
||||
min?: number
|
||||
warnAt?: number
|
||||
variant?: 'inline' | 'footer'
|
||||
variant?: 'inline' | 'footer' | 'field'
|
||||
plainText?: boolean
|
||||
}>(),
|
||||
{
|
||||
@@ -113,6 +113,12 @@ const isErrorStatus = computed(() => status.value === 'over' || status.value ===
|
||||
</span>
|
||||
<span aria-live="polite" class="sr-only">{{ announcement }}</span>
|
||||
</div>
|
||||
<span
|
||||
v-else-if="variant === 'field'"
|
||||
class="font-mono text-xs leading-tight"
|
||||
:class="overBy > 0 ? 'text-error' : 'text-muted'"
|
||||
>{{ formatThousands(value) }} / {{ formatThousands(max) }}</span
|
||||
>
|
||||
<div v-else class="flex flex-col gap-1">
|
||||
<div
|
||||
class="flex items-center gap-1.5 font-mono text-xs"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useSessionStore } from '@/stores/session'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useSidebarCollapsed } from '@/composables/useSidebarCollapsed'
|
||||
import AppBoot from '@/components/AppBoot.vue'
|
||||
import AccountMenuSheet from '@/components/AccountMenuSheet.vue'
|
||||
import AppLogo from '@/components/AppLogo.vue'
|
||||
import ErrorView from '@/components/ErrorView.vue'
|
||||
import OfflineBanner from '@/components/OfflineBanner.vue'
|
||||
@@ -14,10 +15,12 @@ const props = withDefaults(
|
||||
defineProps<{
|
||||
mobileTitle?: string
|
||||
fullBleed?: boolean
|
||||
renderWhileChecking?: boolean
|
||||
}>(),
|
||||
{
|
||||
mobileTitle: undefined,
|
||||
fullBleed: false,
|
||||
renderWhileChecking: false,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -63,6 +66,15 @@ const navUi = computed(() => ({
|
||||
linkLeadingIcon: 'size-4',
|
||||
}))
|
||||
const loggingOut = ref(false)
|
||||
const sidebarOpen = ref(false)
|
||||
const accountSheetOpen = ref(false)
|
||||
|
||||
function openAccountSheet() {
|
||||
sidebarOpen.value = false
|
||||
accountSheetOpen.value = true
|
||||
}
|
||||
|
||||
const showBoot = computed(() => session.status === 'checking' && !props.renderWhileChecking)
|
||||
|
||||
function onRetry() {
|
||||
session.retry()
|
||||
@@ -82,11 +94,10 @@ async function onLogout() {
|
||||
|
||||
<template>
|
||||
<UDashboardGroup unit="rem">
|
||||
<a href="#dashboard-content" class="sr-only focus:not-sr-only"
|
||||
>Skip to content</a
|
||||
>
|
||||
<a href="#dashboard-content" class="sr-only focus:not-sr-only">Skip to content</a>
|
||||
|
||||
<UDashboardSidebar
|
||||
v-model:open="sidebarOpen"
|
||||
v-model:collapsed="sidebarCollapsed"
|
||||
collapsible
|
||||
:resizable="false"
|
||||
@@ -115,8 +126,11 @@ async function onLogout() {
|
||||
<template #footer>
|
||||
<UserMenu
|
||||
:username="session.session?.username ?? ''"
|
||||
:display-name="session.session?.displayName"
|
||||
:email="session.session?.email"
|
||||
:collapsed="sidebarCollapsed"
|
||||
@logout="onLogout"
|
||||
@open-sheet="openAccountSheet"
|
||||
/>
|
||||
</template>
|
||||
</UDashboardSidebar>
|
||||
@@ -164,26 +178,29 @@ async function onLogout() {
|
||||
<template #body>
|
||||
<div
|
||||
id="dashboard-content"
|
||||
:class="
|
||||
fullBleed ? 'flex h-full min-h-0 flex-1 flex-col' : ''
|
||||
"
|
||||
:class="fullBleed ? 'flex h-full min-h-0 flex-1 flex-col' : ''"
|
||||
>
|
||||
<OfflineBanner />
|
||||
|
||||
<AppBoot v-if="session.status === 'checking'" />
|
||||
<AppBoot v-if="showBoot" />
|
||||
<ErrorView
|
||||
v-else-if="session.status === 'error'"
|
||||
kind="error"
|
||||
@retry="onRetry"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
:class="fullBleed ? 'flex min-h-0 flex-1 flex-col' : ''"
|
||||
>
|
||||
<div v-else :class="fullBleed ? 'flex min-h-0 flex-1 flex-col' : ''">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UDashboardPanel>
|
||||
|
||||
<AccountMenuSheet
|
||||
v-model:open="accountSheetOpen"
|
||||
:username="session.session?.username ?? ''"
|
||||
:display-name="session.session?.displayName"
|
||||
:email="session.session?.email"
|
||||
@logout="onLogout"
|
||||
/>
|
||||
</UDashboardGroup>
|
||||
</template>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string
|
||||
description: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="border border-default bg-default">
|
||||
<div class="flex flex-col gap-3.5 p-4 lg:p-5">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<h2 class="text-base/5 font-semibold text-highlighted">{{ title }}</h2>
|
||||
<p class="text-pretty text-muted">{{ description }}</p>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
<div
|
||||
v-if="$slots.footer"
|
||||
class="flex items-center gap-3 border-t border-default bg-muted px-4 py-3 lg:px-5"
|
||||
>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,43 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
useColorMode,
|
||||
type ColorModePreference,
|
||||
} from '@/composables/useColorMode'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import { colorModeOptions, useAccountMenu } from '@/composables/useAccountMenu'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 64rem)'
|
||||
|
||||
const props = defineProps<{
|
||||
username: string
|
||||
displayName?: string
|
||||
email?: string
|
||||
collapsed: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
logout: []
|
||||
'open-sheet': []
|
||||
}>()
|
||||
|
||||
const colorMode = useColorMode()
|
||||
const { colorMode, name, initials, onProfile, goToProfile } = useAccountMenu(() => props)
|
||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||
const open = ref(false)
|
||||
|
||||
const initials = computed(() => {
|
||||
const parts = props.username.split(/[^a-zA-Z0-9]+/).filter(Boolean)
|
||||
if (parts.length >= 2) {
|
||||
return (
|
||||
(parts[0] ?? '').charAt(0) + (parts[1] ?? '').charAt(0)
|
||||
).toUpperCase()
|
||||
}
|
||||
return (parts[0] ?? '').slice(0, 2).toUpperCase()
|
||||
})
|
||||
const chipActive = computed(() => open.value || onProfile.value)
|
||||
|
||||
const triggerAriaLabel = computed(() =>
|
||||
props.collapsed ? `${props.username} — account menu` : undefined,
|
||||
props.collapsed ? `${name.value} — account menu` : undefined,
|
||||
)
|
||||
|
||||
const colorModeOptions: { label: string; value: ColorModePreference }[] = [
|
||||
{ label: 'System', value: 'system' },
|
||||
{ label: 'Light', value: 'light' },
|
||||
{ label: 'Dark', value: 'dark' },
|
||||
]
|
||||
const chipClass = computed(() => [
|
||||
'h-10 gap-2.5 px-1.5 font-normal',
|
||||
props.collapsed ? 'justify-center' : 'justify-start',
|
||||
chipActive.value
|
||||
? 'bg-secondary-200 text-primary-900 hover:bg-secondary-200 hover:text-primary-900 dark:bg-secondary-300 dark:hover:bg-secondary-300'
|
||||
: 'text-highlighted',
|
||||
])
|
||||
|
||||
function onUpdateOpen(value: boolean) {
|
||||
if (isDesktop.value) {
|
||||
open.value = value
|
||||
return
|
||||
}
|
||||
if (value) {
|
||||
emit('open-sheet')
|
||||
}
|
||||
}
|
||||
|
||||
const items = computed(() => [
|
||||
[{ type: 'label' as const, label: props.username }],
|
||||
[
|
||||
{
|
||||
type: 'label' as const,
|
||||
label: name.value,
|
||||
slot: 'account-header' as const,
|
||||
class: 'mb-1 border-b border-default px-2 pt-2 pb-2.5',
|
||||
},
|
||||
{
|
||||
label: 'Profile',
|
||||
icon: 'i-lucide-circle-user',
|
||||
class: onProfile.value ? 'bg-elevated' : undefined,
|
||||
onSelect: goToProfile,
|
||||
},
|
||||
],
|
||||
colorModeOptions.map((option) => ({
|
||||
type: 'checkbox' as const,
|
||||
label: option.label,
|
||||
@@ -51,6 +73,7 @@ const items = computed(() => [
|
||||
[
|
||||
{
|
||||
label: 'Log out',
|
||||
icon: 'i-lucide-log-out',
|
||||
onSelect: () => emit('logout'),
|
||||
},
|
||||
],
|
||||
@@ -58,17 +81,21 @@ const items = computed(() => [
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDropdownMenu :items="items" :content="{ side: 'top', align: 'start' }">
|
||||
<UDropdownMenu
|
||||
:open="open"
|
||||
:items="items"
|
||||
:content="{ side: 'top', align: 'start' }"
|
||||
:ui="{ content: 'w-56 p-1' }"
|
||||
@update:open="onUpdateOpen"
|
||||
>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
block
|
||||
:class="[
|
||||
'h-10 gap-2.5 px-1.5 font-normal text-highlighted',
|
||||
collapsed ? 'justify-center' : 'justify-start',
|
||||
]"
|
||||
title="Account"
|
||||
:class="chipClass"
|
||||
:trailing-icon="collapsed ? undefined : 'i-lucide-chevrons-up-down'"
|
||||
:ui="{ trailingIcon: 'ms-auto size-3.5 text-default' }"
|
||||
:ui="{ trailingIcon: 'ms-auto size-3.5 text-current' }"
|
||||
:aria-label="triggerAriaLabel"
|
||||
>
|
||||
<template #leading>
|
||||
@@ -78,12 +105,18 @@ const items = computed(() => [
|
||||
size="sm"
|
||||
:ui="{
|
||||
root: 'size-6.5 bg-secondary-300',
|
||||
fallback:
|
||||
'text-[0.6875rem] font-semibold text-primary-900',
|
||||
fallback: 'text-[0.6875rem] font-semibold text-primary-900',
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
<span v-if="!collapsed" class="truncate">{{ username }}</span>
|
||||
<span v-if="!collapsed" class="truncate font-medium">{{ name }}</span>
|
||||
</UButton>
|
||||
|
||||
<template #account-header>
|
||||
<span class="flex min-w-0 flex-col gap-0.5">
|
||||
<span class="truncate text-sm font-semibold text-highlighted">{{ name }}</span>
|
||||
<span class="truncate text-xs font-normal text-muted">{{ email }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</UDropdownMenu>
|
||||
</template>
|
||||
@@ -15,16 +15,8 @@ describe('Below the warning threshold', () => {
|
||||
const wrapper = mountCounter(4318)
|
||||
|
||||
expect(wrapper.text()).toContain('4 318 / 10 000')
|
||||
expect(
|
||||
wrapper
|
||||
.find('[data-testid="character-counter-warning-icon"]')
|
||||
.exists(),
|
||||
).toBe(false)
|
||||
expect(
|
||||
wrapper
|
||||
.find('[data-testid="character-counter-over-icon"]')
|
||||
.exists(),
|
||||
).toBe(false)
|
||||
expect(wrapper.find('[data-testid="character-counter-warning-icon"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-testid="character-counter-over-icon"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,11 +25,7 @@ describe('Near the limit — 90% or more', () => {
|
||||
const wrapper = mountCounter(9240)
|
||||
|
||||
expect(wrapper.text()).toContain('9 240 / 10 000')
|
||||
expect(
|
||||
wrapper
|
||||
.find('[data-testid="character-counter-warning-icon"]')
|
||||
.exists(),
|
||||
).toBe(true)
|
||||
expect(wrapper.find('[data-testid="character-counter-warning-icon"]').exists()).toBe(true)
|
||||
expect(wrapper.text()).not.toContain('characters over')
|
||||
})
|
||||
})
|
||||
@@ -47,13 +35,31 @@ describe('Too long — over the max', () => {
|
||||
const wrapper = mountCounter(10214)
|
||||
|
||||
expect(wrapper.text()).toContain('10 214 / 10 000')
|
||||
expect(
|
||||
wrapper
|
||||
.find('[data-testid="character-counter-over-icon"]')
|
||||
.exists(),
|
||||
).toBe(true)
|
||||
expect(wrapper.find('[data-testid="character-counter-over-icon"]').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain(
|
||||
'214 characters over. Shorten the text or remove an image. Formatting counts too.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('The field variant is a bare mono count', () => {
|
||||
function mountField(value: number) {
|
||||
return mount(CharacterCounter, {
|
||||
props: { value, max: 60, variant: 'field' },
|
||||
global: { plugins: [uiPlugin] },
|
||||
})
|
||||
}
|
||||
|
||||
it.each([
|
||||
[0, 'text-muted'],
|
||||
[60, 'text-muted'],
|
||||
[61, 'text-error'],
|
||||
])('shows %i / 60 with the %s color and no icon or message', (value, colorClass) => {
|
||||
const wrapper = mountField(value)
|
||||
|
||||
expect(wrapper.text()).toBe(`${value} / 60`)
|
||||
expect(wrapper.classes()).toContain(colorClass)
|
||||
expect(wrapper.classes()).toContain('font-mono')
|
||||
expect(wrapper.find('svg').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,11 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import router from '@/router'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import DashboardShell from '../DashboardShell.vue'
|
||||
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from './viewport'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
@@ -14,10 +15,27 @@ vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
async function mountShell(path: string, username = 'marta') {
|
||||
interface ShellOptions {
|
||||
username?: string
|
||||
displayName?: string
|
||||
email?: string
|
||||
width?: number
|
||||
}
|
||||
|
||||
let mountedShell: VueWrapper | null = null
|
||||
|
||||
async function mountShell(path: string, options: ShellOptions = {}) {
|
||||
const {
|
||||
username = 'marta',
|
||||
displayName = '',
|
||||
email = 'marta@renew.example',
|
||||
width = DESKTOP_WIDTH,
|
||||
} = options
|
||||
localStorage.clear()
|
||||
setViewportWidth(width)
|
||||
setActivePinia(createPinia())
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn().mockResolvedValue({ verified: true, username }),
|
||||
getMe: vi.fn().mockResolvedValue({ verified: true, username, email, displayName }),
|
||||
request: vi.fn().mockResolvedValue(undefined),
|
||||
})
|
||||
await router.push(path)
|
||||
@@ -27,11 +45,14 @@ async function mountShell(path: string, username = 'marta') {
|
||||
plugins: [router, uiPlugin],
|
||||
},
|
||||
})
|
||||
mountedShell = wrapper
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
mountedShell?.unmount()
|
||||
mountedShell = null
|
||||
await router.push('/login')
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
@@ -166,3 +187,112 @@ describe('Collapsed sidebar hides the wordmark and username text but keeps an ac
|
||||
expect(footer.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
|
||||
})
|
||||
})
|
||||
|
||||
describe('The account chip shows the display name and no item is current on /profile', () => {
|
||||
it('shows the display name and its initials in the sidebar chip', async () => {
|
||||
await mountShell('/home', { displayName: 'Marta Alves' })
|
||||
|
||||
const footer = document.body.querySelector('[data-slot="footer"]') as HTMLElement
|
||||
expect(footer.textContent).toContain('Marta Alves')
|
||||
expect(footer.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
|
||||
})
|
||||
|
||||
it('keeps the collapsed accessible name on the display name', async () => {
|
||||
await mountShell('/home', { displayName: 'Marta Alves' })
|
||||
|
||||
const collapseButton = document.body.querySelector(
|
||||
'[data-testid="sidebar-collapse"]',
|
||||
) as HTMLButtonElement
|
||||
collapseButton.click()
|
||||
await flushPromises()
|
||||
|
||||
const trigger = document.body.querySelector('[data-slot="footer"] button[aria-haspopup]')
|
||||
expect(trigger?.getAttribute('aria-label')).toBe('Marta Alves — account menu')
|
||||
})
|
||||
|
||||
it('marks none of the four navigation items current on /profile', async () => {
|
||||
const wrapper = await mountShell('/profile')
|
||||
|
||||
const links = wrapper.findAll('nav[aria-label="Main"] a')
|
||||
expect(links).toHaveLength(4)
|
||||
links.forEach((link) => expect(link.attributes('aria-current')).toBeUndefined())
|
||||
})
|
||||
})
|
||||
|
||||
describe('Account menu: opened from the drawer on mobile', () => {
|
||||
it('closes the drawer and opens a bottom sheet with a grab handle, the header and 48 px items', async () => {
|
||||
await mountShell('/home', { width: MOBILE_WIDTH })
|
||||
|
||||
const toggle = document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement
|
||||
toggle.click()
|
||||
await flushPromises()
|
||||
|
||||
const accountRow = document.body.querySelector(
|
||||
'[role="dialog"] button[aria-haspopup]',
|
||||
) as HTMLButtonElement
|
||||
expect(accountRow).not.toBeNull()
|
||||
accountRow.click()
|
||||
await flushPromises()
|
||||
|
||||
const sheets = Array.from(document.body.querySelectorAll('[role="dialog"]'))
|
||||
expect(sheets).toHaveLength(1)
|
||||
const menu = document.body.querySelector('[role="menu"]') as HTMLElement
|
||||
expect(
|
||||
menu.querySelector('[aria-hidden="true"].w-9.h-1, [aria-hidden="true"].h-1'),
|
||||
).not.toBeNull()
|
||||
expect(menu.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
|
||||
expect(menu.textContent).toContain('marta@renew.example')
|
||||
const labels = Array.from(
|
||||
menu.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]'),
|
||||
).map((item) => item.textContent?.trim())
|
||||
expect(labels).toEqual(['Profile', 'System', 'Light', 'Dark', 'Log out'])
|
||||
menu.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]').forEach((item) =>
|
||||
expect(item.className).toContain('h-12'),
|
||||
)
|
||||
})
|
||||
|
||||
it('navigates to /profile and closes the sheet from Profile', async () => {
|
||||
await mountShell('/home', { width: MOBILE_WIDTH })
|
||||
;(document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement).click()
|
||||
await flushPromises()
|
||||
;(
|
||||
document.body.querySelector(
|
||||
'[role="dialog"] button[aria-haspopup]',
|
||||
) as HTMLButtonElement
|
||||
).click()
|
||||
await flushPromises()
|
||||
|
||||
const profile = Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('[role="menu"] [role="menuitem"]'),
|
||||
).find((item) => item.textContent?.trim() === 'Profile')!
|
||||
profile.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/profile')
|
||||
expect(document.body.querySelector('[role="menu"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('logs out from the sheet', async () => {
|
||||
await mountShell('/home', { width: MOBILE_WIDTH })
|
||||
;(document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement).click()
|
||||
await flushPromises()
|
||||
;(
|
||||
document.body.querySelector(
|
||||
'[role="dialog"] button[aria-haspopup]',
|
||||
) as HTMLButtonElement
|
||||
).click()
|
||||
await flushPromises()
|
||||
|
||||
const logOut = Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('[role="menu"] [role="menuitem"]'),
|
||||
).find((item) => item.textContent?.trim() === 'Log out')!
|
||||
logOut.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(vi.mocked(useApi)().request).toHaveBeenCalledWith(
|
||||
'/v1/auth/logout',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
)
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
})
|
||||
})
|
||||
@@ -1,175 +1,304 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import router from '@/router'
|
||||
import UserMenu from '../UserMenu.vue'
|
||||
import { useColorMode } from '@/composables/useColorMode'
|
||||
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from './viewport'
|
||||
|
||||
function mountMenu(props: { username: string; collapsed: boolean }) {
|
||||
interface MenuProps {
|
||||
username: string
|
||||
displayName?: string
|
||||
email?: string
|
||||
collapsed: boolean
|
||||
}
|
||||
|
||||
const baseProps: MenuProps = {
|
||||
username: 'marta',
|
||||
displayName: '',
|
||||
email: 'marta@renew.example',
|
||||
collapsed: false,
|
||||
}
|
||||
|
||||
async function mountMenu(props: Partial<MenuProps> = {}, path = '/home') {
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta' }
|
||||
session.status = 'ready'
|
||||
await router.push(path)
|
||||
return mount(UserMenu, {
|
||||
attachTo: document.body,
|
||||
props,
|
||||
global: {
|
||||
plugins: [uiPlugin],
|
||||
},
|
||||
props: { ...baseProps, ...props },
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
}
|
||||
|
||||
function findTriggerButton() {
|
||||
return document.body.querySelector('button')
|
||||
function chip() {
|
||||
return document.body.querySelector('button[aria-haspopup]') as HTMLButtonElement
|
||||
}
|
||||
|
||||
async function openMenu() {
|
||||
findTriggerButton()?.click()
|
||||
chip().click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function findMenuItemsByRole(role: string) {
|
||||
return Array.from(document.body.querySelectorAll(`[role="${role}"]`))
|
||||
function menu() {
|
||||
return document.body.querySelector('[role="menu"]') as HTMLElement | null
|
||||
}
|
||||
|
||||
function findCheckboxItem(label: string) {
|
||||
return findMenuItemsByRole('menuitemcheckbox').find(
|
||||
(el) => el.textContent?.trim() === label,
|
||||
)
|
||||
function itemsIn(container: ParentNode) {
|
||||
return Array.from(
|
||||
container.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]'),
|
||||
) as HTMLElement[]
|
||||
}
|
||||
|
||||
function itemLabels() {
|
||||
return itemsIn(menu()!).map((el) => el.textContent?.trim())
|
||||
}
|
||||
|
||||
function findItem(label: string) {
|
||||
return itemsIn(menu()!).find((el) => el.textContent?.trim() === label) as HTMLElement
|
||||
}
|
||||
|
||||
function activate(element: HTMLElement) {
|
||||
element.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
|
||||
element.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
document.documentElement.classList.remove('dark', 'light')
|
||||
setViewportWidth(DESKTOP_WIDTH)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
document.body.innerHTML = ''
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
describe("UserMenu's dropdown lists the account label, color-mode checkboxes, then Log out", () => {
|
||||
it('shows avatar initials MA, the username, and the three ordered groups', async () => {
|
||||
const wrapper = mountMenu({ username: 'marta', collapsed: false })
|
||||
describe('The chip opens the account menu instead of logging out', () => {
|
||||
it('exposes aria-haspopup, aria-expanded, the title and the name, and opens on activation', async () => {
|
||||
const wrapper = await mountMenu()
|
||||
|
||||
const avatar = document.body.querySelector('[data-slot="fallback"]')
|
||||
expect(avatar?.textContent).toBe('MA')
|
||||
expect(avatar?.getAttribute('aria-hidden')).toBe('true')
|
||||
expect(wrapper.text()).toContain('marta')
|
||||
expect(chip().getAttribute('aria-haspopup')).toBe('menu')
|
||||
expect(chip().getAttribute('aria-expanded')).toBe('false')
|
||||
expect(chip().getAttribute('title')).toBe('Account')
|
||||
expect(chip().textContent).toContain('marta')
|
||||
expect(wrapper.emitted('logout')).toBeUndefined()
|
||||
|
||||
await openMenu()
|
||||
|
||||
const groups = findMenuItemsByRole('group')
|
||||
expect(groups.length).toBe(3)
|
||||
|
||||
expect(groups[0].textContent).toContain('marta')
|
||||
|
||||
const checkboxLabels = findMenuItemsByRole('menuitemcheckbox').map(
|
||||
(el) => el.textContent?.trim(),
|
||||
)
|
||||
expect(checkboxLabels).toEqual(['System', 'Light', 'Dark'])
|
||||
|
||||
expect(groups[2].textContent?.trim()).toBe('Log out')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('derives initials from the first letter of the first two words for a multi-word username', () => {
|
||||
const wrapper = mountMenu({ username: 'jane doe', collapsed: false })
|
||||
|
||||
const avatar = document.body.querySelector('[data-slot="fallback"]')
|
||||
expect(avatar?.textContent).toBe('JD')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('checks exactly the item matching the current color-mode preference', async () => {
|
||||
const colorMode = useColorMode()
|
||||
colorMode.preference = 'dark'
|
||||
|
||||
const wrapper = mountMenu({ username: 'marta', collapsed: false })
|
||||
await openMenu()
|
||||
|
||||
expect(findCheckboxItem('Dark')?.getAttribute('aria-checked')).toBe(
|
||||
'true',
|
||||
)
|
||||
expect(findCheckboxItem('Light')?.getAttribute('aria-checked')).toBe(
|
||||
'false',
|
||||
)
|
||||
expect(findCheckboxItem('System')?.getAttribute('aria-checked')).toBe(
|
||||
'false',
|
||||
)
|
||||
|
||||
wrapper.unmount()
|
||||
expect(chip().getAttribute('aria-expanded')).toBe('true')
|
||||
expect(menu()).not.toBeNull()
|
||||
expect(wrapper.emitted('logout')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Selecting a color-mode item updates the preference and its checked state', () => {
|
||||
it.each([
|
||||
['system', 'light'],
|
||||
['light', 'dark'],
|
||||
['dark', 'system'],
|
||||
])('moves from %s to %s', async (from, to) => {
|
||||
const colorMode = useColorMode()
|
||||
colorMode.preference = from as 'system' | 'light' | 'dark'
|
||||
|
||||
const wrapper = mountMenu({ username: 'marta', collapsed: false })
|
||||
describe('Account menu: opened on desktop', () => {
|
||||
it('lists the header, Profile, System, Light, Dark and Log out with a divider between its three groups', async () => {
|
||||
await mountMenu()
|
||||
await openMenu()
|
||||
|
||||
const label = to.charAt(0).toUpperCase() + to.slice(1)
|
||||
const fromLabel = from.charAt(0).toUpperCase() + from.slice(1)
|
||||
|
||||
findCheckboxItem(label)?.dispatchEvent(
|
||||
new MouseEvent('pointerup', { bubbles: true }),
|
||||
)
|
||||
findCheckboxItem(label)?.dispatchEvent(
|
||||
new MouseEvent('click', { bubbles: true }),
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
expect(colorMode.preference).toBe(to)
|
||||
|
||||
await openMenu()
|
||||
expect(findCheckboxItem(label)?.getAttribute('aria-checked')).toBe(
|
||||
'true',
|
||||
)
|
||||
expect(findCheckboxItem(fromLabel)?.getAttribute('aria-checked')).toBe(
|
||||
'false',
|
||||
)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('A successful logout clears the session and navigates to /login', () => {
|
||||
it('emits logout when the user activates Log out', async () => {
|
||||
const wrapper = mountMenu({ username: 'marta', collapsed: false })
|
||||
await openMenu()
|
||||
|
||||
const logOutItem = Array.from(
|
||||
document.body.querySelectorAll('[role="menuitem"]'),
|
||||
).find((el) => el.textContent?.trim() === 'Log out')
|
||||
;(logOutItem as HTMLElement)?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('logout')).toBeTruthy()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Collapsed sidebar hides the wordmark and username text but keeps an accessible name', () => {
|
||||
it('hides the visible username but keeps it in the accessible name, and opens the same dropdown', async () => {
|
||||
const wrapper = mountMenu({ username: 'marta', collapsed: true })
|
||||
|
||||
expect(wrapper.text()).not.toContain('marta')
|
||||
expect(findTriggerButton()?.getAttribute('aria-label')).toBe(
|
||||
'marta — account menu',
|
||||
)
|
||||
const header = menu()!.querySelector('[role="group"]')!
|
||||
expect(header.textContent).toContain('marta')
|
||||
expect(header.textContent).toContain('marta@renew.example')
|
||||
expect(itemLabels()).toEqual(['Profile', 'System', 'Light', 'Dark', 'Log out'])
|
||||
expect(
|
||||
document.body.querySelector('[data-slot="fallback"]')?.textContent,
|
||||
).toBe('MA')
|
||||
menu()!.querySelector('[data-slot="viewport"]')!.classList.contains('divide-y'),
|
||||
).toBe(true)
|
||||
expect(menu()!.querySelectorAll('[role="group"]')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('shows the display name in the header and the chip when there is one', async () => {
|
||||
await mountMenu({ displayName: 'Marta Alves' })
|
||||
await openMenu()
|
||||
|
||||
expect(chip().textContent).toContain('Marta Alves')
|
||||
expect(menu()!.textContent).toContain('Marta Alves')
|
||||
expect(menu()!.textContent).toContain('marta@renew.example')
|
||||
})
|
||||
|
||||
it('does not highlight Profile away from /profile', async () => {
|
||||
await mountMenu()
|
||||
await openMenu()
|
||||
|
||||
expect(findItem('Profile').classList.contains('bg-elevated')).toBe(false)
|
||||
})
|
||||
|
||||
it('gives the chip the sand fill only while the menu is open', async () => {
|
||||
await mountMenu()
|
||||
|
||||
expect(chip().className).not.toContain('bg-secondary-200')
|
||||
|
||||
await openMenu()
|
||||
|
||||
const groups = findMenuItemsByRole('group')
|
||||
expect(groups.length).toBe(3)
|
||||
expect(groups[0].textContent).toContain('marta')
|
||||
expect(chip().className).toContain('bg-secondary-200')
|
||||
expect(chip().className).toContain('text-primary-900')
|
||||
})
|
||||
})
|
||||
|
||||
wrapper.unmount()
|
||||
describe('The chip is sand while on /profile even with the menu closed', () => {
|
||||
it('keeps the sand fill on /profile and drops it elsewhere', async () => {
|
||||
await mountMenu({}, '/profile')
|
||||
|
||||
expect(chip().className).toContain('bg-secondary-200')
|
||||
expect(chip().getAttribute('aria-expanded')).toBe('false')
|
||||
|
||||
await router.push('/home')
|
||||
await flushPromises()
|
||||
|
||||
expect(chip().className).not.toContain('bg-secondary-200')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Closing the menu', () => {
|
||||
it('closes on Escape and returns focus to the chip', async () => {
|
||||
await mountMenu()
|
||||
await openMenu()
|
||||
|
||||
menu()!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
await flushPromises()
|
||||
|
||||
expect(menu()).toBeNull()
|
||||
expect(chip().getAttribute('aria-expanded')).toBe('false')
|
||||
expect(document.activeElement).toBe(chip())
|
||||
})
|
||||
|
||||
it('closes on a click outside', async () => {
|
||||
await mountMenu()
|
||||
await openMenu()
|
||||
|
||||
document.body.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
|
||||
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
await flushPromises()
|
||||
|
||||
expect(menu()).toBeNull()
|
||||
expect(chip().getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('moves between items with the arrow keys', async () => {
|
||||
await mountMenu()
|
||||
await openMenu()
|
||||
|
||||
const first = itemsIn(menu()!)[0]!
|
||||
first.focus()
|
||||
first.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
|
||||
await flushPromises()
|
||||
|
||||
expect(document.activeElement).toBe(itemsIn(menu()!)[1])
|
||||
|
||||
document.activeElement?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }),
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
expect(document.activeElement).toBe(itemsIn(menu()!)[0])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Menu items', () => {
|
||||
it('navigates to /profile and closes the menu from Profile', async () => {
|
||||
await mountMenu()
|
||||
await openMenu()
|
||||
|
||||
activate(findItem('Profile'))
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/profile')
|
||||
expect(menu()).toBeNull()
|
||||
})
|
||||
|
||||
it('highlights Profile on /profile and only closes when activated there', async () => {
|
||||
await mountMenu({}, '/profile')
|
||||
await openMenu()
|
||||
|
||||
expect(findItem('Profile').classList.contains('bg-elevated')).toBe(true)
|
||||
|
||||
activate(findItem('Profile'))
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/profile')
|
||||
expect(menu()).toBeNull()
|
||||
})
|
||||
|
||||
it('emits logout from Log out', async () => {
|
||||
const wrapper = await mountMenu()
|
||||
await openMenu()
|
||||
|
||||
activate(findItem('Log out'))
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('logout')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Theme choice stays in the menu', () => {
|
||||
it('checks the current preference, sets Dark, closes, and shows Dark checked on reopening', async () => {
|
||||
const colorMode = useColorMode()
|
||||
colorMode.preference = 'system'
|
||||
await mountMenu()
|
||||
await openMenu()
|
||||
|
||||
expect(findItem('System').getAttribute('aria-checked')).toBe('true')
|
||||
|
||||
activate(findItem('Dark'))
|
||||
await flushPromises()
|
||||
|
||||
expect(colorMode.preference).toBe('dark')
|
||||
expect(menu()).toBeNull()
|
||||
|
||||
await openMenu()
|
||||
|
||||
expect(findItem('Dark').getAttribute('aria-checked')).toBe('true')
|
||||
expect(findItem('System').getAttribute('aria-checked')).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Avatar initials', () => {
|
||||
it.each([
|
||||
['marta', '', 'MA', 'marta'],
|
||||
['marta', 'Marta Alves', 'MA', 'Marta Alves'],
|
||||
['marta', 'Marta Alves Costa', 'MA', 'Marta Alves Costa'],
|
||||
['ines', 'Inès Duarte', 'ID', 'Inès Duarte'],
|
||||
['elodie', 'Élodie Martin', 'ÉM', 'Élodie Martin'],
|
||||
['marta', 'Marta', 'MA', 'Marta'],
|
||||
['m.alves', '', 'M.', 'm.alves'],
|
||||
])('shows %s / %j as %s and the name %s', async (username, displayName, initials, shown) => {
|
||||
await mountMenu({ username, displayName })
|
||||
|
||||
expect(document.body.querySelector('[data-slot="fallback"]')?.textContent).toBe(initials)
|
||||
expect(chip().textContent).toContain(shown)
|
||||
|
||||
await openMenu()
|
||||
|
||||
expect(menu()!.textContent).toContain(shown)
|
||||
})
|
||||
})
|
||||
|
||||
describe('The collapsed rail keeps the menu and an accessible name', () => {
|
||||
it('shows only the avatar, names the chip and opens the same menu', async () => {
|
||||
await mountMenu({ displayName: 'Marta Alves', collapsed: true })
|
||||
|
||||
expect(chip().textContent).not.toContain('Marta Alves')
|
||||
expect(chip().getAttribute('aria-label')).toBe('Marta Alves — account menu')
|
||||
expect(document.body.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
|
||||
|
||||
await openMenu()
|
||||
|
||||
expect(itemLabels()).toEqual(['Profile', 'System', 'Light', 'Dark', 'Log out'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Below lg the chip hands the menu to the bottom sheet', () => {
|
||||
it('emits open-sheet instead of opening the popover', async () => {
|
||||
setViewportWidth(MOBILE_WIDTH)
|
||||
const wrapper = await mountMenu()
|
||||
|
||||
await openMenu()
|
||||
|
||||
expect(wrapper.emitted('open-sheet')).toHaveLength(1)
|
||||
expect(menu()).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
const REM_PX = 16
|
||||
|
||||
function widthLimit(query: string, feature: 'min-width' | 'max-width') {
|
||||
const match = query.match(new RegExp(`\\(${feature}:\\s*([\\d.]+)(rem|px)\\)`))
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
return Number(match[1]) * (match[2] === 'rem' ? REM_PX : 1)
|
||||
}
|
||||
|
||||
export function setViewportWidth(width: number) {
|
||||
window.matchMedia = ((query: string) => {
|
||||
const min = widthLimit(query, 'min-width')
|
||||
const max = widthLimit(query, 'max-width')
|
||||
const matches = (min === null || width >= min) && (max === null || width <= max)
|
||||
return {
|
||||
matches,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
addListener: () => undefined,
|
||||
removeListener: () => undefined,
|
||||
dispatchEvent: () => false,
|
||||
}
|
||||
}) as unknown as typeof window.matchMedia
|
||||
}
|
||||
|
||||
export const DESKTOP_WIDTH = 1440
|
||||
export const MOBILE_WIDTH = 390
|
||||
@@ -5,11 +5,7 @@ import router from '@/router'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { ApiError, NetworkError, isOffline, useApi } from '@/composables/useApi'
|
||||
|
||||
function mockResponse(
|
||||
status: number,
|
||||
body: unknown = null,
|
||||
headers: Record<string, string> = {},
|
||||
) {
|
||||
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
@@ -22,10 +18,7 @@ function setOnline(value: boolean) {
|
||||
Object.defineProperty(navigator, 'onLine', { configurable: true, value })
|
||||
}
|
||||
|
||||
async function expectApiError(
|
||||
call: Promise<unknown>,
|
||||
matcher: Record<string, unknown>,
|
||||
) {
|
||||
async function expectApiError(call: Promise<unknown>, matcher: Record<string, unknown>) {
|
||||
await expect(call).rejects.toBeInstanceOf(ApiError)
|
||||
await expect(call).rejects.toMatchObject(matcher)
|
||||
}
|
||||
@@ -109,9 +102,7 @@ describe('useApi global 401/403/429/offline handling', () => {
|
||||
|
||||
await expectApiError(call, { status: 403 })
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(router.currentRoute.value.path).toBe('/register/pending'),
|
||||
)
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
|
||||
expect(session.session).toEqual({ verified: false })
|
||||
})
|
||||
|
||||
@@ -159,9 +150,7 @@ describe('useApi global 401/403/429/offline handling', () => {
|
||||
})
|
||||
|
||||
it('A 429 on a rate-limited endpoint exposes the Retry-After seconds', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
mockResponse(429, null, { 'Retry-After': '30' }),
|
||||
)
|
||||
fetchMock.mockResolvedValue(mockResponse(429, null, { 'Retry-After': '30' }))
|
||||
|
||||
const call = useApi().request('/v1/auth/login', { method: 'POST' })
|
||||
|
||||
@@ -209,3 +198,44 @@ describe('useApi global 401/403/429/offline handling', () => {
|
||||
expect(isOffline.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMe maps the account fields', () => {
|
||||
it('maps email and display_name onto email and displayName', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
mockResponse(200, {
|
||||
id: 1,
|
||||
username: 'marta',
|
||||
email: 'marta@renew.example',
|
||||
is_verified: true,
|
||||
display_name: 'Marta Alves',
|
||||
}),
|
||||
)
|
||||
|
||||
await expect(useApi().getMe()).resolves.toEqual({
|
||||
id: 1,
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
email: 'marta@renew.example',
|
||||
displayName: 'Marta Alves',
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['absent', undefined],
|
||||
['null', null],
|
||||
])('reads a %s display_name as an empty string', async (_label, value) => {
|
||||
fetchMock.mockResolvedValue(
|
||||
mockResponse(200, {
|
||||
id: 1,
|
||||
username: 'marta',
|
||||
email: 'marta@renew.example',
|
||||
is_verified: true,
|
||||
display_name: value,
|
||||
}),
|
||||
)
|
||||
|
||||
const me = await useApi().getMe()
|
||||
|
||||
expect(me.displayName).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useColorMode, type ColorModePreference } from '@/composables/useColorMode'
|
||||
import { accountInitials, accountName } from '@/lib/accountName'
|
||||
|
||||
const PROFILE_PATH = '/profile'
|
||||
|
||||
export const colorModeOptions: { label: string; value: ColorModePreference }[] = [
|
||||
{ label: 'System', value: 'system' },
|
||||
{ label: 'Light', value: 'light' },
|
||||
{ label: 'Dark', value: 'dark' },
|
||||
]
|
||||
|
||||
export function useAccountMenu(account: () => { username: string; displayName?: string }) {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const colorMode = useColorMode()
|
||||
|
||||
const name = computed(() => accountName(account().username, account().displayName))
|
||||
const initials = computed(() => accountInitials(account().username, account().displayName))
|
||||
const onProfile = computed(() => route.path === PROFILE_PATH)
|
||||
|
||||
function goToProfile() {
|
||||
if (!onProfile.value) {
|
||||
router.push(PROFILE_PATH)
|
||||
}
|
||||
}
|
||||
|
||||
return { colorMode, name, initials, onProfile, goToProfile }
|
||||
}
|
||||
@@ -25,6 +25,8 @@ export interface MeResult {
|
||||
id?: number
|
||||
verified: boolean
|
||||
username?: string
|
||||
email?: string
|
||||
displayName: string
|
||||
}
|
||||
|
||||
export type OnUnauthorized = 'redirect' | 'passthrough'
|
||||
@@ -127,11 +129,20 @@ export function useApi() {
|
||||
}
|
||||
|
||||
async function getMe(): Promise<MeResult> {
|
||||
const body = await request<{ id?: number; is_verified: boolean; username?: string }>(
|
||||
'/v1/auth/me',
|
||||
{ onUnauthorized: 'passthrough' },
|
||||
)
|
||||
return { id: body.id, verified: Boolean(body.is_verified), username: body.username }
|
||||
const body = await request<{
|
||||
id?: number
|
||||
is_verified: boolean
|
||||
username?: string
|
||||
email?: string
|
||||
display_name?: string | null
|
||||
}>('/v1/auth/me', { onUnauthorized: 'passthrough' })
|
||||
return {
|
||||
id: body.id,
|
||||
verified: Boolean(body.is_verified),
|
||||
username: body.username,
|
||||
email: body.email,
|
||||
displayName: body.display_name ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
return { getMe, request }
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { accountInitials, accountName } from '@/lib/accountName'
|
||||
|
||||
describe('accountName', () => {
|
||||
it.each([
|
||||
['marta', '', 'marta'],
|
||||
['marta', 'Marta Alves', 'Marta Alves'],
|
||||
['marta', undefined, 'marta'],
|
||||
['', '', ''],
|
||||
])('shows %s with display name %j as %s', (username, display, expected) => {
|
||||
expect(accountName(username, display)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Avatar initials', () => {
|
||||
it.each([
|
||||
['marta', '', 'MA'],
|
||||
['marta', 'Marta Alves', 'MA'],
|
||||
['marta', 'Marta Alves Costa', 'MA'],
|
||||
['ines', 'Inès Duarte', 'ID'],
|
||||
['elodie', 'Élodie Martin', 'ÉM'],
|
||||
['marta', 'Marta', 'MA'],
|
||||
['m.alves', '', 'M.'],
|
||||
['marta', ' Marta Alves ', 'MA'],
|
||||
['', '', ''],
|
||||
])('gives %s with display name %j the initials %s', (username, display, expected) => {
|
||||
expect(accountInitials(username, display)).toBe(expected)
|
||||
})
|
||||
|
||||
it('counts an emoji as one character', () => {
|
||||
expect(accountInitials('x', '😀 Smile')).toBe('😀S')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
const WHITESPACE = /\s+/
|
||||
|
||||
function firstCharacters(value: string, count: number) {
|
||||
return Array.from(value).slice(0, count).join('')
|
||||
}
|
||||
|
||||
export function accountName(username: string, displayName?: string | null) {
|
||||
return displayName ? displayName : username
|
||||
}
|
||||
|
||||
export function accountInitials(username: string, displayName?: string | null) {
|
||||
const words = (displayName ?? '').trim().split(WHITESPACE).filter(Boolean)
|
||||
if (words.length >= 2) {
|
||||
return words
|
||||
.slice(0, 2)
|
||||
.map((word) => firstCharacters(word, 1))
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
}
|
||||
const source = words[0] ?? username
|
||||
return firstCharacters(source, 2).toUpperCase()
|
||||
}
|
||||
@@ -76,6 +76,11 @@ const router = createRouter({
|
||||
component: () => import('@/views/UsersView.vue'),
|
||||
meta: { auth: 'verified', title: 'Users' },
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
component: () => import('@/views/ProfileView.vue'),
|
||||
meta: { auth: 'verified', title: 'Profile' },
|
||||
},
|
||||
{
|
||||
path: '/integration',
|
||||
component: () => import('@/views/IntegrationView.vue'),
|
||||
|
||||
@@ -18,3 +18,31 @@ describe('session store clear action', () => {
|
||||
expect(session.status).toBe('ready')
|
||||
})
|
||||
})
|
||||
|
||||
describe('session store setDisplayName action', () => {
|
||||
it('replaces the session display name with the given value', () => {
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta', displayName: '' }
|
||||
|
||||
session.setDisplayName('Marta Alves')
|
||||
|
||||
expect(session.session?.displayName).toBe('Marta Alves')
|
||||
})
|
||||
|
||||
it.each([[null], [undefined]])('reads %s as an empty display name', (value) => {
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta', displayName: 'Marta Alves' }
|
||||
|
||||
session.setDisplayName(value)
|
||||
|
||||
expect(session.session?.displayName).toBe('')
|
||||
})
|
||||
|
||||
it('does nothing without a session', () => {
|
||||
const session = useSessionStore()
|
||||
|
||||
session.setDisplayName('Marta Alves')
|
||||
|
||||
expect(session.session).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,8 @@ export interface Session {
|
||||
id?: number
|
||||
verified: boolean
|
||||
username?: string
|
||||
email?: string
|
||||
displayName: string
|
||||
}
|
||||
|
||||
export const useSessionStore = defineStore('session', () => {
|
||||
@@ -53,5 +55,11 @@ export const useSessionStore = defineStore('session', () => {
|
||||
status.value = 'ready'
|
||||
}
|
||||
|
||||
return { status, session, check, retry, clear }
|
||||
function setDisplayName(displayName: string | null | undefined) {
|
||||
if (session.value) {
|
||||
session.value = { ...session.value, displayName: displayName ?? '' }
|
||||
}
|
||||
}
|
||||
|
||||
return { status, session, check, retry, clear, setDisplayName }
|
||||
})
|
||||
@@ -7,6 +7,7 @@ import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
|
||||
import NewArticleModal from '@/components/NewArticleModal.vue'
|
||||
import { useArticles, type Article } from '@/composables/useArticles'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { accountName } from '@/lib/accountName'
|
||||
import { formatRelativeDate } from '@/lib/relativeDate'
|
||||
|
||||
const SKELETON_DELAY_MS = 150
|
||||
@@ -37,8 +38,8 @@ function greetingForHour(hour: number) {
|
||||
|
||||
const greeting = computed(() => {
|
||||
const timeOfDayGreeting = greetingForHour(new Date().getHours())
|
||||
const username = session.session?.username
|
||||
return username ? `${timeOfDayGreeting}, ${username}` : timeOfDayGreeting
|
||||
const name = accountName(session.session?.username ?? '', session.session?.displayName)
|
||||
return name ? `${timeOfDayGreeting}, ${name}` : timeOfDayGreeting
|
||||
})
|
||||
|
||||
const subtitle = computed(() =>
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
import { useOverlay, useToast } from '@nuxt/ui/composables'
|
||||
import CharacterCounter from '@/components/CharacterCounter.vue'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import ProfileCard from '@/components/ProfileCard.vue'
|
||||
import { ApiError, authRedirectPending, useApi } from '@/composables/useApi'
|
||||
import { useRetryAfter } from '@/composables/useRetryAfter'
|
||||
import { useSessionStore, type Session } from '@/stores/session'
|
||||
|
||||
const DISPLAY_NAME_MAX = 60
|
||||
const DISPLAY_NAME_LIMIT_BODY = 'Display name must be 60 characters or fewer.'
|
||||
const DISPLAY_NAME_LIMIT_COPY = 'Use 60 characters or fewer.'
|
||||
const SAVE_FAILED_COPY =
|
||||
"Couldn't save your display name. Your text is still here. Try again in a moment."
|
||||
const RESET_FALLBACK_SECONDS = 300
|
||||
const PASSWORD_MASK = '••••••••••••'
|
||||
const SKELETON_CARDS = [
|
||||
{ titleWidth: 'w-30', inputs: 1 },
|
||||
{ titleWidth: 'w-22.5', inputs: 2 },
|
||||
{ titleWidth: 'w-25', inputs: 1 },
|
||||
]
|
||||
|
||||
type ResetState = 'idle' | 'sending' | 'sent' | 'limited' | 'error'
|
||||
|
||||
const session = useSessionStore()
|
||||
const toast = useToast()
|
||||
const overlay = useOverlay()
|
||||
const { request } = useApi()
|
||||
const retryAfter = useRetryAfter()
|
||||
|
||||
const account = ref<Session | null>(session.session)
|
||||
const field = ref('')
|
||||
const saving = ref(false)
|
||||
const saveFailed = ref(false)
|
||||
const serverLimitError = ref(false)
|
||||
const resetState = ref<ResetState>('idle')
|
||||
const inputRef = ref<{ inputRef?: HTMLInputElement } | null>(null)
|
||||
|
||||
watch(
|
||||
() => session.session,
|
||||
(value) => {
|
||||
if (value) {
|
||||
account.value = value
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
account,
|
||||
(value, previous) => {
|
||||
if (value && !previous) {
|
||||
field.value = value.displayName ?? ''
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(field, () => {
|
||||
serverLimitError.value = false
|
||||
})
|
||||
|
||||
watch(retryAfter.remaining, (value) => {
|
||||
if (value === 0 && resetState.value === 'limited') {
|
||||
resetState.value = 'idle'
|
||||
}
|
||||
})
|
||||
|
||||
const username = computed(() => account.value?.username ?? '')
|
||||
const email = computed(() => account.value?.email ?? '')
|
||||
const savedName = computed(() => account.value?.displayName ?? '')
|
||||
const draft = computed(() => field.value.trim())
|
||||
const count = computed(() => Array.from(draft.value).length)
|
||||
const dirty = computed(() => draft.value !== savedName.value)
|
||||
const tooLong = computed(() => count.value > DISPLAY_NAME_MAX)
|
||||
const showFieldError = computed(() => tooLong.value || serverLimitError.value)
|
||||
const saveEnabled = computed(() => dirty.value && !showFieldError.value)
|
||||
const showUnsaved = computed(() => dirty.value && !saving.value)
|
||||
|
||||
const statusText = computed(() => {
|
||||
if (showUnsaved.value) {
|
||||
return 'Unsaved changes'
|
||||
}
|
||||
return savedName.value
|
||||
? `You appear as “${savedName.value}”.`
|
||||
: `You appear as “${username.value}”, your username.`
|
||||
})
|
||||
|
||||
const inputUi = { base: 'h-11 text-lg lg:h-10 lg:text-base' }
|
||||
const readOnlyUi = { base: 'h-11 text-lg disabled:text-toned lg:h-10 lg:text-base' }
|
||||
const readOnlyLockUi = { trailingIcon: 'size-3.5 text-dimmed' }
|
||||
|
||||
const resetNotes = {
|
||||
sent: {
|
||||
color: 'success',
|
||||
icon: 'i-lucide-mail-check',
|
||||
title: 'Reset link sent',
|
||||
},
|
||||
limited: {
|
||||
color: 'warning',
|
||||
icon: 'i-lucide-clock-alert',
|
||||
title: 'Too many requests',
|
||||
description: "You've asked for several links recently. Wait a few minutes, then try again.",
|
||||
},
|
||||
error: {
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: "Couldn't send the link",
|
||||
description: 'Check your connection and try again.',
|
||||
},
|
||||
} as const
|
||||
|
||||
const resetNote = computed(() => {
|
||||
if (resetState.value === 'sent') {
|
||||
return {
|
||||
...resetNotes.sent,
|
||||
description: `Check ${email.value}. The link works once, within 1 hour.`,
|
||||
}
|
||||
}
|
||||
if (resetState.value === 'limited' || resetState.value === 'error') {
|
||||
return resetNotes[resetState.value]
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const resetButtonLabel = computed(() => {
|
||||
if (resetState.value === 'sending') {
|
||||
return 'Sending…'
|
||||
}
|
||||
return resetState.value === 'sent' ? 'Send again' : 'Send reset link'
|
||||
})
|
||||
|
||||
const resetBusy = computed(() => resetState.value === 'sending')
|
||||
const resetDisabled = computed(() => resetBusy.value || resetState.value === 'limited')
|
||||
|
||||
function focusField() {
|
||||
nextTick(() => inputRef.value?.inputRef?.focus())
|
||||
}
|
||||
|
||||
function announceSaved(displayName: string) {
|
||||
toast.add({
|
||||
id: 'display-name-saved',
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: displayName ? 'Display name saved' : 'Display name removed',
|
||||
description: displayName
|
||||
? `You now appear as ${displayName} across RenewCMS and in emails.`
|
||||
: `You now appear as ${username.value}, your username.`,
|
||||
})
|
||||
}
|
||||
|
||||
function isHandledGlobally(error: unknown) {
|
||||
return error instanceof ApiError && (error.status === 401 || error.status === 403)
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
if (saving.value || !saveEnabled.value) {
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
saveFailed.value = false
|
||||
serverLimitError.value = false
|
||||
try {
|
||||
const updated = await request<{ display_name?: string | null }>('/v1/user/me', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ display_name: draft.value }),
|
||||
})
|
||||
const displayName = updated.display_name ?? ''
|
||||
session.setDisplayName(displayName)
|
||||
field.value = displayName
|
||||
announceSaved(displayName)
|
||||
} catch (error) {
|
||||
if (isHandledGlobally(error)) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
error instanceof ApiError &&
|
||||
error.status === 400 &&
|
||||
error.body === DISPLAY_NAME_LIMIT_BODY
|
||||
) {
|
||||
serverLimitError.value = true
|
||||
} else {
|
||||
saveFailed.value = true
|
||||
}
|
||||
focusField()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onSendReset() {
|
||||
if (resetDisabled.value) {
|
||||
return
|
||||
}
|
||||
resetState.value = 'sending'
|
||||
try {
|
||||
await request('/v1/auth/password-reset', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email.value }),
|
||||
})
|
||||
resetState.value = 'sent'
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 429) {
|
||||
retryAfter.start(error.retryAfter || RESET_FALLBACK_SECONDS)
|
||||
resetState.value = 'limited'
|
||||
return
|
||||
}
|
||||
resetState.value = isHandledGlobally(error) ? 'idle' : 'error'
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeRouteLeave(async () => {
|
||||
if (authRedirectPending.value || !dirty.value) {
|
||||
return true
|
||||
}
|
||||
const keepEditing = await overlay
|
||||
.create(ConfirmDialog, {
|
||||
props: {
|
||||
title: 'Leave without saving?',
|
||||
description: "Your display name change hasn't been saved.",
|
||||
confirmLabel: 'Keep editing',
|
||||
cancelLabel: 'Discard changes',
|
||||
pendingLabel: 'Keep editing',
|
||||
tone: 'neutral',
|
||||
action: async () => {},
|
||||
dismissResolvesTo: 'confirm',
|
||||
},
|
||||
})
|
||||
.open()
|
||||
return !keepEditing
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardShell render-while-checking>
|
||||
<div class="flex max-w-164 flex-col gap-5">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<h1 class="text-xl font-semibold text-highlighted">Profile</h1>
|
||||
<p class="text-muted">Your account on this RenewCMS workspace.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!account" aria-busy="true" class="flex flex-col gap-4">
|
||||
<div
|
||||
v-for="(card, cardIndex) in SKELETON_CARDS"
|
||||
:key="cardIndex"
|
||||
data-testid="profile-skeleton-card"
|
||||
class="flex flex-col gap-3 border border-default bg-default p-4 lg:p-5"
|
||||
>
|
||||
<USkeleton class="h-3.5 rounded-none bg-accented" :class="card.titleWidth" />
|
||||
<USkeleton
|
||||
v-for="inputIndex in card.inputs"
|
||||
:key="inputIndex"
|
||||
class="h-10 rounded-none bg-accented"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<UForm :state="{ displayName: field }" @submit="onSave">
|
||||
<ProfileCard
|
||||
title="Display name"
|
||||
description="Shown instead of your username across the back-office and in the emails RenewCMS sends. Leave it empty to use your username."
|
||||
>
|
||||
<UAlert
|
||||
v-if="saveFailed"
|
||||
role="alert"
|
||||
color="error"
|
||||
variant="soft"
|
||||
icon="i-lucide-circle-x"
|
||||
:description="SAVE_FAILED_COPY"
|
||||
:close="false"
|
||||
:ui="{ root: 'gap-2 px-3 py-2.5' }"
|
||||
/>
|
||||
|
||||
<UFormField name="displayName" :error="showFieldError">
|
||||
<template #label>
|
||||
Display name <span class="font-normal text-muted">(optional)</span>
|
||||
</template>
|
||||
<UInput
|
||||
ref="inputRef"
|
||||
v-model="field"
|
||||
placeholder="e.g. Marta Alves"
|
||||
autocomplete="off"
|
||||
:disabled="saving"
|
||||
class="w-full"
|
||||
:ui="inputUi"
|
||||
@keydown.enter.prevent="onSave"
|
||||
/>
|
||||
<div class="mt-1.5 flex items-start gap-3">
|
||||
<span
|
||||
v-if="showFieldError"
|
||||
data-testid="display-name-error"
|
||||
class="flex flex-1 items-center gap-1.5 text-xs/5 text-error"
|
||||
>
|
||||
<UIcon name="i-lucide-circle-alert" class="size-3.5 shrink-0" />
|
||||
{{ DISPLAY_NAME_LIMIT_COPY }}
|
||||
</span>
|
||||
<span v-else class="flex-1" />
|
||||
<CharacterCounter
|
||||
data-testid="display-name-counter"
|
||||
variant="field"
|
||||
:value="count"
|
||||
:max="DISPLAY_NAME_MAX"
|
||||
/>
|
||||
</div>
|
||||
</UFormField>
|
||||
|
||||
<template #footer>
|
||||
<span
|
||||
data-testid="display-name-status"
|
||||
class="min-w-0 flex-1 text-xs/[18px]"
|
||||
:class="showUnsaved ? 'text-warning' : 'text-muted'"
|
||||
>{{ statusText }}</span
|
||||
>
|
||||
<UButton
|
||||
type="submit"
|
||||
:loading="saving"
|
||||
:disabled="!saveEnabled && !saving"
|
||||
class="h-11 shrink-0 px-3.5 lg:h-8"
|
||||
>
|
||||
{{ saving ? 'Saving…' : 'Save' }}
|
||||
</UButton>
|
||||
</template>
|
||||
</ProfileCard>
|
||||
</UForm>
|
||||
|
||||
<ProfileCard
|
||||
title="Account"
|
||||
description="Set when your account was created. You log in with your username, even after adding a display name."
|
||||
>
|
||||
<div class="grid gap-3.5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
|
||||
<UFormField label="Username">
|
||||
<UInput
|
||||
:model-value="username"
|
||||
disabled
|
||||
trailing-icon="i-lucide-lock"
|
||||
class="w-full"
|
||||
:ui="{ ...readOnlyUi, ...readOnlyLockUi }"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="Email">
|
||||
<UInput
|
||||
:model-value="email"
|
||||
disabled
|
||||
trailing-icon="i-lucide-lock"
|
||||
class="w-full"
|
||||
:ui="{ ...readOnlyUi, ...readOnlyLockUi }"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
</ProfileCard>
|
||||
|
||||
<ProfileCard
|
||||
title="Password"
|
||||
:description="`To change it, we'll email a reset link to ${email}. The link works once, within 1 hour.`"
|
||||
>
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-end">
|
||||
<UFormField label="Password" class="min-w-0 flex-1">
|
||||
<UInput
|
||||
:model-value="PASSWORD_MASK"
|
||||
disabled
|
||||
trailing-icon="i-lucide-lock"
|
||||
class="w-full"
|
||||
:ui="{ ...readOnlyUi, ...readOnlyLockUi }"
|
||||
/>
|
||||
</UFormField>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:icon="resetBusy ? undefined : 'i-lucide-mail'"
|
||||
:loading="resetBusy"
|
||||
:disabled="resetDisabled"
|
||||
class="h-11 justify-center border border-accented bg-default px-3.5 whitespace-nowrap text-highlighted ring-0 lg:h-10"
|
||||
@click="onSendReset"
|
||||
>
|
||||
{{ resetButtonLabel }}
|
||||
</UButton>
|
||||
</div>
|
||||
<UAlert
|
||||
v-if="resetNote"
|
||||
data-testid="reset-note"
|
||||
role="status"
|
||||
variant="soft"
|
||||
:color="resetNote.color"
|
||||
:icon="resetNote.icon"
|
||||
:title="resetNote.title"
|
||||
:description="resetNote.description"
|
||||
:close="false"
|
||||
/>
|
||||
</ProfileCard>
|
||||
</template>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
</template>
|
||||
@@ -379,6 +379,58 @@ describe('The greeting is identical across every load state at a fixed hour', ()
|
||||
})
|
||||
})
|
||||
|
||||
describe('The greeting names the person by display name, else username', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ displayName: '', name: 'marta' },
|
||||
{ displayName: 'Marta Alves', name: 'Marta Alves' },
|
||||
])('reads the name "$name" for display name "$displayName"', async ({ displayName, name }) => {
|
||||
vi.spyOn(Date.prototype, 'getHours').mockReturnValue(9)
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta', displayName }
|
||||
session.status = 'ready'
|
||||
global.fetch = fetchRouter({
|
||||
'GET /v1/article/': () => mockResponse(200, sixArticles),
|
||||
}) as unknown as typeof fetch
|
||||
await router.push('/home')
|
||||
const wrapper = mount(HomeView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
currentWrapper = wrapper
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-testid="greeting"]').text()).toBe(`Good morning, ${name}`)
|
||||
})
|
||||
|
||||
it('shows a display name saved after the page was mounted without a reload', async () => {
|
||||
vi.spyOn(Date.prototype, 'getHours').mockReturnValue(9)
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta', displayName: '' }
|
||||
session.status = 'ready'
|
||||
global.fetch = fetchRouter({
|
||||
'GET /v1/article/': () => mockResponse(200, sixArticles),
|
||||
}) as unknown as typeof fetch
|
||||
await router.push('/home')
|
||||
const wrapper = mount(HomeView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
currentWrapper = wrapper
|
||||
await flushPromises()
|
||||
|
||||
session.setDisplayName('Marta Alves')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-testid="greeting"]').text()).toBe('Good morning, Marta Alves')
|
||||
})
|
||||
})
|
||||
|
||||
describe('The greeting handles a missing username', () => {
|
||||
it('renders the plain greeting without a stray comma when the session has no username', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
@@ -6,11 +6,7 @@ import router from '@/router'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import PendingView from '../PendingView.vue'
|
||||
|
||||
function mockResponse(
|
||||
status: number,
|
||||
body: unknown = null,
|
||||
headers: Record<string, string> = {},
|
||||
) {
|
||||
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
@@ -24,9 +20,7 @@ function routeFor(url: string, init?: RequestInit) {
|
||||
return `${method} ${url}`
|
||||
}
|
||||
|
||||
function fetchRouter(
|
||||
handlers: Record<string, () => Response | Promise<Response>>,
|
||||
) {
|
||||
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
|
||||
return vi.fn((url: string, init?: RequestInit) => {
|
||||
const key = routeFor(url, init)
|
||||
const handler = handlers[key]
|
||||
@@ -40,8 +34,7 @@ function fetchRouter(
|
||||
function createOverlayMock() {
|
||||
let props: Record<string, unknown> | null = null
|
||||
let resolveOpen: ((value: unknown) => void) | null = null
|
||||
const create = vi.fn(
|
||||
(_component: unknown, options: { props: Record<string, unknown> }) => {
|
||||
const create = vi.fn((_component: unknown, options: { props: Record<string, unknown> }) => {
|
||||
props = options.props
|
||||
return {
|
||||
open: () =>
|
||||
@@ -49,8 +42,7 @@ function createOverlayMock() {
|
||||
resolveOpen = resolve
|
||||
}),
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
return {
|
||||
create,
|
||||
getProps: () => props,
|
||||
@@ -148,24 +140,16 @@ describe('Logging out', () => {
|
||||
await findButton(wrapper, 'Log out')?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect((findButton(wrapper, 'Log out')?.element as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(
|
||||
(findButton(wrapper, 'Log out')?.element as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true)
|
||||
expect(
|
||||
(
|
||||
findButton(wrapper, 'Cancel registration')
|
||||
?.element as HTMLButtonElement
|
||||
).disabled,
|
||||
(findButton(wrapper, 'Cancel registration')?.element as HTMLButtonElement).disabled,
|
||||
).toBe(true)
|
||||
|
||||
resolveLogout?.()
|
||||
await flushPromises()
|
||||
|
||||
expect(session.session).toBeNull()
|
||||
await vi.waitFor(() =>
|
||||
expect(router.currentRoute.value.path).toBe('/login'),
|
||||
)
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -245,9 +229,7 @@ describe('Cancelled — 200 navigates to /register with a toast', () => {
|
||||
overlayMock.resolveOpen(true)
|
||||
await flushPromises()
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(router.currentRoute.value.path).toBe('/register'),
|
||||
)
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register'))
|
||||
expect(toastAdd).toHaveBeenCalledWith({
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
@@ -275,7 +257,7 @@ describe('Already verified — 403 shows the verified panel and toast, then navi
|
||||
const props = overlayMock.getProps() as { action: () => Promise<void> }
|
||||
await props.action()
|
||||
|
||||
expect(session.session).toEqual({ verified: true })
|
||||
expect(session.session).toMatchObject({ verified: true, displayName: '' })
|
||||
expect(pushSpy).not.toHaveBeenCalledWith('/register/pending')
|
||||
|
||||
overlayMock.resolveOpen(true)
|
||||
@@ -291,12 +273,9 @@ describe('Already verified — 403 shows the verified panel and toast, then navi
|
||||
})
|
||||
expect(router.currentRoute.value.path).not.toBe('/home')
|
||||
|
||||
await vi.waitFor(
|
||||
() => expect(router.currentRoute.value.path).toBe('/home'),
|
||||
{
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'), {
|
||||
timeout: 2000,
|
||||
},
|
||||
)
|
||||
})
|
||||
expect(toastAdd).toHaveBeenCalledTimes(1)
|
||||
expect(pushSpy).not.toHaveBeenCalledWith('/register/pending')
|
||||
pushSpy.mockRestore()
|
||||
@@ -310,9 +289,7 @@ describe('Cancel failed — the dialog stays open and shows the failure inline',
|
||||
name: 'a network failure',
|
||||
handler: () => Promise.reject(new TypeError('Failed to fetch')),
|
||||
},
|
||||
])(
|
||||
'rejects the action instead of navigating for $name',
|
||||
async ({ handler }) => {
|
||||
])('rejects the action instead of navigating for $name', async ({ handler }) => {
|
||||
const { wrapper } = await mountPending({
|
||||
'DELETE /v1/auth/register': handler,
|
||||
})
|
||||
@@ -327,6 +304,5 @@ describe('Cancel failed — the dialog stays open and shows the failure inline',
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/register/pending')
|
||||
expect(toastAdd).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,872 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import router from '@/router'
|
||||
import App from '@/App.vue'
|
||||
import { useSessionStore, type Session } from '@/stores/session'
|
||||
import { ApiError, NetworkError, authRedirectPending, useApi } from '@/composables/useApi'
|
||||
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
let toastAdd: ReturnType<typeof vi.fn>
|
||||
let overlayMock: ReturnType<typeof createOverlayMock>
|
||||
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return {
|
||||
...actual,
|
||||
useToast: () => ({ add: toastAdd }),
|
||||
useOverlay: () => overlayMock,
|
||||
}
|
||||
})
|
||||
|
||||
function createOverlayMock() {
|
||||
let props: Record<string, unknown> | null = null
|
||||
let resolveOpen: ((value: unknown) => void) | null = null
|
||||
const create = vi.fn((_component: unknown, options: { props: Record<string, unknown> }) => {
|
||||
props = options.props
|
||||
return {
|
||||
open: () =>
|
||||
new Promise((resolve) => {
|
||||
resolveOpen = resolve
|
||||
}),
|
||||
}
|
||||
})
|
||||
return {
|
||||
create,
|
||||
getProps: () => props,
|
||||
resolveOpen: (value: unknown) => resolveOpen?.(value),
|
||||
}
|
||||
}
|
||||
|
||||
const MARTA: Session = {
|
||||
id: 1,
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
email: 'marta@renew.example',
|
||||
displayName: '',
|
||||
}
|
||||
|
||||
const DISPLAY_NAME_PATH = '/v1/user/me'
|
||||
const RESET_PATH = '/v1/auth/password-reset'
|
||||
const LOGOUT_PATH = '/v1/auth/logout'
|
||||
const LIMIT_BODY = 'Display name must be 60 characters or fewer.'
|
||||
const SAVE_FAILED_COPY =
|
||||
"Couldn't save your display name. Your text is still here. Try again in a moment."
|
||||
|
||||
type Handler = (init?: RequestInit) => Promise<unknown>
|
||||
|
||||
interface Mounted {
|
||||
wrapper: VueWrapper
|
||||
request: ReturnType<typeof vi.fn>
|
||||
getMe: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function deferred<T = unknown>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
interface MountOptions {
|
||||
session?: Session | null
|
||||
status?: 'idle' | 'ready'
|
||||
getMe?: () => Promise<Session>
|
||||
handlers?: Record<string, Handler>
|
||||
path?: string
|
||||
}
|
||||
|
||||
let mountedWrapper: VueWrapper | null = null
|
||||
|
||||
async function mountApp(options: MountOptions = {}): Promise<Mounted> {
|
||||
const { session = MARTA, status = 'ready', handlers = {}, path = '/profile' } = options
|
||||
setViewportWidth(DESKTOP_WIDTH)
|
||||
setActivePinia(createPinia())
|
||||
const store = useSessionStore()
|
||||
store.session = session ? { ...session } : null
|
||||
store.status = status
|
||||
const request = vi.fn((requestPath: string, init?: RequestInit) => {
|
||||
const key = `${init?.method ?? 'GET'} ${requestPath}`
|
||||
const handler = handlers[key]
|
||||
if (handler) {
|
||||
return handler(init)
|
||||
}
|
||||
if (requestPath === LOGOUT_PATH) {
|
||||
return Promise.resolve(undefined)
|
||||
}
|
||||
return Promise.resolve([])
|
||||
})
|
||||
const getMe = vi.fn(options.getMe ?? (() => Promise.resolve({ ...MARTA })))
|
||||
vi.mocked(useApi).mockReturnValue({ getMe, request } as unknown as ReturnType<typeof useApi>)
|
||||
await router.push(path)
|
||||
const wrapper = mount(App, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
mountedWrapper = wrapper
|
||||
await flushPromises()
|
||||
return { wrapper, request, getMe }
|
||||
}
|
||||
|
||||
function displayNameField() {
|
||||
return document.body.querySelector('input[placeholder="e.g. Marta Alves"]') as HTMLInputElement
|
||||
}
|
||||
|
||||
async function typeInto(value: string) {
|
||||
const input = displayNameField()
|
||||
input.value = value
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function pressEnter() {
|
||||
displayNameField().dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||
)
|
||||
}
|
||||
|
||||
function buttonByText(pattern: RegExp) {
|
||||
return Array.from(document.body.querySelectorAll('button')).find((button) =>
|
||||
pattern.test(button.textContent?.trim() ?? ''),
|
||||
) as HTMLButtonElement | undefined
|
||||
}
|
||||
|
||||
function saveButton() {
|
||||
return buttonByText(/^(Save|Saving…)$/)!
|
||||
}
|
||||
|
||||
function resetButton() {
|
||||
return buttonByText(/^(Send reset link|Sending…|Send again)$/)!
|
||||
}
|
||||
|
||||
function byTestId(id: string) {
|
||||
return document.body.querySelector(`[data-testid="${id}"]`) as HTMLElement | null
|
||||
}
|
||||
|
||||
function counter() {
|
||||
return byTestId('display-name-counter')!
|
||||
}
|
||||
|
||||
function footer() {
|
||||
return byTestId('display-name-status')!
|
||||
}
|
||||
|
||||
function isEnabled(button: HTMLButtonElement) {
|
||||
return !button.disabled
|
||||
}
|
||||
|
||||
async function clickSave() {
|
||||
saveButton().click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function clickReset() {
|
||||
resetButton().click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function patchBodies(request: ReturnType<typeof vi.fn>) {
|
||||
return request.mock.calls
|
||||
.filter(([, init]) => (init as RequestInit | undefined)?.method === 'PATCH')
|
||||
.map(([, init]) => JSON.parse((init as RequestInit).body as string))
|
||||
}
|
||||
|
||||
function callsTo(request: ReturnType<typeof vi.fn>, path: string, method?: string) {
|
||||
return request.mock.calls.filter(
|
||||
([requestPath, init]) =>
|
||||
requestPath === path &&
|
||||
(method === undefined || (init as RequestInit)?.method === method),
|
||||
)
|
||||
}
|
||||
|
||||
function chip() {
|
||||
return document.body.querySelector('button[aria-haspopup]') as HTMLButtonElement
|
||||
}
|
||||
|
||||
function activate(element: HTMLElement) {
|
||||
element.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
|
||||
element.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
}
|
||||
|
||||
function savedResponse(displayName: string) {
|
||||
return Promise.resolve({ id: 1, username: 'marta', display_name: displayName })
|
||||
}
|
||||
|
||||
const NETWORK_ERROR = () => Promise.reject(new NetworkError())
|
||||
const SERVER_ERROR = () => Promise.reject(new ApiError(500))
|
||||
|
||||
beforeEach(() => {
|
||||
toastAdd = vi.fn()
|
||||
overlayMock = createOverlayMock()
|
||||
localStorage.clear()
|
||||
authRedirectPending.value = false
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers()
|
||||
mountedWrapper?.unmount()
|
||||
mountedWrapper = null
|
||||
document.body.innerHTML = ''
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
describe('Route guards for /profile', () => {
|
||||
it('renders ProfilePage for a resolved, verified session', async () => {
|
||||
await mountApp()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/profile')
|
||||
expect(document.body.textContent).toContain('Your account on this RenewCMS workspace.')
|
||||
})
|
||||
|
||||
it('redirects a resolved, unverified session to /register/pending', async () => {
|
||||
await mountApp({ session: { ...MARTA, verified: false } })
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/register/pending')
|
||||
})
|
||||
|
||||
it('redirects a 401 from /auth/me to /login?redirect=/profile&reason=expired', async () => {
|
||||
await mountApp({
|
||||
session: null,
|
||||
status: 'idle',
|
||||
getMe: () => Promise.reject(new ApiError(401)),
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/login?redirect=/profile&reason=expired')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Default: the usual first visit with no display name', () => {
|
||||
it('shows the header, the empty field, the account details and the reset button', async () => {
|
||||
const { request } = await mountApp()
|
||||
const text = document.body.textContent ?? ''
|
||||
|
||||
expect(document.querySelector('nav[aria-label="breadcrumb" i]')?.textContent).toContain(
|
||||
'Profile',
|
||||
)
|
||||
expect(document.querySelector('h1')?.textContent?.trim()).toBe('Profile')
|
||||
expect(text).toContain('Your account on this RenewCMS workspace.')
|
||||
expect(displayNameField().value).toBe('')
|
||||
expect(displayNameField().getAttribute('placeholder')).toBe('e.g. Marta Alves')
|
||||
expect(counter().textContent?.trim()).toBe('0 / 60')
|
||||
expect(footer().textContent?.trim()).toBe('You appear as “marta”, your username.')
|
||||
expect(isEnabled(saveButton())).toBe(false)
|
||||
expect(text).toContain('Display name (optional)')
|
||||
|
||||
const accountInputs = Array.from(
|
||||
document.body.querySelectorAll<HTMLInputElement>('input:disabled'),
|
||||
)
|
||||
expect(accountInputs.map((input) => input.value)).toEqual([
|
||||
'marta',
|
||||
'marta@renew.example',
|
||||
'••••••••••••',
|
||||
])
|
||||
expect(text).toContain(
|
||||
"To change it, we'll email a reset link to marta@renew.example. The link works once, within 1 hour.",
|
||||
)
|
||||
expect(resetButton().textContent?.trim()).toBe('Send reset link')
|
||||
expect(byTestId('reset-note')).toBeNull()
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('marks none of the four navigation items as the current page', async () => {
|
||||
await mountApp()
|
||||
|
||||
const links = document.body.querySelectorAll('nav[aria-label="Main"] a')
|
||||
expect(links).toHaveLength(4)
|
||||
links.forEach((link) => expect(link.getAttribute('aria-current')).toBeNull())
|
||||
})
|
||||
|
||||
it('places the three cards in order Display name, Account, Password', async () => {
|
||||
await mountApp()
|
||||
|
||||
const titles = Array.from(document.body.querySelectorAll('section h2')).map((el) =>
|
||||
el.textContent?.trim(),
|
||||
)
|
||||
expect(titles).toEqual(['Display name', 'Account', 'Password'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Loading: opened by URL before /auth/me has returned', () => {
|
||||
it('shows the header and three skeleton cards without AppBoot, then the Default state', async () => {
|
||||
const pending = deferred<Session>()
|
||||
await mountApp({
|
||||
session: null,
|
||||
status: 'idle',
|
||||
getMe: () => pending.promise,
|
||||
})
|
||||
|
||||
expect(document.querySelector('nav[aria-label="breadcrumb" i]')?.textContent).toContain(
|
||||
'Profile',
|
||||
)
|
||||
expect(document.body.textContent).toContain('Your account on this RenewCMS workspace.')
|
||||
const busy = document.body.querySelector('[aria-busy="true"]')!
|
||||
expect(busy.querySelectorAll('[data-testid="profile-skeleton-card"]')).toHaveLength(3)
|
||||
expect(document.body.querySelector('[aria-label="Loading"]')).toBeNull()
|
||||
expect(displayNameField()).toBeNull()
|
||||
|
||||
pending.resolve({ ...MARTA })
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
|
||||
expect(displayNameField()).not.toBeNull()
|
||||
expect(counter().textContent?.trim()).toBe('0 / 60')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Opened from the menu, the cached user renders at once', () => {
|
||||
it('navigates from /home through the account menu with no skeleton and no second /auth/me', async () => {
|
||||
const { getMe } = await mountApp({ path: '/home' })
|
||||
|
||||
chip().click()
|
||||
await flushPromises()
|
||||
const profileItem = Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
||||
).find((item) => item.textContent?.trim() === 'Profile')!
|
||||
activate(profileItem)
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/profile')
|
||||
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
|
||||
expect(displayNameField()).not.toBeNull()
|
||||
expect(getMe).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Editing: typing turns Save on', () => {
|
||||
it('shows the count, Unsaved changes in the warning color and an enabled Save, sending nothing', async () => {
|
||||
const { request } = await mountApp()
|
||||
|
||||
await typeInto('Marta Alves')
|
||||
|
||||
expect(counter().textContent?.trim()).toBe('11 / 60')
|
||||
expect(footer().textContent?.trim()).toBe('Unsaved changes')
|
||||
expect(footer().classList.contains('text-warning')).toBe(true)
|
||||
expect(isEnabled(saveButton())).toBe(true)
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('The counter counts Unicode characters after trimming', () => {
|
||||
it.each([
|
||||
['"Marta Alves"', 'Marta Alves', 11, 'text-muted', true],
|
||||
['" Marta Alves "', ' Marta Alves ', 11, 'text-muted', true],
|
||||
['60 repetitions of "a"', 'a'.repeat(60), 60, 'text-muted', true],
|
||||
['61 repetitions of "a"', 'a'.repeat(61), 61, 'text-error', false],
|
||||
['60 repetitions of an emoji', '😀'.repeat(60), 60, 'text-muted', true],
|
||||
['61 repetitions of an emoji', '😀'.repeat(61), 61, 'text-error', false],
|
||||
['"Cafe" followed by U+0301', 'Café', 5, 'text-muted', true],
|
||||
['5 spaces, 60 "a", 5 spaces', ` ${'a'.repeat(60)} `, 60, 'text-muted', true],
|
||||
])('%s counts %i, %s, Save enabled: %s', async (_label, value, count, colorClass, enabled) => {
|
||||
await mountApp()
|
||||
|
||||
await typeInto(value)
|
||||
|
||||
expect(counter().textContent?.trim()).toBe(`${count} / 60`)
|
||||
expect(counter().classList.contains(colorClass)).toBe(true)
|
||||
expect(isEnabled(saveButton())).toBe(enabled)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Too long: over 60 characters shows a field error and keeps Save off', () => {
|
||||
it('shows the error, ignores Enter and recovers once shortened', async () => {
|
||||
const { request } = await mountApp()
|
||||
|
||||
await typeInto('Marta Alves, Head of Renovations, Brussels and Wallonia office')
|
||||
|
||||
expect(counter().textContent?.trim()).toBe('62 / 60')
|
||||
expect(counter().classList.contains('text-error')).toBe(true)
|
||||
const error = byTestId('display-name-error')!
|
||||
expect(error.textContent).toContain('Use 60 characters or fewer.')
|
||||
expect(error.querySelector('svg, [class*="i-lucide-circle-alert"]')).not.toBeNull()
|
||||
expect(displayNameField().getAttribute('aria-invalid')).toBe('true')
|
||||
expect(footer().textContent?.trim()).toBe('Unsaved changes')
|
||||
expect(footer().classList.contains('text-warning')).toBe(true)
|
||||
expect(isEnabled(saveButton())).toBe(false)
|
||||
|
||||
pressEnter()
|
||||
await flushPromises()
|
||||
|
||||
expect(patchBodies(request)).toHaveLength(0)
|
||||
|
||||
await typeInto('Marta Alves')
|
||||
|
||||
expect(byTestId('display-name-error')).toBeNull()
|
||||
expect(counter().textContent?.trim()).toBe('11 / 60')
|
||||
expect(isEnabled(saveButton())).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dirty is measured on the trimmed draft against the saved name', () => {
|
||||
it.each([
|
||||
['', ' ', false, 'You appear as “marta”, your username.'],
|
||||
['Marta Alves', 'Marta Alves', false, 'You appear as “Marta Alves”.'],
|
||||
['Marta Alves', 'Marta Alves ', false, 'You appear as “Marta Alves”.'],
|
||||
['Marta Alves', '', true, 'Unsaved changes'],
|
||||
['Marta Alves', 'Marta Alves', true, 'Unsaved changes'],
|
||||
])(
|
||||
'saved %j and typed %j gives Save enabled: %s and %s',
|
||||
async (saved, typed, enabled, text) => {
|
||||
await mountApp({ session: { ...MARTA, displayName: saved } })
|
||||
|
||||
await typeInto(typed)
|
||||
|
||||
expect(isEnabled(saveButton())).toBe(enabled)
|
||||
expect(footer().textContent?.trim()).toBe(text)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('Enter saves when Save is enabled', () => {
|
||||
it('sends PATCH /v1/user/me once with the trimmed display name', async () => {
|
||||
const { request } = await mountApp({
|
||||
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') },
|
||||
})
|
||||
await typeInto('Marta Alves')
|
||||
|
||||
pressEnter()
|
||||
await flushPromises()
|
||||
|
||||
expect(patchBodies(request)).toEqual([{ display_name: 'Marta Alves' }])
|
||||
})
|
||||
|
||||
it('does nothing when the draft is not dirty', async () => {
|
||||
const { request } = await mountApp()
|
||||
|
||||
pressEnter()
|
||||
await flushPromises()
|
||||
|
||||
expect(patchBodies(request)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Saving: the field is disabled and a second submit is ignored', () => {
|
||||
it('sends the trimmed value, disables and dims the field, and ignores Enter and a second click', async () => {
|
||||
const pending = deferred()
|
||||
const { request } = await mountApp({
|
||||
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => pending.promise },
|
||||
})
|
||||
await typeInto(' Marta Alves ')
|
||||
|
||||
await clickSave()
|
||||
|
||||
expect(patchBodies(request)).toEqual([{ display_name: 'Marta Alves' }])
|
||||
expect(displayNameField().disabled).toBe(true)
|
||||
expect(saveButton().textContent?.trim()).toBe('Saving…')
|
||||
expect(saveButton().querySelector('[class*="i-lucide-loader-circle"], svg')).not.toBeNull()
|
||||
expect(footer().textContent?.trim()).not.toBe('Unsaved changes')
|
||||
|
||||
pressEnter()
|
||||
saveButton().click()
|
||||
await flushPromises()
|
||||
|
||||
expect(patchBodies(request)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Saved: a 200 updates the session, the chip, the footer and raises a toast', () => {
|
||||
it('replaces the session name, syncs the field, and raises the success toast', async () => {
|
||||
const { request } = await mountApp({
|
||||
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') },
|
||||
})
|
||||
await typeInto('Marta Alves')
|
||||
|
||||
await clickSave()
|
||||
|
||||
expect(useSessionStore().session?.displayName).toBe('Marta Alves')
|
||||
expect(chip().textContent).toContain('Marta Alves')
|
||||
expect(document.body.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
|
||||
expect(displayNameField().value).toBe('Marta Alves')
|
||||
expect(displayNameField().disabled).toBe(false)
|
||||
expect(counter().textContent?.trim()).toBe('11 / 60')
|
||||
expect(footer().textContent?.trim()).toBe('You appear as “Marta Alves”.')
|
||||
expect(isEnabled(saveButton())).toBe(false)
|
||||
expect(toastAdd).toHaveBeenCalledWith({
|
||||
id: 'display-name-saved',
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Display name saved',
|
||||
description: 'You now appear as Marta Alves across RenewCMS and in emails.',
|
||||
})
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('opens no leave confirmation when navigating away afterwards', async () => {
|
||||
await mountApp({
|
||||
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') },
|
||||
})
|
||||
await typeInto('Marta Alves')
|
||||
await clickSave()
|
||||
|
||||
await router.push('/users')
|
||||
await flushPromises()
|
||||
|
||||
expect(overlayMock.create).not.toHaveBeenCalled()
|
||||
expect(router.currentRoute.value.path).toBe('/users')
|
||||
})
|
||||
})
|
||||
|
||||
describe("The server's value wins over what was typed", () => {
|
||||
it('uses the response display_name in the field, the chip and the toast', async () => {
|
||||
await mountApp({
|
||||
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') },
|
||||
})
|
||||
await typeInto(' Marta Alves ')
|
||||
|
||||
await clickSave()
|
||||
|
||||
expect(displayNameField().value).toBe('Marta Alves')
|
||||
expect(chip().textContent).toContain('Marta Alves')
|
||||
expect(toastAdd.mock.calls[0]![0].description).toContain('Marta Alves across RenewCMS')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Clearing the display name goes back to the username', () => {
|
||||
it('sends an empty display_name and shows the username with the removed toast', async () => {
|
||||
const { request } = await mountApp({
|
||||
session: { ...MARTA, displayName: 'Marta Alves' },
|
||||
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('') },
|
||||
})
|
||||
await typeInto('')
|
||||
|
||||
await clickSave()
|
||||
|
||||
expect(patchBodies(request)).toEqual([{ display_name: '' }])
|
||||
expect(chip().textContent).toContain('marta')
|
||||
expect(chip().textContent).not.toContain('Marta Alves')
|
||||
expect(document.body.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
|
||||
expect(footer().textContent?.trim()).toBe('You appear as “marta”, your username.')
|
||||
expect(toastAdd).toHaveBeenCalledWith({
|
||||
id: 'display-name-saved',
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Display name removed',
|
||||
description: 'You now appear as marta, your username.',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Save failed: a 500 or a network failure keeps the text and offers a retry', () => {
|
||||
it.each([
|
||||
['a 500 response', SERVER_ERROR],
|
||||
['a network failure', NETWORK_ERROR],
|
||||
])(
|
||||
'after %s shows the alert, keeps the value and retries with the same body',
|
||||
async (_label, failure) => {
|
||||
const attempts = [failure, () => savedResponse('Marta Alves')]
|
||||
const { request } = await mountApp({
|
||||
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => attempts.shift()!() },
|
||||
})
|
||||
await typeInto('Marta Alves')
|
||||
|
||||
await clickSave()
|
||||
|
||||
const alert = document.body.querySelector('[role="alert"]')!
|
||||
expect(alert.textContent).toContain(SAVE_FAILED_COPY)
|
||||
expect(displayNameField().value).toBe('Marta Alves')
|
||||
expect(displayNameField().disabled).toBe(false)
|
||||
expect(footer().textContent?.trim()).toBe('Unsaved changes')
|
||||
expect(isEnabled(saveButton())).toBe(true)
|
||||
expect(useSessionStore().session?.displayName).toBe('')
|
||||
expect(toastAdd).not.toHaveBeenCalled()
|
||||
|
||||
await clickSave()
|
||||
|
||||
expect(patchBodies(request)).toEqual([
|
||||
{ display_name: 'Marta Alves' },
|
||||
{ display_name: 'Marta Alves' },
|
||||
])
|
||||
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||
},
|
||||
)
|
||||
|
||||
it('hides the alert while the retry is in flight', async () => {
|
||||
const retry = deferred()
|
||||
const attempts = [SERVER_ERROR, () => retry.promise]
|
||||
await mountApp({
|
||||
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => attempts.shift()!() },
|
||||
})
|
||||
await typeInto('Marta Alves')
|
||||
await clickSave()
|
||||
expect(document.body.querySelector('[role="alert"]')).not.toBeNull()
|
||||
|
||||
await clickSave()
|
||||
|
||||
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||
expect(saveButton().textContent?.trim()).toBe('Saving…')
|
||||
})
|
||||
})
|
||||
|
||||
describe('A 400 from the API', () => {
|
||||
it('shows the field error and disables Save for the display-name message', async () => {
|
||||
await mountApp({
|
||||
handlers: {
|
||||
[`PATCH ${DISPLAY_NAME_PATH}`]: () =>
|
||||
Promise.reject(new ApiError(400, undefined, LIMIT_BODY)),
|
||||
},
|
||||
})
|
||||
await typeInto('Marta Alves')
|
||||
|
||||
await clickSave()
|
||||
|
||||
expect(byTestId('display-name-error')?.textContent).toContain('Use 60 characters or fewer.')
|
||||
expect(isEnabled(saveButton())).toBe(false)
|
||||
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the Save failed alert and keeps the text for any other 400', async () => {
|
||||
await mountApp({
|
||||
handlers: {
|
||||
[`PATCH ${DISPLAY_NAME_PATH}`]: () =>
|
||||
Promise.reject(new ApiError(400, undefined, 'invalid request body')),
|
||||
},
|
||||
})
|
||||
await typeInto('Marta Alves')
|
||||
|
||||
await clickSave()
|
||||
|
||||
expect(document.body.querySelector('[role="alert"]')?.textContent).toContain(
|
||||
SAVE_FAILED_COPY,
|
||||
)
|
||||
expect(displayNameField().value).toBe('Marta Alves')
|
||||
expect(byTestId('display-name-error')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Leaving with unsaved changes asks for confirmation', () => {
|
||||
it('opens a neutral ConfirmDialog, stays on Keep editing and leaves on Discard changes', async () => {
|
||||
await mountApp()
|
||||
await typeInto('Marta Alves')
|
||||
|
||||
const first = router.push('/users')
|
||||
await flushPromises()
|
||||
|
||||
const props = overlayMock.getProps()!
|
||||
expect(props.title).toBe('Leave without saving?')
|
||||
expect(props.description).toBe("Your display name change hasn't been saved.")
|
||||
expect(props.confirmLabel).toBe('Keep editing')
|
||||
expect(props.cancelLabel).toBe('Discard changes')
|
||||
expect(props.tone).toBe('neutral')
|
||||
|
||||
overlayMock.resolveOpen(true)
|
||||
await first
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/profile')
|
||||
expect(displayNameField().value).toBe('Marta Alves')
|
||||
|
||||
const second = router.push('/users')
|
||||
await flushPromises()
|
||||
overlayMock.resolveOpen(false)
|
||||
await second
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/users')
|
||||
expect(useSessionStore().session?.displayName).toBe('')
|
||||
})
|
||||
|
||||
it('opens nothing when nothing is unsaved', async () => {
|
||||
await mountApp()
|
||||
|
||||
await router.push('/users')
|
||||
await flushPromises()
|
||||
|
||||
expect(overlayMock.create).not.toHaveBeenCalled()
|
||||
expect(router.currentRoute.value.path).toBe('/users')
|
||||
})
|
||||
|
||||
it('opens the dialog when Log out is used with a dirty draft', async () => {
|
||||
const { request } = await mountApp()
|
||||
await typeInto('Marta Alves')
|
||||
|
||||
chip().click()
|
||||
await flushPromises()
|
||||
const logOut = Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
||||
).find((item) => item.textContent?.trim() === 'Log out')!
|
||||
activate(logOut)
|
||||
await flushPromises()
|
||||
|
||||
expect(callsTo(request, LOGOUT_PATH, 'POST')).toHaveLength(1)
|
||||
expect(overlayMock.create).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('redirects without the dialog when a 401 while saving is handled globally', async () => {
|
||||
await mountApp({
|
||||
handlers: {
|
||||
[`PATCH ${DISPLAY_NAME_PATH}`]: () => {
|
||||
authRedirectPending.value = true
|
||||
router.push({
|
||||
path: '/login',
|
||||
query: { redirect: '/profile', reason: 'expired' },
|
||||
})
|
||||
return Promise.reject(new ApiError(401))
|
||||
},
|
||||
},
|
||||
})
|
||||
await typeInto('Marta Alves')
|
||||
|
||||
await clickSave()
|
||||
await flushPromises()
|
||||
|
||||
expect(overlayMock.create).not.toHaveBeenCalled()
|
||||
expect(router.currentRoute.value.fullPath).toBe('/login?redirect=/profile&reason=expired')
|
||||
expect(document.body.textContent).not.toContain(SAVE_FAILED_COPY)
|
||||
})
|
||||
})
|
||||
|
||||
describe('The Home greeting follows a display name saved on /profile', () => {
|
||||
it('shows the saved name on /home without a reload', async () => {
|
||||
vi.spyOn(Date.prototype, 'getHours').mockReturnValue(9)
|
||||
await mountApp({
|
||||
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') },
|
||||
})
|
||||
await typeInto('Marta Alves')
|
||||
await clickSave()
|
||||
|
||||
await router.push('/home')
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.querySelector('[data-testid="greeting"]')?.textContent?.trim()).toBe(
|
||||
'Good morning, Marta Alves',
|
||||
)
|
||||
expect(chip().textContent).toContain('Marta Alves')
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Reset: sending, sent and Send again', () => {
|
||||
it('posts the session email with no confirmation and shows Sending… with no note', async () => {
|
||||
const pending = deferred()
|
||||
const { request } = await mountApp({
|
||||
session: { ...MARTA, displayName: 'Marta Alves' },
|
||||
handlers: { [`POST ${RESET_PATH}`]: () => pending.promise },
|
||||
})
|
||||
|
||||
await clickReset()
|
||||
|
||||
const [call] = callsTo(request, RESET_PATH, 'POST')
|
||||
expect(JSON.parse((call![1] as RequestInit).body as string)).toEqual({
|
||||
email: 'marta@renew.example',
|
||||
})
|
||||
expect(resetButton().textContent?.trim()).toBe('Sending…')
|
||||
expect(resetButton().disabled).toBe(true)
|
||||
expect(byTestId('reset-note')).toBeNull()
|
||||
expect(chip().textContent).toContain('Marta Alves')
|
||||
})
|
||||
|
||||
it('shows the sent note, sends again with the note hidden meanwhile, and resets on remount', async () => {
|
||||
const second = deferred()
|
||||
const responses = [() => Promise.resolve({ message: 'ok' }), () => second.promise]
|
||||
const { request } = await mountApp({
|
||||
handlers: { [`POST ${RESET_PATH}`]: () => responses.shift()!() },
|
||||
})
|
||||
|
||||
await clickReset()
|
||||
|
||||
expect(resetButton().textContent?.trim()).toBe('Send again')
|
||||
expect(resetButton().disabled).toBe(false)
|
||||
const note = byTestId('reset-note')!
|
||||
expect(note.getAttribute('role')).toBe('status')
|
||||
expect(note.textContent).toContain('Reset link sent')
|
||||
expect(note.textContent).toContain(
|
||||
'Check marta@renew.example. The link works once, within 1 hour.',
|
||||
)
|
||||
|
||||
await clickReset()
|
||||
|
||||
expect(callsTo(request, RESET_PATH, 'POST')).toHaveLength(2)
|
||||
expect(byTestId('reset-note')).toBeNull()
|
||||
|
||||
second.resolve({ message: 'ok' })
|
||||
await flushPromises()
|
||||
|
||||
expect(byTestId('reset-note')?.textContent).toContain('Reset link sent')
|
||||
|
||||
await router.push('/home')
|
||||
await flushPromises()
|
||||
await router.push('/profile')
|
||||
await flushPromises()
|
||||
|
||||
expect(resetButton().textContent?.trim()).toBe('Send reset link')
|
||||
expect(byTestId('reset-note')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Reset: too many requests disables the button for the Retry-After window', () => {
|
||||
it.each([
|
||||
['a Retry-After of 42', 42, 42],
|
||||
['a Retry-After of 300', 300, 300],
|
||||
['no Retry-After header', undefined, 300],
|
||||
['a Retry-After of 0', 0, 300],
|
||||
])(
|
||||
'with %s the button is enabled again after %i seconds',
|
||||
async (_label, retryAfter, seconds) => {
|
||||
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
|
||||
await mountApp({
|
||||
handlers: {
|
||||
[`POST ${RESET_PATH}`]: () => Promise.reject(new ApiError(429, retryAfter)),
|
||||
},
|
||||
})
|
||||
|
||||
await clickReset()
|
||||
|
||||
const note = byTestId('reset-note')!
|
||||
expect(note.getAttribute('role')).toBe('status')
|
||||
expect(note.textContent).toContain('Too many requests')
|
||||
expect(note.textContent).toContain(
|
||||
"You've asked for several links recently. Wait a few minutes, then try again.",
|
||||
)
|
||||
expect(resetButton().textContent?.trim()).toBe('Send reset link')
|
||||
expect(resetButton().disabled).toBe(true)
|
||||
|
||||
vi.advanceTimersByTime((seconds - 1) * 1000)
|
||||
await flushPromises()
|
||||
|
||||
expect(resetButton().disabled).toBe(true)
|
||||
|
||||
vi.advanceTimersByTime(1000)
|
||||
await flushPromises()
|
||||
|
||||
expect(resetButton().disabled).toBe(false)
|
||||
expect(byTestId('reset-note')).toBeNull()
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('Reset: failed leaves the button pressable', () => {
|
||||
it.each([
|
||||
['a 500 response', SERVER_ERROR],
|
||||
['a network failure', NETWORK_ERROR],
|
||||
])(
|
||||
'after %s shows the error note and recovers on a successful retry',
|
||||
async (_label, failure) => {
|
||||
const responses = [failure, () => Promise.resolve({ message: 'ok' })]
|
||||
await mountApp({
|
||||
handlers: { [`POST ${RESET_PATH}`]: () => responses.shift()!() },
|
||||
})
|
||||
|
||||
await clickReset()
|
||||
|
||||
const note = byTestId('reset-note')!
|
||||
expect(note.getAttribute('role')).toBe('status')
|
||||
expect(note.textContent).toContain("Couldn't send the link")
|
||||
expect(note.textContent).toContain('Check your connection and try again.')
|
||||
expect(resetButton().textContent?.trim()).toBe('Send reset link')
|
||||
expect(resetButton().disabled).toBe(false)
|
||||
|
||||
await clickReset()
|
||||
|
||||
expect(byTestId('reset-note')?.textContent).toContain('Reset link sent')
|
||||
expect(resetButton().textContent?.trim()).toBe('Send again')
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -288,6 +288,32 @@ describe('Default — the list loads newest first, with display name, "you" tag
|
||||
})
|
||||
})
|
||||
|
||||
describe('Display names in the Users list', () => {
|
||||
it('shows each display name with its username beside it and the chip with the session name', async () => {
|
||||
const fixture = defaultFixture().map((item) =>
|
||||
item.kind === 'user' && item.username === 'marta'
|
||||
? { ...item, display_name: 'Marta Alves' }
|
||||
: item,
|
||||
)
|
||||
await mountPage({ list: () => Promise.resolve(fixture) })
|
||||
useSessionStore().session = {
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
displayName: 'Marta Alves',
|
||||
}
|
||||
await flushPromises()
|
||||
|
||||
expect(rowTexts().slice(2)).toEqual([
|
||||
'Inès Duarte ines Accepted 18 Jun 2026',
|
||||
'jonas Accepted 2 Apr 2026',
|
||||
'Marta Alves marta you Accepted 12 Mar 2026',
|
||||
])
|
||||
const footer = document.body.querySelector('[data-slot="footer"]') as HTMLElement
|
||||
expect(footer.textContent).toContain('Marta Alves')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Loading — skeleton rows after 150 ms', () => {
|
||||
it('draws nothing until 150 ms, then five skeleton rows, then the rows', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
|
||||
@@ -6,11 +6,7 @@ import router from '@/router'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import VerifyView from '../VerifyView.vue'
|
||||
|
||||
function mockResponse(
|
||||
status: number,
|
||||
body: unknown = null,
|
||||
headers: Record<string, string> = {},
|
||||
) {
|
||||
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
@@ -24,9 +20,7 @@ function routeFor(url: string, init?: RequestInit) {
|
||||
return `${method} ${url}`
|
||||
}
|
||||
|
||||
function fetchRouter(
|
||||
handlers: Record<string, () => Response | Promise<Response>>,
|
||||
) {
|
||||
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
|
||||
return vi.fn((url: string, init?: RequestInit) => {
|
||||
const key = routeFor(url, init)
|
||||
const handler = handlers[key]
|
||||
@@ -81,16 +75,11 @@ afterEach(async () => {
|
||||
describe('Verifying — the code submits automatically on load', () => {
|
||||
it('shows the full-card spinner and calls verify once with the code', async () => {
|
||||
const verify = vi.fn(() => new Promise(() => {}))
|
||||
const { wrapper, fetchMock } = await mountVerify(
|
||||
'/register/validate?c=abc123',
|
||||
{
|
||||
const { wrapper, fetchMock } = await mountVerify('/register/validate?c=abc123', {
|
||||
'POST /v1/auth/verify': verify,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
expect(wrapper.find('[role="status"]').text()).toContain(
|
||||
'Verifying your account…',
|
||||
)
|
||||
expect(wrapper.find('[role="status"]').text()).toContain('Verifying your account…')
|
||||
expect(verify).toHaveBeenCalledTimes(1)
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/v1/auth/verify',
|
||||
@@ -119,16 +108,13 @@ describe('Success — 200 refreshes the session and navigates to /home with a to
|
||||
description: 'Welcome to RenewCMS.',
|
||||
})
|
||||
|
||||
await vi.waitFor(
|
||||
() => expect(router.currentRoute.value.path).toBe('/home'),
|
||||
{
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'), {
|
||||
timeout: 2000,
|
||||
},
|
||||
)
|
||||
})
|
||||
expect(toastAdd).toHaveBeenCalledTimes(1)
|
||||
|
||||
const session = useSessionStore()
|
||||
expect(session.session).toEqual({ verified: true })
|
||||
expect(session.session).toMatchObject({ verified: true, displayName: '' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -152,9 +138,7 @@ describe('Invalid or expired — 403 offers a way to start over', () => {
|
||||
expect(wrapper.text()).toContain(
|
||||
'Cancel your registration and start over to get a new link.',
|
||||
)
|
||||
expect(
|
||||
findButton(wrapper, 'Cancel registration and start over'),
|
||||
).toBeTruthy()
|
||||
expect(findButton(wrapper, 'Cancel registration and start over')).toBeTruthy()
|
||||
expect(findButton(wrapper, 'Log out')).toBeTruthy()
|
||||
expect(router.currentRoute.value.path).toBe('/register/validate')
|
||||
expect(pushSpy).not.toHaveBeenCalledWith('/register/pending')
|
||||
@@ -165,27 +149,19 @@ describe('Invalid or expired — 403 offers a way to start over', () => {
|
||||
describe('Cancel registration and start over — success', () => {
|
||||
it('sends DELETE with onForbidden passthrough, navigates to /register and toasts', async () => {
|
||||
const deleteRegister = vi.fn(() => mockResponse(200))
|
||||
const { wrapper, fetchMock } = await mountVerify(
|
||||
'/register/validate?c=abc123',
|
||||
{
|
||||
const { wrapper, fetchMock } = await mountVerify('/register/validate?c=abc123', {
|
||||
'POST /v1/auth/verify': () => mockResponse(403),
|
||||
'DELETE /v1/auth/register': deleteRegister,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
await findButton(
|
||||
wrapper,
|
||||
'Cancel registration and start over',
|
||||
)?.trigger('click')
|
||||
await findButton(wrapper, 'Cancel registration and start over')?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/v1/auth/register',
|
||||
expect.objectContaining({ method: 'DELETE' }),
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(router.currentRoute.value.path).toBe('/register'),
|
||||
)
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register'))
|
||||
expect(toastAdd).toHaveBeenCalledWith({
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
@@ -206,38 +182,25 @@ describe('Cancel registration and start over — failure shows an alert, not a r
|
||||
name: 'a network failure',
|
||||
handler: () => Promise.reject(new TypeError('Failed to fetch')),
|
||||
},
|
||||
])(
|
||||
'shows the failure alert and keeps the panel for $name',
|
||||
async ({ handler }) => {
|
||||
const { wrapper } = await mountVerify(
|
||||
'/register/validate?c=abc123',
|
||||
{
|
||||
])('shows the failure alert and keeps the panel for $name', async ({ handler }) => {
|
||||
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
|
||||
'POST /v1/auth/verify': () => mockResponse(403),
|
||||
'DELETE /v1/auth/register': handler,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
await findButton(
|
||||
wrapper,
|
||||
'Cancel registration and start over',
|
||||
)?.trigger('click')
|
||||
await findButton(wrapper, 'Cancel registration and start over')?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain("Couldn't cancel registration")
|
||||
expect(wrapper.text()).toContain(
|
||||
'Something went wrong on the server. Try again in a moment.',
|
||||
)
|
||||
expect(wrapper.text()).toContain(
|
||||
"This verification link doesn't work",
|
||||
)
|
||||
expect(
|
||||
findButton(wrapper, 'Cancel registration and start over'),
|
||||
).toBeTruthy()
|
||||
expect(wrapper.text()).toContain("This verification link doesn't work")
|
||||
expect(findButton(wrapper, 'Cancel registration and start over')).toBeTruthy()
|
||||
expect(findButton(wrapper, 'Log out')).toBeTruthy()
|
||||
expect(router.currentRoute.value.path).toBe('/register/validate')
|
||||
expect(toastAdd).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Log out from the Invalid-or-expired state', () => {
|
||||
@@ -254,9 +217,7 @@ describe('Log out from the Invalid-or-expired state', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(session.session).toBeNull()
|
||||
await vi.waitFor(() =>
|
||||
expect(router.currentRoute.value.path).toBe('/login'),
|
||||
)
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -270,15 +231,9 @@ describe('No session — the inline log-in form replaces the spinner', () => {
|
||||
expect(wrapper.text()).toContain(
|
||||
"You opened this link somewhere you're not logged in. Log in here and we'll verify your account right after.",
|
||||
)
|
||||
expect(
|
||||
wrapper.find('input[placeholder="Your username"]').exists(),
|
||||
).toBe(true)
|
||||
expect(
|
||||
wrapper.find('input[autocomplete="current-password"]').exists(),
|
||||
).toBe(true)
|
||||
expect(wrapper.find('button[type="submit"]').text()).toBe(
|
||||
'Log in and verify',
|
||||
)
|
||||
expect(wrapper.find('input[placeholder="Your username"]').exists()).toBe(true)
|
||||
expect(wrapper.find('input[autocomplete="current-password"]').exists()).toBe(true)
|
||||
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in and verify')
|
||||
expect(wrapper.text()).not.toContain('Forgot password?')
|
||||
expect(wrapper.text()).not.toContain('First time here?')
|
||||
expect(router.currentRoute.value.path).toBe('/register/validate')
|
||||
@@ -310,26 +265,17 @@ describe('No session — successful login retries verification with the same cod
|
||||
const verify = vi.fn()
|
||||
verify.mockResolvedValueOnce(mockResponse(401))
|
||||
verify.mockImplementationOnce(() => new Promise(() => {}))
|
||||
const { wrapper, fetchMock } = await mountVerify(
|
||||
'/register/validate?c=abc123',
|
||||
{
|
||||
const { wrapper, fetchMock } = await mountVerify('/register/validate?c=abc123', {
|
||||
'POST /v1/auth/verify': verify,
|
||||
'POST /v1/auth/login': () => mockResponse(200),
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
await wrapper
|
||||
.find('input[placeholder="Your username"]')
|
||||
.setValue('marta')
|
||||
await wrapper
|
||||
.find('input[autocomplete="current-password"]')
|
||||
.setValue('limewash2026')
|
||||
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
|
||||
await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[role="status"]').text()).toContain(
|
||||
'Verifying your account…',
|
||||
)
|
||||
expect(wrapper.find('[role="status"]').text()).toContain('Verifying your account…')
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/v1/auth/verify',
|
||||
expect.objectContaining({
|
||||
@@ -349,24 +295,14 @@ describe('No session — wrong credentials during the inline login', () => {
|
||||
'POST /v1/auth/login': () => mockResponse(403),
|
||||
})
|
||||
|
||||
await wrapper
|
||||
.find('input[placeholder="Your username"]')
|
||||
.setValue('marta')
|
||||
await wrapper
|
||||
.find('input[autocomplete="current-password"]')
|
||||
.setValue('wrong-password')
|
||||
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
|
||||
await wrapper.find('input[autocomplete="current-password"]').setValue('wrong-password')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain(
|
||||
"That username and password don't match",
|
||||
)
|
||||
expect(wrapper.text()).toContain(
|
||||
'Check both and try again. Passwords are case-sensitive.',
|
||||
)
|
||||
const passwordInput = wrapper.find(
|
||||
'input[autocomplete="current-password"]',
|
||||
)
|
||||
expect(wrapper.text()).toContain("That username and password don't match")
|
||||
expect(wrapper.text()).toContain('Check both and try again. Passwords are case-sensitive.')
|
||||
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
|
||||
expect((passwordInput.element as HTMLInputElement).value).toBe('')
|
||||
expect(passwordInput.element).toBe(document.activeElement)
|
||||
expect(verify).toHaveBeenCalledTimes(1)
|
||||
@@ -378,23 +314,16 @@ describe('No session — too many login attempts', () => {
|
||||
vi.useFakeTimers()
|
||||
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
|
||||
'POST /v1/auth/verify': () => mockResponse(401),
|
||||
'POST /v1/auth/login': () =>
|
||||
mockResponse(429, null, { 'Retry-After': '30' }),
|
||||
'POST /v1/auth/login': () => mockResponse(429, null, { 'Retry-After': '30' }),
|
||||
})
|
||||
|
||||
await wrapper
|
||||
.find('input[placeholder="Your username"]')
|
||||
.setValue('marta')
|
||||
await wrapper
|
||||
.find('input[autocomplete="current-password"]')
|
||||
.setValue('limewash2026')
|
||||
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
|
||||
await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Too many attempts')
|
||||
expect(wrapper.text()).toContain(
|
||||
'For your security, wait a moment before trying again.',
|
||||
)
|
||||
expect(wrapper.text()).toContain('For your security, wait a moment before trying again.')
|
||||
const button = wrapper.find('button[type="submit"]')
|
||||
expect(button.text()).toBe('Try again in 0:30')
|
||||
expect((button.element as HTMLButtonElement).disabled).toBe(true)
|
||||
@@ -402,9 +331,7 @@ describe('No session — too many login attempts', () => {
|
||||
await vi.advanceTimersByTimeAsync(30000)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('button[type="submit"]').text()).toBe(
|
||||
'Log in and verify',
|
||||
)
|
||||
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in and verify')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -415,50 +342,31 @@ describe('No session — server or network error during the inline login', () =>
|
||||
name: 'a network failure',
|
||||
handler: () => Promise.reject(new TypeError('Failed to fetch')),
|
||||
},
|
||||
])(
|
||||
'shows the network/server alert and keeps the fields for $name',
|
||||
async ({ handler }) => {
|
||||
const { wrapper } = await mountVerify(
|
||||
'/register/validate?c=abc123',
|
||||
{
|
||||
])('shows the network/server alert and keeps the fields for $name', async ({ handler }) => {
|
||||
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
|
||||
'POST /v1/auth/verify': () => mockResponse(401),
|
||||
'POST /v1/auth/login': handler,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
await wrapper
|
||||
.find('input[placeholder="Your username"]')
|
||||
.setValue('marta')
|
||||
await wrapper
|
||||
.find('input[autocomplete="current-password"]')
|
||||
.setValue('limewash2026')
|
||||
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
|
||||
await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain("Couldn't reach RenewCMS")
|
||||
expect(wrapper.text()).toContain(
|
||||
'Check your connection and try again.',
|
||||
)
|
||||
expect(wrapper.text()).toContain('Check your connection and try again.')
|
||||
expect(
|
||||
(
|
||||
wrapper.find('input[placeholder="Your username"]')
|
||||
.element as HTMLInputElement
|
||||
).value,
|
||||
(wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement).value,
|
||||
).toBe('marta')
|
||||
expect(
|
||||
(
|
||||
wrapper.find('input[autocomplete="current-password"]')
|
||||
.element as HTMLInputElement
|
||||
).value,
|
||||
(wrapper.find('input[autocomplete="current-password"]').element as HTMLInputElement)
|
||||
.value,
|
||||
).toBe('limewash2026')
|
||||
expect(
|
||||
(
|
||||
wrapper.find('input[placeholder="Your username"]')
|
||||
.element as HTMLInputElement
|
||||
).disabled,
|
||||
(wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement)
|
||||
.disabled,
|
||||
).toBe(false)
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Missing code — no request is made', () => {
|
||||
@@ -475,8 +383,6 @@ describe('Missing code — no request is made', () => {
|
||||
expect(action).toBeTruthy()
|
||||
await action?.trigger('click')
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(router.currentRoute.value.path).toBe('/login'),
|
||||
)
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
|
||||
})
|
||||
})
|
||||
+165
-1
@@ -152,7 +152,13 @@ const sixArticlesFixture = [
|
||||
|
||||
const verifiedSession: ApiMock = {
|
||||
status: 200,
|
||||
body: { id: 1, is_verified: true, username: 'marta' },
|
||||
body: {
|
||||
id: 1,
|
||||
is_verified: true,
|
||||
username: 'marta',
|
||||
email: 'marta@renew.example',
|
||||
display_name: '',
|
||||
},
|
||||
}
|
||||
|
||||
const editorNow = '2026-09-26T11:02:00Z'
|
||||
@@ -667,7 +673,165 @@ function submitAcceptInvite(username: string, password: string) {
|
||||
}
|
||||
}
|
||||
|
||||
const profileDisplayName = 'Marta Alves'
|
||||
const profileFieldPlaceholder = 'e.g. Marta Alves'
|
||||
const profileTooLongName = 'Marta Alves, Head of Renovations, Brussels and Wallonia office'
|
||||
const profileSavedBody = {
|
||||
id: 1,
|
||||
username: 'marta',
|
||||
display_name: profileDisplayName,
|
||||
}
|
||||
|
||||
function profileSession(displayName = ''): ApiMock {
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
id: 1,
|
||||
is_verified: true,
|
||||
username: 'marta',
|
||||
email: 'marta@renew.example',
|
||||
display_name: displayName,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function profileApi(
|
||||
displayName = '',
|
||||
overrides: Record<string, ApiMock> = {},
|
||||
): Record<string, ApiMock> {
|
||||
return {
|
||||
'GET /v1/auth/me': profileSession(displayName),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function profileState(
|
||||
displayName = '',
|
||||
overrides: Record<string, ApiMock> = {},
|
||||
steps?: (page: Page) => Promise<void>,
|
||||
): VisualState {
|
||||
return { path: '/profile', api: profileApi(displayName, overrides), steps }
|
||||
}
|
||||
|
||||
async function typeDisplayName(page: Page, value: string) {
|
||||
await page.getByPlaceholder(profileFieldPlaceholder).fill(value)
|
||||
}
|
||||
|
||||
async function scrollToTop(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
window.scrollTo(0, 0)
|
||||
document.querySelectorAll<HTMLElement>('[data-slot="body"]').forEach((element) => {
|
||||
element.scrollTop = 0
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function pressSave(page: Page) {
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click()
|
||||
}
|
||||
|
||||
async function saveDisplayName(page: Page, value: string) {
|
||||
await typeDisplayName(page, value)
|
||||
await pressSave(page)
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
|
||||
async function pressSendResetLink(page: Page) {
|
||||
await page.getByRole('button', { name: 'Send reset link', exact: true }).click()
|
||||
await page.mouse.move(0, 0)
|
||||
await scrollToTop(page)
|
||||
}
|
||||
|
||||
function resetState(response: ApiMock): VisualState {
|
||||
return profileState(
|
||||
profileDisplayName,
|
||||
{ 'POST /v1/auth/password-reset': response },
|
||||
pressSendResetLink,
|
||||
)
|
||||
}
|
||||
|
||||
async function openAccountMenu(page: Page) {
|
||||
const isMobile = (page.viewportSize()?.width ?? 0) < 1024
|
||||
if (isMobile) {
|
||||
await page.locator('[data-slot="toggle"]').click()
|
||||
await page.locator('[role="dialog"] button[aria-haspopup]').click()
|
||||
await page.locator('[role="menu"]').waitFor()
|
||||
} else {
|
||||
await page.locator('[data-slot="footer"] button[aria-haspopup]').click()
|
||||
await page.locator('[role="menu"]').waitFor()
|
||||
}
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
|
||||
const usersWithDisplayNames = usersFixture.map((item) =>
|
||||
item.kind === 'user' && item.username === 'marta'
|
||||
? { ...item, display_name: profileDisplayName }
|
||||
: item,
|
||||
)
|
||||
|
||||
export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
profile: {
|
||||
'Account menu': {
|
||||
path: '/home',
|
||||
now: boardNow,
|
||||
api: {
|
||||
'GET /v1/auth/me': verifiedSession,
|
||||
'GET /v1/article/': { status: 200, body: sixArticlesFixture },
|
||||
},
|
||||
steps: openAccountMenu,
|
||||
exception: {
|
||||
maxPercent: 26,
|
||||
reason: 'Mobile sheet is ~153px taller than the board because the System/Light/Dark items are kept by user decision, and the board draws Profile highlighted on /home while the feature highlights it only on /profile',
|
||||
approvedBy: 'Florian Sylvain, 2026-09-29',
|
||||
},
|
||||
},
|
||||
Loading: {
|
||||
path: '/profile',
|
||||
api: { 'GET /v1/auth/me': { status: 200, hang: true } },
|
||||
},
|
||||
'Default, no display name': profileState(),
|
||||
Editing: profileState('', {}, async (page) => {
|
||||
await typeDisplayName(page, profileDisplayName)
|
||||
await page.mouse.move(0, 0)
|
||||
}),
|
||||
'Too long': profileState('', {}, async (page) => {
|
||||
await typeDisplayName(page, profileTooLongName)
|
||||
await page.mouse.move(0, 0)
|
||||
}),
|
||||
Saving: profileState(
|
||||
'',
|
||||
{ 'PATCH /v1/user/me': { status: 200, body: profileSavedBody, hang: true } },
|
||||
async (page) => {
|
||||
await saveDisplayName(page, profileDisplayName)
|
||||
},
|
||||
),
|
||||
Saved: profileState(
|
||||
'',
|
||||
{ 'PATCH /v1/user/me': { status: 200, body: profileSavedBody } },
|
||||
async (page) => {
|
||||
await saveDisplayName(page, profileDisplayName)
|
||||
await page.getByText('Display name saved').first().waitFor()
|
||||
},
|
||||
),
|
||||
'Save failed': profileState('', { 'PATCH /v1/user/me': { status: 500 } }, async (page) => {
|
||||
await saveDisplayName(page, profileDisplayName)
|
||||
await page.getByText("Couldn't save your display name").waitFor()
|
||||
}),
|
||||
'Reset: sending': resetState({ status: 200, hang: true }),
|
||||
'Reset: sent': resetState({ status: 200, body: { message: 'ok' } }),
|
||||
'Reset: too many requests (429)': resetState({
|
||||
status: 429,
|
||||
headers: { 'Retry-After': '300' },
|
||||
}),
|
||||
'Reset: failed': resetState({ status: 500 }),
|
||||
'Display names in the Users list': {
|
||||
path: '/users',
|
||||
api: {
|
||||
'GET /v1/auth/me': profileSession(profileDisplayName),
|
||||
'GET /v1/user/': { status: 200, body: usersWithDisplayNames },
|
||||
},
|
||||
},
|
||||
},
|
||||
users: {
|
||||
Default: { path: '/users', api: usersApi() },
|
||||
Loading: {
|
||||
|
||||
Reference in new issue
Block a user