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, '');
|
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
|
return cleanedFieldPath
|
||||||
.split('.')
|
.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)
|
.filter(Boolean)
|
||||||
.join('.');
|
.join('.');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,14 +2,36 @@ import { Table, Text } from '@components';
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import styled from 'styled-components';
|
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 SchemaFieldDrawer from '@app/entityV2/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/SchemaFieldDrawer';
|
||||||
import { useGetEntityWithSchema } from '@app/entityV2/shared/tabs/Dataset/Schema/useGetEntitySchema';
|
import { useGetEntityWithSchema } from '@app/entityV2/shared/tabs/Dataset/Schema/useGetEntitySchema';
|
||||||
import useKeyboardControls from '@app/entityV2/shared/tabs/Dataset/Schema/useKeyboardControls';
|
import useKeyboardControls from '@app/entityV2/shared/tabs/Dataset/Schema/useKeyboardControls';
|
||||||
import { decimalToPercentStr } from '@app/entityV2/shared/tabs/Dataset/Schema/utils/statsUtil';
|
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 { useGetColumnStatsColumns } from '@app/entityV2/shared/tabs/Dataset/Stats/StatsTabV2/columnStats/useGetColumnStatsColumns';
|
||||||
import { isPresent } from '@app/entityV2/shared/tabs/Dataset/Stats/StatsTabV2/utils';
|
import { isPresent } from '@app/entityV2/shared/tabs/Dataset/Stats/StatsTabV2/utils';
|
||||||
import { downgradeV2FieldPath, groupByFieldPath } from '@src/app/entityV2/dataset/profile/schema/utils/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`
|
const EmptyContainer = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -22,25 +44,28 @@ const EmptyContainer = styled.div`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
columnStats: Array<DatasetFieldProfile>;
|
columnStats: DatasetFieldProfile[];
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ColumnStatsTable = ({ columnStats, searchQuery }: Props) => {
|
function ColumnStatsTable({ columnStats, searchQuery }: Props) {
|
||||||
const { entityWithSchema } = useGetEntityWithSchema();
|
const { entityWithSchema } = useGetEntityWithSchema();
|
||||||
const schemaMetadata: any = entityWithSchema?.schemaMetadata || undefined;
|
const rawFields = entityWithSchema?.schemaMetadata?.fields;
|
||||||
const editableSchemaMetadata: any = entityWithSchema?.editableSchemaMetadata || undefined;
|
|
||||||
const fields = schemaMetadata?.fields;
|
const fields = useMemo(() => {
|
||||||
|
return rawFields ? mapToSchemaFields(rawFields) : [];
|
||||||
|
}, [rawFields]);
|
||||||
|
|
||||||
const columnStatsTableData = useMemo(
|
const columnStatsTableData = useMemo(
|
||||||
() =>
|
() =>
|
||||||
columnStats.map((doc) => ({
|
columnStats.map((stat) => ({
|
||||||
column: downgradeV2FieldPath(doc.fieldPath),
|
column: downgradeV2FieldPath(stat.fieldPath),
|
||||||
type: fields?.find((field) => field.fieldPath === doc.fieldPath)?.type,
|
originalFieldPath: stat.fieldPath,
|
||||||
nullPercentage: isPresent(doc.nullProportion) && decimalToPercentStr(doc.nullProportion, 2),
|
type: fields.find((field) => field.fieldPath === stat.fieldPath)?.type,
|
||||||
uniqueValues: isPresent(doc.uniqueCount) && doc.uniqueCount.toString(),
|
nullPercentage: isPresent(stat.nullProportion) && decimalToPercentStr(stat.nullProportion, 2),
|
||||||
min: doc.min,
|
uniqueValues: isPresent(stat.uniqueCount) && stat.uniqueCount.toString(),
|
||||||
max: doc.max,
|
min: stat.min,
|
||||||
|
max: stat.max,
|
||||||
})) || [],
|
})) || [],
|
||||||
[columnStats, fields],
|
[columnStats, fields],
|
||||||
);
|
);
|
||||||
@ -48,12 +73,20 @@ const ColumnStatsTable = ({ columnStats, searchQuery }: Props) => {
|
|||||||
const [expandedDrawerFieldPath, setExpandedDrawerFieldPath] = useState<string | null>(null);
|
const [expandedDrawerFieldPath, setExpandedDrawerFieldPath] = useState<string | null>(null);
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
return groupByFieldPath(fields);
|
const schemaFields = fields;
|
||||||
}, [fields]);
|
|
||||||
|
|
||||||
const filteredData = columnStatsTableData.filter((columnStat) =>
|
// Add fields from column stats that don't exist in schema
|
||||||
columnStat.column?.toLowerCase().includes(searchQuery.toLowerCase()),
|
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({
|
const columnStatsColumns = useGetColumnStatsColumns({
|
||||||
tableData: columnStatsTableData,
|
tableData: columnStatsTableData,
|
||||||
@ -72,7 +105,9 @@ const ColumnStatsTable = ({ columnStats, searchQuery }: Props) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (expandedDrawerFieldPath) {
|
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 row = rowRefs.current[selectedIndex];
|
||||||
const header = headerRef.current;
|
const header = headerRef.current;
|
||||||
|
|
||||||
@ -83,20 +118,9 @@ const ColumnStatsTable = ({ columnStats, searchQuery }: Props) => {
|
|||||||
block: 'nearest',
|
block: 'nearest',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// To bring the row hidden behind the fixed header into view fully
|
// Adjust scroll position to account for fixed header
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (row && header) {
|
handleRowScrollIntoView(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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
}, [expandedDrawerFieldPath, rows]);
|
}, [expandedDrawerFieldPath, rows]);
|
||||||
@ -112,11 +136,13 @@ const ColumnStatsTable = ({ columnStats, searchQuery }: Props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getRowClassName = (record) => {
|
const getRowClassName = (record) => {
|
||||||
return expandedDrawerFieldPath === record.column ? 'selected-row' : '';
|
return expandedDrawerFieldPath === record.originalFieldPath ? 'selected-row' : '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const onRowClick = (record) => {
|
const onRowClick = (record) => {
|
||||||
setExpandedDrawerFieldPath(expandedDrawerFieldPath === record.column ? null : record.column);
|
setExpandedDrawerFieldPath(
|
||||||
|
expandedDrawerFieldPath === record.originalFieldPath ? null : record.originalFieldPath,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -132,11 +158,11 @@ const ColumnStatsTable = ({ columnStats, searchQuery }: Props) => {
|
|||||||
rowRefs={rowRefs}
|
rowRefs={rowRefs}
|
||||||
headerRef={headerRef}
|
headerRef={headerRef}
|
||||||
/>
|
/>
|
||||||
{!!fields && (
|
{fields.length > 0 && (
|
||||||
<SchemaFieldDrawer
|
<SchemaFieldDrawer
|
||||||
schemaFields={fields}
|
schemaFields={fields as any}
|
||||||
expandedDrawerFieldPath={expandedDrawerFieldPath}
|
expandedDrawerFieldPath={expandedDrawerFieldPath}
|
||||||
editableSchemaMetadata={editableSchemaMetadata}
|
editableSchemaMetadata={entityWithSchema?.editableSchemaMetadata as any}
|
||||||
setExpandedDrawerFieldPath={setExpandedDrawerFieldPath}
|
setExpandedDrawerFieldPath={setExpandedDrawerFieldPath}
|
||||||
displayedRows={rows}
|
displayedRows={rows}
|
||||||
defaultSelectedTabName="Statistics"
|
defaultSelectedTabName="Statistics"
|
||||||
@ -146,6 +172,6 @@ const ColumnStatsTable = ({ columnStats, searchQuery }: Props) => {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default ColumnStatsTable;
|
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',
|
title: 'Type',
|
||||||
key: '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) => {
|
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',
|
key: 'view',
|
||||||
render: (record) => (
|
render: (record) => (
|
||||||
<ViewButton>
|
<ViewButton>
|
||||||
<Button variant="text" onClick={() => setExpandedDrawerFieldPath(record.column)}>
|
<Button
|
||||||
|
variant="text"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation(); // Prevent row click
|
||||||
|
setExpandedDrawerFieldPath(record.originalFieldPath);
|
||||||
|
}}
|
||||||
|
>
|
||||||
View
|
View
|
||||||
</Button>
|
</Button>
|
||||||
</ViewButton>
|
</ViewButton>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user