mirror of
https://github.com/datahub-project/datahub.git
synced 2025-10-27 08:54:32 +00:00
fix(ui/column-stats): fix unopenable side panel for nested column stats (#14874)
This commit is contained in:
parent
55c4692e19
commit
080233054b
@ -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('.');
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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));
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user