From d31e79db734f8bc9d7e0341128b76ac5dc53a705 Mon Sep 17 00:00:00 2001 From: Matt Adcock <43476212+MattA-Official@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:51:15 +0100 Subject: [PATCH 1/5] Choose the component, once, for admin forms Five admin screens were built one after another, each copying the last, and the copying carried the mistakes forward. Thirteen screenshots of every screen and modal at two widths, checked against the Nuxt UI component matrix, showed the same thing everywhere: the components are not wrong so much as unchosen. Where a specific one existed for the job, we reached for UInput. A person was entered by pasting a thirty-two character identifier, in four places: awarding a fellowship, recording a membership, and both halves of a manual audit entry. No search, no name, no way to tell you had the right person. PersonPicker replaces all four: an autocomplete searching name, address and student number, debounced, showing the address or number under each name. It reads the account directory, which already pages and allow-lists its columns, and the directory search gains the student number because that is how the committee finds somebody (0031). Six date fields rendered as mm/dd/yyyy, in American order, in a British theatre's admin. DateField is the one place that converts between the YYYY-MM-DD strings the API speaks and the calendar value the input takes, and the one place that says the dates here are London's. No form validated on the client, so a refusal appeared as a red alert behind the modal it concerned. The Zod objects the endpoints validate now live in shared/ and drive the forms, so one definition cannot drift from the other and a refusal lands on the field it is about. Nothing used a toast, though UApp was already in place, so every confirmation stayed on the page until dismissed. Confirmations are toasts now; an alert is for something the reader has to act on. The conventions are decision 0032 and a test that fails when a screen departs from them, the way the design language is a test rather than a review habit (0021). It ships with the three checks this change satisfies; the rest arrive with the tables and the settings page. Stories K-123 and K-124 are the home for the work. Two things the browser suite found, both real. The picker cached results by search term, which would have offered somebody since renamed or erased; it never caches now. And the suite searched by first name, which syntheticPerson draws from a list of thirty-five, so the picker could choose a different person with the same one: it searches by address. --- app/components/DateField.vue | 34 +++++ app/components/PersonPicker.vue | 120 +++++++++++++++++ app/composables/useDebounced.ts | 21 +++ app/pages/admin/audit.vue | 96 ++++++++------ app/pages/admin/fellows.vue | 122 ++++++++++------- app/pages/admin/members.vue | 125 ++++++++++-------- docs/backlog/K-platform.md | 47 +++++++ ...ce-has-one-set-of-component-conventions.md | 65 +++++++++ docs/decisions/README.md | 1 + server/api/admin/audit/index.post.ts | 15 +-- .../api/admin/fellowships/[id]/revoke.post.ts | 4 +- server/api/admin/fellowships/index.post.ts | 11 +- server/api/admin/memberships/index.post.ts | 13 +- server/utils/directory.ts | 6 +- shared/utils/admin-forms.ts | 53 ++++++++ tests/e2e/fellowships.test.ts | 14 +- tests/helpers/webview.ts | 29 ++++ tests/unit/admin-conventions.test.ts | 46 +++++++ 18 files changed, 632 insertions(+), 190 deletions(-) create mode 100644 app/components/DateField.vue create mode 100644 app/components/PersonPicker.vue create mode 100644 app/composables/useDebounced.ts create mode 100644 docs/decisions/0032-the-admin-surface-has-one-set-of-component-conventions.md create mode 100644 shared/utils/admin-forms.ts create mode 100644 tests/unit/admin-conventions.test.ts diff --git a/app/components/DateField.vue b/app/components/DateField.vue new file mode 100644 index 00000000..4ee9e71e --- /dev/null +++ b/app/components/DateField.vue @@ -0,0 +1,34 @@ + + + diff --git a/app/components/PersonPicker.vue b/app/components/PersonPicker.vue new file mode 100644 index 00000000..306d8913 --- /dev/null +++ b/app/components/PersonPicker.vue @@ -0,0 +1,120 @@ + + + diff --git a/app/composables/useDebounced.ts b/app/composables/useDebounced.ts new file mode 100644 index 00000000..57e98690 --- /dev/null +++ b/app/composables/useDebounced.ts @@ -0,0 +1,21 @@ +import type { Ref } from 'vue' + +// A search that fires on every keystroke asks the server a question the typist has not finished. +// Small enough to own rather than take a dependency for. +export function useDebounced(source: Ref, delayMs = 250): Ref { + const settled = ref(source.value) as Ref + let timer: ReturnType | undefined + + watch(source, (value) => { + if (timer) clearTimeout(timer) + timer = setTimeout(() => { + settled.value = value + }, delayMs) + }) + + onScopeDispose(() => { + if (timer) clearTimeout(timer) + }) + + return settled +} diff --git a/app/pages/admin/audit.vue b/app/pages/admin/audit.vue index 7d684597..91ca7f8e 100644 --- a/app/pages/admin/audit.vue +++ b/app/pages/admin/audit.vue @@ -2,8 +2,10 @@ import { h, resolveComponent } from 'vue' import { AUDIT_ACTIONS, AUDIT_ACTION_NAMES, AUDIT_MODULES, MANUAL_ACTION_NAMES, describeAction } from '#shared/utils/audit-actions' import { formatLondon } from '#shared/utils/london' +import { manualEntryForm } from '#shared/utils/admin-forms' +import type { ManualEntryForm } from '#shared/utils/admin-forms' import type { AuditActionName, AuditModule } from '#shared/utils/audit-actions' -import type { TableColumn } from '@nuxt/ui' +import type { FormSubmitEvent, TableColumn } from '@nuxt/ui' definePageMeta({ layout: 'admin', title: 'Audit trail', middleware: 'signed-in' }) @@ -39,9 +41,13 @@ const page = ref(1) const loading = ref(false) const failure = ref(null) +const toast = useToast() +const entryForm = useTemplateRef('entryForm') + const recording = ref(false) -const recorded = ref(null) -const entry = reactive({ action: MANUAL_ACTION_NAMES[0]!, target: '', onBehalfOf: '', occurredOn: '' }) +const entry = reactive>({ + action: MANUAL_ACTION_NAMES[0]!, +}) // A date on this screen is a London day, and the API wants the second it starts or ends (0014). const startOf = (day: string): number | undefined => @@ -71,27 +77,31 @@ async function load(): Promise { } } -async function record(): Promise { +async function record(event: FormSubmitEvent): Promise { failure.value = null try { await $fetch('/api/admin/audit', { method: 'POST', - body: { - action: entry.action, - target: entry.target, - onBehalfOf: entry.onBehalfOf, - occurredAt: startOf(entry.occurredOn), - }, + body: { ...event.data, occurredAt: startOf(event.data.occurredOn) }, + }) + toast.add({ + title: `${describeAction(entry.action ?? '').label} is on the trail`, + description: 'Signed by you.', + icon: 'i-lucide-pen-line', + color: 'success', }) - recorded.value = describeAction(entry.action).label recording.value = false - entry.target = '' - entry.onBehalfOf = '' - entry.occurredOn = '' + entry.target = undefined + entry.onBehalfOf = undefined + entry.occurredOn = undefined await load() } catch (error) { - failure.value = refusalText(error) + const message = refusalText(error) + if (/subject/i.test(message)) entryForm.value?.setErrors([{ name: 'target', message }]) + else if (/recorded for/i.test(message)) entryForm.value?.setErrors([{ name: 'onBehalfOf', message }]) + else if (/has not happened/i.test(message)) entryForm.value?.setErrors([{ name: 'occurredOn', message }]) + else failure.value = message } } @@ -178,16 +188,6 @@ onMounted(load) :description="failure" /> - -
- - @@ -289,47 +287,57 @@ onMounted(load) description="It is signed against you, and everybody named has to be an account here." >
diff --git a/app/pages/admin/fellows.vue b/app/pages/admin/fellows.vue index 493808b0..e71a2e33 100644 --- a/app/pages/admin/fellows.vue +++ b/app/pages/admin/fellows.vue @@ -1,6 +1,8 @@ + + diff --git a/app/components/DateField.vue b/app/components/DateField.vue index 4ee9e71e..22700c08 100644 --- a/app/components/DateField.vue +++ b/app/components/DateField.vue @@ -8,6 +8,8 @@ const model = defineModel() defineProps<{ disabled?: boolean }>() +const field = useTemplateRef('field') + const value = computed({ get(): CalendarDate | null { if (!model.value) return null @@ -26,9 +28,30 @@ const value = computed({ diff --git a/app/pages/admin/audit.vue b/app/pages/admin/audit.vue index 91ca7f8e..0363d5fb 100644 --- a/app/pages/admin/audit.vue +++ b/app/pages/admin/audit.vue @@ -5,6 +5,7 @@ import { formatLondon } from '#shared/utils/london' import { manualEntryForm } from '#shared/utils/admin-forms' import type { ManualEntryForm } from '#shared/utils/admin-forms' import type { AuditActionName, AuditModule } from '#shared/utils/audit-actions' +import type { ActiveFilter } from '~/components/AdminToolbar.vue' import type { FormSubmitEvent, TableColumn } from '@nuxt/ui' definePageMeta({ layout: 'admin', title: 'Audit trail', middleware: 'signed-in' }) @@ -17,6 +18,7 @@ interface Entry { actorName: string | null action: AuditActionName target: string | null + targetName: string | null detail: Record | null createdAt: number } @@ -136,12 +138,62 @@ function chooseModule(): void { } } +const activeFilters = computed(() => { + const active: ActiveFilter[] = [] + if (module.value !== ANY) { + active.push({ key: 'module', label: module.value, icon: 'i-lucide-layers', clear: () => { + module.value = ANY + } }) + } + if (action.value !== ANY) { + active.push({ key: 'action', label: describeAction(action.value).label, icon: 'i-lucide-activity', clear: () => { + action.value = ANY + } }) + } + if (target.value) { + active.push({ key: 'target', label: `About ${target.value}`, icon: 'i-lucide-crosshair', clear: () => { + target.value = '' + } }) + } + if (since.value) active.push({ key: 'from', label: `From ${since.value}`, icon: 'i-lucide-calendar', clear: () => { + since.value = '' + } }) + if (until.value) active.push({ key: 'to', label: `To ${until.value}`, icon: 'i-lucide-calendar', clear: () => { + until.value = '' + } }) + return active +}) + +function clearFilters(): void { + module.value = ANY + action.value = ANY + target.value = '' + since.value = '' + until.value = '' +} + watch([module, action, target, since, until], () => { page.value = 1 void load() }) watch(page, load) +// Raw JSON ran off the edge of the table and told nobody anything. A diff has a shape (0027), so +// it reads as one; everything else reads as its own keys and values. +function describeDetail(detail: Record | null): string[] { + if (!detail) return [] + const parts: string[] = [] + const changes = detail.changes as Record | undefined + for (const [field, change] of Object.entries(changes ?? {})) { + parts.push(`${field}: ${JSON.stringify(change.from)} → ${JSON.stringify(change.to)}`) + } + for (const [key, value] of Object.entries(detail)) { + if (key === 'changes') continue + parts.push(`${key}: ${typeof value === 'string' ? value : JSON.stringify(value)}`) + } + return parts +} + const columns: TableColumn[] = [ { id: 'createdAt', @@ -168,11 +220,19 @@ const columns: TableColumn[] = [ ]) }, }, - { accessorKey: 'target', header: 'To what', meta: { class: { td: 'font-mono text-xs' } } }, + { + id: 'target', + header: 'To whom', + // A name where there is one, and the raw target where the entry is not about a person. + cell: ({ row }) => row.original.targetName + ?? h('span', { class: 'font-mono text-xs text-muted' }, row.original.target ?? ''), + }, { id: 'detail', - header: 'Detail', - cell: ({ row }) => h('code', { class: 'text-xs' }, row.original.detail ? JSON.stringify(row.original.detail) : ''), + header: 'What changed', + meta: { class: { td: 'max-w-xs' } }, + cell: ({ row }) => h('div', { class: 'flex flex-wrap gap-1' }, describeDetail(row.original.detail).map(part => + h(UBadge, { color: 'neutral', variant: 'subtle', size: 'sm', class: 'font-mono' }, () => part))), }, ] @@ -188,83 +248,91 @@ onMounted(load) :description="failure" /> -
- - - - - - - - - - - - - - - - - - - - - - Record something - - - - Export - -
+ + + + + + > + +

import { h, resolveComponent } from 'vue' import { awardFellowship, revokeFellowship } from '#shared/utils/admin-forms' +import type { ActiveFilter } from '~/components/AdminToolbar.vue' import type { FormSubmitEvent, TableColumn } from '@nuxt/ui' import type { AwardFellowship } from '#shared/utils/admin-forms' @@ -29,9 +30,9 @@ interface Listing { } const SHOW = [ - { label: 'Current Fellows', value: 'current' }, - { label: 'Revoked', value: 'revoked' }, - { label: 'Everyone ever', value: 'everyone' }, + { label: 'Current Fellows', value: 'current', icon: 'i-lucide-award' }, + { label: 'Revoked', value: 'revoked', icon: 'i-lucide-ban' }, + { label: 'Everyone ever', value: 'everyone', icon: 'i-lucide-users' }, ] const listing = ref

(null) @@ -119,6 +120,31 @@ watch([show, search], () => { }) watch(page, load) +const activeFilters = computed(() => { + const active: ActiveFilter[] = [] + if (search.value) { + active.push({ key: 'search', label: `Matching ${search.value}`, icon: 'i-lucide-search', clear: () => { + search.value = '' + } }) + } + if (show.value !== 'current') { + active.push({ + key: 'show', + label: SHOW.find(option => option.value === show.value)!.label, + icon: SHOW.find(option => option.value === show.value)!.icon, + clear: () => { + show.value = 'current' + }, + }) + } + return active +}) + +function clearFilters(): void { + search.value = '' + show.value = 'current' +} + const columns: TableColumn[] = [ { accessorKey: 'awardedOn', header: 'Awarded', meta: { class: { td: 'font-mono text-sm whitespace-nowrap' } } }, { @@ -180,46 +206,50 @@ onMounted(load) description="No database held it before this one, so the existing Fellows are entered here by hand. A revocation stops future admissions and rewrites nothing." /> -
- - - - - - - + + - - Record an award - -
+ + + > + +

(null) @@ -103,6 +104,32 @@ async function confirm(member: Member): Promise { } } +// What is filtered, said out loud and removable one at a time (0032). +const activeFilters = computed(() => { + const active: ActiveFilter[] = [] + if (search.value) { + active.push({ key: 'search', label: `Matching ${search.value}`, icon: 'i-lucide-search', clear: () => { + search.value = '' + } }) + } + if (filter.value !== 'current') { + active.push({ + key: 'filter', + label: FILTERS.find(option => option.value === filter.value)!.label, + icon: FILTERS.find(option => option.value === filter.value)!.icon, + clear: () => { + filter.value = 'current' + }, + }) + } + return active +}) + +function clearFilters(): void { + search.value = '' + filter.value = 'current' +} + const exportUrl = computed(() => { const query = new URLSearchParams({ filter: filter.value }) if (search.value) query.set('search', search.value) @@ -183,56 +210,64 @@ onMounted(load) description="This records what somebody bought and when it runs out. A membership counts from the moment it is recorded: checking it against the SU's own list happens afterwards and never holds up a member price." /> -

- - - - - - - + + - - Record one - + + + > + +

{ } } +const activeFilters = computed(() => { + const active: ActiveFilter[] = [] + if (search.value) { + active.push({ + key: 'search', + label: `Matching ${search.value}`, + icon: 'i-lucide-search', + clear: () => { + search.value = '' + }, + }) + } + if (filter.value !== 'everyone') { + active.push({ + key: 'filter', + label: FILTERS.find(option => option.value === filter.value)!.label, + icon: 'i-lucide-filter', + clear: () => { + show('everyone') + }, + }) + } + if (role.value) { + active.push({ + key: 'role', + label: role.value, + icon: 'i-lucide-shield', + clear: () => { + role.value = undefined + }, + }) + } + return active +}) + +function clearFilters(): void { + search.value = '' + role.value = undefined + show('everyone') +} + const seen = (at: number | null): string => at ? formatLondon(new Date(at * 1000), { dateStyle: 'medium' }) : 'Never' @@ -190,53 +232,49 @@ onMounted(load) />

-
- - - - - - - + + - - Add someone - -
+ + + > + +

{ // The actor's name is joined rather than stored: an erased actor reads as its tombstone, which // is the whole point of anonymising rather than deleting (0011). + const subject = alias(schema.users, 'subject') + const items = await db.select({ id: schema.auditLog.id, actorId: schema.auditLog.actorId, actorName: schema.users.name, action: schema.auditLog.action, target: schema.auditLog.target, + // An entry names its subject by id; the screen should not (0032). Joined on the id inside the + // `user:` prefix, so an entry about something else still shows what it says. + targetName: subject.name, detail: schema.auditLog.detail, createdAt: schema.auditLog.createdAt, }) .from(schema.auditLog) .leftJoin(schema.users, eq(schema.users.id, schema.auditLog.actorId)) + .leftJoin(subject, eq(sql`'user:' || ${subject.id}`, schema.auditLog.target)) .where(where) .orderBy(desc(schema.auditLog.createdAt), desc(schema.auditLog.id)) .limit(input.pageSize) diff --git a/tests/e2e/audit.test.ts b/tests/e2e/audit.test.ts index 7f863d8d..1cff6d42 100644 --- a/tests/e2e/audit.test.ts +++ b/tests/e2e/audit.test.ts @@ -322,7 +322,7 @@ describe.skipIf(skip !== null)('the audit screen (J-101 criterion 2, J-103)', () const view = await signedInView() try { await visit(view, `${app.baseURL}/admin/audit`, '[data-test="audit-table"]') - await fill(view, 'input[data-test="audit-target"]', `user:${member.id}`) + await fill(view, 'input[data-test="toolbar-search"]', `user:${member.id}`) await waitFor(view, `document.querySelector('[data-test="audit-table"]').innerText.includes('booking.refunded')`) // Rendered under its own name rather than crashing the table it is one row of. @@ -340,7 +340,7 @@ describe.skipIf(skip !== null)('the audit screen (J-101 criterion 2, J-103)', () const view = await signedInView() try { await visit(view, `${app.baseURL}/admin/audit`, '[data-test="audit-table"]') - await fill(view, 'input[data-test="audit-target"]', `user:${member.id}`) + await fill(view, 'input[data-test="toolbar-search"]', `user:${member.id}`) await waitFor(view, `document.querySelector('[data-test="audit-table"]').innerText.includes('Role granted')`) expect(await textOf(view, '[data-test="audit-table"]')).toContain(officer.name) diff --git a/tests/e2e/directory.test.ts b/tests/e2e/directory.test.ts index a77de4fb..de4e01bc 100644 --- a/tests/e2e/directory.test.ts +++ b/tests/e2e/directory.test.ts @@ -296,22 +296,24 @@ describe.skipIf(skip !== null)('the directory screen', () => { await fillPin(view, '[data-test="mfa-challenge"] input', await codeForStep(secret, stepFor(new Date()))) await waitFor(view, 'document.querySelector(\'[data-test="sign-out"]\')') - await visit(view, `${app.baseURL}/admin/people`, '[data-test="directory-search"]') + await visit(view, `${app.baseURL}/admin/people`, '[data-test="toolbar-search"]') await waitFor(view, `document.body.innerText.includes(${JSON.stringify(known)})`) // Searching narrows to one, and the total below the table says so. - await fill(view, '[data-test="directory-search"]', known) + await fill(view, 'input[data-test="toolbar-search"]', known) await waitFor(view, 'document.querySelector(\'[data-test="directory-total"]\')?.innerText.startsWith("1 ")') expect(await textOf(view)).not.toContain(officer.email) - // A filter whose story is not built says which one rather than looking broken. - await fill(view, '[data-test="directory-search"]', '') - await view.evaluate(`(() => { - const select = document.querySelector('[data-test="directory-filter"]') - const setter = Object.getOwnPropertyDescriptor(select.constructor.prototype, 'value')?.set - if (setter) { setter.call(select, 'members-current'); select.dispatchEvent(new Event('change', { bubbles: true })) } - })()`) - await Bun.sleep(1500) + // Searching is shown back as a chip that can be taken off again (0032). + await waitFor(view, `document.querySelector('[data-test="toolbar-active"]')?.innerText.includes(${JSON.stringify(known)})`) + await click(view, '[data-test="toolbar-clear"]') + await waitFor(view, 'document.querySelector(\'[data-test="toolbar-active"]\') === null') + + // The filters live behind one button, which is what keeps the row from resizing. + await click(view, '[data-test="toolbar-filters"]') + await waitFor(view, 'document.querySelector(\'[data-test="directory-filter"]\')') + await view.evaluate(`document.querySelector('[data-test="toolbar-filters"]').click()`) + await Bun.sleep(500) const invitee = registrableAddress('by-hand') await click(view, '[data-test="invite"]') diff --git a/tests/e2e/fellowships.test.ts b/tests/e2e/fellowships.test.ts index 2cbe971a..40968ada 100644 --- a/tests/e2e/fellowships.test.ts +++ b/tests/e2e/fellowships.test.ts @@ -180,7 +180,7 @@ describe.skipIf(skip !== null)('the roll in a browser (A-127)', () => { // The outcome rather than the notification: a toast dismisses itself, and racing one proves // nothing. Searched for by name, because the roll pages and this suite fills it. - await fill(view, 'input[data-test="fellows-search"]', alumna.email.split('@')[0]!) + await fill(view, 'input[data-test="toolbar-search"]', alumna.email.split('@')[0]!) await waitFor(view, `document.querySelector('[data-test="fellows-table"]').innerText.includes('front of house')`, 20_000) expect(await textOf(view, '[data-test="fellows-table"]')).toContain(alumna.name) } diff --git a/tests/helpers/webview.ts b/tests/helpers/webview.ts index 6315f6be..2ba4c14b 100644 --- a/tests/helpers/webview.ts +++ b/tests/helpers/webview.ts @@ -180,7 +180,6 @@ export async function startApp(): Promise { // The shard's server dies with the shard. Without this it outlives the run holding the port, and // the next run talks to a database it did not create. function shutdown(): void { - Bun.write(Bun.stderr, `[e2e] shutdown hook fired, shared=${Boolean(shared)}\n`) if (!shared) return shared.controller.abort() // SIGKILL, not SIGTERM: an exit handler cannot wait for a graceful stop, and a dev server that diff --git a/tests/unit/admin-conventions.test.ts b/tests/unit/admin-conventions.test.ts index 9f2e732e..c3e9d170 100644 --- a/tests/unit/admin-conventions.test.ts +++ b/tests/unit/admin-conventions.test.ts @@ -34,9 +34,29 @@ describe('a person is chosen, never typed (0032)', () => { }) }) +describe('filters sit in a toolbar at a fixed width (0032)', () => { + test('no screen lays its filters out in a bare flex row', async () => { + expect(await offenders(source => /class="flex flex-wrap items-end gap-3"/.test(source))).toEqual([]) + }) + + // One search of a fixed width and one button, with the filters behind it, is what stops a row + // resizing as its values change. + test('every list uses the shared toolbar', async () => { + const lists = (await screens()).filter(screen => screen.source.includes(' !screen.source.includes(' screen.path)).toEqual([]) + }) +}) + describe('feedback goes where it belongs (0032)', () => { // A confirmation the reader does not have to act on is a toast, not something that sits on the // page until it is dismissed. + test('every table says what would be there when it is empty', async () => { + const tables = (await screens()).filter(screen => screen.source.includes(' !screen.source.includes('#empty')).map(screen => screen.path)).toEqual([]) + }) + test('a screen that confirms an action uses a toast', async () => { const confirming = (await screens()).filter(screen => /Recorded|Revoked\.|is on the (roll|trail)/.test(screen.source)) From e7f3dd818a5c67b25777d43846800eec108ee49b Mon Sep 17 00:00:00 2001 From: Matt Adcock <43476212+MattA-Official@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:21:22 +0100 Subject: [PATCH 3/5] Make fifty settings findable, and say counts in English The settings screen was fifty full-width text boxes, a number in each and no way to find one. Every key now gets the input for what it holds: a switch for a flag, a stepper for a number, a currency input for anything in pence (entered in pounds, stored as pence, 0004), a tags input for a list. A search box sits over the lot, and the workshop groups became tabs carrying their counts, so one group is on screen at a time. Counting things as "4 membership(s)" was in six places. `plural()` is the one place that bothers now, and a test fails on any screen that goes back to the brackets. Two smaller things the screenshots caught: the audit trail printed `expiresAt: 1817074799` where a date belongs, and the sidebar footer wrapped the account name and Sign out onto four lines at 210px. --- app/components/AuthStatus.vue | 13 +- app/layouts/admin.vue | 7 +- app/pages/admin/audit.vue | 13 +- app/pages/admin/config.vue | 290 +++++++++++++++++++-------- app/pages/admin/fellows.vue | 2 +- app/pages/admin/members.vue | 2 +- app/pages/admin/people/[id].vue | 4 +- app/pages/admin/people/index.vue | 6 +- shared/utils/text.ts | 6 + tests/e2e/config-surface.test.ts | 24 ++- tests/unit/admin-conventions.test.ts | 46 +++++ 11 files changed, 310 insertions(+), 103 deletions(-) create mode 100644 shared/utils/text.ts diff --git a/app/components/AuthStatus.vue b/app/components/AuthStatus.vue index 90772cdd..70463693 100644 --- a/app/components/AuthStatus.vue +++ b/app/components/AuthStatus.vue @@ -1,6 +1,9 @@