From b61eca0901dbf565e07895424b3d1d0c3abae93d Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Mon, 7 Sep 2026 13:42:27 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Animate=20rows=20that=20arrived=20s?= =?UTF-8?q?ince=20the=20previous=20refresh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under auto-refresh new messages simply appeared at the top of the list with nothing to tell them apart from the rows already there. No results view in ServicePulse had an arrival animation to reuse, so this adds one for All Messages. The store records which ids of a result were absent from the previous result of the same query. The list marks those rows and a one-shot CSS animation slides them in and lets a blue tint fade over three seconds; the element is new to the DOM (rows are keyed by id), so it plays once on insertion. Nothing animates on the first result, when the query itself changed, after a failure, or on the first result after the view was left and re-entered. Reduced motion keeps the tint but drops the movement. --- .../src/components/audit/AuditList.spec.ts | 14 ++++ .../src/components/audit/AuditList.vue | 46 ++++++++++++- src/Frontend/src/stores/AuditStore.spec.ts | 65 +++++++++++++++++++ src/Frontend/src/stores/AuditStore.ts | 26 ++++++++ 4 files changed, 149 insertions(+), 2 deletions(-) diff --git a/src/Frontend/src/components/audit/AuditList.spec.ts b/src/Frontend/src/components/audit/AuditList.spec.ts index 59f390805..9415b162c 100644 --- a/src/Frontend/src/components/audit/AuditList.spec.ts +++ b/src/Frontend/src/components/audit/AuditList.spec.ts @@ -363,6 +363,20 @@ describe("FEATURE: Audit Messages Query State", () => { }); }); + describe("RULE: Rows that arrived since the previous refresh are marked so they can animate in", () => { + test("EXAMPLE: Only the rows the store reports as new carry the new-row marker", async () => { + const { store } = await renderAuditList([createMessage("msg-1"), createMessage("msg-2")]); + await waitForFirstLoadToComplete(); + + store.newMessageIds = ["msg-2"]; + await nextTick(); + + const [first, second] = getMessageItems(); + expect(first).not.toHaveClass("new-row"); + expect(second).toHaveClass("new-row"); + }); + }); + describe("RULE: A failed query tells the user what happened and what to try", () => { test("EXAMPLE: The error banner is shown after a failed query", async () => { const { store } = await renderAuditList([]); diff --git a/src/Frontend/src/components/audit/AuditList.vue b/src/Frontend/src/components/audit/AuditList.vue index 2f6241746..a16459b76 100644 --- a/src/Frontend/src/components/audit/AuditList.vue +++ b/src/Frontend/src/components/audit/AuditList.vue @@ -19,7 +19,8 @@ import { useConfigurationStore } from "@/stores/ConfigurationStore"; import { loadDefaultRange, narrowingPresets, resolveTimeRange, type RangePreset } from "@/components/audit/timeRange"; const store = useAuditStore(); -const { messages, totalCount, sortBy, messageFilterString, selectedEndpointName, itemsPerPage, timeRangeFrom, timeRangeTo, queryFailed, queryDurationMs, queryCompletedAt } = storeToRefs(store); +const { messages, newMessageIds, totalCount, sortBy, messageFilterString, selectedEndpointName, itemsPerPage, timeRangeFrom, timeRangeTo, queryFailed, queryDurationMs, queryCompletedAt } = storeToRefs(store); +const newRowIds = computed(() => new Set(newMessageIds.value)); const route = useRoute(); const router = useRouter(); const autoRefreshValue = ref(null); @@ -223,7 +224,7 @@ watch(autoRefreshValue, (newValue) => { visible and usable: the refresh button already signals the running query --> @@ -339,4 +340,45 @@ watch(autoRefreshValue, (newValue) => { .results-table > :not(.item) { grid-column: 1 / -1; } + +/* A row that arrived since the previous refresh of the same query slides in and + glows briefly, so what changed is visible without hunting for it. The glow + lasts a few seconds because auto-refresh ticks are seconds apart, and it plays + once per row: the element is new to the DOM (keyed by id), so the animation + starts on insertion and does not restart on later renders. */ +.results-table > .new-row { + animation: new-row-arrive 3s ease-out; +} + +@keyframes new-row-arrive { + 0% { + opacity: 0; + transform: translateY(-0.5rem); + background-color: #d3ebf2; + } + 12% { + opacity: 1; + transform: none; + background-color: #d3ebf2; + } + 100% { + background-color: transparent; + } +} + +@media (prefers-reduced-motion: reduce) { + .results-table > .new-row { + animation: new-row-glow 3s ease-out; + } + + @keyframes new-row-glow { + 0%, + 40% { + background-color: #d3ebf2; + } + 100% { + background-color: transparent; + } + } +} diff --git a/src/Frontend/src/stores/AuditStore.spec.ts b/src/Frontend/src/stores/AuditStore.spec.ts index 07d809872..54fcad1cd 100644 --- a/src/Frontend/src/stores/AuditStore.spec.ts +++ b/src/Frontend/src/stores/AuditStore.spec.ts @@ -59,6 +59,55 @@ describe("AuditStore refresh", () => { expect(store.queryFailed).toBe(false); }); + describe("new rows since the previous result of the same query", () => { + const msg = (id: string) => ({ id }); + + test("rows that were not in the previous result are marked new", async () => { + const store = useAuditStore(); + fetchTypedFromServiceControl.mockResolvedValueOnce([responseWithTotalCount(1), [msg("msg-1")]]); + await store.refresh(); + + fetchTypedFromServiceControl.mockResolvedValueOnce([responseWithTotalCount(2), [msg("msg-2"), msg("msg-1")]]); + await store.refresh(); + + expect(store.newMessageIds).toEqual(["msg-2"]); + }); + + test("the first result is never marked new", async () => { + const store = useAuditStore(); + fetchTypedFromServiceControl.mockResolvedValueOnce([responseWithTotalCount(2), [msg("msg-1"), msg("msg-2")]]); + + await store.refresh(); + + expect(store.newMessageIds).toEqual([]); + }); + + test("a changed query marks nothing new, even when every row differs", async () => { + const store = useAuditStore(); + fetchTypedFromServiceControl.mockResolvedValueOnce([responseWithTotalCount(1), [msg("msg-1")]]); + await store.refresh(); + + store.messageFilterString = "orders"; + fetchTypedFromServiceControl.mockResolvedValueOnce([responseWithTotalCount(1), [msg("msg-9")]]); + await store.refresh(); + + expect(store.newMessageIds).toEqual([]); + }); + + test("the result after a failed query is not marked new", async () => { + const store = useAuditStore(); + fetchTypedFromServiceControl.mockResolvedValueOnce([responseWithTotalCount(1), [msg("msg-1")]]); + await store.refresh(); + fetchTypedFromServiceControl.mockRejectedValueOnce(new Error("Internal Server Error")); + await store.refresh(); + + fetchTypedFromServiceControl.mockResolvedValueOnce([responseWithTotalCount(2), [msg("msg-2"), msg("msg-1")]]); + await store.refresh(); + + expect(store.newMessageIds).toEqual([]); + }); + }); + test("a failed query flags the failure instead of throwing", async () => { fetchTypedFromServiceControl.mockRejectedValue(new Error("Internal Server Error")); const store = useAuditStore(); @@ -185,6 +234,22 @@ describe("AuditStore refresh", () => { expect(store.queryCompletedAt).toBeNull(); }); + test("clearResults resets the new-row baseline, so the next result marks nothing new", async () => { + const store = useAuditStore(); + fetchTypedFromServiceControl.mockResolvedValueOnce([responseWithTotalCount(1), [{ id: "msg-1" }]]); + await store.refresh(); + fetchTypedFromServiceControl.mockResolvedValueOnce([responseWithTotalCount(2), [{ id: "msg-2" }, { id: "msg-1" }]]); + await store.refresh(); + expect(store.newMessageIds).toEqual(["msg-2"]); + + store.clearResults(); + expect(store.newMessageIds).toEqual([]); + + fetchTypedFromServiceControl.mockResolvedValueOnce([responseWithTotalCount(3), [{ id: "msg-3" }, { id: "msg-2" }, { id: "msg-1" }]]); + await store.refresh(); + expect(store.newMessageIds).toEqual([]); + }); + test("a superseded query is not reported as a failure", async () => { const store = useAuditStore(); diff --git a/src/Frontend/src/stores/AuditStore.ts b/src/Frontend/src/stores/AuditStore.ts index 2a32a2cba..497dc3067 100644 --- a/src/Frontend/src/stores/AuditStore.ts +++ b/src/Frontend/src/stores/AuditStore.ts @@ -40,6 +40,12 @@ export const useAuditStore = defineStore("AuditStore", () => { const queryDurationMs = ref(null); const queryCompletedAt = ref(null); const searchHistory = ref(loadSearchHistory()); + // Ids of rows in the current results that were absent from the previous result of the + // same query (e.g. arrived through auto-refresh), so the view can animate their arrival. + // Empty for the first result and whenever the query itself changed: then every row is + // different and highlighting them all says nothing. + const newMessageIds = ref([]); + let previousResultsQueryKey: string | null = null; let activeQuery: AbortController | null = null; async function loadEndpoints() { @@ -88,6 +94,21 @@ export const useAuditStore = defineStore("AuditStore", () => { } totalCount.value = parseInt(response.headers.get("total-count") ?? "0"); + const queryKey = JSON.stringify({ + endpoint: selectedEndpointName.value, + from: timeRangeFrom.value, + to: timeRangeTo.value, + filter: messageFilterString.value, + pageSize: itemsPerPage.value, + sort: sortByInstances.value, + }); + if (queryKey === previousResultsQueryKey) { + const previousIds = new Set(messages.value.map((m) => m.id)); + newMessageIds.value = data.filter((m) => !previousIds.has(m.id)).map((m) => m.id); + } else { + newMessageIds.value = []; + } + previousResultsQueryKey = queryKey; messages.value = data; queryFailed.value = false; queryDurationMs.value = Math.round(performance.now() - started); @@ -102,6 +123,8 @@ export const useAuditStore = defineStore("AuditStore", () => { // and surfaces here as a failed response. Not rethrown: the callers are watchers, so a // rethrow would only become an unhandled rejection instead of user feedback. messages.value = []; + newMessageIds.value = []; + previousResultsQueryKey = null; totalCount.value = 0; queryFailed.value = true; } finally { @@ -134,6 +157,8 @@ export const useAuditStore = defineStore("AuditStore", () => { queryFailed.value = false; queryDurationMs.value = null; queryCompletedAt.value = null; + newMessageIds.value = []; + previousResultsQueryKey = null; } return { @@ -143,6 +168,7 @@ export const useAuditStore = defineStore("AuditStore", () => { loadEndpoints, sortBy: sortByInstances, messages, + newMessageIds, messageFilterString, selectedEndpointName, itemsPerPage,