mirror of
https://github.com/lukaszraczylo/ghtracker.git
synced 2026-10-17 01:17:48 +00:00
feat: refresh a single repository from its own button (#2)
* feat(refresh): refresh a single repository with POST /refresh?repo= The endpoint refreshes one configured repository and returns the full state plus the repo name. Each repository has a 60-second cooldown that is separate from the full-refresh limit. A running full refresh or a refresh of the same repository returns 409. An unknown repository returns 404. Cross-site and disabled-config behaviour stay the same. The collector reuses its fetch path and replaces only that repository. * feat(ui): add a refresh button beside each repository The button appears in the project list and in each attention group header. It stays hidden when the server disables manual refresh. The button shows a spinner while its repository refreshes and reports refusals in the notice line. The store swaps in the returned state, so no extra fetch follows.
This commit is contained in:
@@ -16,9 +16,12 @@ const props = defineProps<{
|
||||
facets: Facets
|
||||
owners: string[]
|
||||
active: number
|
||||
manualRefresh?: boolean
|
||||
refreshing?: string[]
|
||||
scanning?: boolean
|
||||
}>()
|
||||
const filters = defineModel<Filters>('filters', { required: true })
|
||||
defineEmits<{ clear: [] }>()
|
||||
defineEmits<{ clear: []; refresh: [fullName: string] }>()
|
||||
|
||||
const columnCount = useColumns()
|
||||
// A group's height is its header plus its rows, so rows+2 approximates it.
|
||||
@@ -59,6 +62,10 @@ defineExpose({ focusSearch: () => bar.value?.focus() })
|
||||
:block="block"
|
||||
:now-ms="nowMs"
|
||||
:crit-seconds="critSeconds"
|
||||
:manual-refresh="manualRefresh"
|
||||
:refreshing="refreshing"
|
||||
:scanning="scanning"
|
||||
@refresh="(name) => $emit('refresh', name)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,11 +2,20 @@
|
||||
import { faCodePullRequest, faCircleDot, faGears } from '@fortawesome/free-solid-svg-icons'
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import RepoRefreshButton from '@/components/RepoRefreshButton.vue'
|
||||
import { ageSince, isZeroTime } from '@/lib/format'
|
||||
import type { Repo } from '@/lib/types'
|
||||
|
||||
const props = defineProps<{ repos: Repo[]; total: number; focused: string | null; nowMs: number }>()
|
||||
defineEmits<{ select: [fullName: string] }>()
|
||||
const props = defineProps<{
|
||||
repos: Repo[]
|
||||
total: number
|
||||
focused: string | null
|
||||
nowMs: number
|
||||
manualRefresh?: boolean
|
||||
refreshing?: string[]
|
||||
scanning?: boolean
|
||||
}>()
|
||||
defineEmits<{ select: [fullName: string]; refresh: [fullName: string] }>()
|
||||
|
||||
/** Rows rendered at first and added each time the list is scrolled near its end. */
|
||||
const PAGE_SIZE = 20
|
||||
@@ -111,6 +120,14 @@ const LABEL = { crit: 'Critical', warn: 'Warning', ok: 'Healthy' } as const
|
||||
<a v-else :href="repo.releasesUrl" class="text-muted-foreground hover:text-link text-xs"
|
||||
>no release</a
|
||||
>
|
||||
<RepoRefreshButton
|
||||
v-if="manualRefresh"
|
||||
:repo="repo.fullName"
|
||||
:busy="refreshing?.includes(repo.fullName) ?? false"
|
||||
:blocked="scanning ?? false"
|
||||
class="-my-1 -mr-1"
|
||||
@refresh="$emit('refresh', repo.fullName)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="text-muted-foreground mt-1 flex items-center gap-4 pl-[18px] text-xs tabular-nums"
|
||||
|
||||
@@ -2,10 +2,19 @@
|
||||
import { faTag } from '@fortawesome/free-solid-svg-icons'
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import AttentionRow from '@/components/AttentionRow.vue'
|
||||
import RepoRefreshButton from '@/components/RepoRefreshButton.vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import type { Block } from '@/lib/types'
|
||||
|
||||
defineProps<{ block: Block; nowMs: number; critSeconds: number }>()
|
||||
defineProps<{
|
||||
block: Block
|
||||
nowMs: number
|
||||
critSeconds: number
|
||||
manualRefresh?: boolean
|
||||
refreshing?: string[]
|
||||
scanning?: boolean
|
||||
}>()
|
||||
defineEmits<{ refresh: [fullName: string] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -24,8 +33,15 @@ defineProps<{ block: Block; nowMs: number; critSeconds: number }>()
|
||||
{{ block.repo.release.tag }}
|
||||
</a>
|
||||
</Badge>
|
||||
<span class="text-muted-foreground ml-auto text-xs">
|
||||
<span class="text-muted-foreground ml-auto flex items-center gap-1 text-xs">
|
||||
{{ block.rows.length }} {{ block.rows.length === 1 ? 'item' : 'items' }}
|
||||
<RepoRefreshButton
|
||||
v-if="manualRefresh"
|
||||
:repo="block.repo.fullName"
|
||||
:busy="refreshing?.includes(block.repo.fullName) ?? false"
|
||||
:blocked="scanning ?? false"
|
||||
@refresh="$emit('refresh', block.repo.fullName)"
|
||||
/>
|
||||
</span>
|
||||
</header>
|
||||
<ul class="divide-border divide-y px-4">
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { faArrowsRotate } from '@fortawesome/free-solid-svg-icons'
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
defineProps<{ repo: string; busy: boolean; blocked: boolean }>()
|
||||
defineEmits<{ refresh: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
class="text-muted-foreground hover:text-link shrink-0"
|
||||
:disabled="busy || blocked"
|
||||
:aria-label="`Refresh ${repo}`"
|
||||
:aria-busy="busy"
|
||||
:title="`Refresh ${repo}`"
|
||||
data-test="repo-refresh"
|
||||
@click="$emit('refresh')"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faArrowsRotate" :spin="busy" aria-hidden="true" />
|
||||
</Button>
|
||||
</template>
|
||||
@@ -448,3 +448,55 @@ describe('LoadingScreen', () => {
|
||||
expect(w.attributes('aria-live')).toBe('polite')
|
||||
})
|
||||
})
|
||||
|
||||
describe('per-repository refresh button', () => {
|
||||
const block = buildBlocks(mixedState())[0]
|
||||
const repos = mixedState().repos
|
||||
const group = (extra = {}) =>
|
||||
mount(RepoGroup, { props: { block, nowMs, critSeconds: CRIT_SECONDS, ...extra } })
|
||||
const list = (extra = {}) =>
|
||||
mount(ProjectList, { props: { repos, total: 2, focused: null, nowMs, ...extra } })
|
||||
|
||||
it('is hidden unless manual refresh is on', () => {
|
||||
expect(group().find('[data-test=repo-refresh]').exists()).toBe(false)
|
||||
expect(list().find('[data-test=repo-refresh]').exists()).toBe(false)
|
||||
expect(list({ manualRefresh: false }).find('[data-test=repo-refresh]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders one labelled button per project and one per group header', () => {
|
||||
const l = list({ manualRefresh: true })
|
||||
const buttons = l.findAll('[data-test=project] [data-test=repo-refresh]')
|
||||
expect(buttons.map((b) => b.attributes('aria-label'))).toEqual([
|
||||
'Refresh o/r',
|
||||
'Refresh o/fine',
|
||||
])
|
||||
const g = group({ manualRefresh: true })
|
||||
expect(g.get('header [data-test=repo-refresh]').attributes('aria-label')).toBe('Refresh o/r')
|
||||
})
|
||||
|
||||
it('emits the repo name on click without selecting the project', async () => {
|
||||
const l = list({ manualRefresh: true })
|
||||
await l.findAll('[data-test=repo-refresh]')[1].trigger('click')
|
||||
expect(l.emitted('refresh')).toEqual([['o/fine']])
|
||||
expect(l.emitted('select')).toBeUndefined()
|
||||
const g = group({ manualRefresh: true })
|
||||
await g.get('[data-test=repo-refresh]').trigger('click')
|
||||
expect(g.emitted('refresh')).toEqual([['o/r']])
|
||||
})
|
||||
|
||||
it('disables only the refreshing repo and spins its icon', () => {
|
||||
const l = list({ manualRefresh: true, refreshing: ['o/r'] })
|
||||
const [busy, idle] = l.findAll('[data-test=repo-refresh]')
|
||||
expect(busy.attributes('disabled')).toBeDefined()
|
||||
expect(busy.attributes('aria-busy')).toBe('true')
|
||||
expect(busy.find('svg').classes()).toContain('fa-spin')
|
||||
expect(idle.attributes('disabled')).toBeUndefined()
|
||||
expect(idle.find('svg').classes()).not.toContain('fa-spin')
|
||||
})
|
||||
|
||||
it('disables every button while a full scan runs', () => {
|
||||
const l = list({ manualRefresh: true, scanning: true })
|
||||
for (const b of l.findAll('[data-test=repo-refresh]'))
|
||||
expect(b.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user