feat(ui): show alert action buttons and status chips

Alerts that match a configured action get a button labelled from the
config. A confirm dialog opens first when the action defines confirm text.
After a press the UI posts the alert, reloads the state through the
existing poll path and shows the reported status as a chip, with an anchor
when the status carries a link.

The alert dialog comes from reka-ui through the usual shadcn-vue wrappers.
This commit is contained in:
2026-10-04 16:37:31 +01:00
parent 5824c4dd10
commit 23bbbedf59
18 changed files with 496 additions and 4 deletions
+2
View File
@@ -2,6 +2,7 @@
import { computed } from 'vue'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import AgeMeter from '@/components/AgeMeter.vue'
import RowActions from '@/components/RowActions.vue'
import { ageSince, isZeroTime, secondsSince, shorten } from '@/lib/format'
import { iconFor } from '@/lib/kinds'
import type { Row } from '@/lib/types'
@@ -33,6 +34,7 @@ const ratio = computed(() => secondsSince(props.row.since, props.nowMs) / props.
<p class="text-muted-foreground mt-0.5 text-sm wrap-anywhere">
{{ shorten(row.detail, 120) }}
</p>
<RowActions v-if="row.actions?.length" :row="row" />
</div>
<div
class="col-start-2 flex items-center gap-3 sm:col-start-auto sm:flex-col sm:items-end sm:gap-1.5 sm:pt-0.5"
+121
View File
@@ -0,0 +1,121 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import AttentionRow from './AttentionRow.vue'
import { alert, hoursAgo, repo, state } from '@/test/fixtures'
import { buildBlocks } from '@/stores/tracker'
import type { ActionDef, Alert, Row } from '@/lib/types'
const rerun: ActionDef = {
id: 'rerun',
label: 'Re-run',
confirm: 'Run this action?',
kinds: ['workflow_failed'],
}
const rowFor = (actions: ActionDef[], over: Partial<Alert> = {}): Row =>
buildBlocks(state({ repos: [repo()], alerts: [alert(over)], actions }))[0].rows[0]
describe('buildBlocks with actions', () => {
it('leaves rows untouched when no actions are configured', () => {
const r = rowFor([])
expect('actions' in r).toBe(false)
expect('actionStatus' in r).toBe(false)
})
it.each([
['kind listed', ['workflow_failed'], true],
['kind not listed', ['pr_waiting'], false],
['empty kinds match everything', [], true],
])('%s', (_name, kinds, shown) => {
const r = rowFor([{ ...rerun, kinds }])
expect(Boolean(r.actions?.length)).toBe(shown)
})
it('carries the status of the alert', () => {
const r = rowFor([rerun], { actionStatus: { rerun: { state: 'running' } } })
expect(r.actionStatus).toEqual({ rerun: { state: 'running' } })
})
})
describe('RowActions', () => {
beforeEach(() => setActivePinia(createPinia()))
afterEach(() => {
vi.unstubAllGlobals()
document.body.innerHTML = ''
})
const mountRow = (row: Row) =>
mount(AttentionRow, {
props: { row, nowMs: Date.parse(hoursAgo(0)), critSeconds: 604800 },
attachTo: document.body,
})
it('shows no button without actions', () => {
const w = mountRow(rowFor([]))
expect(w.find('[data-test=row-actions]').exists()).toBe(false)
})
it('shows a button labelled from the config', () => {
const w = mountRow(rowFor([rerun]))
expect(w.find('[data-test=action-rerun]').text()).toBe('Re-run')
expect(w.find('[data-test=action-status]').exists()).toBe(false)
})
it('asks for confirmation, then posts the alert', async () => {
const fetchMock = vi.fn((url: string) =>
Promise.resolve(
new Response(
JSON.stringify(
url.startsWith('/api/actions/')
? { state: 'queued', link: 'https://ci.example.test/9' }
: state(),
),
{ status: 200 },
),
),
)
vi.stubGlobal('fetch', fetchMock)
const w = mountRow(rowFor([rerun]))
await w.get('[data-test=action-rerun]').trigger('click')
expect(document.body.textContent).toContain('Run this action?')
expect(fetchMock).not.toHaveBeenCalled()
;(document.body.querySelector('[data-test=action-confirm]') as HTMLElement).click()
await vi.waitFor(() => expect(w.find('[data-test=action-status]').exists()).toBe(true))
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
expect(url).toBe('/api/actions/rerun')
expect(JSON.parse(init.body as string)).toEqual({
repo: 'o/r',
url: 'https://github.com/o/r/actions/runs/1',
})
await vi.waitFor(() =>
expect(w.get('[data-test=action-status] a').attributes('href')).toBe(
'https://ci.example.test/9',
),
)
})
it('posts at once when the action has no confirm text', async () => {
const fetchMock = vi.fn<(url: string, init?: RequestInit) => Promise<Response>>(() =>
Promise.resolve(new Response('{}', { status: 200 })),
)
vi.stubGlobal('fetch', fetchMock)
const w = mountRow(rowFor([{ ...rerun, confirm: undefined }]))
await w.get('[data-test=action-rerun]').trigger('click')
expect(fetchMock).toHaveBeenCalledWith('/api/actions/rerun', expect.anything())
})
it('shows the status reported by the server as text, or as a link', () => {
const plain = mountRow(
rowFor([rerun], { actionStatus: { rerun: { state: 'done', label: 'Finished' } } }),
)
expect(plain.get('[data-test=action-status]').text()).toBe('Finished')
expect(plain.find('[data-test=action-status] a').exists()).toBe(false)
const linked = mountRow(
rowFor([rerun], {
actionStatus: { rerun: { state: 'done', link: 'https://x.example.test/' } },
}),
)
expect(linked.get('[data-test=action-status] a').text()).toBe('done')
})
})
+83
View File
@@ -0,0 +1,83 @@
<script setup lang="ts">
import { ref } from 'vue'
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { actionKey, useTrackerStore } from '@/stores/tracker'
import type { ActionDef, ActionStatus, Row } from '@/lib/types'
const props = defineProps<{ row: Row }>()
const store = useTrackerStore()
// `asking` outlives the dialog so the confirm click still knows which action to run.
const asking = ref<ActionDef | null>(null)
const open = ref(false)
const keyOf = (a: ActionDef) => actionKey(a.id, props.row.repo, props.row.url)
const busy = (a: ActionDef) => store.runningActions.includes(keyOf(a))
const statusOf = (a: ActionDef): ActionStatus | undefined =>
props.row.actionStatus?.[a.id] ?? store.localStatus[keyOf(a)]
function run(a: ActionDef): void {
void store.runAction(a.id, props.row.repo, props.row.url)
}
function press(a: ActionDef): void {
if (!a.confirm) return run(a)
asking.value = a
open.value = true
}
</script>
<template>
<div class="mt-2 flex flex-wrap items-center gap-2" data-test="row-actions">
<template v-for="a in row.actions" :key="a.id">
<Button
variant="outline"
size="xs"
:disabled="busy(a)"
:aria-busy="busy(a)"
:data-test="`action-${a.id}`"
@click="press(a)"
>
<FontAwesomeIcon v-if="busy(a)" :icon="faSpinner" spin aria-hidden="true" />
{{ a.label }}
</Button>
<Badge v-if="busy(a)" variant="secondary" data-test="action-status">Sending</Badge>
<Badge v-else-if="statusOf(a)" variant="secondary" data-test="action-status">
<a
v-if="statusOf(a)?.link"
:href="statusOf(a)?.link"
class="underline-offset-4 hover:underline"
>{{ statusOf(a)?.label || statusOf(a)?.state }}</a
>
<template v-else>{{ statusOf(a)?.label || statusOf(a)?.state }}</template>
</Badge>
</template>
<AlertDialog v-model:open="open">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{{ asking?.label }}</AlertDialogTitle>
<AlertDialogDescription>{{ asking?.confirm }}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction data-test="action-confirm" @click="asking && run(asking)">
{{ asking?.label }}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</template>
+1
View File
@@ -125,6 +125,7 @@ describe('AgeMeter', () => {
describe('AttentionRow', () => {
const row = (over: Partial<Row> = {}): Row => ({
repo: 'o/r',
kind: 'pr_waiting',
subject: '#5 <b>bold</b> title',
detail: 'open 9d',
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { AlertDialogEmits, AlertDialogProps } from 'reka-ui'
import { AlertDialogRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<AlertDialogProps>()
const emits = defineEmits<AlertDialogEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<AlertDialogRoot data-slot="alert-dialog" v-bind="forwarded">
<slot />
</AlertDialogRoot>
</template>
@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { AlertDialogActionProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { AlertDialogAction } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<AlertDialogActionProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
<slot />
</AlertDialogAction>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { AlertDialogCancelProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { AlertDialogCancel } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<AlertDialogCancel
v-bind="delegatedProps"
:class="cn(buttonVariants({ variant: 'outline' }), props.class)"
>
<slot />
</AlertDialogCancel>
</template>
@@ -0,0 +1,43 @@
<script setup lang="ts">
import type { AlertDialogContentEmits, AlertDialogContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
AlertDialogContent,
AlertDialogOverlay,
AlertDialogPortal,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
defineOptions({
inheritAttrs: false,
})
const props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<AlertDialogContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<AlertDialogPortal>
<AlertDialogOverlay
data-slot="alert-dialog-overlay"
class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50"
/>
<AlertDialogContent
data-slot="alert-dialog-content"
v-bind="{ ...forwarded, ...$attrs }"
:class="
cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
props.class,
)
"
>
<slot />
</AlertDialogContent>
</AlertDialogPortal>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { AlertDialogDescriptionProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { AlertDialogDescription, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<AlertDialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<AlertDialogDescription
data-slot="alert-dialog-description"
v-bind="forwardedProps"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</AlertDialogDescription>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>
<template>
<div
data-slot="alert-dialog-footer"
:class="cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>
<template>
<div
data-slot="alert-dialog-header"
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { AlertDialogTitleProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { AlertDialogTitle, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<AlertDialogTitleProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<AlertDialogTitle
data-slot="alert-dialog-title"
v-bind="forwardedProps"
:class="cn('text-lg font-semibold', props.class)"
>
<slot />
</AlertDialogTitle>
</template>
+8
View File
@@ -0,0 +1,8 @@
export { default as AlertDialog } from './AlertDialog.vue'
export { default as AlertDialogAction } from './AlertDialogAction.vue'
export { default as AlertDialogCancel } from './AlertDialogCancel.vue'
export { default as AlertDialogContent } from './AlertDialogContent.vue'
export { default as AlertDialogDescription } from './AlertDialogDescription.vue'
export { default as AlertDialogFooter } from './AlertDialogFooter.vue'
export { default as AlertDialogHeader } from './AlertDialogHeader.vue'
export { default as AlertDialogTitle } from './AlertDialogTitle.vue'