Skip to content
Merged
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
[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
  • Loading branch information
anandbraman authored and Karakatiza666 committed Apr 6, 2026
commit 3121825e58615835fbcfa357990c191397f088c7
31 changes: 23 additions & 8 deletions js-packages/web-console/src/lib/components/pipelines/Table.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import PipelineStatus from '$lib/components/pipelines/list/PipelineStatus.svelte'
import ThSort from '$lib/components/pipelines/table/ThSort.svelte'
import { dateMax } from '$lib/functions/common/date'
import { matchesSubstring } from '$lib/functions/common/string'
import { type NamesInUnion, unionName } from '$lib/functions/common/union'
import { formatDateTime, useElapsedTime } from '$lib/functions/format'
import type {
Expand Down Expand Up @@ -38,7 +39,7 @@
selectBy: 'name'
})
$effect(() => {
table.setRows(pipelinesWithLastChange)
table.setRows(pipelinesFiltered)
})
$effect(() => {
selectedPipelines = table.selected as string[]
Expand Down Expand Up @@ -67,13 +68,29 @@
['Compiling', ['Queued', 'CompilingSql', 'SqlCompiled', 'CompilingRust']],
['Failed', ['SystemError', 'SqlError', 'RustError']]
]

let nameSearch = $state('')
const pipelinesFiltered = $derived(
pipelinesWithLastChange.filter((p) => matchesSubstring(p.name, nameSearch))
)

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

<div class="relative -mt-7 mb-6 flex flex-col items-center justify-end gap-4 md:mb-0 md:flex-row">
<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">
<input
data-testid="input-pipeline-search"
class="input h-9 sm:w-60"
type="search"
placeholder="Search pipelines..."
oninput={(e) => {
nameSearch = e.currentTarget.value
}}
/>
<select
class="h_-9 select ml-auto w-40 md:ml-0"
data-testid="select-pipeline-status"
class="h_-9 select sm:w-40"
onchange={(e) => {
statusFilter.value = filterStatuses.find((v) => e.currentTarget.value === v[0])![0]
statusFilter.set()
Expand All @@ -83,9 +100,7 @@
<option value={filter[0]}>{filter[0]}</option>
{/each}
</select>
<div class="ml-auto flex gap-4 md:ml-0">
{@render preHeaderEnd?.()}
</div>
{@render preHeaderEnd?.()}
</div>
<Datatable headless {table}>
<table class="p-1">
Expand Down Expand Up @@ -136,7 +151,7 @@
</thead>
<tbody>
{#each table.rows as pipeline}
<tr class="group"
<tr class="group" data-testid="box-row-{pipeline.name}"
><td class="{td} border-surface-100-900 px-2 group-hover:bg-surface-50-950">
<input
class="checkbox"
Expand Down Expand Up @@ -219,7 +234,7 @@
{:else}
<tr>
<td class={td}></td>
<td class={td} colspan={99}>No pipelines with the specified status</td>
<td class={td} colspan={99}>No pipelines found</td>
</tr>
{/each}
</tbody>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,11 +171,13 @@
</button>
{/snippet}

<div class="flex h-9 flex-wrap gap-2">
{#each actions as action}
{@render action()}
{/each}
</div>
{#if actions.length}
<div class="flex h-9 flex-wrap gap-2">
{#each actions as action}
{@render action()}
{/each}
</div>
{/if}

{#snippet deleteDialog()}
<DeleteDialog
Expand Down
40 changes: 40 additions & 0 deletions js-packages/web-console/src/lib/functions/common/string.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { matchesSubstring } from './string'

describe('matchesSubstring', () => {
it('returns true when search is empty', () => {
expect(matchesSubstring('any-pipeline', '')).toBe(true)
})

it('matches exact name', () => {
expect(matchesSubstring('my-pipeline', 'my-pipeline')).toBe(true)
})

it('matches partial name at the start', () => {
expect(matchesSubstring('my-pipeline', 'my-')).toBe(true)
})

it('matches partial name in the middle', () => {
expect(matchesSubstring('my-pipeline', 'pipe')).toBe(true)
})

it('matches partial name at the end', () => {
expect(matchesSubstring('my-pipeline', 'line')).toBe(true)
})

it('is case-insensitive (lowercase search, mixed case text)', () => {
expect(matchesSubstring('My-Pipeline', 'my-pipeline')).toBe(true)
})

it('is case-insensitive (uppercase search)', () => {
expect(matchesSubstring('my-pipeline', 'MY-PIPE')).toBe(true)
})

it('returns false when there is no match', () => {
expect(matchesSubstring('my-pipeline', 'xyz')).toBe(false)
})

it('returns false for partial mismatch', () => {
expect(matchesSubstring('my-pipeline', 'my-pipez')).toBe(false)
})
})
8 changes: 8 additions & 0 deletions js-packages/web-console/src/lib/functions/common/string.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@ export function humanSize(bytes: number): string {
return bytes.toFixed(1) + ' ' + units[u]
}

/**
* Case-insensitive substring match. Returns true if `text` contains `search`,
* or if `search` is empty.
*/
export function matchesSubstring(text: string, search: string): boolean {
return !search || text.toLowerCase().includes(search.toLowerCase())
}

export function nthIndexOf(
str: string,
substring: string,
Expand Down
132 changes: 132 additions & 0 deletions js-packages/web-console/tests/pipelineSearch.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { expect, test } from '@playwright/test'
import { client } from '$lib/services/manager/client.gen'
import {
deletePipeline,
getExtendedPipeline,
putPipeline
} from '$lib/services/pipelineManager'

const API_ORIGIN = (process.env.PLAYWRIGHT_API_ORIGIN ?? 'http://localhost:8080').replace(/\/$/, '')
client.setConfig({ baseUrl: API_ORIGIN })

const PREFIX = `test-search-${Date.now()}`
const PIPELINES = [`${PREFIX}-alpha`, `${PREFIX}-beta`, `${PREFIX}-gamma`] as const

async function cleanupPipelines() {
for (const name of PIPELINES) {
try {
await deletePipeline(name)
} catch {
// Pipeline may not exist
}
}
}

/** Poll the API until a pipeline reaches "Stopped" status. */
async function waitForStopped(name: string, timeoutMs = 120_000) {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
const p = await getExtendedPipeline(name)
if (p.status === 'Stopped') return
await new Promise((r) => setTimeout(r, 1000))
}
throw new Error(`Timed out waiting for ${name} to reach Stopped status`)
}

/** Navigate to the home page and wait until all test pipelines are visible. */
async function gotoAndWaitForPipelines(page: import('@playwright/test').Page) {
await page.goto('/')
for (const name of PIPELINES) {
await expect(page.getByTestId(`box-row-${name}`)).toBeVisible()
}
}

test.describe('Pipeline search', () => {
test.setTimeout(180_000)

test.beforeAll(async ({}, testInfo) => {
testInfo.setTimeout(120_000)
await cleanupPipelines()
for (const name of PIPELINES) {
await putPipeline(name, {
name,
description: `E2E search test pipeline: ${name}`,
program_code: 'create view test as (select 1)'
})
}
// Wait for all pipelines to finish compiling via API polling,
// so individual tests don't need to wait on DOM status changes.
for (const name of PIPELINES) {
await waitForStopped(name)
}
})

test.afterAll(async ({}, testInfo) => {
testInfo.setTimeout(60_000)
await cleanupPipelines()
})

test('filters pipelines by name substring', async ({ page }) => {
await gotoAndWaitForPipelines(page)

const searchInput = page.getByTestId('input-pipeline-search')
await searchInput.fill('alpha')

await expect(page.getByTestId(`box-row-${PREFIX}-alpha`)).toBeVisible()
await expect(page.getByTestId(`box-row-${PREFIX}-beta`)).not.toBeVisible()
await expect(page.getByTestId(`box-row-${PREFIX}-gamma`)).not.toBeVisible()
})

test('search is case-insensitive', async ({ page }) => {
await gotoAndWaitForPipelines(page)

const searchInput = page.getByTestId('input-pipeline-search')
await searchInput.fill(PREFIX.toUpperCase())

// All three pipelines share the prefix, so all should be visible
for (const name of PIPELINES) {
await expect(page.getByTestId(`box-row-${name}`)).toBeVisible()
}
})

test('shows empty state when no pipelines match', async ({ page }) => {
await gotoAndWaitForPipelines(page)

const searchInput = page.getByTestId('input-pipeline-search')
await searchInput.fill('nonexistent-pipeline-xyz-999')

await expect(page.getByText('No pipelines found')).toBeVisible()
})

test('clearing search shows all pipelines again', async ({ page }) => {
await gotoAndWaitForPipelines(page)

const searchInput = page.getByTestId('input-pipeline-search')
await searchInput.fill('alpha')
await expect(page.getByTestId(`box-row-${PREFIX}-beta`)).not.toBeVisible()

await searchInput.clear()

for (const name of PIPELINES) {
await expect(page.getByTestId(`box-row-${name}`)).toBeVisible()
}
})

test('search works together with status filter', async ({ page }) => {
await gotoAndWaitForPipelines(page)

// All test pipelines should be in "Stopped" (Ready To Start) status
const statusSelect = page.getByTestId('select-pipeline-status')
await statusSelect.selectOption('Ready To Start')

const searchInput = page.getByTestId('input-pipeline-search')
await searchInput.fill('beta')

await expect(page.getByTestId(`box-row-${PREFIX}-beta`)).toBeVisible()
await expect(page.getByTestId(`box-row-${PREFIX}-alpha`)).not.toBeVisible()

// Switching to a non-matching status should hide everything
await statusSelect.selectOption('Running')
await expect(page.getByTestId(`box-row-${PREFIX}-beta`)).not.toBeVisible()
})
})
Loading