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;