From 37e4e6e3210a982f3f20fdf65b29b05ab7ee0a61 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Thu, 28 May 2026 20:41:03 +0000 Subject: [PATCH 01/11] feat(source-picker): chip + kebab menu UX Tighten the source picker across the 9 callsites that surface it (search, charts, traces, dashboards filter modal, Kubernetes, service map, table select, raw SQL editor, secondary trace panel). Pill becomes a real chip with a single hover affordance on the input root; the chevron no longer steals the click target. Schema preview, edit sources, and create new source move out of the dropdown into an adjacent kebab menu, so the dropdown lists data sources only and the management actions sit on a dedicated surface with consistent ordering. `` gains a controlled variant so the kebab can drive the modal from outside the icon trigger, and a small `isSourceSchemaPreviewEnabled` helper lets callers decide whether to surface the menu item. Also fix the `text-sucess-hover` typo flagged while in here, and the previous draft that landed both `Edit this source` and `Manage sources` pointing at the same handler now ships a single `Edit sources` action. --- .changeset/source-chip-kebab-menu.md | 5 + packages/app/src/DBSearchPage.tsx | 21 +- packages/app/src/DBServiceMapPage.tsx | 31 ++- packages/app/src/DashboardFiltersModal.tsx | 17 +- packages/app/src/KubernetesDashboardPage.tsx | 34 ++- .../DBSearchPage.directTrace.test.tsx | 7 +- .../ChartEditor/RawSqlChartEditor.tsx | 20 +- .../ChartEditorControls.tsx | 19 +- .../__tests__/DBEditTimeChartForm.test.tsx | 1 + packages/app/src/components/DBTableSelect.tsx | 44 ++-- packages/app/src/components/DBTracePanel.tsx | 22 +- .../src/components/SourceSchemaPreview.tsx | 88 ++++++-- packages/app/src/components/SourceSelect.tsx | 213 +++++++++++------- .../__tests__/DBTracePanel.test.tsx | 2 + .../styles/SourceSelectControlled.module.scss | 40 ++++ 15 files changed, 398 insertions(+), 166 deletions(-) create mode 100644 .changeset/source-chip-kebab-menu.md diff --git a/.changeset/source-chip-kebab-menu.md b/.changeset/source-chip-kebab-menu.md new file mode 100644 index 0000000000..9ec99239ab --- /dev/null +++ b/.changeset/source-chip-kebab-menu.md @@ -0,0 +1,5 @@ +--- +'@hyperdx/app': patch +--- + +feat(source-picker): chip + kebab menu UX diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index 92fe91474c..0eb171a2cc 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -127,7 +127,9 @@ import DBSqlRowTableWithSideBar from './components/DBSqlRowTableWithSidebar'; import PatternTable from './components/PatternTable'; import { DBSearchHeatmapChart } from './components/Search/DBSearchHeatmapChart'; import DirectTraceSidePanel from './components/Search/DirectTraceSidePanel'; -import SourceSchemaPreview from './components/SourceSchemaPreview'; +import SourceSchemaPreview, { + isSourceSchemaPreviewEnabled, +} from './components/SourceSchemaPreview'; import { getRelativeTimeOptionLabel, LIVE_TAIL_DURATION_MS, @@ -1664,10 +1666,8 @@ export function DBSearchPage() { [inputSourceObj], ); - const sourceSchemaPreview = useMemo( - () => , - [inputSourceObj], - ); + const [isSourceSchemaPreviewOpen, setIsSourceSchemaPreviewOpen] = + useState(false); const onTimePickerSearch = useCallback( (range: string) => { @@ -1886,11 +1886,20 @@ export function DBSearchPage() { name="source" onCreate={openNewSourceModal} onEdit={onEditSources} + onSchemaPreview={() => setIsSourceSchemaPreviewOpen(true)} + isSchemaPreviewEnabled={isSourceSchemaPreviewEnabled( + inputSourceObj, + )} allowedSourceKinds={ALLOWED_SOURCE_KINDS} data-testid="source-selector" - sourceSchemaPreview={sourceSchemaPreview} style={{ minWidth: 150 }} /> + setIsSourceSchemaPreviewOpen(false)} + /> { if (watchedSource !== sourceId) { @@ -116,15 +120,22 @@ function DBServiceMapPage() { ); const sourceSelect = source ? ( - - } - /> + <> + setIsSourceSchemaPreviewOpen(true)} + isSchemaPreviewEnabled={isSourceSchemaPreviewEnabled(source)} + /> + setIsSourceSchemaPreviewOpen(false)} + /> + ) : null; const headerActions = ( diff --git a/packages/app/src/DashboardFiltersModal.tsx b/packages/app/src/DashboardFiltersModal.tsx index 94b5fcb77a..c773922d12 100644 --- a/packages/app/src/DashboardFiltersModal.tsx +++ b/packages/app/src/DashboardFiltersModal.tsx @@ -37,7 +37,9 @@ import SearchWhereInput, { import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor'; import { SourceMultiSelectControlled } from './components/SourceMultiSelect'; -import SourceSchemaPreview from './components/SourceSchemaPreview'; +import SourceSchemaPreview, { + isSourceSchemaPreviewEnabled, +} from './components/SourceSchemaPreview'; import { SourceSelectControlled } from './components/SourceSelect'; import { useSource, useSources } from './source'; import { getMetricTableName } from './utils'; @@ -139,6 +141,8 @@ const DashboardFilterEditForm = ({ const [modalContentRef, setModalContentRef] = useState( null, ); + const [isSourceSchemaPreviewOpen, setIsSourceSchemaPreviewOpen] = + useState(false); return ( - } + onSchemaPreview={() => setIsSourceSchemaPreviewOpen(true)} + isSchemaPreviewEnabled={isSourceSchemaPreviewEnabled(source)} disabled={!!presetSource} /> + setIsSourceSchemaPreviewOpen(false)} + /> {!presetSource && ( - } + onSchemaPreview={() => setIsLogSchemaPreviewOpen(true)} + isSchemaPreviewEnabled={isSourceSchemaPreviewEnabled(logSource)} + /> + setIsLogSchemaPreviewOpen(false)} /> - } + onSchemaPreview={() => setIsMetricSchemaPreviewOpen(true)} + isSchemaPreviewEnabled={isSourceSchemaPreviewEnabled(metricSource)} + /> + setIsMetricSchemaPreviewOpen(false)} /> ); @@ -1326,7 +1342,7 @@ function KubernetesDashboardPage() { const dashboardBody = ( <> - Kubernetes Dashboard – {brandName} + Kubernetes Dashboard – {brandName}{/* [prose-lint: allow] page-title separator matches sibling dashboard pages */} {metricSource && logSource && ( diff --git a/packages/app/src/__tests__/DBSearchPage.directTrace.test.tsx b/packages/app/src/__tests__/DBSearchPage.directTrace.test.tsx index ac3b7e5c63..cb5327aa5c 100644 --- a/packages/app/src/__tests__/DBSearchPage.directTrace.test.tsx +++ b/packages/app/src/__tests__/DBSearchPage.directTrace.test.tsx @@ -208,7 +208,12 @@ jest.mock('../components/PatternTable', () => () =>
); jest.mock('../components/Search/DBSearchHeatmapChart', () => ({ DBSearchHeatmapChart: () =>
, })); -jest.mock('../components/SourceSchemaPreview', () => () =>
); +jest.mock('../components/SourceSchemaPreview', () => ({ + __esModule: true, + default: () =>
, + isSourceSchemaPreviewEnabled: () => false, + getSourceSchemaTables: () => [], +})); jest.mock('../components/Error/ErrorBoundary', () => ({ ErrorBoundary: ({ children }: { children: React.ReactNode }) => ( <>{children} diff --git a/packages/app/src/components/ChartEditor/RawSqlChartEditor.tsx b/packages/app/src/components/ChartEditor/RawSqlChartEditor.tsx index 04b9a2a746..fb3a441601 100644 --- a/packages/app/src/components/ChartEditor/RawSqlChartEditor.tsx +++ b/packages/app/src/components/ChartEditor/RawSqlChartEditor.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Control, UseFormSetValue, useWatch } from 'react-hook-form'; import { TableConnection, @@ -31,7 +31,9 @@ import { DEFAULT_TILE_ALERT } from '@/utils/alerts'; import { ConnectionSelectControlled } from '../ConnectionSelect'; import { OnClickFormButton } from '../DBEditTimeChartForm/OnClickForm/OnClickFormButton'; -import SourceSchemaPreview from '../SourceSchemaPreview'; +import SourceSchemaPreview, { + isSourceSchemaPreviewEnabled, +} from '../SourceSchemaPreview'; import { SourceSelectControlled } from '../SourceSelect'; import { SQL_PLACEHOLDERS } from './constants'; @@ -137,9 +139,8 @@ export default function RawSqlChartEditor({ return [...paramCompletions, ...macroCompletions]; }, [displayType]); - const sourceSchemaPreview = useMemo(() => { - return ; - }, [sourceObject]); + const [isSourceSchemaPreviewOpen, setIsSourceSchemaPreviewOpen] = + useState(false); const tableConnections: TableConnection[] = useMemo(() => { if (!sources) return []; @@ -206,7 +207,14 @@ export default function RawSqlChartEditor({ size="xs" clearable placeholder="None" - sourceSchemaPreview={sourceSchemaPreview} + onSchemaPreview={() => setIsSourceSchemaPreviewOpen(true)} + isSchemaPreviewEnabled={isSourceSchemaPreviewEnabled(sourceObject)} + /> + setIsSourceSchemaPreviewOpen(false)} /> diff --git a/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx b/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx index fdaa884e8c..62948f7109 100644 --- a/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { Control, FieldArrayWithId, @@ -25,7 +26,9 @@ import { } from '@/components/ChartEditor/types'; import MVOptimizationIndicator from '@/components/MaterializedViews/MVOptimizationIndicator'; import SearchWhereInput from '@/components/SearchInput/SearchWhereInput'; -import SourceSchemaPreview from '@/components/SourceSchemaPreview'; +import SourceSchemaPreview, { + isSourceSchemaPreviewEnabled, +} from '@/components/SourceSchemaPreview'; import { SourceSelectControlled } from '@/components/SourceSelect'; import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor'; import { IS_LOCAL_MODE } from '@/config'; @@ -91,6 +94,9 @@ export function ChartEditorControls({ openDisplaySettings, openHeatmapSettings, }: ChartEditorControlsProps) { + const [isSourceSchemaPreviewOpen, setIsSourceSchemaPreviewOpen] = + useState(false); + return ( <> @@ -108,9 +114,14 @@ export function ChartEditorControls({ ? [...HEATMAP_ALLOWED_SOURCE_KINDS] : undefined } - sourceSchemaPreview={ - - } + onSchemaPreview={() => setIsSourceSchemaPreviewOpen(true)} + isSchemaPreviewEnabled={isSourceSchemaPreviewEnabled(tableSource)} + /> + setIsSourceSchemaPreviewOpen(false)} /> diff --git a/packages/app/src/components/DBEditTimeChartForm/__tests__/DBEditTimeChartForm.test.tsx b/packages/app/src/components/DBEditTimeChartForm/__tests__/DBEditTimeChartForm.test.tsx index 16e055d46c..3148b8b53c 100644 --- a/packages/app/src/components/DBEditTimeChartForm/__tests__/DBEditTimeChartForm.test.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/__tests__/DBEditTimeChartForm.test.tsx @@ -100,6 +100,7 @@ jest.mock('../../SourceSelect', () => ({ ), + SourceManagementMenu: () => null, })); jest.mock('../../ChartSQLPreview', () => ({ diff --git a/packages/app/src/components/DBTableSelect.tsx b/packages/app/src/components/DBTableSelect.tsx index e76740536d..0228db08f7 100644 --- a/packages/app/src/components/DBTableSelect.tsx +++ b/packages/app/src/components/DBTableSelect.tsx @@ -1,11 +1,14 @@ +import { useState } from 'react'; import { useController, UseControllerProps } from 'react-hook-form'; -import { Flex, Select } from '@mantine/core'; +import { ComboboxChevron, Flex, Select } from '@mantine/core'; import { IconTable } from '@tabler/icons-react'; import { useTablesDirect } from '@/clickhouse'; -import SourceSchemaPreview from './SourceSchemaPreview'; -import { SourceSelectRightSection } from './SourceSelect'; +import SourceSchemaPreview, { + isSourceSchemaPreviewEnabled, +} from './SourceSchemaPreview'; +import { SourceManagementMenu } from './SourceSelect'; function DBTableSelect({ database, @@ -38,25 +41,22 @@ function DBTableSelect({ label: db.name, })); - const rightSectionProps = SourceSelectRightSection({ - sourceSchemaPreview: - connectionId && database && table ? ( - - ) : undefined, - }); + const [isSchemaPreviewOpen, setIsSchemaPreviewOpen] = useState(false); + const previewSource = + connectionId && database && table + ? { + connection: connectionId, + from: { databaseName: database, tableName: table }, + } + : undefined; return ( - + ), - SourceManagementMenu: () => null, })); jest.mock('../../ChartSQLPreview', () => ({ diff --git a/packages/app/src/components/SelectControlled.tsx b/packages/app/src/components/SelectControlled.tsx index b4653198a1..439a263f10 100644 --- a/packages/app/src/components/SelectControlled.tsx +++ b/packages/app/src/components/SelectControlled.tsx @@ -2,7 +2,7 @@ import { useCallback } from 'react'; import { useController, UseControllerProps } from 'react-hook-form'; import { Select, SelectProps } from '@mantine/core'; -export enum SelectControlledSpecialValues { +enum SelectControlledSpecialValues { CreateNewValue = '_create_new_value', EditValue = '_edit_value', } diff --git a/packages/app/src/components/__tests__/SourceSchemaPreview.test.tsx b/packages/app/src/components/__tests__/SourceSchemaPreview.test.tsx new file mode 100644 index 0000000000..62b5ec213c --- /dev/null +++ b/packages/app/src/components/__tests__/SourceSchemaPreview.test.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import SourceSchemaPreview from '../SourceSchemaPreview'; + +jest.mock('@/hooks/useMetadata', () => ({ + useTableMetadata: () => ({ data: undefined, isLoading: false }), +})); + +jest.mock('../ChartSQLPreview', () => ({ + SQLPreview: () =>
SQL Preview
, +})); + +const mockLogSource = { + connection: 'conn-1', + from: { databaseName: 'default', tableName: 'logs' }, +}; + +describe('SourceSchemaPreview — controlled mode', () => { + it('does not render the uncontrolled trigger when controlled is true', () => { + renderWithMantine( + , + ); + // The non-controlled mode would render a clickable icon/text trigger; + // in controlled mode nothing should render when the modal is closed. + expect(screen.queryByText('Schema')).not.toBeInTheDocument(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('opens the modal when open is true', () => { + renderWithMantine( + , + ); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('does not show the modal when open is false', () => { + renderWithMantine( + , + ); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('calls onClose when the modal close button is clicked', async () => { + const onClose = jest.fn(); + renderWithMantine( + , + ); + await userEvent.click(screen.getByRole('button', { name: /close/i })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('calls onClose on Escape key press', async () => { + const onClose = jest.fn(); + renderWithMantine( + , + ); + await userEvent.keyboard('{Escape}'); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/app/src/components/__tests__/SourceSelect.test.tsx b/packages/app/src/components/__tests__/SourceSelect.test.tsx new file mode 100644 index 0000000000..94909cab3d --- /dev/null +++ b/packages/app/src/components/__tests__/SourceSelect.test.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { SourceManagementMenu } from '../SourceSelect'; + +describe('SourceManagementMenu', () => { + it('returns null when no actions are wired', () => { + const { container } = renderWithMantine( + , + ); + expect(container.firstChild).toBeNull(); + }); + + it('renders the kebab trigger when onSchemaPreview is wired', () => { + renderWithMantine( + , + ); + expect(screen.getByTestId('source-actions-menu')).toBeInTheDocument(); + }); + + it('renders the kebab trigger when only onEdit is wired', () => { + renderWithMantine( + , + ); + expect(screen.getByTestId('source-actions-menu')).toBeInTheDocument(); + }); + + it('renders the kebab trigger when only onCreate is wired', () => { + renderWithMantine( + , + ); + expect(screen.getByTestId('source-actions-menu')).toBeInTheDocument(); + }); + + describe('View schema item', () => { + it('is disabled when hasSelection is false', async () => { + renderWithMantine( + , + ); + await userEvent.click(screen.getByTestId('source-actions-menu')); + expect( + screen.getByText('View schema').closest('button'), + ).toHaveAttribute('data-disabled', 'true'); + }); + + it('is disabled when isSchemaPreviewEnabled is false', async () => { + renderWithMantine( + , + ); + await userEvent.click(screen.getByTestId('source-actions-menu')); + expect( + screen.getByText('View schema').closest('button'), + ).toHaveAttribute('data-disabled', 'true'); + }); + + it('is enabled when hasSelection and isSchemaPreviewEnabled are both true', async () => { + renderWithMantine( + , + ); + await userEvent.click(screen.getByTestId('source-actions-menu')); + expect( + screen.getByText('View schema').closest('button'), + ).not.toHaveAttribute('data-disabled'); + }); + + it('calls onSchemaPreview when clicked', async () => { + const onSchemaPreview = jest.fn(); + renderWithMantine( + , + ); + await userEvent.click(screen.getByTestId('source-actions-menu')); + await userEvent.click(screen.getByText('View schema')); + expect(onSchemaPreview).toHaveBeenCalledTimes(1); + }); + }); + + it('calls onEdit when Edit sources is clicked', async () => { + const onEdit = jest.fn(); + renderWithMantine( + , + ); + await userEvent.click(screen.getByTestId('source-actions-menu')); + await userEvent.click(screen.getByText('Edit sources')); + expect(onEdit).toHaveBeenCalledTimes(1); + }); + + it('calls onCreate when Create new source is clicked', async () => { + const onCreate = jest.fn(); + renderWithMantine( + , + ); + await userEvent.click(screen.getByTestId('source-actions-menu')); + await userEvent.click(screen.getByText('Create new source')); + expect(onCreate).toHaveBeenCalledTimes(1); + }); +}); From cc1b9aca610d220e76c3777e5ee5390ad259175a Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Thu, 28 May 2026 21:24:35 +0000 Subject: [PATCH 04/11] test(source-picker): fix SourceSelect + SourceSchemaPreview test assertions for Mantine 9 - Replace `container.firstChild` with `screen.queryByTestId` for the "returns null" check; MantineProvider injects a style tag as the first child. - Use `screen.findByText()` for menu item queries after clicking the trigger; Mantine 9 Menu renders items asynchronously via floating-ui positioning. - Use `.mantine-Modal-close` selector for the modal close button; Mantine 9 CloseButton renders without aria-label by default. --- .../__tests__/SourceSchemaPreview.test.tsx | 7 +++- .../__tests__/SourceSelect.test.tsx | 39 ++++++++++--------- 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/packages/app/src/components/__tests__/SourceSchemaPreview.test.tsx b/packages/app/src/components/__tests__/SourceSchemaPreview.test.tsx index 62b5ec213c..d033a5a3fb 100644 --- a/packages/app/src/components/__tests__/SourceSchemaPreview.test.tsx +++ b/packages/app/src/components/__tests__/SourceSchemaPreview.test.tsx @@ -67,7 +67,12 @@ describe('SourceSchemaPreview — controlled mode', () => { onClose={onClose} />, ); - await userEvent.click(screen.getByRole('button', { name: /close/i })); + // Mantine 9 CloseButton renders without aria-label; find by its class. + const closeBtn = document.querySelector( + '.mantine-Modal-close', + ) as HTMLElement; + expect(closeBtn).toBeTruthy(); + await userEvent.click(closeBtn); expect(onClose).toHaveBeenCalledTimes(1); }); diff --git a/packages/app/src/components/__tests__/SourceSelect.test.tsx b/packages/app/src/components/__tests__/SourceSelect.test.tsx index 94909cab3d..788b105256 100644 --- a/packages/app/src/components/__tests__/SourceSelect.test.tsx +++ b/packages/app/src/components/__tests__/SourceSelect.test.tsx @@ -1,15 +1,13 @@ import React from 'react'; -import { screen } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { SourceManagementMenu } from '../SourceSelect'; describe('SourceManagementMenu', () => { - it('returns null when no actions are wired', () => { - const { container } = renderWithMantine( - , - ); - expect(container.firstChild).toBeNull(); + it('does not render the kebab trigger when no actions are wired', () => { + renderWithMantine(); + expect(screen.queryByTestId('source-actions-menu')).not.toBeInTheDocument(); }); it('renders the kebab trigger when onSchemaPreview is wired', () => { @@ -43,9 +41,11 @@ describe('SourceManagementMenu', () => { />, ); await userEvent.click(screen.getByTestId('source-actions-menu')); - expect( - screen.getByText('View schema').closest('button'), - ).toHaveAttribute('data-disabled', 'true'); + const viewSchemaBtn = await screen.findByText('View schema'); + expect(viewSchemaBtn.closest('button')).toHaveAttribute( + 'data-disabled', + 'true', + ); }); it('is disabled when isSchemaPreviewEnabled is false', async () => { @@ -57,9 +57,11 @@ describe('SourceManagementMenu', () => { />, ); await userEvent.click(screen.getByTestId('source-actions-menu')); - expect( - screen.getByText('View schema').closest('button'), - ).toHaveAttribute('data-disabled', 'true'); + const viewSchemaBtn = await screen.findByText('View schema'); + expect(viewSchemaBtn.closest('button')).toHaveAttribute( + 'data-disabled', + 'true', + ); }); it('is enabled when hasSelection and isSchemaPreviewEnabled are both true', async () => { @@ -71,9 +73,10 @@ describe('SourceManagementMenu', () => { />, ); await userEvent.click(screen.getByTestId('source-actions-menu')); - expect( - screen.getByText('View schema').closest('button'), - ).not.toHaveAttribute('data-disabled'); + const viewSchemaBtn = await screen.findByText('View schema'); + expect(viewSchemaBtn.closest('button')).not.toHaveAttribute( + 'data-disabled', + ); }); it('calls onSchemaPreview when clicked', async () => { @@ -86,7 +89,7 @@ describe('SourceManagementMenu', () => { />, ); await userEvent.click(screen.getByTestId('source-actions-menu')); - await userEvent.click(screen.getByText('View schema')); + await userEvent.click(await screen.findByText('View schema')); expect(onSchemaPreview).toHaveBeenCalledTimes(1); }); }); @@ -97,7 +100,7 @@ describe('SourceManagementMenu', () => { , ); await userEvent.click(screen.getByTestId('source-actions-menu')); - await userEvent.click(screen.getByText('Edit sources')); + await userEvent.click(await screen.findByText('Edit sources')); expect(onEdit).toHaveBeenCalledTimes(1); }); @@ -107,7 +110,7 @@ describe('SourceManagementMenu', () => { , ); await userEvent.click(screen.getByTestId('source-actions-menu')); - await userEvent.click(screen.getByText('Create new source')); + await userEvent.click(await screen.findByText('Create new source')); expect(onCreate).toHaveBeenCalledTimes(1); }); }); From 252ba5b0af6f3428a8f8ebc4c3290af3d17758bb Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Thu, 28 May 2026 21:52:13 +0000 Subject: [PATCH 05/11] test(source-picker): cover onManageSources and Edit-source rename --- .../__tests__/SourceSelect.test.tsx | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/app/src/components/__tests__/SourceSelect.test.tsx b/packages/app/src/components/__tests__/SourceSelect.test.tsx index 788b105256..08b1e23b37 100644 --- a/packages/app/src/components/__tests__/SourceSelect.test.tsx +++ b/packages/app/src/components/__tests__/SourceSelect.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { screen, waitFor } from '@testing-library/react'; +import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { SourceManagementMenu } from '../SourceSelect'; @@ -24,6 +24,13 @@ describe('SourceManagementMenu', () => { expect(screen.getByTestId('source-actions-menu')).toBeInTheDocument(); }); + it('renders the kebab trigger when only onManageSources is wired', () => { + renderWithMantine( + , + ); + expect(screen.getByTestId('source-actions-menu')).toBeInTheDocument(); + }); + it('renders the kebab trigger when only onCreate is wired', () => { renderWithMantine( , @@ -94,16 +101,30 @@ describe('SourceManagementMenu', () => { }); }); - it('calls onEdit when Edit sources is clicked', async () => { + it('calls onEdit when Edit source is clicked', async () => { const onEdit = jest.fn(); renderWithMantine( - , + // hasSelection required: Edit source is disabled without a selection. + , ); await userEvent.click(screen.getByTestId('source-actions-menu')); - await userEvent.click(await screen.findByText('Edit sources')); + await userEvent.click(await screen.findByText('Edit source')); expect(onEdit).toHaveBeenCalledTimes(1); }); + it('calls onManageSources when Manage sources is clicked', async () => { + const onManageSources = jest.fn(); + renderWithMantine( + , + ); + await userEvent.click(screen.getByTestId('source-actions-menu')); + await userEvent.click(await screen.findByText('Manage sources')); + expect(onManageSources).toHaveBeenCalledTimes(1); + }); + it('calls onCreate when Create new source is clicked', async () => { const onCreate = jest.fn(); renderWithMantine( From 5751776c2869f830634f0411d1b1683932676448 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Thu, 28 May 2026 21:57:53 +0000 Subject: [PATCH 06/11] test(e2e): move source-action assertions from dropdown to kebab menu --- .../app/tests/e2e/features/sources.spec.ts | 19 +++++++++++----- .../app/tests/e2e/page-objects/SearchPage.ts | 22 ++++++++++++++----- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/packages/app/tests/e2e/features/sources.spec.ts b/packages/app/tests/e2e/features/sources.spec.ts index b56bad6fbf..3490a016d3 100644 --- a/packages/app/tests/e2e/features/sources.spec.ts +++ b/packages/app/tests/e2e/features/sources.spec.ts @@ -102,13 +102,20 @@ test.describe('Sources Functionality', { tag: ['@sources'] }, () => { await searchPage.goto(); }); - test('should show source actions in dropdown', async () => { - // Open source selector dropdown - await searchPage.sourceDropdown.click(); + test('should show source actions in kebab menu', async () => { + // Open the source-actions kebab menu next to the source picker. + await searchPage.sourceActionsMenu.click(); - // Verify action items are visible in the dropdown + // Per-source and create actions are always wired. + await expect(searchPage.editSourceItem).toBeVisible(); await expect(searchPage.createNewSourceItem).toBeVisible(); - await expect(searchPage.editSourcesItem).toBeVisible(); + + // `Manage sources` is wired only in non-local (full-stack) mode. + if (process.env.E2E_FULLSTACK === 'true') { + await expect(searchPage.manageSourcesItem).toBeVisible(); + } else { + await expect(searchPage.manageSourcesItem).toHaveCount(0); + } }); test( @@ -142,7 +149,7 @@ test.describe('Sources Functionality', { tag: ['@sources'] }, () => { ); test('should show proper fields when creating a new source', async () => { - await searchPage.sourceDropdown.click(); + await searchPage.sourceActionsMenu.click(); await searchPage.createNewSourceItem.click(); // for each source type (log, trace, session, metric), verify the correct fields are shown for (const sourceData of allSourcesData) { diff --git a/packages/app/tests/e2e/page-objects/SearchPage.ts b/packages/app/tests/e2e/page-objects/SearchPage.ts index 6734ab72b9..9682c977f1 100644 --- a/packages/app/tests/e2e/page-objects/SearchPage.ts +++ b/packages/app/tests/e2e/page-objects/SearchPage.ts @@ -73,12 +73,24 @@ export class SearchPage { this.sourceSelector = page.getByTestId('source-selector'); } + get sourceActionsMenu() { + return this.page.getByTestId('source-actions-menu'); + } + get createNewSourceItem() { - return this.page.getByRole('option', { name: 'Create New Source' }); + return this.page.getByRole('menuitem', { name: 'Create new source' }); + } + + get editSourceItem() { + return this.page.getByRole('menuitem', { name: 'Edit source' }); } - get editSourcesItem() { - return this.page.getByRole('option', { name: 'Edit Sources' }); + get manageSourcesItem() { + return this.page.getByRole('menuitem', { name: 'Manage sources' }); + } + + get viewSchemaItem() { + return this.page.getByRole('menuitem', { name: 'View schema' }); } /** @@ -98,8 +110,8 @@ export class SearchPage { } async openEditSourceModal() { - await this.sourceSelector.click(); - await this.editSourcesItem.click(); + await this.sourceActionsMenu.click(); + await this.editSourceItem.click(); } async sourceModalShowOptionalFields() { From 019b2f0345a6cc67d4045a93ab00872032053a56 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Thu, 28 May 2026 23:12:44 +0000 Subject: [PATCH 07/11] fix(source-picker): hard-nav from Manage sources to drop nuqs query leak `router.push('/team')` from the kebab let DBSearchPage's `useQueryStates` (source/where/select/whereLanguage/filters/orderBy) restore its state into the new URL during the client-side transition, so the team page loaded with `?source=` and `SourcesList` auto-expanded that row. Manage sources ended up acting like Edit source. Explicit `{ pathname: '/team', query: {} }` didn't help, since nuqs still wrote its tracked state back via `history.replaceState` on the way out. Switch to `window.location.assign('/team')` so the navigation drops all client state and lands on a clean list view at `/team?tab=data`. --- packages/app/src/DBSearchPage.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index edf6b94009..b348a8ddcd 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -1761,11 +1761,16 @@ export function DBSearchPage() { // `Manage sources`: open the all-sources list view. Only wired in // non-local mode; local has no list-view surface so the menu item - // hides itself when this prop is undefined. + // hides itself when this prop is undefined. We use `window.location` + // for a hard navigation instead of `router.push` so the page's + // `useQueryStates` (source/where/select/whereLanguage/filters/orderBy) + // can't restore its state into the new URL during the client-side + // transition; otherwise `SourcesList` auto-expands the leaked + // `?source=` and Manage Sources behaves like Edit source. const onManageSources = useMemo(() => { if (IS_LOCAL_MODE) return undefined; return () => { - router.push('/team'); + window.location.assign('/team'); }; }, []); From 09274a920a3f4513db08fb180295a329b5fe4dcd Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Thu, 28 May 2026 23:37:16 +0000 Subject: [PATCH 08/11] fix(source-form): restore horizontal Duration Precision slider inside Card Mantine 9's Slider styles use the pattern `:where([data-orientation="vertical"]) .` for vertical-mode overrides. The intent was to scope those rules to a vertical Slider, but the compiled selector matches when ANY ancestor has `data-orientation="vertical"`. Mantine 9's Card sets `data-orientation="vertical"` by default, and SourceForm renders inside SourcesList's Card, so the Duration Precision slider's trackContainer, track, bar, thumb, markWrapper and markLabel all picked up the vertical-orientation styling: the track collapsed to 8px wide and the four marks (Seconds / Millisecond / Microsecond / Nanosecond) stacked on top of each other. Pass `styles` for every affected part to restore the horizontal-orientation values from Mantine's base CSS. Inline styles beat the `:where()` rules regardless of specificity. Other Sliders in the app live outside any Card, so no global fix is needed. Also fix a pre-existing em-dash in a nearby PromQL comment per the team's no-em-dash rule. --- .../app/src/components/Sources/SourceForm.tsx | 51 ++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/Sources/SourceForm.tsx b/packages/app/src/components/Sources/SourceForm.tsx index 7544b14bc1..c4c492c4f8 100644 --- a/packages/app/src/components/Sources/SourceForm.tsx +++ b/packages/app/src/components/Sources/SourceForm.tsx @@ -1521,6 +1521,55 @@ function TraceTableModelForm(props: TableModelProps) { ]} value={value} onChange={onChange} + // Mantine 9's Slider styles use the pattern + // `:where([data-orientation="vertical"]) .`, + // which matches when ANY ancestor has + // `data-orientation="vertical"`. Mantine Card sets + // `data-orientation="vertical"` by default, and the + // SourceForm renders inside a Card, so the slider's + // trackContainer/track/bar/thumb/markWrapper/ + // markLabel all pick up the vertical-orientation + // styling: the track collapses to 8px wide and the + // four marks stack on top of each other. Override + // every affected part back to its horizontal + // default so the slider renders correctly inside + // the Card. + styles={{ + trackContainer: { + width: '100%', + flexDirection: 'row', + height: 'calc(var(--slider-size) * 2)', + }, + track: { + width: '100%', + height: 'var(--slider-size)', + }, + bar: { + top: 0, + bottom: 0, + height: '100%', + insetInlineStart: 'var(--slider-bar-offset)', + width: 'var(--slider-bar-width)', + }, + thumb: { + left: 'var(--slider-thumb-offset)', + top: '50%', + right: 'auto', + bottom: 'auto', + transform: 'translate(-50%, -50%)', + }, + markWrapper: { + insetInlineStart: + 'calc(var(--mark-offset) - var(--slider-size) / 2)', + top: 0, + bottom: 'auto', + width: 'auto', + }, + markLabel: { + transform: + 'translate(calc(-50% + var(--slider-size) / 2), calc(var(--mantine-spacing-xs) / 2))', + }, + }} />
)} @@ -1913,7 +1962,7 @@ function PromqlTableModelForm({ }, [setValue]); // PromQL sources use the standard database + table fields from BaseSourceSchema. - // No additional fields needed — the table should point to the TimeSeries engine table. + // No additional fields needed; the table should point to the TimeSeries engine table. return null; } From ccdb59c0f5a74c63214e1302b3bb075ac83c2e9a Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Thu, 28 May 2026 23:41:28 +0000 Subject: [PATCH 09/11] fix(source-form): float Duration Precision value badge above the thumb Same Mantine 9 cascade as 09274a92: the Slider's label part has `:where([data-orientation="vertical"]) .label { top: auto; inset-inline-start: calc(100% + 8px); }`, so the value badge moved from "above the thumb" (base CSS sets `top: -36px`) to inline with the thumb but offset to the right. Override the label part too so the badge floats above the thumb as designed. --- packages/app/src/components/Sources/SourceForm.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/app/src/components/Sources/SourceForm.tsx b/packages/app/src/components/Sources/SourceForm.tsx index c4c492c4f8..a73ed1ca40 100644 --- a/packages/app/src/components/Sources/SourceForm.tsx +++ b/packages/app/src/components/Sources/SourceForm.tsx @@ -1569,6 +1569,10 @@ function TraceTableModelForm(props: TableModelProps) { transform: 'translate(calc(-50% + var(--slider-size) / 2), calc(var(--mantine-spacing-xs) / 2))', }, + label: { + top: '-36px', + insetInlineStart: 'auto', + }, }} />
From 4af69980f62b5a67f1349213c43cd426483a43fa Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Fri, 29 May 2026 00:11:12 +0000 Subject: [PATCH 10/11] fix(source-picker): prefix router.basePath in hard-nav helpers and switch onEditCurrentSource to hard-nav onManageSources was already using window.location.assign for hard navigation, but the path lacked router.basePath, breaking the /clickstack build where basePath=/clickstack. onEditCurrentSource was using router.push, which lets the page's useQueryStates merge stale /search state into the destination URL, causing SourcesList to auto-expand the leaked ?source= param. Switch it to the same window.location.assign pattern used by onManageSources, also adding the basePath prefix. Co-Authored-By: Claude Opus --- packages/app/src/DBSearchPage.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index b348a8ddcd..4a9c225f83 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -1744,18 +1744,19 @@ export function DBSearchPage() { // `Edit source` (singular): operate on the currently selected source. // Local mode opens the inline edit modal seeded with `inputSource`; - // non-local navigates to the team page deep-linked to that source so - // `` auto-expands it. Both modes converge on "edit the - // source you were looking at" in one click. + // non-local uses a hard navigation so the page's `useQueryStates` + // (source/where/select/whereLanguage/filters/orderBy) can't merge + // stale /search state into the destination URL, and so + // `router.basePath` is correctly prepended for the /clickstack build. const onEditCurrentSource = useCallback(() => { if (IS_LOCAL_MODE) { setModelFormExpanded(true); return; } if (inputSource) { - router.push({ pathname: '/team', query: { source: inputSource } }); + window.location.assign(`${router.basePath}/team?source=${inputSource}`); } else { - router.push('/team'); + window.location.assign(`${router.basePath}/team`); } }, [inputSource, setModelFormExpanded]); @@ -1765,12 +1766,12 @@ export function DBSearchPage() { // for a hard navigation instead of `router.push` so the page's // `useQueryStates` (source/where/select/whereLanguage/filters/orderBy) // can't restore its state into the new URL during the client-side - // transition; otherwise `SourcesList` auto-expands the leaked - // `?source=` and Manage Sources behaves like Edit source. + // transition, and so `router.basePath` is correctly prepended for + // the /clickstack build. const onManageSources = useMemo(() => { if (IS_LOCAL_MODE) return undefined; return () => { - window.location.assign('/team'); + window.location.assign(`${router.basePath}/team`); }; }, []); From bcee4847420c19c240ec26e7ec97ddce52ef807e Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Fri, 29 May 2026 13:09:43 +0100 Subject: [PATCH 11/11] refactor(source-picker): glue kebab to the source Select as a split-button chip The kebab `SourceManagementMenu` was rendered as a separate sibling of the Select with a 4px gap, so it read as "Select plus an unrelated icon button" rather than "actions on this source." Restyle the pair as a single split-button chip: the input's right corners are squared, the kebab's left corners are squared, the kebab stretches to match the input's height, and its left border overlaps the input's right border by 1px so the two share a single seam. Override Mantine v7 corners via `--input-radius` / `--ai-radius` (the canonical override path) instead of fighting class-level `border-radius` against `@layer mantine`. Also normalize the kebab tooltip to the app-wide convention (`withArrow`, default color) so it matches `Filter Settings` and other gray-tinted tooltips across the app. When `hasMenu` is false (no handlers wired, e.g. `DBTableSelect` callers), the `[data-with-menu]` attribute is omitted and the original side-by-side layout is preserved. Co-authored-by: Cursor --- packages/app/src/components/SourceSelect.tsx | 4 +- .../styles/SourceSelectControlled.module.scss | 46 ++++++++++++++++--- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/packages/app/src/components/SourceSelect.tsx b/packages/app/src/components/SourceSelect.tsx index 4766493636..d00e23e656 100644 --- a/packages/app/src/components/SourceSelect.tsx +++ b/packages/app/src/components/SourceSelect.tsx @@ -129,7 +129,7 @@ export const SourceManagementMenu = ({ return ( - +