From 6905722795409e9a0d8d57d4156c0ab80e82f5b9 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Sat, 3 Oct 2026 01:35:29 +0200 Subject: [PATCH 1/7] feat(sql-editor): browse nested BI connections --- .../handbook/engineering/data-warehouse.md | 12 ++ .../data-warehouse/editor/OutputPane.tsx | 2 +- .../editor/SQLEditorScene.stories.tsx | 100 ++++++++++++ .../data-warehouse/editor/bi/biEditorTypes.ts | 11 +- .../editor/bi/components/BIDataPane.tsx | 95 +---------- .../editor/sidebar/queryDatabaseLogic.tsx | 8 +- .../editor/sqlEditorLogic.test.ts | 61 +++++++ .../frontend/bi/BIConnectionGroup.tsx | 84 ++++++++++ .../frontend/bi/BIConnections.tsx | 17 ++ .../frontend/bi/BIDataPaneSection.tsx | 93 +++++++++++ .../frontend/bi/biConnectionTree.test.ts | 106 +++++++++++++ .../frontend/bi/biConnectionTree.ts | 133 ++++++++++++++++ .../frontend/bi/biConnectionsLogic.ts | 150 ++++++++++++++++++ 13 files changed, 780 insertions(+), 92 deletions(-) create mode 100644 products/data_warehouse/frontend/bi/BIConnectionGroup.tsx create mode 100644 products/data_warehouse/frontend/bi/BIConnections.tsx create mode 100644 products/data_warehouse/frontend/bi/BIDataPaneSection.tsx create mode 100644 products/data_warehouse/frontend/bi/biConnectionTree.test.ts create mode 100644 products/data_warehouse/frontend/bi/biConnectionTree.ts create mode 100644 products/data_warehouse/frontend/bi/biConnectionsLogic.ts diff --git a/docs/published/handbook/engineering/data-warehouse.md b/docs/published/handbook/engineering/data-warehouse.md index c4ec0d3436b5..e383e6eced3d 100644 --- a/docs/published/handbook/engineering/data-warehouse.md +++ b/docs/published/handbook/engineering/data-warehouse.md @@ -236,3 +236,15 @@ You'll need to install MS SQL drivers for the PostHog app to connect to a MS SQL ```text symbol not found in flat namespace '_bcp_batch' ``` + +## Connected fields in BI mode + +Below Dimensions and Measures, **Connections** lists the selected table's linked tables and views. +Expand a connection to load its fields. Each expanded connection has its own Dimensions, Measures, +and nested Connections; the Connections heading is omitted when there are no further links. + +Drag connected fields onto a shelf, double-click them, or press Enter to add them to Rows. +Measures are aggregated automatically. The worksheet keeps its original source table and uses the full +connection path in queries and shelf labels, such as `person.company.name`. +Connections expand on demand, including repeated links to the same table. Search filters the fields +inside expanded connections, and a failed field load has a Retry button. diff --git a/frontend/src/scenes/data-warehouse/editor/OutputPane.tsx b/frontend/src/scenes/data-warehouse/editor/OutputPane.tsx index 8881d955f840..3c9f78672553 100644 --- a/frontend/src/scenes/data-warehouse/editor/OutputPane.tsx +++ b/frontend/src/scenes/data-warehouse/editor/OutputPane.tsx @@ -1314,7 +1314,7 @@ const Content = ({ if (!response && !responseLoading && !insightLoading) { return (
diff --git a/frontend/src/scenes/data-warehouse/editor/SQLEditorScene.stories.tsx b/frontend/src/scenes/data-warehouse/editor/SQLEditorScene.stories.tsx index dc37ec4465b0..ebba526066f0 100644 --- a/frontend/src/scenes/data-warehouse/editor/SQLEditorScene.stories.tsx +++ b/frontend/src/scenes/data-warehouse/editor/SQLEditorScene.stories.tsx @@ -733,3 +733,103 @@ export const BIDataSourcePicker: Story = { await waitFor(() => expect(page.getByRole('searchbox', { name: 'Search tables' })).toBeVisible()) }, } + +const BI_CONNECTIONS_CONFIG: BIConfig = { + ...BI_WORKSHEET_CONFIG, + chartType: ChartDisplayType.ActionsTable, + rows: [], + columns: [], + filters: [], +} + +export const BIConnections: Story = { + ...BIModeWorksheet, + parameters: { + ...BIModeWorksheet.parameters, + pageUrl: `${urls.sqlEditor()}#${new URLSearchParams({ + q: buildBIQuery(BI_CONNECTIONS_CONFIG)?.query ?? '', + mode: 'bi', + bi: JSON.stringify(BI_CONNECTIONS_CONFIG), + })}`, + testOptions: { + waitForSelector: '[data-attr="bi-editor-connection-fields"] [data-attr="bi-editor-connection-fields"]', + viewport: { width: 1280, height: 900 }, + }, + msw: { + mocks: { + ...BIModeWorksheet.parameters?.msw.mocks, + post: { + ...BIModeWorksheet.parameters?.msw.mocks.post, + '/api/environments/:team_id/query/DatabaseSchemaQuery/': { + tables: { + events: { + id: 'events', + name: 'events', + type: 'posthog', + fields: { + event: BI_EVENTS_FIELDS.event, + revenue: BI_EVENTS_FIELDS.revenue, + person: { + name: 'person', + type: 'lazy_table', + table: 'persons', + schema_valid: true, + hogql_value: 'person', + }, + }, + }, + persons: { + id: 'persons', + name: 'persons', + type: 'posthog', + fields: { + email: { name: 'email', type: 'string', schema_valid: true, hogql_value: 'email' }, + lifetime_value: { + name: 'lifetime_value', + type: 'float', + schema_valid: true, + hogql_value: 'lifetime_value', + }, + company: { + name: 'company', + type: 'lazy_table', + table: 'companies', + schema_valid: true, + hogql_value: 'company', + }, + }, + }, + companies: { + id: 'companies', + name: 'companies', + type: 'data_warehouse', + fields: { + name: { name: 'name', type: 'string', schema_valid: true, hogql_value: 'name' }, + annual_revenue: { + name: 'annual_revenue', + type: 'decimal', + schema_valid: true, + hogql_value: 'annual_revenue', + }, + }, + }, + }, + }, + }, + }, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await canvas.findByText('Locate', {}, { timeout: 15000 }) + await userEvent.click(canvas.getByRole('button', { name: 'SQL' })) + await userEvent.click(canvas.getByRole('button', { name: 'BI' })) + const autoUpdate = canvasElement.querySelector('[data-attr="bi-editor-auto-update"]')! + if (autoUpdate.getAttribute('aria-checked') === 'true') { + await userEvent.click(autoUpdate) + } + await userEvent.click(await canvas.findByRole('button', { name: 'person' })) + await userEvent.click(await canvas.findByRole('button', { name: 'person.company' })) + await waitFor(() => expect(canvas.getByText('annual_revenue')).toBeVisible()) + }, +} diff --git a/frontend/src/scenes/data-warehouse/editor/bi/biEditorTypes.ts b/frontend/src/scenes/data-warehouse/editor/bi/biEditorTypes.ts index 3f9277598967..c84683781690 100644 --- a/frontend/src/scenes/data-warehouse/editor/bi/biEditorTypes.ts +++ b/frontend/src/scenes/data-warehouse/editor/bi/biEditorTypes.ts @@ -298,12 +298,17 @@ export interface BIDataPaneFields { measures: BIField[] } -export function getBIDataPaneFields(table: DatabaseSchemaTable | undefined, source: BIDataSource): BIDataPaneFields { +export function getBIDataPaneFields( + table: Pick | undefined, + source: BIDataSource, + path: string[] = [] +): BIDataPaneFields { const fields = Object.values(table?.fields ?? {}) .filter((field) => DATA_PANE_FIELD_TYPES.has(field.type)) .map((field): BIField => { - const expression = escapeDottedHogQLIdentifier(field.name) - return { id: getBIFieldId(source, expression), name: field.name, expression, type: field.type, source } + const name = [...path, field.name].join('.') + const expression = escapeDottedHogQLIdentifier(name) + return { id: getBIFieldId(source, expression), name, expression, type: field.type, source } }) .sort((first, second) => first.name.localeCompare(second.name)) diff --git a/frontend/src/scenes/data-warehouse/editor/bi/components/BIDataPane.tsx b/frontend/src/scenes/data-warehouse/editor/bi/components/BIDataPane.tsx index b72695758f9e..7dcbcbe5a5bf 100644 --- a/frontend/src/scenes/data-warehouse/editor/bi/components/BIDataPane.tsx +++ b/frontend/src/scenes/data-warehouse/editor/bi/components/BIDataPane.tsx @@ -1,93 +1,15 @@ import { useActions, useValues } from 'kea' -import { IconCalendar, IconPlus } from '@posthog/icons' +import { IconPlus } from '@posthog/icons' import { LemonButton, LemonInput, Spinner } from '@posthog/lemon-ui' -import { cn } from 'lib/utils/css-classes' - +import { BIConnections } from 'products/data_warehouse/frontend/bi/BIConnections' +import { BIDataPaneSection } from 'products/data_warehouse/frontend/bi/BIDataPaneSection' import { BIDataSourcePicker } from 'products/data_warehouse/frontend/bi/BIDataSourcePicker' import { editorSizingLogic } from '../../editorSizingLogic' import { queryDatabaseLogic } from '../../sidebar/queryDatabaseLogic' import { biEditorLogic } from '../biEditorLogic' -import { BIField, BI_FIELD_DRAG_MIME_TYPE, getBIDropTarget, serializeBIField } from '../biEditorTypes' - -function FieldTypeGlyph({ field, measure }: { field: BIField; measure: boolean }): JSX.Element { - const glyph = - field.type === 'date' || field.type === 'datetime' ? ( - - ) : measure || ['integer', 'float', 'decimal'].includes(field.type) ? ( - '#' - ) : field.type === 'boolean' ? ( - 'T|F' - ) : field.type === 'json' || field.type === 'array' ? ( - '{ }' - ) : ( - 'Abc' - ) - return ( - - {glyph} - - ) -} - -function DataPaneSection({ - title, - fields, - measure, - emptyText, -}: { - title: string - fields: BIField[] - measure: boolean - emptyText: string -}): JSX.Element { - const { addFieldToShelf } = useActions(biEditorLogic) - - const addField = (field: BIField): void => { - const target = getBIDropTarget(field, 'rows') - addFieldToShelf(target.field, target.shelf) - } - - return ( -
-
{title}
- {fields.length === 0 ? {emptyText} : null} - {fields.map((field) => ( - - ))} -
- ) -} /** Lists the selected table's fields as dimensions and measures, ready to drag onto shelves. */ export function BIDataPane(): JSX.Element { @@ -161,20 +83,18 @@ export function BIDataPane(): JSX.Element {
) : !hasFields ? ( -

- No fields found. Drag columns from the database tree instead. -

+

No dimensions or measures on this table.

) : !hasMatches ? ( -

No matching fields

+

No matching fields in this table

) : ( <> - - )} + {config.source && !dataPaneFieldsError ? : null}
{ return chain.map((segment) => String(segment)).join('.') } -const resolveFieldTraverserTarget = ( +export const resolveFieldTraverserTarget = ( tableName: string, field: DatabaseSchemaField, tableLookup?: TableLookup, @@ -593,6 +593,12 @@ const resolveFieldTraverserTarget = ( return null } + const traversalKey = JSON.stringify([tableName, field.chain]) + if (visitedChains.has(traversalKey)) { + return null + } + visitedChains.add(traversalKey) + const baseTable = tableLookup[tableName] if (!baseTable) { return null diff --git a/frontend/src/scenes/data-warehouse/editor/sqlEditorLogic.test.ts b/frontend/src/scenes/data-warehouse/editor/sqlEditorLogic.test.ts index 133d049a764c..471299d52023 100644 --- a/frontend/src/scenes/data-warehouse/editor/sqlEditorLogic.test.ts +++ b/frontend/src/scenes/data-warehouse/editor/sqlEditorLogic.test.ts @@ -32,6 +32,7 @@ import { initKeaTests } from '~/test/init' import { ChartDisplayType, InsightShortId, InsightModel } from '~/types' import { metricsLogic } from 'products/data_catalog/frontend/metricsLogic' +import { biConnectionsLogic } from 'products/data_warehouse/frontend/bi/biConnectionsLogic' import { sqlEditorDraftStorage } from 'products/data_warehouse/frontend/sqlEditorDraftStorage' import { BI_EDITOR_EVENTS } from './bi/biEditorAnalytics' @@ -2608,6 +2609,66 @@ describe('sqlEditorLogic', () => { biLogic.unmount() }) + it('loads connected fields on expansion and keeps their paths rooted in the worksheet', async () => { + await expectLogic(databaseLogic).toFinishAllListeners() + const biLogic = biEditorLogic({ tabId: TAB_ID }) + biLogic.mount() + biLogic.actions.restoreState({ editorView: BIEditorView.BI, config }) + databaseLogic.actions.loadDatabaseSuccess({ + tables: { + events: { + id: 'events', + name: 'events', + type: 'posthog', + fields: { + person: { + name: 'person', + type: 'lazy_table', + table: 'persons', + hogql_value: 'person', + schema_valid: true, + }, + }, + }, + persons: { id: 'persons', name: 'persons', type: 'posthog', fields: {} }, + }, + joins: [], + }) + databaseLogic.actions.setDatabaseFieldsComplete(false) + const connections = biConnectionsLogic({ tabId: TAB_ID }) + connections.mount() + expect(connections.values.connections[0]).toMatchObject({ expanded: false, state: 'loading' }) + expect(databaseLogic.values.tableFieldsStatus.persons).toBeUndefined() + + queryEndpointMock.mockReturnValue([ + 200, + { + tables: { + persons: { + id: 'persons', + name: 'persons', + type: 'posthog', + fields: { + email: { name: 'email', type: 'string', hogql_value: 'email', schema_valid: true }, + }, + }, + }, + joins: [], + }, + ]) + useMocks({ post: { '/api/environments/:team_id/query/DatabaseSchemaQuery/': queryEndpointMock } }) + await expectLogic(databaseLogic, () => + connections.actions.toggleConnection('["person"]', 'persons') + ).toDispatchActions(['hydrateTableFieldsSuccess']) + expect(connections.values.connections[0].fields.dimensions).toEqual([ + expect.objectContaining({ name: 'person.email', expression: 'person.email', source: config.source }), + ]) + biLogic.actions.setDataSource({ table: 'persons' }) + expect(connections.values.connections).toEqual([]) + connections.unmount() + biLogic.unmount() + }) + it('creates and edits a calculated measure without applying canceled drafts or losing its sort', () => { const biLogic = biEditorLogic({ tabId: TAB_ID }) biLogic.mount() diff --git a/products/data_warehouse/frontend/bi/BIConnectionGroup.tsx b/products/data_warehouse/frontend/bi/BIConnectionGroup.tsx new file mode 100644 index 000000000000..722e80d4eac3 --- /dev/null +++ b/products/data_warehouse/frontend/bi/BIConnectionGroup.tsx @@ -0,0 +1,84 @@ +import { useActions } from 'kea' + +import { IconChevronRight, IconDatabase } from '@posthog/icons' +import { LemonButton, Spinner } from '@posthog/lemon-ui' + +import { cn } from 'lib/utils/css-classes' + +import { biConnectionsLogic } from './biConnectionsLogic' +import { BIConnection } from './biConnectionTree' +import { BIDataPaneSection } from './BIDataPaneSection' + +export function BIConnectionGroup({ connections }: { connections: BIConnection[] }): JSX.Element | null { + const { toggleConnection, hydrateTableFields } = useActions(biConnectionsLogic) + if (!connections.length) { + return null + } + return ( +
+
Connections
+ {connections.map((connection) => ( +
+ } + onClick={() => toggleConnection(connection.id, connection.tableName)} + aria-expanded={connection.expanded} + data-attr="bi-editor-connection" + tooltip={connection.path.join('.')} + > + + {connection.name} + + {connection.expanded ? ( +
+ {connection.state === 'loading' ? ( +
+ Loading fields +
+ ) : connection.state === 'error' ? ( +
+ Couldn't load fields. + + connection.tableName && hydrateTableFields([connection.tableName]) + } + > + Retry + +
+ ) : connection.state === 'missing' ? ( + + This connection's fields are unavailable. + + ) : ( + <> + + + + + )} +
+ ) : null} +
+ ))} +
+ ) +} diff --git a/products/data_warehouse/frontend/bi/BIConnections.tsx b/products/data_warehouse/frontend/bi/BIConnections.tsx new file mode 100644 index 000000000000..738f61f17ffe --- /dev/null +++ b/products/data_warehouse/frontend/bi/BIConnections.tsx @@ -0,0 +1,17 @@ +import { BindLogic, useMountedLogic, useValues } from 'kea' + +import { biEditorLogic } from 'scenes/data-warehouse/editor/bi/biEditorLogic' + +import { BIConnectionGroup } from './BIConnectionGroup' +import { biConnectionsLogic } from './biConnectionsLogic' + +export function BIConnections(): JSX.Element { + const biLogic = useMountedLogic(biEditorLogic) + const logicProps = { tabId: biLogic.props.tabId } + const { filteredConnections } = useValues(biConnectionsLogic(logicProps)) + return ( + + + + ) +} diff --git a/products/data_warehouse/frontend/bi/BIDataPaneSection.tsx b/products/data_warehouse/frontend/bi/BIDataPaneSection.tsx new file mode 100644 index 000000000000..216a0a183b13 --- /dev/null +++ b/products/data_warehouse/frontend/bi/BIDataPaneSection.tsx @@ -0,0 +1,93 @@ +import { useActions } from 'kea' + +import { IconCalendar } from '@posthog/icons' + +import { cn } from 'lib/utils/css-classes' +import { biEditorLogic } from 'scenes/data-warehouse/editor/bi/biEditorLogic' +import { + BIField, + BI_FIELD_DRAG_MIME_TYPE, + getBIDropTarget, + serializeBIField, +} from 'scenes/data-warehouse/editor/bi/biEditorTypes' + +function FieldTypeGlyph({ field, measure }: { field: BIField; measure: boolean }): JSX.Element { + const glyph = + field.type === 'date' || field.type === 'datetime' ? ( + + ) : measure || ['integer', 'float', 'decimal'].includes(field.type) ? ( + '#' + ) : field.type === 'boolean' ? ( + 'T|F' + ) : field.type === 'json' || field.type === 'array' ? ( + '{ }' + ) : ( + 'Abc' + ) + return ( + + {glyph} + + ) +} + +export function BIDataPaneSection({ + title, + fields, + measure, + emptyText, + path = [], +}: { + title: string + fields: BIField[] + measure: boolean + emptyText: string + path?: string[] +}): JSX.Element { + const { addFieldToShelf } = useActions(biEditorLogic) + + const addField = (field: BIField): void => { + const target = getBIDropTarget(field, 'rows') + addFieldToShelf(target.field, target.shelf) + } + + return ( +
+
{title}
+ {fields.length === 0 ? {emptyText} : null} + {fields.map((field) => ( + + ))} +
+ ) +} diff --git a/products/data_warehouse/frontend/bi/biConnectionTree.test.ts b/products/data_warehouse/frontend/bi/biConnectionTree.test.ts new file mode 100644 index 000000000000..9458672610ca --- /dev/null +++ b/products/data_warehouse/frontend/bi/biConnectionTree.test.ts @@ -0,0 +1,106 @@ +import { + BIDataSource, + BIField, + buildBIQuery, + DEFAULT_BI_CONFIG, + getBIDropTarget, +} from 'scenes/data-warehouse/editor/bi/biEditorTypes' + +import { DatabaseSchemaField, DatabaseSchemaTable } from '~/queries/schema/schema-general' + +import { buildBIConnections } from './biConnectionTree' + +const field = ( + name: string, + type: DatabaseSchemaField['type'], + rest: Partial = {} +): DatabaseSchemaField => ({ name, type, hogql_value: name, schema_valid: true, ...rest }) +const table = (name: string, fields: DatabaseSchemaField[]): DatabaseSchemaTable => ({ + id: name, + name, + type: 'posthog', + fields: Object.fromEntries(fields.map((field) => [field.name, field])), +}) +const source: BIDataSource = { table: 'events', connectionId: 'example-connection' } +const tables = { + events: table('events', [field('person', 'lazy_table', { table: 'persons' })]), + persons: table('persons', [ + field('email', 'string'), + field('lifetime_value', 'float'), + field('company', 'lazy_table', { table: 'companies' }), + ]), + companies: table('companies', [field('name', 'string'), field('annual_revenue', 'decimal')]), +} + +it('drags nested dimensions and measures using paths from the original source', () => { + const [person] = buildBIConnections(source, tables, ['["person"]', '["person","company"]'], {}, true) + const [company] = person.connections + expect(company.connections).toEqual([]) + const dimension = company.fields.dimensions[0] + const measure = company.fields.measures[0] + expect(dimension).toMatchObject({ name: 'person.company.name', expression: 'person.company.name', source }) + expect(measure).toMatchObject({ name: 'person.company.annual_revenue', type: 'decimal', source }) + const value = getBIDropTarget(measure, 'rows').field as BIField + const query = buildBIQuery({ + ...DEFAULT_BI_CONFIG, + source, + rows: [dimension], + values: [{ field: value, aggregation: 'sum' }], + }) + expect(query?.query).toContain('person.company.name') + expect(query?.query).toContain('sum(person.company.annual_revenue)') + expect(query?.query).toContain('FROM events') +}) + +it.each(['lazy_table', 'view', 'materialized_view', 'virtual_table'] as const)( + 'uses declared fields for %s connections', + (type) => { + const catalog = { + ...tables, + events: table('events', [field('person', type, { table: 'persons', fields: ['email'] })]), + } + const [person] = buildBIConnections(source, catalog, ['["person"]'], {}, true) + expect(person.fields.dimensions.map((field) => field.name)).toEqual(['person.email']) + expect(person.fields.measures).toEqual([]) + expect(person.connections).toEqual([]) + } +) + +it('resolves linked aliases and only expands requested paths through cyclic tables', () => { + const catalog = { + ...tables, + events: table('events', [ + field('person', 'lazy_table', { table: 'persons' }), + field('customer', 'field_traverser', { chain: ['person'] }), + ]), + persons: table('persons', [field('email', 'string'), field('manager', 'lazy_table', { table: 'persons' })]), + } + const [customer] = buildBIConnections(source, catalog, ['["customer"]', '["customer","manager"]'], {}, true) + expect(customer.fields.dimensions[0].expression).toBe('customer.email') + const [manager] = customer.connections + expect(manager.fields.dimensions[0].expression).toBe('customer.manager.email') + expect(manager.connections[0]).toMatchObject({ expanded: false, connections: [] }) +}) + +it.each([ + [undefined, false, 'loading'], + ['error', false, 'error'], + ['missing', false, 'missing'], + ['loaded', false, 'ready'], + [undefined, true, 'ready'], +] as const)('distinguishes unresolved, failed, and empty connections (%s, complete=%s)', (status, complete, state) => { + const catalog = { ...tables, persons: table('persons', []) } + const [person] = buildBIConnections(source, catalog, ['["person"]'], status ? { persons: status } : {}, complete) + expect(person.state).toBe(state) + expect(person.fields).toEqual({ dimensions: [], measures: [] }) +}) + +it('does not recurse forever when aliases refer to each other', () => { + const catalog = { + events: table('events', [ + field('first', 'field_traverser', { chain: ['second'] }), + field('second', 'field_traverser', { chain: ['first'] }), + ]), + } + expect(buildBIConnections(source, catalog, [], {}, true)).toEqual([]) +}) diff --git a/products/data_warehouse/frontend/bi/biConnectionTree.ts b/products/data_warehouse/frontend/bi/biConnectionTree.ts new file mode 100644 index 000000000000..759b10b84023 --- /dev/null +++ b/products/data_warehouse/frontend/bi/biConnectionTree.ts @@ -0,0 +1,133 @@ +import { TableFieldsStatus } from 'scenes/data-management/database/databaseTableListLogic' +import { BIDataPaneFields, BIDataSource, getBIDataPaneFields } from 'scenes/data-warehouse/editor/bi/biEditorTypes' +import { resolveFieldTraverserTarget } from 'scenes/data-warehouse/editor/sidebar/queryDatabaseLogic' + +import { DatabaseSchemaField, DatabaseSchemaTable } from '~/queries/schema/schema-general' + +export interface BIConnection { + id: string + name: string + path: string[] + tableName?: string + expanded: boolean + state: 'ready' | 'loading' | 'error' | 'missing' + fields: BIDataPaneFields + connections: BIConnection[] +} + +function getConnectionState( + field: DatabaseSchemaField, + table: DatabaseSchemaTable | undefined, + status: TableFieldsStatus[string] | undefined, + complete: boolean +): BIConnection['state'] { + if (status === 'error' || status === 'missing') { + return status + } + if (table && !complete && !Object.keys(table.fields).length && status !== 'loaded') { + return 'loading' + } + return !table && !field.fields?.length ? 'missing' : 'ready' +} + +function getConnectionFields( + field: DatabaseSchemaField, + table: DatabaseSchemaTable | undefined +): DatabaseSchemaField[] { + return field.fields?.length + ? field.fields + .filter((name) => name !== 'team_id') + .map( + (name): DatabaseSchemaField => + table?.fields[name] ?? { + name, + hogql_value: name, + type: 'unknown', + schema_valid: true, + } + ) + : Object.values(table?.fields ?? {}).filter((child) => child.name !== 'team_id' || child.type !== 'unknown') +} + +export function buildBIConnections( + source: BIDataSource, + tables: Record, + expandedIds: string[], + tableFieldsStatus: TableFieldsStatus, + databaseFieldsComplete: boolean +): BIConnection[] { + const expanded = new Set(expandedIds) + const getTable = (name?: string): DatabaseSchemaTable | undefined => + name ? (tables[name] ?? tables[name.replaceAll('`', '')]) : undefined + const visit = (tableName: string, fields: DatabaseSchemaField[], path: string[]): BIConnection[] => + fields + .flatMap((originalField): BIConnection[] => { + const field = + originalField.type === 'field_traverser' + ? resolveFieldTraverserTarget(tableName, originalField, tables) + : originalField + if (!field || !['lazy_table', 'virtual_table', 'view', 'materialized_view'].includes(field.type)) { + return [] + } + const childPath = [...path, originalField.name] + const id = JSON.stringify(childPath) + const table = getTable(field.table) + const status = table ? tableFieldsStatus[table.name] : undefined + const state = getConnectionState(field, table, status, databaseFieldsComplete) + const connection: BIConnection = { + id, + name: originalField.name, + path: childPath, + tableName: table?.name, + expanded: expanded.has(id), + state, + fields: { dimensions: [], measures: [] }, + connections: [], + } + if (!connection.expanded || state !== 'ready') { + return [connection] + } + const childFields = getConnectionFields(field, table) + const nestedTableName = table?.name ?? field.table ?? tableName + const scalarFields = childFields.map((child) => { + const resolved = + child.type === 'field_traverser' + ? resolveFieldTraverserTarget(nestedTableName, child, tables) + : child + return resolved ? { ...resolved, name: child.name } : child + }) + return [ + { + ...connection, + fields: getBIDataPaneFields( + { fields: Object.fromEntries(scalarFields.map((child) => [child.name, child])) }, + source, + childPath + ), + connections: visit(nestedTableName, childFields, childPath), + }, + ] + }) + .sort((left, right) => left.name.localeCompare(right.name)) + return visit(source.table, Object.values(getTable(source.table)?.fields ?? {}), []) +} + +export function filterBIConnections(connections: BIConnection[], search: string): BIConnection[] { + const term = search.trim().toLowerCase() + if (!term) { + return connections + } + return connections.flatMap((connection) => { + if (connection.path.join('.').toLowerCase().includes(term)) { + return [connection] + } + const fields = { + dimensions: connection.fields.dimensions.filter((field) => field.name.toLowerCase().includes(term)), + measures: connection.fields.measures.filter((field) => field.name.toLowerCase().includes(term)), + } + const children = filterBIConnections(connection.connections, term) + return !connection.expanded || fields.dimensions.length || fields.measures.length || children.length + ? [{ ...connection, fields, connections: children }] + : [] + }) +} diff --git a/products/data_warehouse/frontend/bi/biConnectionsLogic.ts b/products/data_warehouse/frontend/bi/biConnectionsLogic.ts new file mode 100644 index 000000000000..6d3f61c7bbb4 --- /dev/null +++ b/products/data_warehouse/frontend/bi/biConnectionsLogic.ts @@ -0,0 +1,150 @@ +import { + MakeLogicType, + LogicWrapper, + actions, + connect, + kea, + key, + listeners, + path, + props, + reducers, + selectors, +} from 'kea' + +import { databaseTableListLogic } from 'scenes/data-management/database/databaseTableListLogic' +import { biEditorLogic } from 'scenes/data-warehouse/editor/bi/biEditorLogic' + +import type { DatabaseSchemaTable } from '../../../../frontend/src/queries/schema/schema-general' +import type { TableFieldsStatus } from '../../../../frontend/src/scenes/data-management/database/databaseTableListLogic' +import type { BIConfig } from '../../../../frontend/src/scenes/data-warehouse/editor/bi/biEditorTypes' +import { buildBIConnections, filterBIConnections } from './biConnectionTree' +import type { BIConnection } from './biConnectionTree' + +export interface BIConnectionsLogicProps { + tabId: string +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface biConnectionsLogicValues { + config: BIConfig // biEditorLogic + dataPaneSearch: string // biEditorLogic + databaseConnectionId: string | null // biEditorLogic + allTablesMap: Record // databaseTableListLogic + databaseFieldsComplete: boolean // databaseTableListLogic + tableFieldsStatus: TableFieldsStatus // databaseTableListLogic + connections: BIConnection[] + expandedIds: string[] + filteredConnections: BIConnection[] +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface biConnectionsLogicActions { + resetConfig: () => { + value: true + } // biEditorLogic + setDataSource: ( + source: import('../../../../frontend/src/scenes/data-warehouse/editor/bi/biEditorTypes').BIDataSource + ) => { + source: import('../../../../frontend/src/scenes/data-warehouse/editor/bi/biEditorTypes').BIDataSource + } // biEditorLogic + hydrateTableFields: (tableNames: string[]) => { + tableNames: string[] + } // databaseTableListLogic + toggleConnection: ( + id: string, + tableName?: string + ) => { + id: string + tableName: string | undefined + } +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface biConnectionsLogicMeta { + key: string + __keaTypeGenInternalSelectorTypes: { + connections: ( + config: BIConfig, + allTablesMap: Record, + expandedIds: string[], + tableFieldsStatus: TableFieldsStatus, + databaseFieldsComplete: boolean, + databaseConnectionId: string | null + ) => BIConnection[] + filteredConnections: (connections: BIConnection[], dataPaneSearch: string) => BIConnection[] + } +} + +export type biConnectionsLogicType = MakeLogicType< + biConnectionsLogicValues, + biConnectionsLogicActions, + BIConnectionsLogicProps, + biConnectionsLogicMeta +> + +export const biConnectionsLogic: LogicWrapper = kea([ + props({} as BIConnectionsLogicProps), + key((props) => props.tabId), + path((key) => ['products', 'data_warehouse', 'frontend', 'bi', 'biConnectionsLogic', key]), + connect((props: BIConnectionsLogicProps) => ({ + values: [ + biEditorLogic({ tabId: props.tabId }), + ['config', 'dataPaneSearch', 'databaseConnectionId'], + databaseTableListLogic, + ['allTablesMap', 'tableFieldsStatus', 'databaseFieldsComplete'], + ], + actions: [ + biEditorLogic({ tabId: props.tabId }), + ['setDataSource', 'resetConfig'], + databaseTableListLogic, + ['hydrateTableFields'], + ], + })), + actions({ toggleConnection: (id: string, tableName?: string) => ({ id, tableName }) }), + reducers({ + expandedIds: [ + [] as string[], + { + toggleConnection: (state, { id }) => + state.includes(id) ? state.filter((key) => key !== id) : [...state, id], + setDataSource: () => [], + resetConfig: () => [], + }, + ], + }), + selectors({ + connections: [ + (s) => [ + s.config, + s.allTablesMap, + s.expandedIds, + s.tableFieldsStatus, + s.databaseFieldsComplete, + s.databaseConnectionId, + ], + ( + config: BIConfig, + tables: Record, + expanded: string[], + status: TableFieldsStatus, + complete: boolean, + connectionId: string | null + ): BIConnection[] => + config.source && (config.source.connectionId ?? null) === connectionId + ? buildBIConnections(config.source, tables, expanded, status, complete) + : [], + ], + filteredConnections: [ + (s) => [s.connections, s.dataPaneSearch], + (connections: BIConnection[], search: string): BIConnection[] => filterBIConnections(connections, search), + ], + }), + listeners(({ actions, values }) => ({ + toggleConnection: ({ id, tableName }) => { + if (tableName && values.expandedIds.includes(id)) { + actions.hydrateTableFields([tableName]) + } + }, + })), +]) From dbc69c778a690aac19d1dec3051914208673a023 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Sat, 3 Oct 2026 02:19:48 +0200 Subject: [PATCH 2/7] fix(bi): remove headings inside connections --- docs/published/handbook/engineering/data-warehouse.md | 4 ++-- products/data_warehouse/frontend/bi/BIConnectionGroup.tsx | 3 --- products/data_warehouse/frontend/bi/BIConnections.tsx | 3 +++ products/data_warehouse/frontend/bi/BIDataPaneSection.tsx | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/published/handbook/engineering/data-warehouse.md b/docs/published/handbook/engineering/data-warehouse.md index e383e6eced3d..e6cca851e891 100644 --- a/docs/published/handbook/engineering/data-warehouse.md +++ b/docs/published/handbook/engineering/data-warehouse.md @@ -240,8 +240,8 @@ symbol not found in flat namespace '_bcp_batch' ## Connected fields in BI mode Below Dimensions and Measures, **Connections** lists the selected table's linked tables and views. -Expand a connection to load its fields. Each expanded connection has its own Dimensions, Measures, -and nested Connections; the Connections heading is omitted when there are no further links. +Expand a connection to load its dimensions, measures, and nested connections. Inside connections, +fields and further links appear without section headings. Drag connected fields onto a shelf, double-click them, or press Enter to add them to Rows. Measures are aggregated automatically. The worksheet keeps its original source table and uses the full diff --git a/products/data_warehouse/frontend/bi/BIConnectionGroup.tsx b/products/data_warehouse/frontend/bi/BIConnectionGroup.tsx index 722e80d4eac3..05a2d358062e 100644 --- a/products/data_warehouse/frontend/bi/BIConnectionGroup.tsx +++ b/products/data_warehouse/frontend/bi/BIConnectionGroup.tsx @@ -16,7 +16,6 @@ export function BIConnectionGroup({ connections }: { connections: BIConnection[] } return (
-
Connections
{connections.map((connection) => (
+ {filteredConnections.length ? ( +
Connections
+ ) : null} ) diff --git a/products/data_warehouse/frontend/bi/BIDataPaneSection.tsx b/products/data_warehouse/frontend/bi/BIDataPaneSection.tsx index 216a0a183b13..7d3ccc1babf8 100644 --- a/products/data_warehouse/frontend/bi/BIDataPaneSection.tsx +++ b/products/data_warehouse/frontend/bi/BIDataPaneSection.tsx @@ -43,7 +43,7 @@ export function BIDataPaneSection({ emptyText, path = [], }: { - title: string + title?: string fields: BIField[] measure: boolean emptyText: string @@ -58,7 +58,7 @@ export function BIDataPaneSection({ return (
-
{title}
+ {title ?
{title}
: null} {fields.length === 0 ? {emptyText} : null} {fields.map((field) => (
) : connection.state === 'missing' ? ( - This connection's fields are unavailable. + Fields are unavailable. Check that the linked table still exists. ) : ( <> diff --git a/products/data_warehouse/frontend/bi/BIDataPaneSection.tsx b/products/data_warehouse/frontend/bi/BIDataPaneSection.tsx index 7d3ccc1babf8..03d9ece1968f 100644 --- a/products/data_warehouse/frontend/bi/BIDataPaneSection.tsx +++ b/products/data_warehouse/frontend/bi/BIDataPaneSection.tsx @@ -75,8 +75,8 @@ export function BIDataPaneSection({ event.dataTransfer.setData(BI_FIELD_DRAG_MIME_TYPE, serializeBIField(field)) }} onDoubleClick={() => addField(field)} - onKeyDown={(event) => { - if (event.key === 'Enter') { + onClick={(event) => { + if (event.detail === 0) { addField(field) } }} diff --git a/products/data_warehouse/frontend/bi/biConnectionTree.test.ts b/products/data_warehouse/frontend/bi/biConnectionTree.test.ts index c8a1656c9704..05bf05b00b0a 100644 --- a/products/data_warehouse/frontend/bi/biConnectionTree.test.ts +++ b/products/data_warehouse/frontend/bi/biConnectionTree.test.ts @@ -67,6 +67,17 @@ describe('BI connections', () => { } ) + it('uses the PostHog table ID for hydration and status', () => { + const catalog = { ...tables, persons: { ...table('persons', []), id: 'person-table-id' } } + const expanded = ['["person"]'] + expect(getPendingBIConnectionTables(buildBIConnections(source, catalog, expanded, {}, false))).toEqual([ + 'person-table-id', + ]) + expect(buildBIConnections(source, catalog, expanded, { 'person-table-id': 'error' }, false)[0].state).toBe( + 'error' + ) + }) + it('resolves linked aliases and only expands requested paths through cyclic tables', () => { const catalog = { ...tables, diff --git a/products/data_warehouse/frontend/bi/biConnectionTree.ts b/products/data_warehouse/frontend/bi/biConnectionTree.ts index 335fd40c1c7e..9e639c873c2a 100644 --- a/products/data_warehouse/frontend/bi/biConnectionTree.ts +++ b/products/data_warehouse/frontend/bi/biConnectionTree.ts @@ -15,6 +15,10 @@ export interface BIConnection { connections: BIConnection[] } +function getHydrationKey(table: DatabaseSchemaTable): string { + return table.type === 'posthog' ? table.id : table.name +} + function getConnectionState( field: DatabaseSchemaField, table: DatabaseSchemaTable | null, @@ -81,7 +85,7 @@ export function buildBIConnections( ? getConnectionState( originalField, pendingTable, - tableFieldsStatus[pendingTable.name], + tableFieldsStatus[getHydrationKey(pendingTable)], databaseFieldsComplete ) : null @@ -94,13 +98,13 @@ export function buildBIConnections( const childPath = [...path, originalField.name] const id = JSON.stringify(childPath) const table = pendingTable ?? getTable(field?.table) - const status = table ? tableFieldsStatus[table.name] : undefined + const status = table ? tableFieldsStatus[getHydrationKey(table)] : undefined const state = pendingState ?? getConnectionState(field!, table, status, databaseFieldsComplete) const connection: BIConnection = { id, name: originalField.name, path: childPath, - tableName: table?.name, + tableName: table ? getHydrationKey(table) : undefined, expanded: expanded.has(id), state, fields: { dimensions: [], measures: [] }, From b6ff5692173547e0927f93ff7152daaf0bbbbfd1 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Sat, 3 Oct 2026 12:27:15 +0200 Subject: [PATCH 6/7] chore(sql-editor): make BI stories independent of sidebar state --- .../scenes/data-warehouse/editor/SQLEditorScene.stories.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/scenes/data-warehouse/editor/SQLEditorScene.stories.tsx b/frontend/src/scenes/data-warehouse/editor/SQLEditorScene.stories.tsx index ebba526066f0..5534d9f08841 100644 --- a/frontend/src/scenes/data-warehouse/editor/SQLEditorScene.stories.tsx +++ b/frontend/src/scenes/data-warehouse/editor/SQLEditorScene.stories.tsx @@ -821,7 +821,9 @@ export const BIConnections: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement) - await canvas.findByText('Locate', {}, { timeout: 15000 }) + await waitFor(() => expect(canvasElement.querySelector('[data-attr="bi-editor-data-source"]')).toBeVisible(), { + timeout: 15000, + }) await userEvent.click(canvas.getByRole('button', { name: 'SQL' })) await userEvent.click(canvas.getByRole('button', { name: 'BI' })) const autoUpdate = canvasElement.querySelector('[data-attr="bi-editor-auto-update"]')! From 74f46ef7b13485b1a74c336c7dbcf92e2293ddea Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:07:00 +0000 Subject: [PATCH 7/7] chore(visual): update storybook baselines 4 updated Run: 47d17024-29f2-42ee-be8a-ec4027873f19 Co-authored-by: mariusandra <53387+mariusandra@users.noreply.github.com> --- frontend/snapshots.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/snapshots.yml b/frontend/snapshots.yml index 963afb4d948e..5551a329e055 100644 --- a/frontend/snapshots.yml +++ b/frontend/snapshots.yml @@ -9506,6 +9506,10 @@ snapshots: hash: v1.k794b7964.25fd8a22fa490bf3e3e600a984b131b28202b8172e46e1a35e090694dfe63e2f.UBgi2bw3KLPn9F1RDJIVKYgfFlzVDD2yZj4iUPufRVA scenes-app-data-warehouse-sql-editor--bi-calculated-measure-editor--light: hash: v1.k794b7964.66016e766d5d3b78aae28be29d15e9bbf7ebb60b48de6d488eb7b10ea4676afd.wpa1Nkk9kYDByTPyA-MjGrtArnLDfzP4S6zkO34CMfo + scenes-app-data-warehouse-sql-editor--bi-connections--dark: + hash: v1.k794b7964.1f2ab2237c64512591fa64ede2d01c957272510b58e8d280d03f972947127f18.FGHAY9evifk4UKdQC7B9nSpLfbdJH9G26TeeACebtLY + scenes-app-data-warehouse-sql-editor--bi-connections--light: + hash: v1.k794b7964.6cc3f998d8be9bf398eaaa215c3f8eaa9dc6557a2008361d3ba9bf3bf9d91da2.ddofcjJWgAQ_QZYP6WkNgJIp_TeW5k1Wmc_NTG1M7ZQ scenes-app-data-warehouse-sql-editor--bi-data-source-picker--dark: hash: v1.k794b7964.5a7a0a5291dae3a67320c33fe0d7e087ab35dcd5738ee99f101735cd7fe6895d.v84-mZ0JtjVV_28syxCq0lC0DPMk7Z4LPSKq_seARBg scenes-app-data-warehouse-sql-editor--bi-data-source-picker--light: @@ -9519,9 +9523,9 @@ snapshots: scenes-app-data-warehouse-sql-editor--bi-quick-filters--light: hash: v1.k794b7964.7d055020d8b9cdc001efc8b5ca146f2d22e7c916af54b7361f2d8f61d59b940f.11bvglysenXe98m_innE4VvTdCUKMhRuSS62QGmFscM scenes-app-data-warehouse-sql-editor--bi-quick-filters-narrow--dark: - hash: v1.k794b7964.3a3d179dc8ec4fed037e1e0737ddad5dea4f828b262e35d07ae5c0dcfec1447d.dqy-xY-ApF2QSGtDMf_HIMrtyrPbFAnhhWHB8cmi4M8 + hash: v1.k794b7964.9438ec9789fe83709762909669ffa2b2377eab421066004a257c7467ea941ad1.zz_SahLmEDK9GozyYfPrB5ydS01sT-n0L9kpHjIPCd0 scenes-app-data-warehouse-sql-editor--bi-quick-filters-narrow--light: - hash: v1.k794b7964.46ca9e07669ba124d6d051b6b0cf1dbbf30b441533a9eaa064c3492577893e93.KhY5WMXImePUoV5bH-nrNztp1axD08hcOl4kfH-uaYQ + hash: v1.k794b7964.27d42f80122f06e2159d402c8887260e3a057bae29102b96b66f6de97c4116c3.04u1T8VmDQZWOP90QHr9sO19BElmuaemVAxK-394fHc scenes-app-data-warehouse-sql-editor--edited-insight--dark: hash: v1.k794b7964.94eba9d351ec4c6b649fc4b09f5d4a69fed2995448c0cc1dac5d63d3df0ad20c.U6hnpHxuk9RKFoPU8bc536a9dTuzluwHE0pF4J0nPYc scenes-app-data-warehouse-sql-editor--edited-insight--light: