feat(ui): support assets selection from glossary page (#10803)

* feat: initial commit glossary redesign

* chore: add localization

* fix: update glossary ui

* fix: missing localization

* feat: update glossary ui

* fix: jest tests

* fix: jest tests

* fix: update breadcrumbs

* fix: update cypress tests

* chore: remove logs

* fix: update glossary right panel

* fix: jest tests

* fix: add reviewer functionality

* feat(ui): support assets selection from glossary page

---------

Co-authored-by: karanh37 <karanh37@gmail.com>
Co-authored-by: karanh37 <33024356+karanh37@users.noreply.github.com>
This commit is contained in:
Chirag Madlani 2023-03-29 11:29:11 +05:30 committed by GitHub
parent a75bc74433
commit 53ec29bc82
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 499 additions and 130 deletions

View File

@ -0,0 +1,45 @@
/*
* Copyright 2023 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 { EntityType } from 'enums/entity.enum';
import { Container } from 'generated/entity/data/container';
import { Dashboard } from 'generated/entity/data/dashboard';
import { Mlmodel } from 'generated/entity/data/mlmodel';
import { Pipeline } from 'generated/entity/data/pipeline';
import { Table } from 'generated/entity/data/table';
import { Topic } from 'generated/entity/data/topic';
export interface AssetSelectionModalProps {
glossaryFQN: string;
open: boolean;
onCancel: () => void;
onSave?: () => void;
}
export type AssetsUnion =
| EntityType.TABLE
| EntityType.PIPELINE
| EntityType.DASHBOARD
| EntityType.MLMODEL
| EntityType.TOPIC
| EntityType.CONTAINER;
export type AssetFilterKeys = AssetsUnion | 'all';
export type MapPatchAPIResponse = {
[EntityType.TABLE]: Table;
[EntityType.DASHBOARD]: Dashboard;
[EntityType.MLMODEL]: Mlmodel;
[EntityType.PIPELINE]: Pipeline;
[EntityType.CONTAINER]: Container;
[EntityType.TOPIC]: Topic;
};

View File

@ -0,0 +1,265 @@
/*
* Copyright 2023 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 { Button, List, Modal, Radio, Space } from 'antd';
import Searchbar from 'components/common/searchbar/Searchbar';
import TableDataCardV2 from 'components/common/table-data-card-v2/TableDataCardV2';
import { EntityUnion } from 'components/Explore/explore.interface';
import Loader from 'components/Loader/Loader';
import { PAGE_SIZE_MEDIUM } from 'constants/constants';
import { EntityType } from 'enums/entity.enum';
import { SearchIndex } from 'enums/search.enum';
import { compare, Operation } from 'fast-json-patch';
import { cloneDeep, groupBy, map, startCase } from 'lodash';
import { EntityDetailUnion } from 'Models';
import VirtualList from 'rc-virtual-list';
import {
default as React,
UIEventHandler,
useEffect,
useMemo,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import { patchDashboardDetails } from 'rest/dashboardAPI';
import { patchMlModelDetails } from 'rest/mlModelAPI';
import { patchContainerDetails } from 'rest/objectStoreAPI';
import { patchPipelineDetails } from 'rest/pipelineAPI';
import { searchQuery } from 'rest/searchAPI';
import { patchTableDetails } from 'rest/tableAPI';
import { patchTopicDetails } from 'rest/topicsAPI';
import { getCountBadge } from 'utils/CommonUtils';
import { getQueryFilterToExcludeTerm } from 'utils/GlossaryUtils';
import {
AssetFilterKeys,
AssetSelectionModalProps,
AssetsUnion,
MapPatchAPIResponse,
} from './AssetSelectionModal.interface';
export const AssetSelectionModal = ({
glossaryFQN,
onCancel,
open,
}: AssetSelectionModalProps) => {
const { t } = useTranslation();
const [search, setSearch] = useState('');
const [items, setItems] = useState<EntityDetailUnion[]>([]);
const [itemCount, setItemCount] = useState<Record<AssetFilterKeys, number>>();
const [selectedItems, setSelectedItems] =
useState<Map<string, EntityDetailUnion>>();
const [isLoading, setIsLoading] = useState(false);
const [activeFilter, setActiveFilter] = useState<AssetFilterKeys>('all');
const [pageNumber, setPageNumber] = useState(1);
const fetchEntities = async (searchText = '', page = 1) => {
try {
setIsLoading(true);
const res = await searchQuery({
pageNumber: page,
pageSize: PAGE_SIZE_MEDIUM,
searchIndex: [
SearchIndex.TABLE,
SearchIndex.PIPELINE,
SearchIndex.MLMODEL,
SearchIndex.TOPIC,
SearchIndex.DASHBOARD,
SearchIndex.CONTAINER,
],
query: searchText,
queryFilter: getQueryFilterToExcludeTerm(glossaryFQN),
});
const groupedArray = groupBy(res.hits.hits, '_source.entityType');
setItemCount({
all: res.hits.total.value,
[EntityType.TABLE]: groupedArray[EntityType.TABLE]?.length ?? 0,
[EntityType.PIPELINE]: groupedArray[EntityType.PIPELINE]?.length ?? 0,
[EntityType.MLMODEL]: groupedArray[EntityType.MLMODEL]?.length ?? 0,
[EntityType.TOPIC]: groupedArray[EntityType.TOPIC]?.length ?? 0,
[EntityType.DASHBOARD]: groupedArray[EntityType.DASHBOARD]?.length ?? 0,
[EntityType.CONTAINER]: groupedArray[EntityType.CONTAINER]?.length ?? 0,
});
setActiveFilter('all');
setItems(res.hits.hits);
setPageNumber(page);
} catch (error) {
console.error(error);
} finally {
setIsLoading(false);
}
};
const handleCardClick = (details: EntityUnion) => {
const id = details.id;
if (!id) {
return;
}
if (selectedItems?.has(id ?? '')) {
setSelectedItems((prevItems) => {
const selectedItemMap = new Map();
prevItems?.forEach(
(item) => item.id !== id && selectedItemMap.set(item.id, item)
);
return selectedItemMap;
});
} else {
setSelectedItems((prevItems) => {
const selectedItemMap = new Map();
prevItems?.forEach((item) => selectedItemMap.set(item.id, item));
selectedItemMap.set(
id,
items.find(({ _source }) => _source.id === id)._source
);
return selectedItemMap;
});
}
};
const getAPIfromSource = (
source: AssetsUnion
): ((
id: string,
jsonPatch: Operation[]
) => Promise<MapPatchAPIResponse[typeof source]>) => {
switch (source) {
case EntityType.TABLE:
return patchTableDetails;
case EntityType.DASHBOARD:
return patchDashboardDetails;
case EntityType.MLMODEL:
return patchMlModelDetails;
case EntityType.PIPELINE:
return patchPipelineDetails;
case EntityType.TOPIC:
return patchTopicDetails;
case EntityType.CONTAINER:
return patchContainerDetails;
}
};
const handleSave = async () => {
setIsLoading(true);
const promises = [...(selectedItems?.values() ?? [])].map((item) => {
const jsonPatch = compare(
{ tags: item.tags },
{
tags: [
...item.tags,
{ tagFQN: glossaryFQN, source: 'Glossary', labelType: 'Manual' },
],
}
);
const api = getAPIfromSource(item.entityType);
return api(item.id, jsonPatch);
});
try {
await Promise.all(promises);
onCancel();
} catch (error) {
console.error(error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchEntities();
}, []);
const onScroll: UIEventHandler<HTMLElement> = (e) => {
if (e.currentTarget.scrollHeight - e.currentTarget.scrollTop === 500) {
fetchEntities(search, pageNumber + 1);
}
};
const filteredData = useMemo(() => {
return activeFilter === 'all'
? cloneDeep(items)
: items.filter((i) => i._source.entityType === activeFilter);
}, [items, activeFilter]);
return (
<Modal
destroyOnClose
closable={false}
closeIcon={null}
footer={
<>
<Button onClick={onCancel}>{t('label.cancel')}</Button>
<Button loading={isLoading} type="primary" onClick={handleSave}>
{t('label.save')}
</Button>
</>
}
open={open}
style={{ top: 40 }}
title={t('label.add-entity', { entity: t('label.asset-plural') })}
width={750}>
<Space className="w-full h-full" direction="vertical" size={16}>
<Searchbar
removeMargin
placeholder={t('label.search-entity', {
entity: t('label.asset-plural'),
})}
searchValue={search}
onSearch={(s) => {
setSearch(s);
fetchEntities(s);
}}
/>
<Radio.Group
value={activeFilter}
onChange={(e) => setActiveFilter(e.target.value)}>
{map(
itemCount,
(value, key) =>
value > 0 && (
<Radio.Button key={key} value={key}>
{startCase(key)} {getCountBadge(value)}
</Radio.Button>
)
)}
</Radio.Group>
<List loading={{ spinning: isLoading, indicator: <Loader /> }}>
<VirtualList
data={filteredData}
height={500}
itemKey="id"
onScroll={onScroll}>
{({ _index: index, _source: item }) => (
<TableDataCardV2
showCheckboxes
checked={selectedItems?.has(item.id)}
className="m-b-xs"
handleSummaryPanelDisplay={handleCardClick}
id={`tabledatacard-${item.id}`}
key={item.id}
searchIndex={index}
source={{ ...item, tags: [] }}
/>
)}
</VirtualList>
</List>
</Space>
</Modal>
);
};

View File

@ -49,7 +49,8 @@ export type ExploreSearchIndexKey =
| 'PIPELINE'
| 'DASHBOARD'
| 'MLMODEL'
| 'TOPIC';
| 'TOPIC'
| 'CONTAINER';
export type SearchHitCounts = Record<ExploreSearchIndex, number>;

View File

@ -16,6 +16,7 @@ import { ReactComponent as ExportIcon } from 'assets/svg/ic-export.svg';
import { ReactComponent as ImportIcon } from 'assets/svg/ic-import.svg';
import { ReactComponent as IconDropdown } from 'assets/svg/menu.svg';
import { AxiosError } from 'axios';
import { AssetSelectionModal } from 'components/Assets/AssetsSelectionModal/AssetSelectionModal';
import EntityDeleteModal from 'components/Modals/EntityDeleteModal/EntityDeleteModal';
import { OperationPermission } from 'components/PermissionProvider/PermissionProvider.interface';
import VersionButton from 'components/VersionButton/VersionButton.component';
@ -66,6 +67,7 @@ const GlossaryHeaderButtons = ({
const [showActions, setShowActions] = useState(false);
const [isDelete, setIsDelete] = useState<boolean>(false);
const [, setVersionList] = useState<EntityHistory>({} as EntityHistory);
const [showAddAssets, setShowAddAssets] = useState(false);
const isExportAction = useMemo(
() => action === GlossaryAction.EXPORT,
@ -115,6 +117,10 @@ const GlossaryHeaderButtons = ({
setIsDelete(false);
};
const handleAddAssetsClick = () => {
setShowAddAssets(true);
};
const addButtonContent = [
{
label: t('label.glossary-term'),
@ -124,6 +130,7 @@ const GlossaryHeaderButtons = ({
{
label: t('label.asset-plural'),
key: '2',
onClick: () => handleAddAssetsClick(),
},
];
@ -317,6 +324,13 @@ const GlossaryHeaderButtons = ({
onOk={handleCancelGlossaryExport}
/>
)}
{selectedData.fullyQualifiedName && !isGlossary && (
<AssetSelectionModal
glossaryFQN={selectedData.fullyQualifiedName}
open={showAddAssets}
onCancel={() => setShowAddAssets(false)}
/>
)}
</>
);
};

View File

@ -48,6 +48,7 @@ const GlossaryTermReferencesModal = ({
return (
<Modal
destroyOnClose
footer={[
<Button key="cancel-btn" type="link" onClick={onClose}>
{t('label.cancel')}
@ -60,8 +61,8 @@ const GlossaryTermReferencesModal = ({
{t('label.save')}
</Button>,
]}
open={isVisible}
title={t('label.reference-plural')}
visible={isVisible}
onCancel={onClose}>
<Form className="reference-edit-form" form={form} onFinish={handleSubmit}>
<Form.List name="references">

View File

@ -12,11 +12,12 @@
*/
import { ExclamationCircleOutlined } from '@ant-design/icons';
import { Checkbox } from 'antd';
import classNames from 'classnames';
import { EntityUnion } from 'components/Explore/explore.interface';
import { isString, startCase, uniqueId } from 'lodash';
import { ExtraInfo } from 'Models';
import React, { useMemo } from 'react';
import React, { forwardRef, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation, useParams } from 'react-router-dom';
import { getEntityId, getEntityName } from 'utils/EntityUtils';
@ -56,146 +57,164 @@ export interface TableDataCardPropsV2 {
details: EntityUnion,
entityType: string
) => void;
checked?: boolean;
showCheckboxes?: boolean;
}
const TableDataCardV2: React.FC<TableDataCardPropsV2> = ({
id,
className,
source,
matches,
searchIndex,
handleSummaryPanelDisplay,
}) => {
const { t } = useTranslation();
const location = useLocation();
const { tab } = useParams<{ tab: string }>();
const TableDataCardV2: React.FC<TableDataCardPropsV2> = forwardRef<
HTMLDivElement,
TableDataCardPropsV2
>(
(
{
id,
className,
source,
matches,
searchIndex,
handleSummaryPanelDisplay,
showCheckboxes,
checked,
},
ref
) => {
const { t } = useTranslation();
const location = useLocation();
const { tab } = useParams<{ tab: string }>();
const otherDetails = useMemo(() => {
const _otherDetails: ExtraInfo[] = [
{
key: 'Owner',
value: getOwnerValue(source.owner as EntityReference),
placeholderText: getEntityPlaceHolder(
getEntityName(source.owner as EntityReference),
source.owner?.deleted
),
id: getEntityId(source.owner as EntityReference),
isEntityDetails: true,
isLink: true,
openInNewTab: false,
profileName:
source.owner?.type === OwnerType.USER
? source.owner?.name
: undefined,
},
];
const otherDetails = useMemo(() => {
const _otherDetails: ExtraInfo[] = [
{
key: 'Owner',
value: getOwnerValue(source.owner as EntityReference),
placeholderText: getEntityPlaceHolder(
getEntityName(source.owner as EntityReference),
source.owner?.deleted
),
id: getEntityId(source.owner as EntityReference),
isEntityDetails: true,
isLink: true,
openInNewTab: false,
profileName:
source.owner?.type === OwnerType.USER
? source.owner?.name
: undefined,
},
];
if (
source.entityType !== EntityType.GLOSSARY_TERM &&
source.entityType !== EntityType.TAG
) {
_otherDetails.push({
key: 'Tier',
value: source.tier
? isString(source.tier)
? source.tier
: source.tier?.tagFQN.split(FQN_SEPARATOR_CHAR)[1]
: '',
});
}
if (
source.entityType !== EntityType.GLOSSARY_TERM &&
source.entityType !== EntityType.TAG
) {
_otherDetails.push({
key: 'Tier',
value: source.tier
? isString(source.tier)
? source.tier
: source.tier?.tagFQN.split(FQN_SEPARATOR_CHAR)[1]
: '',
});
}
if ('usageSummary' in source) {
_otherDetails.push({
value: getUsagePercentile(
source.usageSummary?.weeklyStats?.percentileRank || 0,
true
),
});
}
if ('usageSummary' in source) {
_otherDetails.push({
value: getUsagePercentile(
source.usageSummary?.weeklyStats?.percentileRank || 0,
true
),
});
}
if ('tableType' in source) {
_otherDetails.push({
key: 'Type',
value: source.tableType,
showLabel: true,
});
}
if ('tableType' in source) {
_otherDetails.push({
key: 'Type',
value: source.tableType,
showLabel: true,
});
}
return _otherDetails;
}, [source]);
return _otherDetails;
}, [source]);
const handleLinkClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (location.pathname.includes(ROUTES.TOUR)) {
AppState.currentTourPage = CurrentTourPageType.DATASET_PAGE;
}
};
const handleLinkClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (location.pathname.includes(ROUTES.TOUR)) {
AppState.currentTourPage = CurrentTourPageType.DATASET_PAGE;
}
};
const headerLabel = useMemo(() => {
return getEntityHeaderLabel(source);
}, [source]);
const headerLabel = useMemo(() => {
return getEntityHeaderLabel(source);
}, [source]);
const serviceIcon = useMemo(() => {
return getServiceIcon(source);
}, [source]);
const serviceIcon = useMemo(() => {
return getServiceIcon(source);
}, [source]);
return (
<div
className={classNames(
'data-asset-info-card-container',
'table-data-card-container',
className ? className : ''
)}
data-testid="table-data-card"
id={id}
onClick={() => {
handleSummaryPanelDisplay &&
handleSummaryPanelDisplay(source as EntityUnion, tab);
}}>
<div>
{headerLabel}
<div className="tw-flex tw-items-center">
{serviceIcon}
<TableDataCardTitle
handleLinkClick={handleLinkClick}
id={id}
searchIndex={searchIndex}
source={source}
return (
<div
className={classNames(
'data-asset-info-card-container',
'table-data-card-container',
className ? className : ''
)}
data-testid="table-data-card"
id={id}
ref={ref}
onClick={() => {
handleSummaryPanelDisplay &&
handleSummaryPanelDisplay(source as EntityUnion, tab);
}}>
<div>
{headerLabel}
<div className="tw-flex tw-items-center">
{serviceIcon}
<TableDataCardTitle
handleLinkClick={handleLinkClick}
id={id}
searchIndex={searchIndex}
source={source}
/>
{source.deleted && (
<>
<div
className="tw-rounded tw-bg-error-lite tw-text-error tw-text-xs tw-font-medium tw-h-5 tw-px-1.5 tw-py-0.5 tw-ml-2"
data-testid="deleted">
<ExclamationCircleOutlined className="tw-mr-1" />
{t('label.deleted')}
</div>
</>
)}
{showCheckboxes && (
<Checkbox checked={checked} className="m-l-auto" />
)}
</div>
</div>
<div className="tw-pt-3">
<TableDataCardBody
description={source.description || ''}
extraInfo={otherDetails}
tags={source.tags}
/>
{source.deleted && (
<>
<div
className="tw-rounded tw-bg-error-lite tw-text-error tw-text-xs tw-font-medium tw-h-5 tw-px-1.5 tw-py-0.5 tw-ml-2"
data-testid="deleted">
<ExclamationCircleOutlined className="tw-mr-1" />
{t('label.deleted')}
</div>
</>
)}
</div>
{matches && matches.length > 0 ? (
<div className="tw-pt-2" data-testid="matches-stats">
<span className="tw-text-grey-muted">{`${t(
'label.matches'
)}:`}</span>
{matches.map((data, i) => (
<span className="tw-ml-2" key={uniqueId()}>
{`${data.value} in ${startCase(data.key)}${
i !== matches.length - 1 ? ',' : ''
}`}
</span>
))}
</div>
) : null}
</div>
<div className="tw-pt-3">
<TableDataCardBody
description={source.description || ''}
extraInfo={otherDetails}
tags={source.tags}
/>
</div>
{matches && matches.length > 0 ? (
<div className="tw-pt-2" data-testid="matches-stats">
<span className="tw-text-grey-muted">{`${t('label.matches')}:`}</span>
{matches.map((data, i) => (
<span className="tw-ml-2" key={uniqueId()}>
{`${data.value} in ${startCase(data.key)}${
i !== matches.length - 1 ? ',' : ''
}`}
</span>
))}
</div>
) : null}
</div>
);
};
);
}
);
export default TableDataCardV2;

View File

@ -160,3 +160,27 @@ export const getSearchedDataFromGlossaryTree = (
return acc;
}, [] as ModifiedGlossaryTerm[]);
};
export const getQueryFilterToExcludeTerm = (fqn: string) => ({
query: {
bool: {
must: [
{
bool: {
must: [
{
bool: {
must_not: {
term: {
'tags.tagFQN': fqn,
},
},
},
},
],
},
},
],
},
},
});