Skip to content

Commit 8cea24d

Browse files
committed
[UI] implement pipeline search
Added a search feature to the UI to filter pipelines by name Signed-off-by: Anand Raman <anand.raman@feldera.com> updated tests
1 parent 1a75121 commit 8cea24d

File tree

5 files changed

+210
-13
lines changed

5 files changed

+210
-13
lines changed

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

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import PipelineStatus from '$lib/components/pipelines/list/PipelineStatus.svelte'
1010
import ThSort from '$lib/components/pipelines/table/ThSort.svelte'
1111
import { dateMax } from '$lib/functions/common/date'
12+
import { matchesSubstring } from '$lib/functions/common/string'
1213
import { type NamesInUnion, unionName } from '$lib/functions/common/union'
1314
import { formatDateTime, useElapsedTime } from '$lib/functions/format'
1415
import type {
@@ -38,7 +39,7 @@
3839
selectBy: 'name'
3940
})
4041
$effect(() => {
41-
table.setRows(pipelinesWithLastChange)
42+
table.setRows(pipelinesFiltered)
4243
})
4344
$effect(() => {
4445
selectedPipelines = table.selected as string[]
@@ -67,13 +68,29 @@
6768
['Compiling', ['Queued', 'CompilingSql', 'SqlCompiled', 'CompilingRust']],
6869
['Failed', ['SystemError', 'SqlError', 'RustError']]
6970
]
71+
72+
let nameSearch = $state('')
73+
const pipelinesFiltered = $derived(
74+
pipelinesWithLastChange.filter((p) => matchesSubstring(p.name, nameSearch))
75+
)
76+
7077
const { formatElapsedTime } = useElapsedTime()
7178
const td = 'py-1 text-base border-t-[0.5px]'
7279
</script>
7380

74-
<div class="relative -mt-7 mb-6 flex flex-col items-center justify-end gap-4 md:mb-0 md:flex-row">
81+
<div class="relative mb-2 flex flex-col items-stretch gap-2 sm:flex-row sm:items-end sm:justify-end sm:gap-4 lg:-mt-7 lg:mb-0">
82+
<input
83+
data-testid="input-pipeline-search"
84+
class="input h-9 sm:w-60"
85+
type="search"
86+
placeholder="Search pipelines..."
87+
oninput={(e) => {
88+
nameSearch = e.currentTarget.value
89+
}}
90+
/>
7591
<select
76-
class="h_-9 select ml-auto w-40 md:ml-0"
92+
data-testid="select-pipeline-status"
93+
class="h_-9 select sm:w-40"
7794
onchange={(e) => {
7895
statusFilter.value = filterStatuses.find((v) => e.currentTarget.value === v[0])![0]
7996
statusFilter.set()
@@ -83,9 +100,7 @@
83100
<option value={filter[0]}>{filter[0]}</option>
84101
{/each}
85102
</select>
86-
<div class="ml-auto flex gap-4 md:ml-0">
87-
{@render preHeaderEnd?.()}
88-
</div>
103+
{@render preHeaderEnd?.()}
89104
</div>
90105
<Datatable headless {table}>
91106
<table class="p-1">
@@ -136,7 +151,7 @@
136151
</thead>
137152
<tbody>
138153
{#each table.rows as pipeline}
139-
<tr class="group"
154+
<tr class="group" data-testid="box-row-{pipeline.name}"
140155
><td class="{td} border-surface-100-900 px-2 group-hover:bg-surface-50-950">
141156
<input
142157
class="checkbox"
@@ -219,7 +234,7 @@
219234
{:else}
220235
<tr>
221236
<td class={td}></td>
222-
<td class={td} colspan={99}>No pipelines with the specified status</td>
237+
<td class={td} colspan={99}>No pipelines found</td>
223238
</tr>
224239
{/each}
225240
</tbody>

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -171,11 +171,13 @@
171171
</button>
172172
{/snippet}
173173

174-
<div class="flex h-9 flex-wrap gap-2">
175-
{#each actions as action}
176-
{@render action()}
177-
{/each}
178-
</div>
174+
{#if actions.length}
175+
<div class="flex h-9 flex-wrap gap-2">
176+
{#each actions as action}
177+
{@render action()}
178+
{/each}
179+
</div>
180+
{/if}
179181

180182
{#snippet deleteDialog()}
181183
<DeleteDialog
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { matchesSubstring } from './string'
3+
4+
describe('matchesSubstring', () => {
5+
it('returns true when search is empty', () => {
6+
expect(matchesSubstring('any-pipeline', '')).toBe(true)
7+
})
8+
9+
it('matches exact name', () => {
10+
expect(matchesSubstring('my-pipeline', 'my-pipeline')).toBe(true)
11+
})
12+
13+
it('matches partial name at the start', () => {
14+
expect(matchesSubstring('my-pipeline', 'my-')).toBe(true)
15+
})
16+
17+
it('matches partial name in the middle', () => {
18+
expect(matchesSubstring('my-pipeline', 'pipe')).toBe(true)
19+
})
20+
21+
it('matches partial name at the end', () => {
22+
expect(matchesSubstring('my-pipeline', 'line')).toBe(true)
23+
})
24+
25+
it('is case-insensitive (lowercase search, mixed case text)', () => {
26+
expect(matchesSubstring('My-Pipeline', 'my-pipeline')).toBe(true)
27+
})
28+
29+
it('is case-insensitive (uppercase search)', () => {
30+
expect(matchesSubstring('my-pipeline', 'MY-PIPE')).toBe(true)
31+
})
32+
33+
it('returns false when there is no match', () => {
34+
expect(matchesSubstring('my-pipeline', 'xyz')).toBe(false)
35+
})
36+
37+
it('returns false for partial mismatch', () => {
38+
expect(matchesSubstring('my-pipeline', 'my-pipez')).toBe(false)
39+
})
40+
})

js-packages/web-console/src/lib/functions/common/string.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,14 @@ export function humanSize(bytes: number): string {
6262
return bytes.toFixed(1) + ' ' + units[u]
6363
}
6464

65+
/**
66+
* Case-insensitive substring match. Returns true if `text` contains `search`,
67+
* or if `search` is empty.
68+
*/
69+
export function matchesSubstring(text: string, search: string): boolean {
70+
return !search || text.toLowerCase().includes(search.toLowerCase())
71+
}
72+
6573
export function nthIndexOf(
6674
str: string,
6775
substring: string,
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { expect, test } from '@playwright/test'
2+
import { client } from '$lib/services/manager/client.gen'
3+
import {
4+
deletePipeline,
5+
getExtendedPipeline,
6+
putPipeline
7+
} from '$lib/services/pipelineManager'
8+
9+
const API_ORIGIN = (process.env.PLAYWRIGHT_API_ORIGIN ?? 'http://localhost:8080').replace(/\/$/, '')
10+
client.setConfig({ baseUrl: API_ORIGIN })
11+
12+
const PREFIX = `test-search-${Date.now()}`
13+
const PIPELINES = [`${PREFIX}-alpha`, `${PREFIX}-beta`, `${PREFIX}-gamma`] as const
14+
15+
async function cleanupPipelines() {
16+
for (const name of PIPELINES) {
17+
try {
18+
await deletePipeline(name)
19+
} catch {
20+
// Pipeline may not exist
21+
}
22+
}
23+
}
24+
25+
/** Poll the API until a pipeline reaches "Stopped" status. */
26+
async function waitForStopped(name: string, timeoutMs = 120_000) {
27+
const start = Date.now()
28+
while (Date.now() - start < timeoutMs) {
29+
const p = await getExtendedPipeline(name)
30+
if (p.status === 'Stopped') return
31+
await new Promise((r) => setTimeout(r, 1000))
32+
}
33+
throw new Error(`Timed out waiting for ${name} to reach Stopped status`)
34+
}
35+
36+
/** Navigate to the home page and wait until all test pipelines are visible. */
37+
async function gotoAndWaitForPipelines(page: import('@playwright/test').Page) {
38+
await page.goto('/')
39+
for (const name of PIPELINES) {
40+
await expect(page.getByTestId(`box-row-${name}`)).toBeVisible()
41+
}
42+
}
43+
44+
test.describe('Pipeline search', () => {
45+
test.setTimeout(180_000)
46+
47+
test.beforeAll(async ({}, testInfo) => {
48+
testInfo.setTimeout(120_000)
49+
await cleanupPipelines()
50+
for (const name of PIPELINES) {
51+
await putPipeline(name, {
52+
name,
53+
description: `E2E search test pipeline: ${name}`,
54+
program_code: 'create view test as (select 1)'
55+
})
56+
}
57+
// Wait for all pipelines to finish compiling via API polling,
58+
// so individual tests don't need to wait on DOM status changes.
59+
for (const name of PIPELINES) {
60+
await waitForStopped(name)
61+
}
62+
})
63+
64+
test.afterAll(async ({}, testInfo) => {
65+
testInfo.setTimeout(60_000)
66+
await cleanupPipelines()
67+
})
68+
69+
test('filters pipelines by name substring', async ({ page }) => {
70+
await gotoAndWaitForPipelines(page)
71+
72+
const searchInput = page.getByTestId('input-pipeline-search')
73+
await searchInput.fill('alpha')
74+
75+
await expect(page.getByTestId(`box-row-${PREFIX}-alpha`)).toBeVisible()
76+
await expect(page.getByTestId(`box-row-${PREFIX}-beta`)).not.toBeVisible()
77+
await expect(page.getByTestId(`box-row-${PREFIX}-gamma`)).not.toBeVisible()
78+
})
79+
80+
test('search is case-insensitive', async ({ page }) => {
81+
await gotoAndWaitForPipelines(page)
82+
83+
const searchInput = page.getByTestId('input-pipeline-search')
84+
await searchInput.fill(PREFIX.toUpperCase())
85+
86+
// All three pipelines share the prefix, so all should be visible
87+
for (const name of PIPELINES) {
88+
await expect(page.getByTestId(`box-row-${name}`)).toBeVisible()
89+
}
90+
})
91+
92+
test('shows empty state when no pipelines match', async ({ page }) => {
93+
await gotoAndWaitForPipelines(page)
94+
95+
const searchInput = page.getByTestId('input-pipeline-search')
96+
await searchInput.fill('nonexistent-pipeline-xyz-999')
97+
98+
await expect(page.getByText('No pipelines found')).toBeVisible()
99+
})
100+
101+
test('clearing search shows all pipelines again', async ({ page }) => {
102+
await gotoAndWaitForPipelines(page)
103+
104+
const searchInput = page.getByTestId('input-pipeline-search')
105+
await searchInput.fill('alpha')
106+
await expect(page.getByTestId(`box-row-${PREFIX}-beta`)).not.toBeVisible()
107+
108+
await searchInput.clear()
109+
110+
for (const name of PIPELINES) {
111+
await expect(page.getByTestId(`box-row-${name}`)).toBeVisible()
112+
}
113+
})
114+
115+
test('search works together with status filter', async ({ page }) => {
116+
await gotoAndWaitForPipelines(page)
117+
118+
// All test pipelines should be in "Stopped" (Ready To Start) status
119+
const statusSelect = page.getByTestId('select-pipeline-status')
120+
await statusSelect.selectOption('Ready To Start')
121+
122+
const searchInput = page.getByTestId('input-pipeline-search')
123+
await searchInput.fill('beta')
124+
125+
await expect(page.getByTestId(`box-row-${PREFIX}-beta`)).toBeVisible()
126+
await expect(page.getByTestId(`box-row-${PREFIX}-alpha`)).not.toBeVisible()
127+
128+
// Switching to a non-matching status should hide everything
129+
await statusSelect.selectOption('Running')
130+
await expect(page.getByTestId(`box-row-${PREFIX}-beta`)).not.toBeVisible()
131+
})
132+
})

0 commit comments

Comments
 (0)