UI : Added tags at schema Level (#10471)

* Added tags at schema Level

* Add task handling for Database Schema

* added cypress and unit test

* Changes as per comments

---------

Co-authored-by: Mohit Yadav <105265192+mohityadav766@users.noreply.github.com>
Co-authored-by: mohitdeuex <mohit.y@deuexsolutions.com>
This commit is contained in:
Ashish Gupta 2023-03-13 22:24:35 +05:30 committed by GitHub
parent 0f9c2c2164
commit c0fe8ec871
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 243 additions and 69 deletions

View File

@ -19,6 +19,7 @@ import static org.openmetadata.schema.type.Relationship.CREATED;
import static org.openmetadata.schema.type.Relationship.IS_ABOUT;
import static org.openmetadata.schema.type.Relationship.REPLIED_TO;
import static org.openmetadata.service.Entity.DASHBOARD;
import static org.openmetadata.service.Entity.DATABASE_SCHEMA;
import static org.openmetadata.service.Entity.FIELD_DESCRIPTION;
import static org.openmetadata.service.Entity.PIPELINE;
import static org.openmetadata.service.Entity.TABLE;
@ -60,6 +61,7 @@ import org.openmetadata.schema.api.feed.EntityLinkThreadCount;
import org.openmetadata.schema.api.feed.ResolveTask;
import org.openmetadata.schema.api.feed.ThreadCount;
import org.openmetadata.schema.entity.data.Dashboard;
import org.openmetadata.schema.entity.data.DatabaseSchema;
import org.openmetadata.schema.entity.data.Pipeline;
import org.openmetadata.schema.entity.data.Table;
import org.openmetadata.schema.entity.data.Topic;
@ -342,8 +344,33 @@ public class FeedRepository {
patch = JsonUtils.getJsonPatch(oldJson, updatedEntityJson);
repository.patch(uriInfo, pipeline.getId(), user, patch);
break;
default:
case DATABASE_SCHEMA:
DatabaseSchema databaseSchema = JsonUtils.readValue(json, DatabaseSchema.class);
oldJson = JsonUtils.pojoToJson(databaseSchema);
if (entityLink.getFieldName() != null) {
if (descriptionTasks.contains(taskType) && entityLink.getFieldName().equals(FIELD_DESCRIPTION)) {
databaseSchema.setDescription(newValue);
} else if (tagTasks.contains(taskType) && entityLink.getFieldName().equals("tags")) {
List<TagLabel> tags = JsonUtils.readObjects(newValue, TagLabel.class);
databaseSchema.setTags(tags);
} else {
// Not supported
throw new IllegalArgumentException(
String.format(UNSUPPORTED_FIELD_NAME_FOR_TASK, entityLink.getFieldName(), task.getType()));
}
} else {
// Not supported
throw new IllegalArgumentException(
String.format(
"The Entity link with no field name - %s is not supported for %s task.",
entityLink, task.getType()));
}
updatedEntityJson = JsonUtils.pojoToJson(databaseSchema);
patch = JsonUtils.getJsonPatch(oldJson, updatedEntityJson);
repository.patch(uriInfo, databaseSchema.getId(), user, patch);
break;
default:
throw new IllegalArgumentException("Task is not supported for the Data Asset.");
}
}
}

View File

@ -59,6 +59,7 @@ export const SEARCH_ENTITY_TABLE = {
term: 'fact_session',
entity: MYDATA_SUMMARY_OPTIONS.tables,
serviceName: 'sample_data',
schemaName: 'shopify',
},
table_3: {
term: 'raw_product_catalog',

View File

@ -16,6 +16,7 @@ import {
descriptionBox,
interceptURL,
verifyResponseStatusCode,
visitEntityDetailsPage,
} from '../../common/common';
import {
DELETE_TERM,
@ -155,6 +156,98 @@ describe('Tags page should work', () => {
addNewTagToEntity(entity, `${NEW_TAG_CATEGORY.name}.${NEW_TAG.name}`);
});
it('Add tag at DatabaseSchema level should work', () => {
const entity = SEARCH_ENTITY_TABLE.table_2;
const term = `${NEW_TAG_CATEGORY.name}.${NEW_TAG.name}`;
const term2 = 'PersonalData.Personal';
visitEntityDetailsPage(entity.term, entity.serviceName, entity.entity);
cy.get('[data-testid="breadcrumb-link"]')
.should('be.visible')
.within(() => {
cy.contains(entity.schemaName).click();
});
cy.get('[data-testid="tags"] > [data-testid="add-tag"]')
.should('be.visible')
.click();
cy.get('[data-testid="tag-selector"] input')
.should('be.visible')
.type(term);
cy.get('.ant-select-item-option-content')
.contains(term)
.should('be.visible')
.click();
cy.get(
'[data-testid="tags-wrapper"] > [data-testid="tag-container"]'
).contains(term);
interceptURL('PATCH', '/api/v1/databaseSchemas/*', 'addTags');
cy.get('[data-testid="saveAssociatedTag"]').should('be.visible').click();
verifyResponseStatusCode('@addTags', 200);
cy.get('[data-testid="entity-tags"]')
.scrollIntoView()
.should('be.visible')
.contains(term);
cy.get('[data-testid="tag-thread-count"]').should('exist').contains(1);
// Create task to add tags
interceptURL('POST', '/api/v1/feed', 'taskCreated');
cy.get('[data-testid="request-entity-tags"] > [data-testid="image"]')
.should('exist')
.click();
cy.get(
'[data-testid="select-tags"] > .ant-select-selector > .ant-select-selection-overflow'
)
.should('be.visible')
.click()
.type(term2);
cy.get('.ant-select-item-option-content').contains(term2).click();
cy.get('[data-testid="tags-label"]').click();
cy.get('[data-testid="submit-test"]').should('be.visible').click();
// Accept the tag suggestion which is created
cy.get('.ant-btn-compact-first-item')
.should('be.visible')
.contains('Accept Suggestion')
.click();
verifyResponseStatusCode('@taskCreated', 201);
cy.get('[data-testid="entity-tags"]')
.scrollIntoView()
.should('be.visible')
.contains(term2);
cy.get('[data-testid="tag-thread-count"]').should('exist').contains(2);
cy.get('[data-testid="edit-button"]').should('exist').click();
// Remove all added tags
cy.get('.ant-select-selection-item-remove')
.eq(0)
.should('be.visible')
.click();
cy.get('.ant-select-selection-item-remove')
.eq(0)
.should('be.visible')
.click();
interceptURL('PATCH', '/api/v1/databaseSchemas/*', 'removeTags');
cy.get('[data-testid="saveAssociatedTag"]').should('be.visible').click();
verifyResponseStatusCode('@removeTags', 200);
cy.get('[data-testid="tags"] > [data-testid="add-tag"]').should(
'be.visible'
);
cy.get('[data-testid="tag-thread-count"]').should('exist').contains(3);
});
it('Check Usage of tag and it should redirect to explore page with tags filter', () => {
interceptURL(
'GET',

View File

@ -83,6 +83,8 @@ interface Props {
currentOwner?: Dashboard['owner'];
removeTier?: () => void;
onRestoreEntity?: () => void;
allowSoftDelete?: boolean;
isRecursiveDelete?: boolean;
}
const EntityPageInfo = ({
@ -114,6 +116,8 @@ const EntityPageInfo = ({
entityFieldTasks,
removeTier,
onRestoreEntity,
isRecursiveDelete = false,
allowSoftDelete,
}: Props) => {
const history = useHistory();
const tagThread = entityFieldThreads?.[0];
@ -443,13 +447,18 @@ const EntityPageInfo = ({
) : null}
{!isVersionSelected && (
<ManageButton
allowSoftDelete={!deleted}
allowSoftDelete={
entityType === EntityType.DATABASE_SCHEMA
? allowSoftDelete
: !deleted
}
canDelete={canDelete}
deleted={deleted}
entityFQN={entityFqn}
entityId={entityId}
entityName={entityName}
entityType={entityType}
isRecursiveDelete={isRecursiveDelete}
onAnnouncementClick={() => setIsAnnouncementDrawer(true)}
onRestoreEntity={onRestoreEntity}
/>

View File

@ -11,19 +11,17 @@
* limitations under the License.
*/
import { Card, Col, Row, Skeleton, Space, Table as TableAntd } from 'antd';
import { Card, Col, Row, Skeleton, Table as TableAntd } from 'antd';
import { ColumnsType } from 'antd/lib/table';
import { AxiosError } from 'axios';
import ActivityFeedList from 'components/ActivityFeed/ActivityFeedList/ActivityFeedList';
import ActivityThreadPanel from 'components/ActivityFeed/ActivityThreadPanel/ActivityThreadPanel';
import Description from 'components/common/description/Description';
import ManageButton from 'components/common/entityPageInfo/ManageButton/ManageButton';
import EntitySummaryDetails from 'components/common/EntitySummaryDetails/EntitySummaryDetails';
import EntityPageInfo from 'components/common/entityPageInfo/EntityPageInfo';
import ErrorPlaceHolder from 'components/common/error-with-placeholder/ErrorPlaceHolder';
import NextPrevious from 'components/common/next-previous/NextPrevious';
import RichTextEditorPreviewer from 'components/common/rich-text-editor/RichTextEditorPreviewer';
import TabsPane from 'components/common/TabsPane/TabsPane';
import TitleBreadcrumb from 'components/common/title-breadcrumb/title-breadcrumb.component';
import { TitleBreadcrumbProps } from 'components/common/title-breadcrumb/title-breadcrumb.interface';
import PageContainerV1 from 'components/containers/PageContainerV1';
import Loader from 'components/Loader/Loader';
@ -33,9 +31,10 @@ import {
ResourceEntity,
} from 'components/PermissionProvider/PermissionProvider.interface';
import { compare, Operation } from 'fast-json-patch';
import { TagLabel } from 'generated/type/tagLabel';
import { isUndefined, startCase, toNumber } from 'lodash';
import { observer } from 'mobx-react';
import { ExtraInfo } from 'Models';
import { EntityTags, ExtraInfo } from 'Models';
import React, {
Fragment,
FunctionComponent,
@ -106,7 +105,11 @@ import {
serviceTypeLogo,
} from '../../utils/ServiceUtils';
import { getErrorText } from '../../utils/StringsUtils';
import { getEntityLink } from '../../utils/TableUtils';
import {
getEntityLink,
getTagsWithoutTier,
getTierTags,
} from '../../utils/TableUtils';
import { showErrorToast } from '../../utils/ToastUtils';
const DatabaseSchemaPage: FunctionComponent = () => {
@ -157,6 +160,9 @@ const DatabaseSchemaPage: FunctionComponent = () => {
const [databaseSchemaPermission, setDatabaseSchemaPermission] =
useState<OperationPermission>(DEFAULT_ENTITY_PERMISSION);
const [tags, setTags] = useState<Array<EntityTags>>([]);
const [tier, setTier] = useState<TagLabel>();
const fetchDatabaseSchemaPermission = async () => {
setIsLoading(true);
try {
@ -253,7 +259,11 @@ const DatabaseSchemaPage: FunctionComponent = () => {
const getDetailsByFQN = () => {
setIsSchemaDetailsLoading(true);
getDatabaseSchemaDetailsByFQN(databaseSchemaFQN, ['owner', 'usageSummary'])
getDatabaseSchemaDetailsByFQN(databaseSchemaFQN, [
'owner',
'usageSummary',
'tags',
])
.then((res) => {
if (res) {
const {
@ -263,11 +273,14 @@ const DatabaseSchemaPage: FunctionComponent = () => {
service,
serviceType,
database,
tags,
} = res;
setDatabaseSchema(res);
setDescription(schemaDescription);
setDatabaseSchemaId(id);
setDatabaseSchemaName(name);
setTags(getTagsWithoutTier(tags || []));
setTier(getTierTags(tags ?? []));
setSlashedTableName([
{
name: startCase(ServiceCategory.DATABASE_SERVICES),
@ -470,6 +483,25 @@ const DatabaseSchemaPage: FunctionComponent = () => {
});
};
const onTagUpdate = async (selectedTags?: Array<EntityTags>) => {
if (selectedTags) {
const updatedTags = [...(tier ? [tier] : []), ...selectedTags];
const updatedData = { ...databaseSchema, tags: updatedTags };
try {
const res = await saveUpdatedDatabaseSchemaData(
updatedData as DatabaseSchema
);
setDatabaseSchema(res);
setTags(getTagsWithoutTier(res.tags || []));
setTier(getTierTags(res.tags ?? []));
getEntityFeedCount();
} catch (error) {
showErrorToast(error as AxiosError, t('server.api-error'));
}
}
};
const fetchActivityFeed = (after?: string) => {
setIsentityThreadLoading(true);
getAllFeeds(
@ -711,40 +743,43 @@ const DatabaseSchemaPage: FunctionComponent = () => {
) : (
<>
<Col span={24}>
<Space align="center" className="justify-between w-full">
<TitleBreadcrumb titleLinks={slashedTableName} />
<ManageButton
isRecursiveDelete
allowSoftDelete={false}
canDelete={databaseSchemaPermission.Delete}
entityFQN={databaseSchemaFQN}
entityId={databaseSchemaId}
entityName={databaseSchemaName}
entityType={EntityType.DATABASE_SCHEMA}
/>
</Space>
</Col>
<Col span={24}>
{extraInfo.map((info, index) => (
<Space key={index}>
<EntitySummaryDetails
currentOwner={databaseSchema?.owner}
data={info}
removeOwner={
databaseSchemaPermission.EditOwner ||
databaseSchemaPermission.EditAll
? handleRemoveOwner
: undefined
}
updateOwner={
databaseSchemaPermission.EditOwner ||
databaseSchemaPermission.EditAll
? handleUpdateOwner
: undefined
}
/>
</Space>
))}
<EntityPageInfo
isRecursiveDelete
allowSoftDelete={false}
canDelete={databaseSchemaPermission.Delete}
currentOwner={databaseSchema?.owner}
entityFieldThreads={getEntityFieldThreadCounts(
EntityField.TAGS,
entityFieldThreadCount
)}
entityFqn={databaseSchemaFQN}
entityId={databaseSchemaId}
entityName={databaseSchemaName}
entityType={EntityType.DATABASE_SCHEMA}
extraInfo={extraInfo}
followersList={[]}
isTagEditable={
databaseSchemaPermission.EditAll ||
databaseSchemaPermission.EditTags
}
removeOwner={
databaseSchemaPermission.EditOwner ||
databaseSchemaPermission.EditAll
? handleRemoveOwner
: undefined
}
tags={tags}
tagsHandler={onTagUpdate}
tier={tier}
titleLinks={slashedTableName}
updateOwner={
databaseSchemaPermission.EditOwner ||
databaseSchemaPermission.EditAll
? handleUpdateOwner
: undefined
}
onThreadLinkSelect={onThreadLinkSelect}
/>
</Col>
<Col data-testid="description-container" span={24}>
<Description

View File

@ -46,14 +46,6 @@ jest.mock('components/containers/PageContainerV1', () =>
))
);
jest.mock('components/common/title-breadcrumb/title-breadcrumb.component', () =>
jest
.fn()
.mockImplementation(() => (
<div data-testid="TitleBreadcrumb">titleBreadcrumb</div>
))
);
jest.mock('components/common/TabsPane/TabsPane', () =>
jest.fn().mockImplementation(() => <div data-testid="TabsPane">TabsPane</div>)
);
@ -81,19 +73,12 @@ jest.mock('components/common/error-with-placeholder/ErrorPlaceHolder', () =>
<div data-testid="ErrorPlaceHolder">{children}</div>
))
);
jest.mock('components/common/EntitySummaryDetails/EntitySummaryDetails', () =>
jest
.fn()
.mockImplementation(() => (
<div data-testid="EntitySummaryDetails">EntitySummaryDetails</div>
))
);
jest.mock('components/common/entityPageInfo/ManageButton/ManageButton', () =>
jest.mock('components/common/entityPageInfo/EntityPageInfo', () =>
jest
.fn()
.mockImplementation(() => (
<div data-testid="ManageButton">ManageButton</div>
<div data-testid="entityPageInfo">EntityPageInfo</div>
))
);
@ -205,15 +190,11 @@ describe('Tests for DatabaseSchemaPage', () => {
});
const pageContainer = await screen.findByTestId('PageContainer');
const titleBreadcrumb = await screen.findByTestId('TitleBreadcrumb');
const entityPageInfo = await screen.findByTestId('entityPageInfo');
const tabsPane = await screen.findByTestId('TabsPane');
const richTextEditorPreviewer = await screen.findAllByTestId(
'RichTextEditorPreviewer'
);
const entitySummaryDetails = await screen.findByTestId(
'EntitySummaryDetails'
);
const manageButton = await screen.findByTestId('ManageButton');
const description = await screen.findByTestId('Description');
const nextPrevious = await screen.findByTestId('NextPrevious');
const databaseSchemaTable = await screen.findByTestId(
@ -221,11 +202,9 @@ describe('Tests for DatabaseSchemaPage', () => {
);
expect(pageContainer).toBeInTheDocument();
expect(titleBreadcrumb).toBeInTheDocument();
expect(entityPageInfo).toBeInTheDocument();
expect(tabsPane).toBeInTheDocument();
expect(richTextEditorPreviewer).toHaveLength(10);
expect(entitySummaryDetails).toBeInTheDocument();
expect(manageButton).toBeInTheDocument();
expect(description).toBeInTheDocument();
expect(nextPrevious).toBeInTheDocument();
expect(databaseSchemaTable).toBeInTheDocument();

View File

@ -11,13 +11,20 @@
* limitations under the License.
*/
import { DatabaseSchema } from 'generated/entity/data/databaseSchema';
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';
export type EntityData = Table | Topic | Dashboard | Pipeline | Mlmodel;
export type EntityData =
| Table
| Topic
| Dashboard
| Pipeline
| Mlmodel
| DatabaseSchema;
export interface Option {
label: string;

View File

@ -60,3 +60,6 @@ export const getQueryStringForSchemaTables = (
schemaName: DatabaseSchema
) =>
`(service.name:${serviceName.name}) AND (database.name:${databaseName.name}) AND (databaseSchema.name:${schemaName.name})`;
export const defaultFields = `${TabSpecificField.TAGS}, ${TabSpecificField.OWNER},
${TabSpecificField.USAGE_SUMMARY}`;

View File

@ -22,6 +22,7 @@ import {
TaskActionMode,
} from 'pages/TasksPage/TasksPage.interface';
import { getDashboardByFqn } from 'rest/dashboardAPI';
import { getDatabaseSchemaDetailsByFQN } from 'rest/databaseAPI';
import { getUserSuggestions } from 'rest/miscAPI';
import { getMlModelByFQN } from 'rest/mlModelAPI';
import { getPipelineByFqn } from 'rest/pipelineAPI';
@ -42,6 +43,7 @@ import { Column, Table } from '../generated/entity/data/table';
import { TaskType } from '../generated/entity/feed/thread';
import { getEntityName, getPartialNameFromTableFQN } from './CommonUtils';
import { defaultFields as DashboardFields } from './DashboardDetailsUtils';
import { defaultFields as DatabaseSchemaFields } from './DatabaseSchemaDetailsUtils';
import { defaultFields as TableFields } from './DatasetDetailsUtils';
import { defaultFields as MlModelFields } from './MlModelDetailsUtils';
import { defaultFields as PipelineFields } from './PipelineDetailsUtils';
@ -185,6 +187,7 @@ export const TASK_ENTITIES = [
EntityType.TOPIC,
EntityType.PIPELINE,
EntityType.MLMODEL,
EntityType.DATABASE_SCHEMA,
];
export const getBreadCrumbList = (
@ -254,6 +257,14 @@ export const getBreadCrumbList = (
return [service(ServiceCategory.ML_MODEL_SERVICES), activeEntity];
}
case EntityType.DATABASE_SCHEMA: {
return [
service(ServiceCategory.DATABASE_SERVICES),
database,
activeEntity,
];
}
default:
return [];
}
@ -306,6 +317,15 @@ export const fetchEntityDetail = (
break;
case EntityType.DATABASE_SCHEMA:
getDatabaseSchemaDetailsByFQN(entityFQN, DatabaseSchemaFields)
.then((res) => {
setEntityData(res);
})
.catch((err: AxiosError) => showErrorToast(err));
break;
default:
break;
}