+ Pull requests are hidden while the filter keeps only pipelines without one.
+
+
+
From 4b33252c9e45494fe5aabda91f102fe401dfad7c Mon Sep 17 00:00:00 2001
From: Olivier Vernin
Date: Fri, 2 Oct 2026 17:05:29 +0200
Subject: [PATCH 2/5] fix: keep the pager on the page shown when another fails
to load
A page the reader asks for only becomes the current one once it
arrived. When it fails, the pager stays on the rows shown and says the
page could not be loaded, until the reader asks for a page again or
changes the filter; a background refresh no longer clears it. A refresh
that lands while a page is pending loads that page too.
Pull requests whose link is not http(s) are now listed as plain text
instead of being dropped, so the list agrees with the count.
---
src/components/PullRequestList.spec.js | 82 +++++++++++++++++++++++++-
src/components/PullRequestList.vue | 70 ++++++++++++++++------
2 files changed, 132 insertions(+), 20 deletions(-)
diff --git a/src/components/PullRequestList.spec.js b/src/components/PullRequestList.spec.js
index 64c510991..edbdfca3f 100644
--- a/src/components/PullRequestList.spec.js
+++ b/src/components/PullRequestList.spec.js
@@ -163,12 +163,55 @@ describe('PullRequestList', () => {
expect(title.text()).toContain('opens the merge request in a new tab')
})
- it('drops a link that is not http or https', async () => {
- answer({ data: [action(7, { url: 'javascript:alert(1)' }), action(8)], total_count: 2 })
+ it('lists a pull request whose link is not http or https as plain text', async () => {
+ answer({ data: [action(7, { url: 'javascript:alert(1)', title: 'Unsafe' }), action(8)], total_count: 2 })
const wrapper = await mountList()
+ // Both are counted and listed, only the safe one is a link.
+ expect(wrapper.findAll('.pull-requests__item')).toHaveLength(2)
const hrefs = wrapper.findAll('a[target="_blank"]').map((link) => link.attributes('href'))
expect(hrefs).toEqual(['https://github.com/updatecli/udash/pull/8'])
+ const unsafe = wrapper.findAll('.pull-requests__title')[0]
+ expect(unsafe.element.tagName).toBe('SPAN')
+ expect(unsafe.text()).toBe('Unsafe')
+ })
+
+ it('keeps the pager on the rows shown when the page asked for fails to load', async () => {
+ answer({ data: [action(1)], total_count: 30 })
+ const wrapper = await mountList({ paginated: true, limit: 10 })
+
+ api.apiFetch.mockImplementation(async () => { throw Object.assign(new Error('boom'), { status: 503 }) })
+ wrapper.getComponent(VPagination).vm.$emit('update:modelValue', 3)
+ await flushPromises()
+
+ expect(wrapper.getComponent(VPagination).props('modelValue')).toBe(1)
+ expect(wrapper.text()).toContain('Bump dependency 1')
+ expect(wrapper.get('.pull-requests__page-error').text()).toBe('Page 3 could not be loaded. The Udash API is unavailable right now. Try again in a moment.')
+
+ // A later refresh still reads the page on screen, not the one that failed, and keeps
+ // saying the page asked for could not be loaded.
+ answer({ data: [action(1)], total_count: 30 })
+ await vi.advanceTimersByTimeAsync(60 * 1000)
+ await flushPromises()
+ expect(lastBody()).toMatchObject({ page: 1 })
+ expect(wrapper.find('.pull-requests__page-error').exists()).toBe(true)
+
+ // Asking for a page again replaces it.
+ answer({ data: [action(11)], total_count: 30 })
+ wrapper.getComponent(VPagination).vm.$emit('update:modelValue', 2)
+ await flushPromises()
+ expect(wrapper.find('.pull-requests__page-error').exists()).toBe(false)
+ })
+
+ it('drops the count of the previous filter while the new one loads', async () => {
+ answer({ data: [action(1)], total_count: 12 })
+ const wrapper = await mountList({ filter: {}, countQualifier: 'in this period' })
+ expect(wrapper.get('.pull-requests__count').text()).toBe('12 pull requests in this period')
+
+ api.apiFetch.mockImplementation(() => new Promise(() => {}))
+ await wrapper.setProps({ filter: { results: ['✗'] } })
+
+ expect(wrapper.find('.pull-requests__count').exists()).toBe(false)
})
it('pages through every pull request', async () => {
@@ -232,6 +275,41 @@ describe('PullRequestList', () => {
expect(wrapper.text()).toContain('Bump dependency 12')
})
+ it('keeps the page asked for when a refresh comes before it arrived', async () => {
+ answer({ data: [action(1)], total_count: 30 })
+ const wrapper = await mountList({ paginated: true, limit: 10 })
+
+ api.apiFetch.mockImplementation(() => new Promise(() => {}))
+ wrapper.getComponent(VPagination).vm.$emit('update:modelValue', 3)
+ await flushPromises()
+
+ answer({ data: [action(21)], total_count: 30 })
+ await vi.advanceTimersByTimeAsync(60 * 1000)
+ await flushPromises()
+
+ expect(lastBody()).toMatchObject({ page: 3 })
+ expect(wrapper.getComponent(VPagination).props('modelValue')).toBe(3)
+ expect(wrapper.text()).toContain('Bump dependency 21')
+ expect(wrapper.get('[aria-live="polite"]').text()).toBe('Page 3 of 3, pull requests 21 to 21 of 30')
+ })
+
+ it('says nothing when a refresh fails to move back to the last page', async () => {
+ answer({ data: [action(1)], total_count: 30 })
+ const wrapper = await mountList({ paginated: true, limit: 10 })
+ wrapper.getComponent(VPagination).vm.$emit('update:modelValue', 3)
+ await flushPromises()
+
+ api.apiFetch.mockImplementation(async (path, { body }) => {
+ if (body.page === 3) return { data: [], total_count: 12 }
+ throw Object.assign(new Error('boom'), { status: 503 })
+ })
+ await vi.advanceTimersByTimeAsync(60 * 1000)
+ await flushPromises()
+
+ expect(lastBody()).toMatchObject({ page: 2 })
+ expect(wrapper.find('.pull-requests__page-error').exists()).toBe(false)
+ })
+
it('qualifies the count when a filter narrows it', async () => {
answer({ data: [action(1)], total_count: 3 })
const wrapper = await mountList({ countQualifier: 'in this period' })
diff --git a/src/components/PullRequestList.vue b/src/components/PullRequestList.vue
index 8f2ef278d..1d2efd42a 100644
--- a/src/components/PullRequestList.vue
+++ b/src/components/PullRequestList.vue
@@ -30,7 +30,7 @@
@@ -164,6 +176,7 @@ const error = ref(null)
const loading = ref(false)
const page = ref(1)
const announcement = ref('')
+const pageError = ref('')
const expanded = reactive({})
const pageCount = computed(() => Math.ceil(total.value / props.limit))
@@ -174,6 +187,9 @@ const countLabel = computed(() => [
].filter(Boolean).join(' '))
let generation = 0
+// The page the reader asked for, until its request settled. A refresh meanwhile loads
+// it too, so it does not drop the page the reader is waiting on.
+let requested = null
function severity(result) {
const rank = SEVERITY.indexOf(result)
@@ -205,9 +221,11 @@ function toRow(action) {
.map((result) => ({ result, count: counts[result], textClass: TALLY_CLASSES[result] }))
return {
+ key: action.url,
+ // url is empty when the link is not http(s), which the row then shows as plain text.
url,
hasTitle: Boolean(action.title),
- title: action.title || url.replace(/^https?:\/\//, ''),
+ title: action.title || String(action.url || '').replace(/^https?:\/\//i, ''),
noun: extractGitURLInfo(url)?.provider === 'gitlab' ? 'merge request' : 'pull request',
repository: shortRepository(action.repository),
branch: action.branch || '',
@@ -219,10 +237,15 @@ function toRow(action) {
}
}
-// load fetches the current page. announce says whether to tell screen readers what
-// the page now holds, which only a change asked for by the reader warrants.
-async function load({ announce = false } = {}) {
+// load fetches a page, the one the reader asked for or else the current one. The page
+// only becomes the current one once it arrived, so a page that fails to load leaves the
+// pager on the rows shown. announce says whether the reader asked for that page, which
+// warrants telling screen readers what it holds, or that it could not be loaded.
+async function load({ target = requested ?? page.value, announce = requested !== null } = {}) {
error.value = null
+ // Only a page the reader asks for replaces the message: a background refresh, even one
+ // that succeeds, still leaves the pager short of the page that could not be loaded.
+ if (announce) pageError.value = ''
loading.value = true
// A slow response must not overwrite the one from a later refresh.
const current = ++generation
@@ -234,21 +257,21 @@ async function load({ announce = false } = {}) {
...filterRequestBody(props.filter),
...(props.filter?.scmid ? { scmid: props.filter.scmid } : {}),
limit: props.limit,
- page: page.value,
+ page: target,
},
})
if (current !== generation) return
total.value = data.total_count || 0
- // A refresh can leave the current page past the last one, once pull requests got merged.
- if ((data.data || []).length === 0 && total.value > 0 && page.value > pageCount.value) {
- page.value = pageCount.value
- load()
+ // A refresh can leave the page past the last one, once pull requests got merged.
+ if ((data.data || []).length === 0 && total.value > 0 && target > pageCount.value) {
+ load({ target: pageCount.value, announce })
return
}
- // A link that is not http(s) is not offered at all.
- rows.value = (data.data || []).map(toRow).filter((row) => row.url)
+ requested = null
+ page.value = target
+ rows.value = (data.data || []).map(toRow)
markUpdated()
if (announce) {
@@ -258,9 +281,13 @@ async function load({ announce = false } = {}) {
}
} catch (loadError) {
if (current !== generation) return
- // A background refresh that fails keeps what is already on screen.
+ requested = null
+ // A background refresh that fails keeps what is already on screen. A page the reader
+ // asked for says it could not be loaded, next to the pager still on the rows shown.
if (rows.value === null) {
error.value = describeLoadError(loadError, 'the pull requests')
+ } else if (announce) {
+ pageError.value = `Page ${target} could not be loaded. ${describeLoadError(loadError, 'it')}`
}
} finally {
if (current === generation) loading.value = false
@@ -269,13 +296,16 @@ async function load({ announce = false } = {}) {
// goTo moves to a page the reader asked for, so what it now holds is announced.
function goTo(nextPage) {
- page.value = nextPage
- load({ announce: true })
+ requested = nextPage
+ load()
}
watch(() => props.filter, () => {
rows.value = null
+ total.value = 0
page.value = 1
+ requested = null
+ pageError.value = ''
load()
}, { deep: true })
@@ -463,6 +493,10 @@ a.pull-requests__count:focus-visible {
margin-top: 8px;
}
+.pull-requests__page-error {
+ margin: 4px 0 0;
+}
+
.pull-requests__pagination :deep(.v-pagination__list) {
justify-content: flex-start;
}
From ef12574438f35d67c444205863230be3e81de670 Mon Sep 17 00:00:00 2001
From: Olivier Vernin
Date: Fri, 2 Oct 2026 17:07:37 +0200
Subject: [PATCH 3/5] chore: limit to 5 pr at a time
Signed-off-by: Olivier Vernin
---
src/views/Dashboard.vue | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/views/Dashboard.vue b/src/views/Dashboard.vue
index 5e76f5c40..128f2d2ef 100644
--- a/src/views/Dashboard.vue
+++ b/src/views/Dashboard.vue
@@ -35,7 +35,7 @@
From c89680666b204c439ceeac60fbf1d60bc99d4589 Mon Sep 17 00:00:00 2001
From: Olivier Vernin
Date: Fri, 2 Oct 2026 21:57:08 +0200
Subject: [PATCH 4/5] chore: move comment inside if
Signed-off-by: Olivier Vernin
---
src/components/pipeline/activityChart.vue | 24 ++++++++++++-----------
1 file changed, 13 insertions(+), 11 deletions(-)
diff --git a/src/components/pipeline/activityChart.vue b/src/components/pipeline/activityChart.vue
index e94427c6e..dd6a5c20c 100644
--- a/src/components/pipeline/activityChart.vue
+++ b/src/components/pipeline/activityChart.vue
@@ -1,18 +1,18 @@
-