From 0701fed67e2bfd227ec6ab0985553f9d6f8be53c Mon Sep 17 00:00:00 2001 From: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com> Date: Tue, 30 Apr 2024 12:42:43 +0530 Subject: [PATCH] Glossary expand fix (#16066) * Fix #16046 : modify glossaryTerms api endpoint to support querying immediate children with childrenCount * fix(ui): support lazy loading for n level of glossary * fix modal update for glossaryTerm * fixed expand/collapse button bug and update glossary page issue --------- Co-authored-by: sonikashah Co-authored-by: Shailesh Parmar --- .../GlossaryDetails.component.tsx | 10 +- .../GlossaryDetails.interface.ts | 3 +- .../GlossaryTermTab.component.tsx | 189 ++++++++++++------ .../GlossaryTermTab.interface.ts | 3 - .../GlossaryTermTab/GlossaryTermTab.test.tsx | 27 ++- .../GlossaryTermsV1.component.tsx | 6 +- .../GlossaryTermsV1.interface.ts | 1 - .../GlossaryTerms/GlossaryTermsV1.test.tsx | 2 +- .../Glossary/GlossaryV1.component.tsx | 51 +++-- .../components/Glossary/GlossaryV1.test.tsx | 6 + .../components/Glossary/useGlossary.store.ts | 52 +++++ .../AsyncSelectList/TreeAsyncSelectList.tsx | 2 +- .../GlossaryPage/GlossaryPage.component.tsx | 74 +++---- .../main/resources/ui/src/rest/glossaryAPI.ts | 20 ++ .../ui/src/utils/GlossaryUtils.test.ts | 82 +++++++- .../resources/ui/src/utils/GlossaryUtils.ts | 53 ++++- 16 files changed, 426 insertions(+), 155 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/Glossary/useGlossary.store.ts diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.component.tsx index 41efabd11b0..72faaf55b9c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.component.tsx @@ -32,6 +32,7 @@ import TabsLabel from '../../common/TabsLabel/TabsLabel.component'; import GlossaryDetailsRightPanel from '../GlossaryDetailsRightPanel/GlossaryDetailsRightPanel.component'; import GlossaryHeader from '../GlossaryHeader/GlossaryHeader.component'; import GlossaryTermTab from '../GlossaryTermTab/GlossaryTermTab.component'; +import { useGlossaryStore } from '../useGlossary.store'; import './glossary-details.less'; import { GlossaryDetailsProps, @@ -40,11 +41,9 @@ import { const GlossaryDetails = ({ permissions, - glossary, updateGlossary, updateVote, handleGlossaryDelete, - glossaryTerms, termsLoading, refreshGlossaryTerms, onAddGlossaryTerm, @@ -54,7 +53,7 @@ const GlossaryDetails = ({ }: GlossaryDetailsProps) => { const { t } = useTranslation(); const history = useHistory(); - + const { activeGlossary: glossary } = useGlossaryStore(); const { tab: activeTab } = useParams<{ tab: string }>(); const [feedCount, setFeedCount] = useState( FEED_COUNT_INITIAL_DATA @@ -152,7 +151,7 @@ const GlossaryDetails = ({ entityName={getEntityName(glossary)} entityType={EntityType.GLOSSARY} hasEditAccess={permissions.EditDescription || permissions.EditAll} - isDescriptionExpanded={isEmpty(glossaryTerms)} + isDescriptionExpanded={isEmpty(glossary.children)} isEdit={isDescriptionEditable} owner={glossary?.owner} showActions={!glossary.deleted} @@ -164,10 +163,8 @@ const GlossaryDetails = ({ Promise; updateVote?: (data: VotingDataProps) => Promise; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.component.tsx index aa4b7c5dbb2..1e488291766 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.component.tsx @@ -12,27 +12,22 @@ */ import { FilterOutlined } from '@ant-design/icons'; -import { - Button, - Col, - Modal, - Row, - Space, - Table, - TableProps, - Tooltip, -} from 'antd'; +import Icon from '@ant-design/icons/lib/components/Icon'; +import { Button, Col, Modal, Row, Space, TableProps, Tooltip } from 'antd'; import { ColumnsType, ExpandableConfig } from 'antd/lib/table/interface'; import { AxiosError } from 'axios'; import classNames from 'classnames'; import { compare } from 'fast-json-patch'; -import { isEmpty, isUndefined } from 'lodash'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { cloneDeep, isEmpty, isUndefined } from 'lodash'; +import React, { useCallback, useMemo, useState } from 'react'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; +import { ReactComponent as IconDrag } from '../../../assets/svg/drag.svg'; import { ReactComponent as EditIcon } from '../../../assets/svg/edit-new.svg'; +import { ReactComponent as IconDown } from '../../../assets/svg/ic-arrow-down.svg'; +import { ReactComponent as IconRight } from '../../../assets/svg/ic-arrow-right.svg'; import { ReactComponent as DownUpArrowIcon } from '../../../assets/svg/ic-down-up-arrow.svg'; import { ReactComponent as UpDownArrowIcon } from '../../../assets/svg/ic-up-down-arrow.svg'; import { ReactComponent as PlusOutlinedIcon } from '../../../assets/svg/plus-outlined.svg'; @@ -40,7 +35,11 @@ import ErrorPlaceHolder from '../../../components/common/ErrorWithPlaceholder/Er import { OwnerLabel } from '../../../components/common/OwnerLabel/OwnerLabel.component'; import RichTextEditorPreviewer from '../../../components/common/RichTextEditor/RichTextEditorPreviewer'; import StatusBadge from '../../../components/common/StatusBadge/StatusBadge.component'; -import { DE_ACTIVE_COLOR } from '../../../constants/constants'; +import { + API_RES_MAX_SIZE, + DE_ACTIVE_COLOR, + TEXT_BODY_COLOR, +} from '../../../constants/constants'; import { GLOSSARIES_DOCS } from '../../../constants/docs.constants'; import { TABLE_CONSTANTS } from '../../../constants/Teams.constants'; import { ERROR_PLACEHOLDER_TYPE } from '../../../enums/common.enum'; @@ -50,20 +49,28 @@ import { Status, } from '../../../generated/entity/data/glossaryTerm'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; -import { patchGlossaryTerm } from '../../../rest/glossaryAPI'; +import { + getFirstLevelGlossaryTerms, + getGlossaryTerms, + GlossaryTermWithChildren, + patchGlossaryTerm, +} from '../../../rest/glossaryAPI'; import { Transi18next } from '../../../utils/CommonUtils'; import { getEntityName } from '../../../utils/EntityUtils'; import Fqn from '../../../utils/Fqn'; import { buildTree, + findExpandableKeysForArray, + findGlossaryTermByFqn, StatusClass, StatusFilters, } from '../../../utils/GlossaryUtils'; import { getGlossaryPath } from '../../../utils/RouterUtils'; -import { getTableExpandableConfig } from '../../../utils/TableUtils'; import { showErrorToast } from '../../../utils/ToastUtils'; import { DraggableBodyRowProps } from '../../common/Draggable/DraggableBodyRowProps.interface'; import Loader from '../../common/Loader/Loader'; +import Table from '../../common/Table/Table'; +import { ModifiedGlossary, useGlossaryStore } from '../useGlossary.store'; import { GlossaryTermTabProps, ModifiedGlossaryTerm, @@ -71,37 +78,38 @@ import { } from './GlossaryTermTab.interface'; const GlossaryTermTab = ({ - childGlossaryTerms = [], refreshGlossaryTerms, permissions, isGlossary, - selectedData, termsLoading, onAddGlossaryTerm, onEditGlossaryTerm, className, }: GlossaryTermTabProps) => { + const { activeGlossary, updateActiveGlossary } = useGlossaryStore(); const { theme } = useApplicationStore(); const { t } = useTranslation(); - const [isLoading, setIsLoading] = useState(true); - const [glossaryTerms, setGlossaryTerms] = useState( - [] - ); - const [expandedRowKeys, setExpandedRowKeys] = useState([]); + const glossaryTerms = activeGlossary?.children as ModifiedGlossaryTerm[]; + const [movedGlossaryTerm, setMovedGlossaryTerm] = useState(); const [isModalOpen, setIsModalOpen] = useState(false); const [isTableLoading, setIsTableLoading] = useState(false); const [isTableHovered, setIsTableHovered] = useState(false); + const [expandedRowKeys, setExpandedRowKeys] = useState([]); const glossaryTermStatus: Status | null = useMemo(() => { if (!isGlossary) { - return (selectedData as GlossaryTerm).status ?? Status.Approved; + return (activeGlossary as GlossaryTerm).status ?? Status.Approved; } return null; - }, [isGlossary, selectedData]); + }, [isGlossary, activeGlossary]); + + const expandableKeys = useMemo(() => { + return findExpandableKeysForArray(glossaryTerms); + }, [glossaryTerms]); const columns = useMemo(() => { const data: ColumnsType = [ @@ -240,22 +248,71 @@ const GlossaryTermTab = ({ }, [glossaryTerms, permissions]); const handleAddGlossaryTermClick = () => { - onAddGlossaryTerm(!isGlossary ? (selectedData as GlossaryTerm) : undefined); + onAddGlossaryTerm( + !isGlossary ? (activeGlossary as GlossaryTerm) : undefined + ); }; const expandableConfig: ExpandableConfig = useMemo( () => ({ - ...getTableExpandableConfig(true), - expandedRowKeys, - onExpand: (expanded, record) => { - setExpandedRowKeys( - expanded - ? [...expandedRowKeys, record.fullyQualifiedName || ''] - : expandedRowKeys.filter((key) => key !== record.fullyQualifiedName) + expandIcon: ({ expanded, onExpand, record }) => { + const { children, childrenCount } = record; + + return childrenCount ?? children?.length ?? 0 > 0 ? ( + <> + + onExpand(record, e)} + /> + + ) : ( + <> + + + ); }, + expandedRowKeys: expandedRowKeys, + onExpand: async (expanded, record) => { + if (expanded) { + let children = record.children as GlossaryTermWithChildren[]; + if (!children?.length) { + const { data } = await getFirstLevelGlossaryTerms( + record.fullyQualifiedName || '' + ); + const terms = cloneDeep(glossaryTerms) ?? []; + + const item = findGlossaryTermByFqn( + terms, + record.fullyQualifiedName ?? '' + ); + + (item as ModifiedGlossary).children = data; + + updateActiveGlossary({ children: terms }); + + children = data; + } + setExpandedRowKeys([ + ...expandedRowKeys, + record.fullyQualifiedName || '', + ]); + + return children; + } else { + setExpandedRowKeys( + expandedRowKeys.filter((key) => key !== record.fullyQualifiedName) + ); + } + + return ; + }, }), - [expandedRowKeys] + [glossaryTerms, updateActiveGlossary, expandedRowKeys] ); const handleMoveRow = useCallback( @@ -324,33 +381,47 @@ const GlossaryTermTab = ({ handleTableHover, } as DraggableBodyRowProps); - const toggleExpandAll = () => { - if (expandedRowKeys.length === childGlossaryTerms.length) { - setExpandedRowKeys([]); - } else { - setExpandedRowKeys( - childGlossaryTerms.map((item) => item.fullyQualifiedName || '') - ); - } - }; - const onDragConfirmationModalClose = useCallback(() => { setIsModalOpen(false); setIsTableHovered(false); }, []); - useEffect(() => { - if (childGlossaryTerms) { - const data = buildTree(childGlossaryTerms); - setGlossaryTerms(data as ModifiedGlossaryTerm[]); - setExpandedRowKeys( - childGlossaryTerms.map((item) => item.fullyQualifiedName || '') - ); - } - setIsLoading(false); - }, [childGlossaryTerms]); + const fetchAllTerms = async () => { + setIsTableLoading(true); + const { data } = await getGlossaryTerms({ + glossary: activeGlossary?.id || '', + limit: API_RES_MAX_SIZE, + fields: 'children,owner,parent', + }); + updateActiveGlossary({ + children: buildTree(data) as ModifiedGlossary['children'], + }); + const keys = data.reduce((prev, curr) => { + if (curr.children?.length) { + prev.push(curr.fullyQualifiedName ?? ''); + } - if (termsLoading || isLoading) { + return prev; + }, [] as string[]); + + setExpandedRowKeys(keys); + + setIsTableLoading(false); + }; + + const toggleExpandAll = () => { + if (expandedRowKeys.length === expandableKeys.length) { + setExpandedRowKeys([]); + } else { + fetchAllTerms(); + } + }; + + const isAllExpanded = useMemo(() => { + return expandedRowKeys.length === expandableKeys.length; + }, [expandedRowKeys, expandableKeys]); + + if (termsLoading) { return ; } @@ -382,15 +453,13 @@ const GlossaryTermTab = ({ type="text" onClick={toggleExpandAll}> - {expandedRowKeys.length === childGlossaryTerms.length ? ( + {isAllExpanded ? ( ) : ( )} - {expandedRowKeys.length === childGlossaryTerms.length - ? t('label.collapse-all') - : t('label.expand-all')} + {isAllExpanded ? t('label.collapse-all') : t('label.expand-all')} @@ -437,7 +506,9 @@ const GlossaryTermTab = ({ renderElement={} values={{ from: movedGlossaryTerm?.from.name, - to: movedGlossaryTerm?.to?.name ?? getEntityName(selectedData), + to: + movedGlossaryTerm?.to?.name ?? + (activeGlossary && getEntityName(activeGlossary)), entity: isUndefined(movedGlossaryTerm?.to) ? '' : t('label.term-lowercase'), diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.interface.ts index a0c8da520e0..742054e1146 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.interface.ts @@ -12,12 +12,9 @@ */ import { OperationPermission } from '../../../context/PermissionProvider/PermissionProvider.interface'; -import { Glossary } from '../../../generated/entity/data/glossary'; import { GlossaryTerm } from '../../../generated/entity/data/glossaryTerm'; export interface GlossaryTermTabProps { - selectedData: Glossary | GlossaryTerm; - childGlossaryTerms: GlossaryTerm[]; isGlossary: boolean; termsLoading: boolean; refreshGlossaryTerms: () => void; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.test.tsx index 8b57d20e304..554b9971671 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.test.tsx @@ -25,13 +25,14 @@ import { mockedGlossaryTerms, MOCK_PERMISSIONS, } from '../../../mocks/Glossary.mock'; +import { useGlossaryStore } from '../useGlossary.store'; import GlossaryTermTab from './GlossaryTermTab.component'; const mockOnAddGlossaryTerm = jest.fn(); const mockRefreshGlossaryTerms = jest.fn(); const mockOnEditGlossaryTerm = jest.fn(); -const mockProps1 = { +const mockProps = { childGlossaryTerms: [], isGlossary: false, permissions: MOCK_PERMISSIONS, @@ -42,11 +43,6 @@ const mockProps1 = { onEditGlossaryTerm: mockOnEditGlossaryTerm, }; -const mockProps2 = { - ...mockProps1, - childGlossaryTerms: mockedGlossaryTerms, -}; - jest.mock('../../../rest/glossaryAPI', () => ({ getGlossaryTerms: jest .fn() @@ -75,10 +71,16 @@ jest.mock('../../common/Loader/Loader', () => jest.mock('../../common/OwnerLabel/OwnerLabel.component', () => ({ OwnerLabel: jest.fn().mockImplementation(() =>
OwnerLabel
), })); +jest.mock('../useGlossary.store', () => ({ + useGlossaryStore: jest.fn().mockImplementation(() => ({ + activeGlossary: mockedGlossaryTerms[0], + updateActiveGlossary: jest.fn(), + })), +})); describe('Test GlossaryTermTab component', () => { it('should show the ErrorPlaceHolder component, if no glossary is present', () => { - const { container } = render(, { + const { container } = render(, { wrapper: MemoryRouter, }); @@ -86,7 +88,7 @@ describe('Test GlossaryTermTab component', () => { }); it('should call the onAddGlossaryTerm fn onClick of add button in ErrorPlaceHolder', () => { - const { container } = render(, { + const { container } = render(, { wrapper: MemoryRouter, }); @@ -96,7 +98,14 @@ describe('Test GlossaryTermTab component', () => { }); it('should contain all necessary fields value in table when glossary data is not empty', async () => { - const { container } = render(, { + (useGlossaryStore as unknown as jest.Mock).mockImplementation(() => ({ + activeGlossary: { + ...mockedGlossaryTerms[0], + children: mockedGlossaryTerms, + }, + updateActiveGlossary: jest.fn(), + })); + const { container } = render(, { wrapper: MemoryRouter, }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/GlossaryTermsV1.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/GlossaryTermsV1.component.tsx index 6b631e24114..f2fb6fa0e8d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/GlossaryTermsV1.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/GlossaryTermsV1.component.tsx @@ -51,6 +51,7 @@ import { AssetSelectionModal } from '../../DataAssets/AssetsSelectionModal/Asset import { GlossaryTabs } from '../GlossaryDetails/GlossaryDetails.interface'; import GlossaryHeader from '../GlossaryHeader/GlossaryHeader.component'; import GlossaryTermTab from '../GlossaryTermTab/GlossaryTermTab.component'; +import { useGlossaryStore } from '../useGlossary.store'; import { GlossaryTermsV1Props } from './GlossaryTermsV1.interface'; import AssetsTabs, { AssetsTabRef } from './tabs/AssetsTabs.component'; import { AssetsOfEntity } from './tabs/AssetsTabs.interface'; @@ -58,7 +59,6 @@ import GlossaryOverviewTab from './tabs/GlossaryOverviewTab.component'; const GlossaryTermsV1 = ({ glossaryTerm, - childGlossaryTerms, handleGlossaryTermUpdate, handleGlossaryTermDelete, permissions, @@ -82,6 +82,8 @@ const GlossaryTermsV1 = ({ FEED_COUNT_INITIAL_DATA ); const [assetCount, setAssetCount] = useState(0); + const { activeGlossary } = useGlossaryStore(); + const childGlossaryTerms = activeGlossary?.children ?? []; const assetPermissions = useMemo(() => { const glossaryTermStatus = glossaryTerm.status ?? Status.Approved; @@ -198,12 +200,10 @@ const GlossaryTermsV1 = ({ key: 'terms', children: ( Promise; handleGlossaryTermDelete: (id: string) => Promise; refreshGlossaryTerms: () => void; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/GlossaryTermsV1.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/GlossaryTermsV1.test.tsx index de5962704d3..873333508b1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/GlossaryTermsV1.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/GlossaryTermsV1.test.tsx @@ -88,7 +88,7 @@ const mockProps = { describe('Test Glossary-term component', () => { it('Should render Glossary-term component', async () => { - render(); + render(); const glossaryTerm = screen.getByTestId('glossary-term'); const tabs = await screen.findAllByRole('tab'); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.component.tsx index d63dc71f661..3e8c077080f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.component.tsx @@ -19,10 +19,7 @@ import { useTranslation } from 'react-i18next'; import { useHistory, useParams } from 'react-router-dom'; import { withActivityFeed } from '../../components/AppRouter/withActivityFeed'; import { HTTP_STATUS_CODE } from '../../constants/Auth.constants'; -import { - API_RES_MAX_SIZE, - getGlossaryTermDetailsPath, -} from '../../constants/constants'; +import { getGlossaryTermDetailsPath } from '../../constants/constants'; import { usePermissionProvider } from '../../context/PermissionProvider/PermissionProvider'; import { OperationPermission, @@ -33,13 +30,12 @@ import { CreateThread, ThreadType, } from '../../generated/api/feed/createThread'; -import { Glossary } from '../../generated/entity/data/glossary'; import { GlossaryTerm } from '../../generated/entity/data/glossaryTerm'; import { VERSION_VIEW_GLOSSARY_PERMISSION } from '../../mocks/Glossary.mock'; import { postThread } from '../../rest/feedsAPI'; import { addGlossaryTerm, - getGlossaryTerms, + getFirstLevelGlossaryTerms, ListGlossaryTermsParams, patchGlossaryTerm, } from '../../rest/glossaryAPI'; @@ -57,6 +53,7 @@ import GlossaryTermsV1 from './GlossaryTerms/GlossaryTermsV1.component'; import { GlossaryV1Props } from './GlossaryV1.interfaces'; import './glossaryV1.less'; import ImportGlossary from './ImportGlossary/ImportGlossary'; +import { useGlossaryStore } from './useGlossary.store'; const GlossaryV1 = ({ isGlossaryActive, @@ -74,13 +71,15 @@ const GlossaryV1 = ({ const { t } = useTranslation(); const { action, tab } = useParams<{ action: EntityAction; glossaryName: string; tab: string }>(); + const history = useHistory(); const [threadLink, setThreadLink] = useState(''); const [threadType, setThreadType] = useState( ThreadType.Conversation ); const { postFeed, deleteFeed, updateFeed } = useActivityFeedProvider(); - + const [activeGlossaryTerm, setActiveGlossaryTerm] = + useState(null); const { getEntityPermission } = usePermissionProvider(); const [isLoading, setIsLoading] = useState(true); const [isTermsLoading, setIsTermsLoading] = useState(false); @@ -94,13 +93,12 @@ const GlossaryV1 = ({ useState(DEFAULT_ENTITY_PERMISSION); const [isEditModalOpen, setIsEditModalOpen] = useState(false); - const [activeGlossaryTerm, setActiveGlossaryTerm] = useState< - GlossaryTerm | undefined - >(); + const [editMode, setEditMode] = useState(false); - const [glossaryTerms, setGlossaryTerms] = useState([]); - const { id } = selectedData ?? {}; + const { activeGlossary, updateActiveGlossary } = useGlossaryStore(); + + const { id, fullyQualifiedName } = activeGlossary ?? {}; const isImportAction = useMemo( () => action === EntityAction.IMPORT, @@ -124,12 +122,14 @@ const GlossaryV1 = ({ ) => { refresh ? setIsTermsLoading(true) : setIsLoading(true); try { - const { data } = await getGlossaryTerms({ - ...params, - limit: API_RES_MAX_SIZE, - fields: 'children,owner,parent', + const { data } = await getFirstLevelGlossaryTerms( + params?.glossary ?? params?.parent ?? '' + ); + updateActiveGlossary({ + children: data.map((data) => + data.childrenCount ?? 0 > 0 ? { ...data, children: [] } : data + ), }); - setGlossaryTerms(data); } catch (error) { showErrorToast(error as AxiosError); } finally { @@ -187,16 +187,18 @@ const GlossaryV1 = ({ const loadGlossaryTerms = useCallback( (refresh = false) => { fetchGlossaryTerm( - isGlossaryActive ? { glossary: id } : { parent: id }, + isGlossaryActive + ? { glossary: fullyQualifiedName } + : { parent: fullyQualifiedName }, refresh ); }, - [id, isGlossaryActive] + [fullyQualifiedName, isGlossaryActive] ); const handleGlossaryTermModalAction = ( editMode: boolean, - glossaryTerm: GlossaryTerm | undefined + glossaryTerm: GlossaryTerm | null ) => { setEditMode(editMode); setActiveGlossaryTerm(glossaryTerm); @@ -349,8 +351,6 @@ const GlossaryV1 = ({ !isEmpty(selectedData) && (isGlossaryActive ? ( - handleGlossaryTermModalAction(false, term) + handleGlossaryTermModalAction(false, term ?? null) } onEditGlossaryTerm={(term) => - handleGlossaryTermModalAction(true, term) + handleGlossaryTermModalAction(true, term ?? null) } onThreadLinkSelect={onThreadLinkSelect} /> ) : ( - handleGlossaryTermModalAction(false, term) + handleGlossaryTermModalAction(false, term ?? null) } onAssetClick={onAssetClick} onEditGlossaryTerm={(term) => diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.test.tsx index be66203f0af..5630b726fe4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.test.tsx @@ -124,6 +124,12 @@ jest.mock('./ImportGlossary/ImportGlossary', () => jest.mock('../../components/AppRouter/withActivityFeed', () => ({ withActivityFeed: jest.fn().mockImplementation((component) => component), })); +jest.mock('./useGlossary.store', () => ({ + useGlossaryStore: jest.fn().mockImplementation(() => ({ + activeGlossary: mockedGlossaryTerms[0], + updateActiveGlossary: jest.fn(), + })), +})); const mockProps: GlossaryV1Props = { selectedData: mockedGlossaries[0], diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/useGlossary.store.ts b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/useGlossary.store.ts new file mode 100644 index 00000000000..8f067fdb1ed --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/useGlossary.store.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2024 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { create } from 'zustand'; +import { Glossary } from '../../generated/entity/data/glossary'; +import { GlossaryTermWithChildren } from '../../rest/glossaryAPI'; + +export type ModifiedGlossary = Glossary & { + children?: GlossaryTermWithChildren[]; +}; + +export const useGlossaryStore = create<{ + glossaries: Glossary[]; + activeGlossary: ModifiedGlossary; + setGlossaries: (glossaries: Glossary[]) => void; + setActiveGlossary: (glossary: ModifiedGlossary) => void; + updateGlossary: (glossary: Glossary) => void; + updateActiveGlossary: (glossary: Partial) => void; +}>()((set, get) => ({ + glossaries: [], + activeGlossary: {} as ModifiedGlossary, + + setGlossaries: (glossaries: Glossary[]) => { + set({ glossaries }); + }, + updateGlossary: (glossary: Glossary) => { + const { glossaries } = get(); + + const newGlossaries = glossaries.map((g) => + g.fullyQualifiedName === glossary.fullyQualifiedName ? glossary : g + ); + + set({ glossaries: newGlossaries }); + }, + setActiveGlossary: (glossary: ModifiedGlossary) => { + set({ activeGlossary: glossary }); + }, + updateActiveGlossary: (glossary: Partial) => { + const { activeGlossary } = get(); + + set({ activeGlossary: { ...activeGlossary, ...glossary } as Glossary }); + }, +})); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelectList/TreeAsyncSelectList.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelectList/TreeAsyncSelectList.tsx index eb9f5655770..ddb861a1bab 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelectList/TreeAsyncSelectList.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelectList/TreeAsyncSelectList.tsx @@ -189,7 +189,7 @@ const TreeAsyncSelectList: FC> = ({ label: value, }; }); - selectedTagsRef.current = selectedValues; + selectedTagsRef.current = selectedValues as SelectOption[]; onChange?.(selectedValues); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/Glossary/GlossaryPage/GlossaryPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/Glossary/GlossaryPage/GlossaryPage.component.tsx index 630a5466808..97d43e24187 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/Glossary/GlossaryPage/GlossaryPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/Glossary/GlossaryPage/GlossaryPage.component.tsx @@ -23,6 +23,10 @@ import { VotingDataProps } from '../../../components/Entity/Voting/voting.interf import EntitySummaryPanel from '../../../components/Explore/EntitySummaryPanel/EntitySummaryPanel.component'; import { EntityDetailsObjectInterface } from '../../../components/Explore/ExplorePage.interface'; import GlossaryV1 from '../../../components/Glossary/GlossaryV1.component'; +import { + ModifiedGlossary, + useGlossaryStore, +} from '../../../components/Glossary/useGlossary.store'; import PageLayoutV1 from '../../../components/PageLayoutV1/PageLayoutV1'; import { FQN_SEPARATOR_CHAR } from '../../../constants/char.constants'; import { PAGE_SIZE_LARGE, ROUTES } from '../../../constants/constants'; @@ -55,16 +59,24 @@ const GlossaryPage = () => { const { permissions } = usePermissionProvider(); const { fqn: glossaryFqn } = useFqn(); const history = useHistory(); - const [glossaries, setGlossaries] = useState([]); + const [isLoading, setIsLoading] = useState(true); - const [selectedData, setSelectedData] = useState(); + const [isRightPanelLoading, setIsRightPanelLoading] = useState(true); const [previewAsset, setPreviewAsset] = useState(); + const { + glossaries, + setGlossaries, + activeGlossary, + setActiveGlossary, + updateActiveGlossary, + } = useGlossaryStore(); + const isGlossaryActive = useMemo(() => { setIsRightPanelLoading(true); - setSelectedData(undefined); + setActiveGlossary({} as ModifiedGlossary); if (glossaryFqn) { return Fqn.split(glossaryFqn).length === 1; @@ -140,7 +152,7 @@ const GlossaryPage = () => { fields: 'relatedTerms,reviewers,tags,owner,children,votes,domain,extension', }); - setSelectedData(response); + setActiveGlossary(response as ModifiedGlossary); } catch (error) { showErrorToast(error as AxiosError); } finally { @@ -153,7 +165,7 @@ const GlossaryPage = () => { if (!isGlossaryActive) { fetchGlossaryTermDetails(); } else { - setSelectedData( + setActiveGlossary( glossaries.find( (glossary) => glossary.fullyQualifiedName === glossaryFqn ) || glossaries[0] @@ -167,25 +179,17 @@ const GlossaryPage = () => { }, [isGlossaryActive, glossaryFqn, glossaries]); const updateGlossary = async (updatedData: Glossary) => { - const jsonPatch = compare(selectedData as Glossary, updatedData); + const jsonPatch = compare(activeGlossary as Glossary, updatedData); try { const response = await patchGlossaries( - selectedData?.id as string, + activeGlossary?.id as string, jsonPatch ); - setGlossaries((pre) => { - return pre.map((item) => { - if (item.name === response.name) { - return response; - } else { - return item; - } - }); - }); + updateActiveGlossary(response); - if (selectedData?.name !== updatedData.name) { + if (activeGlossary?.name !== updatedData.name) { history.push(getGlossaryPath(response.fullyQualifiedName)); fetchGlossaryList(); } @@ -198,36 +202,24 @@ const GlossaryPage = () => { async (data: VotingDataProps) => { try { const isGlossaryEntity = - Fqn.split(selectedData?.fullyQualifiedName ?? '').length <= 1; + Fqn.split(activeGlossary?.fullyQualifiedName ?? '').length <= 1; if (isGlossaryEntity) { const { entity: { votes }, - } = await updateGlossaryVotes(selectedData?.id ?? '', data); - setSelectedData( - (pre) => - pre && { - ...pre, - votes, - } - ); + } = await updateGlossaryVotes(activeGlossary?.id ?? '', data); + updateActiveGlossary({ votes }); } else { const { entity: { votes }, - } = await updateGlossaryTermVotes(selectedData?.id ?? '', data); - setSelectedData( - (pre) => - pre && { - ...pre, - votes, - } - ); + } = await updateGlossaryTermVotes(activeGlossary?.id ?? '', data); + updateActiveGlossary({ votes }); } } catch (error) { showErrorToast(error as AxiosError); } }, - [setSelectedData, selectedData] + [updateActiveGlossary, activeGlossary] ); const handleGlossaryDelete = async (id: string) => { @@ -260,18 +252,18 @@ const GlossaryPage = () => { const handleGlossaryTermUpdate = useCallback( async (updatedData: GlossaryTerm) => { - const jsonPatch = compare(selectedData as GlossaryTerm, updatedData); + const jsonPatch = compare(activeGlossary as GlossaryTerm, updatedData); if (isEmpty(jsonPatch)) { return; } try { const response = await patchGlossaryTerm( - selectedData?.id as string, + activeGlossary?.id as string, jsonPatch ); if (response) { - setSelectedData(response); - if (selectedData?.name !== updatedData.name) { + setActiveGlossary(response as ModifiedGlossary); + if (activeGlossary?.name !== updatedData.name) { history.push(getGlossaryPath(response.fullyQualifiedName)); fetchGlossaryList(); } @@ -284,7 +276,7 @@ const GlossaryPage = () => { showErrorToast(error as AxiosError); } }, - [selectedData] + [activeGlossary] ); const handleGlossaryTermDelete = async (id: string) => { @@ -373,7 +365,7 @@ const GlossaryPage = () => { isSummaryPanelOpen={Boolean(previewAsset)} isVersionsView={false} refreshActiveGlossaryTerm={fetchGlossaryTermDetails} - selectedData={selectedData as Glossary} + selectedData={activeGlossary as Glossary} updateGlossary={updateGlossary} updateVote={updateVote} onAssetClick={handleAssetClick} diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/glossaryAPI.ts b/openmetadata-ui/src/main/resources/ui/src/rest/glossaryAPI.ts index eddc7f2c452..4b47c6dc609 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/glossaryAPI.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/glossaryAPI.ts @@ -289,3 +289,23 @@ export const searchGlossaryTerms = async (search: string, page = 1) => { return data; }; + +export type GlossaryTermWithChildren = Omit & { + children?: GlossaryTerm[]; +}; + +export const getFirstLevelGlossaryTerms = async (parentFQN: string) => { + const apiUrl = `/glossaryTerms`; + + const { data } = await APIClient.get< + PagingResponse + >(apiUrl, { + params: { + directChildrenOf: parentFQN, + fields: 'childrenCount', + limit: 100000, + }, + }); + + return data; +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.test.ts index e59b83d7327..c6cb6006e23 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.test.ts @@ -10,6 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { ModifiedGlossaryTerm } from '../components/Glossary/GlossaryTermTab/GlossaryTermTab.interface'; import { EntityType } from '../enums/entity.enum'; import { MOCKED_GLOSSARY_TERMS, @@ -17,7 +18,12 @@ import { MOCKED_GLOSSARY_TERMS_TREE, MOCKED_GLOSSARY_TERMS_TREE_1, } from '../mocks/Glossary.mock'; -import { buildTree, getQueryFilterToExcludeTerm } from './GlossaryUtils'; +import { + buildTree, + findExpandableKeys, + findExpandableKeysForArray, + getQueryFilterToExcludeTerm, +} from './GlossaryUtils'; describe('Glossary Utils', () => { it('getQueryFilterToExcludeTerm returns the correct query filter', () => { @@ -79,4 +85,78 @@ describe('Glossary Utils', () => { MOCKED_GLOSSARY_TERMS_TREE_1 ); }); + + it('should return an empty array if no glossary term is provided', () => { + const expandableKeys = findExpandableKeys(); + + expect(expandableKeys).toEqual([]); + }); + + it('should return an array of expandable keys when glossary term has children', () => { + const glossaryTerm = { + fullyQualifiedName: 'example', + children: [ + { + fullyQualifiedName: 'child1', + children: [ + { + fullyQualifiedName: 'grandchild1', + }, + { + childrenCount: 2, + fullyQualifiedName: 'grandchild2', + }, + ], + }, + { + fullyQualifiedName: 'child2', + }, + ], + }; + + const expandableKeys = findExpandableKeys( + glossaryTerm as ModifiedGlossaryTerm + ); + + expect(expandableKeys).toEqual(['grandchild2', 'child1', 'example']); + }); + + it('should return an array of expandable keys when glossary term has childrenCount', () => { + const glossaryTerm = { + fullyQualifiedName: 'example', + childrenCount: 2, + }; + + const expandableKeys = findExpandableKeys( + glossaryTerm as ModifiedGlossaryTerm + ); + + expect(expandableKeys).toEqual(['example']); + }); + + it('should find expandable keys for an array of glossary terms', () => { + const glossaryTerms = [ + { + fullyQualifiedName: 'example1', + children: [ + { + fullyQualifiedName: 'child1', + }, + ], + }, + { + fullyQualifiedName: 'example2', + childrenCount: 2, + }, + { + fullyQualifiedName: 'example3', + }, + ]; + + const expandableKeys = findExpandableKeysForArray( + glossaryTerms as ModifiedGlossaryTerm[] + ); + + expect(expandableKeys).toEqual(['example1', 'example2']); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.ts index ecd076932c6..a6c3c295b5e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.ts @@ -15,6 +15,7 @@ import { DefaultOptionType } from 'antd/lib/select'; import { isEmpty } from 'lodash'; import { StatusType } from '../components/common/StatusBadge/StatusBadge.interface'; import { ModifiedGlossaryTerm } from '../components/Glossary/GlossaryTermTab/GlossaryTermTab.interface'; +import { ModifiedGlossary } from '../components/Glossary/useGlossary.store'; import { FQN_SEPARATOR_CHAR } from '../constants/char.constants'; import { EntityType } from '../enums/entity.enum'; import { Glossary } from '../generated/entity/data/glossary'; @@ -148,7 +149,7 @@ export const getGlossaryBreadcrumbs = (fqn: string) => { export const findGlossaryTermByFqn = ( list: ModifiedGlossaryTerm[], fullyQualifiedName: string -): GlossaryTerm | Glossary | null => { +): GlossaryTerm | Glossary | ModifiedGlossary | null => { for (const item of list) { if (item.fullyQualifiedName === fullyQualifiedName) { return item; @@ -197,3 +198,53 @@ export const convertGlossaryTermsToTreeOptions = ( return treeData; }; + +/** + * Finds the expandable keys in a glossary term. + * @param glossaryTerm - The glossary term to search for expandable keys. + * @returns An array of expandable keys found in the glossary term. + */ +export const findExpandableKeys = ( + glossaryTerm?: ModifiedGlossaryTerm +): string[] => { + let expandableKeys: string[] = []; + + if (!glossaryTerm) { + return expandableKeys; + } + + if (glossaryTerm.children) { + glossaryTerm.children.forEach((child) => { + expandableKeys = expandableKeys.concat( + findExpandableKeys(child as ModifiedGlossaryTerm) + ); + }); + if (glossaryTerm.fullyQualifiedName) { + expandableKeys.push(glossaryTerm.fullyQualifiedName); + } + } else if (glossaryTerm.childrenCount) { + if (glossaryTerm.fullyQualifiedName) { + expandableKeys.push(glossaryTerm.fullyQualifiedName); + } + } + + return expandableKeys; +}; + +/** + * Finds the expandable keys for an array of glossary terms. + * + * @param glossaryTerms - An array of ModifiedGlossaryTerm objects. + * @returns An array of expandable keys. + */ +export const findExpandableKeysForArray = ( + glossaryTerms: ModifiedGlossaryTerm[] +): string[] => { + let expandableKeys: string[] = []; + + glossaryTerms.forEach((glossaryTerm) => { + expandableKeys = expandableKeys.concat(findExpandableKeys(glossaryTerm)); + }); + + return expandableKeys; +};