feat #10584 : Supported DataModel in Dashboard Page (#10931)

* Supported DataModel in Dashboard Page

* url fqn changes

* changes as per comments
This commit is contained in:
Ashish Gupta 2023-04-06 15:03:30 +05:30 committed by GitHub
parent 62b88b0404
commit 4001aa0ccc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1163 additions and 7 deletions

View File

@ -56,6 +56,6 @@
"default": null
}
},
"required": ["name", "service", "modelType", "columns"],
"required": ["name", "service", "dataModelType", "columns"],
"additionalProperties": false
}

View File

@ -125,7 +125,7 @@
},
"required": [
"name",
"modelType",
"dataModelType",
"columns"
],
"additionalProperties": false

View File

@ -0,0 +1,91 @@
/*
* 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 { Table } from 'antd';
import { ColumnsType } from 'antd/lib/table';
import ErrorPlaceHolder from 'components/common/error-with-placeholder/ErrorPlaceHolder';
import RichTextEditorPreviewer from 'components/common/rich-text-editor/RichTextEditorPreviewer';
import Loader from 'components/Loader/Loader';
import { getDataModelDetailsPath } from 'constants/constants';
import { CONNECTORS_DOCS } from 'constants/docs.constants';
import { servicesDisplayName } from 'constants/Services.constant';
import { isEmpty, isUndefined } from 'lodash';
import { DataModelTableProps } from 'pages/DataModelPage/DataModelsInterface';
import { ServicePageData } from 'pages/service';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { getEntityName } from 'utils/EntityUtils';
const DataModelTable = ({ data, isLoading }: DataModelTableProps) => {
const { t } = useTranslation();
const tableColumn: ColumnsType<ServicePageData> = useMemo(
() => [
{
title: t('label.name'),
dataIndex: 'displayName',
key: 'displayName',
render: (_, record: ServicePageData) => {
return (
<Link to={getDataModelDetailsPath(record.fullyQualifiedName || '')}>
{getEntityName(record)}
</Link>
);
},
},
{
title: t('label.description'),
dataIndex: 'description',
key: 'description',
render: (description: ServicePageData['description']) =>
!isUndefined(description) && description.trim() ? (
<RichTextEditorPreviewer markdown={description} />
) : (
<span className="text-grey-muted">
{t('label.no-entity', {
entity: t('label.description'),
})}
</span>
),
},
],
[]
);
return isEmpty(data) ? (
<ErrorPlaceHolder
doc={CONNECTORS_DOCS}
heading={servicesDisplayName.dashboardDataModel}
/>
) : (
<div data-testid="table-container">
<Table
bordered
className="mt-4 table-shadow"
columns={tableColumn}
data-testid="data-models-table"
dataSource={data}
loading={{
spinning: isLoading,
indicator: <Loader size="small" />,
}}
pagination={false}
rowKey="id"
size="small"
/>
</div>
);
};
export default DataModelTable;

View File

@ -0,0 +1,262 @@
/*
* 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, Space, Table, Typography } from 'antd';
import { ColumnsType } from 'antd/lib/table';
import RichTextEditorPreviewer from 'components/common/rich-text-editor/RichTextEditorPreviewer';
import { CellRendered } from 'components/ContainerDetail/ContainerDataModel/ContainerDataModel.interface';
import { ModalWithMarkdownEditor } from 'components/Modals/ModalWithMarkdownEditor/ModalWithMarkdownEditor';
import TagsContainer from 'components/Tag/TagsContainer/tags-container';
import TagsViewer from 'components/Tag/TagsViewer/tags-viewer';
import { Column } from 'generated/entity/data/dashboardDataModel';
import { cloneDeep, isEmpty, isUndefined } from 'lodash';
import { EntityTags, TagOption } from 'Models';
import React, { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
updateDataModelColumnDescription,
updateDataModelColumnTags,
} from 'utils/DataModelsUtils';
import { fetchTagsAndGlossaryTerms } from 'utils/TagsUtils';
import { ReactComponent as EditIcon } from '../../../assets/svg/ic-edit.svg';
import { ModelTabProps } from './ModelTab.interface';
const ModelTab = ({
data,
isReadOnly,
hasEditDescriptionPermission,
hasEditTagsPermission,
onUpdate,
}: ModelTabProps) => {
const { t } = useTranslation();
const [editColumnDescription, setEditColumnDescription] = useState<Column>();
const [editContainerColumnTags, setEditContainerColumnTags] =
useState<Column>();
const [tagList, setTagList] = useState<TagOption[]>([]);
const [isTagLoading, setIsTagLoading] = useState<boolean>(false);
const [tagFetchFailed, setTagFetchFailed] = useState<boolean>(false);
const fetchTags = useCallback(async () => {
setIsTagLoading(true);
try {
const tagsAndTerms = await fetchTagsAndGlossaryTerms();
setTagList(tagsAndTerms);
} catch (error) {
setTagList([]);
setTagFetchFailed(true);
} finally {
setIsTagLoading(false);
}
}, [fetchTagsAndGlossaryTerms]);
const handleFieldTagsChange = useCallback(
async (selectedTags: EntityTags[] = [], selectedColumn: Column) => {
const newSelectedTags: TagOption[] = selectedTags.map((tag) => ({
fqn: tag.tagFQN,
source: tag.source,
}));
const dataModelData = cloneDeep(data);
updateDataModelColumnTags(
dataModelData,
editContainerColumnTags?.name ?? selectedColumn.name,
newSelectedTags
);
await onUpdate(dataModelData);
setEditContainerColumnTags(undefined);
},
[data, updateDataModelColumnTags]
);
const handleColumnDescriptionChange = useCallback(
async (updatedDescription: string) => {
if (!isUndefined(editColumnDescription)) {
const dataModelColumns = cloneDeep(data);
updateDataModelColumnDescription(
dataModelColumns,
editColumnDescription?.name,
updatedDescription
);
await onUpdate(dataModelColumns);
}
setEditColumnDescription(undefined);
},
[editColumnDescription, data]
);
const handleAddTagClick = useCallback(
(record: Column) => {
if (isUndefined(editContainerColumnTags)) {
setEditContainerColumnTags(record);
// Fetch tags and terms only once
if (tagList.length === 0 || tagFetchFailed) {
fetchTags();
}
}
},
[editContainerColumnTags, tagList, tagFetchFailed]
);
const renderColumnDescription: CellRendered<Column, 'description'> =
useCallback(
(description, record, index) => {
return (
<Space
className="custom-group w-full"
data-testid="description"
id={`field-description-${index}`}
size={4}>
<>
{description ? (
<RichTextEditorPreviewer markdown={description} />
) : (
<Typography.Text className="tw-no-description">
{t('label.no-entity', {
entity: t('label.description'),
})}
</Typography.Text>
)}
</>
{isReadOnly && !hasEditDescriptionPermission ? null : (
<Button
className="p-0 opacity-0 group-hover-opacity-100"
data-testid="edit-button"
icon={<EditIcon width="16px" />}
type="text"
onClick={() => setEditColumnDescription(record)}
/>
)}
</Space>
);
},
[isReadOnly, hasEditDescriptionPermission]
);
const renderColumnTags: CellRendered<Column, 'tags'> = useCallback(
(tags, record: Column) => {
const isSelectedField = editContainerColumnTags?.name === record.name;
const isUpdatingTags = isSelectedField || !isEmpty(tags);
return (
<>
{isReadOnly ? (
<TagsViewer sizeCap={-1} tags={tags || []} />
) : (
<Space
align={isUpdatingTags ? 'start' : 'center'}
className="justify-between"
data-testid="tags-wrapper"
direction={isUpdatingTags ? 'vertical' : 'horizontal'}
onClick={() => handleAddTagClick(record)}>
<TagsContainer
editable={isSelectedField}
isLoading={isTagLoading && isSelectedField}
selectedTags={tags || []}
showAddTagButton={hasEditTagsPermission}
size="small"
tagList={tagList}
type="label"
onCancel={() => setEditContainerColumnTags(undefined)}
onSelectionChange={(tags) =>
handleFieldTagsChange(tags, record)
}
/>
</Space>
)}
</>
);
},
[
editContainerColumnTags,
isReadOnly,
isTagLoading,
tagList,
hasEditTagsPermission,
]
);
const tableColumn: ColumnsType<Column> = useMemo(
() => [
{
title: t('label.name'),
dataIndex: 'name',
key: 'name',
width: 250,
},
{
title: t('label.type'),
dataIndex: 'dataTypeDisplay',
key: 'dataTypeDisplay',
width: 100,
},
{
title: t('label.description'),
dataIndex: 'description',
key: 'description',
accessor: 'description',
render: renderColumnDescription,
},
{
title: t('label.tag-plural'),
dataIndex: 'tags',
key: 'tags',
accessor: 'tags',
width: 350,
render: renderColumnTags,
},
],
[
editColumnDescription,
hasEditDescriptionPermission,
hasEditTagsPermission,
editContainerColumnTags,
isReadOnly,
isTagLoading,
]
);
return (
<>
<Table
bordered
className="p-t-xs"
columns={tableColumn}
data-testid="charts-table"
dataSource={data}
pagination={false}
rowKey="name"
size="small"
/>
{editColumnDescription && (
<ModalWithMarkdownEditor
header={`${t('label.edit-entity', {
entity: t('label.column'),
})}: "${editColumnDescription.name}"`}
placeholder={t('label.enter-field-description', {
field: t('label.column'),
})}
value={editColumnDescription.description || ''}
visible={Boolean(editColumnDescription)}
onCancel={() => setEditColumnDescription(undefined)}
onSave={handleColumnDescriptionChange}
/>
)}
</>
);
};
export default ModelTab;

View File

@ -0,0 +1,21 @@
/*
* 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 { Column } from 'generated/entity/data/dashboardDataModel';
export interface ModelTabProps {
data: Column[];
isReadOnly: boolean;
hasEditTagsPermission: boolean;
hasEditDescriptionPermission: boolean;
onUpdate: (updatedDataModel: Column[]) => Promise<void>;
}

View File

@ -126,6 +126,11 @@ const DatabaseSchemaPageComponent = withSuspenseFallback(
() => import('pages/DatabaseSchemaPage/DatabaseSchemaPage.component')
)
);
const DataModelDetailsPage = withSuspenseFallback(
React.lazy(() => import('pages/DataModelPage/DataModelPage.component'))
);
const DatasetDetailsPage = withSuspenseFallback(
React.lazy(
() => import('pages/DatasetDetailsPage/DatasetDetailsPage.component')
@ -350,6 +355,16 @@ const AuthenticatedAppRouter: FunctionComponent = () => {
component={DashboardDetailsPage}
path={ROUTES.DASHBOARD_DETAILS_WITH_TAB}
/>
<Route
exact
component={DataModelDetailsPage}
path={ROUTES.DATA_MODEL_DETAILS}
/>
<Route
exact
component={DataModelDetailsPage}
path={ROUTES.DATA_MODEL_DETAILS_WITH_TAB}
/>
<Route
exact
component={PipelineDetailsPage}

View File

@ -245,6 +245,9 @@ export const servicesDisplayName: { [key: string]: string } = {
objectStoreServices: i18n.t('label.entity-service', {
entity: i18n.t('label.object-store'),
}),
dashboardDataModel: i18n.t('label.entity-service', {
entity: i18n.t('label.data-model'),
}),
};
export const DEF_UI_SCHEMA = {

View File

@ -124,6 +124,7 @@ export const INGESTION_NAME = ':ingestionName';
export const LOG_ENTITY_NAME = ':logEntityName';
export const KPI_NAME = ':kpiName';
export const PLACEHOLDER_ACTION = ':action';
export const PLACEHOLDER_ROUTE_DATA_MODEL_FQN = ':dashboardDataModelFQN';
export const pagingObject = { after: '', before: '', total: 0 };
@ -212,6 +213,8 @@ export const ROUTES = {
TOPIC_DETAILS_WITH_TAB: `/topic/${PLACEHOLDER_ROUTE_TOPIC_FQN}/${PLACEHOLDER_ROUTE_TAB}`,
DASHBOARD_DETAILS: `/dashboard/${PLACEHOLDER_ROUTE_DASHBOARD_FQN}`,
DASHBOARD_DETAILS_WITH_TAB: `/dashboard/${PLACEHOLDER_ROUTE_DASHBOARD_FQN}/${PLACEHOLDER_ROUTE_TAB}`,
DATA_MODEL_DETAILS: `/dashboardDataModel/${PLACEHOLDER_ROUTE_DATA_MODEL_FQN}`,
DATA_MODEL_DETAILS_WITH_TAB: `/dashboardDataModel/${PLACEHOLDER_ROUTE_DATA_MODEL_FQN}/${PLACEHOLDER_ROUTE_TAB}`,
DATABASE_DETAILS: `/database/${PLACEHOLDER_ROUTE_DATABASE_FQN}`,
SCHEMA_DETAILS: `/databaseSchema/${PLACEHOLDER_ROUTE_DATABASE_SCHEMA_FQN}`,
DATABASE_DETAILS_WITH_TAB: `/database/${PLACEHOLDER_ROUTE_DATABASE_FQN}/${PLACEHOLDER_ROUTE_TAB}`,
@ -438,6 +441,19 @@ export const getDashboardDetailsPath = (dashboardFQN: string, tab?: string) => {
return path;
};
export const getDataModelDetailsPath = (dataModelFQN: string, tab?: string) => {
let path = tab
? ROUTES.DATA_MODEL_DETAILS_WITH_TAB
: ROUTES.DATA_MODEL_DETAILS;
path = path.replace(PLACEHOLDER_ROUTE_DATA_MODEL_FQN, dataModelFQN);
if (tab) {
path = path.replace(PLACEHOLDER_ROUTE_TAB, tab);
}
return path;
};
export const getPipelineDetailsPath = (pipelineFQN: string, tab?: string) => {
let path = tab ? ROUTES.PIPELINE_DETAILS_WITH_TAB : ROUTES.PIPELINE_DETAILS;
path = path.replace(PLACEHOLDER_ROUTE_PIPELINE_FQN, pipelineFQN);

View File

@ -42,6 +42,7 @@ export enum EntityType {
ALERT = 'alert',
CONTAINER = 'container',
TAG = 'tag',
DASHBOARD_DATA_MODEL = 'dashboardDataModel',
}
export enum AssetsType {

View File

@ -181,6 +181,7 @@
"data-insight-tier-summary": "Total Data Assets by Tier",
"data-insight-top-viewed-entity-summary": "Most Viewed Data Assets",
"data-insight-total-entity-summary": "Total Data Assets",
"data-model": "Data Model",
"data-quality-test": "Data Quality Test",
"data-type": "Data Type",
"database": "Database",
@ -470,7 +471,9 @@
"ml-model-lowercase-plural": "ML models",
"ml-model-plural": "ML Models",
"mode": "Mode",
"model": "Model",
"model-name": "Model Name",
"model-plural": "Models",
"model-store": "Model Store",
"monday": "Monday",
"month": "Month",

View File

@ -181,6 +181,7 @@
"data-insight-tier-summary": "Total de activos de datos por nivel",
"data-insight-top-viewed-entity-summary": "Activos de datos más vistos",
"data-insight-total-entity-summary": "Total de activos de datos",
"data-model": "Data Model",
"data-quality-test": "Prueba de calidad de datos",
"data-type": "Tipo de datos",
"database": "Base de datos",
@ -470,7 +471,9 @@
"ml-model-lowercase-plural": "modelos de ML",
"ml-model-plural": "Modelos de ML",
"mode": "Moda",
"model": "Model",
"model-name": "Nombre del Modelo",
"model-plural": "Models",
"model-store": "Almacenamiento de Modelos",
"monday": "Lunes",
"month": "Mes",

View File

@ -181,6 +181,7 @@
"data-insight-tier-summary": "Total des Resources de Données par Rang",
"data-insight-top-viewed-entity-summary": "Resources de Données les plus Visitées",
"data-insight-total-entity-summary": "Total Resources de Données",
"data-model": "Data Model",
"data-quality-test": "Data Quality Test",
"data-type": "Type de donnée",
"database": "Base de Données",
@ -470,7 +471,9 @@
"ml-model-lowercase-plural": "ML models",
"ml-model-plural": "Modéles d'IA",
"mode": "Mode",
"model": "Model",
"model-name": "Nom du Modèle",
"model-plural": "Models",
"model-store": "Magasin de Modèles",
"monday": "Monday",
"month": "Month",

View File

@ -181,6 +181,7 @@
"data-insight-tier-summary": "ティアごとの全データアセット",
"data-insight-top-viewed-entity-summary": "最も閲覧されたデータアセット",
"data-insight-total-entity-summary": "全てのデータアセット",
"data-model": "Data Model",
"data-quality-test": "データ品質テスト",
"data-type": "データ型",
"database": "データベース",
@ -470,7 +471,9 @@
"ml-model-lowercase-plural": "ML models",
"ml-model-plural": "MLモデル",
"mode": "モード",
"model": "Model",
"model-name": "モデル名",
"model-plural": "Models",
"model-store": "モデルストア",
"monday": "月曜日",
"month": "月",

View File

@ -181,6 +181,7 @@
"data-insight-tier-summary": "Recursos de dados totais por nível",
"data-insight-top-viewed-entity-summary": "Recursos de dados mais visualizados",
"data-insight-total-entity-summary": "Total de recursos de dados",
"data-model": "Data Model",
"data-quality-test": "Teste de qualidade de dados",
"data-type": "Tipo de dado",
"database": "Banco de dados",
@ -470,7 +471,9 @@
"ml-model-lowercase-plural": "ML models",
"ml-model-plural": "Modelos de ML",
"mode": "Modo",
"model": "Model",
"model-name": "Nome do modelo",
"model-plural": "Models",
"model-store": "Estoque modelo",
"monday": "Segunda-feira",
"month": "Mês",

View File

@ -181,6 +181,7 @@
"data-insight-tier-summary": "分层的总数据资产",
"data-insight-top-viewed-entity-summary": "查看次数最多的数据资产",
"data-insight-total-entity-summary": "所有数据资产",
"data-model": "Data Model",
"data-quality-test": "Data Quality Test",
"data-type": "Data Type",
"database": "数据库",
@ -470,7 +471,9 @@
"ml-model-lowercase-plural": "ML models",
"ml-model-plural": "机器学习模型",
"mode": "Mode",
"model": "Model",
"model-name": "模型名",
"model-plural": "Models",
"model-store": "模型存储",
"monday": "Monday",
"month": "Month",

View File

@ -0,0 +1,464 @@
/*
* 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 { Card, Space, Tabs } from 'antd';
import AppState from 'AppState';
import { AxiosError } from 'axios';
import Description from 'components/common/description/Description';
import EntityPageInfo from 'components/common/entityPageInfo/EntityPageInfo';
import ErrorPlaceHolder from 'components/common/error-with-placeholder/ErrorPlaceHolder';
import PageContainerV1 from 'components/containers/PageContainerV1';
import ModelTab from 'components/DataModels/ModelTab/ModelTab.component';
import Loader from 'components/Loader/Loader';
import { usePermissionProvider } from 'components/PermissionProvider/PermissionProvider';
import {
OperationPermission,
ResourceEntity,
} from 'components/PermissionProvider/PermissionProvider.interface';
import { FQN_SEPARATOR_CHAR } from 'constants/char.constants';
import { getServiceDetailsPath } from 'constants/constants';
import { ENTITY_CARD_CLASS } from 'constants/entity.constants';
import { NO_PERMISSION_TO_VIEW } from 'constants/HelperTextUtil';
import { EntityInfo, EntityType } from 'enums/entity.enum';
import { ServiceCategory } from 'enums/service.enum';
import { OwnerType } from 'enums/user.enum';
import { compare } from 'fast-json-patch';
import { DashboardDataModel } from 'generated/entity/data/dashboardDataModel';
import { LabelType, State, TagSource } from 'generated/type/tagLabel';
import { isUndefined, omitBy } from 'lodash';
import { EntityTags, ExtraInfo } from 'Models';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useHistory, useParams } from 'react-router-dom';
import {
addDataModelFollower,
getDataModelsByName,
patchDataModelDetails,
removeDataModelFollower,
} from 'rest/dataModelsAPI';
import {
getCurrentUserId,
getEntityMissingError,
getEntityPlaceHolder,
getOwnerValue,
} from 'utils/CommonUtils';
import { getDataModelsDetailPath } from 'utils/DataModelsUtils';
import { getEntityName } from 'utils/EntityUtils';
import { DEFAULT_ENTITY_PERMISSION } from 'utils/PermissionsUtils';
import { serviceTypeLogo } from 'utils/ServiceUtils';
import { getTagsWithoutTier, getTierTags } from 'utils/TableUtils';
import { showErrorToast } from 'utils/ToastUtils';
import { DATA_MODELS_DETAILS_TABS } from './DataModelsInterface';
const DataModelsPage = () => {
const history = useHistory();
const { t } = useTranslation();
const { getEntityPermissionByFqn } = usePermissionProvider();
const { dashboardDataModelFQN, tab } = useParams() as Record<string, string>;
const [isEditDescription, setIsEditDescription] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [hasError, setHasError] = useState<boolean>(false);
const [dataModelPermissions, setDataModelPermissions] =
useState<OperationPermission>(DEFAULT_ENTITY_PERMISSION);
const [dataModelData, setDataModelData] = useState<DashboardDataModel>();
// get current user details
const currentUser = useMemo(
() => AppState.getCurrentUserDetails(),
[AppState.userDetails, AppState.nonSecureUserDetails]
);
const {
hasViewPermission,
hasEditDescriptionPermission,
hasEditOwnerPermission,
hasEditTagsPermission,
hasEditTierPermission,
} = useMemo(() => {
return {
hasViewPermission:
dataModelPermissions.ViewAll || dataModelPermissions.ViewBasic,
hasEditDescriptionPermission:
dataModelPermissions.EditAll || dataModelPermissions.EditDescription,
hasEditOwnerPermission:
dataModelPermissions.EditAll || dataModelPermissions.EditOwner,
hasEditTagsPermission:
dataModelPermissions.EditAll || dataModelPermissions.EditTags,
hasEditTierPermission:
dataModelPermissions.EditAll || dataModelPermissions.EditTier,
hasEditLineagePermission:
dataModelPermissions.EditAll || dataModelPermissions.EditLineage,
};
}, [dataModelPermissions]);
const {
tier,
deleted,
owner,
description,
version,
tags,
entityName,
entityId,
followers,
isUserFollowing,
} = useMemo(() => {
return {
deleted: dataModelData?.deleted,
owner: dataModelData?.owner,
description: dataModelData?.description,
version: dataModelData?.version,
tier: getTierTags(dataModelData?.tags ?? []),
tags: getTagsWithoutTier(dataModelData?.tags ?? []),
entityId: dataModelData?.id,
entityName: getEntityName(dataModelData),
isUserFollowing: dataModelData?.followers?.some(
({ id }: { id: string }) => id === getCurrentUserId()
),
followers: dataModelData?.followers ?? [],
};
}, [dataModelData]);
const breadcrumbTitles = useMemo(() => {
const serviceType = dataModelData?.serviceType;
const service = dataModelData?.service;
const serviceName = service?.name;
return [
{
name: serviceName || '',
url: serviceName
? getServiceDetailsPath(
serviceName,
ServiceCategory.DASHBOARD_SERVICES
)
: '',
imgSrc: serviceType ? serviceTypeLogo(serviceType) : undefined,
},
{
name: entityName,
url: '',
activeTitle: true,
},
];
}, [dataModelData, dashboardDataModelFQN, entityName]);
const fetchResourcePermission = async (dashboardDataModelFQN: string) => {
setIsLoading(true);
try {
const entityPermission = await getEntityPermissionByFqn(
ResourceEntity.CONTAINER,
dashboardDataModelFQN
);
setDataModelPermissions(entityPermission);
} catch (error) {
showErrorToast(
t('server.fetch-entity-permissions-error', {
entity: t('label.asset-lowercase'),
})
);
} finally {
setIsLoading(false);
}
};
const handleUpdateDataModelData = (updatedData: DashboardDataModel) => {
const jsonPatch = compare(omitBy(dataModelData, isUndefined), updatedData);
return patchDataModelDetails(dataModelData?.id ?? '', jsonPatch);
};
const handleTabChange = (tabValue: string) => {
if (tabValue !== tab) {
history.push({
pathname: getDataModelsDetailPath(dashboardDataModelFQN, tabValue),
});
}
};
const handleUpdateDescription = async (updatedDescription: string) => {
try {
const { description: newDescription, version } =
await handleUpdateDataModelData({
...(dataModelData as DashboardDataModel),
description: updatedDescription,
});
setDataModelData((prev) => ({
...(prev as DashboardDataModel),
description: newDescription,
version,
}));
} catch (error) {
showErrorToast(error as AxiosError);
}
};
const fetchDataModelDetails = async (dashboardDataModelFQN: string) => {
setIsLoading(true);
try {
const response = await getDataModelsByName(
dashboardDataModelFQN,
'owner,tags,followers'
);
setDataModelData(response);
} catch (error) {
showErrorToast(error as AxiosError);
setHasError(true);
} finally {
setIsLoading(false);
}
};
const handleFollowDataModel = async () => {
const followerId = currentUser?.id ?? '';
const dataModelId = dataModelData?.id ?? '';
try {
if (isUserFollowing) {
const response = await removeDataModelFollower(dataModelId, followerId);
const { oldValue } = response.changeDescription.fieldsDeleted[0];
setDataModelData((prev) => ({
...(prev as DashboardDataModel),
followers: (dataModelData?.followers || []).filter(
(follower) => follower.id !== oldValue[0].id
),
}));
} else {
const response = await addDataModelFollower(dataModelId, followerId);
const { newValue } = response.changeDescription.fieldsAdded[0];
setDataModelData((prev) => ({
...(prev as DashboardDataModel),
followers: [...(dataModelData?.followers ?? []), ...newValue],
}));
}
} catch (error) {
showErrorToast(error as AxiosError);
}
};
const extraInfo: Array<ExtraInfo> = [
{
key: EntityInfo.OWNER,
value: owner && getOwnerValue(owner),
placeholderText: getEntityPlaceHolder(
getEntityName(owner),
owner?.deleted
),
isLink: true,
openInNewTab: false,
profileName: owner?.type === OwnerType.USER ? owner?.name : undefined,
},
{
key: EntityInfo.TIER,
value: tier?.tagFQN ? tier.tagFQN.split(FQN_SEPARATOR_CHAR)[1] : '',
},
];
const handleRemoveTier = async () => {
try {
const { tags: newTags, version } = await handleUpdateDataModelData({
...(dataModelData as DashboardDataModel),
tags: getTagsWithoutTier(dataModelData?.tags ?? []),
});
setDataModelData((prev) => ({
...(prev as DashboardDataModel),
tags: newTags,
version,
}));
} catch (error) {
showErrorToast(error as AxiosError);
}
};
const handleUpdateTags = async (selectedTags: Array<EntityTags> = []) => {
try {
const { tags: newTags, version } = await handleUpdateDataModelData({
...(dataModelData as DashboardDataModel),
tags: [...(tier ? [tier] : []), ...selectedTags],
});
setDataModelData((prev) => ({
...(prev as DashboardDataModel),
tags: newTags,
version,
}));
} catch (error) {
showErrorToast(error as AxiosError);
}
};
const handleUpdateOwner = useCallback(
async (updatedOwner?: DashboardDataModel['owner']) => {
try {
const { owner: newOwner, version } = await handleUpdateDataModelData({
...(dataModelData as DashboardDataModel),
owner: updatedOwner ? updatedOwner : undefined,
});
setDataModelData((prev) => ({
...(prev as DashboardDataModel),
owner: newOwner,
version,
}));
} catch (error) {
showErrorToast(error as AxiosError);
}
},
[dataModelData, dataModelData?.owner]
);
const handleUpdateTier = async (updatedTier?: string) => {
try {
if (updatedTier) {
const { tags: newTags, version } = await handleUpdateDataModelData({
...(dataModelData as DashboardDataModel),
tags: [
...getTagsWithoutTier(dataModelData?.tags ?? []),
{
tagFQN: updatedTier,
labelType: LabelType.Manual,
state: State.Confirmed,
source: TagSource.Classification,
},
],
});
setDataModelData((prev) => ({
...(prev as DashboardDataModel),
tags: newTags,
version,
}));
}
} catch (error) {
showErrorToast(error as AxiosError);
}
};
const handleUpdateDataModel = async (
updatedDataModel: DashboardDataModel['columns']
) => {
try {
const { columns: newColumns, version } = await handleUpdateDataModelData({
...(dataModelData as DashboardDataModel),
columns: updatedDataModel,
});
setDataModelData((prev) => ({
...(prev as DashboardDataModel),
columns: newColumns,
version,
}));
} catch (error) {
showErrorToast(error as AxiosError);
}
};
useEffect(() => {
if (hasViewPermission) {
fetchDataModelDetails(dashboardDataModelFQN);
}
}, [dashboardDataModelFQN, dataModelPermissions]);
useEffect(() => {
fetchResourcePermission(dashboardDataModelFQN);
}, [dashboardDataModelFQN]);
// Rendering
if (isLoading) {
return <Loader />;
}
if (hasError) {
return (
<ErrorPlaceHolder>
{getEntityMissingError(t('label.data-model'), dashboardDataModelFQN)}
</ErrorPlaceHolder>
);
}
if (!hasViewPermission && !isLoading) {
return <ErrorPlaceHolder>{NO_PERMISSION_TO_VIEW}</ErrorPlaceHolder>;
}
return (
<PageContainerV1>
<div className="entity-details-container">
<EntityPageInfo
canDelete={dataModelPermissions.Delete}
currentOwner={owner}
deleted={deleted}
entityFqn={dashboardDataModelFQN}
entityId={entityId}
entityName={entityName || ''}
entityType={EntityType.DASHBOARD_DATA_MODEL}
extraInfo={extraInfo}
followHandler={handleFollowDataModel}
followers={followers.length}
followersList={followers}
isFollowing={isUserFollowing}
isTagEditable={hasEditTagsPermission}
removeTier={hasEditTierPermission ? handleRemoveTier : undefined}
tags={tags}
tagsHandler={handleUpdateTags}
tier={tier}
titleLinks={breadcrumbTitles}
updateOwner={hasEditOwnerPermission ? handleUpdateOwner : undefined}
updateTier={hasEditTierPermission ? handleUpdateTier : undefined}
version={version + ''}
/>
<Tabs activeKey={tab} className="h-full" onChange={handleTabChange}>
<Tabs.TabPane
key={DATA_MODELS_DETAILS_TABS.MODEL}
tab={
<span data-testid={DATA_MODELS_DETAILS_TABS.MODEL}>
{t('label.model')}
</span>
}>
<Card className={ENTITY_CARD_CLASS}>
<Space className="w-full" direction="vertical" size={8}>
<Description
description={description}
entityFqn={dashboardDataModelFQN}
entityName={entityName}
entityType={EntityType.CONTAINER}
hasEditAccess={hasEditDescriptionPermission}
isEdit={isEditDescription}
isReadOnly={deleted}
owner={owner}
onCancel={() => setIsEditDescription(false)}
onDescriptionEdit={() => setIsEditDescription(true)}
onDescriptionUpdate={handleUpdateDescription}
/>
<ModelTab
data={dataModelData?.columns || []}
hasEditDescriptionPermission={hasEditDescriptionPermission}
hasEditTagsPermission={hasEditTagsPermission}
isReadOnly={Boolean(deleted)}
onUpdate={handleUpdateDataModel}
/>
</Space>
</Card>
</Tabs.TabPane>
</Tabs>
</div>
</PageContainerV1>
);
};
export default DataModelsPage;

View File

@ -0,0 +1,25 @@
/*
* 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 { ServicePageData } from 'pages/service';
export interface DataModelTableProps {
data: Array<ServicePageData>;
isLoading: boolean;
}
export enum DATA_MODELS_DETAILS_TABS {
MODEL = 'model',
ACTIVITY = 'activityFeed',
SQL = 'sql',
LINEAGE = 'lineage',
}

View File

@ -27,6 +27,7 @@ import TestConnection from 'components/common/TestConnection/TestConnection';
import TitleBreadcrumb from 'components/common/title-breadcrumb/title-breadcrumb.component';
import { TitleBreadcrumbProps } from 'components/common/title-breadcrumb/title-breadcrumb.interface';
import PageContainerV1 from 'components/containers/PageContainerV1';
import DataModelTable from 'components/DataModels/DataModelsTable';
import Ingestion from 'components/Ingestion/Ingestion.component';
import Loader from 'components/Loader/Loader';
import { usePermissionProvider } from 'components/PermissionProvider/PermissionProvider';
@ -36,6 +37,7 @@ import TagsViewer from 'components/Tag/TagsViewer/tags-viewer';
import { EntityType } from 'enums/entity.enum';
import { compare } from 'fast-json-patch';
import { Container } from 'generated/entity/data/container';
import { DashboardDataModel } from 'generated/entity/data/dashboardDataModel';
import { isEmpty, isNil, isUndefined, startCase, toLower } from 'lodash';
import {
ExtraInfo,
@ -46,7 +48,7 @@ import {
import React, { FunctionComponent, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useHistory, useParams } from 'react-router-dom';
import { getDashboards } from 'rest/dashboardAPI';
import { getDashboards, getDataModels } from 'rest/dashboardAPI';
import { getDatabases } from 'rest/databaseAPI';
import {
deleteIngestionPipelineById,
@ -121,7 +123,8 @@ export type ServicePageData =
| Dashboard
| Mlmodel
| Pipeline
| Container;
| Container
| DashboardDataModel;
const ServicePage: FunctionComponent = () => {
const { t } = useTranslation();
@ -140,6 +143,8 @@ const ServicePage: FunctionComponent = () => {
const [description, setDescription] = useState('');
const [serviceDetails, setServiceDetails] = useState<ServicesType>();
const [data, setData] = useState<Array<ServicePageData>>([]);
const [dataModel, setDataModel] = useState<Array<ServicePageData>>([]);
const [dataModelPaging, setDataModelPaging] = useState<Paging>(pagingObject);
const [isLoading, setIsLoading] = useState(true);
const [paging, setPaging] = useState<Paging>(pagingObject);
const [activeTab, setActiveTab] = useState(
@ -187,7 +192,8 @@ const ServicePage: FunctionComponent = () => {
serviceName,
paging.total,
ingestions,
servicePermission
servicePermission,
dataModelPaging.total
),
[serviceName, paging, ingestions, servicePermission]
);
@ -449,6 +455,23 @@ const ServicePage: FunctionComponent = () => {
}
};
const fetchDashboardsDataModel = async (paging?: PagingWithoutTotal) => {
setIsLoading(true);
try {
const { data, paging: resPaging } = await getDataModels(
serviceFQN,
'owner,tags,followers',
paging
);
setDataModel(data);
setDataModelPaging(resPaging);
} catch (error) {
showErrorToast(error as AxiosError);
} finally {
setIsLoading(false);
}
};
const fetchPipeLines = async (paging?: PagingWithoutTotal) => {
setIsLoading(true);
try {
@ -517,6 +540,7 @@ const ServicePage: FunctionComponent = () => {
}
case ServiceCategory.DASHBOARD_SERVICES: {
fetchDashboards(paging);
fetchDashboardsDataModel(paging);
break;
}
@ -877,6 +901,9 @@ const ServicePage: FunctionComponent = () => {
}
};
const getDataModalTab = () => (
<DataModelTable data={dataModel} isLoading={isLoading} />
);
useEffect(() => {
if (
servicePageTabs(getCountLabel(serviceName))[activeTab - 1].path !== tab
@ -1138,6 +1165,7 @@ const ServicePage: FunctionComponent = () => {
</div>
))}
{activeTab === 4 && getDataModalTab()}
{activeTab === 2 && getIngestionTab()}
{activeTab === 3 && (

View File

@ -13,7 +13,7 @@
import { AxiosResponse } from 'axios';
import { Operation } from 'fast-json-patch';
import { PagingWithoutTotal, RestoreRequestType } from 'Models';
import { PagingResponse, PagingWithoutTotal, RestoreRequestType } from 'Models';
import { ServicePageData } from 'pages/service';
import { Dashboard } from '../generated/entity/data/dashboard';
import { EntityHistory } from '../generated/type/entityHistory';
@ -129,3 +129,22 @@ export const restoreDashboard = async (id: string) => {
return response.data;
};
export const getDataModels = async (
service: string,
fields: string,
paging?: PagingWithoutTotal
) => {
const response = await APIClient.get<PagingResponse<ServicePageData[]>>(
`/dashboard/datamodels`,
{
params: {
service,
fields,
...paging,
},
}
);
return response.data;
};

View File

@ -0,0 +1,69 @@
/*
* 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 { AxiosResponse } from 'axios';
import { Operation } from 'fast-json-patch';
import { DashboardDataModel } from 'generated/entity/data/dashboardDataModel';
import { EntityReference } from 'generated/type/entityReference';
import APIClient from './index';
const URL = '/dashboard/datamodels';
const configOptionsForPatch = {
headers: { 'Content-type': 'application/json-patch+json' },
};
const configOptions = {
headers: { 'Content-type': 'application/json' },
};
export const getDataModelsByName = async (
name: string,
fields: string | string[]
) => {
const response = await APIClient.get<DashboardDataModel>(
`${URL}/name/${name}?fields=${fields}`
);
return response.data;
};
export const patchDataModelDetails = async (id: string, data: Operation[]) => {
const response = await APIClient.patch<
Operation[],
AxiosResponse<DashboardDataModel>
>(`${URL}/${id}`, data, configOptionsForPatch);
return response.data;
};
export const addDataModelFollower = async (id: string, userId: string) => {
const response = await APIClient.put<
string,
AxiosResponse<{
changeDescription: { fieldsAdded: { newValue: EntityReference[] }[] };
}>
>(`${URL}/${id}/followers`, userId, configOptions);
return response.data;
};
export const removeDataModelFollower = async (id: string, userId: string) => {
const response = await APIClient.delete<
string,
AxiosResponse<{
changeDescription: { fieldsDeleted: { oldValue: EntityReference[] }[] };
}>
>(`${URL}/${id}/followers/${userId}`, configOptions);
return response.data;
};

View File

@ -0,0 +1,114 @@
/*
* 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 {
PLACEHOLDER_ROUTE_DATA_MODEL_FQN,
PLACEHOLDER_ROUTE_TAB,
ROUTES,
} from 'constants/constants';
import { Column } from 'generated/entity/data/dashboardDataModel';
import { LabelType, State, TagLabel } from 'generated/type/tagLabel';
import { isEmpty } from 'lodash';
import { EntityTags, TagOption } from 'Models';
export const getDataModelsDetailPath = (dataModelFQN: string, tab?: string) => {
let path = tab
? ROUTES.DATA_MODEL_DETAILS_WITH_TAB
: ROUTES.DATA_MODEL_DETAILS;
path = path.replace(PLACEHOLDER_ROUTE_DATA_MODEL_FQN, dataModelFQN);
if (tab) {
path = path.replace(PLACEHOLDER_ROUTE_TAB, tab);
}
return path;
};
export const updateDataModelColumnDescription = (
containerColumns: Column[] = [],
changedColumnName: string,
description: string
) => {
containerColumns.forEach((containerColumn) => {
if (containerColumn.name === changedColumnName) {
containerColumn.description = description;
} else {
const hasChildren = !isEmpty(containerColumn.children);
// stop condition
if (hasChildren) {
updateDataModelColumnDescription(
containerColumn.children,
changedColumnName,
description
);
}
}
});
};
const getUpdatedDataModelColumnTags = (
containerColumn: Column,
newContainerColumnTags: TagOption[] = []
) => {
const newTagsFqnList = newContainerColumnTags.map((newTag) => newTag.fqn);
const prevTags = containerColumn?.tags?.filter((tag) =>
newTagsFqnList.includes(tag.tagFQN)
);
const prevTagsFqnList = prevTags?.map((prevTag) => prevTag.tagFQN);
const newTags: EntityTags[] = newContainerColumnTags.reduce((prev, curr) => {
const isExistingTag = prevTagsFqnList?.includes(curr.fqn);
return isExistingTag
? prev
: [
...prev,
{
labelType: LabelType.Manual,
state: State.Confirmed,
source: curr.source,
tagFQN: curr.fqn,
},
];
}, [] as EntityTags[]);
return [...(prevTags as TagLabel[]), ...newTags];
};
export const updateDataModelColumnTags = (
containerColumns: Column[] = [],
changedColumnName: string,
newColumnTags: TagOption[] = []
) => {
containerColumns.forEach((containerColumn) => {
if (containerColumn.name === changedColumnName) {
containerColumn.tags = getUpdatedDataModelColumnTags(
containerColumn,
newColumnTags
);
} else {
const hasChildren = !isEmpty(containerColumn.children);
// stop condition
if (hasChildren) {
updateDataModelColumnTags(
containerColumn.children,
changedColumnName,
newColumnTags
);
}
}
});
};

View File

@ -893,7 +893,8 @@ export const getServicePageTabs = (
serviceName: ServiceTypes,
instanceCount: number,
ingestions: IngestionPipeline[],
servicePermission: OperationPermission
servicePermission: OperationPermission,
dataModelCount: number
) => {
const tabs = [];
@ -906,6 +907,15 @@ export const getServicePageTabs = (
});
}
if (serviceName === ServiceCategory.DASHBOARD_SERVICES) {
tabs.push({
name: t('label.data-model'),
isProtected: false,
position: 4,
count: dataModelCount,
});
}
tabs.push(
{
name: t('label.ingestion-plural'),