Skip to content

Commit 2f34ab5

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>
1 parent 1a75121 commit 2f34ab5

File tree

4 files changed

+182
-2
lines changed

4 files changed

+182
-2
lines changed

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

Lines changed: 19 additions & 2 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,12 +68,28 @@
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

7481
<div class="relative -mt-7 mb-6 flex flex-col items-center justify-end gap-4 md:mb-0 md:flex-row">
82+
<input
83+
data-testid="input-pipeline-search"
84+
class="input h-9 w-60 md:ml-0"
85+
type="search"
86+
placeholder="Search pipelines..."
87+
oninput={(e) => {
88+
nameSearch = e.currentTarget.value
89+
}}
90+
/>
7591
<select
92+
data-testid="select-pipeline-status"
7693
class="h_-9 select ml-auto w-40 md:ml-0"
7794
onchange={(e) => {
7895
statusFilter.value = filterStatuses.find((v) => e.currentTarget.value === v[0])![0]
@@ -219,7 +236,7 @@
219236
{:else}
220237
<tr>
221238
<td class={td}></td>
222-
<td class={td} colspan={99}>No pipelines with the specified status</td>
239+
<td class={td} colspan={99}>No pipelines found</td>
223240
</tr>
224241
{/each}
225242
</tbody>
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: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { expect, test } from '@playwright/test'
2+
import { client } from '$lib/services/manager/client.gen'
3+
import { deletePipeline, putPipeline } from '$lib/services/pipelineManager'
4+
5+
const API_ORIGIN = (process.env.PLAYWRIGHT_API_ORIGIN ?? 'http://localhost:8080').replace(/\/$/, '')
6+
client.setConfig({ baseUrl: API_ORIGIN })
7+
8+
const PREFIX = `test-search-${Date.now()}`
9+
const PIPELINES = [`${PREFIX}-alpha`, `${PREFIX}-beta`, `${PREFIX}-gamma`] as const
10+
11+
async function cleanupPipelines() {
12+
for (const name of PIPELINES) {
13+
try {
14+
await deletePipeline(name)
15+
} catch {
16+
// Pipeline may not exist
17+
}
18+
}
19+
}
20+
21+
/** Navigate to the home page and wait until all test pipelines have compiled and are ready to start. */
22+
async function gotoAndWaitForPipelines(page: import('@playwright/test').Page) {
23+
await page.goto('/')
24+
// Wait for every test pipeline to reach "Ready To Start" (Stopped) status.
25+
// Cannot use networkidle because the pipeline list polls every 2 s.
26+
for (const name of PIPELINES) {
27+
const row = page.getByRole('row').filter({ has: page.getByRole('link', { name }) })
28+
await expect(row.getByText('Ready To Start')).toBeVisible({ timeout: 120_000 })
29+
}
30+
}
31+
32+
test.describe('Pipeline search', () => {
33+
test.setTimeout(180_000)
34+
35+
test.beforeAll(async ({}, testInfo) => {
36+
testInfo.setTimeout(120_000)
37+
await cleanupPipelines()
38+
for (const name of PIPELINES) {
39+
await putPipeline(name, {
40+
name,
41+
description: `E2E search test pipeline: ${name}`,
42+
program_code: 'create view test as (select 1)'
43+
})
44+
}
45+
})
46+
47+
test.afterAll(async ({}, testInfo) => {
48+
testInfo.setTimeout(60_000)
49+
await cleanupPipelines()
50+
})
51+
52+
test('filters pipelines by name substring', async ({ page }) => {
53+
await gotoAndWaitForPipelines(page)
54+
55+
const searchInput = page.getByTestId('input-pipeline-search')
56+
await searchInput.fill('alpha')
57+
58+
await expect(page.getByRole('link', { name: `${PREFIX}-alpha` })).toBeVisible()
59+
await expect(page.getByRole('link', { name: `${PREFIX}-beta` })).not.toBeVisible()
60+
await expect(page.getByRole('link', { name: `${PREFIX}-gamma` })).not.toBeVisible()
61+
})
62+
63+
test('search is case-insensitive', async ({ page }) => {
64+
await gotoAndWaitForPipelines(page)
65+
66+
const searchInput = page.getByTestId('input-pipeline-search')
67+
await searchInput.fill(PREFIX.toUpperCase())
68+
69+
// All three pipelines share the prefix, so all should be visible
70+
for (const name of PIPELINES) {
71+
await expect(page.getByRole('link', { name })).toBeVisible()
72+
}
73+
})
74+
75+
test('shows empty state when no pipelines match', async ({ page }) => {
76+
await gotoAndWaitForPipelines(page)
77+
78+
const searchInput = page.getByTestId('input-pipeline-search')
79+
await searchInput.fill('nonexistent-pipeline-xyz-999')
80+
81+
await expect(page.getByText('No pipelines found')).toBeVisible()
82+
})
83+
84+
test('clearing search shows all pipelines again', async ({ page }) => {
85+
await gotoAndWaitForPipelines(page)
86+
87+
const searchInput = page.getByTestId('input-pipeline-search')
88+
await searchInput.fill('alpha')
89+
await expect(page.getByRole('link', { name: `${PREFIX}-beta` })).not.toBeVisible()
90+
91+
await searchInput.clear()
92+
93+
for (const name of PIPELINES) {
94+
await expect(page.getByRole('link', { name })).toBeVisible()
95+
}
96+
})
97+
98+
test('search works together with status filter', async ({ page }) => {
99+
await gotoAndWaitForPipelines(page)
100+
101+
// All test pipelines should be in "Stopped" (Ready To Start) status
102+
const statusSelect = page.getByTestId('select-pipeline-status')
103+
await statusSelect.selectOption('Ready To Start')
104+
105+
const searchInput = page.getByTestId('input-pipeline-search')
106+
await searchInput.fill('beta')
107+
108+
await expect(page.getByRole('link', { name: `${PREFIX}-beta` })).toBeVisible()
109+
await expect(page.getByRole('link', { name: `${PREFIX}-alpha` })).not.toBeVisible()
110+
111+
// Switching to a non-matching status should hide everything
112+
await statusSelect.selectOption('Running')
113+
await expect(page.getByRole('link', { name: `${PREFIX}-beta` })).not.toBeVisible()
114+
})
115+
})

0 commit comments

Comments
 (0)