From 252ad4a9991d5218ea588bfab682baca0652b3d4 Mon Sep 17 00:00:00 2001 From: Tom Alexander Date: Wed, 3 Jun 2026 11:15:57 -0400 Subject: [PATCH] fix: prevent source setup dropdowns from being clipped by the modal (HDX-4445) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Database, Table, and Server Connection pickers in the source setup form used comboboxProps={{ withinPortal: false }}, so their dropdowns rendered inside the modal's DOM and were clipped by its bounds — with many databases the list was cut off at the modal edge. Render the dropdowns in a portal instead so the full list is visible and scrollable. Adds a component test that mounts each picker with a 20-item list and asserts every option is rendered AND rendered in a portal (outside the picker's own container). The portal check fails if withinPortal is flipped back to false, independent of how many options there are. --- .changeset/source-form-dropdown-clipping.md | 5 + .../app/src/components/ConnectionSelect.tsx | 2 +- packages/app/src/components/DBTableSelect.tsx | 2 +- .../app/src/components/DatabaseSelect.tsx | 2 +- .../sourceFormPickerDropdowns.test.tsx | 150 ++++++++++++++++++ 5 files changed, 158 insertions(+), 3 deletions(-) create mode 100644 .changeset/source-form-dropdown-clipping.md create mode 100644 packages/app/src/components/__tests__/sourceFormPickerDropdowns.test.tsx diff --git a/.changeset/source-form-dropdown-clipping.md b/.changeset/source-form-dropdown-clipping.md new file mode 100644 index 0000000000..08f54d1c83 --- /dev/null +++ b/.changeset/source-form-dropdown-clipping.md @@ -0,0 +1,5 @@ +--- +'@hyperdx/app': patch +--- + +Fix the database, table, and connection dropdowns being clipped inside the source setup modal. The dropdowns now render in a portal, so the full list is visible and scrollable when configuring or editing a source. diff --git a/packages/app/src/components/ConnectionSelect.tsx b/packages/app/src/components/ConnectionSelect.tsx index 476f254394..2422377cfb 100644 --- a/packages/app/src/components/ConnectionSelect.tsx +++ b/packages/app/src/components/ConnectionSelect.tsx @@ -26,7 +26,7 @@ export function ConnectionSelectControlled({ allowDeselect={false} data={values} // disabled={isDatabasesLoading} - comboboxProps={{ withinPortal: false }} + comboboxProps={{ withinPortal: true }} searchable placeholder="Connection" leftSection={} diff --git a/packages/app/src/components/DBTableSelect.tsx b/packages/app/src/components/DBTableSelect.tsx index 0228db08f7..8d715b6280 100644 --- a/packages/app/src/components/DBTableSelect.tsx +++ b/packages/app/src/components/DBTableSelect.tsx @@ -61,7 +61,7 @@ function DBTableSelect({ data={data} disabled={isTablesLoading} value={table} - comboboxProps={{ withinPortal: false }} + comboboxProps={{ withinPortal: true }} onChange={v => setTable(v ?? undefined)} onBlur={onBlur} name={name} diff --git a/packages/app/src/components/DatabaseSelect.tsx b/packages/app/src/components/DatabaseSelect.tsx index db48b2fd76..b2270a313a 100644 --- a/packages/app/src/components/DatabaseSelect.tsx +++ b/packages/app/src/components/DatabaseSelect.tsx @@ -41,7 +41,7 @@ function DatabaseSelect({ maxDropdownHeight={280} data={data} disabled={isDatabasesLoading} - comboboxProps={{ withinPortal: false }} + comboboxProps={{ withinPortal: true }} value={database} onChange={v => setDatabase(v ?? undefined)} onBlur={onBlur} diff --git a/packages/app/src/components/__tests__/sourceFormPickerDropdowns.test.tsx b/packages/app/src/components/__tests__/sourceFormPickerDropdowns.test.tsx new file mode 100644 index 0000000000..70d2841534 --- /dev/null +++ b/packages/app/src/components/__tests__/sourceFormPickerDropdowns.test.tsx @@ -0,0 +1,150 @@ +import { useForm } from 'react-hook-form'; +import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { useDatabasesDirect, useTablesDirect } from '@/clickhouse'; +import { useConnections } from '@/connection'; + +import { ConnectionSelectControlled } from '../ConnectionSelect'; +import { DatabaseSelectControlled } from '../DatabaseSelect'; +import { DBTableSelectControlled } from '../DBTableSelect'; + +jest.mock('@/clickhouse', () => ({ + useDatabasesDirect: jest.fn(), + useTablesDirect: jest.fn(), +})); +jest.mock('@/connection', () => ({ + useConnections: jest.fn(), +})); +// DBTableSelect renders these next to the table picker; not relevant to this test. +jest.mock('../SourceSchemaPreview', () => ({ + __esModule: true, + default: () => null, + isSourceSchemaPreviewEnabled: () => false, +})); +jest.mock('../SourceSelect', () => ({ + SourceManagementMenu: () => null, +})); + +// Mantine's Combobox calls scrollIntoView when its dropdown opens; jsdom lacks it. +window.HTMLElement.prototype.scrollIntoView = jest.fn(); + +// The hooks return large `UseQueryResult`/connection types; the components only +// read a couple of fields, so cast to a loose mock for these minimal fixtures. +const asMock = (fn: unknown) => fn as jest.Mock; + +// Use a large list so the dropdown overflows like the bug report (HDX-4445). +const OPTION_COUNT = 20; +const databaseNames = Array.from( + { length: OPTION_COUNT }, + (_, i) => `db_${String(i).padStart(2, '0')}`, +); +const tableNames = Array.from( + { length: OPTION_COUNT }, + (_, i) => `table_${String(i).padStart(2, '0')}`, +); +const connections = Array.from({ length: OPTION_COUNT }, (_, i) => ({ + id: `conn-${i}`, + name: `Connection ${String(i).padStart(2, '0')}`, +})); + +beforeEach(() => { + asMock(useDatabasesDirect).mockReturnValue({ + data: { data: databaseNames.map(name => ({ name })) }, + isLoading: false, + }); + asMock(useTablesDirect).mockReturnValue({ + data: { data: tableNames.map(name => ({ name })) }, + isLoading: false, + }); + asMock(useConnections).mockReturnValue({ data: connections }); +}); + +function DatabaseHarness() { + const { control } = useForm(); + return ( + + ); +} + +function TableHarness() { + const { control } = useForm(); + return ( + + ); +} + +function ConnectionHarness() { + const { control } = useForm(); + return ; +} + +/** + * HDX-4445: these pickers live inside the source-setup modal. With many entries + * the dropdown must (a) render every option and (b) render them in a portal, so + * the modal's overflow can't clip the list. + * + * `hidden: true` — jsdom has no layout, so the portaled dropdown computes as + * "hidden"; the default role query would skip it. + * + * Note on what jsdom can/can't prove: clipping is a *visual* effect of + * `overflow:hidden`, which does not change the DOM or an element's box, and + * jsdom has no layout — so pixel visibility ("is option 20 actually on screen") + * is not assertable here. The two checks below are what's both meaningful and + * deterministic: every option is rendered (no truncation/virtualization), and + * the options are portaled OUT of the picker's container so a modal can't clip + * them. The container check is what flips between fixed (`withinPortal: true`) + * and broken (`false`) — independent of option count. True on-screen proof + * would require a real-browser (E2E) test. + */ +async function expectAllOptionsRenderedInPortal( + container: HTMLElement, + trigger: HTMLElement, +) { + await userEvent.click(trigger); + + // (a) All options are rendered — none dropped by truncation/virtualization. + const allOptions = await screen.findAllByRole('option', { hidden: true }); + expect(allOptions).toHaveLength(OPTION_COUNT); + + // (b) None are nested inside the picker's own container: they are portaled + // out, so the modal's overflow cannot clip them. Fails on withinPortal:false. + expect( + within(container).queryAllByRole('option', { hidden: true }), + ).toHaveLength(0); +} + +describe('source form picker dropdowns render all options in a portal', () => { + it(`DatabaseSelect renders all ${OPTION_COUNT} databases in a portal`, async () => { + const { container } = renderWithMantine(); + await expectAllOptionsRenderedInPortal( + container, + screen.getByPlaceholderText('Database'), + ); + }); + + it(`DBTableSelect renders all ${OPTION_COUNT} tables in a portal`, async () => { + const { container } = renderWithMantine(); + await expectAllOptionsRenderedInPortal( + container, + screen.getByPlaceholderText('Table'), + ); + }); + + it(`ConnectionSelect renders all ${OPTION_COUNT} connections in a portal`, async () => { + const { container } = renderWithMantine(); + await expectAllOptionsRenderedInPortal( + container, + screen.getByPlaceholderText('Connection'), + ); + }); +});