refactor(webui): extract repeated dashboard elements into shared components

Markup and helpers that were copied between pages now live in one
place:
- ChartCard, StatDisabledAlert, StatItem, DetailField, BudgetRatioBars,
  LabeledSelect, TargetPicker and LoadStateView components
- lib/scope.ts, lib/columns.ts and lib/chart-palette.ts
- messageOf, isAuthRejection, and the USD to micro-USD conversion
  helpers
- one monthly cost projection, one date formatter, and the
  useCopyFeedback composable

Behaviour is unchanged. Before and after screenshots of every page
match at 390 and 1440 px. Empty-state text now comes from the shared
EmptyState component, and the user run-out date uses the short date
format. admin_assets_gen.go is regenerated.

Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
This commit is contained in:
2026-09-24 06:04:46 +01:00
parent ce39b8eb37
commit 2608e01c50
67 changed files with 2008 additions and 1136 deletions
@@ -0,0 +1,45 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useCopyFeedback } from './useCopyFeedback'
describe('useCopyFeedback', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('starts idle', () => {
const { state } = useCopyFeedback()
expect(state.value).toBe('idle')
})
it.each(['copied', 'selected', 'failed'] as const)('set(%s) records that outcome', (outcome) => {
const { state, set } = useCopyFeedback()
set(outcome)
expect(state.value).toBe(outcome)
})
it('reverts to idle after 1500ms', () => {
const { state, set } = useCopyFeedback()
set('copied')
vi.advanceTimersByTime(1499)
expect(state.value).toBe('copied')
vi.advanceTimersByTime(1)
expect(state.value).toBe('idle')
})
it('a second set() before the first reset restarts the 1500ms window rather than stacking timers', () => {
const { state, set } = useCopyFeedback()
set('copied')
vi.advanceTimersByTime(1000)
set('selected')
vi.advanceTimersByTime(1000)
// 2000ms since the first set(), but only 1000ms since the second —
// still showing the second outcome, not yet reset.
expect(state.value).toBe('selected')
vi.advanceTimersByTime(500)
expect(state.value).toBe('idle')
})
})
+42
View File
@@ -0,0 +1,42 @@
import { onUnmounted, ref, type Ref } from 'vue'
/** RESET_MS is how long a copy-feedback state (`copied`/`selected`/`failed`) stays visible before reverting to `idle` — SnippetBlock.vue's and ModelChip.vue's own identical 1500ms. */
const RESET_MS = 1500
/** CopyFeedbackState mirrors lib/clipboard.ts's copyText own return union, plus the resting `idle` state neither SnippetBlock.vue nor ModelChip.vue had a name for before this composable. */
export type CopyFeedbackState = 'idle' | 'copied' | 'selected' | 'failed'
export interface UseCopyFeedback {
/** The current feedback state — drives a caller's own icon/label swap. */
state: Ref<CopyFeedbackState>
/** Records a copyText() outcome and schedules the reset-to-idle timer, clearing any still-pending one first (a rapid double-click restarts the window rather than stacking two timers). */
set: (result: 'copied' | 'selected' | 'failed') => void
}
/**
* useCopyFeedback (reuse-audit.md F10) is the ONE "show a transient copy
* outcome, then revert to idle after 1500ms" composable SnippetBlock.vue
* and ModelChip.vue used to each hand-roll — the same `state` ref/
* `resetTimer`/`clearTimeout`-on-unmount triple, with the identical
* 1500ms magic number declared independently in both. Calls
* `onUnmounted` itself (this composable is only ever invoked from a
* component's own `setup()`/`<script setup>`, same as every other
* composable in this panel), so a caller no longer needs its own cleanup
* hook just for this timer.
*/
export function useCopyFeedback(): UseCopyFeedback {
const state = ref<CopyFeedbackState>('idle')
let resetTimer: ReturnType<typeof setTimeout> | undefined
function set(result: 'copied' | 'selected' | 'failed'): void {
state.value = result
clearTimeout(resetTimer)
resetTimer = setTimeout(() => {
state.value = 'idle'
}, RESET_MS)
}
onUnmounted(() => clearTimeout(resetTimer))
return { state, set }
}
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { useTargetPicker } from './useTargetPicker'
describe('useTargetPicker', () => {
it('defaults to targetKind "group" with an empty targetName', () => {
const picker = useTargetPicker(() => ['eng'], () => ['alice'])
expect(picker.targetKind.value).toBe('group')
expect(picker.targetName.value).toBe('')
})
it('targetNames reads groupNames() while targetKind is "group", userNames() while "user"', () => {
const picker = useTargetPicker(() => ['eng', 'ops'], () => ['alice', 'bob'])
expect(picker.targetNames.value).toEqual(['eng', 'ops'])
picker.targetKind.value = 'user'
expect(picker.targetNames.value).toEqual(['alice', 'bob'])
})
it('nameValid applies isValidName for a group target', () => {
const picker = useTargetPicker(() => [], () => [])
picker.targetName.value = 'valid-name.1'
expect(picker.nameValid.value).toBe(true)
picker.targetName.value = 'has spaces'
expect(picker.nameValid.value).toBe(false)
})
it('nameValid applies isValidUserName (non-empty only) for a user target', () => {
const picker = useTargetPicker(() => [], () => [])
picker.targetKind.value = 'user'
picker.targetName.value = 'has spaces are fine for a user'
expect(picker.nameValid.value).toBe(true)
picker.targetName.value = ' '
expect(picker.nameValid.value).toBe(false)
})
it('snippetLabel names middleware.yaml for a group target', () => {
const picker = useTargetPicker(() => [], () => [])
expect(picker.snippetLabel.value).toBe('Paste into middleware.yaml')
})
it('snippetLabel names the user\'s users.json line, with the current target name, for a user target', () => {
const picker = useTargetPicker(() => [], () => [])
picker.targetKind.value = 'user'
picker.targetName.value = 'alice'
expect(picker.snippetLabel.value).toBe('Merge into alice’s users.json line')
})
})
+42
View File
@@ -0,0 +1,42 @@
import { computed, ref, type ComputedRef, type Ref } from 'vue'
import { isValidName, isValidUserName } from '@/lib/snippets'
/** GrantTargetKind — named to avoid colliding with lib/target-columns.ts's own unrelated TargetKind ('mcp' | 'agent'). */
export type GrantTargetKind = 'group' | 'user'
export interface UseTargetPicker {
targetKind: Ref<GrantTargetKind>
targetName: Ref<string>
/** Every configured name for the CURRENT targetKind — groupNames() while 'group', userNames() while 'user'. */
targetNames: ComputedRef<string[]>
/** A group name follows NAME_PATTERN (isValidName); a user name only needs to be non-empty (isValidUserName) — auth.go's own two different name rules. */
nameValid: ComputedRef<boolean>
/** The SnippetBlock label every ChangeHelper form using this picker shows once its snippet is ready. */
snippetLabel: ComputedRef<string>
}
/**
* useTargetPicker (reuse-audit.md F12) is the ONE "Target kind, then Group
* or User name" picker GrantForm.vue and LimitsForm.vue used to each
* hand-roll — the same targetKind/targetName refs, targetNames/nameValid
* computeds (down to the identical doc comments), and SnippetBlock label
* ternary. `groupNames`/`userNames` are getters, not plain arrays, so a
* caller can pass `() => props.groupNames` and keep this reactive to a
* prop that changes after mount (destructuring `props.groupNames`
* directly would freeze it at the value read at call time).
*/
export function useTargetPicker(groupNames: () => string[], userNames: () => string[]): UseTargetPicker {
const targetKind = ref<GrantTargetKind>('group')
const targetName = ref('')
const targetNames = computed(() => (targetKind.value === 'group' ? groupNames() : userNames()))
const nameValid = computed(() => (targetKind.value === 'group' ? isValidName(targetName.value.trim()) : isValidUserName(targetName.value)))
const snippetLabel = computed(() =>
targetKind.value === 'group' ? 'Paste into middleware.yaml' : `Merge into ${targetName.value}’s users.json line`,
)
return { targetKind, targetName, targetNames, nameValid, snippetLabel }
}