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,