From 080233054bed6d6f322b4a4c85800050e4a4eda4 Mon Sep 17 00:00:00 2001 From: Jonny Dixon <45681293+acrylJonny@users.noreply.github.com> Date: Fri, 24 Oct 2025 21:52:45 +0100 Subject: [PATCH] fix(ui/column-stats): fix unopenable side panel for nested column stats (#14874) --- .../dataset/profile/schema/utils/utils.ts | 11 +- .../columnStats/ColumnStatsTable.tsx | 102 +++--- .../columnStats/ColumnStatsTable.utils.ts | 129 +++++++ .../__tests__/ColumnStatsTable.test.tsx | 205 ++++++++++++ .../__tests__/ColumnStatsTable.utils.test.ts | 314 ++++++++++++++++++ .../__tests__/fieldPathUtils.test.ts | 98 ++++++ .../columnStats/useGetColumnStatsColumns.tsx | 25 +- 7 files changed, 841 insertions(+), 43 deletions(-) create mode 100644 datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Stats/StatsTabV2/columnStats/ColumnStatsTable.utils.ts create mode 100644 datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Stats/StatsTabV2/columnStats/__tests__/ColumnStatsTable.test.tsx create mode 100644 datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Stats/StatsTabV2/columnStats/__tests__/ColumnStatsTable.utils.test.ts create mode 100644 datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Stats/StatsTabV2/columnStats/__tests__/fieldPathUtils.test.ts diff --git a/datahub-web-react/src/app/entityV2/dataset/profile/schema/utils/utils.ts b/datahub-web-react/src/app/entityV2/dataset/profile/schema/utils/utils.ts index 8afb92c60b..ff3e2e53c8 100644 --- a/datahub-web-react/src/app/entityV2/dataset/profile/schema/utils/utils.ts +++ b/datahub-web-react/src/app/entityV2/dataset/profile/schema/utils/utils.ts @@ -58,10 +58,17 @@ export function downgradeV2FieldPath(fieldPath?: string | null) { const cleanedFieldPath = fieldPath.replace(KEY_SCHEMA_PREFIX, '').replace(VERSION_PREFIX, ''); - // strip out all annotation segments + // Remove all bracket annotations (e.g., [0], [*], [key]) from the field path return cleanedFieldPath .split('.') - .map((segment) => (segment.startsWith('[') ? null : segment)) + .map((segment) => { + // Remove segments that are entirely brackets (e.g., "[0]", "[*]") + if (segment.startsWith('[') && segment.endsWith(']')) { + return null; + } + // Remove bracket suffixes from segments (e.g., "addresses[0]" -> "addresses") + return segment.replace(/\[[^\]]*\]/g, ''); + }) .filter(Boolean) .join('.'); } diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Stats/StatsTabV2/columnStats/ColumnStatsTable.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Stats/StatsTabV2/columnStats/ColumnStatsTable.tsx index ffa15aa502..fb1f6bff8d 100644 --- a/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Stats/StatsTabV2/columnStats/ColumnStatsTable.tsx +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Stats/StatsTabV2/columnStats/ColumnStatsTable.tsx @@ -2,14 +2,36 @@ import { Table, Text } from '@components'; import React, { useEffect, useMemo, useRef, useState } from 'react'; import styled from 'styled-components'; +import { ExtendedSchemaFields } from '@app/entityV2/dataset/profile/schema/utils/types'; import SchemaFieldDrawer from '@app/entityV2/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/SchemaFieldDrawer'; import { useGetEntityWithSchema } from '@app/entityV2/shared/tabs/Dataset/Schema/useGetEntitySchema'; import useKeyboardControls from '@app/entityV2/shared/tabs/Dataset/Schema/useKeyboardControls'; import { decimalToPercentStr } from '@app/entityV2/shared/tabs/Dataset/Schema/utils/statsUtil'; +import { + createStatsOnlyField, + filterColumnStatsByQuery, + flattenFields, + handleRowScrollIntoView, + mapToSchemaFields, +} from '@app/entityV2/shared/tabs/Dataset/Stats/StatsTabV2/columnStats/ColumnStatsTable.utils'; import { useGetColumnStatsColumns } from '@app/entityV2/shared/tabs/Dataset/Stats/StatsTabV2/columnStats/useGetColumnStatsColumns'; import { isPresent } from '@app/entityV2/shared/tabs/Dataset/Stats/StatsTabV2/utils'; import { downgradeV2FieldPath, groupByFieldPath } from '@src/app/entityV2/dataset/profile/schema/utils/utils'; -import { DatasetFieldProfile } from '@src/types.generated'; + +// Local type definitions since generated types aren't available +interface DatasetFieldProfile { + fieldPath: string; + nullCount?: number | null; + nullProportion?: number | null; + uniqueCount?: number | null; + min?: string | null; + max?: string | null; +} + +// Extended type that includes the fieldPath property we know exists +interface ExtendedSchemaFieldsWithFieldPath extends ExtendedSchemaFields { + fieldPath: string; +} const EmptyContainer = styled.div` display: flex; @@ -22,25 +44,28 @@ const EmptyContainer = styled.div` `; interface Props { - columnStats: Array; + columnStats: DatasetFieldProfile[]; searchQuery: string; } -const ColumnStatsTable = ({ columnStats, searchQuery }: Props) => { +function ColumnStatsTable({ columnStats, searchQuery }: Props) { const { entityWithSchema } = useGetEntityWithSchema(); - const schemaMetadata: any = entityWithSchema?.schemaMetadata || undefined; - const editableSchemaMetadata: any = entityWithSchema?.editableSchemaMetadata || undefined; - const fields = schemaMetadata?.fields; + const rawFields = entityWithSchema?.schemaMetadata?.fields; + + const fields = useMemo(() => { + return rawFields ? mapToSchemaFields(rawFields) : []; + }, [rawFields]); const columnStatsTableData = useMemo( () => - columnStats.map((doc) => ({ - column: downgradeV2FieldPath(doc.fieldPath), - type: fields?.find((field) => field.fieldPath === doc.fieldPath)?.type, - nullPercentage: isPresent(doc.nullProportion) && decimalToPercentStr(doc.nullProportion, 2), - uniqueValues: isPresent(doc.uniqueCount) && doc.uniqueCount.toString(), - min: doc.min, - max: doc.max, + columnStats.map((stat) => ({ + column: downgradeV2FieldPath(stat.fieldPath), + originalFieldPath: stat.fieldPath, + type: fields.find((field) => field.fieldPath === stat.fieldPath)?.type, + nullPercentage: isPresent(stat.nullProportion) && decimalToPercentStr(stat.nullProportion, 2), + uniqueValues: isPresent(stat.uniqueCount) && stat.uniqueCount.toString(), + min: stat.min, + max: stat.max, })) || [], [columnStats, fields], ); @@ -48,12 +73,20 @@ const ColumnStatsTable = ({ columnStats, searchQuery }: Props) => { const [expandedDrawerFieldPath, setExpandedDrawerFieldPath] = useState(null); const rows = useMemo(() => { - return groupByFieldPath(fields); - }, [fields]); + const schemaFields = fields; - const filteredData = columnStatsTableData.filter((columnStat) => - columnStat.column?.toLowerCase().includes(searchQuery.toLowerCase()), - ); + // Add fields from column stats that don't exist in schema + const statsOnlyFields = columnStats + .filter((stat) => !schemaFields.find((field) => field.fieldPath === stat.fieldPath)) + .map(createStatsOnlyField); + + const combinedFields = [...schemaFields, ...statsOnlyFields]; + const groupedFields = groupByFieldPath(combinedFields as any); + + return flattenFields(groupedFields); + }, [fields, columnStats]); + + const filteredData = filterColumnStatsByQuery(columnStatsTableData, searchQuery); const columnStatsColumns = useGetColumnStatsColumns({ tableData: columnStatsTableData, @@ -72,7 +105,9 @@ const ColumnStatsTable = ({ columnStats, searchQuery }: Props) => { useEffect(() => { if (expandedDrawerFieldPath) { - const selectedIndex = rows.findIndex((row) => row.fieldPath === expandedDrawerFieldPath); + const selectedIndex = rows.findIndex( + (row) => (row as ExtendedSchemaFieldsWithFieldPath).fieldPath === expandedDrawerFieldPath, + ); const row = rowRefs.current[selectedIndex]; const header = headerRef.current; @@ -83,20 +118,9 @@ const ColumnStatsTable = ({ columnStats, searchQuery }: Props) => { block: 'nearest', }); } - // To bring the row hidden behind the fixed header into view fully + // Adjust scroll position to account for fixed header setTimeout(() => { - if (row && header) { - const rowRect = row.getBoundingClientRect(); - const headerRect = header.getBoundingClientRect(); - const rowTop = rowRect.top; - const headerBottom = headerRect.bottom; - const scrollContainer = row.closest('table')?.parentElement; - - if (scrollContainer && rowTop < headerBottom) { - const scrollAmount = headerBottom - rowTop; - scrollContainer.scrollTop -= scrollAmount; - } - } + handleRowScrollIntoView(row, header); }, 100); } }, [expandedDrawerFieldPath, rows]); @@ -112,11 +136,13 @@ const ColumnStatsTable = ({ columnStats, searchQuery }: Props) => { } const getRowClassName = (record) => { - return expandedDrawerFieldPath === record.column ? 'selected-row' : ''; + return expandedDrawerFieldPath === record.originalFieldPath ? 'selected-row' : ''; }; const onRowClick = (record) => { - setExpandedDrawerFieldPath(expandedDrawerFieldPath === record.column ? null : record.column); + setExpandedDrawerFieldPath( + expandedDrawerFieldPath === record.originalFieldPath ? null : record.originalFieldPath, + ); }; return ( @@ -132,11 +158,11 @@ const ColumnStatsTable = ({ columnStats, searchQuery }: Props) => { rowRefs={rowRefs} headerRef={headerRef} /> - {!!fields && ( + {fields.length > 0 && ( { )} ); -}; +} export default ColumnStatsTable; diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Stats/StatsTabV2/columnStats/ColumnStatsTable.utils.ts b/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Stats/StatsTabV2/columnStats/ColumnStatsTable.utils.ts new file mode 100644 index 0000000000..9a92bed92a --- /dev/null +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Stats/StatsTabV2/columnStats/ColumnStatsTable.utils.ts @@ -0,0 +1,129 @@ +import { ExtendedSchemaFields } from '@app/entityV2/dataset/profile/schema/utils/types'; + +// Type definitions for safe type mapping +interface SchemaField { + fieldPath: string; + type?: any; + nativeDataType?: string | null; + schemaFieldEntity?: any; + nullable: boolean; + recursive?: boolean; + description?: string | null; + children?: SchemaField[]; +} + +// For DatasetFieldProfile, we'll define it locally since the generated types aren't available +interface DatasetFieldProfile { + fieldPath: string; + nullCount?: number | null; + nullProportion?: number | null; + uniqueCount?: number | null; + min?: string | null; + max?: string | null; +} + +/** + * Infers if a field is nullable based on column statistics. + * Uses null count or proportion data when available, defaults to nullable. + */ +export function inferIsFieldNullable(stat: DatasetFieldProfile): boolean { + if (stat.nullCount != null) { + return stat.nullCount > 0; + } + + if (stat.nullProportion != null) { + return stat.nullProportion > 0; + } + + return true; // Default to nullable when data unavailable +} + +/** + * Safely maps unknown field data to SchemaField type. + */ +export function mapToSchemaField(field: unknown): SchemaField { + if (!field || typeof field !== 'object') { + throw new Error('Invalid field data provided to mapToSchemaField'); + } + + const fieldObj = field as Record; + + return { + fieldPath: fieldObj.fieldPath || '', + type: fieldObj.type || null, + nativeDataType: fieldObj.nativeDataType || null, + schemaFieldEntity: fieldObj.schemaFieldEntity || null, + nullable: fieldObj.nullable ?? false, + recursive: fieldObj.recursive || false, + description: fieldObj.description || null, + children: fieldObj.children || undefined, + }; +} + +/** + * Safely maps an array of unknown field data to SchemaField array. + */ +export function mapToSchemaFields(fields: unknown[]): SchemaField[] { + if (!Array.isArray(fields)) { + return []; + } + + return fields.map(mapToSchemaField); +} + +/** + * Creates a stats-only field object for fields that exist in column stats but not in schema. + */ +export function createStatsOnlyField(stat: DatasetFieldProfile): SchemaField { + return { + fieldPath: stat.fieldPath, + type: null, + nativeDataType: null, + schemaFieldEntity: null, + nullable: inferIsFieldNullable(stat), + recursive: false, + description: null, + }; +} + +/** + * Flattens nested field hierarchies to enable drawer field path matching. + */ +export function flattenFields(fieldList: ExtendedSchemaFields[]): ExtendedSchemaFields[] { + const result: ExtendedSchemaFields[] = []; + fieldList.forEach((field) => { + result.push(field); + if (field.children) { + result.push(...flattenFields(field.children)); + } + }); + return result; +} + +/** + * Handles scroll adjustment when a row is selected to ensure it's visible. + */ +export function handleRowScrollIntoView(row: HTMLTableRowElement | undefined, header: HTMLTableSectionElement | null) { + if (!row || !header) return; + + const rowRect = row.getBoundingClientRect(); + const headerRect = header.getBoundingClientRect(); + const rowTop = rowRect.top; + const headerBottom = headerRect.bottom; + const scrollContainer = row.closest('table')?.parentElement; + + if (scrollContainer && rowTop < headerBottom) { + const scrollAmount = headerBottom - rowTop; + scrollContainer.scrollTop -= scrollAmount; + } +} + +/** + * Filters column stats data based on search query. + */ +export function filterColumnStatsByQuery(data: any[], query: string) { + if (!query.trim()) return data; + + const lowercaseQuery = query.toLowerCase(); + return data.filter((columnStat) => columnStat.column?.toLowerCase().includes(lowercaseQuery)); +} diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Stats/StatsTabV2/columnStats/__tests__/ColumnStatsTable.test.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Stats/StatsTabV2/columnStats/__tests__/ColumnStatsTable.test.tsx new file mode 100644 index 0000000000..b0f8ccccfb --- /dev/null +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Stats/StatsTabV2/columnStats/__tests__/ColumnStatsTable.test.tsx @@ -0,0 +1,205 @@ +import { MockedProvider } from '@apollo/client/testing'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { vi } from 'vitest'; + +import { useGetEntityWithSchema } from '@app/entityV2/shared/tabs/Dataset/Schema/useGetEntitySchema'; +import ColumnStatsTable from '@app/entityV2/shared/tabs/Dataset/Stats/StatsTabV2/columnStats/ColumnStatsTable'; +import TestPageContainer from '@utils/test-utils/TestPageContainer'; + +// Local type definition to match the component's interface +interface DatasetFieldProfile { + fieldPath: string; + nullCount?: number | null; + nullProportion?: number | null; + uniqueCount?: number | null; + uniqueProportion?: number | null; + min?: string | null; + max?: string | null; +} + +// Mock the hooks and dependencies +vi.mock('@app/entityV2/shared/tabs/Dataset/Schema/useGetEntitySchema'); +vi.mock('@app/entityV2/shared/tabs/Dataset/Schema/useKeyboardControls', () => ({ + __esModule: true, + default: () => ({ + selectPreviousField: vi.fn(), + selectNextField: vi.fn(), + }), +})); + +const mockUseGetEntityWithSchema = vi.mocked(useGetEntityWithSchema); + +describe('ColumnStatsTable', () => { + const mockSchemaFields = [ + { + fieldPath: 'customer_id', + type: { type: 'STRING' }, + nativeDataType: 'VARCHAR', + nullable: false, + recursive: false, + }, + { + fieldPath: 'customer_details.email', + type: { type: 'STRING' }, + nativeDataType: 'VARCHAR', + nullable: true, + recursive: false, + }, + ]; + + const mockColumnStats: DatasetFieldProfile[] = [ + { + fieldPath: 'customer_id', + nullCount: 0, + nullProportion: 0.0, + uniqueCount: 100, + uniqueProportion: 1.0, + min: '1', + max: '100', + }, + { + fieldPath: 'customer_details.email', + nullCount: 5, + nullProportion: 0.05, + uniqueCount: 95, + uniqueProportion: 0.95, + min: 'alice@example.com', + max: 'zoe@example.com', + }, + ]; + + beforeEach(() => { + mockUseGetEntityWithSchema.mockReturnValue({ + entityWithSchema: { + schemaMetadata: { + fields: mockSchemaFields as any, + name: 'test_schema', + version: 1, + platformUrn: 'urn:li:dataPlatform:test', + hash: 'test_hash', + } as any, + editableSchemaMetadata: null, + }, + loading: false, + refetch: vi.fn(), + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('renders basic column stats table', () => { + render( + + + + + , + ); + + // Verify field names are displayed + expect(screen.getByText('customer_id')).toBeInTheDocument(); + expect(screen.getByText('customer_details.email')).toBeInTheDocument(); + + // Verify statistics are displayed + expect(screen.getByText('0%')).toBeInTheDocument(); + expect(screen.getByText('5.00%')).toBeInTheDocument(); + }); + + it('filters results based on search query', () => { + render( + + + + + , + ); + + // Verify only matching field is displayed (text split by highlighting) + expect(screen.getByText('customer_details.')).toBeInTheDocument(); + expect(screen.getByText('email')).toBeInTheDocument(); + expect(screen.queryByText('customer_id')).not.toBeInTheDocument(); + }); + + it('handles nested field paths correctly', () => { + render( + + + + + , + ); + + // Verify nested field row is rendered correctly + const nestedFieldRow = screen.getByText('customer_details.email').closest('tr'); + expect(nestedFieldRow).toBeInTheDocument(); + + // Verify View buttons are available for all fields + const viewButtons = screen.getAllByText('View'); + expect(viewButtons.length).toBe(2); + }); + + it('shows empty state when no results found', () => { + render( + + + + + , + ); + + expect(screen.getByText('No search results!')).toBeInTheDocument(); + }); + + it('handles simple field paths', () => { + const simpleStats: DatasetFieldProfile[] = [ + { + fieldPath: 'simple_field', + nullCount: 10, + nullProportion: 0.1, + uniqueCount: 90, + uniqueProportion: 0.9, + }, + ]; + + const simpleFields = [ + { + fieldPath: 'simple_field', + type: { type: 'STRING' }, + nativeDataType: 'VARCHAR', + nullable: true, + recursive: false, + }, + ]; + + mockUseGetEntityWithSchema.mockReturnValue({ + entityWithSchema: { + schemaMetadata: { + fields: simpleFields as any, + name: 'test_schema', + version: 1, + platformUrn: 'urn:li:dataPlatform:test', + hash: 'test_hash', + } as any, + editableSchemaMetadata: null, + }, + loading: false, + refetch: vi.fn(), + }); + + render( + + + + + , + ); + + // Verify simple field is displayed correctly + expect(screen.getByText('simple_field')).toBeInTheDocument(); + expect(screen.getByText('10.00%')).toBeInTheDocument(); + expect(screen.getByRole('table')).toBeInTheDocument(); + }); +}); diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Stats/StatsTabV2/columnStats/__tests__/ColumnStatsTable.utils.test.ts b/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Stats/StatsTabV2/columnStats/__tests__/ColumnStatsTable.utils.test.ts new file mode 100644 index 0000000000..27ef428038 --- /dev/null +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Stats/StatsTabV2/columnStats/__tests__/ColumnStatsTable.utils.test.ts @@ -0,0 +1,314 @@ +import { + createStatsOnlyField, + filterColumnStatsByQuery, + flattenFields, + handleRowScrollIntoView, + inferIsFieldNullable, + mapToSchemaField, + mapToSchemaFields, +} from '@app/entityV2/shared/tabs/Dataset/Stats/StatsTabV2/columnStats/ColumnStatsTable.utils'; + +// Local type definitions for testing +interface DatasetFieldProfile { + fieldPath: string; + nullCount?: number | null; + nullProportion?: number | null; + uniqueCount?: number | null; + min?: string | null; + max?: string | null; +} + +interface ExtendedSchemaFields { + fieldPath: string; + type: any; + children?: ExtendedSchemaFields[]; + depth?: number; + nullable: boolean; + recursive: boolean; +} + +describe('ColumnStatsTable Utils', () => { + describe('inferIsFieldNullable', () => { + it('should return true when nullCount is greater than 0', () => { + const stat: DatasetFieldProfile = { + fieldPath: 'test_field', + nullCount: 5, + }; + expect(inferIsFieldNullable(stat)).toBe(true); + }); + + it('should return false when nullCount is 0', () => { + const stat: DatasetFieldProfile = { + fieldPath: 'test_field', + nullCount: 0, + }; + expect(inferIsFieldNullable(stat)).toBe(false); + }); + + it('should return true when nullProportion is greater than 0', () => { + const stat: DatasetFieldProfile = { + fieldPath: 'test_field', + nullProportion: 0.1, + }; + expect(inferIsFieldNullable(stat)).toBe(true); + }); + + it('should return false when nullProportion is 0', () => { + const stat: DatasetFieldProfile = { + fieldPath: 'test_field', + nullProportion: 0.0, + }; + expect(inferIsFieldNullable(stat)).toBe(false); + }); + + it('should default to true when no null data is available', () => { + const stat: DatasetFieldProfile = { + fieldPath: 'test_field', + }; + expect(inferIsFieldNullable(stat)).toBe(true); + }); + + it('should prioritize nullCount over nullProportion', () => { + const stat: DatasetFieldProfile = { + fieldPath: 'test_field', + nullCount: 0, + nullProportion: 0.1, + }; + expect(inferIsFieldNullable(stat)).toBe(false); + }); + }); + + describe('mapToSchemaField', () => { + it('should map valid field data correctly', () => { + const fieldData = { + fieldPath: 'test_field', + type: { type: 'STRING' }, + nativeDataType: 'VARCHAR', + nullable: true, + recursive: false, + description: 'Test field', + }; + + const result = mapToSchemaField(fieldData); + + expect(result).toEqual({ + fieldPath: 'test_field', + type: { type: 'STRING' }, + nativeDataType: 'VARCHAR', + schemaFieldEntity: null, + nullable: true, + recursive: false, + description: 'Test field', + children: undefined, + }); + }); + + it('should handle missing optional properties', () => { + const fieldData = { + fieldPath: 'test_field', + }; + + const result = mapToSchemaField(fieldData); + + expect(result).toEqual({ + fieldPath: 'test_field', + type: null, + nativeDataType: null, + schemaFieldEntity: null, + nullable: false, + recursive: false, + description: null, + children: undefined, + }); + }); + + it('should throw error for invalid input', () => { + expect(() => mapToSchemaField(null)).toThrow('Invalid field data provided to mapToSchemaField'); + expect(() => mapToSchemaField(undefined)).toThrow('Invalid field data provided to mapToSchemaField'); + expect(() => mapToSchemaField('string')).toThrow('Invalid field data provided to mapToSchemaField'); + }); + }); + + describe('mapToSchemaFields', () => { + it('should map array of field data correctly', () => { + const fieldsData = [ + { fieldPath: 'field1', type: 'STRING' }, + { fieldPath: 'field2', type: 'NUMBER' }, + ]; + + const result = mapToSchemaFields(fieldsData); + + expect(result).toHaveLength(2); + expect(result[0].fieldPath).toBe('field1'); + expect(result[1].fieldPath).toBe('field2'); + }); + + it('should return empty array for non-array input', () => { + expect(mapToSchemaFields(null as any)).toEqual([]); + expect(mapToSchemaFields(undefined as any)).toEqual([]); + expect(mapToSchemaFields('string' as any)).toEqual([]); + }); + }); + + describe('createStatsOnlyField', () => { + it('should create field with inferred nullability', () => { + const stat: DatasetFieldProfile = { + fieldPath: 'stats_only_field', + nullCount: 5, + }; + + const result = createStatsOnlyField(stat); + + expect(result).toEqual({ + fieldPath: 'stats_only_field', + type: null, + nativeDataType: null, + schemaFieldEntity: null, + nullable: true, // inferred from nullCount > 0 + recursive: false, + description: null, + }); + }); + }); + + describe('flattenFields', () => { + it('should flatten nested field hierarchies', () => { + const fields: ExtendedSchemaFields[] = [ + { + fieldPath: 'parent', + type: null, + nullable: false, + recursive: false, + children: [ + { + fieldPath: 'parent.child1', + type: null, + nullable: false, + recursive: false, + children: [ + { + fieldPath: 'parent.child1.grandchild', + type: null, + nullable: false, + recursive: false, + }, + ], + }, + { fieldPath: 'parent.child2', type: null, nullable: false, recursive: false }, + ], + }, + { fieldPath: 'sibling', type: null, nullable: false, recursive: false }, + ]; + + const result = flattenFields(fields); + + expect(result).toHaveLength(5); + expect(result.map((f) => f.fieldPath)).toEqual([ + 'parent', + 'parent.child1', + 'parent.child1.grandchild', + 'parent.child2', + 'sibling', + ]); + }); + + it('should handle fields without children', () => { + const fields: ExtendedSchemaFields[] = [ + { fieldPath: 'field1', type: null, nullable: false, recursive: false }, + { fieldPath: 'field2', type: null, nullable: false, recursive: false }, + ]; + + const result = flattenFields(fields); + + expect(result).toHaveLength(2); + expect(result.map((f) => f.fieldPath)).toEqual(['field1', 'field2']); + }); + }); + + describe('filterColumnStatsByQuery', () => { + const testData = [ + { column: 'customer_id' }, + { column: 'customer_name' }, + { column: 'order_total' }, + { column: 'product_category' }, + ]; + + it('should filter data based on query', () => { + const result = filterColumnStatsByQuery(testData, 'customer'); + expect(result).toHaveLength(2); + expect(result.map((item) => item.column)).toEqual(['customer_id', 'customer_name']); + }); + + it('should be case insensitive', () => { + const result = filterColumnStatsByQuery(testData, 'CUSTOMER'); + expect(result).toHaveLength(2); + }); + + it('should return all data for empty query', () => { + const result = filterColumnStatsByQuery(testData, ''); + expect(result).toHaveLength(4); + }); + + it('should return all data for whitespace-only query', () => { + const result = filterColumnStatsByQuery(testData, ' '); + expect(result).toHaveLength(4); + }); + + it('should return empty array for no matches', () => { + const result = filterColumnStatsByQuery(testData, 'nonexistent'); + expect(result).toHaveLength(0); + }); + }); + + describe('handleRowScrollIntoView', () => { + let mockRow: HTMLTableRowElement; + let mockHeader: HTMLTableSectionElement; + let mockScrollContainer: HTMLElement; + + beforeEach(() => { + // Create mock DOM elements + mockScrollContainer = document.createElement('div'); + mockScrollContainer.scrollTop = 100; + + const mockTable = document.createElement('table'); + Object.defineProperty(mockTable, 'parentElement', { + value: mockScrollContainer, + writable: false, + }); + + mockRow = document.createElement('tr'); + mockRow.closest = vi.fn().mockReturnValue(mockTable); + mockRow.getBoundingClientRect = vi.fn().mockReturnValue({ + top: 50, + bottom: 100, + }); + + mockHeader = document.createElement('thead'); + mockHeader.getBoundingClientRect = vi.fn().mockReturnValue({ + top: 0, + bottom: 80, + }); + }); + + it('should adjust scroll when row is above header', () => { + handleRowScrollIntoView(mockRow, mockHeader); + expect(mockScrollContainer.scrollTop).toBe(70); // 100 - (80 - 50) + }); + + it('should not adjust scroll when row is below header', () => { + mockRow.getBoundingClientRect = vi.fn().mockReturnValue({ + top: 100, + bottom: 150, + }); + + handleRowScrollIntoView(mockRow, mockHeader); + expect(mockScrollContainer.scrollTop).toBe(100); // unchanged + }); + + it('should handle missing row or header gracefully', () => { + expect(() => handleRowScrollIntoView(undefined, mockHeader)).not.toThrow(); + expect(() => handleRowScrollIntoView(mockRow, null)).not.toThrow(); + expect(() => handleRowScrollIntoView(undefined, null)).not.toThrow(); + }); + }); +}); diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Stats/StatsTabV2/columnStats/__tests__/fieldPathUtils.test.ts b/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Stats/StatsTabV2/columnStats/__tests__/fieldPathUtils.test.ts new file mode 100644 index 0000000000..ae815843b3 --- /dev/null +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Stats/StatsTabV2/columnStats/__tests__/fieldPathUtils.test.ts @@ -0,0 +1,98 @@ +import { downgradeV2FieldPath } from '@src/app/entityV2/dataset/profile/schema/utils/utils'; + +describe('downgradeV2FieldPath', () => { + it('should handle simple field paths without modification', () => { + expect(downgradeV2FieldPath('simple_field')).toBe('simple_field'); + expect(downgradeV2FieldPath('user_name')).toBe('user_name'); + expect(downgradeV2FieldPath('id')).toBe('id'); + }); + + it('should handle nested field paths with dots', () => { + expect(downgradeV2FieldPath('user.name')).toBe('user.name'); + expect(downgradeV2FieldPath('address.street.name')).toBe('address.street.name'); + expect(downgradeV2FieldPath('metadata.created.timestamp')).toBe('metadata.created.timestamp'); + }); + + it('should remove array index segments starting with [', () => { + expect(downgradeV2FieldPath('user.addresses[0].street')).toBe('user.addresses.street'); + expect(downgradeV2FieldPath('items[*].price')).toBe('items.price'); + expect(downgradeV2FieldPath('data[123].value')).toBe('data.value'); + }); + + it('should handle complex nested paths with multiple brackets and dots', () => { + expect(downgradeV2FieldPath('metadata.tags[0].category.name')).toBe('metadata.tags.category.name'); + expect(downgradeV2FieldPath('users[*].addresses[0].coordinates[1]')).toBe('users.addresses.coordinates'); + expect(downgradeV2FieldPath('data.records[0].attributes.values[*].name')).toBe( + 'data.records.attributes.values.name', + ); + }); + + it('should handle edge cases with consecutive brackets', () => { + expect(downgradeV2FieldPath('data[0][1].value')).toBe('data.value'); + expect(downgradeV2FieldPath('matrix[*][*].cell')).toBe('matrix.cell'); + }); + + it('should handle field paths starting with brackets', () => { + expect(downgradeV2FieldPath('[0].value')).toBe('value'); + expect(downgradeV2FieldPath('[*].item.name')).toBe('item.name'); + }); + + it('should handle field paths ending with brackets', () => { + expect(downgradeV2FieldPath('data.items[0]')).toBe('data.items'); + expect(downgradeV2FieldPath('users.addresses[*]')).toBe('users.addresses'); + }); + + it('should handle null and undefined inputs', () => { + expect(downgradeV2FieldPath(null)).toBe(null); + expect(downgradeV2FieldPath(undefined)).toBe(undefined); + }); + + it('should handle empty string', () => { + expect(downgradeV2FieldPath('')).toBe(''); + }); + + it('should handle field paths with only brackets', () => { + expect(downgradeV2FieldPath('[0]')).toBe(''); + expect(downgradeV2FieldPath('[*]')).toBe(''); + expect(downgradeV2FieldPath('[0][1][2]')).toBe(''); + }); + + it('should handle mixed bracket formats', () => { + expect(downgradeV2FieldPath('data[0].items[*].tags[type].value')).toBe('data.items.tags.value'); + expect(downgradeV2FieldPath('users[id=123].profile.settings[0]')).toBe('users.profile.settings'); + }); + + it('should preserve field names that contain bracket-like characters but are not array indices', () => { + // This test ensures that field names themselves containing brackets are preserved + // if they don't start with [ (though this is an edge case) + expect(downgradeV2FieldPath('field_with_brackets].value')).toBe('field_with_brackets].value'); + }); + + // Test cases that specifically relate to our nested column drawer fix + describe('Nested Column Drawer Fix Scenarios', () => { + it('should handle typical nested object structures', () => { + expect(downgradeV2FieldPath('customer.address.street')).toBe('customer.address.street'); + expect(downgradeV2FieldPath('order.items[0].product.name')).toBe('order.items.product.name'); + }); + + it('should handle array of primitives', () => { + expect(downgradeV2FieldPath('tags[*]')).toBe('tags'); + expect(downgradeV2FieldPath('categories[0]')).toBe('categories'); + }); + + it('should handle deeply nested array structures', () => { + expect(downgradeV2FieldPath('data.levels[0].sublevels[*].items[0].value')).toBe( + 'data.levels.sublevels.items.value', + ); + }); + + it('should handle JSON-like structures common in datasets', () => { + expect(downgradeV2FieldPath('event.properties.custom_fields[user_id]')).toBe( + 'event.properties.custom_fields', + ); + expect(downgradeV2FieldPath('response.data.results[0].metadata.tags[*].name')).toBe( + 'response.data.results.metadata.tags.name', + ); + }); + }); +}); diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Stats/StatsTabV2/columnStats/useGetColumnStatsColumns.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Stats/StatsTabV2/columnStats/useGetColumnStatsColumns.tsx index d9b996c428..dcab038ddd 100644 --- a/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Stats/StatsTabV2/columnStats/useGetColumnStatsColumns.tsx +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Stats/StatsTabV2/columnStats/useGetColumnStatsColumns.tsx @@ -83,9 +83,22 @@ export const useGetColumnStatsColumns = ({ tableData, searchQuery, setExpandedDr { title: 'Type', key: 'type', - render: (record) => capitalizeFirstLetter(record.type?.toLowerCase()), + render: (record) => { + // Handle both object format { type: 'STRING' } and direct string format + if (!record.type) return ''; + + const typeString = typeof record.type === 'string' ? record.type : record.type.type; + return typeString ? capitalizeFirstLetter(typeString.toLowerCase()) : ''; + }, sorter: (sourceA, sourceB) => { - return sourceA.type.localeCompare(sourceB.type); + const getTypeString = (type: any): string => { + if (!type) return ''; + return typeof type === 'string' ? type : type.type || ''; + }; + + const typeA = getTypeString(sourceA.type); + const typeB = getTypeString(sourceB.type); + return typeA.localeCompare(typeB); }, }, ]; @@ -96,7 +109,13 @@ export const useGetColumnStatsColumns = ({ tableData, searchQuery, setExpandedDr key: 'view', render: (record) => ( -