From b1d414a1fc5081037a9f62177a645c541e0fd97c Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Thu, 29 Oct 2026 14:37:11 +0000 Subject: [PATCH 1/3] ui: admin account and registration row actions as icon buttons Accounts: Edit / Test / Overrides / Delete become compact SVG icon buttons in one non-wrapping row, matching the Users rows (edit, zap, sliders, trash). Each has a per-account aria-label (unchanged) and a title tooltip; the short verb stays as sr-only text so code and tests that find an action by its text still work. Delete keeps .btn-destructive as its hook but uses the subtle danger look, set slightly apart, instead of a solid red button. The Test check reports progress and its result through the hidden text, tooltip and a short green/red tint, as the text button did through its label. Registrations: Approve stays a small primary text button (the main call to action, now with an aria-label naming the registration); Reject and Delete are icon buttons, and nothing stacks. Adds the sliders and zap icons to icons.ts and a shared rowIconButton helper; jest covers both rows. Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/a11y.test.ts | 21 +++++ frontend/src/__tests__/icons.test.ts | 2 +- frontend/src/__tests__/registrations.test.ts | 34 +++++++ frontend/src/icons.ts | 14 +++ frontend/src/lib/row-icon-button.ts | 35 ++++++++ frontend/src/modules/registrations.ts | 33 ++++--- frontend/src/settings.ts | 93 ++++++++++---------- frontend/src/styles/settings.css | 47 ++++++++++ 8 files changed, 222 insertions(+), 57 deletions(-) create mode 100644 frontend/src/lib/row-icon-button.ts diff --git a/frontend/src/__tests__/a11y.test.ts b/frontend/src/__tests__/a11y.test.ts index 0cc472ad0..fd5435718 100644 --- a/frontend/src/__tests__/a11y.test.ts +++ b/frontend/src/__tests__/a11y.test.ts @@ -110,6 +110,27 @@ describe('accessibility smoke', () => { const deleteBtn = container.querySelector('.btn-destructive') as HTMLButtonElement | null; expect(deleteBtn?.getAttribute('aria-label')).toBe('Delete Prod (111222333)'); }); + + it('row actions are icon buttons with a per-account name and a tooltip', async () => { + const container = document.createElement('div'); + container.id = 'aws-accounts-list'; + document.body.appendChild(container); + + (api.listAccounts as jest.Mock).mockResolvedValue([ + { id: 'a1', name: 'Prod', provider: 'aws', external_id: '111222333', enabled: true }, + ]); + + await loadAccountsForProvider('aws'); + + const buttons = Array.from(container.querySelectorAll('td.account-actions .row-actions button')); + expect(buttons.map(b => [b.getAttribute('aria-label'), b.title, b.querySelector('svg.icon')?.getAttribute('class')])).toEqual([ + ['Edit Prod (111222333)', 'Edit account', 'icon icon-edit'], + ['Test credentials for Prod (111222333)', 'Test credentials', 'icon icon-zap'], + ['Service overrides for Prod (111222333)', 'Service overrides', 'icon icon-sliders'], + ['Delete Prod (111222333)', 'Delete account', 'icon icon-trash-2'], + ]); + for (const b of buttons) expect(b.classList.contains('icon-btn')).toBe(true); + }); }); describe('RI Exchange settings deep-link', () => { diff --git a/frontend/src/__tests__/icons.test.ts b/frontend/src/__tests__/icons.test.ts index 14bbd1814..750d873f9 100644 --- a/frontend/src/__tests__/icons.test.ts +++ b/frontend/src/__tests__/icons.test.ts @@ -9,7 +9,7 @@ import { showToast } from '../toast'; const ALL: IconName[] = [ 'alert-triangle', 'check', 'check-circle', 'chevron-down', 'chevron-right', 'chevron-up', 'chevrons-up-down', 'copy', 'download', 'edit', 'filter', 'info', - 'pause', 'play', 'rotate-cw', 'settings', 'slash', 'trash-2', 'x', 'x-circle', + 'pause', 'play', 'rotate-cw', 'settings', 'sliders', 'slash', 'trash-2', 'x', 'x-circle', 'zap', ]; function parse(markup: string): SVGSVGElement { diff --git a/frontend/src/__tests__/registrations.test.ts b/frontend/src/__tests__/registrations.test.ts index 0da3db8ca..c6dac55b6 100644 --- a/frontend/src/__tests__/registrations.test.ts +++ b/frontend/src/__tests__/registrations.test.ts @@ -71,3 +71,37 @@ describe('loadRegistrations default filter', () => { expect(api.listRegistrations).toHaveBeenCalledWith(undefined); }); }); + +describe('registration row actions', () => { + beforeEach(() => { + document.body.replaceChildren(); + jest.clearAllMocks(); + }); + + test('Approve stays a text button; Reject and Delete are named icon buttons in one row', async () => { + const container = document.createElement('div'); + container.id = 'registrations-list'; + document.body.appendChild(container); + (api.listRegistrations as jest.Mock).mockResolvedValue([{ + id: 'r1', provider: 'aws', account_name: 'Checkout', external_id: '650412398877', + contact_email: 'c@example.com', status: 'pending', created_at: '2026-10-28T12:00:00Z', + }]); + + await loadRegistrations(); + + const actions = container.querySelector('tbody tr td:last-child .row-actions')!; + const buttons = Array.from(actions.querySelectorAll('button')); + expect(buttons.map(b => b.getAttribute('aria-label'))).toEqual([ + 'Approve registration Checkout', + 'Reject registration Checkout', + 'Delete registration Checkout', + ]); + expect(buttons[0]!.textContent).toBe('Approve'); + for (const [btn, icon, title] of [[buttons[1]!, 'x-circle', 'Reject registration'], [buttons[2]!, 'trash-2', 'Delete registration']] as const) { + expect(btn.classList.contains('icon-btn')).toBe(true); + expect(btn.querySelector(`svg.icon-${icon}`)).not.toBeNull(); + expect(btn.title).toBe(title); + } + expect(buttons[2]!.classList.contains('btn-danger')).toBe(true); + }); +}); diff --git a/frontend/src/icons.ts b/frontend/src/icons.ts index 8f6ef09d9..43a2b1be7 100644 --- a/frontend/src/icons.ts +++ b/frontend/src/icons.ts @@ -68,6 +68,18 @@ const ICONS = { ['circle', { cx: '12', cy: '12', r: '3' }], ['path', { d: 'M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z' }], ], + // Per-account service overrides (Admin > Accounts row action). + sliders: [ + ['line', { x1: '4', y1: '21', x2: '4', y2: '14' }], + ['line', { x1: '4', y1: '10', x2: '4', y2: '3' }], + ['line', { x1: '12', y1: '21', x2: '12', y2: '12' }], + ['line', { x1: '12', y1: '8', x2: '12', y2: '3' }], + ['line', { x1: '20', y1: '21', x2: '20', y2: '16' }], + ['line', { x1: '20', y1: '12', x2: '20', y2: '3' }], + ['line', { x1: '1', y1: '14', x2: '7', y2: '14' }], + ['line', { x1: '9', y1: '8', x2: '15', y2: '8' }], + ['line', { x1: '17', y1: '16', x2: '23', y2: '16' }], + ], slash: [ ['circle', { cx: '12', cy: '12', r: '10' }], ['line', { x1: '4.93', y1: '4.93', x2: '19.07', y2: '19.07' }], @@ -87,6 +99,8 @@ const ICONS = { ['line', { x1: '15', y1: '9', x2: '9', y2: '15' }], ['line', { x1: '9', y1: '9', x2: '15', y2: '15' }], ], + // Test credentials (Admin > Accounts row action). + zap: [['polygon', { points: '13 2 3 14 12 14 11 22 21 10 12 10 13 2' }]], } as const satisfies Record; export type IconName = keyof typeof ICONS; diff --git a/frontend/src/lib/row-icon-button.ts b/frontend/src/lib/row-icon-button.ts new file mode 100644 index 000000000..87b028307 --- /dev/null +++ b/frontend/src/lib/row-icon-button.ts @@ -0,0 +1,35 @@ +/** + * Compact icon-only row action for the Admin account and registration + * tables, matching the Users / Groups / API keys rows (.icon-btn). + * + * The accessible name comes from aria-label and the tooltip from title. + * The short verb is also kept as visually hidden text so code and tests + * that find a row action by its text ("Delete", "Overrides") keep working. + */ +import { createIcon, type IconName } from '../icons'; + +export interface RowIconButtonOptions { + icon: IconName; + /** Short verb kept as visually hidden text, e.g. "Delete". */ + text: string; + /** Tooltip, e.g. "Delete account". */ + title: string; + /** Accessible name naming the row, e.g. "Delete Prod (1234)". */ + ariaLabel: string; + /** Extra classes, e.g. the danger variant. */ + className?: string; +} + +export function rowIconButton(opts: RowIconButtonOptions): HTMLButtonElement { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = `btn btn-small icon-btn${opts.className ? ` ${opts.className}` : ''}`; + btn.title = opts.title; + btn.setAttribute('aria-label', opts.ariaLabel); + btn.appendChild(createIcon(opts.icon)); + const text = document.createElement('span'); + text.className = 'sr-only'; + text.textContent = opts.text; + btn.appendChild(text); + return btn; +} diff --git a/frontend/src/modules/registrations.ts b/frontend/src/modules/registrations.ts index 4a03f35f5..f18679195 100644 --- a/frontend/src/modules/registrations.ts +++ b/frontend/src/modules/registrations.ts @@ -11,6 +11,7 @@ import { badgeClass, statusLabel, statusTone } from '../lib/status-badge'; import { showToast } from '../toast'; import { confirmDialog } from '../confirmDialog'; import { friendlyError } from '../api/errors'; +import { rowIconButton } from '../lib/row-icon-button'; type AccountProvider = 'aws' | 'azure' | 'gcp'; @@ -138,18 +139,26 @@ function renderRegistrationsTable(container: HTMLElement, regs: AccountRegistrat row.appendChild(statusTd); const actionsTd = document.createElement('td'); + // One non-wrapping row: Approve stays a small text button (the main + // call to action), Reject and Delete are compact icon buttons. + const actions = document.createElement('div'); + actions.className = 'row-actions'; + const regLabel = reg.account_name || reg.external_id; if (reg.status === 'pending') { const approveBtn = document.createElement('button'); + approveBtn.type = 'button'; approveBtn.className = 'btn btn-small btn-primary'; approveBtn.textContent = 'Approve'; + approveBtn.setAttribute('aria-label', `Approve registration ${regLabel}`); approveBtn.addEventListener('click', () => handleApprove(reg)); - actionsTd.appendChild(approveBtn); + actions.appendChild(approveBtn); - const rejectBtn = document.createElement('button'); - rejectBtn.className = 'btn btn-small btn-danger ml-xs'; - rejectBtn.textContent = 'Reject'; + const rejectBtn = rowIconButton({ + icon: 'x-circle', text: 'Reject', title: 'Reject registration', + ariaLabel: `Reject registration ${regLabel}`, className: 'btn-warning', + }); rejectBtn.addEventListener('click', () => void handleReject(reg)); - actionsTd.appendChild(rejectBtn); + actions.appendChild(rejectBtn); } else if (reg.status === 'approved') { // After deleteAccount resets the linked registration back to pending, and // the 000021 migration healed historical drift, an approved row always has @@ -157,20 +166,22 @@ function renderRegistrationsTable(container: HTMLElement, regs: AccountRegistrat const span = document.createElement('span'); span.className = 'text-muted'; span.textContent = 'Account created'; - actionsTd.appendChild(span); + actions.appendChild(span); } else if (reg.rejection_reason) { const span = document.createElement('span'); span.className = 'text-muted'; span.textContent = 'Rejected'; span.title = reg.rejection_reason; - actionsTd.appendChild(span); + actions.appendChild(span); } // Delete button (available for any status) - const deleteBtn = document.createElement('button'); - deleteBtn.className = 'btn btn-small ml-xs'; - deleteBtn.textContent = 'Delete'; + const deleteBtn = rowIconButton({ + icon: 'trash-2', text: 'Delete', title: 'Delete registration', + ariaLabel: `Delete registration ${regLabel}`, className: 'btn-danger', + }); deleteBtn.addEventListener('click', () => void handleDelete(reg)); - actionsTd.appendChild(deleteBtn); + actions.appendChild(deleteBtn); + actionsTd.appendChild(actions); row.appendChild(actionsTd); tbody.appendChild(row); diff --git a/frontend/src/settings.ts b/frontend/src/settings.ts index fc7c5d7e5..eaa848f43 100644 --- a/frontend/src/settings.ts +++ b/frontend/src/settings.ts @@ -12,6 +12,7 @@ import { deleteAccount } from './settings-account-delete'; import { reflectDirtyState } from './settings-subnav'; import { showToast } from './toast'; import { createIcon } from './icons'; +import { rowIconButton } from './lib/row-icon-button'; import { isValidCombination, getValidPaymentOptions, getValidTermOptions, getPaymentLabel, getCommitmentConfig, normalizePaymentValue } from './commitmentOptions'; import { openModal, closeModal } from './modal'; import { loadRecommendations } from './recommendations'; @@ -632,47 +633,39 @@ function renderAccountsList( const actionsTd = document.createElement('td'); actionsTd.className = 'account-actions'; + // Compact icon buttons in one non-wrapping row, like the Users rows. + const actions = document.createElement('div'); + actions.className = 'row-actions'; - const editBtn = document.createElement('button'); - editBtn.type = 'button'; - editBtn.className = 'btn btn-small'; - editBtn.textContent = 'Edit'; - editBtn.setAttribute('aria-label', `Edit ${accountLabel}`); + const editBtn = rowIconButton({ + icon: 'edit', text: 'Edit', title: 'Edit account', ariaLabel: `Edit ${accountLabel}`, + }); editBtn.addEventListener('click', () => openAccountModal(provider, account)); - actionsTd.appendChild(editBtn); + actions.appendChild(editBtn); - const testBtn = document.createElement('button'); - testBtn.type = 'button'; - testBtn.className = 'btn btn-small'; - testBtn.textContent = 'Test'; - testBtn.setAttribute('aria-label', `Test credentials for ${accountLabel}`); + const testBtn = rowIconButton({ + icon: 'zap', text: 'Test', title: 'Test credentials', ariaLabel: `Test credentials for ${accountLabel}`, + }); testBtn.addEventListener('click', () => void testAccount(account.id, accountLabel, testBtn)); - actionsTd.appendChild(testBtn); + actions.appendChild(testBtn); - const overridesBtn = document.createElement('button'); - overridesBtn.type = 'button'; - overridesBtn.className = 'btn btn-small'; - overridesBtn.textContent = 'Overrides'; - overridesBtn.setAttribute('aria-label', `Service overrides for ${accountLabel}`); + const overridesBtn = rowIconButton({ + icon: 'sliders', text: 'Overrides', title: 'Service overrides', ariaLabel: `Service overrides for ${accountLabel}`, + }); overridesBtn.addEventListener('click', () => openAccountOverridesModal(account)); - actionsTd.appendChild(overridesBtn); - - // Destructive Delete lives in its own subgroup to isolate it from the - // routine actions. A small spacer + btn-destructive class (from P2) - // signal "be careful". - const spacer = document.createElement('span'); - spacer.className = 'account-actions-spacer'; - spacer.setAttribute('aria-hidden', 'true'); - actionsTd.appendChild(spacer); - - const deleteBtn = document.createElement('button'); - deleteBtn.type = 'button'; - deleteBtn.className = 'btn btn-small btn-destructive'; - deleteBtn.textContent = 'Delete'; - deleteBtn.setAttribute('aria-label', `Delete ${accountLabel}`); + actions.appendChild(overridesBtn); + + // Destructive Delete: the subtle danger icon variant, set slightly apart + // from the routine actions. .btn-destructive stays as the hook that + // identifies the row's delete action. + const deleteBtn = rowIconButton({ + icon: 'trash-2', text: 'Delete', title: 'Delete account', ariaLabel: `Delete ${accountLabel}`, + className: 'btn-destructive btn-danger account-delete-btn', + }); deleteBtn.addEventListener('click', () => void deleteAccount(account, provider, loadAccountsForProvider)); - actionsTd.appendChild(deleteBtn); + actions.appendChild(deleteBtn); + actionsTd.appendChild(actions); tr.appendChild(actionsTd); tbody.appendChild(tr); }); @@ -1409,13 +1402,29 @@ export async function loadOverridesPanel(accountId: string, panel: HTMLElement, * clicked — still gets feedback. */ async function testAccount(accountId: string, accountLabel: string, btn: HTMLButtonElement): Promise { - const original = btn.textContent; + // The row action is an icon button: progress and result go to its hidden + // text, its tooltip and a data-test-result tint; the toasts carry the + // message itself. + const labelEl = btn.querySelector('.sr-only') ?? btn; + const original = labelEl.textContent; + const originalTitle = btn.title; + const show = (text: string, result?: 'ok' | 'failed'): void => { + labelEl.textContent = text; + btn.title = text; + if (result) btn.dataset['testResult'] = result; + }; + const restore = (): void => { + labelEl.textContent = original; + btn.title = originalTitle; + delete btn.dataset['testResult']; + btn.disabled = false; + }; btn.disabled = true; - btn.textContent = 'Testing...'; + show('Testing...'); showToast({ message: `Testing credentials for ${accountLabel}…`, kind: 'info', timeout: 3_000 }); try { const result = await api.testAccountCredentials(accountId); - btn.textContent = result.ok ? 'OK' : 'Failed'; + show(result.ok ? 'OK' : 'Failed', result.ok ? 'ok' : 'failed'); if (result.ok) { const detail = result.message ? `: ${result.message}` : ''; showToast({ message: `Credentials OK for ${accountLabel}${detail}`, kind: 'success', timeout: 5_000 }); @@ -1426,18 +1435,12 @@ async function testAccount(accountId: string, accountLabel: string, btn: HTMLBut timeout: null, }); } - setTimeout(() => { - btn.textContent = original; - btn.disabled = false; - }, 3000); + setTimeout(restore, 3000); } catch (err: unknown) { - btn.textContent = 'Error'; + show('Error', 'failed'); const msg = err instanceof Error ? err.message : String(err); showToast({ message: `Failed to test credentials for ${accountLabel}: ${msg}`, kind: 'error', timeout: null }); - setTimeout(() => { - btn.textContent = original; - btn.disabled = false; - }, 3000); + setTimeout(restore, 3000); } } diff --git a/frontend/src/styles/settings.css b/frontend/src/styles/settings.css index 5e677adc9..2ac85a705 100644 --- a/frontend/src/styles/settings.css +++ b/frontend/src/styles/settings.css @@ -721,3 +721,50 @@ tr.override-disabled { .override-transfer-account { white-space: nowrap; } + +/* Admin > Accounts row actions: compact icon buttons in one row, as on the + * Users rows. The cell stays a table cell (components.css made it a flex + * box, which pulled its bottom border out of line with the row). */ +.accounts-table td.account-actions { + display: table-cell; + width: 1%; + white-space: nowrap; +} + +/* Delete keeps .btn-destructive as its hook but uses the subtle danger look + * (tinted, red icon) instead of a solid red block next to neutral actions; + * it fills red only on hover. A little extra space sets it apart. */ +.row-actions button.icon-btn.btn-destructive { + background: var(--cudly-error-bg); + border-color: var(--cudly-error); + color: var(--cudly-error-fg); +} + +.row-actions button.icon-btn.btn-destructive:hover { + background: var(--cudly-error); + border-color: var(--cudly-error); + color: var(--cudly-primary-fg); +} + +.row-actions .account-delete-btn { + margin-left: var(--cudly-sp-2); +} + +/* Test credentials result, shown for a few seconds after the check. */ +.row-actions button[data-test-result="ok"] { + border-color: var(--cudly-success); + color: var(--cudly-success); +} + +.row-actions button[data-test-result="failed"] { + border-color: var(--cudly-error); + color: var(--cudly-error-fg); +} + +/* Registrations: Approve is the one text button in the row; match the icon + * buttons' height so the row lines up. */ +.row-actions .btn-small.btn-primary { + height: 1.75rem; + margin: 0; + padding: 0 var(--cudly-sp-3); +} From e21ebea565aee68fd59f1f0386669e2d32c38bfa Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Thu, 29 Oct 2026 14:43:14 +0000 Subject: [PATCH 2/3] ui: keep account row action labels inside their buttons Follow-up to the account row icon buttons. Their sr-only verb is absolutely positioned and had no positioned ancestor, so on phones, where the accounts table scrolls sideways, it sat at the table's far edge and widened the page. Make the icon buttons its containing block. Co-Authored-By: Claude Opus 5.5 --- frontend/src/styles/settings.css | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/frontend/src/styles/settings.css b/frontend/src/styles/settings.css index 2ac85a705..e7a5d6758 100644 --- a/frontend/src/styles/settings.css +++ b/frontend/src/styles/settings.css @@ -731,6 +731,13 @@ tr.override-disabled { white-space: nowrap; } +/* The sr-only verb inside each icon button is absolutely positioned; give + * it the button as containing block, or inside the sideways-scrolling + * table on phones it lands at the table's far edge and widens the page. */ +.row-actions button.icon-btn { + position: relative; +} + /* Delete keeps .btn-destructive as its hook but uses the subtle danger look * (tinted, red icon) instead of a solid red block next to neutral actions; * it fills red only on hover. A little extra space sets it apart. */ From 05f0ef653ccb2132803e43cd8a3a9656aa715ee6 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Thu, 29 Oct 2026 17:19:10 +0000 Subject: [PATCH 3/3] test: list the row-action icons on their own line in icons.test.ts Adapted for standalone: the table sweep PR adds clipboard-check to the line above, and editing neighbouring lines of the same list made the two PRs conflict. Same icons, same tests. Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/icons.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/__tests__/icons.test.ts b/frontend/src/__tests__/icons.test.ts index 750d873f9..496ed50b4 100644 --- a/frontend/src/__tests__/icons.test.ts +++ b/frontend/src/__tests__/icons.test.ts @@ -9,7 +9,9 @@ import { showToast } from '../toast'; const ALL: IconName[] = [ 'alert-triangle', 'check', 'check-circle', 'chevron-down', 'chevron-right', 'chevron-up', 'chevrons-up-down', 'copy', 'download', 'edit', 'filter', 'info', - 'pause', 'play', 'rotate-cw', 'settings', 'sliders', 'slash', 'trash-2', 'x', 'x-circle', 'zap', + 'pause', 'play', 'rotate-cw', 'settings', 'slash', 'trash-2', 'x', 'x-circle', + // Admin account / registration row actions. + 'sliders', 'zap', ]; function parse(markup: string): SVGSVGElement {