mirror of
				https://github.com/datahub-project/datahub.git
				synced 2025-10-31 02:37:05 +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
	 Jonny Dixon
						Jonny Dixon