diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/img/service-icon-amazon-s3.svg b/openmetadata-ui/src/main/resources/ui/src/assets/img/service-icon-amazon-s3.svg new file mode 100644 index 00000000000..3f63be51fa2 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/assets/img/service-icon-amazon-s3.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/img/service-icon-gcs.png b/openmetadata-ui/src/main/resources/ui/src/assets/img/service-icon-gcs.png new file mode 100644 index 00000000000..b3957dfd55c Binary files /dev/null and b/openmetadata-ui/src/main/resources/ui/src/assets/img/service-icon-gcs.png differ diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/img/service-icon-ms-azure.png b/openmetadata-ui/src/main/resources/ui/src/assets/img/service-icon-ms-azure.png new file mode 100644 index 00000000000..f1200c72318 Binary files /dev/null and b/openmetadata-ui/src/main/resources/ui/src/assets/img/service-icon-ms-azure.png differ diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ContainerDetail/ContainerChildren/ContainerChildren.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ContainerDetail/ContainerChildren/ContainerChildren.test.tsx new file mode 100644 index 00000000000..23e5716d737 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/ContainerDetail/ContainerChildren/ContainerChildren.test.tsx @@ -0,0 +1,83 @@ +/* + * 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 { render, screen } from '@testing-library/react'; +import React from 'react'; +import { BrowserRouter } from 'react-router-dom'; +import ContainerChildren from './ContainerChildren'; + +const mockChildrenList = [ + { + id: '1', + name: 'Container 1', + fullyQualifiedName: 'namespace.Container1', + description: 'Description of Container 1', + type: 'container', + }, + { + id: '2', + name: 'Container 2', + fullyQualifiedName: 'namespace.Container2', + description: 'Description of Container 2', + type: 'container', + }, +]; + +describe('ContainerChildren', () => { + it('Should render table with correct columns', () => { + render( + + + + ); + + expect(screen.getByTestId('container-list-table')).toBeInTheDocument(); + expect(screen.getByText('label.name')).toBeInTheDocument(); + expect(screen.getByText('label.description')).toBeInTheDocument(); + }); + + it('Should render container names as links', () => { + render( + + + + ); + + const containerNameLinks = screen.getAllByTestId('container-name'); + + expect(containerNameLinks).toHaveLength(2); + + containerNameLinks.forEach((link, index) => { + expect(link).toHaveAttribute( + 'href', + `/container/${mockChildrenList[index].fullyQualifiedName}` + ); + expect(link).toHaveTextContent(mockChildrenList[index].name); + }); + }); + + it('Should render container descriptions as rich text', () => { + render( + + + + ); + + const richTextPreviewers = screen.getAllByTestId('viewer-container'); + + expect(richTextPreviewers).toHaveLength(2); + + richTextPreviewers.forEach((previewer, index) => { + expect(previewer).toHaveTextContent(mockChildrenList[index].description); + }); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ContainerDetail/ContainerChildren/ContainerChildren.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ContainerDetail/ContainerChildren/ContainerChildren.tsx new file mode 100644 index 00000000000..8fca9c354b4 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/ContainerDetail/ContainerChildren/ContainerChildren.tsx @@ -0,0 +1,82 @@ +/* + * 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 { Typography } from 'antd'; +import Table, { ColumnsType } from 'antd/lib/table'; +import RichTextEditorPreviewer from 'components/common/rich-text-editor/RichTextEditorPreviewer'; +import { Container } from 'generated/entity/data/container'; +import { EntityReference } from 'generated/type/entityReference'; +import React, { FC, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; +import { getEntityName } from 'utils/CommonUtils'; +import { getContainerDetailPath } from 'utils/ContainerDetailUtils'; + +interface ContainerChildrenProps { + childrenList: Container['children']; +} + +const ContainerChildren: FC = ({ childrenList }) => { + const { t } = useTranslation(); + + const columns: ColumnsType = useMemo( + () => [ + { + title: t('label.name'), + dataIndex: 'name', + width: '200px', + key: 'name', + render: (_, record) => ( + + {getEntityName(record)} + + ), + }, + { + title: t('label.description'), + dataIndex: 'description', + key: 'description', + render: (description: EntityReference['description']) => ( + <> + {description ? ( + + ) : ( + + {t('label.no-entity', { + entity: t('label.description'), + })} + + )} + + ), + }, + ], + [] + ); + + return ( + + ); +}; + +export default ContainerChildren; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ContainerDetail/ContainerDataModel/ContainerDataModel.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/ContainerDetail/ContainerDataModel/ContainerDataModel.interface.ts new file mode 100644 index 00000000000..f7f2e3cc904 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/ContainerDetail/ContainerDataModel/ContainerDataModel.interface.ts @@ -0,0 +1,28 @@ +/* + * 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 { Container } from 'generated/entity/data/container'; +import { ReactNode } from 'react'; + +export type CellRendered = ( + value: T[K], + record: T, + index: number +) => ReactNode; + +export interface ContainerDataModelProps { + dataModel: Container['dataModel']; + hasDescriptionEditAccess: boolean; + hasTagEditAccess: boolean; + isReadOnly: boolean; + onUpdate: (updatedDataModel: Container['dataModel']) => Promise; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ContainerDetail/ContainerDataModel/ContainerDataModel.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ContainerDetail/ContainerDataModel/ContainerDataModel.test.tsx new file mode 100644 index 00000000000..a478f3dff3c --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/ContainerDetail/ContainerDataModel/ContainerDataModel.test.tsx @@ -0,0 +1,175 @@ +/* + * 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 { + act, + findByTestId, + findByText, + queryByTestId, + render, + screen, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Column } from 'generated/entity/data/container'; +import React from 'react'; +import ContainerDataModel from './ContainerDataModel'; + +const props = { + dataModel: { + isPartitioned: false, + columns: [ + { + name: 'department_id', + dataType: 'NUMERIC', + dataTypeDisplay: 'numeric', + description: + 'The ID of the department. This column is the primary key for this table.', + fullyQualifiedName: 's3_object_store_sample.finance.department_id', + tags: [], + constraint: 'PRIMARY_KEY', + ordinalPosition: 1, + }, + { + name: 'budget_total_value', + dataType: 'NUMERIC', + dataTypeDisplay: 'numeric', + description: "The department's budget for the current year.", + fullyQualifiedName: 's3_object_store_sample.finance.budget_total_value', + tags: [], + ordinalPosition: 2, + }, + { + name: 'notes', + dataType: 'VARCHAR', + dataLength: 100, + dataTypeDisplay: 'varchar', + description: 'Notes concerning sustainability for the budget.', + fullyQualifiedName: 's3_object_store_sample.finance.notes', + tags: [], + ordinalPosition: 3, + }, + { + name: 'budget_executor', + dataType: 'VARCHAR', + dataTypeDisplay: 'varchar', + description: 'The responsible finance lead for the budget execution', + fullyQualifiedName: 's3_object_store_sample.finance.budget_executor', + tags: [], + ordinalPosition: 4, + }, + ] as Column[], + }, + hasDescriptionEditAccess: true, + hasTagEditAccess: true, + isReadOnly: false, + onUpdate: jest.fn(), +}; + +jest.mock('utils/TagsUtils', () => ({ + fetchTagsAndGlossaryTerms: jest.fn().mockReturnValue([]), +})); + +jest.mock('utils/ContainerDetailUtils', () => ({ + updateContainerColumnDescription: jest.fn(), + updateContainerColumnTags: jest.fn(), +})); + +jest.mock('components/common/rich-text-editor/RichTextEditorPreviewer', () => + jest + .fn() + .mockReturnValue( +
Description Preview
+ ) +); + +jest.mock( + 'components/Modals/ModalWithMarkdownEditor/ModalWithMarkdownEditor', + () => ({ + ModalWithMarkdownEditor: jest + .fn() + .mockReturnValue(
Editor Modal
), + }) +); + +jest.mock('components/Tag/TagsContainer/tags-container', () => + jest + .fn() + .mockReturnValue(
Tag Container
) +); + +jest.mock('components/Tag/TagsViewer/tags-viewer', () => + jest.fn().mockReturnValue(
Tag Viewer
) +); + +describe('ContainerDataModel', () => { + it('Should render the Container data model component', async () => { + render(); + + const containerDataModel = await screen.findByTestId( + 'container-data-model-table' + ); + const rows = await screen.findAllByRole('row'); + + const row1 = rows[1]; + + expect(containerDataModel).toBeInTheDocument(); + + // should render header row and content row + expect(rows).toHaveLength(5); + + const name = await findByText(row1, 'department_id'); + const dataType = await findByText(row1, 'numeric'); + const description = await findByText(row1, 'Description Preview'); + const tags = await findByTestId(row1, 'tag-container'); + + expect(name).toBeInTheDocument(); + expect(dataType).toBeInTheDocument(); + expect(description).toBeInTheDocument(); + expect(tags).toBeInTheDocument(); + }); + + it('On edit description button click modal editor should render', async () => { + render(); + + const rows = await screen.findAllByRole('row'); + + const row1 = rows[1]; + + const editDescriptionButton = await findByTestId(row1, 'edit-button'); + + expect(editDescriptionButton).toBeInTheDocument(); + + await act(async () => { + userEvent.click(editDescriptionButton); + }); + + expect(await screen.findByTestId('editor-modal')).toBeInTheDocument(); + }); + + it('Should not render the edit action if isReadOnly', async () => { + render( + + ); + + const rows = await screen.findAllByRole('row'); + + const row1 = rows[1]; + + const editDescriptionButton = queryByTestId(row1, 'edit-button'); + + expect(editDescriptionButton).toBeNull(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ContainerDetail/ContainerDataModel/ContainerDataModel.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ContainerDetail/ContainerDataModel/ContainerDataModel.tsx new file mode 100644 index 00000000000..f2d5db145f1 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/ContainerDetail/ContainerDataModel/ContainerDataModel.tsx @@ -0,0 +1,283 @@ +/* + * 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, Popover, Space, Typography } from 'antd'; +import Table, { ColumnsType } from 'antd/lib/table'; +import RichTextEditorPreviewer from 'components/common/rich-text-editor/RichTextEditorPreviewer'; +import TagsContainer from 'components/Tag/TagsContainer/tags-container'; +import TagsViewer from 'components/Tag/TagsViewer/tags-viewer'; +import { Column } from 'generated/entity/data/container'; +import { cloneDeep, isEmpty, isUndefined, toLower } from 'lodash'; +import { EntityTags, TagOption } from 'Models'; +import React, { FC, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + updateContainerColumnDescription, + updateContainerColumnTags, +} from 'utils/ContainerDetailUtils'; +import { getTableExpandableConfig } from 'utils/TableUtils'; +import { fetchTagsAndGlossaryTerms } from 'utils/TagsUtils'; +import { + CellRendered, + ContainerDataModelProps, +} from './ContainerDataModel.interface'; + +import { ReactComponent as EditIcon } from 'assets/svg/ic-edit.svg'; +import { ModalWithMarkdownEditor } from 'components/Modals/ModalWithMarkdownEditor/ModalWithMarkdownEditor'; +import { getEntityName } from 'utils/CommonUtils'; + +const ContainerDataModel: FC = ({ + dataModel, + hasDescriptionEditAccess, + hasTagEditAccess, + isReadOnly, + onUpdate, +}) => { + const { t } = useTranslation(); + + const [editContainerColumnDescription, setEditContainerColumnDescription] = + useState(); + const [editContainerColumnTags, setEditContainerColumnTags] = + useState(); + + const [tagList, setTagList] = useState([]); + const [isTagLoading, setIsTagLoading] = useState(false); + const [tagFetchFailed, setTagFetchFailed] = useState(false); + + const fetchTags = async () => { + setIsTagLoading(true); + try { + const tagsAndTerms = await fetchTagsAndGlossaryTerms(); + setTagList(tagsAndTerms); + } catch (error) { + setTagList([]); + setTagFetchFailed(true); + } finally { + setIsTagLoading(false); + } + }; + + const handleFieldTagsChange = async (selectedTags: EntityTags[] = []) => { + if (!isUndefined(editContainerColumnTags)) { + const newSelectedTags: TagOption[] = selectedTags.map((tag) => ({ + fqn: tag.tagFQN, + source: tag.source, + })); + + const containerDataModel = cloneDeep(dataModel); + + updateContainerColumnTags( + containerDataModel?.columns, + editContainerColumnTags?.name, + newSelectedTags + ); + + await onUpdate(containerDataModel); + } + setEditContainerColumnTags(undefined); + }; + + const handleAddTagClick = (record: Column) => { + if (isUndefined(editContainerColumnTags)) { + setEditContainerColumnTags(record); + // Fetch tags and terms only once + if (tagList.length === 0 || tagFetchFailed) { + fetchTags(); + } + } + }; + + const handleContainerColumnDescriptionChange = async ( + updatedDescription: string + ) => { + if (!isUndefined(editContainerColumnDescription)) { + const containerDataModel = cloneDeep(dataModel); + updateContainerColumnDescription( + containerDataModel?.columns, + editContainerColumnDescription?.name, + updatedDescription + ); + await onUpdate(containerDataModel); + } + setEditContainerColumnDescription(undefined); + }; + + const renderContainerColumnDescription: CellRendered = + (description, record, index) => { + return ( + + <> + {description ? ( + + ) : ( + + {t('label.no-entity', { + entity: t('label.description'), + })} + + )} + + {isReadOnly && !hasDescriptionEditAccess ? null : ( +
(), + rowExpandable: (record) => !isEmpty(record.children), + }} + pagination={false} + size="small" + /> + {editContainerColumnDescription && ( + setEditContainerColumnDescription(undefined)} + onSave={handleContainerColumnDescriptionChange} + /> + )} + + ); +}; + +export default ContainerDataModel; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/GlobalSetting/GlobalSettingLeftPanel.tsx b/openmetadata-ui/src/main/resources/ui/src/components/GlobalSetting/GlobalSettingLeftPanel.tsx index b0976a17999..6fbc1301997 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/GlobalSetting/GlobalSettingLeftPanel.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/GlobalSetting/GlobalSettingLeftPanel.tsx @@ -43,15 +43,13 @@ const GlobalSettingLeftPanel = () => { () => getGlobalSettingsMenuWithPermission(permissions, isAdminUser).reduce( (acc: ItemType[], curr: MenuList) => { - const menuItem = getGlobalSettingMenuItem( - curr.category, - camelCase(curr.category), - '', - '', - curr.items, - 'group', - curr.isBeta - ); + const menuItem = getGlobalSettingMenuItem({ + label: curr.category, + key: camelCase(curr.category), + children: curr.items, + type: 'group', + isBeta: curr.isBeta, + }); if (menuItem.children?.length) { return [...acc, menuItem]; } else { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/PermissionProvider/PermissionProvider.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/PermissionProvider/PermissionProvider.interface.ts index e3f8f8249a1..e2c44011400 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/PermissionProvider/PermissionProvider.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/PermissionProvider/PermissionProvider.interface.ts @@ -63,6 +63,7 @@ export enum ResourceEntity { USER = 'user', WEBHOOK = 'webhook', OBJECT_STORE_SERVICE = 'objectStoreService', + CONTAINER = 'container', } export interface PermissionContextType { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/CustomPropertyTable.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/CustomPropertyTable.interface.ts index c9c865ed245..c0dd362d30b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/CustomPropertyTable.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/CustomPropertyTable.interface.ts @@ -11,6 +11,7 @@ * limitations under the License. */ +import { Container } from 'generated/entity/data/container'; import { EntityType } from '../../../enums/entity.enum'; import { Dashboard } from '../../../generated/entity/data/dashboard'; import { Mlmodel } from '../../../generated/entity/data/mlmodel'; @@ -18,7 +19,12 @@ import { Pipeline } from '../../../generated/entity/data/pipeline'; import { Table } from '../../../generated/entity/data/table'; import { Topic } from '../../../generated/entity/data/topic'; -export type EntityDetails = Table & Topic & Dashboard & Pipeline & Mlmodel; +export type EntityDetails = Table & + Topic & + Dashboard & + Pipeline & + Mlmodel & + Container; export interface CustomPropertyProps { entityDetails: EntityDetails; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/CustomPropertyTable.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/CustomPropertyTable.test.tsx index 22610507008..811c674f5b1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/CustomPropertyTable.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/CustomPropertyTable.test.tsx @@ -20,12 +20,8 @@ import { import React from 'react'; import { getTypeByFQN } from 'rest/metadataTypeAPI'; import { EntityType } from '../../../enums/entity.enum'; -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'; import { CustomPropertyTable } from './CustomPropertyTable'; +import { EntityDetails } from './CustomPropertyTable.interface'; const mockCustomProperties = [ { @@ -71,7 +67,7 @@ jest.mock('rest/metadataTypeAPI', () => ({ ), })); -const mockTableDetails = {} as Table & Topic & Dashboard & Pipeline & Mlmodel; +const mockTableDetails = {} as EntityDetails; const handleExtensionUpdate = jest.fn(); const mockProp = { 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 bc3e44ed1d4..1f965cb6c98 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 @@ -216,6 +216,10 @@ const AddTestSuitePage = withSuspenseFallback( React.lazy(() => import('pages/TestSuitePage/TestSuiteStepper')) ); +const ContainerPage = withSuspenseFallback( + React.lazy(() => import('pages/ContainerPage/ContainerPage')) +); + const AuthenticatedAppRouter: FunctionComponent = () => { const { permissions } = usePermissionProvider(); @@ -346,6 +350,12 @@ const AuthenticatedAppRouter: FunctionComponent = () => { component={PipelineDetailsPage} path={ROUTES.PIPELINE_DETAILS} /> + + = { dashboards: 'dashboard', pipelines: 'pipeline', mlmodels: 'mlmodel', + containers: 'container', }; export const VALIDATE_MESSAGES = { 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 24479e7321d..279b2fb6e78 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 @@ -40,6 +40,7 @@ export enum EntityType { DATA_INSIGHT_CHART = 'dataInsightChart', KPI = 'kpi', ALERT = 'alert', + CONTAINER = 'container', } 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 fa538d29979..51a94f16256 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 @@ -92,6 +92,7 @@ "chart-entity": "Chart {{entity}}", "chart-plural": "Charts", "check-status": "Check status", + "children": "Children", "claim-ownership": "Claim Ownership", "classification": "Classification", "classification-lowercase": "classification", @@ -898,6 +899,7 @@ "dbt-result-file-path": "dbt run results file path to extract the test results information.", "dbt-run-result-http-path-message": "dbt runs the results on an http path to extract the test results.", "deeply-understand-table-relations-message": "Deeply understand table relations; thanks to column-level lineage.", + "define-custom-property-for-entity": "Define custom properties for {{entity}} to serve your organizational needs.", "delete-action-description": "Deleting this {{entityType}} will permanently remove its metadata from OpenMetadata.", "delete-entity-permanently": "Once you delete this {{entityType}}, it will be removed permanently.", "delete-entity-type-action-description": "Deleting this {{entityType}} will permanently remove its metadata from OpenMetadata.", 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 55e216263cf..47739066ae9 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 @@ -92,6 +92,7 @@ "chart-entity": "Chart {{entity}}", "chart-plural": "Charts", "check-status": "Check status", + "children": "Children", "claim-ownership": "Claim Ownership", "classification": "Classification", "classification-lowercase": "classification", @@ -898,6 +899,7 @@ "dbt-result-file-path": "dbt run results file path to extract the test results information.", "dbt-run-result-http-path-message": "dbt runs the results on an http path to extract the test results.", "deeply-understand-table-relations-message": "Comprenez en profondeur la relation entre vos tables avec la traçabilité au niveau de la colonne.", + "define-custom-property-for-entity": "Define custom properties for {{entity}} to serve your organizational needs.", "delete-action-description": "Supprimer cette {{entityType}} supprimera de manière permanente les métadonnées dans OpenMetadata.", "delete-entity-permanently": "Une fois que vous supprimez {{entityType}}, il sera supprimé de manière permanente", "delete-entity-type-action-description": "Deleting this {{entityType}} will permanently remove its metadata from OpenMetadata.", 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 23235dcd55d..d6c8d3711f0 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 @@ -92,6 +92,7 @@ "chart-entity": "图表 {{entity}}", "chart-plural": "图表", "check-status": "Check status", + "children": "Children", "claim-ownership": "Claim Ownership", "classification": "类别", "classification-lowercase": "类别", @@ -898,6 +899,7 @@ "dbt-result-file-path": "dbt run results file path to extract the test results information.", "dbt-run-result-http-path-message": "dbt run results http path to extract the test results information.", "deeply-understand-table-relations-message": "Deeply understand table relations; thanks to column-level lineage.", + "define-custom-property-for-entity": "Define custom properties for {{entity}} to serve your organizational needs.", "delete-action-description": "Deleting this {{entityType}} will permanently remove its metadata from OpenMetadata.", "delete-entity-permanently": "Once you delete this {{entityType}}, it will be removed permanently", "delete-entity-type-action-description": "Deleting this {{entityType}} will permanently remove its metadata from OpenMetadata.", diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ContainerPage/ContainerPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/ContainerPage/ContainerPage.tsx new file mode 100644 index 00000000000..5d411dd78ab --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ContainerPage/ContainerPage.tsx @@ -0,0 +1,751 @@ +/* + * 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, Col, Row, Tabs } from 'antd'; +import AppState from 'AppState'; +import { AxiosError } from 'axios'; +import { CustomPropertyTable } from 'components/common/CustomPropertyTable/CustomPropertyTable'; +import { CustomPropertyProps } from 'components/common/CustomPropertyTable/CustomPropertyTable.interface'; +import Description from 'components/common/description/Description'; +import EntityPageInfo from 'components/common/entityPageInfo/EntityPageInfo'; +import ErrorPlaceHolder from 'components/common/error-with-placeholder/ErrorPlaceHolder'; +import ContainerChildren from 'components/ContainerDetail/ContainerChildren/ContainerChildren'; +import ContainerDataModel from 'components/ContainerDetail/ContainerDataModel/ContainerDataModel'; +import PageContainerV1 from 'components/containers/PageContainerV1'; +import EntityLineageComponent from 'components/EntityLineage/EntityLineage.component'; +import { + Edge, + EdgeData, + LeafNodes, + LineagePos, + LoadingNodeState, +} from 'components/EntityLineage/EntityLineage.interface'; +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 { Container } from 'generated/entity/data/container'; +import { EntityLineage } from 'generated/type/entityLineage'; +import { EntityReference } from 'generated/type/entityReference'; +import { LabelType, State, TagSource } from 'generated/type/tagLabel'; +import { isUndefined, omitBy } from 'lodash'; +import { observer } from 'mobx-react'; +import { EntityTags, ExtraInfo } from 'Models'; +import React, { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useHistory, useParams } from 'react-router-dom'; +import { getLineageByFQN } from 'rest/lineageAPI'; +import { addLineage, deleteLineageEdge } from 'rest/miscAPI'; +import { + addContainerFollower, + getContainerByName, + patchContainerDetails, + removeContainerFollower, + restoreContainer, +} from 'rest/objectStoreAPI'; +import { + getCurrentUserId, + getEntityMissingError, + getEntityName, + getEntityPlaceHolder, + getOwnerValue, + refreshPage, +} from 'utils/CommonUtils'; +import { getContainerDetailPath } from 'utils/ContainerDetailUtils'; +import { getEntityLineage } from 'utils/EntityUtils'; +import { DEFAULT_ENTITY_PERMISSION } from 'utils/PermissionsUtils'; +import { getLineageViewPath } from 'utils/RouterUtils'; +import { serviceTypeLogo } from 'utils/ServiceUtils'; +import { getTagsWithoutTier, getTierTags } from 'utils/TableUtils'; +import { showErrorToast, showSuccessToast } from 'utils/ToastUtils'; + +enum CONTAINER_DETAILS_TABS { + SCHEME = 'schema', + CHILDREN = 'children', + Lineage = 'lineage', + CUSTOM_PROPERTIES = 'custom-properties', +} + +const ContainerPage = () => { + const history = useHistory(); + const { t } = useTranslation(); + const { getEntityPermissionByFqn } = usePermissionProvider(); + const { containerName, tab = CONTAINER_DETAILS_TABS.SCHEME } = + useParams<{ containerName: string; tab: CONTAINER_DETAILS_TABS }>(); + + // Local states + const [isLoading, setIsLoading] = useState(false); + const [isChildrenLoading, setIsChildrenLoading] = useState(false); + const [hasError, setHasError] = useState(false); + const [isEditDescription, setIsEditDescription] = useState(false); + const [isLineageLoading, setIsLineageLoading] = useState(false); + + const [parentContainers, setParentContainers] = useState([]); + const [containerData, setContainerData] = useState(); + const [containerChildrenData, setContainerChildrenData] = useState< + Container['children'] + >([]); + const [containerPermissions, setContainerPermissions] = + useState(DEFAULT_ENTITY_PERMISSION); + const [entityLineage, setEntityLineage] = useState( + {} as EntityLineage + ); + const [leafNodes, setLeafNodes] = useState({} as LeafNodes); + const [isNodeLoading, setNodeLoading] = useState({ + id: undefined, + state: false, + }); + + // data fetching methods + const fetchContainerParent = async ( + parentName: string, + newContainer = false + ) => { + try { + const response = await getContainerByName(parentName, 'parent'); + setParentContainers((prev) => + newContainer ? [response] : [response, ...prev] + ); + if (response.parent && response.parent.fullyQualifiedName) { + await fetchContainerParent(response.parent.fullyQualifiedName); + } + } catch (error) { + showErrorToast(error as AxiosError, t('server.unexpected-response')); + } + }; + + const fetchContainerDetail = async (containerFQN: string) => { + setIsLoading(true); + try { + const response = await getContainerByName( + containerFQN, + 'parent,dataModel,owner,tags,followers,extension' + ); + setContainerData(response); + if (response.parent && response.parent.fullyQualifiedName) { + await fetchContainerParent(response.parent.fullyQualifiedName, true); + } + } catch (error) { + showErrorToast(error as AxiosError); + setHasError(true); + } finally { + setIsLoading(false); + } + }; + + const fetchContainerChildren = async (containerFQN: string) => { + setIsChildrenLoading(true); + try { + const { children } = await getContainerByName(containerFQN, 'children'); + setContainerChildrenData(children); + } catch (error) { + showErrorToast(error as AxiosError); + } finally { + setIsChildrenLoading(false); + } + }; + + const fetchLineageData = async (containerFQN: string) => { + setIsLineageLoading(true); + try { + const response = await getLineageByFQN( + containerFQN, + EntityType.CONTAINER + ); + + setEntityLineage(response); + } catch (error) { + showErrorToast(error as AxiosError); + } finally { + setIsLineageLoading(false); + } + }; + + const fetchResourcePermission = async (containerFQN: string) => { + setIsLoading(true); + try { + const entityPermission = await getEntityPermissionByFqn( + ResourceEntity.CONTAINER, + containerFQN + ); + setContainerPermissions(entityPermission); + } catch (error) { + showErrorToast( + t('server.fetch-entity-permissions-error', { + entity: t('label.asset-lowercase'), + }) + ); + } finally { + setIsLoading(false); + } + }; + + const { + hasViewPermission, + hasEditDescriptionPermission, + hasEditOwnerPermission, + hasEditTagsPermission, + hasEditTierPermission, + hasEditCustomFieldsPermission, + hasEditLineagePermission, + } = useMemo(() => { + return { + hasViewPermission: + containerPermissions.ViewAll || containerPermissions.ViewBasic, + hasEditDescriptionPermission: + containerPermissions.EditAll || containerPermissions.EditDescription, + hasEditOwnerPermission: + containerPermissions.EditAll || containerPermissions.EditOwner, + hasEditTagsPermission: + containerPermissions.EditAll || containerPermissions.EditTags, + hasEditTierPermission: + containerPermissions.EditAll || containerPermissions.EditTier, + hasEditCustomFieldsPermission: + containerPermissions.EditAll || containerPermissions.EditCustomFields, + hasEditLineagePermission: + containerPermissions.EditAll || containerPermissions.EditLineage, + }; + }, [containerPermissions]); + + const { + tier, + deleted, + owner, + description, + version, + tags, + entityName, + entityId, + followers, + isUserFollowing, + } = useMemo(() => { + return { + deleted: containerData?.deleted, + owner: containerData?.owner, + description: containerData?.description, + version: containerData?.version, + tier: getTierTags(containerData?.tags ?? []), + tags: getTagsWithoutTier(containerData?.tags ?? []), + entityId: containerData?.id, + entityName: getEntityName(containerData), + isUserFollowing: containerData?.followers?.some( + ({ id }: { id: string }) => id === getCurrentUserId() + ), + followers: containerData?.followers ?? [], + }; + }, [containerData]); + + 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 breadcrumbTitles = useMemo(() => { + const serviceType = containerData?.serviceType; + const service = containerData?.service; + const serviceName = service?.name; + + const parentContainerItems = parentContainers.map((container) => ({ + name: getEntityName(container), + url: getContainerDetailPath(container.fullyQualifiedName ?? ''), + })); + + return [ + { + name: serviceName || '', + url: serviceName + ? getServiceDetailsPath( + serviceName, + ServiceCategory.OBJECT_STORE_SERVICES + ) + : '', + imgSrc: serviceType ? serviceTypeLogo(serviceType) : undefined, + }, + ...parentContainerItems, + { + name: entityName, + url: '', + activeTitle: true, + }, + ]; + }, [containerData, containerName, entityName, parentContainers]); + + // get current user details + const currentUser = useMemo( + () => AppState.getCurrentUserDetails(), + [AppState.userDetails, AppState.nonSecureUserDetails] + ); + + const handleTabChange = (tabValue: string) => { + if (tabValue !== tab) { + history.push({ + pathname: getContainerDetailPath(containerName, tabValue), + }); + } + }; + + const handleUpdateContainerData = (updatedData: Container) => { + const jsonPatch = compare(omitBy(containerData, isUndefined), updatedData); + + return patchContainerDetails(containerData?.id ?? '', jsonPatch); + }; + + const handleUpdateDescription = async (updatedDescription: string) => { + try { + const { description: newDescription, version } = + await handleUpdateContainerData({ + ...(containerData as Container), + description: updatedDescription, + }); + + setContainerData((prev) => ({ + ...(prev as Container), + description: newDescription, + version, + })); + } catch (error) { + showErrorToast(error as AxiosError); + } + }; + + const handleFollowContainer = async () => { + const followerId = currentUser?.id ?? ''; + const containerId = containerData?.id ?? ''; + try { + if (isUserFollowing) { + const response = await removeContainerFollower(containerId, followerId); + const { oldValue } = response.changeDescription.fieldsDeleted[0]; + + setContainerData((prev) => ({ + ...(prev as Container), + followers: (containerData?.followers || []).filter( + (follower) => follower.id !== oldValue[0].id + ), + })); + } else { + const response = await addContainerFollower(containerId, followerId); + const { newValue } = response.changeDescription.fieldsAdded[0]; + + setContainerData((prev) => ({ + ...(prev as Container), + followers: [...(containerData?.followers ?? []), ...newValue], + })); + } + } catch (error) { + showErrorToast(error as AxiosError); + } + }; + + const handleRemoveOwner = async () => { + try { + const { owner: newOwner, version } = await handleUpdateContainerData({ + ...(containerData as Container), + owner: undefined, + }); + + setContainerData((prev) => ({ + ...(prev as Container), + owner: newOwner, + version, + })); + } catch (error) { + showErrorToast(error as AxiosError); + } + }; + + const handleRemoveTier = async () => { + try { + const { tags: newTags, version } = await handleUpdateContainerData({ + ...(containerData as Container), + tags: getTagsWithoutTier(containerData?.tags ?? []), + }); + + setContainerData((prev) => ({ + ...(prev as Container), + tags: newTags, + version, + })); + } catch (error) { + showErrorToast(error as AxiosError); + } + }; + + const handleUpdateOwner = async (updatedOwner?: Container['owner']) => { + try { + if (updatedOwner) { + const { owner: newOwner, version } = await handleUpdateContainerData({ + ...(containerData as Container), + owner: updatedOwner ?? containerData?.owner, + }); + + setContainerData((prev) => ({ + ...(prev as Container), + owner: newOwner, + version, + })); + } + } catch (error) { + showErrorToast(error as AxiosError); + } + }; + + const handleUpdateTier = async (updatedTier?: string) => { + try { + if (updatedTier) { + const { tags: newTags, version } = await handleUpdateContainerData({ + ...(containerData as Container), + tags: [ + ...(containerData?.tags ?? []), + { + tagFQN: updatedTier, + labelType: LabelType.Manual, + state: State.Confirmed, + source: TagSource.Classification, + }, + ], + }); + + setContainerData((prev) => ({ + ...(prev as Container), + tags: newTags, + version, + })); + } + } catch (error) { + showErrorToast(error as AxiosError); + } + }; + + const handleUpdateTags = async (selectedTags: Array = []) => { + try { + const { tags: newTags, version } = await handleUpdateContainerData({ + ...(containerData as Container), + tags: [...(tier ? [tier] : []), ...selectedTags], + }); + + setContainerData((prev) => ({ + ...(prev as Container), + tags: newTags, + version, + })); + } catch (error) { + showErrorToast(error as AxiosError); + } + }; + + const handleRestoreContainer = async () => { + try { + await restoreContainer(containerData?.id ?? ''); + showSuccessToast( + t('message.restore-entities-success', { + entity: t('label.container'), + }), + 2000 + ); + refreshPage(); + } catch (error) { + showErrorToast( + error as AxiosError, + t('message.restore-entities-error', { + entity: t('label.container'), + }) + ); + } + }; + + // Lineage handlers + const handleAddLineage = async (edge: Edge) => { + try { + await addLineage(edge); + } catch (error) { + showErrorToast(error as AxiosError); + } + }; + + const handleRemoveLineage = async (data: EdgeData) => { + try { + await deleteLineageEdge( + data.fromEntity, + data.fromId, + data.toEntity, + data.toId + ); + } catch (error) { + showErrorToast(error as AxiosError); + } + }; + + const handleSetLeafNode = (val: EntityLineage, pos: LineagePos) => { + if (pos === 'to' && val.downstreamEdges?.length === 0) { + setLeafNodes((prev) => ({ + ...prev, + downStreamNode: [...(prev.downStreamNode ?? []), val.entity.id], + })); + } + if (pos === 'from' && val.upstreamEdges?.length === 0) { + setLeafNodes((prev) => ({ + ...prev, + upStreamNode: [...(prev.upStreamNode ?? []), val.entity.id], + })); + } + }; + + const handleLoadLineageNode = async ( + node: EntityReference, + pos: LineagePos + ) => { + setNodeLoading({ id: node.id, state: true }); + + try { + const response = await getLineageByFQN( + node.fullyQualifiedName ?? '', + node.type + ); + handleSetLeafNode(response, pos); + setEntityLineage(getEntityLineage(entityLineage, response, pos)); + setTimeout(() => { + setNodeLoading((prev) => ({ ...prev, state: false })); + }, 500); + } catch (error) { + showErrorToast(error as AxiosError); + } + }; + + const handleFullScreenClick = () => + history.push(getLineageViewPath(EntityType.CONTAINER, containerName)); + + const handleExtensionUpdate = async (updatedContainer: Container) => { + try { + const response = await handleUpdateContainerData(updatedContainer); + setContainerData(response); + } catch (error) { + showErrorToast(error as AxiosError); + } + }; + + const handleUpdateDataModel = async ( + updatedDataModel: Container['dataModel'] + ) => { + try { + const { dataModel: newDataModel, version } = + await handleUpdateContainerData({ + ...(containerData as Container), + dataModel: updatedDataModel, + }); + + setContainerData((prev) => ({ + ...(prev as Container), + dataModel: newDataModel, + version, + })); + } catch (error) { + showErrorToast(error as AxiosError); + } + }; + + // Effects + useEffect(() => { + if (hasViewPermission) { + fetchContainerDetail(containerName); + } + }, [containerName, containerPermissions]); + + useEffect(() => { + fetchResourcePermission(containerName); + // reset parent containers list on containername change + setParentContainers([]); + }, [containerName]); + + useEffect(() => { + if (tab === CONTAINER_DETAILS_TABS.Lineage) { + fetchLineageData(containerName); + } + if (tab === CONTAINER_DETAILS_TABS.CHILDREN) { + fetchContainerChildren(containerName); + } + }, [tab, containerName]); + + // Rendering + if (isLoading) { + return ; + } + + if (hasError) { + return ( + + {getEntityMissingError(t('label.container'), containerName)} + + ); + } + + if (!hasViewPermission && !isLoading) { + return {NO_PERMISSION_TO_VIEW}; + } + + return ( + +
+ + + + {t('label.schema')} + + }> + +
+ setIsEditDescription(false)} + onDescriptionEdit={() => setIsEditDescription(true)} + onDescriptionUpdate={handleUpdateDescription} + /> + + + + + + + + {t('label.children')} + + }> + + + {isChildrenLoading ? ( + + ) : ( + + )} + + + + + {t('label.lineage')} + + }> + + setEntityLineage(lineage)} + entityType={EntityType.CONTAINER} + hasEditAccess={hasEditLineagePermission} + isLoading={isLineageLoading} + isNodeLoading={isNodeLoading} + lineageLeafNodes={leafNodes} + loadNodeHandler={handleLoadLineageNode} + removeLineageHandler={handleRemoveLineage} + onFullScreenClick={handleFullScreenClick} + /> + + + + {t('label.custom-property-plural')} + + }> + + + + + + + + ); +}; + +export default observer(ContainerPage); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/CustomPropertiesPageV1/CustomPropertiesPageV1.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/CustomPropertiesPageV1/CustomPropertiesPageV1.tsx index e4b2fe5e40b..6276e2682f5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/CustomPropertiesPageV1/CustomPropertiesPageV1.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/CustomPropertiesPageV1/CustomPropertiesPageV1.tsx @@ -164,6 +164,9 @@ const CustomEntityDetailV1 = () => { case ENTITY_PATH.mlmodels: return PAGE_HEADERS.ML_MODELS_CUSTOM_ATTRIBUTES; + + case ENTITY_PATH.containers: + return PAGE_HEADERS.CONTAINER_CUSTOM_ATTRIBUTES; default: return PAGE_HEADERS.TABLES_CUSTOM_ATTRIBUTES; } 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 b98dd4264de..50dd65102f7 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 @@ -32,6 +32,7 @@ import { usePermissionProvider } from 'components/PermissionProvider/PermissionP import { OperationPermission } from 'components/PermissionProvider/PermissionProvider.interface'; import ServiceConnectionDetails from 'components/ServiceConnectionDetails/ServiceConnectionDetails.component'; import TagsViewer from 'components/Tag/TagsViewer/tags-viewer'; +import { EntityType } from 'enums/entity.enum'; import { Container } from 'generated/entity/data/container'; import { isEmpty, isNil, isUndefined, startCase, toLower } from 'lodash'; import { ExtraInfo, ServicesUpdateRequest, ServiceTypes } from 'Models'; @@ -488,10 +489,10 @@ const ServicePage: FunctionComponent = () => { setData(response.data); setPaging(response.paging); - setIsLoading(false); } catch (error) { setData([]); setPaging(pagingObject); + } finally { setIsLoading(false); } }; @@ -548,11 +549,7 @@ const ServicePage: FunctionComponent = () => { return getEntityLink(SearchIndex.MLMODEL, fqn); case ServiceCategory.OBJECT_STORE_SERVICES: - /** - * Update this when containers details page is ready - */ - - return ''; + return getEntityLink(EntityType.CONTAINER, fqn); case ServiceCategory.DATABASE_SERVICES: default: @@ -632,7 +629,7 @@ const ServicePage: FunctionComponent = () => { case ServiceCategory.OBJECT_STORE_SERVICES: { const container = data as Container; - return container.tags && container.tags?.length > 0 ? ( + return container.tags && container.tags.length > 0 ? ( { + const response = await APIClient.get( + `containers/name/${name}?fields=${fields}` + ); + + return response.data; +}; + +export const patchContainerDetails = async (id: string, data: Operation[]) => { + const response = await APIClient.patch>( + `/containers/${id}`, + data, + configOptionsForPatch + ); + + return response.data; +}; + +export const addContainerFollower = async (id: string, userId: string) => { + const response = await APIClient.put< + string, + AxiosResponse<{ + changeDescription: { fieldsAdded: { newValue: EntityReference[] }[] }; + }> + >(`/containers/${id}/followers`, userId, configOptions); + + return response.data; +}; + +export const restoreContainer = async (id: string) => { + const response = await APIClient.put< + RestoreRequestType, + AxiosResponse + >('/containers/restore', { id }); + + return response.data; +}; + +export const removeContainerFollower = async (id: string, userId: string) => { + const response = await APIClient.delete< + string, + AxiosResponse<{ + changeDescription: { fieldsDeleted: { oldValue: EntityReference[] }[] }; + }> + >(`/containers/${id}/followers/${userId}`, configOptions); + + return response.data; +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/styles/components/menu.less b/openmetadata-ui/src/main/resources/ui/src/styles/components/menu.less index c84db0e57ae..84a99ca1f6c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/styles/components/menu.less +++ b/openmetadata-ui/src/main/resources/ui/src/styles/components/menu.less @@ -59,6 +59,16 @@ .ant-menu-item-selected { .ant-menu-title-content { font-weight: 600; + .ant-badge { + color: @primary-color; + } + } + } + .ant-menu-item-active { + .ant-menu-title-content { + .ant-badge { + color: @primary-color; + } } } diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/CommonUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/CommonUtils.tsx index 7ef671e931d..72cfbf744fd 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/CommonUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/CommonUtils.tsx @@ -23,6 +23,7 @@ import { } from 'components/common/CronEditor/CronEditor.constant'; import ErrorPlaceHolder from 'components/common/error-with-placeholder/ErrorPlaceHolder'; import Loader from 'components/Loader/Loader'; +import { Container } from 'generated/entity/data/container'; import { t } from 'i18next'; import { capitalize, @@ -71,7 +72,7 @@ import { Dashboard } from '../generated/entity/data/dashboard'; import { Database } from '../generated/entity/data/database'; import { GlossaryTerm } from '../generated/entity/data/glossaryTerm'; import { Pipeline } from '../generated/entity/data/pipeline'; -import { Table } from '../generated/entity/data/table'; +import { Column, Table } from '../generated/entity/data/table'; import { Topic } from '../generated/entity/data/topic'; import { Webhook } from '../generated/entity/events/webhook'; import { ThreadTaskStatus, ThreadType } from '../generated/entity/feed/thread'; @@ -572,6 +573,8 @@ export const getEntityName = ( | Kpi | Classification | Field + | Container + | Column ) => { return entity?.displayName || entity?.name || ''; }; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/ContainerDetailUtils.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/ContainerDetailUtils.test.ts new file mode 100644 index 00000000000..78a9cc470a5 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/ContainerDetailUtils.test.ts @@ -0,0 +1,202 @@ +/* + * 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, DataType } from 'generated/entity/data/container'; +import { + getContainerDetailPath, + updateContainerColumnDescription, + updateContainerColumnTags, +} from './ContainerDetailUtils'; + +const mockTagOptions = [ + { + fqn: 'PII.Sensitive', + source: 'Classification', + }, + { + fqn: 'PersonalData.Personal', + source: 'Classification', + }, +]; + +const mockTags = [ + { + tagFQN: 'PII.Sensitive', + source: 'Classification', + labelType: 'Manual', + state: 'Confirmed', + }, + { + tagFQN: 'PersonalData.Personal', + source: 'Classification', + labelType: 'Manual', + state: 'Confirmed', + }, +]; + +const nestedColumn = { + name: 'Order', + displayName: 'Order', + dataType: DataType.Record, + description: 'All the order events on our online store', + children: [ + { + name: 'order_id', + dataType: DataType.Int, + description: 'order_id', + }, + { + name: 'api_client_id', + dataType: DataType.Int, + description: 'api_client_id', + }, + ], +}; + +const singleColumn = { + name: 'id', + dataType: DataType.String, + fullyQualifiedName: 'sample_kafka.customer_events.id', +}; + +const updatedNestedColumn: Column = { + name: 'Order', + displayName: 'Order', + dataType: DataType.Record, + description: 'All the order events on our online store', + children: [ + { + name: 'order_id', + dataType: DataType.Int, + description: 'order_id', + }, + { + name: 'api_client_id', + dataType: DataType.Int, + description: 'updated description', + }, + ], +}; + +const updatedSingleColumn = { + name: 'id', + dataType: DataType.String, + fullyQualifiedName: 'sample_kafka.customer_events.id', + description: 'updated description', +}; + +const nestedColumnWithTags = { + name: 'Order', + displayName: 'Order', + dataType: DataType.Record, + description: 'All the order events on our online store', + children: [ + { + name: 'order_id', + dataType: DataType.Int, + description: 'order_id', + tags: [], + }, + { + name: 'api_client_id', + dataType: DataType.Int, + description: 'api_client_id', + tags: [], + }, + ], +}; + +const updatedNestedColumnWithTags: Column = { + name: 'Order', + displayName: 'Order', + dataType: DataType.Record, + description: 'All the order events on our online store', + children: [ + { + name: 'order_id', + dataType: DataType.Int, + description: 'order_id', + tags: mockTags as Column['tags'], + }, + { + name: 'api_client_id', + dataType: DataType.Int, + description: 'api_client_id', + tags: [], + }, + ], +}; + +describe('getContainerDetailPath', () => { + it('returns the correct path without tab', () => { + const containerFQN = 'my-container'; + const path = getContainerDetailPath(containerFQN); + + expect(path).toEqual(`/container/${containerFQN}`); + }); + + it('returns the correct path with tab', () => { + const containerFQN = 'my-container'; + const tab = 'my-tab'; + const path = getContainerDetailPath(containerFQN, tab); + + expect(path).toEqual(`/container/${containerFQN}/${tab}`); + }); + + it('updateContainerColumnDescription method should update the column', () => { + const containerColumns = [singleColumn, nestedColumn]; + + // updated the single column + updateContainerColumnDescription( + containerColumns, + 'id', + 'updated description' + ); + + // updated the nested column + updateContainerColumnDescription( + containerColumns, + 'api_client_id', + 'updated description' + ); + + const updatedContainerColumns = [updatedSingleColumn, updatedNestedColumn]; + + expect(containerColumns).toEqual(updatedContainerColumns); + }); + + it('updateContainerColumnTags method should update the column', () => { + const containerColumns = [ + { ...singleColumn, tags: [], description: 'updated description' }, + ]; + + // updated the single column + updateContainerColumnTags(containerColumns, 'id', mockTagOptions); + + const updatedContainerColumns = [ + { ...updatedSingleColumn, tags: mockTags }, + ]; + + expect(containerColumns).toEqual(updatedContainerColumns); + }); + + it('updateContainerColumnTags method should update the nested column', () => { + const containerColumns = [nestedColumnWithTags]; + + // updated the single column + updateContainerColumnTags(containerColumns, 'order_id', mockTagOptions); + + const updatedContainerColumns = [updatedNestedColumnWithTags]; + + expect(containerColumns).toEqual(updatedContainerColumns); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/ContainerDetailUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/ContainerDetailUtils.ts new file mode 100644 index 00000000000..9f045423854 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/ContainerDetailUtils.ts @@ -0,0 +1,112 @@ +/* + * 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_CONTAINER_NAME, + PLACEHOLDER_ROUTE_TAB, + ROUTES, +} from 'constants/constants'; +import { Column, ContainerDataModel } from 'generated/entity/data/container'; +import { LabelType, State, TagLabel } from 'generated/type/tagLabel'; +import { isEmpty } from 'lodash'; +import { EntityTags, TagOption } from 'Models'; + +export const getContainerDetailPath = (containerFQN: string, tab?: string) => { + let path = tab ? ROUTES.CONTAINER_DETAILS_WITH_TAB : ROUTES.CONTAINER_DETAILS; + path = path.replace(PLACEHOLDER_CONTAINER_NAME, containerFQN); + + if (tab) { + path = path.replace(PLACEHOLDER_ROUTE_TAB, tab); + } + + return path; +}; + +const getUpdatedContainerColumnTags = ( + 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 updateContainerColumnTags = ( + containerColumns: ContainerDataModel['columns'] = [], + changedColumnName: string, + newColumnTags: TagOption[] = [] +) => { + containerColumns.forEach((containerColumn) => { + if (containerColumn.name === changedColumnName) { + containerColumn.tags = getUpdatedContainerColumnTags( + containerColumn, + newColumnTags + ); + } else { + const hasChildren = !isEmpty(containerColumn.children); + + // stop condition + if (hasChildren) { + updateContainerColumnTags( + containerColumn.children, + changedColumnName, + newColumnTags + ); + } + } + }); +}; + +export const updateContainerColumnDescription = ( + containerColumns: ContainerDataModel['columns'] = [], + changedColumnName: string, + description: string +) => { + containerColumns.forEach((containerColumn) => { + if (containerColumn.name === changedColumnName) { + containerColumn.description = description; + } else { + const hasChildren = !isEmpty(containerColumn.children); + + // stop condition + if (hasChildren) { + updateContainerColumnDescription( + containerColumn.children, + changedColumnName, + description + ); + } + } + }); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/GlobalSettingsUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/GlobalSettingsUtils.tsx index 6208e68cd2a..a1db217801e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/GlobalSettingsUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/GlobalSettingsUtils.tsx @@ -13,6 +13,7 @@ import { Badge } from 'antd'; import { ItemType } from 'antd/lib/menu/hooks/useItems'; +import classNames from 'classnames'; import { ResourceEntity, UIPermission, @@ -163,6 +164,7 @@ export const getGlobalSettingsMenuWithPermission = ( permissions ), icon: , + isBeta: Boolean, }, ], }, @@ -225,6 +227,14 @@ export const getGlobalSettingsMenuWithPermission = ( ), icon: , }, + { + label: i18next.t('label.container-plural'), + isProtected: userPermissions.hasViewPermissions( + ResourceEntity.TYPE, + permissions + ), + icon: , + }, ], }, { @@ -255,30 +265,42 @@ export const getGlobalSettingsMenuWithPermission = ( ]; }; -export const getGlobalSettingMenuItem = ( - label: string, - key: string, - category?: string, - icon?: React.ReactNode, +export const getGlobalSettingMenuItem = (args: { + label: string; + key: string; + category?: string; + icon?: React.ReactNode; children?: { label: string; isProtected: boolean; icon: React.ReactNode; - }[], - type?: string, - isBeta?: boolean -): { + isBeta?: boolean; + }[]; + type?: string; + isBeta?: boolean; + isChildren?: boolean; +}): { key: string; icon: React.ReactNode; children: ItemType[] | undefined; label: ReactNode; type: string | undefined; } => { + const { children, label, key, icon, category, isBeta, type, isChildren } = + args; + const subItems = children ? children .filter((menu) => menu.isProtected) - .map(({ label, icon }) => { - return getGlobalSettingMenuItem(label, camelCase(label), key, icon); + .map(({ label, icon, isBeta: isChildBeta }) => { + return getGlobalSettingMenuItem({ + label, + key: camelCase(label), + category: key, + icon, + isBeta: isChildBeta, + isChildren: true, + }); }) : undefined; @@ -288,7 +310,7 @@ export const getGlobalSettingMenuItem = ( children: subItems, label: isBeta ? ( { case MetadataServiceType.OpenMetadata: return LOGO; + case ObjectStoreServiceType.Azure: + return MS_AZURE; + + case ObjectStoreServiceType.S3: + return AMAZON_S3; + + case ObjectStoreServiceType.Gcs: + return GCS; + default: { let logo; if (serviceTypes.messagingServices.includes(type)) { diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/TableUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/TableUtils.tsx index 2c83237332b..3abc429b0c1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/TableUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/TableUtils.tsx @@ -63,6 +63,7 @@ import { getTableFQNFromColumnFQN, sortTagsCaseInsensitive, } from './CommonUtils'; +import { getContainerDetailPath } from './ContainerDetailUtils'; import { getGlossaryPath, getSettingPath } from './RouterUtils'; import { ordinalize } from './StringsUtils'; @@ -245,6 +246,9 @@ export const getEntityLink = ( case SearchIndex.MLMODEL: return getMlModelPath(fullyQualifiedName); + case EntityType.CONTAINER: + return getContainerDetailPath(fullyQualifiedName); + case SearchIndex.TABLE: case EntityType.TABLE: default: