From c0fe8ec8714c6746b9f54a49a8cd6dab1755c83a Mon Sep 17 00:00:00 2001 From: Ashish Gupta Date: Mon, 13 Mar 2023 22:24:35 +0530 Subject: [PATCH] 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 --- .../service/jdbi3/FeedRepository.java | 29 ++++- .../ui/cypress/constants/constants.js | 1 + .../ui/cypress/e2e/Pages/Tags.spec.js | 93 ++++++++++++++ .../common/entityPageInfo/EntityPageInfo.tsx | 11 +- .../DatabaseSchemaPage.component.tsx | 117 ++++++++++++------ .../DatabaseSchemaPage.test.tsx | 29 +---- .../pages/TasksPage/TasksPage.interface.ts | 9 +- .../src/utils/DatabaseSchemaDetailsUtils.ts | 3 + .../main/resources/ui/src/utils/TasksUtils.ts | 20 +++ 9 files changed, 243 insertions(+), 69 deletions(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/FeedRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/FeedRepository.java index a74076377ee..59bec1607f8 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/FeedRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/FeedRepository.java @@ -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 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."); } } } diff --git a/openmetadata-ui/src/main/resources/ui/cypress/constants/constants.js b/openmetadata-ui/src/main/resources/ui/cypress/constants/constants.js index 377e99efd86..0b7b59e8c9c 100644 --- a/openmetadata-ui/src/main/resources/ui/cypress/constants/constants.js +++ b/openmetadata-ui/src/main/resources/ui/cypress/constants/constants.js @@ -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', diff --git a/openmetadata-ui/src/main/resources/ui/cypress/e2e/Pages/Tags.spec.js b/openmetadata-ui/src/main/resources/ui/cypress/e2e/Pages/Tags.spec.js index f7d2b854c1a..3d7c9c496c7 100644 --- a/openmetadata-ui/src/main/resources/ui/cypress/e2e/Pages/Tags.spec.js +++ b/openmetadata-ui/src/main/resources/ui/cypress/e2e/Pages/Tags.spec.js @@ -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', diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/entityPageInfo/EntityPageInfo.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/entityPageInfo/EntityPageInfo.tsx index 38732448e02..ee869e912d3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/entityPageInfo/EntityPageInfo.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/entityPageInfo/EntityPageInfo.tsx @@ -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 && ( setIsAnnouncementDrawer(true)} onRestoreEntity={onRestoreEntity} /> diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseSchemaPage/DatabaseSchemaPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseSchemaPage/DatabaseSchemaPage.component.tsx index a0940c893a5..26f572f5c49 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseSchemaPage/DatabaseSchemaPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseSchemaPage/DatabaseSchemaPage.component.tsx @@ -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(DEFAULT_ENTITY_PERMISSION); + const [tags, setTags] = useState>([]); + const [tier, setTier] = useState(); + 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) => { + 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 = () => { ) : ( <> - - - - - - - {extraInfo.map((info, index) => ( - - - - ))} + )) ); -jest.mock('components/common/title-breadcrumb/title-breadcrumb.component', () => - jest - .fn() - .mockImplementation(() => ( -
titleBreadcrumb
- )) -); - jest.mock('components/common/TabsPane/TabsPane', () => jest.fn().mockImplementation(() =>
TabsPane
) ); @@ -81,19 +73,12 @@ jest.mock('components/common/error-with-placeholder/ErrorPlaceHolder', () =>
{children}
)) ); -jest.mock('components/common/EntitySummaryDetails/EntitySummaryDetails', () => - jest - .fn() - .mockImplementation(() => ( -
EntitySummaryDetails
- )) -); -jest.mock('components/common/entityPageInfo/ManageButton/ManageButton', () => +jest.mock('components/common/entityPageInfo/EntityPageInfo', () => jest .fn() .mockImplementation(() => ( -
ManageButton
+
EntityPageInfo
)) ); @@ -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(); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TasksPage/TasksPage.interface.ts b/openmetadata-ui/src/main/resources/ui/src/pages/TasksPage/TasksPage.interface.ts index c2cd45bf3b2..be7ec3445d7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TasksPage/TasksPage.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TasksPage/TasksPage.interface.ts @@ -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; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/DatabaseSchemaDetailsUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/DatabaseSchemaDetailsUtils.ts index 5dc3afef3e8..12786cbe3ea 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/DatabaseSchemaDetailsUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/DatabaseSchemaDetailsUtils.ts @@ -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}`; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/TasksUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/TasksUtils.ts index 1ce42289743..fde7bf5d5f8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/TasksUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/TasksUtils.ts @@ -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; }