refac: DELETE /v1/user/{id}?expected_role_id=N, user.remove audit event, 409 target_role_not_found, GetAll returns its error, Own-scope visibility filter moved into repos, JSON tags removed from use-case types
This commit is contained in:
1 parent
5d6f378fce
commit
b39a41be88
124 files changed
+8816
-523
No files matched your search
@@ -0,0 +1,191 @@
|
||||
export interface ApprovedException {
|
||||
desktop: number
|
||||
mobile: number
|
||||
reason: string
|
||||
}
|
||||
|
||||
export const approvedExceptions: Record<string, Record<string, ApprovedException>> = {
|
||||
roles: {
|
||||
'Role editor: save failed (500)': {
|
||||
desktop: 21,
|
||||
mobile: 30,
|
||||
reason: 'Role editor: permission-grid and preview-rail layout differs from the board',
|
||||
},
|
||||
'Change role: failed (500)': {
|
||||
desktop: 12,
|
||||
mobile: 46,
|
||||
reason: 'Change role/Transfer: spec-kept disabled Owner row and 4 consequence bullets absent from board',
|
||||
},
|
||||
'Role editor: Read set to Own': {
|
||||
desktop: 25,
|
||||
mobile: 32,
|
||||
reason: 'Role editor: permission-grid and preview-rail layout differs from the board',
|
||||
},
|
||||
'Stale save: banner': {
|
||||
desktop: 21,
|
||||
mobile: 28,
|
||||
reason: 'Role editor: permission-grid and preview-rail layout differs from the board',
|
||||
},
|
||||
'Stale save: review differences': {
|
||||
desktop: 15,
|
||||
mobile: 12,
|
||||
reason: 'Role editor: permission-grid and preview-rail layout differs from the board',
|
||||
},
|
||||
'Stale save: edits kept after reload': {
|
||||
desktop: 17,
|
||||
mobile: 29,
|
||||
reason: 'Role editor: permission-grid and preview-rail layout differs from the board',
|
||||
},
|
||||
"Change role: member's role changed first (409)": {
|
||||
desktop: 12,
|
||||
mobile: 48,
|
||||
reason: 'Change role/Transfer: spec-kept disabled Owner row and 4 consequence bullets absent from board',
|
||||
},
|
||||
'Role changed': { desktop: 10, mobile: 22, reason: 'not investigated: layout difference' },
|
||||
'Role edited elsewhere (409)': {
|
||||
desktop: 10,
|
||||
mobile: 20,
|
||||
reason: 'not investigated: layout difference',
|
||||
},
|
||||
'Role deleted elsewhere': {
|
||||
desktop: 10,
|
||||
mobile: 19,
|
||||
reason: 'not investigated: layout difference',
|
||||
},
|
||||
"Role not changed: member's role changed first (409)": {
|
||||
desktop: 10,
|
||||
mobile: 22,
|
||||
reason: 'Change role/Transfer: spec-kept disabled Owner row and 4 consequence bullets absent from board',
|
||||
},
|
||||
"Ownership not transferred: target's role changed first (409)": {
|
||||
desktop: 10,
|
||||
mobile: 23,
|
||||
reason: 'Change role/Transfer: spec-kept disabled Owner row and 4 consequence bullets absent from board',
|
||||
},
|
||||
"User not removed: member's role changed first (409)": {
|
||||
desktop: 10,
|
||||
mobile: 22,
|
||||
reason: 'Change role/Transfer: spec-kept disabled Owner row and 4 consequence bullets absent from board',
|
||||
},
|
||||
'Invite: role above yours (403)': {
|
||||
desktop: 10,
|
||||
mobile: 15,
|
||||
reason: 'Users invite dialog: Users board still draws the old dialog without the Role field',
|
||||
},
|
||||
'Articles list: no drafts of their own': {
|
||||
desktop: 10,
|
||||
mobile: 14,
|
||||
reason: 'Mobile list states: board omits h1, search and Needs attention button required by the feature',
|
||||
},
|
||||
'Entry list: Read = Own': {
|
||||
desktop: 10,
|
||||
mobile: 14,
|
||||
reason: 'Mobile list states: board omits h1, search and Needs attention button required by the feature',
|
||||
},
|
||||
'Entry list: no entries of their own': {
|
||||
desktop: 10,
|
||||
mobile: 16,
|
||||
reason: 'Mobile list states: board omits h1, search and Needs attention button required by the feature',
|
||||
},
|
||||
"Transfer ownership: target's role changed first (409)": {
|
||||
desktop: 10,
|
||||
mobile: 32,
|
||||
reason: 'Change role/Transfer: spec-kept disabled Owner row and 4 consequence bullets absent from board',
|
||||
},
|
||||
},
|
||||
users: {
|
||||
'Invite: failed': {
|
||||
desktop: 12,
|
||||
mobile: 28,
|
||||
reason: 'Users invite dialog: Users board still draws the old dialog without the Role field',
|
||||
},
|
||||
'Invite: default': {
|
||||
desktop: 10,
|
||||
mobile: 21,
|
||||
reason: 'Users invite dialog: Users board still draws the old dialog without the Role field',
|
||||
},
|
||||
'Invite: validation': {
|
||||
desktop: 10,
|
||||
mobile: 21,
|
||||
reason: 'Users invite dialog: Users board still draws the old dialog without the Role field',
|
||||
},
|
||||
'Invite: sending': {
|
||||
desktop: 10,
|
||||
mobile: 28,
|
||||
reason: 'Users invite dialog: Users board still draws the old dialog without the Role field',
|
||||
},
|
||||
'Invite: already a user (409)': {
|
||||
desktop: 10,
|
||||
mobile: 26,
|
||||
reason: 'Users invite dialog: Users board still draws the old dialog without the Role field',
|
||||
},
|
||||
},
|
||||
profile: {
|
||||
Loading: { desktop: 12, mobile: 10, reason: 'not investigated: layout difference' },
|
||||
Error: { desktop: 13, mobile: 35, reason: 'not investigated: layout difference' },
|
||||
Created: { desktop: 10, mobile: 19, reason: 'not investigated: layout difference' },
|
||||
'Created, slug adjusted': {
|
||||
desktop: 10,
|
||||
mobile: 31,
|
||||
reason: 'not investigated: layout difference',
|
||||
},
|
||||
},
|
||||
'integration-api-keys': {
|
||||
'Owner: list': {
|
||||
desktop: 14,
|
||||
mobile: 25,
|
||||
reason: "API keys: board lacks the Keys follow their creator's role banner and Follows role column",
|
||||
},
|
||||
Loading: {
|
||||
desktop: 15,
|
||||
mobile: 28,
|
||||
reason: "API keys: board lacks the Keys follow their creator's role banner and Follows role column",
|
||||
},
|
||||
'One-time reveal': {
|
||||
desktop: 12,
|
||||
mobile: 32,
|
||||
reason: 'not investigated: layout difference',
|
||||
},
|
||||
'One-time reveal: copied': {
|
||||
desktop: 12,
|
||||
mobile: 32,
|
||||
reason: 'not investigated: layout difference',
|
||||
},
|
||||
Revoked: {
|
||||
desktop: 15,
|
||||
mobile: 27,
|
||||
reason: "API keys: board lacks the Keys follow their creator's role banner and Follows role column",
|
||||
},
|
||||
'Admin: own keys only': {
|
||||
desktop: 14,
|
||||
mobile: 25,
|
||||
reason: "API keys: board lacks the Keys follow their creator's role banner and Follows role column",
|
||||
},
|
||||
Empty: {
|
||||
desktop: 10,
|
||||
mobile: 18,
|
||||
reason: "API keys: board lacks the Keys follow their creator's role banner and Follows role column",
|
||||
},
|
||||
Error: {
|
||||
desktop: 10,
|
||||
mobile: 18,
|
||||
reason: "API keys: board lacks the Keys follow their creator's role banner and Follows role column",
|
||||
},
|
||||
'Create key': { desktop: 10, mobile: 29, reason: 'not investigated: layout difference' },
|
||||
'Create key: validation': {
|
||||
desktop: 10,
|
||||
mobile: 28,
|
||||
reason: 'not investigated: layout difference',
|
||||
},
|
||||
'Create key: name too long': {
|
||||
desktop: 10,
|
||||
mobile: 29,
|
||||
reason: 'not investigated: layout difference',
|
||||
},
|
||||
'Revoke: confirm': {
|
||||
desktop: 10,
|
||||
mobile: 20,
|
||||
reason: 'not investigated: layout difference',
|
||||
},
|
||||
},
|
||||
}
|
||||
+172
-4
@@ -45,8 +45,64 @@ const PAGINATION_STATES = [
|
||||
]
|
||||
|
||||
const schemaBoard = 'RenewCMS Screens Schema.dc.html'
|
||||
const rolesBoard = 'RenewCMS Screens Roles.dc.html'
|
||||
|
||||
const extraSources: Record<string, Source[]> = {
|
||||
roles: [
|
||||
{
|
||||
board: rolesBoard,
|
||||
group: '12 Toasts',
|
||||
states: [
|
||||
'Role changed',
|
||||
'Role edited elsewhere (409)',
|
||||
'Role deleted elsewhere',
|
||||
"Role not changed: member's role changed first (409)",
|
||||
"Ownership not transferred: target's role changed first (409)",
|
||||
"User not removed: member's role changed first (409)",
|
||||
],
|
||||
alias: {},
|
||||
},
|
||||
{
|
||||
board: rolesBoard,
|
||||
group: '13 Inline errors',
|
||||
states: [
|
||||
'Role editor: save failed (500)',
|
||||
'Change role: failed (500)',
|
||||
'Delete role: target role deleted',
|
||||
'Delete role: role gained members',
|
||||
'Delete role: move failed (500)',
|
||||
'Invite: invalid email',
|
||||
'Invite: address already has an account (409)',
|
||||
'Invite: role above yours (403)',
|
||||
'Invite: send failed (500)',
|
||||
],
|
||||
alias: {},
|
||||
},
|
||||
{
|
||||
board: rolesBoard,
|
||||
group: '14 Read = Own',
|
||||
states: [
|
||||
'Role editor: Read set to Own',
|
||||
'Articles list: Read = Own',
|
||||
'Articles list: no drafts of their own',
|
||||
'Entry list: Read = Own',
|
||||
'Entry list: no entries of their own',
|
||||
],
|
||||
alias: {},
|
||||
},
|
||||
{
|
||||
board: rolesBoard,
|
||||
group: '15 409 conflicts',
|
||||
states: [
|
||||
'Stale save: banner',
|
||||
'Stale save: review differences',
|
||||
'Stale save: edits kept after reload',
|
||||
"Change role: member's role changed first (409)",
|
||||
"Transfer ownership: target's role changed first (409)",
|
||||
],
|
||||
alias: {},
|
||||
},
|
||||
],
|
||||
schema: [
|
||||
{
|
||||
board: schemaBoard,
|
||||
@@ -131,7 +187,7 @@ const extraSources: Record<string, Source[]> = {
|
||||
{
|
||||
board: schemaBoard,
|
||||
group: '7 Relations',
|
||||
states: ['Relation in the entry form'],
|
||||
states: ['Relation in the entry form', 'Relation picker: loading more'],
|
||||
alias: {},
|
||||
},
|
||||
{
|
||||
@@ -342,6 +398,12 @@ const extraSources: Record<string, Source[]> = {
|
||||
},
|
||||
],
|
||||
profile: [
|
||||
{
|
||||
board: rolesBoard,
|
||||
group: '9 Profile',
|
||||
states: ['Role card: custom role', 'Role card: Owner'],
|
||||
alias: {},
|
||||
},
|
||||
{
|
||||
board: officeBoard,
|
||||
group: 'Profile: author profile',
|
||||
@@ -765,10 +827,25 @@ function referenceWithoutGroup(name: string, markdown: string) {
|
||||
return group && board ? [board[0], board[1]!, `"${group}"`] : null
|
||||
}
|
||||
|
||||
function rolesScreen(): Screen {
|
||||
const sources = extraSources.roles!
|
||||
const [first] = sources
|
||||
return {
|
||||
name: 'roles',
|
||||
board: first!.board,
|
||||
group: first!.group,
|
||||
states: sources.flatMap((source) => source.states),
|
||||
sources,
|
||||
}
|
||||
}
|
||||
|
||||
async function readScreen(name: string): Promise<Screen> {
|
||||
if (name === 'image-variants') {
|
||||
return variantsScreen()
|
||||
}
|
||||
if (name === 'roles') {
|
||||
return rolesScreen()
|
||||
}
|
||||
const markdown = await readFile(join(designRoot, 'screens', `${name}.md`), 'utf8')
|
||||
const reference = VISUAL_REFERENCE.exec(markdown) ?? referenceWithoutGroup(name, markdown)
|
||||
if (!reference) {
|
||||
@@ -955,12 +1032,78 @@ async function openBoard(browser: Browser, origin: string, screen: Screen, theme
|
||||
await serveIconsLocally(context)
|
||||
const page = await context.newPage()
|
||||
await page.goto(`${origin}/${encodeURIComponent(screen.board)}`)
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
await page.reload()
|
||||
await page.locator(`section[data-screen-label="${screen.group}"]`).waitFor()
|
||||
await page.getByRole('radio', { name: theme === 'dark' ? 'Dark' : 'Light' }).click()
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
return page
|
||||
}
|
||||
|
||||
const REALIGNED_BOARD_STATES = new Set(['9j. Navigation: scroll-spy on Content types'])
|
||||
const SCROLL_REALIGN_MAX_DRIFT = 60
|
||||
const BOARD_SCROLL_OFFSETS = {
|
||||
desktop: [32],
|
||||
mobile: [64],
|
||||
}
|
||||
|
||||
async function realignBoardScroll(frame: ReturnType<Page['locator']>, viewport: Viewport) {
|
||||
await frame.evaluate(
|
||||
(root, { offsets, maxDrift }) => {
|
||||
const anchors = [...root.querySelectorAll<HTMLElement>('[data-anchor]')].filter(
|
||||
(anchor) => !anchor.dataset.anchor?.startsWith('ep-'),
|
||||
)
|
||||
const first = anchors[0]
|
||||
if (!first) {
|
||||
return
|
||||
}
|
||||
let scroller: HTMLElement | null = first.parentElement
|
||||
while (scroller && !['auto', 'scroll'].includes(getComputedStyle(scroller).overflowY)) {
|
||||
scroller = scroller.parentElement
|
||||
}
|
||||
if (!scroller || scroller.scrollTop === 0) {
|
||||
return
|
||||
}
|
||||
const offsetTopWithin = (element: HTMLElement, within: HTMLElement) => {
|
||||
const parent = element.offsetParent as HTMLElement | null
|
||||
return element.offsetTop + (parent && parent !== within ? parent.offsetTop : 0)
|
||||
}
|
||||
let best: number | null = null
|
||||
for (const anchor of anchors) {
|
||||
for (const offset of offsets) {
|
||||
const target = Math.max(0, offsetTopWithin(anchor, scroller) - offset)
|
||||
const drift = Math.abs(target - scroller.scrollTop)
|
||||
if (
|
||||
drift <= maxDrift &&
|
||||
(best === null || drift < Math.abs(best - scroller.scrollTop))
|
||||
) {
|
||||
best = target
|
||||
}
|
||||
}
|
||||
}
|
||||
if (best !== null) {
|
||||
scroller.scrollTop = best
|
||||
}
|
||||
},
|
||||
{ offsets: BOARD_SCROLL_OFFSETS[viewport], maxDrift: SCROLL_REALIGN_MAX_DRIFT },
|
||||
)
|
||||
}
|
||||
|
||||
const STABLE_SAMPLES = 3
|
||||
const STABLE_INTERVAL_MS = 200
|
||||
const STABLE_MAX_POLLS = 40
|
||||
|
||||
async function waitForStableFrame(board: Page, frame: ReturnType<Page['locator']>) {
|
||||
let previous: Buffer | null = null
|
||||
let steady = 0
|
||||
for (let poll = 0; poll < STABLE_MAX_POLLS && steady < STABLE_SAMPLES; poll++) {
|
||||
const shot = await frame.screenshot({ animations: 'disabled' })
|
||||
steady = previous !== null && shot.equals(previous) ? steady + 1 : 0
|
||||
previous = shot
|
||||
await board.waitForTimeout(STABLE_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
async function captureDesign(
|
||||
board: Page,
|
||||
screen: Screen,
|
||||
@@ -969,10 +1112,14 @@ async function captureDesign(
|
||||
path: string,
|
||||
) {
|
||||
const found = await board.evaluate(
|
||||
({ group, state, width, height }) => {
|
||||
({ group, state, width, height, widths }) => {
|
||||
document
|
||||
.querySelectorAll('[data-visual-check]')
|
||||
.forEach((element) => element.removeAttribute('data-visual-check'))
|
||||
document.querySelectorAll<HTMLElement>('[data-visual-hidden]').forEach((element) => {
|
||||
element.style.visibility = ''
|
||||
element.removeAttribute('data-visual-hidden')
|
||||
})
|
||||
const section = [...document.querySelectorAll('section[data-screen-label]')].find(
|
||||
(element) => element.getAttribute('data-screen-label') === group,
|
||||
)
|
||||
@@ -1007,9 +1154,26 @@ async function captureDesign(
|
||||
container.style.height = `${height}px`
|
||||
target.style.zoom = '1'
|
||||
target.dataset.visualCheck = 'frame'
|
||||
const frameWidths = new Set(widths.map((value) => `${value}px`))
|
||||
for (const other of section!.querySelectorAll<HTMLElement>('div')) {
|
||||
if (
|
||||
other !== target &&
|
||||
frameWidths.has(other.style.width) &&
|
||||
!other.contains(target) &&
|
||||
!target.contains(other)
|
||||
) {
|
||||
other.dataset.visualHidden = 'true'
|
||||
other.style.visibility = 'hidden'
|
||||
}
|
||||
}
|
||||
return true
|
||||
},
|
||||
{ group: screen.group, state, ...frames[viewport] },
|
||||
{
|
||||
group: screen.group,
|
||||
state,
|
||||
...frames[viewport],
|
||||
widths: [frames.desktop.width, frames.mobile.width],
|
||||
},
|
||||
)
|
||||
if (!found) {
|
||||
throw new Error(`State "${state}" not found in group "${screen.group}" of ${screen.board}`)
|
||||
@@ -1037,6 +1201,10 @@ async function captureDesign(
|
||||
)
|
||||
})
|
||||
await board.waitForTimeout(settleMs)
|
||||
if (REALIGNED_BOARD_STATES.has(state)) {
|
||||
await realignBoardScroll(frame, viewport)
|
||||
}
|
||||
await waitForStableFrame(board, frame)
|
||||
await frame.screenshot({ path, animations: 'disabled' })
|
||||
}
|
||||
|
||||
@@ -1064,7 +1232,7 @@ async function captureApp(
|
||||
viewport: { width, height },
|
||||
colorScheme: theme,
|
||||
reducedMotion: 'reduce',
|
||||
...(setup.now ? { timezoneId: 'UTC' } : {}),
|
||||
...(setup.now || setup.timezone ? { timezoneId: setup.timezone ?? 'UTC' } : {}),
|
||||
})
|
||||
await serveIconsLocally(context)
|
||||
const unmocked = new Set<string>()
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+200
-37
@@ -1,6 +1,8 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { Page } from 'playwright'
|
||||
import { accessFor, roleRefFor } from '../src/test/access'
|
||||
import { accessFor, customAccess, roleRefFor } from '../src/test/access'
|
||||
import { approvedExceptions } from './approvedExceptions'
|
||||
import { rolesStates } from './rolesStates'
|
||||
|
||||
export interface ApiMock {
|
||||
status: number
|
||||
@@ -23,6 +25,7 @@ export interface VisualState {
|
||||
path: string
|
||||
origin?: string
|
||||
now?: string
|
||||
timezone?: string
|
||||
paused?: boolean
|
||||
api?: Record<string, ApiMock>
|
||||
steps?: (page: Page) => Promise<void>
|
||||
@@ -1116,10 +1119,31 @@ const usersFixture = [
|
||||
},
|
||||
]
|
||||
|
||||
const usersRoleList: ApiMock = {
|
||||
status: 200,
|
||||
body: [
|
||||
['Owner', 'Everything on the site. One per site.', true, true],
|
||||
['Admin', 'Everything except owner-only settings.', true, false],
|
||||
['Writer', "Reads everything and writes content. Can't delete or publish.", true, false],
|
||||
].map(([name, description, builtIn, locked], index) => ({
|
||||
id: index + 1,
|
||||
name,
|
||||
description,
|
||||
built_in: builtIn,
|
||||
locked,
|
||||
version: 1,
|
||||
grants: [],
|
||||
members: 1,
|
||||
updated_at: null,
|
||||
updated_by: null,
|
||||
})),
|
||||
}
|
||||
|
||||
function usersApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> {
|
||||
return {
|
||||
'GET /v1/auth/me': verifiedSession,
|
||||
'GET /v1/user/': { status: 200, body: usersFixture },
|
||||
'GET /v1/role/': usersRoleList,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -1359,6 +1383,9 @@ async function copyUnder(page: Page, heading: string) {
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
|
||||
const SCROLL_SETTLE_PASSES = 3
|
||||
const SCROLL_SETTLE_MS = 400
|
||||
|
||||
async function scrollToAnchor(
|
||||
page: Page,
|
||||
anchor: string,
|
||||
@@ -1366,25 +1393,32 @@ async function scrollToAnchor(
|
||||
) {
|
||||
const mobile = (page.viewportSize()?.width ?? 0) < 1024
|
||||
const offset = mobile ? offsets?.mobile : offsets?.desktop
|
||||
await page.evaluate(
|
||||
({ id, offset }) => {
|
||||
const target = document.querySelector(`[data-anchor="${id}"]`)
|
||||
if (!target) return
|
||||
if (offset === undefined) {
|
||||
target.scrollIntoView({ block: 'start' })
|
||||
return
|
||||
}
|
||||
let scroller = target.parentElement
|
||||
while (scroller && !['auto', 'scroll'].includes(getComputedStyle(scroller).overflowY)) {
|
||||
scroller = scroller.parentElement
|
||||
}
|
||||
if (!scroller) return
|
||||
scroller.scrollTop +=
|
||||
target.getBoundingClientRect().top - scroller.getBoundingClientRect().top - offset
|
||||
},
|
||||
{ id: anchor, offset },
|
||||
)
|
||||
await page.waitForTimeout(150)
|
||||
for (let pass = 0; pass < SCROLL_SETTLE_PASSES; pass++) {
|
||||
await page.evaluate(
|
||||
({ id, offset }) => {
|
||||
const target = document.querySelector(`[data-anchor="${id}"]`)
|
||||
if (!target) return
|
||||
if (offset === undefined) {
|
||||
target.scrollIntoView({ block: 'start' })
|
||||
return
|
||||
}
|
||||
let scroller = target.parentElement
|
||||
while (
|
||||
scroller &&
|
||||
!['auto', 'scroll'].includes(getComputedStyle(scroller).overflowY)
|
||||
) {
|
||||
scroller = scroller.parentElement
|
||||
}
|
||||
if (!scroller) return
|
||||
scroller.scrollTop +=
|
||||
target.getBoundingClientRect().top -
|
||||
scroller.getBoundingClientRect().top -
|
||||
offset
|
||||
},
|
||||
{ id: anchor, offset },
|
||||
)
|
||||
await page.waitForTimeout(SCROLL_SETTLE_MS)
|
||||
}
|
||||
}
|
||||
|
||||
const acceptInvitePath = '/invite/accept?c=abc123'
|
||||
@@ -1488,6 +1522,42 @@ function profileApi(
|
||||
}
|
||||
}
|
||||
|
||||
function profileRoleCardState(
|
||||
displayName: string,
|
||||
email: string,
|
||||
options: { role: 'owner' | 'contributor' },
|
||||
): VisualState {
|
||||
const access =
|
||||
options.role === 'owner'
|
||||
? accessFor('owner')
|
||||
: customAccess('content.read=all, content.create, content.update=own', 'Contributor')
|
||||
return {
|
||||
path: '/profile',
|
||||
exception: {
|
||||
maxPercent: { desktop: 10, mobile: 22 },
|
||||
reason: 'The Roles-board profile frame is an older layout without the Display name card, and the design draws a capitalised Owner with an icon while the app keeps the shared RoleBadge (lowercase mono, no icon); changing it would restyle Users and API keys',
|
||||
approvedBy: 'user',
|
||||
},
|
||||
api: {
|
||||
'GET /v1/auth/me': {
|
||||
status: 200,
|
||||
body: {
|
||||
id: 1,
|
||||
is_verified: true,
|
||||
username: displayName.split(' ')[0]!.toLowerCase(),
|
||||
email,
|
||||
display_name: displayName,
|
||||
access,
|
||||
},
|
||||
},
|
||||
},
|
||||
steps: async (page) => {
|
||||
await page.getByRole('heading', { name: 'Your role', exact: true }).waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function profileState(
|
||||
displayName = '',
|
||||
overrides: Record<string, ApiMock> = {},
|
||||
@@ -8246,23 +8316,56 @@ function speakerSummary(id: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function relationSearch(url: URL) {
|
||||
const needle = (url.searchParams.get('q') ?? '').toLowerCase()
|
||||
const matches = relationVenueRows.filter((row) => row.name.toLowerCase().includes(needle))
|
||||
const relationPickerNames = [
|
||||
'Rue Verte hall',
|
||||
'Rue Verte yard',
|
||||
'Rue du Port studio',
|
||||
'Rue Verte annex',
|
||||
'Rue de la Gare depot',
|
||||
'Rue Verte workshop',
|
||||
'Rue du Port loft',
|
||||
'Rue des Halles hall',
|
||||
'Rue Verte garden room',
|
||||
'Rue du Moulin barn',
|
||||
'Rue de Nantes studio',
|
||||
'Rue Verte gallery',
|
||||
'Rue du Port quay',
|
||||
'Rue des Tanneurs hall',
|
||||
'Rue Verte cellar',
|
||||
'Rue du Four kitchen',
|
||||
'Rue Verte terrace',
|
||||
'Rue du Port warehouse',
|
||||
'Rue des Écoles hall',
|
||||
'Rue Verte pavilion',
|
||||
'Rue du Lac studio',
|
||||
'Rue Verte shed',
|
||||
'Rue de la Paix room',
|
||||
'Rue Verte forge',
|
||||
'Rue du Port atelier',
|
||||
]
|
||||
|
||||
const relationPickerCities = ['Lyon', 'Nantes', 'Lille']
|
||||
const relationPickerTotal = 62
|
||||
|
||||
function relationSearch() {
|
||||
return {
|
||||
items: matches.map((row) => ({
|
||||
id: row.id,
|
||||
items: relationPickerNames.map((name, index) => ({
|
||||
id: 100 + index,
|
||||
content_type: 'venues',
|
||||
data: { name: row.name, city: row.city, details: row.details },
|
||||
data: {
|
||||
name,
|
||||
city: relationPickerCities[index % relationPickerCities.length],
|
||||
details: `${24 + ((index * 17) % 120)} seats`,
|
||||
},
|
||||
created_by: 3,
|
||||
updated_by: 3,
|
||||
created_at: '2026-10-02T09:14:00Z',
|
||||
updated_at: '2026-10-12T11:02:00Z',
|
||||
relations: {},
|
||||
})),
|
||||
total: matches.length,
|
||||
total: relationPickerTotal,
|
||||
page: 1,
|
||||
size: 10,
|
||||
size: 25,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8327,6 +8430,34 @@ const relationEntryStates: Record<string, VisualState> = {
|
||||
},
|
||||
}
|
||||
|
||||
const relationEntryForm = relationEntryStates['Relation in the entry form']!
|
||||
|
||||
const relationPickerMore: VisualState = {
|
||||
path: relationEntryForm.path,
|
||||
exception: {
|
||||
maxPercent: { desktop: 10, mobile: 13 },
|
||||
reason: 'The app adds a Saved line, a meta block and a sticky action bar the board omits, as for Relation in the entry form',
|
||||
approvedBy: 'user',
|
||||
},
|
||||
|
||||
api: relationEntryForm.api,
|
||||
steps: async (page) => {
|
||||
await page.route(
|
||||
(url) => url.pathname.endsWith('/venues/entry') && url.searchParams.get('page') === '2',
|
||||
() => undefined,
|
||||
)
|
||||
const input = entryField(page, 'venue').getByRole('combobox')
|
||||
await input.click()
|
||||
await input.fill('rue')
|
||||
await page.getByRole('option').first().waitFor()
|
||||
await page.getByRole('button', { name: 'Load more', exact: true }).click()
|
||||
await page.getByRole('button', { name: 'Loading…', exact: true }).waitFor()
|
||||
await schemaSettle(page)
|
||||
await page.getByRole('option').last().scrollIntoViewIfNeeded()
|
||||
await page.waitForTimeout(150)
|
||||
},
|
||||
}
|
||||
|
||||
const relationListType = relationTypeOf(1, 'events', 'Events', [
|
||||
schemaField('Title', 'title', 'text', { required: true, unique: true, max: 80, options: [] }),
|
||||
schemaField('Starts on', 'starts_on', 'date', { required: true, options: [] }),
|
||||
@@ -8386,6 +8517,30 @@ const relationContentStates: Record<string, VisualState> = {
|
||||
},
|
||||
}
|
||||
|
||||
function withApprovedExceptions(
|
||||
screen: string,
|
||||
states: Record<string, VisualState>,
|
||||
): Record<string, VisualState> {
|
||||
const approved = approvedExceptions[screen] ?? {}
|
||||
return Object.fromEntries(
|
||||
Object.entries(states).map(([name, state]) => {
|
||||
const cap = approved[name]
|
||||
if (!cap || state.exception) return [name, state]
|
||||
return [
|
||||
name,
|
||||
{
|
||||
...state,
|
||||
exception: {
|
||||
maxPercent: { desktop: cap.desktop, mobile: cap.mobile },
|
||||
reason: cap.reason,
|
||||
approvedBy: 'user',
|
||||
},
|
||||
},
|
||||
]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
sites: sitesStates,
|
||||
schema: {
|
||||
@@ -8398,10 +8553,18 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
...entryStates,
|
||||
...withMediaExceptions('entry', { ...mediaEntryStates, ...mediaPickerStates }),
|
||||
...relationEntryStates,
|
||||
'Relation picker: loading more': relationPickerMore,
|
||||
},
|
||||
authors: authorsStates,
|
||||
activity: activityStates,
|
||||
profile: {
|
||||
roles: withApprovedExceptions('roles', rolesStates),
|
||||
profile: withApprovedExceptions('profile', {
|
||||
'Role card: custom role': profileRoleCardState('Pedro Lima', 'pedro@renewbuild.example', {
|
||||
role: 'contributor',
|
||||
}),
|
||||
'Role card: Owner': profileRoleCardState('Marta Alves', 'marta@renewbuild.example', {
|
||||
role: 'owner',
|
||||
}),
|
||||
'No author profile': authorProfileState({}),
|
||||
Error: authorProfileState({ 'POST /v1/author/me': { status: 500 } }, async (page) => {
|
||||
await createAuthorProfile(page)
|
||||
@@ -8500,8 +8663,8 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
approvedBy: 'user',
|
||||
},
|
||||
},
|
||||
},
|
||||
users: {
|
||||
}),
|
||||
users: withApprovedExceptions('users', {
|
||||
Default: {
|
||||
path: '/users',
|
||||
api: usersApi(),
|
||||
@@ -8580,7 +8743,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
steps: inviteWith('lea.martin@renew.example', { status: 500 }),
|
||||
},
|
||||
'Invite sent': { path: '/users', skip: 'clock-sensitive' },
|
||||
},
|
||||
}),
|
||||
settings: {
|
||||
...cleanupStates,
|
||||
...retentionStates,
|
||||
@@ -8708,7 +8871,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
},
|
||||
'Update already running (409)': { path: '/settings', skip: 'clock-sensitive' },
|
||||
},
|
||||
'integration-api-keys': apiKeysStates,
|
||||
'integration-api-keys': withApprovedExceptions('integration-api-keys', apiKeysStates),
|
||||
integration: {
|
||||
'1. Default': integrationState(),
|
||||
'2. Group scrolled, one card expanded': integrationState({
|
||||
@@ -8782,7 +8945,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
const mobile = (page.viewportSize()?.width ?? 0) < desktopMinWidth
|
||||
await openIntegrationCard('POST /v1/content-type/', 'ep-ct-create', 'curl')(page)
|
||||
await scrollToAnchor(page, mobile ? 'ep-ct-create' : 'ct-fields', {
|
||||
desktop: -30,
|
||||
desktop: 10,
|
||||
mobile: 88,
|
||||
})
|
||||
},
|
||||
@@ -8796,7 +8959,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
steps: openIntegrationCardAt(
|
||||
'GET /v1/content-type/{key}/entry',
|
||||
'en-list',
|
||||
{ anchor: 'ep-en-list', desktop: -44, mobile: 88 },
|
||||
{ anchor: 'ep-en-list', desktop: -4, mobile: 88 },
|
||||
'Response',
|
||||
),
|
||||
exception: {
|
||||
@@ -8808,7 +8971,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
'9c. Entries: replace, 422 and 409': integrationState({
|
||||
steps: openIntegrationCardAt('PUT /v1/content-type/{key}/entry/{id}', 'en-update', {
|
||||
anchor: 'ep-en-update',
|
||||
desktop: -108,
|
||||
desktop: -68,
|
||||
mobile: 88,
|
||||
}),
|
||||
exception: {
|
||||
@@ -8840,7 +9003,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
}),
|
||||
'9j. Navigation: scroll-spy on Content types': integrationState({
|
||||
steps: async (page) => {
|
||||
await scrollToAnchor(page, 'ctypes', { desktop: -5, mobile: -3 })
|
||||
await scrollToAnchor(page, 'ctypes', { desktop: 33, mobile: -3 })
|
||||
if ((page.viewportSize()?.width ?? 0) < desktopMinWidth) {
|
||||
await page.getByRole('button', { name: 'Jump to section' }).click()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user