fix(ui/column-stats): fix unopenable side panel for nested column stats (#14874)

This commit is contained in:
Jonny Dixon 2025-10-24 21:52:45 +01:00 committed by GitHub
parent 55c4692e19
commit 080233054b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 841 additions and 43 deletions

View File

@ -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('.');
}

View File

@ -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<DatasetFieldProfile>;
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<string | null>(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 && (
<SchemaFieldDrawer
schemaFields={fields}
schemaFields={fields as any}
expandedDrawerFieldPath={expandedDrawerFieldPath}
editableSchemaMetadata={editableSchemaMetadata}
editableSchemaMetadata={entityWithSchema?.editableSchemaMetadata as any}
setExpandedDrawerFieldPath={setExpandedDrawerFieldPath}
displayedRows={rows}
defaultSelectedTabName="Statistics"
@ -146,6 +172,6 @@ const ColumnStatsTable = ({ columnStats, searchQuery }: Props) => {
)}
</>
);
};
}
export default ColumnStatsTable;

View File

@ -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<string, any>;
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));
}

View File

@ -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(
<MockedProvider mocks={[]} addTypename={false}>
<TestPageContainer>
<ColumnStatsTable columnStats={mockColumnStats} searchQuery="" />
</TestPageContainer>
</MockedProvider>,
);
// 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(
<MockedProvider mocks={[]} addTypename={false}>
<TestPageContainer>
<ColumnStatsTable columnStats={mockColumnStats} searchQuery="email" />
</TestPageContainer>
</MockedProvider>,
);
// 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(
<MockedProvider mocks={[]} addTypename={false}>
<TestPageContainer>
<ColumnStatsTable columnStats={mockColumnStats} searchQuery="" />
</TestPageContainer>
</MockedProvider>,
);
// 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(
<MockedProvider mocks={[]} addTypename={false}>
<TestPageContainer>
<ColumnStatsTable columnStats={mockColumnStats} searchQuery="nonexistent" />
</TestPageContainer>
</MockedProvider>,
);
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(
<MockedProvider mocks={[]} addTypename={false}>
<TestPageContainer>
<ColumnStatsTable columnStats={simpleStats} searchQuery="" />
</TestPageContainer>
</MockedProvider>,
);
// Verify simple field is displayed correctly
expect(screen.getByText('simple_field')).toBeInTheDocument();
expect(screen.getByText('10.00%')).toBeInTheDocument();
expect(screen.getByRole('table')).toBeInTheDocument();
});
});

View File

@ -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();
});
});
});

View File

@ -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',
);
});
});
});

View File

@ -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) => (
<ViewButton>
<Button variant="text" onClick={() => setExpandedDrawerFieldPath(record.column)}>
<Button
variant="text"
onClick={(e) => {
e.stopPropagation(); // Prevent row click
setExpandedDrawerFieldPath(record.originalFieldPath);
}}
>
View
</Button>
</ViewButton>