mirror of
https://github.com/open-metadata/OpenMetadata.git
synced 2025-12-24 14:08:45 +00:00
✨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:
parent
bc4abc44ed
commit
dccc139aad
@ -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 |
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
@ -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>;
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
@ -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 {
|
||||
|
||||
@ -63,6 +63,7 @@ export enum ResourceEntity {
|
||||
USER = 'user',
|
||||
WEBHOOK = 'webhook',
|
||||
OBJECT_STORE_SERVICE = 'objectStoreService',
|
||||
CONTAINER = 'container',
|
||||
}
|
||||
|
||||
export interface PermissionContextType {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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.',
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -40,6 +40,7 @@ export enum EntityType {
|
||||
DATA_INSIGHT_CHART = 'dataInsightChart',
|
||||
KPI = 'kpi',
|
||||
ALERT = 'alert',
|
||||
CONTAINER = 'container',
|
||||
}
|
||||
|
||||
export enum AssetsType {
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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);
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 || '';
|
||||
};
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
@ -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]}
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user