diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageSearchSelect/LineageSearchSelect.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageSearchSelect/LineageSearchSelect.test.tsx
index 5f4ac5a193b..8fc34f62b3b 100644
--- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageSearchSelect/LineageSearchSelect.test.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageSearchSelect/LineageSearchSelect.test.tsx
@@ -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();
- 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();
- 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();
- 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();
+
+ 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();
+
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('should handle dropdown visibility change', async () => {
+ const { container } = render();
+ 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('');
+ });
});
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageSearchSelect/LineageSearchSelect.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageSearchSelect/LineageSearchSelect.tsx
index 4eba40e6e70..13497c30a30 100644
--- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageSearchSelect/LineageSearchSelect.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageSearchSelect/LineageSearchSelect.tsx
@@ -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([]);
+ const [allOptions, setAllOptions] = useState([]);
+ const [renderedOptions, setRenderedOptions] = useState(
+ []
+ );
+ const [searchValue, setSearchValue] = useState('');
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: (
@@ -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) => {
+ 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}
/>
);
};
diff --git a/openmetadata-ui/src/main/resources/ui/src/constants/Lineage.constants.ts b/openmetadata-ui/src/main/resources/ui/src/constants/Lineage.constants.ts
index 2b839961bdd..39df67198d5 100644
--- a/openmetadata-ui/src/main/resources/ui/src/constants/Lineage.constants.ts
+++ b/openmetadata-ui/src/main/resources/ui/src/constants/Lineage.constants.ts
@@ -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;