feat(ui)#10271:UI - Object Storage Service Part-2 (#10419)

* feat(ui)#10271:UI - Object Storage Service

* chore: add objectStoreService in permission resource enum

* chore: add support for connection config form for object store service

* chore: add support for service connection details schema for object service

* chore: change objectStores to objectstores

* chore: add object stores in global settings

* chore: update objectstore locals

* chore: change objectStoreServices to objectstoreServices

* chore: add support for page header for object stores

* chore: fetch containers for object store service

* chore: fix connection config is not showing up for object store service

* chore: update getLinkForFqn for object store service

* fix: add object store service in delete widget modal

* chore: add object store service logo

* chore: update object store service label

* chore: fix routing of object store service

* test: add unit test for object store service

* fix: missing tags column header

* test: add unit test for global setting left panel

* test: update global setting left panel test

* chore: update svg to take the current color

* address comments

* feat(ui)#10271:UI - Object Storage Service Part-2

* chore: add support for container children

* chore: add support for data model component

* chore: add support for adding/updating description

* fix: read serviceType directly from entity

* chore: add support for follow and unFollow container

* chore: add support for update/remove owner and tier

* chore: add support for updating/removing tags of container

* fix: version is not updating in real time

* chore: add support for restoring the container

* chore: add support for lineage

* chore: add support for custom properties

* chore: add support for CRUD for custom attributes

* chore: add support for container nth level in breadcrumb

* fix: unit test

* chore: locale update

* chore: add support for updating data models

* chore: add object store service icon

* address previous PR comments

* test: add unit test

* chore: fix label issue

* chore: remove duplicate codes

* chore: add beta tag for object store service

* fix: tags source issue

* address comments
This commit is contained in:
Sachin Chaurasiya 2023-03-10 13:25:24 +05:30 committed by GitHub
parent bc4abc44ed
commit dccc139aad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 1937 additions and 36 deletions

View File

@ -0,0 +1,34 @@
<svg xmlns="http://www.w3.org/2000/svg" width="428" height="512" viewBox="0 0 428 512">
<defs>
<style>
.cls-1 {
fill: #e25444;
}
.cls-1, .cls-2, .cls-3 {
fill-rule: evenodd;
}
.cls-2 {
fill: #7b1d13;
}
.cls-3 {
fill: #58150d;
}
</style>
</defs>
<path class="cls-1" d="M378,99L295,257l83,158,34-19V118Z"/>
<path class="cls-2" d="M378,99L212,118,127.5,257,212,396l166,19V99Z"/>
<path class="cls-3" d="M43,99L16,111V403l27,12L212,257Z"/>
<path class="cls-1" d="M42.637,98.667l169.587,47.111V372.444L42.637,415.111V98.667Z"/>
<path class="cls-3" d="M212.313,170.667l-72.008-11.556,72.008-81.778,71.83,81.778Z"/>
<path class="cls-3" d="M284.143,159.111l-71.919,11.733-71.919-11.733V77.333"/>
<path class="cls-3" d="M212.313,342.222l-72.008,13.334,72.008,70.222,71.83-70.222Z"/>
<path class="cls-2" d="M212,16L140,54V159l72.224-20.333Z"/>
<path class="cls-2" d="M212.224,196.444l-71.919,7.823V309.105l71.919,8.228V196.444Z"/>
<path class="cls-2" d="M212.224,373.333L140.305,355.3V458.363L212.224,496V373.333Z"/>
<path class="cls-1" d="M284.143,355.3l-71.919,18.038V496l71.919-37.637V355.3Z"/>
<path class="cls-1" d="M212.224,196.444l71.919,7.823V309.105l-71.919,8.228V196.444Z"/>
<path class="cls-1" d="M212,16l72,38V159l-72-20V16Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View File

@ -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(
<BrowserRouter>
<ContainerChildren childrenList={mockChildrenList} />
</BrowserRouter>
);
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(
<BrowserRouter>
<ContainerChildren childrenList={mockChildrenList} />
</BrowserRouter>
);
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(
<BrowserRouter>
<ContainerChildren childrenList={mockChildrenList} />
</BrowserRouter>
);
const richTextPreviewers = screen.getAllByTestId('viewer-container');
expect(richTextPreviewers).toHaveLength(2);
richTextPreviewers.forEach((previewer, index) => {
expect(previewer).toHaveTextContent(mockChildrenList[index].description);
});
});
});

View File

@ -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<ContainerChildrenProps> = ({ childrenList }) => {
const { t } = useTranslation();
const columns: ColumnsType<EntityReference> = useMemo(
() => [
{
title: t('label.name'),
dataIndex: 'name',
width: '200px',
key: 'name',
render: (_, record) => (
<Link
className="link-hover"
data-testid="container-name"
to={getContainerDetailPath(record.fullyQualifiedName || '')}>
{getEntityName(record)}
</Link>
),
},
{
title: t('label.description'),
dataIndex: 'description',
key: 'description',
render: (description: EntityReference['description']) => (
<>
{description ? (
<RichTextEditorPreviewer markdown={description} />
) : (
<Typography.Text className="tw-no-description">
{t('label.no-entity', {
entity: t('label.description'),
})}
</Typography.Text>
)}
</>
),
},
],
[]
);
return (
<Table
bordered
columns={columns}
data-testid="container-list-table"
dataSource={childrenList}
pagination={false}
rowKey="id"
size="small"
/>
);
};
export default ContainerChildren;

View File

@ -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<T, K extends keyof T> = (
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<void>;
}

View File

@ -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(
<div data-testid="description-preview">Description Preview</div>
)
);
jest.mock(
'components/Modals/ModalWithMarkdownEditor/ModalWithMarkdownEditor',
() => ({
ModalWithMarkdownEditor: jest
.fn()
.mockReturnValue(<div data-testid="editor-modal">Editor Modal</div>),
})
);
jest.mock('components/Tag/TagsContainer/tags-container', () =>
jest
.fn()
.mockReturnValue(<div data-testid="tag-container">Tag Container</div>)
);
jest.mock('components/Tag/TagsViewer/tags-viewer', () =>
jest.fn().mockReturnValue(<div data-testid="tag-viewer">Tag Viewer</div>)
);
describe('ContainerDataModel', () => {
it('Should render the Container data model component', async () => {
render(<ContainerDataModel {...props} />);
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(<ContainerDataModel {...props} />);
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(
<ContainerDataModel
{...props}
isReadOnly
hasDescriptionEditAccess={false}
/>
);
const rows = await screen.findAllByRole('row');
const row1 = rows[1];
const editDescriptionButton = queryByTestId(row1, 'edit-button');
expect(editDescriptionButton).toBeNull();
});
});

View File

@ -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<ContainerDataModelProps> = ({
dataModel,
hasDescriptionEditAccess,
hasTagEditAccess,
isReadOnly,
onUpdate,
}) => {
const { t } = useTranslation();
const [editContainerColumnDescription, setEditContainerColumnDescription] =
useState<Column>();
const [editContainerColumnTags, setEditContainerColumnTags] =
useState<Column>();
const [tagList, setTagList] = useState<TagOption[]>([]);
const [isTagLoading, setIsTagLoading] = useState<boolean>(false);
const [tagFetchFailed, setTagFetchFailed] = useState<boolean>(false);
const fetchTags = 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<Column, 'description'> =
(description, record, index) => {
return (
<Space
className="custom-group w-full"
data-testid="description"
id={`field-description-${index}`}
size={4}>
<>
{description ? (
<RichTextEditorPreviewer markdown={description} />
) : (
<Typography.Text className="tw-no-description">
{t('label.no-entity', {
entity: t('label.description'),
})}
</Typography.Text>
)}
</>
{isReadOnly && !hasDescriptionEditAccess ? null : (
<Button
className="p-0 opacity-0 group-hover-opacity-100"
data-testid="edit-button"
icon={<EditIcon width="16px" />}
type="text"
onClick={() => setEditContainerColumnDescription(record)}
/>
)}
</Space>
);
};
const renderContainerColumnTags: CellRendered<Column, 'tags'> = (
tags,
record: Column
) => {
const isSelectedField = editContainerColumnTags?.name === record.name;
const isUpdatingTags = isSelectedField || !isEmpty(tags);
return (
<>
{isReadOnly ? (
<Space wrap>
<TagsViewer sizeCap={-1} tags={tags || []} />
</Space>
) : (
<Space
align={isUpdatingTags ? 'start' : 'center'}
className="justify-between"
data-testid="tags-wrapper"
direction={isUpdatingTags ? 'vertical' : 'horizontal'}
onClick={() => handleAddTagClick(record)}>
<TagsContainer
editable={isSelectedField}
isLoading={isTagLoading && isSelectedField}
selectedTags={tags || []}
showAddTagButton={hasTagEditAccess}
size="small"
tagList={tagList}
type="label"
onCancel={() => setEditContainerColumnTags(undefined)}
onSelectionChange={handleFieldTagsChange}
/>
</Space>
)}
</>
);
};
const columns: ColumnsType<Column> = useMemo(
() => [
{
title: t('label.name'),
dataIndex: 'name',
key: 'name',
accessor: 'name',
width: 300,
render: (_, record: Column) => (
<Popover
destroyTooltipOnHide
content={getEntityName(record)}
trigger="hover">
<Typography.Text>{getEntityName(record)}</Typography.Text>
</Popover>
),
},
{
title: t('label.type'),
dataIndex: 'dataTypeDisplay',
key: 'dataTypeDisplay',
accessor: 'dataTypeDisplay',
ellipsis: true,
width: 220,
render: (dataTypeDisplay: Column['dataTypeDisplay']) => {
return (
<Popover
destroyTooltipOnHide
content={toLower(dataTypeDisplay)}
overlayInnerStyle={{
maxWidth: '420px',
overflowWrap: 'break-word',
textAlign: 'center',
}}
trigger="hover">
<Typography.Text ellipsis className="cursor-pointer">
{dataTypeDisplay}
</Typography.Text>
</Popover>
);
},
},
{
title: t('label.description'),
dataIndex: 'description',
key: 'description',
accessor: 'description',
render: renderContainerColumnDescription,
},
{
title: t('label.tag-plural'),
dataIndex: 'tags',
key: 'tags',
accessor: 'tags',
width: 350,
render: renderContainerColumnTags,
},
],
[
hasDescriptionEditAccess,
hasTagEditAccess,
editContainerColumnDescription,
editContainerColumnTags,
isReadOnly,
isTagLoading,
]
);
return (
<>
<Table
bordered
columns={columns}
data-testid="container-data-model-table"
dataSource={dataModel?.columns}
expandable={{
...getTableExpandableConfig<Column>(),
rowExpandable: (record) => !isEmpty(record.children),
}}
pagination={false}
size="small"
/>
{editContainerColumnDescription && (
<ModalWithMarkdownEditor
header={`${t('label.edit-entity', {
entity: t('label.column'),
})}: "${editContainerColumnDescription.name}"`}
placeholder={t('label.enter-field-description', {
field: t('label.column'),
})}
value={editContainerColumnDescription.description ?? ''}
visible={Boolean(editContainerColumnDescription)}
onCancel={() => setEditContainerColumnDescription(undefined)}
onSave={handleContainerColumnDescriptionChange}
/>
)}
</>
);
};
export default ContainerDataModel;

View File

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

View File

@ -63,6 +63,7 @@ export enum ResourceEntity {
USER = 'user',
WEBHOOK = 'webhook',
OBJECT_STORE_SERVICE = 'objectStoreService',
CONTAINER = 'container',
}
export interface PermissionContextType {

View File

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

View File

@ -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 = {

View File

@ -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}
/>
<Route exact component={ContainerPage} path={ROUTES.CONTAINER_DETAILS} />
<Route
exact
component={ContainerPage}
path={ROUTES.CONTAINER_DETAILS_WITH_TAB}
/>
<Route
exact
component={PipelineDetailsPage}

View File

@ -11,6 +11,8 @@
* limitations under the License.
*/
import i18n from 'utils/i18next/LocalUtil';
export const PAGE_HEADERS = {
ADVANCE_SEARCH: {
header: 'Advanced Search',
@ -114,6 +116,12 @@ export const PAGE_HEADERS = {
subHeader:
'Define custom properties for ML models to serve your organizational needs.',
},
CONTAINER_CUSTOM_ATTRIBUTES: {
header: i18n.t('label.container-plural'),
subHeader: i18n.t('message.define-custom-property-for-entity', {
entity: i18n.t('label.container-plural'),
}),
},
BOTS: {
header: 'Bots',
subHeader: 'Create well-defined bots with scoped access permissions.',

View File

@ -11,6 +11,9 @@
* limitations under the License.
*/
import amazonS3 from 'assets/img/service-icon-amazon-s3.svg';
import gcs from 'assets/img/service-icon-gcs.png';
import msAzure from 'assets/img/service-icon-ms-azure.png';
import { ObjectStoreServiceType } from 'generated/entity/services/objectstoreService';
import { map, startCase } from 'lodash';
import { ServiceTypes } from 'Models';
@ -143,6 +146,9 @@ export const KINESIS = kinesis;
export const QUICKSIGHT = quicksight;
export const DOMO = domo;
export const SAGEMAKER = sagemaker;
export const AMAZON_S3 = amazonS3;
export const GCS = gcs;
export const MS_AZURE = msAzure;
export const PLUS = plus;
export const NOSERVICE = noService;

View File

@ -119,6 +119,7 @@ export const INGESTION_NAME = ':ingestionName';
export const LOG_ENTITY_NAME = ':logEntityName';
export const KPI_NAME = ':kpiName';
export const PLACEHOLDER_ACTION = ':action';
export const PLACEHOLDER_CONTAINER_NAME = ':containerName';
export const pagingObject = { after: '', before: '', total: 0 };
@ -255,6 +256,9 @@ export const ROUTES = {
KPI_LIST: `/data-insights/kpi`,
ADD_KPI: `/data-insights/kpi/add-kpi`,
EDIT_KPI: `/data-insights/kpi/edit-kpi/${KPI_NAME}`,
CONTAINER_DETAILS: `/container/${PLACEHOLDER_CONTAINER_NAME}`,
CONTAINER_DETAILS_WITH_TAB: `/container/${PLACEHOLDER_CONTAINER_NAME}/${PLACEHOLDER_ROUTE_TAB}`,
};
export const SOCKET_EVENTS = {
@ -532,6 +536,7 @@ export const ENTITY_PATH: Record<string, string> = {
dashboards: 'dashboard',
pipelines: 'pipeline',
mlmodels: 'mlmodel',
containers: 'container',
};
export const VALIDATE_MESSAGES = {

View File

@ -40,6 +40,7 @@ export enum EntityType {
DATA_INSIGHT_CHART = 'dataInsightChart',
KPI = 'kpi',
ALERT = 'alert',
CONTAINER = 'container',
}
export enum AssetsType {

View File

@ -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.",

View File

@ -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.",

View File

@ -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.",

View File

@ -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<boolean>(false);
const [isChildrenLoading, setIsChildrenLoading] = useState<boolean>(false);
const [hasError, setHasError] = useState<boolean>(false);
const [isEditDescription, setIsEditDescription] = useState<boolean>(false);
const [isLineageLoading, setIsLineageLoading] = useState<boolean>(false);
const [parentContainers, setParentContainers] = useState<Container[]>([]);
const [containerData, setContainerData] = useState<Container>();
const [containerChildrenData, setContainerChildrenData] = useState<
Container['children']
>([]);
const [containerPermissions, setContainerPermissions] =
useState<OperationPermission>(DEFAULT_ENTITY_PERMISSION);
const [entityLineage, setEntityLineage] = useState<EntityLineage>(
{} as EntityLineage
);
const [leafNodes, setLeafNodes] = useState<LeafNodes>({} as LeafNodes);
const [isNodeLoading, setNodeLoading] = useState<LoadingNodeState>({
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<ExtraInfo> = [
{
key: EntityInfo.OWNER,
value: owner && getOwnerValue(owner),
placeholderText: getEntityPlaceHolder(
getEntityName(owner),
owner?.deleted
),
isLink: true,
openInNewTab: false,
profileName: owner?.type === OwnerType.USER ? owner?.name : undefined,
},
{
key: EntityInfo.TIER,
value: tier?.tagFQN ? tier.tagFQN.split(FQN_SEPARATOR_CHAR)[1] : '',
},
];
const 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<EntityTags> = []) => {
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 <Loader />;
}
if (hasError) {
return (
<ErrorPlaceHolder>
{getEntityMissingError(t('label.container'), containerName)}
</ErrorPlaceHolder>
);
}
if (!hasViewPermission && !isLoading) {
return <ErrorPlaceHolder>{NO_PERMISSION_TO_VIEW}</ErrorPlaceHolder>;
}
return (
<PageContainerV1>
<div className="entity-details-container">
<EntityPageInfo
canDelete={containerPermissions.Delete}
currentOwner={owner}
deleted={deleted}
entityFqn={containerName}
entityId={entityId}
entityName={entityName || ''}
entityType={EntityType.CONTAINER}
extraInfo={extraInfo}
followHandler={handleFollowContainer}
followers={followers.length}
followersList={followers}
isFollowing={isUserFollowing}
isTagEditable={hasEditTagsPermission}
removeOwner={hasEditOwnerPermission ? handleRemoveOwner : undefined}
removeTier={hasEditTierPermission ? handleRemoveTier : undefined}
tags={tags}
tagsHandler={handleUpdateTags}
tier={tier}
titleLinks={breadcrumbTitles}
updateOwner={hasEditOwnerPermission ? handleUpdateOwner : undefined}
updateTier={hasEditTierPermission ? handleUpdateTier : undefined}
version={version + ''}
onRestoreEntity={handleRestoreContainer}
/>
<Tabs activeKey={tab} className="h-full" onChange={handleTabChange}>
<Tabs.TabPane
key={CONTAINER_DETAILS_TABS.SCHEME}
tab={
<span data-testid={CONTAINER_DETAILS_TABS.SCHEME}>
{t('label.schema')}
</span>
}>
<Row
className="tw-bg-white tw-flex-grow tw-p-4 tw-shadow tw-rounded-md"
gutter={[0, 16]}>
<Col span={24}>
<Description
description={description}
entityFqn={containerName}
entityName={entityName}
entityType={EntityType.CONTAINER}
hasEditAccess={hasEditDescriptionPermission}
isEdit={isEditDescription}
isReadOnly={deleted}
owner={owner}
onCancel={() => setIsEditDescription(false)}
onDescriptionEdit={() => setIsEditDescription(true)}
onDescriptionUpdate={handleUpdateDescription}
/>
</Col>
<Col span={24}>
<ContainerDataModel
dataModel={containerData?.dataModel}
hasDescriptionEditAccess={hasEditDescriptionPermission}
hasTagEditAccess={hasEditTagsPermission}
isReadOnly={Boolean(deleted)}
onUpdate={handleUpdateDataModel}
/>
</Col>
</Row>
</Tabs.TabPane>
<Tabs.TabPane
key={CONTAINER_DETAILS_TABS.CHILDREN}
tab={
<span data-testid={CONTAINER_DETAILS_TABS.CHILDREN}>
{t('label.children')}
</span>
}>
<Row
className="tw-bg-white tw-flex-grow tw-p-4 tw-shadow tw-rounded-md"
gutter={[0, 16]}>
<Col span={24}>
{isChildrenLoading ? (
<Loader />
) : (
<ContainerChildren childrenList={containerChildrenData} />
)}
</Col>
</Row>
</Tabs.TabPane>
<Tabs.TabPane
key={CONTAINER_DETAILS_TABS.Lineage}
tab={
<span data-testid={CONTAINER_DETAILS_TABS.Lineage}>
{t('label.lineage')}
</span>
}>
<Card
className={`${ENTITY_CARD_CLASS} card-body-full`}
data-testid="lineage-details">
<EntityLineageComponent
addLineageHandler={handleAddLineage}
deleted={deleted}
entityLineage={entityLineage}
entityLineageHandler={(lineage) => setEntityLineage(lineage)}
entityType={EntityType.CONTAINER}
hasEditAccess={hasEditLineagePermission}
isLoading={isLineageLoading}
isNodeLoading={isNodeLoading}
lineageLeafNodes={leafNodes}
loadNodeHandler={handleLoadLineageNode}
removeLineageHandler={handleRemoveLineage}
onFullScreenClick={handleFullScreenClick}
/>
</Card>
</Tabs.TabPane>
<Tabs.TabPane
key={CONTAINER_DETAILS_TABS.CUSTOM_PROPERTIES}
tab={
<span data-testid={CONTAINER_DETAILS_TABS.CUSTOM_PROPERTIES}>
{t('label.custom-property-plural')}
</span>
}>
<Card className={ENTITY_CARD_CLASS}>
<CustomPropertyTable
entityDetails={
containerData as CustomPropertyProps['entityDetails']
}
entityType={EntityType.CONTAINER}
handleExtensionUpdate={handleExtensionUpdate}
hasEditAccess={hasEditCustomFieldsPermission}
/>
</Card>
</Tabs.TabPane>
</Tabs>
</div>
</PageContainerV1>
);
};
export default observer(ContainerPage);

View File

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

View File

@ -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 ? (
<TagsViewer
showStartWith={false}
sizeCap={-1}

View File

@ -10,11 +10,24 @@
* 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 { Container } from 'generated/entity/data/container';
import { EntityReference } from 'generated/type/entityReference';
import { Paging } from 'generated/type/paging';
import { RestoreRequestType } from 'Models';
import { ServicePageData } from 'pages/service';
import { getURLWithQueryFields } from 'utils/APIUtils';
import APIClient from './index';
const configOptionsForPatch = {
headers: { 'Content-type': 'application/json-patch+json' },
};
const configOptions = {
headers: { 'Content-type': 'application/json' },
};
export const getContainers = async (
serviceName: string,
arrQueryFields: string | string[],
@ -32,3 +45,52 @@ export const getContainers = async (
return response.data;
};
export const getContainerByName = async (name: string, fields: string) => {
const response = await APIClient.get<Container>(
`containers/name/${name}?fields=${fields}`
);
return response.data;
};
export const patchContainerDetails = async (id: string, data: Operation[]) => {
const response = await APIClient.patch<Operation[], AxiosResponse<Container>>(
`/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<Container>
>('/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;
};

View File

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

View File

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

View File

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

View File

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

View File

@ -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: <ObjectStoreIcon className="side-panel-icons w-4 h-4" />,
isBeta: Boolean,
},
],
},
@ -225,6 +227,14 @@ export const getGlobalSettingsMenuWithPermission = (
),
icon: <MlModelIcon className="side-panel-icons" />,
},
{
label: i18next.t('label.container-plural'),
isProtected: userPermissions.hasViewPermissions(
ResourceEntity.TYPE,
permissions
),
icon: <ObjectStoreIcon className="side-panel-icons" />,
},
],
},
{
@ -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 ? (
<Badge
className="text-xs text-grey-muted"
className={classNames({ 'text-xs text-grey-muted': !isChildren })}
color="#7147e8"
count="beta"
offset={[30, 8]}

View File

@ -17,6 +17,7 @@ import {
ResourceEntity,
} from 'components/PermissionProvider/PermissionProvider.interface';
import cryptoRandomString from 'crypto-random-string-with-promisify-polyfill';
import { ObjectStoreServiceType } from 'generated/entity/data/container';
import { t } from 'i18next';
import {
Bucket,
@ -39,6 +40,7 @@ import {
import {
AIRBYTE,
AIRFLOW,
AMAZON_S3,
AMUNDSEN,
ATHENA,
ATLAS,
@ -56,6 +58,7 @@ import {
DRUID,
DYNAMODB,
FIVETRAN,
GCS,
GLUE,
HIVE,
IBMDB2,
@ -68,6 +71,7 @@ import {
MLFLOW,
MODE,
MSSQL,
MS_AZURE,
MYSQL,
NIFI,
ORACLE,
@ -283,6 +287,15 @@ export const serviceTypeLogo = (type: string) => {
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)) {

View File

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