Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 29 additions & 5 deletions js-packages/web-console/src/lib/components/pipelines/Table.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import PipelineStatus from '$lib/components/pipelines/list/PipelineStatus.svelte'
import ThSort from '$lib/components/pipelines/table/ThSort.svelte'
import { useElapsedTime } from '$lib/compositions/common/useElapsedTime'
import { useLayoutSettings } from '$lib/compositions/layout/useLayoutSettings.svelte'
import { dateMax } from '$lib/functions/common/date'
import { matchesSubstring } from '$lib/functions/common/string'
import { type NamesInUnion, unionName } from '$lib/functions/common/union'
Expand Down Expand Up @@ -84,6 +85,15 @@

const { formatElapsedTime } = useElapsedTime()
const td = 'py-1 text-base border-t-[0.5px]'

// Persist the active sort and restore it on the matching column. Each ThSort
// owns the upstream `direction`/`onSort` API; the column identity lives here.
const { pipelinesTableSort } = useLayoutSettings()
const sortColumn = (column: string) => ({
direction:
pipelinesTableSort.value.column === column ? pipelinesTableSort.value.direction : undefined,
onSort: (direction: 'asc' | 'desc') => (pipelinesTableSort.value = { column, direction })
})
</script>

<div class="pipeline-table-wrapper bg-white-dark w-fit min-w-full">
Expand Down Expand Up @@ -134,13 +144,13 @@
onclick={() => table.selectAll()}
/></th
>
<ThSort class="px-1 py-1" {table} field="name"
<ThSort class="px-1 py-1" {table} field="name" {...sortColumn('name')}
><span class="text-base font-normal text-surface-950-50">Pipeline name</span></ThSort
>
<th class="px-1 py-1 text-left"
><span class="text-base font-normal text-surface-950-50">Storage</span></th
>
<ThSort {table} class="px-1 py-1" field="status"
<ThSort {table} class="px-1 py-1" field="status" {...sortColumn('status')}
><span class="ml-8 text-base font-normal text-surface-950-50">Status</span></ThSort
>
<th class="px-1 py-1 text-left"
Expand All @@ -150,21 +160,35 @@
{table}
class="w-20 py-1 pr-4 text-right xl:w-32"
field={(p) => p.connectors?.numErrors}
{...sortColumn('numErrors')}
>
<span class="text-base font-normal text-surface-950-50">
<span class="inline xl:hidden">Errors</span>
<span class="hidden xl:!inline">Runtime errors</span>
</span>
</ThSort>
<ThSort {table} class="w-20 px-1 py-1 xl:w-32" field="platformVersion">
<ThSort
{table}
class="w-20 px-1 py-1 xl:w-32"
field="platformVersion"
{...sortColumn('platformVersion')}
>
<span class="text-base font-normal text-surface-950-50">
Runtime <span class="hidden xl:!inline">version</span>
</span>
</ThSort>
<ThSort {table} class="px-1 py-1" field="lastStatusSince"
<ThSort
{table}
class="px-1 py-1"
field="lastStatusSince"
{...sortColumn('lastStatusSince')}
><span class="text-base font-normal text-surface-950-50">Status changed</span></ThSort
>
<ThSort {table} class="px-1 py-1" field="deploymentResourcesStatusSince"
<ThSort
{table}
class="px-1 py-1"
field="deploymentResourcesStatusSince"
{...sortColumn('deploymentResourcesStatusSince')}
><span class="text-base font-normal text-surface-950-50">Deployed on</span></ThSort
>
</tr>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/**
* Mouse interaction tests for column sorting in the pipelines `Table`.
*
* The real pipelines `Table` is mounted with a handful of pipeline thumbs whose name order
* and "status changed" order deliberately disagree, so every assertion about row
* order can only pass if the click actually re-sorted the rows. The default sort
* (name ascending) and its persistence both live in `useLayoutSettings`, so the
* test also checks the localStorage key the component writes through.
*/

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { page } from 'vitest/browser'
import { render } from 'vitest-browser-svelte'
import type { PipelineThumb } from '$lib/services/pipelineManager'
import { useLayoutSettings } from '$lib/compositions/layout/useLayoutSettings.svelte'

const SORT_KEY = 'layout/pipelines/table/sort'

// `vi.mock` factories are hoisted above the imports and run before ordinary
// module-scope consts initialize, so a factory can't read them. `vi.hoisted` is
// hoisted too, so its result is available to both the factory and the fixtures.
const { PLATFORM_VERSION } = vi.hoisted(() => ({ PLATFORM_VERSION: '1.0.0' }))

// Table and its PipelineVersion child both read `page.data.feldera`. A `version`
// equal to every thumb's platformVersion keeps the runtime column in its
// "latest" state, so no update Popover is rendered.
vi.mock('$app/state', () => ({
page: { data: { feldera: { version: PLATFORM_VERSION, unstableFeatures: [] } } }
}))

import Table from './Table.svelte'

// `lastStatusSince` is derived from these timestamps. The date order
// (bravo < delta < charlie < alpha) is a rotation of the name order
// (alpha < bravo < charlie < delta), so sorting by either column yields a
// distinct, unambiguous row sequence.
const lastChange: Record<string, string> = {
alpha: '2024-01-04T00:00:00Z',
bravo: '2024-01-01T00:00:00Z',
charlie: '2024-01-03T00:00:00Z',
delta: '2024-01-02T00:00:00Z'
}

// Only the fields the Table template reads are populated; the rest of
// PipelineThumb is irrelevant to sorting, hence the cast.
const thumb = (name: string): PipelineThumb =>
({
name,
description: '',
status: 'Stopped',
storageStatus: 'Cleared',
deploymentStatusSince: lastChange[name],
programStatusSince: lastChange[name],
deploymentError: undefined,
platformVersion: PLATFORM_VERSION,
programConfig: { runtime_version: null },
deploymentResourcesStatus: 'Stopped',
deploymentResourcesStatusSince: new Date(lastChange[name]),
connectors: { numErrors: 0 }
}) as unknown as PipelineThumb

// Fed deliberately unsorted so a passing default-order assertion proves the
// table sorted them rather than echoing the input order.
const pipelines = [thumb('charlie'), thumb('alpha'), thumb('delta'), thumb('bravo')]

// Each rendered row carries data-testid="box-row-<name>"; reading them back in
// DOM order gives the visible row sequence.
const rowOrder = () =>
Array.from(document.querySelectorAll('tbody tr[data-testid^="box-row-"]')).map((tr) =>
tr.getAttribute('data-testid')!.slice('box-row-'.length)
)

// The sortable headers render their label inside the clickable <th>; the <th>
// gains the `active` class while it is the column the table is sorted by.
const header = (label: string) => page.getByText(label, { exact: true })
const headerCell = (label: string) => header(label).element().closest('th')!

const persistedSort = () => JSON.parse(localStorage.getItem(SORT_KEY)!)

const mountTable = () => render(Table, { props: { pipelines, selectedPipelines: [] } } as any)

describe('Table — column sorting', () => {
beforeEach(() => {
// The persisted sort is a process-wide singleton (useLocalStorage caches by
// key), so reset both the backing store and the cached value before each test
// to keep them independent.
localStorage.clear()
useLayoutSettings().pipelinesTableSort.value = { column: 'name', direction: 'asc' }
})

afterEach(async () => {
// @vincjo/datatables' setRows() defers a scroll-position restore via
// setTimeout(..., 2) that dereferences table.element. On unmount Svelte nulls
// that binding, so a timer still in flight throws "Cannot set properties of
// null (setting 'scrollTop')". vitest-browser-svelte unmounts after afterEach
// runs, so waiting out the 2 ms window here lets the timer fire while the
// component — and its element — is still alive.
await new Promise((resolve) => setTimeout(resolve, 10))
localStorage.clear()
})

it('sorts by pipeline name ascending by default', async () => {
mountTable()

await expect.poll(rowOrder).toEqual(['alpha', 'bravo', 'charlie', 'delta'])
expect(headerCell('Pipeline name').classList.contains('active')).toBe(true)
})

it('clicking the name header toggles ascending → descending → ascending', async () => {
mountTable()
await expect.poll(rowOrder).toEqual(['alpha', 'bravo', 'charlie', 'delta'])

await header('Pipeline name').click()
await expect.poll(rowOrder).toEqual(['delta', 'charlie', 'bravo', 'alpha'])

await header('Pipeline name').click()
await expect.poll(rowOrder).toEqual(['alpha', 'bravo', 'charlie', 'delta'])
})

it('clicking a different header sorts by that column and moves the active marker', async () => {
mountTable()
await expect.poll(rowOrder).toEqual(['alpha', 'bravo', 'charlie', 'delta'])

// "Status changed" sorts by lastStatusSince, ascending on first click.
await header('Status changed').click()
await expect.poll(rowOrder).toEqual(['bravo', 'delta', 'charlie', 'alpha'])

expect(headerCell('Status changed').classList.contains('active')).toBe(true)
expect(headerCell('Pipeline name').classList.contains('active')).toBe(false)
})

it('persists the active sort to localStorage', async () => {
mountTable()
await expect.poll(rowOrder).toEqual(['alpha', 'bravo', 'charlie', 'delta'])

await header('Pipeline name').click()
await expect.poll(persistedSort).toEqual({ column: 'name', direction: 'desc' })

await header('Status changed').click()
await expect.poll(persistedSort).toEqual({ column: 'lastStatusSince', direction: 'asc' })
})

it('restores a persisted non-default sort on mount', async () => {
// Simulate a returning user whose last sort was name descending.
useLayoutSettings().pipelinesTableSort.value = { column: 'name', direction: 'desc' }

mountTable()

await expect.poll(rowOrder).toEqual(['delta', 'charlie', 'bravo', 'alpha'])
expect(headerCell('Pipeline name').classList.contains('active')).toBe(true)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,34 @@
const {
table,
field,
direction,
onSort,
children,
class: _class
}: {
table: TableHandlerInterface<T>
field: Field<T>
/** Initial sort direction, applied once when the column mounts. */
direction?: 'asc' | 'desc'
/** Notifies the parent when the user changes this column's sort, e.g. to persist it. */
onSort?: (direction: 'asc' | 'desc') => void
children: Snippet
class?: string
} = $props()
const sort = table.createSort(field)

// Create the sort once and apply its initial direction, mirroring @vincjo/datatables' ThSort.
// svelte-ignore state_referenced_locally
const sort = table.createSort(field).init(direction)

const setSort = () => {
sort.set()
if (sort.direction === 'asc' || sort.direction === 'desc') {
onSort?.(sort.direction)
}
}
</script>

<th onclick={() => sort.set()} class={_class} class:active={sort.isActive}>
<th onclick={setSort} class={_class} class:active={sort.isActive}>
<div class="flex">
{@render children()}
<span class:asc={sort.direction === 'asc'} class:desc={sort.direction === 'desc'}> </span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,18 @@ export const useLayoutSettings = () => {
const hideWarnings = useLocalStorage('layout/pipelines/logs/hideWarnings', false)
const verbatimErrors = useLocalStorage('layout/pipelines/logs/verbatimErrors', false)
const sqlPanelFullHeight = useLocalStorage('layout/profile-bundle/sqlPanelFullHeight', false)
const pipelinesTableSort = useLocalStorage<{ column: string; direction: 'asc' | 'desc' }>(
'layout/pipelines/table/sort',
{ column: 'name', direction: 'asc' }
)

return {
showPipelinesPanel,
showMonitoringPanel,
showInteractionPanel,
hideWarnings,
verbatimErrors,
sqlPanelFullHeight
sqlPanelFullHeight,
pipelinesTableSort
}
}
Loading