diff --git a/frontend/README.md b/frontend/README.md index 677c5b101..78969237f 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -64,7 +64,7 @@ This creates `build.zip` with a top-level `dist/` directory, including `dist/ind - Add data: JSON HTTP, OTLP HTTP and OpenTelemetry Collector setup, compatible dataset selection/creation, endpoint validation, generated commands/configuration with copy feedback, and server ingestion checks that distinguish existing data from increases since a previous check. API keys remain in the sender's environment. - Alerts: searchable, sortable lists with tag filters and 25-row pagination; PromQL and SQL threshold creation/editing, query previews without notifications, hold duration, runtime instances and delivery errors, evaluate/enable/mute/duplicate/delete actions, and Slack/Webhook/Alertmanager targets with masked secrets. PromQL creation requires explicit server capability; existing builder rules are read-only. - Datasets: search, create logs/metrics/traces with an optional custom partition, manage information and ingestion/storage statistics, inspect schema, explore, set/clear retention, configure/disable hot tier when the server enables it, and delete with typed name confirmation. Time partitions cannot be set at creation. Static schema upload, tags/labels, existing partition editing and index management are not supported. -- Dashboards: server-backed lists and detail routes, search/tags/favourites/sort/pagination, ownership, full-document saves with conflict resolution, SQL and PromQL tiles, six variable types, URL time ranges and selections, classic import/export, and explicit migration of old browser-local dashboards. Demo dashboards use independent in-memory state per client. +- Dashboards: server-backed lists and detail routes, search/tags/favourites/sort/pagination, ownership, full-document saves with conflict resolution, SQL and PromQL tiles, six variable types, URL time ranges and selections, classic import/export, and explicit migration of old browser-local dashboards. Default dataset and tile dataset fields offer keyboard-accessible dropdowns with fuzzy search and retryable discovery. Changing a dashboard default seeds new tiles; existing tile queries retain their datasets. Demo dashboards use independent in-memory state per client. - Team: native users and one-time passwords, role and privilege management, default OIDC role configuration, provider group mappings and role provenance, and API key creation/copy/deletion with configurable expiration from 30 minutes to 90 days or no expiration. New UI-created keys default to 90 days; existing keys keep their original lifetime. Search and 25-row pagination apply to every tab; authorization stays with the backend. - Agents: schema-discovered GenAI datasets, related spans grouped into runs, successful/failed/incomplete status, observed duration, models and recorded token usage. Run details include the span tree, model and tool calls, errors, captured conversation and raw fields. Search/status/model filters and captured time bounds survive navigation between Agents, Traces and Logs. Missing telemetry and bounded samples are explicit; evaluations and cost estimates remain deferred. - Cluster: registered node reachability and resource metrics with independent request states; read-only. diff --git a/frontend/docs/review-media/searchable-picker/browse.png b/frontend/docs/review-media/searchable-picker/browse.png new file mode 100644 index 000000000..7cf00e193 Binary files /dev/null and b/frontend/docs/review-media/searchable-picker/browse.png differ diff --git a/frontend/docs/review-media/searchable-picker/change-default.mp4 b/frontend/docs/review-media/searchable-picker/change-default.mp4 new file mode 100644 index 000000000..4080ecab2 Binary files /dev/null and b/frontend/docs/review-media/searchable-picker/change-default.mp4 differ diff --git a/frontend/docs/review-media/searchable-picker/fuzzy-search.png b/frontend/docs/review-media/searchable-picker/fuzzy-search.png new file mode 100644 index 000000000..186ebc972 Binary files /dev/null and b/frontend/docs/review-media/searchable-picker/fuzzy-search.png differ diff --git a/frontend/docs/review-media/searchable-picker/inherited-default.png b/frontend/docs/review-media/searchable-picker/inherited-default.png new file mode 100644 index 000000000..476b8ece2 Binary files /dev/null and b/frontend/docs/review-media/searchable-picker/inherited-default.png differ diff --git a/frontend/e2e-live/dashboards.spec.ts b/frontend/e2e-live/dashboards.spec.ts index 9807d07af..7920041a0 100644 --- a/frontend/e2e-live/dashboards.spec.ts +++ b/frontend/e2e-live/dashboards.spec.ts @@ -195,7 +195,8 @@ test('create SQL and PromQL tiles and variables in the native UI; verify exact c await variable.getByLabel('Variable name').fill('metrics_dataset'); await variable.getByLabel('Variable label').fill('Metrics dataset'); await variable.getByLabel('Variable type').selectOption('dataset'); - await variable.getByLabel('Default dataset').fill(metrics); + await variable.getByRole('combobox', { name: 'Default dataset' }).fill(metrics); + await variable.getByRole('option', { name: metrics, exact: true }).click(); await variable.locator('[data-dialog-confirm]').click(); await page.getByRole('button', { name: 'Add variable', exact: true }).click(); variable = page.getByRole('dialog', { name: 'Add variable', exact: true }); @@ -211,7 +212,8 @@ test('create SQL and PromQL tiles and variables in the native UI; verify exact c await page.getByRole('button', { name: 'Add tile', exact: true }).click(); let editor = page.getByRole('dialog', { name: 'Add tile', exact: true }); await editor.getByLabel('Tile title').fill('Live log events'); - await editor.getByLabel('Dataset', { exact: true }).selectOption(logs); + await editor.getByRole('combobox', { name: 'Dataset', exact: true }).click(); + await editor.getByRole('option', { name: logs, exact: true }).click(); await editor.getByRole('textbox', { name: 'SQL query', exact: true }).fill(sql); await editor.getByLabel('Chart type').selectOption('table'); await editor.getByLabel('Width (columns)').selectOption('12'); @@ -220,7 +222,8 @@ test('create SQL and PromQL tiles and variables in the native UI; verify exact c editor = page.getByRole('dialog', { name: 'Add tile', exact: true }); await editor.getByLabel('Tile title').fill('Live host load'); await editor.getByLabel('Query language').selectOption('promql'); - await editor.getByLabel('Dataset', { exact: true }).selectOption('$metrics_dataset'); + await editor.getByRole('combobox', { name: 'Dataset', exact: true }).click(); + await editor.getByRole('option', { name: '$metrics_dataset', exact: true }).click(); await editor.getByRole('textbox', { name: 'PromQL query A', exact: true }).fill(promql); await editor.getByLabel('Width (columns)').selectOption('12'); await editor.locator('[data-dialog-confirm]').click(); diff --git a/frontend/e2e/dashboard-builder-regressions.spec.ts b/frontend/e2e/dashboard-builder-regressions.spec.ts index b329a11de..e6843a364 100644 --- a/frontend/e2e/dashboard-builder-regressions.spec.ts +++ b/frontend/e2e/dashboard-builder-regressions.spec.ts @@ -75,7 +75,8 @@ async function addBuilder(page: Page) { await page.getByRole('button', { name: 'Add tile', exact: true }).click(); const editor = page.getByRole('dialog', { name: 'Add tile', exact: true }); await editor.getByLabel('Tile title', { exact: true }).fill('Bucket counts'); - await editor.getByLabel('Dataset', { exact: true }).selectOption('app-logs'); + await editor.getByRole('combobox', { name: 'Dataset', exact: true }).click(); + await editor.getByRole('option', { name: 'app-logs', exact: true }).click(); await editor.getByLabel('Query language', { exact: true }).selectOption('builder'); return editor; } diff --git a/frontend/e2e/dashboard-editor.spec.ts b/frontend/e2e/dashboard-editor.spec.ts index d5864ee9a..69387c5a0 100644 --- a/frontend/e2e/dashboard-editor.spec.ts +++ b/frontend/e2e/dashboard-editor.spec.ts @@ -73,7 +73,8 @@ async function openEditor(page: Page, title: string, chart: string, sql: string) await page.getByRole('button', { name: 'Add tile', exact: true }).click(); const editor = page.getByRole('dialog', { name: 'Add tile', exact: true }); await editor.getByLabel('Tile title').fill(title); - await editor.getByLabel('Dataset', { exact: true }).selectOption('app-logs'); + await editor.getByRole('combobox', { name: 'Dataset', exact: true }).click(); + await editor.getByRole('option', { name: 'app-logs', exact: true }).click(); await editor.getByRole('textbox', { name: 'SQL query', exact: true }).fill(sql); await editor.getByLabel('Chart type').selectOption({ label: chart }); return editor; diff --git a/frontend/e2e/dashboard-layout-sections.spec.ts b/frontend/e2e/dashboard-layout-sections.spec.ts index 06645838a..7c5f98593 100644 --- a/frontend/e2e/dashboard-layout-sections.spec.ts +++ b/frontend/e2e/dashboard-layout-sections.spec.ts @@ -199,7 +199,8 @@ test('visual builder generates executable filtered SQL and survives save/reload' await page.getByRole('button', { name: 'Add tile', exact: true }).click(); const editor = page.getByRole('dialog', { name: 'Add tile', exact: true }); await editor.getByLabel('Tile title').fill('Requests'); - await editor.getByLabel('Dataset', { exact: true }).selectOption('app-logs'); + await editor.getByRole('combobox', { name: 'Dataset', exact: true }).click(); + await editor.getByRole('option', { name: 'app-logs', exact: true }).click(); await editor.getByLabel('Query language').selectOption('builder'); await editor.getByLabel('Chart type').selectOption('table'); await expect(editor.getByLabel('Dimension 1')).toBeVisible(); diff --git a/frontend/e2e/dashboards.spec.ts b/frontend/e2e/dashboards.spec.ts index c34e00978..ac6dfe873 100644 --- a/frontend/e2e/dashboards.spec.ts +++ b/frontend/e2e/dashboards.spec.ts @@ -22,7 +22,8 @@ async function addSql(page: Page, title: string, dataset = 'application_logs') { await page.getByRole('button', { name: 'Add tile', exact: true }).click(); const editor = page.getByRole('dialog', { name: 'Add tile', exact: true }); await editor.getByLabel('Tile title').fill(title); - await editor.getByLabel('Dataset', { exact: true }).selectOption(dataset); + await editor.getByRole('combobox', { name: 'Dataset', exact: true }).click(); + await editor.getByRole('option', { name: dataset, exact: true }).click(); await editor .getByRole('textbox', { name: 'SQL query', exact: true }) .fill(`SELECT COUNT(*) AS events FROM "${dataset}"`); @@ -338,7 +339,8 @@ test('demo PromQL tile add/edit/delete and variable selection', async ({ page }) const editor = page.getByRole('dialog', { name: 'Add tile', exact: true }); await editor.getByLabel('Tile title').fill('Metric stat'); await editor.getByLabel('Query language').selectOption('promql'); - await editor.getByLabel('Dataset', { exact: true }).selectOption('$metrics_dataset'); + await editor.getByRole('combobox', { name: 'Dataset', exact: true }).click(); + await editor.getByRole('option', { name: '$metrics_dataset', exact: true }).click(); await editor .getByRole('textbox', { name: 'PromQL query A', exact: true }) .fill('{__name__="system.cpu.load_average.1m",host=~"$host"}'); @@ -510,11 +512,8 @@ test('PromQL capability off prevents authoring and querying without hiding store await expect(page.getByLabel('Host', { exact: true })).toBeDisabled(); await page.getByRole('button', { name: 'Add tile', exact: true }).click(); await expect(page.getByLabel('Query language').locator('option[value="promql"]')).toBeDisabled(); - await expect( - page - .getByLabel('Dataset', { exact: true }) - .getByRole('option', { name: 'app-logs', exact: true }), - ).toHaveCount(1); + await page.getByRole('combobox', { name: 'Dataset', exact: true }).click(); + await expect(page.getByRole('option', { name: 'app-logs', exact: true })).toBeVisible(); await page.waitForLoadState('networkidle'); expect(server.calls.filter((call) => call.path.startsWith('/prometheus'))).toHaveLength(0); }); @@ -1958,10 +1957,10 @@ test('list, text and dataset variables can be authored; View query and Explore d await editor.getByLabel('Variable label').fill(`${type} variable`); await editor.getByLabel('Variable type').selectOption(type); if (type === 'list') await editor.getByLabel('Values (comma separated)').fill('one, two'); - else - await editor - .getByLabel(type === 'dataset' ? 'Default dataset' : 'Default value') - .fill(type === 'dataset' ? 'app-logs' : 'note'); + else if (type === 'dataset') { + await editor.getByRole('combobox', { name: 'Default dataset' }).fill('app-lgos'); + await editor.getByRole('option', { name: 'app-logs', exact: true }).click(); + } else await editor.getByLabel('Default value').fill('note'); await editor.locator('[data-dialog-confirm]').click(); if (type === 'text') await expect(page.getByLabel('text variable', { exact: true })).toHaveValue('note'); @@ -2227,7 +2226,8 @@ test('dashboard default dataset survives reload, seeds new tiles and can be chan await page.getByRole('button', { name: 'Create dashboard', exact: true }).click(); const form = page.getByRole('dialog', { name: 'Create dashboard', exact: true }); await form.getByLabel('Dashboard title').fill('Dataset scoped dashboard'); - await form.getByLabel('Default dataset').selectOption('app-logs'); + await form.getByRole('combobox', { name: 'Default dataset', exact: true }).click(); + await form.getByRole('option', { name: 'app-logs', exact: true }).click(); await form.locator('[data-dialog-confirm]').click(); await expect( page.getByRole('heading', { name: 'Dataset scoped dashboard', exact: true }), @@ -2252,7 +2252,8 @@ test('dashboard default dataset survives reload, seeds new tiles and can be chan await page.getByRole('menuitem', { name: 'Rename and tags', exact: true }).click(); const metadata = page.getByRole('dialog', { name: 'Rename and tags', exact: true }); await expect(metadata.getByLabel('Default dataset')).toHaveValue('app-logs'); - await metadata.getByLabel('Default dataset').selectOption('otel-metrics'); + await metadata.getByRole('combobox', { name: 'Default dataset', exact: true }).click(); + await metadata.getByRole('option', { name: 'otel-metrics', exact: true }).click(); await metadata.getByRole('button', { name: 'Apply', exact: true }).click(); await page.getByRole('button', { name: 'Save', exact: true }).click(); await expect(page.getByText('All changes saved', { exact: true })).toBeVisible(); @@ -2277,14 +2278,15 @@ test('dashboard default dataset survives reload, seeds new tiles and can be chan .getByRole('button', { name: 'Actions for Dataset scoped dashboard', exact: true }) .click(); await page.getByRole('menuitem', { name: 'Rename and tags', exact: true }).click(); - await metadata.getByLabel('Default dataset').selectOption(''); + await metadata.getByRole('combobox', { name: 'Default dataset', exact: true }).click(); + await metadata.getByRole('option', { name: 'No default dataset', exact: true }).click(); await metadata.getByRole('button', { name: 'Update dashboard', exact: true }).click(); await expect(metadata).not.toBeVisible(); expect(server.writes.at(-1)?.dataset).toBeNull(); expect(server.writes.at(-1)?.tiles).toEqual(originalTiles); await page.getByRole('link', { name: 'Dataset scoped dashboard', exact: true }).click(); await page.getByRole('button', { name: 'Add tile', exact: true }).click(); - await expect(editor.getByLabel('Dataset', { exact: true })).toHaveValue(''); + await expect(editor.getByLabel('Dataset', { exact: true })).toHaveValue('No dataset'); }); test('editing metadata retains a saved dashboard dataset unavailable to discovery', async ({ @@ -2305,3 +2307,209 @@ test('editing metadata retains a saved dashboard dataset unavailable to discover expect(server.writes.at(-1)?.dataset).toBe('archived-logs'); expect(server.writes.at(-1)?.tiles).toEqual(classic.tiles); }); + +test('dataset search is transient until a fuzzy match is selected and saved', async ({ page }) => { + const server = await mockServer(page, { + document: { ...structuredClone(classic), dataset: 'app-logs' } as Dashboard, + }); + await page.goto(detail); + await page.getByRole('button', { name: 'Dashboard actions', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Rename and tags', exact: true }).click(); + const form = page.getByRole('dialog', { name: 'Rename and tags', exact: true }); + const picker = form.getByRole('combobox', { name: 'Default dataset', exact: true }); + await picker.click(); + await expect(form.getByRole('option', { name: 'app-logs', exact: true })).toBeVisible(); + await expect(form.getByRole('option', { name: 'otel-metrics', exact: true })).toBeVisible(); + await picker.fill('nothing-matches-this-dataset'); + await expect(form.getByText('No matching datasets', { exact: true })).toBeVisible(); + await picker.press('Escape'); + await expect(form).toBeVisible(); + await expect(picker).toHaveValue('app-logs'); + let discardPrompts = 0; + page.on('dialog', async (dialog) => { + discardPrompts++; + await dialog.dismiss(); + }); + await form.getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(form).not.toBeVisible(); + expect(discardPrompts).toBe(0); + expect(server.writes).toHaveLength(0); + + await page.getByRole('button', { name: 'Dashboard actions', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Rename and tags', exact: true }).click(); + await picker.fill('otel-mterics'); + await expect(form.getByRole('option', { name: 'otel-metrics', exact: true })).toBeVisible(); + await picker.press('ArrowDown'); + await picker.press('Enter'); + await expect(picker).toHaveValue('otel-metrics'); + await expect(form).toBeVisible(); + await form.getByRole('button', { name: 'Apply', exact: true }).click(); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(page.getByText('All changes saved', { exact: true })).toBeVisible(); + expect(server.writes.at(-1)?.dataset).toBe('otel-metrics'); + expect(server.writes.at(-1)?.tiles).toEqual(classic.tiles); + await page.reload(); + await expect( + page.getByText('Classic report · Dataset: otel-metrics', { exact: true }), + ).toBeVisible(); +}); + +test('default dataset discovery exposes loading, failure, retry and an empty list', async ({ + page, +}) => { + await mockServer(page); + await page.goto('/dashboards'); + await expect(page.getByRole('heading', { name: 'Dashboards', exact: true })).toBeVisible(); + let release: () => void = () => {}; + const waiting = new Promise((resolve) => { + release = resolve; + }); + let attempts = 0; + let failDiscovery = true; + await page.route('**/api/v1/logstream', async (route) => { + attempts++; + if (failDiscovery) { + await waiting; + return route.fulfill({ status: 503, body: 'Dataset discovery unavailable' }); + } + return route.fulfill({ json: [] }); + }); + await page.getByRole('button', { name: 'Create dashboard', exact: true }).click(); + const form = page.getByRole('dialog', { name: 'Create dashboard', exact: true }); + const picker = form.getByRole('combobox', { name: 'Default dataset', exact: true }); + await picker.click(); + await expect(picker).toHaveAttribute('aria-busy', 'true'); + await expect(form.getByRole('status')).toContainText('Loading datasets'); + release(); + await expect(form.getByText(/Could not load datasets/)).toBeVisible(); + failDiscovery = false; + const beforeRetry = attempts; + await form.getByRole('button', { name: 'Retry loading datasets' }).click(); + await expect(form.getByRole('option', { name: 'No default dataset', exact: true })).toBeVisible(); + await expect(form.getByText(/Could not load datasets/)).toHaveCount(0); + await expect(form.getByRole('option')).toHaveCount(1); + await picker.fill('app'); + await expect(form.getByText('No matching datasets', { exact: true })).toBeVisible(); + expect(attempts).toBe(beforeRetry + 1); +}); + +test('dataset variable defaults retain unavailable values and retry discovery without exposing variable references', async ({ + page, +}) => { + const options = { + document: { + ...structuredClone(classic), + variables: [ + { name: 'source', label: 'Source', type: 'dataset', defaultValue: 'archived-logs' }, + { name: 'other_source', label: 'Other source', type: 'dataset', defaultValue: 'app-logs' }, + ], + } as Dashboard, + denied: '/api/v1/logstream' as string | undefined, + }; + const server = await mockServer(page, options); + await page.goto(detail); + await page.getByRole('button', { name: 'Actions for variable Source', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Edit variable', exact: true }).click(); + const form = page.getByRole('dialog', { name: 'Edit variable', exact: true }); + const picker = form.getByRole('combobox', { name: 'Default dataset', exact: true }); + await expect(picker).toHaveValue(/archived-logs/); + await picker.click(); + await expect(form.getByText(/Could not load datasets/)).toBeVisible(); + options.denied = undefined; + await form.getByRole('button', { name: 'Retry loading datasets' }).click(); + await expect(form.getByRole('option', { name: 'app-logs', exact: true })).toBeVisible(); + await expect(form.getByRole('option', { name: /archived-logs/ })).toBeVisible(); + await expect(form.getByRole('option', { name: /\$other_source/ })).toHaveCount(0); + await picker.press('Escape'); + await form.getByLabel('Variable label').fill('Archived source'); + await form.getByRole('button', { name: 'Apply variable', exact: true }).click(); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(page.getByText('All changes saved', { exact: true })).toBeVisible(); + expect(server.writes.at(-1)?.variables).toEqual([ + { name: 'source', label: 'Archived source', type: 'dataset', defaultValue: 'archived-logs' }, + { name: 'other_source', label: 'Other source', type: 'dataset', defaultValue: 'app-logs' }, + ]); +}); + +test('tile dataset search keeps schema and draft unchanged and PromQL choices restricted', async ({ + page, +}) => { + const server = await mockServer(page, { + document: { ...structuredClone(classic), dataset: 'app-logs' } as Dashboard, + }); + await page.goto(detail); + await page.getByRole('button', { name: 'Add tile', exact: true }).click(); + const form = page.getByRole('dialog', { name: 'Add tile', exact: true }); + const picker = form.getByRole('combobox', { name: 'Dataset', exact: true }); + await expect(picker).toHaveValue('app-logs'); + await page.waitForLoadState('networkidle'); + const schemaCalls = server.calls.filter((call) => call.path.endsWith('/schema')).length; + await picker.fill('otel'); + await expect(form.getByRole('option', { name: 'otel-metrics', exact: true })).toBeVisible(); + await picker.press('Escape'); + await expect(picker).toHaveValue('app-logs'); + expect(server.calls.filter((call) => call.path.endsWith('/schema'))).toHaveLength(schemaCalls); + await form.getByLabel('Query language').selectOption('promql'); + await picker.click(); + await expect(form.getByRole('option', { name: 'otel-metrics', exact: true })).toBeVisible(); + await expect(form.getByRole('option', { name: 'app-logs', exact: true })).toHaveCount(0); + await expect(form.getByRole('option', { name: '$metrics_dataset', exact: true })).toBeVisible(); + await picker.fill('mtrcs_dtst'); + await form.getByRole('option', { name: '$metrics_dataset', exact: true }).click(); + await expect(picker).toHaveValue('$metrics_dataset'); +}); + +test('reselecting the current dataset does not dirty an editor or drop SQL dataset bindings', async ({ + page, +}) => { + const bindings = ['app-logs', 'otel-metrics']; + const server = await mockServer(page, { + document: { + ...structuredClone(classic), + tiles: [{ ...structuredClone(classic.tiles[1]), dbName: bindings }], + } as Dashboard, + }); + await page.goto(detail); + await tileAction(page, 'Errors', 'Edit'); + const form = page.getByRole('dialog', { name: 'Edit tile', exact: true }); + const picker = form.getByRole('combobox', { name: 'Dataset', exact: true }); + await picker.click(); + await form.getByRole('option', { name: 'app-logs', exact: true }).click(); + let discardPrompts = 0; + page.on('dialog', async (dialog) => { + discardPrompts++; + await dialog.dismiss(); + }); + await form.getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(form).not.toBeVisible(); + expect(discardPrompts).toBe(0); + await tileAction(page, 'Errors', 'Edit'); + await picker.click(); + await form.getByRole('option', { name: 'app-logs', exact: true }).click(); + await form.getByLabel('Tile title').fill('Renamed errors'); + await form.getByRole('button', { name: 'Apply tile', exact: true }).click(); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(page.getByText('All changes saved', { exact: true })).toBeVisible(); + expect(server.writes.at(-1)?.tiles?.[0].dbName).toEqual(bindings); +}); + +test('braced dataset variable references are not labelled unavailable in the tile picker', async ({ + page, +}) => { + await mockServer(page, { + document: { + ...structuredClone(classic), + variables: [{ name: 'source', label: 'Source', type: 'dataset', defaultValue: 'app-logs' }], + tiles: [{ ...structuredClone(classic.tiles[1]), dbName: ['${source}'] }], + } as Dashboard, + }); + await page.goto(detail); + await tileAction(page, 'Errors', 'Edit'); + const form = page.getByRole('dialog', { name: 'Edit tile', exact: true }); + const picker = form.getByRole('combobox', { name: 'Dataset', exact: true }); + await picker.click(); + await expect(form.getByRole('option', { name: 'app-logs', exact: true })).toBeVisible(); + await expect(form.getByRole('option', { name: /unavailable/ })).toHaveCount(0); + await picker.press('Escape'); + await expect(picker).toHaveValue('${source}'); +}); diff --git a/frontend/src/components/ui/SearchableSelect.css b/frontend/src/components/ui/SearchableSelect.css new file mode 100644 index 000000000..3c3dbfbbc --- /dev/null +++ b/frontend/src/components/ui/SearchableSelect.css @@ -0,0 +1,68 @@ +.ui-searchable-select { + position: relative; + min-width: 0; +} +.ui-searchable-select-control { + display: flex; + align-items: center; + gap: 8px; +} +.ui-searchable-select-control > .ui-select-frame { + flex: 1; + min-width: 0; +} +.ui-searchable-select-input { + padding-right: 32px; +} +.ui-searchable-select-popup { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + z-index: 30; + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + background: var(--color-surface-raised); + box-shadow: var(--shadow-panel); + color: var(--color-text); + overflow: hidden; +} +.ui-searchable-select-options { + max-height: 240px; + overflow-y: auto; + overscroll-behavior: contain; +} +.ui-searchable-select-option { + padding: 8px 12px; + font-size: 13px; + overflow-wrap: anywhere; + cursor: pointer; +} +.ui-searchable-select-option[aria-selected='true'] { + font-weight: 600; +} +.ui-searchable-select-option[data-active] { + background: var(--color-primary-soft); + color: var(--color-primary); +} +.ui-searchable-select-status { + margin: 0; + padding: 8px 12px; + font-size: 12px; + color: var(--color-muted); +} +.ui-searchable-select-retry { + color: var(--color-primary); + background: transparent; + border: 0; + padding: 4px; + cursor: pointer; + font: inherit; + font-size: 12px; + text-decoration: underline; +} + +.ui-searchable-select-retry:disabled { + color: var(--color-muted); + cursor: default; +} diff --git a/frontend/src/components/ui/SearchableSelect.stories.tsx b/frontend/src/components/ui/SearchableSelect.stories.tsx new file mode 100644 index 000000000..1a869017f --- /dev/null +++ b/frontend/src/components/ui/SearchableSelect.stories.tsx @@ -0,0 +1,133 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { SearchableSelect, type SearchableSelectProps } from './SearchableSelect'; +import { Dialog } from './Dialog'; +import './ui.css'; + +const options = [ + { value: '', label: 'No dataset' }, + { value: 'app-logs' }, + { value: 'audit-events' }, + { value: 'production-api' }, + ...Array.from({ length: 150 }, (_, index) => ({ value: `service-${index}-logs` })), +]; +function Example(props: Partial) { + const [value, setValue] = useState(props.value ?? 'app-logs'); + const [retries, setRetries] = useState(0); + const [changes, setChanges] = useState(0); + return ( +
+ + { + setValue(next); + setChanges((count) => count + 1); + }} + onRetry={props.error ? () => setRetries((count) => count + 1) : undefined} + /> + +

{value || '(empty)'}

+

{retries}

+

{changes}

+
+ ); +} +const meta = { + title: 'Prism/SearchableSelect', + component: SearchableSelect, + args: { label: 'Dataset', options, value: 'app-logs', onValueChange: () => {} }, + render: (args) => , +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const Datasets: Story = {}; +export const Unavailable: Story = { + args: { + value: 'deleted-logs', + options: [...options, { value: 'deleted-logs', label: 'deleted-logs (unavailable)' }], + }, +}; +export const LoadFailure: Story = { args: { options: [], error: 'Could not load datasets.' } }; +export const Loading: Story = { args: { options: [], loading: true } }; +export const Disabled: Story = { args: { disabled: true } }; +export const InDialog: Story = { + render: () => { + const [open, setOpen] = useState(true); + return ( + <> + + + + + + ); + }, +}; + +export const AsyncOptions: Story = { + render: () => { + const [updated, setUpdated] = useState(false); + const [value, setValue] = useState('app-logs'); + return ( +
+ + +

{value}

+
+ ); + }, +}; + +export const RetryLoading: Story = { + render: () => { + const [loading, setLoading] = useState(false); + const [failed, setFailed] = useState(true); + const [value, setValue] = useState('app-logs'); + return ( +
+ { + setLoading(true); + setTimeout(() => { + setLoading(false); + setFailed(false); + }, 1000); + }} + /> +
+ ); + }, +}; diff --git a/frontend/src/components/ui/SearchableSelect.tsx b/frontend/src/components/ui/SearchableSelect.tsx new file mode 100644 index 000000000..78ab4546e --- /dev/null +++ b/frontend/src/components/ui/SearchableSelect.tsx @@ -0,0 +1,232 @@ +import { useEffect, useRef, useState } from 'react'; +import { ChevronDown } from 'lucide-react'; +import { fuzzyFilter } from '../../lib/fuzzy'; +import { Field, useField, type FieldOptions } from './Field'; +import './SearchableSelect.css'; + +export interface SearchableSelectProps extends FieldOptions { + id?: string; + value: string; + options: ReadonlyArray<{ value: string; label?: string }>; + onValueChange: (value: string) => void; + disabled?: boolean; + loading?: boolean; + onRetry?: () => void; + placeholder?: string; + emptyLabel?: string; + 'aria-describedby'?: string; +} + +const MAX_OPTIONS = 100; + +export function SearchableSelect({ + id, + label, + hint, + error, + value, + options, + onValueChange, + disabled = false, + loading = false, + onRetry, + placeholder = 'Search datasets…', + emptyLabel = 'No matching datasets', + 'aria-describedby': describedBy, +}: SearchableSelectProps) { + const field = useField(id, describedBy, { hint, error }); + const root = useRef(null); + const input = useRef(null); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + const [active, setActive] = useState(); + const selected = options.find((option) => option.value === value); + const selectedLabel = selected?.label ?? selected?.value ?? value; + const matches = fuzzyFilter(options, query, (option) => option.label ?? option.value); + const visible = matches.slice(0, MAX_OPTIONS); + const expanded = open && !disabled; + const listId = `${field.inputId}-options`; + const countId = `${field.inputId}-count`; + const activeIndex = loading ? -1 : visible.findIndex((option) => option.value === active); + const show = () => { + if (!disabled) { + setOpen(true); + setQuery(''); + setActive(undefined); + } + }; + const close = () => { + setOpen(false); + setQuery(''); + setActive(undefined); + }; + const choose = (next: string) => { + if (next !== value) onValueChange(next); + close(); + input.current?.focus(); + }; + + useEffect(() => { + if (!expanded) return; + const dismiss = (event: PointerEvent) => { + if (!root.current?.contains(event.target as Node)) close(); + }; + // Capture before the dialog's document listener so the first Escape closes only this list. + const escape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + close(); + } + }; + document.addEventListener('pointerdown', dismiss); + window.addEventListener('keydown', escape, true); + return () => { + document.removeEventListener('pointerdown', dismiss); + window.removeEventListener('keydown', escape, true); + }; + }, [expanded]); + + useEffect(() => { + if (active !== undefined && activeIndex < 0) setActive(undefined); + }, [active, activeIndex]); + + useEffect(() => { + if (expanded && activeIndex >= 0) { + document.getElementById(`${listId}-${activeIndex}`)?.scrollIntoView?.({ block: 'nearest' }); + } + }, [activeIndex, expanded, listId]); + + return ( + +
{ + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) close(); + }} + > +
+
+ = 0 ? `${listId}-${activeIndex}` : undefined + } + aria-describedby={ + [field.describedBy, expanded ? countId : undefined].filter(Boolean).join(' ') || + undefined + } + aria-invalid={error ? true : undefined} + aria-busy={loading || undefined} + disabled={disabled} + autoComplete="off" + placeholder={loading ? 'Loading datasets…' : placeholder} + value={expanded ? query : selectedLabel} + onFocus={(event) => event.currentTarget.select()} + onClick={() => { + if (!expanded) show(); + }} + onChange={(event) => { + setOpen(true); + setQuery(event.target.value); + setActive(undefined); + }} + onKeyDown={(event) => { + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + if (!expanded) { + show(); + if (!loading && options.length) { + setActive( + options[ + event.key === 'ArrowDown' ? 0 : Math.min(options.length, MAX_OPTIONS) - 1 + ].value, + ); + } + return; + } + const nextIndex = + event.key === 'ArrowDown' + ? Math.min(activeIndex + 1, visible.length - 1) + : activeIndex < 0 + ? visible.length - 1 + : Math.max(activeIndex - 1, 0); + setActive(visible[nextIndex]?.value); + } else if (event.key === 'Enter') { + event.preventDefault(); + if (!expanded) show(); + else if (activeIndex >= 0 && !loading) choose(visible[activeIndex].value); + } else if (event.key === 'Tab') close(); + }} + /> +
+ {error && onRetry && ( + + )} +
+ {expanded && ( +
event.preventDefault()} + > +
+ {!loading && + visible.map((option, index) => ( +
{ + if (event.pointerType === 'mouse') setActive(option.value); + }} + onClick={() => choose(option.value)} + > + {option.label ?? option.value} +
+ ))} +
+

+ {loading + ? 'Loading datasets…' + : !matches.length + ? emptyLabel + : matches.length > MAX_OPTIONS + ? `Showing ${MAX_OPTIONS} of ${matches.length} matches. Refine your search.` + : `${matches.length} ${matches.length === 1 ? 'option' : 'options'}`} +

+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts index 5beeaa07b..615cdcbf7 100644 --- a/frontend/src/components/ui/index.ts +++ b/frontend/src/components/ui/index.ts @@ -4,6 +4,7 @@ import './ui.css'; export { Button, type ButtonProps } from './Button'; export { Input, type InputProps } from './Input'; export { Select, type SelectProps } from './Select'; +export { SearchableSelect, type SearchableSelectProps } from './SearchableSelect'; export { Badge, type BadgeProps } from './Badge'; export { Card, CardHeader, CardBody } from './Card'; export { ActionsMenu, type ActionsMenuItem } from './ActionsMenu'; diff --git a/frontend/src/features/dashboards/DashboardDialogs.tsx b/frontend/src/features/dashboards/DashboardDialogs.tsx index 911426ef0..15ce4b562 100644 --- a/frontend/src/features/dashboards/DashboardDialogs.tsx +++ b/frontend/src/features/dashboards/DashboardDialogs.tsx @@ -93,6 +93,7 @@ export function DashboardDialogs({ variables={url.variables} names={variableNames(draft)} datasets={(datasets.data ?? []).map((dataset) => dataset.name)} + datasetsStatus={datasets} promqlEnabled={promqlEnabled} onDirty={workspace.setEditorDirty} onClose={closeEditor} diff --git a/frontend/src/features/dashboards/DashboardForm.tsx b/frontend/src/features/dashboards/DashboardForm.tsx index 8ab1b63eb..55951838f 100644 --- a/frontend/src/features/dashboards/DashboardForm.tsx +++ b/frontend/src/features/dashboards/DashboardForm.tsx @@ -1,8 +1,9 @@ import { useCallback, useEffect, useId, useState } from 'react'; -import { Button, Dialog, Input, InlineError, Select } from '../../components/ui'; +import { Button, Dialog, Input, InlineError, SearchableSelect } from '../../components/ui'; import { useApp } from '../../app/AppProvider'; import { useAsync } from '../../hooks/useAsync'; import type { Dashboard } from '../../lib/types'; +import { datasetOptions } from './pickerOptions'; import { dashboardError, tagsFromText } from './helpers'; export function DashboardForm({ @@ -30,7 +31,6 @@ export function DashboardForm({ const { client } = useApp(); const datasets = useAsync(useCallback((signal) => client.listDatasets(signal), [client])); const [dataset, setDataset] = useState(original?.dataset ?? ''); - const datasetNames = (datasets.data ?? []).map((entry) => entry.name); const [title, setTitle] = useState(original?.title ?? ''); const [tags, setTags] = useState((original?.tags ?? []).join(', ')); const [description, setDescription] = useState( @@ -79,22 +79,20 @@ export function DashboardForm({ required autoFocus /> - - variable.type === 'dataset') + .map((variable) => variable.name); + const reference = /^\$(?:(\w+)|\{(\w+)\})$/.exec(dataset); + // A saved `${name}` reference is as valid as the listed `$name` form. + const referencesVariable = !!reference && datasetVariables.includes(reference[1] ?? reference[2]); const schema = useAsync( useCallback( (signal) => @@ -264,33 +271,35 @@ export function TileEditor({ {promqlEnabled === undefined &&

Loading server capabilities…

} - - + ...datasetVariables.map((name) => `$${name}`), + ...(referencesVariable ? [dataset] : []), + ], + selected: dataset, + emptyLabel: 'No dataset', + // Unchecked metrics datasets may still hold metrics, so they are not marked. + loaded: isPromql + ? !!promqlEnabled && !!metrics.data && !metrics.data.unchecked.includes(dataset) + : !!datasets.data, + })} + loading={isPromql ? metrics.loading : datasets.loading} + onRetry={isPromql ? metrics.reload : datasets.reload} + error={ + (isPromql ? metrics.error : datasets.error) + ? isPromql + ? 'Could not discover metrics datasets.' + : 'Could not load datasets.' + : undefined + } + /> change({ defaultValue: event.target.value })} - /> + {draft.type === 'dataset' ? ( + change({ defaultValue })} + options={datasetOptions({ + choices: datasets, + selected: draft.defaultValue, + emptyLabel: 'No default dataset', + loaded: !datasetsStatus || (!datasetsStatus.loading && !datasetsStatus.error), + })} + loading={datasetsStatus?.loading} + onRetry={datasetsStatus?.reload} + error={ + datasetsStatus?.error + ? 'Could not load datasets. The saved default is retained.' + : undefined + } + /> + ) : ( + change({ defaultValue: event.target.value })} + /> + )}

{validation}

diff --git a/frontend/src/features/dashboards/pickerOptions.test.ts b/frontend/src/features/dashboards/pickerOptions.test.ts new file mode 100644 index 000000000..8b9853e9d --- /dev/null +++ b/frontend/src/features/dashboards/pickerOptions.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { datasetOptions } from './pickerOptions'; + +describe('datasetOptions', () => { + it('starts with the empty choice and dedupes choices', () => { + expect( + datasetOptions({ + choices: ['a', 'b', 'a', ''], + selected: 'a', + emptyLabel: 'None', + loaded: true, + }), + ).toEqual([{ value: '', label: 'None' }, { value: 'a' }, { value: 'b' }]); + }); + + it('keeps a saved selection missing from discovery and marks it once loaded', () => { + expect( + datasetOptions({ choices: ['a'], selected: 'gone', emptyLabel: 'None', loaded: true }), + ).toEqual([ + { value: '', label: 'None' }, + { value: 'gone', label: 'gone (unavailable)' }, + { value: 'a' }, + ]); + }); + + it('does not mark the saved selection while loading or after a failure', () => { + expect( + datasetOptions({ choices: [], selected: 'saved', emptyLabel: 'None', loaded: false }), + ).toEqual([{ value: '', label: 'None' }, { value: 'saved' }]); + }); + + it('adds nothing for an empty or missing selection', () => { + for (const selected of ['', null, undefined]) + expect( + datasetOptions({ choices: ['a'], selected, emptyLabel: 'None', loaded: true }), + ).toEqual([{ value: '', label: 'None' }, { value: 'a' }]); + }); +}); diff --git a/frontend/src/features/dashboards/pickerOptions.ts b/frontend/src/features/dashboards/pickerOptions.ts new file mode 100644 index 000000000..581fc73bd --- /dev/null +++ b/frontend/src/features/dashboards/pickerOptions.ts @@ -0,0 +1,28 @@ +export type DatasetOption = { value: string; label?: string }; + +/** + * Options for a dataset picker: an explicit empty choice first, then the saved + * selection when discovery does not list it, then deduplicated choices. The + * saved value is only marked unavailable once discovery has succeeded, so a + * loading or failed list never mislabels it. + */ +export function datasetOptions({ + choices, + selected, + emptyLabel, + loaded, +}: { + choices: ReadonlyArray; + selected?: string | null; + emptyLabel: string; + loaded: boolean; +}): DatasetOption[] { + const unique = [...new Set(choices.filter(Boolean))]; + return [ + { value: '', label: emptyLabel }, + ...(selected && !unique.includes(selected) + ? [loaded ? { value: selected, label: `${selected} (unavailable)` } : { value: selected }] + : []), + ...unique.map((value) => ({ value })), + ]; +} diff --git a/frontend/src/lib/fuzzy.test.ts b/frontend/src/lib/fuzzy.test.ts new file mode 100644 index 000000000..539e5bdeb --- /dev/null +++ b/frontend/src/lib/fuzzy.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { fuzzyFilter, fuzzyScore } from './fuzzy'; + +const rank = (names: string[], query: string) => fuzzyFilter(names, query, (name) => name); + +describe('fuzzy dataset ranking', () => { + it('ranks exact, prefix, substring, then subsequence matches ignoring case', () => { + expect(rank(['a-p-p', 'my-app-logs', 'application', 'APP', 'unrelated'], 'app')).toEqual([ + 'APP', + 'application', + 'my-app-logs', + 'a-p-p', + ]); + }); + it('preserves incoming order for empty queries and ties', () => { + expect(rank(['z', '', 'a'], ' ')).toEqual(['z', '', 'a']); + expect(rank(['APP', 'app'], 'app')).toEqual(['APP', 'app']); + }); + it('finds minor substitutions, omissions, and transpositions after direct matches', () => { + expect(rank(['app-logs', 'app-lgos'], 'app-lgos')).toEqual(['app-lgos', 'app-logs']); + expect(fuzzyScore('app-logz', 'app-logs')).not.toBeNull(); + expect(fuzzyScore('app-logss', 'app-logs')).not.toBeNull(); + expect(fuzzyScore('unrelated', 'app-logs')).toBeNull(); + expect(fuzzyScore('xy', 'xz')).toBeNull(); + }); + it('matches word and separator fragments and retains original items', () => { + const items = [ + { name: 'production-api-logs', id: 1 }, + { name: 'billing-events', id: 2 }, + ]; + expect(fuzzyFilter(items, 'p-a-l', (item) => item.name)).toEqual([items[0]]); + expect(fuzzyFilter(items, 'events', (item) => item.name)).toEqual([items[1]]); + }); +}); diff --git a/frontend/src/lib/fuzzy.ts b/frontend/src/lib/fuzzy.ts new file mode 100644 index 000000000..a02ccc312 --- /dev/null +++ b/frontend/src/lib/fuzzy.ts @@ -0,0 +1,62 @@ +/** Stable, case-insensitive ranking for names. Lower scores are better. */ +export function fuzzyScore(query: string, candidate: string): number | null { + const needle = query.trim().toLowerCase(); + const name = candidate.toLowerCase(); + if (!needle) return 0; + if (name === needle) return 0; + if (name.startsWith(needle)) return 100 + (name.length - needle.length) / (name.length + 1); + const position = name.indexOf(needle); + if (position >= 0) return 200 + position / (name.length + 1); + let cursor = 0; + let first = -1; + let last = -1; + for (const char of needle) { + const next = name.indexOf(char, cursor); + if (next < 0) { + cursor = -1; + break; + } + if (first < 0) first = next; + last = next; + cursor = next + 1; + } + if (cursor >= 0) + return ( + 300 + (last - first - needle.length + 1) / (name.length + 1) + first / (name.length + 1) ** 2 + ); + // Limit typo matching to substantive queries and similarly sized names. + const limit = needle.length >= 8 ? 2 : 1; + if (needle.length < 4 || Math.abs(name.length - needle.length) > limit) return null; + let previous = Array.from({ length: name.length + 1 }, (_, index) => index); + let beforePrevious = previous; + for (let row = 1; row <= needle.length; row++) { + const current = [row]; + for (let column = 1; column <= name.length; column++) { + current[column] = Math.min( + current[column - 1] + 1, + previous[column] + 1, + previous[column - 1] + (needle[row - 1] === name[column - 1] ? 0 : 1), + ); + if ( + row > 1 && + column > 1 && + needle[row - 1] === name[column - 2] && + needle[row - 2] === name[column - 1] + ) { + current[column] = Math.min(current[column], beforePrevious[column - 2] + 1); + } + } + beforePrevious = previous; + previous = current; + } + const distance = previous[name.length]; + return distance <= limit ? 400 + distance : null; +} + +export function fuzzyFilter(items: readonly T[], query: string, text: (item: T) => string): T[] { + return items + .map((item, index) => ({ item, index, score: fuzzyScore(query, text(item)) })) + .filter((entry): entry is typeof entry & { score: number } => entry.score !== null) + .sort((a, b) => a.score - b.score || a.index - b.index) + .map(({ item }) => item); +} diff --git a/frontend/story-tests/searchable-select.spec.ts b/frontend/story-tests/searchable-select.spec.ts new file mode 100644 index 000000000..92dd61269 --- /dev/null +++ b/frontend/story-tests/searchable-select.spec.ts @@ -0,0 +1,167 @@ +import { test, expect } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; + +const story = (name: string) => `/iframe.html?id=prism-searchableselect--${name}&viewMode=story`; + +test('query stays separate, keyboard selection, empty choice, and dismissal', async ({ page }) => { + await page.goto(story('datasets')); + const input = page.getByRole('combobox', { name: 'Dataset' }); + await expect(input).toHaveValue('app-logs'); + await input.click(); + await expect(input).toHaveValue(''); + await expect(page.getByRole('status')).toContainText('Showing 100 of 154'); + await input.fill('app-lgos'); + await expect(page.getByRole('option', { name: 'app-logs', exact: true })).toBeVisible(); + await input.press('Enter'); + await expect(page.getByTestId('committed')).toHaveText('app-logs'); + await expect(page.getByRole('listbox')).toBeVisible(); + await input.fill('audit'); + await input.press('ArrowDown'); + await input.press('Enter'); + await expect(input).toHaveValue('audit-events'); + await expect(page.getByTestId('committed')).toHaveText('audit-events'); + await input.click(); + await input.fill('absolutely-no-such-dataset'); + await expect(page.getByRole('status')).toHaveText('No matching datasets'); + await input.press('Tab'); + await expect(input).toHaveValue('audit-events'); + await expect(page.getByRole('button', { name: 'Next control' })).toBeFocused(); + await input.click(); + await page.getByRole('option', { name: 'No dataset', exact: true }).click(); + await expect(page.getByTestId('committed')).toHaveText('(empty)'); + await expect(input).toBeFocused(); + await input.click(); + await input.fill('audit'); + await page.getByRole('button', { name: 'Outside control' }).click(); + await expect(input).toHaveValue('No dataset'); + await expect(page.getByRole('listbox')).toHaveCount(0); +}); + +test('Escape dismisses picker before its parent dialog', async ({ page }) => { + await page.goto(story('in-dialog')); + const input = page.getByRole('combobox', { name: 'Dataset' }); + await input.click(); + await input.fill('audit'); + await input.press('Escape'); + await expect(page.getByRole('dialog')).toBeVisible(); + await expect(page.getByRole('listbox')).toHaveCount(0); + await expect(input).toHaveValue('app-logs'); + await input.press('Escape'); + await expect(page.getByRole('dialog')).toHaveCount(0); +}); + +test('saved unavailable dataset is visible and retry is actionable', async ({ page }) => { + await page.goto(story('unavailable')); + await expect(page.getByRole('combobox')).toHaveValue('deleted-logs (unavailable)'); + await page.goto(story('load-failure')); + await page.getByRole('combobox').click(); + await expect(page.getByRole('alert')).toHaveText('Could not load datasets.'); + await page.getByRole('button', { name: 'Retry loading datasets' }).click(); + await expect(page.getByTestId('retries')).toHaveText('1'); +}); + +for (const theme of ['light', 'dark']) + test(`open picker passes axe in ${theme}`, async ({ page }) => { + await page.goto(story('datasets')); + await page.evaluate((theme) => { + document.documentElement.dataset.theme = theme; + }, theme); + await page.getByRole('combobox').click(); + expect( + (await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa']).analyze()) + .violations, + ).toEqual([]); + }); + +test('async replacement clears active option rather than committing its replacement', async ({ + page, +}) => { + await page.goto(story('async-options')); + await page.clock.install(); + await page.getByRole('button', { name: 'Refresh datasets' }).click(); + const input = page.getByRole('combobox'); + await input.click(); + await input.press('ArrowDown'); + await input.press('ArrowDown'); + await expect(input).toHaveAttribute('aria-activedescendant', /options-1$/); + await page.clock.fastForward(1000); + await expect(page.getByRole('option', { name: 'production-api' })).toBeVisible(); + await expect(input).not.toHaveAttribute('aria-activedescendant'); + await input.press('Enter'); + await expect(page.getByTestId('committed')).toHaveText('app-logs'); + await expect(page.getByRole('listbox')).toBeVisible(); +}); + +test('touch movement does not activate options while scrolling', async ({ page }) => { + await page.goto(story('datasets')); + const input = page.getByRole('combobox'); + await input.click(); + await page + .getByRole('option', { name: 'audit-events', exact: true }) + .dispatchEvent('pointermove', { pointerType: 'touch' }); + await expect(input).not.toHaveAttribute('aria-activedescendant'); +}); + +test('reselecting committed value closes without emitting change', async ({ page }) => { + await page.goto(story('datasets')); + const input = page.getByRole('combobox'); + await input.click(); + await page.getByRole('option', { name: 'app-logs', exact: true }).click(); + await expect(page.getByTestId('changes')).toHaveText('0'); + await expect(page.getByRole('listbox')).toHaveCount(0); + await expect(input).toBeFocused(); + await input.click(); + await page.getByRole('option', { name: 'audit-events', exact: true }).click(); + await expect(page.getByTestId('changes')).toHaveText('1'); +}); + +test('retry is reachable by Tab with popup closed and returns focus to input', async ({ page }) => { + await page.goto(story('load-failure')); + const input = page.getByRole('combobox'); + const retry = page.getByRole('button', { name: 'Retry loading datasets' }); + await expect(retry).toBeVisible(); + await input.click(); + await input.press('Tab'); + await expect(page.getByRole('listbox')).toHaveCount(0); + await expect(retry).toBeFocused(); + await retry.press('Enter'); + await expect(page.getByTestId('retries')).toHaveText('1'); + await expect(input).toBeFocused(); + await expect(input).toHaveAttribute('aria-expanded', 'true'); + await expect(page.getByRole('status')).toHaveText('No matching datasets'); + await retry.click(); + await expect(page.getByTestId('retries')).toHaveText('2'); + await expect(input).toBeFocused(); +}); + +test('status click and list scrolling retain input focus', async ({ page }) => { + await page.goto(story('datasets')); + const input = page.getByRole('combobox'); + await input.click(); + await page.getByRole('status').click(); + await expect(input).toBeFocused(); + await expect(page.getByRole('listbox')).toBeVisible(); + const listbox = page.getByRole('listbox'); + await listbox.hover(); + await page.mouse.wheel(0, 300); + await expect.poll(() => listbox.evaluate((element) => element.scrollTop)).toBeGreaterThan(0); + await expect(input).toBeFocused(); + await input.fill('audit'); + await expect(page.getByRole('option', { name: 'audit-events', exact: true })).toBeVisible(); +}); + +test('retry keeps input focus when button becomes disabled during loading', async ({ page }) => { + await page.goto(story('retry-loading')); + await page.clock.install(); + const input = page.getByRole('combobox'); + const retry = page.getByRole('button', { name: 'Retry loading datasets' }); + await retry.click(); + await expect(retry).toBeDisabled(); + await expect(input).toBeFocused(); + await expect(input).toHaveAttribute('aria-busy', 'true'); + await expect(page.getByRole('status')).toHaveText('Loading datasets…'); + await page.clock.fastForward(1000); + await expect(retry).toHaveCount(0); + await expect(page.getByRole('option', { name: 'audit-events', exact: true })).toBeVisible(); + await expect(input).toBeFocused(); +});