diff --git a/openmetadata-spec/src/main/resources/json/schema/api/data/createDashboardDataModel.json b/openmetadata-spec/src/main/resources/json/schema/api/data/createDashboardDataModel.json index 03d9f08c16e..92326ce313d 100644 --- a/openmetadata-spec/src/main/resources/json/schema/api/data/createDashboardDataModel.json +++ b/openmetadata-spec/src/main/resources/json/schema/api/data/createDashboardDataModel.json @@ -56,6 +56,6 @@ "default": null } }, - "required": ["name", "service", "modelType", "columns"], + "required": ["name", "service", "dataModelType", "columns"], "additionalProperties": false } diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/data/dashboardDataModel.json b/openmetadata-spec/src/main/resources/json/schema/entity/data/dashboardDataModel.json index 5b299a77c89..2e9f72320cf 100644 --- a/openmetadata-spec/src/main/resources/json/schema/entity/data/dashboardDataModel.json +++ b/openmetadata-spec/src/main/resources/json/schema/entity/data/dashboardDataModel.json @@ -125,7 +125,7 @@ }, "required": [ "name", - "modelType", + "dataModelType", "columns" ], "additionalProperties": false diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataModels/DataModelsTable.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataModels/DataModelsTable.tsx new file mode 100644 index 00000000000..9ec154e4f0c --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataModels/DataModelsTable.tsx @@ -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 = useMemo( + () => [ + { + title: t('label.name'), + dataIndex: 'displayName', + key: 'displayName', + render: (_, record: ServicePageData) => { + return ( + + {getEntityName(record)} + + ); + }, + }, + { + title: t('label.description'), + dataIndex: 'description', + key: 'description', + render: (description: ServicePageData['description']) => + !isUndefined(description) && description.trim() ? ( + + ) : ( + + {t('label.no-entity', { + entity: t('label.description'), + })} + + ), + }, + ], + [] + ); + + return isEmpty(data) ? ( + + ) : ( +
+ , + }} + pagination={false} + rowKey="id" + size="small" + /> + + ); +}; + +export default DataModelTable; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataModels/ModelTab/ModelTab.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataModels/ModelTab/ModelTab.component.tsx new file mode 100644 index 00000000000..5069502ed4b --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataModels/ModelTab/ModelTab.component.tsx @@ -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(); + const [editContainerColumnTags, setEditContainerColumnTags] = + useState(); + + const [tagList, setTagList] = useState([]); + const [isTagLoading, setIsTagLoading] = useState(false); + const [tagFetchFailed, setTagFetchFailed] = useState(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 = + useCallback( + (description, record, index) => { + return ( + + <> + {description ? ( + + ) : ( + + {t('label.no-entity', { + entity: t('label.description'), + })} + + )} + + {isReadOnly && !hasEditDescriptionPermission ? null : ( +
+ + {editColumnDescription && ( + setEditColumnDescription(undefined)} + onSave={handleColumnDescriptionChange} + /> + )} + + ); +}; + +export default ModelTab; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataModels/ModelTab/ModelTab.interface.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataModels/ModelTab/ModelTab.interface.tsx new file mode 100644 index 00000000000..4a051892d67 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataModels/ModelTab/ModelTab.interface.tsx @@ -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; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/router/AuthenticatedAppRouter.tsx b/openmetadata-ui/src/main/resources/ui/src/components/router/AuthenticatedAppRouter.tsx index 846cc544f1d..dbe52575fa6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/router/AuthenticatedAppRouter.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/router/AuthenticatedAppRouter.tsx @@ -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} /> + + { 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); diff --git a/openmetadata-ui/src/main/resources/ui/src/enums/entity.enum.ts b/openmetadata-ui/src/main/resources/ui/src/enums/entity.enum.ts index e961baa3977..98b701d5371 100644 --- a/openmetadata-ui/src/main/resources/ui/src/enums/entity.enum.ts +++ b/openmetadata-ui/src/main/resources/ui/src/enums/entity.enum.ts @@ -42,6 +42,7 @@ export enum EntityType { ALERT = 'alert', CONTAINER = 'container', TAG = 'tag', + DASHBOARD_DATA_MODEL = 'dashboardDataModel', } export enum AssetsType { diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json index 35f88f556db..1e97e47d170 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json @@ -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", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json index 0984a37dd69..faabd866778 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json @@ -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", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json index bb1b62c800d..60bbd79a799 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json @@ -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", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json index 256556c0a6c..895215d5016 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json @@ -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": "月", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json index 802b62f9c45..f9d29fdfd27 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json @@ -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", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json index df72162e15d..d21196c9e5d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json @@ -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", diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/DataModelPage/DataModelPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/DataModelPage/DataModelPage.component.tsx new file mode 100644 index 00000000000..fae50b5476c --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/pages/DataModelPage/DataModelPage.component.tsx @@ -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; + + const [isEditDescription, setIsEditDescription] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [hasError, setHasError] = useState(false); + const [dataModelPermissions, setDataModelPermissions] = + useState(DEFAULT_ENTITY_PERMISSION); + const [dataModelData, setDataModelData] = useState(); + + // 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 = [ + { + 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 = []) => { + 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 ; + } + + if (hasError) { + return ( + + {getEntityMissingError(t('label.data-model'), dashboardDataModelFQN)} + + ); + } + + if (!hasViewPermission && !isLoading) { + return {NO_PERMISSION_TO_VIEW}; + } + + return ( + +
+ + + + {t('label.model')} + + }> + + + setIsEditDescription(false)} + onDescriptionEdit={() => setIsEditDescription(true)} + onDescriptionUpdate={handleUpdateDescription} + /> + + + + + + +
+
+ ); +}; + +export default DataModelsPage; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/DataModelPage/DataModelsInterface.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/DataModelPage/DataModelsInterface.tsx new file mode 100644 index 00000000000..979249d313e --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/pages/DataModelPage/DataModelsInterface.tsx @@ -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; + isLoading: boolean; +} + +export enum DATA_MODELS_DETAILS_TABS { + MODEL = 'model', + ACTIVITY = 'activityFeed', + SQL = 'sql', + LINEAGE = 'lineage', +} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/service/index.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/service/index.tsx index 725984acc44..5e3992ab80f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/service/index.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/service/index.tsx @@ -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(); const [data, setData] = useState>([]); + const [dataModel, setDataModel] = useState>([]); + const [dataModelPaging, setDataModelPaging] = useState(pagingObject); const [isLoading, setIsLoading] = useState(true); const [paging, setPaging] = useState(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 = () => ( + + ); useEffect(() => { if ( servicePageTabs(getCountLabel(serviceName))[activeTab - 1].path !== tab @@ -1138,6 +1165,7 @@ const ServicePage: FunctionComponent = () => { ))} + {activeTab === 4 && getDataModalTab()} {activeTab === 2 && getIngestionTab()} {activeTab === 3 && ( diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/dashboardAPI.ts b/openmetadata-ui/src/main/resources/ui/src/rest/dashboardAPI.ts index d0cb6366466..e15b8585fce 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/dashboardAPI.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/dashboardAPI.ts @@ -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>( + `/dashboard/datamodels`, + { + params: { + service, + fields, + ...paging, + }, + } + ); + + return response.data; +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/dataModelsAPI.ts b/openmetadata-ui/src/main/resources/ui/src/rest/dataModelsAPI.ts new file mode 100644 index 00000000000..c749e882cad --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/rest/dataModelsAPI.ts @@ -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( + `${URL}/name/${name}?fields=${fields}` + ); + + return response.data; +}; + +export const patchDataModelDetails = async (id: string, data: Operation[]) => { + const response = await APIClient.patch< + Operation[], + AxiosResponse + >(`${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; +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/DataModelsUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/DataModelsUtils.ts new file mode 100644 index 00000000000..0a6b96c0e8e --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/DataModelsUtils.ts @@ -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 + ); + } + } + }); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/ServiceUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/ServiceUtils.tsx index 133de267ad1..5e3902984d4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/ServiceUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/ServiceUtils.tsx @@ -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'),