Skip to content

Commit 0802330

Browse files
authored
fix(ui/column-stats): fix unopenable side panel for nested column stats (#14874)
1 parent 55c4692 commit 0802330

File tree

7 files changed

+841
-43
lines changed

7 files changed

+841
-43
lines changed

datahub-web-react/src/app/entityV2/dataset/profile/schema/utils/utils.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,17 @@ export function downgradeV2FieldPath(fieldPath?: string | null) {
5858

5959
const cleanedFieldPath = fieldPath.replace(KEY_SCHEMA_PREFIX, '').replace(VERSION_PREFIX, '');
6060

61-
// strip out all annotation segments
61+
// Remove all bracket annotations (e.g., [0], [*], [key]) from the field path
6262
return cleanedFieldPath
6363
.split('.')
64-
.map((segment) => (segment.startsWith('[') ? null : segment))
64+
.map((segment) => {
65+
// Remove segments that are entirely brackets (e.g., "[0]", "[*]")
66+
if (segment.startsWith('[') && segment.endsWith(']')) {
67+
return null;
68+
}
69+
// Remove bracket suffixes from segments (e.g., "addresses[0]" -> "addresses")
70+
return segment.replace(/\[[^\]]*\]/g, '');
71+
})
6572
.filter(Boolean)
6673
.join('.');
6774
}

datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Stats/StatsTabV2/columnStats/ColumnStatsTable.tsx

Lines changed: 64 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,36 @@ import { Table, Text } from '@components';
22
import React, { useEffect, useMemo, useRef, useState } from 'react';
33
import styled from 'styled-components';
44

5+
import { ExtendedSchemaFields } from '@app/entityV2/dataset/profile/schema/utils/types';
56
import SchemaFieldDrawer from '@app/entityV2/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/SchemaFieldDrawer';
67
import { useGetEntityWithSchema } from '@app/entityV2/shared/tabs/Dataset/Schema/useGetEntitySchema';
78
import useKeyboardControls from '@app/entityV2/shared/tabs/Dataset/Schema/useKeyboardControls';
89
import { decimalToPercentStr } from '@app/entityV2/shared/tabs/Dataset/Schema/utils/statsUtil';
10+
import {
11+
createStatsOnlyField,
12+
filterColumnStatsByQuery,
13+
flattenFields,
14+
handleRowScrollIntoView,
15+
mapToSchemaFields,
16+
} from '@app/entityV2/shared/tabs/Dataset/Stats/StatsTabV2/columnStats/ColumnStatsTable.utils';
917
import { useGetColumnStatsColumns } from '@app/entityV2/shared/tabs/Dataset/Stats/StatsTabV2/columnStats/useGetColumnStatsColumns';
1018
import { isPresent } from '@app/entityV2/shared/tabs/Dataset/Stats/StatsTabV2/utils';
1119
import { downgradeV2FieldPath, groupByFieldPath } from '@src/app/entityV2/dataset/profile/schema/utils/utils';
12-
import { DatasetFieldProfile } from '@src/types.generated';
20+
21+
// Local type definitions since generated types aren't available
22+
interface DatasetFieldProfile {
23+
fieldPath: string;
24+
nullCount?: number | null;
25+
nullProportion?: number | null;
26+
uniqueCount?: number | null;
27+
min?: string | null;
28+
max?: string | null;
29+
}
30+
31+
// Extended type that includes the fieldPath property we know exists
32+
interface ExtendedSchemaFieldsWithFieldPath extends ExtendedSchemaFields {
33+
fieldPath: string;
34+
}
1335

1436
const EmptyContainer = styled.div`
1537
display: flex;
@@ -22,38 +44,49 @@ const EmptyContainer = styled.div`
2244
`;
2345

2446
interface Props {
25-
columnStats: Array<DatasetFieldProfile>;
47+
columnStats: DatasetFieldProfile[];
2648
searchQuery: string;
2749
}
2850

29-
const ColumnStatsTable = ({ columnStats, searchQuery }: Props) => {
51+
function ColumnStatsTable({ columnStats, searchQuery }: Props) {
3052
const { entityWithSchema } = useGetEntityWithSchema();
31-
const schemaMetadata: any = entityWithSchema?.schemaMetadata || undefined;
32-
const editableSchemaMetadata: any = entityWithSchema?.editableSchemaMetadata || undefined;
33-
const fields = schemaMetadata?.fields;
53+
const rawFields = entityWithSchema?.schemaMetadata?.fields;
54+
55+
const fields = useMemo(() => {
56+
return rawFields ? mapToSchemaFields(rawFields) : [];
57+
}, [rawFields]);
3458

3559
const columnStatsTableData = useMemo(
3660
() =>
37-
columnStats.map((doc) => ({
38-
column: downgradeV2FieldPath(doc.fieldPath),
39-
type: fields?.find((field) => field.fieldPath === doc.fieldPath)?.type,
40-
nullPercentage: isPresent(doc.nullProportion) && decimalToPercentStr(doc.nullProportion, 2),
41-
uniqueValues: isPresent(doc.uniqueCount) && doc.uniqueCount.toString(),
42-
min: doc.min,
43-
max: doc.max,
61+
columnStats.map((stat) => ({
62+
column: downgradeV2FieldPath(stat.fieldPath),
63+
originalFieldPath: stat.fieldPath,
64+
type: fields.find((field) => field.fieldPath === stat.fieldPath)?.type,
65+
nullPercentage: isPresent(stat.nullProportion) && decimalToPercentStr(stat.nullProportion, 2),
66+
uniqueValues: isPresent(stat.uniqueCount) && stat.uniqueCount.toString(),
67+
min: stat.min,
68+
max: stat.max,
4469
})) || [],
4570
[columnStats, fields],
4671
);
4772

4873
const [expandedDrawerFieldPath, setExpandedDrawerFieldPath] = useState<string | null>(null);
4974

5075
const rows = useMemo(() => {
51-
return groupByFieldPath(fields);
52-
}, [fields]);
76+
const schemaFields = fields;
5377

54-
const filteredData = columnStatsTableData.filter((columnStat) =>
55-
columnStat.column?.toLowerCase().includes(searchQuery.toLowerCase()),
56-
);
78+
// Add fields from column stats that don't exist in schema
79+
const statsOnlyFields = columnStats
80+
.filter((stat) => !schemaFields.find((field) => field.fieldPath === stat.fieldPath))
81+
.map(createStatsOnlyField);
82+
83+
const combinedFields = [...schemaFields, ...statsOnlyFields];
84+
const groupedFields = groupByFieldPath(combinedFields as any);
85+
86+
return flattenFields(groupedFields);
87+
}, [fields, columnStats]);
88+
89+
const filteredData = filterColumnStatsByQuery(columnStatsTableData, searchQuery);
5790

5891
const columnStatsColumns = useGetColumnStatsColumns({
5992
tableData: columnStatsTableData,
@@ -72,7 +105,9 @@ const ColumnStatsTable = ({ columnStats, searchQuery }: Props) => {
72105

73106
useEffect(() => {
74107
if (expandedDrawerFieldPath) {
75-
const selectedIndex = rows.findIndex((row) => row.fieldPath === expandedDrawerFieldPath);
108+
const selectedIndex = rows.findIndex(
109+
(row) => (row as ExtendedSchemaFieldsWithFieldPath).fieldPath === expandedDrawerFieldPath,
110+
);
76111
const row = rowRefs.current[selectedIndex];
77112
const header = headerRef.current;
78113

@@ -83,20 +118,9 @@ const ColumnStatsTable = ({ columnStats, searchQuery }: Props) => {
83118
block: 'nearest',
84119
});
85120
}
86-
// To bring the row hidden behind the fixed header into view fully
121+
// Adjust scroll position to account for fixed header
87122
setTimeout(() => {
88-
if (row && header) {
89-
const rowRect = row.getBoundingClientRect();
90-
const headerRect = header.getBoundingClientRect();
91-
const rowTop = rowRect.top;
92-
const headerBottom = headerRect.bottom;
93-
const scrollContainer = row.closest('table')?.parentElement;
94-
95-
if (scrollContainer && rowTop < headerBottom) {
96-
const scrollAmount = headerBottom - rowTop;
97-
scrollContainer.scrollTop -= scrollAmount;
98-
}
99-
}
123+
handleRowScrollIntoView(row, header);
100124
}, 100);
101125
}
102126
}, [expandedDrawerFieldPath, rows]);
@@ -112,11 +136,13 @@ const ColumnStatsTable = ({ columnStats, searchQuery }: Props) => {
112136
}
113137

114138
const getRowClassName = (record) => {
115-
return expandedDrawerFieldPath === record.column ? 'selected-row' : '';
139+
return expandedDrawerFieldPath === record.originalFieldPath ? 'selected-row' : '';
116140
};
117141

118142
const onRowClick = (record) => {
119-
setExpandedDrawerFieldPath(expandedDrawerFieldPath === record.column ? null : record.column);
143+
setExpandedDrawerFieldPath(
144+
expandedDrawerFieldPath === record.originalFieldPath ? null : record.originalFieldPath,
145+
);
120146
};
121147

122148
return (
@@ -132,11 +158,11 @@ const ColumnStatsTable = ({ columnStats, searchQuery }: Props) => {
132158
rowRefs={rowRefs}
133159
headerRef={headerRef}
134160
/>
135-
{!!fields && (
161+
{fields.length > 0 && (
136162
<SchemaFieldDrawer
137-
schemaFields={fields}
163+
schemaFields={fields as any}
138164
expandedDrawerFieldPath={expandedDrawerFieldPath}
139-
editableSchemaMetadata={editableSchemaMetadata}
165+
editableSchemaMetadata={entityWithSchema?.editableSchemaMetadata as any}
140166
setExpandedDrawerFieldPath={setExpandedDrawerFieldPath}
141167
displayedRows={rows}
142168
defaultSelectedTabName="Statistics"
@@ -146,6 +172,6 @@ const ColumnStatsTable = ({ columnStats, searchQuery }: Props) => {
146172
)}
147173
</>
148174
);
149-
};
175+
}
150176

151177
export default ColumnStatsTable;
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { ExtendedSchemaFields } from '@app/entityV2/dataset/profile/schema/utils/types';
2+
3+
// Type definitions for safe type mapping
4+
interface SchemaField {
5+
fieldPath: string;
6+
type?: any;
7+
nativeDataType?: string | null;
8+
schemaFieldEntity?: any;
9+
nullable: boolean;
10+
recursive?: boolean;
11+
description?: string | null;
12+
children?: SchemaField[];
13+
}
14+
15+
// For DatasetFieldProfile, we'll define it locally since the generated types aren't available
16+
interface DatasetFieldProfile {
17+
fieldPath: string;
18+
nullCount?: number | null;
19+
nullProportion?: number | null;
20+
uniqueCount?: number | null;
21+
min?: string | null;
22+
max?: string | null;
23+
}
24+
25+
/**
26+
* Infers if a field is nullable based on column statistics.
27+
* Uses null count or proportion data when available, defaults to nullable.
28+
*/
29+
export function inferIsFieldNullable(stat: DatasetFieldProfile): boolean {
30+
if (stat.nullCount != null) {
31+
return stat.nullCount > 0;
32+
}
33+
34+
if (stat.nullProportion != null) {
35+
return stat.nullProportion > 0;
36+
}
37+
38+
return true; // Default to nullable when data unavailable
39+
}
40+
41+
/**
42+
* Safely maps unknown field data to SchemaField type.
43+
*/
44+
export function mapToSchemaField(field: unknown): SchemaField {
45+
if (!field || typeof field !== 'object') {
46+
throw new Error('Invalid field data provided to mapToSchemaField');
47+
}
48+
49+
const fieldObj = field as Record<string, any>;
50+
51+
return {
52+
fieldPath: fieldObj.fieldPath || '',
53+
type: fieldObj.type || null,
54+
nativeDataType: fieldObj.nativeDataType || null,
55+
schemaFieldEntity: fieldObj.schemaFieldEntity || null,
56+
nullable: fieldObj.nullable ?? false,
57+
recursive: fieldObj.recursive || false,
58+
description: fieldObj.description || null,
59+
children: fieldObj.children || undefined,
60+
};
61+
}
62+
63+
/**
64+
* Safely maps an array of unknown field data to SchemaField array.
65+
*/
66+
export function mapToSchemaFields(fields: unknown[]): SchemaField[] {
67+
if (!Array.isArray(fields)) {
68+
return [];
69+
}
70+
71+
return fields.map(mapToSchemaField);
72+
}
73+
74+
/**
75+
* Creates a stats-only field object for fields that exist in column stats but not in schema.
76+
*/
77+
export function createStatsOnlyField(stat: DatasetFieldProfile): SchemaField {
78+
return {
79+
fieldPath: stat.fieldPath,
80+
type: null,
81+
nativeDataType: null,
82+
schemaFieldEntity: null,
83+
nullable: inferIsFieldNullable(stat),
84+
recursive: false,
85+
description: null,
86+
};
87+
}
88+
89+
/**
90+
* Flattens nested field hierarchies to enable drawer field path matching.
91+
*/
92+
export function flattenFields(fieldList: ExtendedSchemaFields[]): ExtendedSchemaFields[] {
93+
const result: ExtendedSchemaFields[] = [];
94+
fieldList.forEach((field) => {
95+
result.push(field);
96+
if (field.children) {
97+
result.push(...flattenFields(field.children));
98+
}
99+
});
100+
return result;
101+
}
102+
103+
/**
104+
* Handles scroll adjustment when a row is selected to ensure it's visible.
105+
*/
106+
export function handleRowScrollIntoView(row: HTMLTableRowElement | undefined, header: HTMLTableSectionElement | null) {
107+
if (!row || !header) return;
108+
109+
const rowRect = row.getBoundingClientRect();
110+
const headerRect = header.getBoundingClientRect();
111+
const rowTop = rowRect.top;
112+
const headerBottom = headerRect.bottom;
113+
const scrollContainer = row.closest('table')?.parentElement;
114+
115+
if (scrollContainer && rowTop < headerBottom) {
116+
const scrollAmount = headerBottom - rowTop;
117+
scrollContainer.scrollTop -= scrollAmount;
118+
}
119+
}
120+
121+
/**
122+
* Filters column stats data based on search query.
123+
*/
124+
export function filterColumnStatsByQuery(data: any[], query: string) {
125+
if (!query.trim()) return data;
126+
127+
const lowercaseQuery = query.toLowerCase();
128+
return data.filter((columnStat) => columnStat.column?.toLowerCase().includes(lowercaseQuery));
129+
}

0 commit comments

Comments
 (0)