Skip to content

Commit b25062b

Browse files
committed
[web-console] Sort pipelines by name on the home page by default, persist order
Signed-off-by: Karakatiza666 <bulakh.96@gmail.com>
1 parent c16f2ab commit b25062b

4 files changed

Lines changed: 201 additions & 8 deletions

File tree

js-packages/web-console/src/lib/components/pipelines/Table.svelte

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import PipelineStatus from '$lib/components/pipelines/list/PipelineStatus.svelte'
77
import ThSort from '$lib/components/pipelines/table/ThSort.svelte'
88
import { useElapsedTime } from '$lib/compositions/common/useElapsedTime'
9+
import { useLayoutSettings } from '$lib/compositions/layout/useLayoutSettings.svelte'
910
import { dateMax } from '$lib/functions/common/date'
1011
import { matchesSubstring } from '$lib/functions/common/string'
1112
import { type NamesInUnion, unionName } from '$lib/functions/common/union'
@@ -84,6 +85,15 @@
8485
8586
const { formatElapsedTime } = useElapsedTime()
8687
const td = 'py-1 text-base border-t-[0.5px]'
88+
89+
// Persist the active sort and restore it on the matching column. Each ThSort
90+
// owns the upstream `direction`/`onSort` API; the column identity lives here.
91+
const { pipelinesTableSort } = useLayoutSettings()
92+
const sortColumn = (column: string) => ({
93+
direction:
94+
pipelinesTableSort.value.column === column ? pipelinesTableSort.value.direction : undefined,
95+
onSort: (direction: 'asc' | 'desc') => (pipelinesTableSort.value = { column, direction })
96+
})
8797
</script>
8898

8999
<div class="pipeline-table-wrapper bg-white-dark w-fit min-w-full">
@@ -134,13 +144,13 @@
134144
onclick={() => table.selectAll()}
135145
/></th
136146
>
137-
<ThSort class="px-1 py-1" {table} field="name"
147+
<ThSort class="px-1 py-1" {table} field="name" {...sortColumn('name')}
138148
><span class="text-base font-normal text-surface-950-50">Pipeline name</span></ThSort
139149
>
140150
<th class="px-1 py-1 text-left"
141151
><span class="text-base font-normal text-surface-950-50">Storage</span></th
142152
>
143-
<ThSort {table} class="px-1 py-1" field="status"
153+
<ThSort {table} class="px-1 py-1" field="status" {...sortColumn('status')}
144154
><span class="ml-8 text-base font-normal text-surface-950-50">Status</span></ThSort
145155
>
146156
<th class="px-1 py-1 text-left"
@@ -150,21 +160,35 @@
150160
{table}
151161
class="w-20 py-1 pr-4 text-right xl:w-32"
152162
field={(p) => p.connectors?.numErrors}
163+
{...sortColumn('numErrors')}
153164
>
154165
<span class="text-base font-normal text-surface-950-50">
155166
<span class="inline xl:hidden">Errors</span>
156167
<span class="hidden xl:!inline">Runtime errors</span>
157168
</span>
158169
</ThSort>
159-
<ThSort {table} class="w-20 px-1 py-1 xl:w-32" field="platformVersion">
170+
<ThSort
171+
{table}
172+
class="w-20 px-1 py-1 xl:w-32"
173+
field="platformVersion"
174+
{...sortColumn('platformVersion')}
175+
>
160176
<span class="text-base font-normal text-surface-950-50">
161177
Runtime <span class="hidden xl:!inline">version</span>
162178
</span>
163179
</ThSort>
164-
<ThSort {table} class="px-1 py-1" field="lastStatusSince"
180+
<ThSort
181+
{table}
182+
class="px-1 py-1"
183+
field="lastStatusSince"
184+
{...sortColumn('lastStatusSince')}
165185
><span class="text-base font-normal text-surface-950-50">Status changed</span></ThSort
166186
>
167-
<ThSort {table} class="px-1 py-1" field="deploymentResourcesStatusSince"
187+
<ThSort
188+
{table}
189+
class="px-1 py-1"
190+
field="deploymentResourcesStatusSince"
191+
{...sortColumn('deploymentResourcesStatusSince')}
168192
><span class="text-base font-normal text-surface-950-50">Deployed on</span></ThSort
169193
>
170194
</tr>
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/**
2+
* Mouse interaction tests for column sorting in the pipelines `Table`.
3+
*
4+
* The real pipelines `Table` is mounted with a handful of pipeline thumbs whose name order
5+
* and "status changed" order deliberately disagree, so every assertion about row
6+
* order can only pass if the click actually re-sorted the rows. The default sort
7+
* (name ascending) and its persistence both live in `useLayoutSettings`, so the
8+
* test also checks the localStorage key the component writes through.
9+
*/
10+
11+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
12+
import { page } from 'vitest/browser'
13+
import { render } from 'vitest-browser-svelte'
14+
import type { PipelineThumb } from '$lib/services/pipelineManager'
15+
import { useLayoutSettings } from '$lib/compositions/layout/useLayoutSettings.svelte'
16+
17+
const SORT_KEY = 'layout/pipelines/table/sort'
18+
19+
// `vi.mock` factories are hoisted above the imports and run before ordinary
20+
// module-scope consts initialize, so a factory can't read them. `vi.hoisted` is
21+
// hoisted too, so its result is available to both the factory and the fixtures.
22+
const { PLATFORM_VERSION } = vi.hoisted(() => ({ PLATFORM_VERSION: '1.0.0' }))
23+
24+
// Table and its PipelineVersion child both read `page.data.feldera`. A `version`
25+
// equal to every thumb's platformVersion keeps the runtime column in its
26+
// "latest" state, so no update Popover is rendered.
27+
vi.mock('$app/state', () => ({
28+
page: { data: { feldera: { version: PLATFORM_VERSION, unstableFeatures: [] } } }
29+
}))
30+
31+
import Table from './Table.svelte'
32+
33+
// `lastStatusSince` is derived from these timestamps. The date order
34+
// (bravo < delta < charlie < alpha) is a rotation of the name order
35+
// (alpha < bravo < charlie < delta), so sorting by either column yields a
36+
// distinct, unambiguous row sequence.
37+
const lastChange: Record<string, string> = {
38+
alpha: '2024-01-04T00:00:00Z',
39+
bravo: '2024-01-01T00:00:00Z',
40+
charlie: '2024-01-03T00:00:00Z',
41+
delta: '2024-01-02T00:00:00Z'
42+
}
43+
44+
// Only the fields the Table template reads are populated; the rest of
45+
// PipelineThumb is irrelevant to sorting, hence the cast.
46+
const thumb = (name: string): PipelineThumb =>
47+
({
48+
name,
49+
description: '',
50+
status: 'Stopped',
51+
storageStatus: 'Cleared',
52+
deploymentStatusSince: lastChange[name],
53+
programStatusSince: lastChange[name],
54+
deploymentError: undefined,
55+
platformVersion: PLATFORM_VERSION,
56+
programConfig: { runtime_version: null },
57+
deploymentResourcesStatus: 'Stopped',
58+
deploymentResourcesStatusSince: new Date(lastChange[name]),
59+
connectors: { numErrors: 0 }
60+
}) as unknown as PipelineThumb
61+
62+
// Fed deliberately unsorted so a passing default-order assertion proves the
63+
// table sorted them rather than echoing the input order.
64+
const pipelines = [thumb('charlie'), thumb('alpha'), thumb('delta'), thumb('bravo')]
65+
66+
// Each rendered row carries data-testid="box-row-<name>"; reading them back in
67+
// DOM order gives the visible row sequence.
68+
const rowOrder = () =>
69+
Array.from(document.querySelectorAll('tbody tr[data-testid^="box-row-"]')).map((tr) =>
70+
tr.getAttribute('data-testid')!.slice('box-row-'.length)
71+
)
72+
73+
// The sortable headers render their label inside the clickable <th>; the <th>
74+
// gains the `active` class while it is the column the table is sorted by.
75+
const header = (label: string) => page.getByText(label, { exact: true })
76+
const headerCell = (label: string) => header(label).element().closest('th')!
77+
78+
const persistedSort = () => JSON.parse(localStorage.getItem(SORT_KEY)!)
79+
80+
const mountTable = () => render(Table, { props: { pipelines, selectedPipelines: [] } } as any)
81+
82+
describe('Table — column sorting', () => {
83+
beforeEach(() => {
84+
// The persisted sort is a process-wide singleton (useLocalStorage caches by
85+
// key), so reset both the backing store and the cached value before each test
86+
// to keep them independent.
87+
localStorage.clear()
88+
useLayoutSettings().pipelinesTableSort.value = { column: 'name', direction: 'asc' }
89+
})
90+
91+
afterEach(() => {
92+
localStorage.clear()
93+
})
94+
95+
it('sorts by pipeline name ascending by default', async () => {
96+
mountTable()
97+
98+
await expect.poll(rowOrder).toEqual(['alpha', 'bravo', 'charlie', 'delta'])
99+
expect(headerCell('Pipeline name').classList.contains('active')).toBe(true)
100+
})
101+
102+
it('clicking the name header toggles ascending → descending → ascending', async () => {
103+
mountTable()
104+
await expect.poll(rowOrder).toEqual(['alpha', 'bravo', 'charlie', 'delta'])
105+
106+
await header('Pipeline name').click()
107+
await expect.poll(rowOrder).toEqual(['delta', 'charlie', 'bravo', 'alpha'])
108+
109+
await header('Pipeline name').click()
110+
await expect.poll(rowOrder).toEqual(['alpha', 'bravo', 'charlie', 'delta'])
111+
})
112+
113+
it('clicking a different header sorts by that column and moves the active marker', async () => {
114+
mountTable()
115+
await expect.poll(rowOrder).toEqual(['alpha', 'bravo', 'charlie', 'delta'])
116+
117+
// "Status changed" sorts by lastStatusSince, ascending on first click.
118+
await header('Status changed').click()
119+
await expect.poll(rowOrder).toEqual(['bravo', 'delta', 'charlie', 'alpha'])
120+
121+
expect(headerCell('Status changed').classList.contains('active')).toBe(true)
122+
expect(headerCell('Pipeline name').classList.contains('active')).toBe(false)
123+
})
124+
125+
it('persists the active sort to localStorage', async () => {
126+
mountTable()
127+
await expect.poll(rowOrder).toEqual(['alpha', 'bravo', 'charlie', 'delta'])
128+
129+
await header('Pipeline name').click()
130+
await expect.poll(persistedSort).toEqual({ column: 'name', direction: 'desc' })
131+
132+
await header('Status changed').click()
133+
await expect.poll(persistedSort).toEqual({ column: 'lastStatusSince', direction: 'asc' })
134+
})
135+
136+
it('restores a persisted non-default sort on mount', async () => {
137+
// Simulate a returning user whose last sort was name descending.
138+
useLayoutSettings().pipelinesTableSort.value = { column: 'name', direction: 'desc' }
139+
140+
mountTable()
141+
142+
await expect.poll(rowOrder).toEqual(['delta', 'charlie', 'bravo', 'alpha'])
143+
expect(headerCell('Pipeline name').classList.contains('active')).toBe(true)
144+
})
145+
})

js-packages/web-console/src/lib/components/pipelines/table/ThSort.svelte

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,37 @@
55
const {
66
table,
77
field,
8+
direction,
9+
onSort,
810
children,
911
class: _class
1012
}: {
1113
table: TableHandlerInterface<T>
1214
field: Field<T>
15+
/** Initial sort direction, applied once when the column mounts. */
16+
direction?: 'asc' | 'desc'
17+
/** Notifies the parent when the user changes this column's sort, e.g. to persist it. */
18+
onSort?: (direction: 'asc' | 'desc') => void
1319
children: Snippet
1420
class?: string
1521
} = $props()
16-
const sort = table.createSort(field)
22+
23+
// Mirror @vincjo/datatables' own ThSort: create the sort and apply the initial
24+
// direction inline (`init` is a no-op when `direction` is undefined). `table`,
25+
// `field`, and `direction` are static over this component's lifetime, and the
26+
// builder must be created once: its identity drives the active-column indicator.
27+
// svelte-ignore state_referenced_locally
28+
const sort = table.createSort(field).init(direction)
29+
30+
const setSort = () => {
31+
sort.set()
32+
if (sort.direction === 'asc' || sort.direction === 'desc') {
33+
onSort?.(sort.direction)
34+
}
35+
}
1736
</script>
1837

19-
<th onclick={() => sort.set()} class={_class} class:active={sort.isActive}>
38+
<th onclick={setSort} class={_class} class:active={sort.isActive}>
2039
<div class="flex">
2140
{@render children()}
2241
<span class:asc={sort.direction === 'asc'} class:desc={sort.direction === 'desc'}> </span>

js-packages/web-console/src/lib/compositions/layout/useLayoutSettings.svelte.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,18 @@ export const useLayoutSettings = () => {
1212
const hideWarnings = useLocalStorage('layout/pipelines/logs/hideWarnings', false)
1313
const verbatimErrors = useLocalStorage('layout/pipelines/logs/verbatimErrors', false)
1414
const sqlPanelFullHeight = useLocalStorage('layout/profile-bundle/sqlPanelFullHeight', false)
15+
const pipelinesTableSort = useLocalStorage<{ column: string; direction: 'asc' | 'desc' }>(
16+
'layout/pipelines/table/sort',
17+
{ column: 'name', direction: 'asc' }
18+
)
1519

1620
return {
1721
showPipelinesPanel,
1822
showMonitoringPanel,
1923
showInteractionPanel,
2024
hideWarnings,
2125
verbatimErrors,
22-
sqlPanelFullHeight
26+
sqlPanelFullHeight,
27+
pipelinesTableSort
2328
}
2429
}

0 commit comments

Comments
 (0)