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 <sonikashah94@gmail.com>
Co-authored-by: Shailesh Parmar <shailesh.parmar.webdev@gmail.com>
This commit is contained in:
Chirag Madlani 2024-04-30 12:42:43 +05:30 committed by GitHub
parent 97d3625302
commit 0701fed67e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 426 additions and 155 deletions

View File

@ -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<FeedCounts>(
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 = ({
<GlossaryTermTab
isGlossary
childGlossaryTerms={glossaryTerms}
permissions={permissions}
refreshGlossaryTerms={refreshGlossaryTerms}
selectedData={glossary}
termsLoading={termsLoading}
onAddGlossaryTerm={onAddGlossaryTerm}
onEditGlossaryTerm={onEditGlossaryTerm}
@ -192,7 +189,6 @@ const GlossaryDetails = ({
isVersionView,
permissions,
glossary,
glossaryTerms,
termsLoading,
description,
isDescriptionEditable,

View File

@ -24,8 +24,7 @@ export enum GlossaryTabs {
export type GlossaryDetailsProps = {
isVersionView?: boolean;
permissions: OperationPermission;
glossary: Glossary;
glossaryTerms: GlossaryTerm[];
termsLoading: boolean;
updateGlossary: (value: Glossary) => Promise<void>;
updateVote?: (data: VotingDataProps) => Promise<void>;

View File

@ -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<ModifiedGlossaryTerm[]>(
[]
);
const [expandedRowKeys, setExpandedRowKeys] = useState<string[]>([]);
const glossaryTerms = activeGlossary?.children as ModifiedGlossaryTerm[];
const [movedGlossaryTerm, setMovedGlossaryTerm] =
useState<MoveGlossaryTermType>();
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const [isTableLoading, setIsTableLoading] = useState(false);
const [isTableHovered, setIsTableHovered] = useState(false);
const [expandedRowKeys, setExpandedRowKeys] = useState<string[]>([]);
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<ModifiedGlossaryTerm> = [
@ -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<ModifiedGlossaryTerm> = useMemo(
() => ({
...getTableExpandableConfig<ModifiedGlossaryTerm>(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 ? (
<>
<IconDrag className="m-r-xs drag-icon" height={12} width={8} />
<Icon
className="m-r-xs vertical-baseline"
component={expanded ? IconDown : IconRight}
data-testid="expand-icon"
style={{ fontSize: '10px', color: TEXT_BODY_COLOR }}
onClick={(e) => onExpand(record, e)}
/>
</>
) : (
<>
<IconDrag className="m-r-xs drag-icon" height={12} width={8} />
<span className="expand-cell-empty-icon-container" />
</>
);
},
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 <Loader />;
},
}),
[expandedRowKeys]
[glossaryTerms, updateActiveGlossary, expandedRowKeys]
);
const handleMoveRow = useCallback(
@ -324,33 +381,47 @@ const GlossaryTermTab = ({
handleTableHover,
} as DraggableBodyRowProps<GlossaryTerm>);
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 <Loader />;
}
@ -382,15 +453,13 @@ const GlossaryTermTab = ({
type="text"
onClick={toggleExpandAll}>
<Space align="center" size={4}>
{expandedRowKeys.length === childGlossaryTerms.length ? (
{isAllExpanded ? (
<DownUpArrowIcon color={DE_ACTIVE_COLOR} height="14px" />
) : (
<UpDownArrowIcon color={DE_ACTIVE_COLOR} height="14px" />
)}
{expandedRowKeys.length === childGlossaryTerms.length
? t('label.collapse-all')
: t('label.expand-all')}
{isAllExpanded ? t('label.collapse-all') : t('label.expand-all')}
</Space>
</Button>
</div>
@ -437,7 +506,9 @@ const GlossaryTermTab = ({
renderElement={<strong />}
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'),

View File

@ -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;

View File

@ -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(() => <div>OwnerLabel</div>),
}));
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(<GlossaryTermTab {...mockProps1} />, {
const { container } = render(<GlossaryTermTab {...mockProps} />, {
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(<GlossaryTermTab {...mockProps1} />, {
const { container } = render(<GlossaryTermTab {...mockProps} />, {
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(<GlossaryTermTab {...mockProps2} />, {
(useGlossaryStore as unknown as jest.Mock).mockImplementation(() => ({
activeGlossary: {
...mockedGlossaryTerms[0],
children: mockedGlossaryTerms,
},
updateActiveGlossary: jest.fn(),
}));
const { container } = render(<GlossaryTermTab {...mockProps} />, {
wrapper: MemoryRouter,
});

View File

@ -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<number>(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: (
<GlossaryTermTab
childGlossaryTerms={childGlossaryTerms}
className="p-md glossary-term-table-container"
isGlossary={false}
permissions={permissions}
refreshGlossaryTerms={refreshGlossaryTerms}
selectedData={glossaryTerm}
termsLoading={termsLoading}
onAddGlossaryTerm={onAddGlossaryTerm}
onEditGlossaryTerm={onEditGlossaryTerm}

View File

@ -19,7 +19,6 @@ export interface GlossaryTermsV1Props {
isVersionView?: boolean;
permissions: OperationPermission;
glossaryTerm: GlossaryTerm;
childGlossaryTerms: GlossaryTerm[];
handleGlossaryTermUpdate: (data: GlossaryTerm) => Promise<void>;
handleGlossaryTermDelete: (id: string) => Promise<void>;
refreshGlossaryTerms: () => void;

View File

@ -88,7 +88,7 @@ const mockProps = {
describe('Test Glossary-term component', () => {
it('Should render Glossary-term component', async () => {
render(<GlossaryTerms {...mockProps} childGlossaryTerms={[]} />);
render(<GlossaryTerms {...mockProps} />);
const glossaryTerm = screen.getByTestId('glossary-term');
const tabs = await screen.findAllByRole('tab');

View File

@ -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<string>('');
const [threadType, setThreadType] = useState<ThreadType>(
ThreadType.Conversation
);
const { postFeed, deleteFeed, updateFeed } = useActivityFeedProvider();
const [activeGlossaryTerm, setActiveGlossaryTerm] =
useState<GlossaryTerm | null>(null);
const { getEntityPermission } = usePermissionProvider();
const [isLoading, setIsLoading] = useState(true);
const [isTermsLoading, setIsTermsLoading] = useState(false);
@ -94,13 +93,12 @@ const GlossaryV1 = ({
useState<OperationPermission>(DEFAULT_ENTITY_PERMISSION);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [activeGlossaryTerm, setActiveGlossaryTerm] = useState<
GlossaryTerm | undefined
>();
const [editMode, setEditMode] = useState(false);
const [glossaryTerms, setGlossaryTerms] = useState<GlossaryTerm[]>([]);
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 ? (
<GlossaryDetails
glossary={selectedData as Glossary}
glossaryTerms={glossaryTerms}
handleGlossaryDelete={onGlossaryDelete}
isVersionView={isVersionsView}
permissions={glossaryPermission}
@ -359,16 +359,15 @@ const GlossaryV1 = ({
updateGlossary={updateGlossary}
updateVote={updateVote}
onAddGlossaryTerm={(term) =>
handleGlossaryTermModalAction(false, term)
handleGlossaryTermModalAction(false, term ?? null)
}
onEditGlossaryTerm={(term) =>
handleGlossaryTermModalAction(true, term)
handleGlossaryTermModalAction(true, term ?? null)
}
onThreadLinkSelect={onThreadLinkSelect}
/>
) : (
<GlossaryTermsV1
childGlossaryTerms={glossaryTerms}
glossaryTerm={selectedData as GlossaryTerm}
handleGlossaryTermDelete={onGlossaryTermDelete}
handleGlossaryTermUpdate={onGlossaryTermUpdate}
@ -380,7 +379,7 @@ const GlossaryV1 = ({
termsLoading={isTermsLoading}
updateVote={updateVote}
onAddGlossaryTerm={(term) =>
handleGlossaryTermModalAction(false, term)
handleGlossaryTermModalAction(false, term ?? null)
}
onAssetClick={onAssetClick}
onEditGlossaryTerm={(term) =>

View File

@ -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],

View File

@ -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<ModifiedGlossary>) => 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<ModifiedGlossary>) => {
const { activeGlossary } = get();
set({ activeGlossary: { ...activeGlossary, ...glossary } as Glossary });
},
}));

View File

@ -189,7 +189,7 @@ const TreeAsyncSelectList: FC<Omit<AsyncSelectListProps, 'fetchOptions'>> = ({
label: value,
};
});
selectedTagsRef.current = selectedValues;
selectedTagsRef.current = selectedValues as SelectOption[];
onChange?.(selectedValues);
};

View File

@ -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<Glossary[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [selectedData, setSelectedData] = useState<Glossary | GlossaryTerm>();
const [isRightPanelLoading, setIsRightPanelLoading] = useState(true);
const [previewAsset, setPreviewAsset] =
useState<EntityDetailsObjectInterface>();
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}

View File

@ -289,3 +289,23 @@ export const searchGlossaryTerms = async (search: string, page = 1) => {
return data;
};
export type GlossaryTermWithChildren = Omit<GlossaryTerm, 'children'> & {
children?: GlossaryTerm[];
};
export const getFirstLevelGlossaryTerms = async (parentFQN: string) => {
const apiUrl = `/glossaryTerms`;
const { data } = await APIClient.get<
PagingResponse<GlossaryTermWithChildren[]>
>(apiUrl, {
params: {
directChildrenOf: parentFQN,
fields: 'childrenCount',
limit: 100000,
},
});
return data;
};

View File

@ -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']);
});
});

View File

@ -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;
};