add virtual list for lineage select (#21312)

* add virtual list for lineage select

* update tests

* move to lineage constants

(cherry picked from commit 4dab2be0ab13c65aa37bddc9a3ff2025956ee75b)
This commit is contained in:
Karan Hotchandani 2025-05-20 18:35:39 +05:30 committed by OpenMetadata Release Bot
parent 4d4c56fbac
commit b850319dca
3 changed files with 203 additions and 36 deletions

View File

@ -10,9 +10,16 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { act, fireEvent, render, screen } from '@testing-library/react';
import {
act,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as React from 'react';
import { useLineageProvider } from '../../../../context/LineageProvider/LineageProvider';
import { LineagePlatformView } from '../../../../context/LineageProvider/LineageProvider.interface';
import { EntityType } from '../../../../enums/entity.enum';
import { LineageLayer } from '../../../../generated/settings/settings';
@ -30,30 +37,61 @@ const mockedNodes = [
],
},
},
position: { x: 100, y: 100 },
},
{
data: {
node: {
fullyQualifiedName: 'test2',
},
},
position: { x: 200, y: 200 },
},
{
data: {
node: {
fullyQualifiedName: 'test3',
},
},
position: { x: 300, y: 300 },
},
{ data: { node: { fullyQualifiedName: 'test2' } } },
{ data: { node: { fullyQualifiedName: 'test3' } } },
];
const mockNodeClick = jest.fn();
const mockColumnClick = jest.fn();
const mockReactFlowInstance = {
setCenter: jest.fn(),
};
const defaultMockProps = {
activeLayer: [LineageLayer.ColumnLevelLineage],
nodes: mockedNodes,
onNodeClick: mockNodeClick,
onColumnClick: mockColumnClick,
platformView: LineagePlatformView.None,
reactFlowInstance: mockReactFlowInstance,
zoomValue: 1,
isEditMode: false,
isPlatformLineage: false,
};
jest.mock('../../../../context/LineageProvider/LineageProvider', () => ({
useLineageProvider: jest.fn().mockImplementation(() => ({
activeLayer: [LineageLayer.ColumnLevelLineage],
nodes: mockedNodes,
onNodeClick: mockNodeClick,
onColumnClick: mockColumnClick,
platformView: LineagePlatformView.None,
})),
useLineageProvider: jest.fn(),
}));
describe('LineageSearchSelect', () => {
beforeEach(() => {
jest.clearAllMocks();
(useLineageProvider as jest.Mock).mockImplementation(
() => defaultMockProps
);
});
it('should render select with options', async () => {
const { container } = render(<LineageSearchSelect />);
const selectElement = screen.getByTestId('lineage-search');
expect(selectElement).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByTestId('lineage-search')).toBeInTheDocument();
});
await act(async () => {
const selectElm = container.querySelector('.ant-select-selector');
@ -65,11 +103,11 @@ describe('LineageSearchSelect', () => {
expect(option1).toBeInTheDocument();
});
it('should call onNodeClick', async () => {
it('should call onNodeClick and center the node', async () => {
const { container } = render(<LineageSearchSelect />);
const selectElement = screen.getByTestId('lineage-search');
expect(selectElement).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByTestId('lineage-search')).toBeInTheDocument();
});
await act(async () => {
const selectElm = container.querySelector('.ant-select-selector');
@ -83,13 +121,14 @@ describe('LineageSearchSelect', () => {
fireEvent.click(option1);
expect(mockNodeClick).toHaveBeenCalled();
expect(mockReactFlowInstance.setCenter).toHaveBeenCalled();
});
it('should call onColumnClick', async () => {
const { container } = render(<LineageSearchSelect />);
const selectElement = screen.getByTestId('lineage-search');
expect(selectElement).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByTestId('lineage-search')).toBeInTheDocument();
});
await act(async () => {
const selectElm = container.querySelector('.ant-select-selector');
@ -104,4 +143,52 @@ describe('LineageSearchSelect', () => {
expect(mockColumnClick).toHaveBeenCalled();
});
it('should not render when platform lineage is enabled', () => {
(useLineageProvider as jest.Mock).mockImplementation(() => ({
...defaultMockProps,
isPlatformLineage: true,
}));
const { container } = render(<LineageSearchSelect />);
expect(container).toBeEmptyDOMElement();
});
it('should not render when platform view is not None', () => {
(useLineageProvider as jest.Mock).mockImplementation(() => ({
...defaultMockProps,
platformView: LineagePlatformView.Service,
}));
const { container } = render(<LineageSearchSelect />);
expect(container).toBeEmptyDOMElement();
});
it('should handle dropdown visibility change', async () => {
const { container } = render(<LineageSearchSelect />);
await waitFor(() => {
expect(screen.getByTestId('lineage-search')).toBeInTheDocument();
});
// Open dropdown
await act(async () => {
const selectElm = container.querySelector('.ant-select-selector');
selectElm && userEvent.click(selectElm);
});
// Close dropdown
await act(async () => {
const selectElm = container.querySelector('.ant-select-selector');
selectElm && userEvent.click(selectElm);
});
// Verify search value is cleared
const searchInput = container.querySelector(
'.ant-select-selection-search-input'
) as HTMLInputElement;
expect(searchInput?.value).toBe('');
});
});

View File

@ -14,10 +14,16 @@ import { RightOutlined } from '@ant-design/icons';
import { Select, Space, Typography } from 'antd';
import { DefaultOptionType } from 'antd/lib/select';
import classNames from 'classnames';
import React, { useCallback, useEffect, useState } from 'react';
import { debounce } from 'lodash';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Node } from 'reactflow';
import { ZOOM_TRANSITION_DURATION } from '../../../../constants/Lineage.constants';
import {
DEBOUNCE_TIMEOUT,
INITIAL_NODE_ITEMS_LENGTH,
NODE_ITEMS_PAGE_SIZE,
ZOOM_TRANSITION_DURATION,
} from '../../../../constants/Lineage.constants';
import { useLineageProvider } from '../../../../context/LineageProvider/LineageProvider';
import { LineagePlatformView } from '../../../../context/LineageProvider/LineageProvider.interface';
import { Column } from '../../../../generated/entity/data/table';
@ -39,10 +45,13 @@ const LineageSearchSelect = () => {
platformView,
} = useLineageProvider();
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [cachedOptions, setCachedOptions] = useState<DefaultOptionType[]>([]);
const [allOptions, setAllOptions] = useState<DefaultOptionType[]>([]);
const [renderedOptions, setRenderedOptions] = useState<DefaultOptionType[]>(
[]
);
const [searchValue, setSearchValue] = useState<string>('');
const [isLoading, setIsLoading] = useState(false);
// Only compute options when dropdown is open
const generateNodeOptions = useCallback(() => {
const options: DefaultOptionType[] = [];
@ -51,6 +60,7 @@ const LineageSearchSelect = () => {
if (!node) {
return;
}
const nodeOption = {
label: (
<Space data-testid={`option-${node.fullyQualifiedName}`} size={0}>
@ -71,7 +81,6 @@ const LineageSearchSelect = () => {
const { childrenFlatten = [] } = getEntityChildrenAndLabel(node);
// Add all columns of the node as separate options
childrenFlatten.forEach((column: Column) => {
const columnOption = {
label: (
@ -108,24 +117,88 @@ const LineageSearchSelect = () => {
return options;
}, [nodes]);
// Load options when dropdown is opened
useEffect(() => {
if (isDropdownOpen && cachedOptions.length === 0) {
if (isDropdownOpen && allOptions.length === 0) {
setIsLoading(true);
const options = generateNodeOptions();
setCachedOptions(options);
setAllOptions(options);
setRenderedOptions(options.slice(0, INITIAL_NODE_ITEMS_LENGTH));
setIsLoading(false);
}
}, [isDropdownOpen, cachedOptions.length, generateNodeOptions]);
}, [isDropdownOpen, allOptions.length, generateNodeOptions]);
// Reset cached options when nodes change
useEffect(() => {
setCachedOptions([]);
setAllOptions([]);
setRenderedOptions([]);
setSearchValue('');
}, [nodes]);
const handleDropdownVisibleChange = useCallback((open: boolean) => {
setIsDropdownOpen(open);
}, []);
const filterOptions = useCallback(
(value: string) => {
if (value) {
const filteredOptions = allOptions.filter((option) =>
option.dataLabel
?.toString()
.toLowerCase()
.includes(value.toLowerCase())
);
setRenderedOptions(filteredOptions);
} else {
setRenderedOptions(allOptions.slice(0, INITIAL_NODE_ITEMS_LENGTH));
}
},
[allOptions]
);
// Create a debounced version of the filter function
const debouncedFilterOptions = useMemo(
() => debounce(filterOptions, DEBOUNCE_TIMEOUT),
[filterOptions]
);
// Cleanup debounce on unmount
useEffect(() => {
return () => {
debouncedFilterOptions.cancel();
};
}, [debouncedFilterOptions]);
const handleSearch = (value: string) => {
setSearchValue(value);
debouncedFilterOptions(value);
};
const loadMoreData = () => {
if (searchValue) {
// If searching, just use the filtered options from allOptions
filterOptions(searchValue);
} else {
const nextLength = Math.min(
renderedOptions.length + NODE_ITEMS_PAGE_SIZE,
allOptions.length
);
setRenderedOptions(allOptions.slice(0, nextLength));
}
};
const handleDropdownVisibleChange = useCallback(
(open: boolean) => {
setIsDropdownOpen(open);
if (!open) {
setSearchValue('');
setRenderedOptions(allOptions.slice(0, INITIAL_NODE_ITEMS_LENGTH));
debouncedFilterOptions.cancel();
}
},
[allOptions, debouncedFilterOptions]
);
const handlePopupScroll = (e: React.UIEvent<HTMLElement, UIEvent>) => {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
if (scrollTop + clientHeight >= scrollHeight - 10) {
loadMoreData();
}
};
const onOptionSelect = useCallback(
(value?: string) => {
@ -135,7 +208,6 @@ const LineageSearchSelect = () => {
if (selectedNode) {
const { position } = selectedNode;
onNodeClick(selectedNode);
// moving selected node in center
reactFlowInstance?.setCenter(position.x, position.y, {
duration: ZOOM_TRANSITION_DURATION,
zoom: zoomValue,
@ -160,16 +232,20 @@ const LineageSearchSelect = () => {
})}
data-testid="lineage-search"
dropdownMatchSelectWidth={false}
listHeight={300}
loading={isLoading}
optionFilterProp="dataLabel"
optionLabelProp="dataLabel"
options={cachedOptions}
options={renderedOptions}
placeholder={t('label.search-entity', {
entity: t('label.lineage'),
})}
popupClassName="lineage-search-options-list"
searchValue={searchValue}
onChange={onOptionSelect}
onDropdownVisibleChange={handleDropdownVisibleChange}
onPopupScroll={handlePopupScroll}
onSearch={handleSearch}
/>
);
};

View File

@ -128,3 +128,7 @@ export const LINEAGE_EXPORT_HEADERS = [
{ field: 'glossaryTerms', title: 'Glossary Terms' },
{ field: 'depth', title: 'Level' },
];
export const INITIAL_NODE_ITEMS_LENGTH = 50;
export const NODE_ITEMS_PAGE_SIZE = 50;
export const DEBOUNCE_TIMEOUT = 300;