feat(SPA): profile page, display name and the new account menu

This commit is contained in:
fsylvain committed 2026-09-29 10:13:09 +02:00
1 parent 55525c06d6
commit b0f7b7892b
29 files changed
+3166 -469

No files matched your search

+165 -1
View File
@@ -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: {