diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TagRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TagRepository.java index 66f80d2dfce..1ae75c518af 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TagRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TagRepository.java @@ -83,10 +83,11 @@ public class TagRepository extends EntityRepository { @Override public void setInheritedFields(Tag tag, Fields fields) { Classification parent = - Entity.getEntity(CLASSIFICATION, tag.getClassification().getId(), "", ALL); + Entity.getEntity(CLASSIFICATION, tag.getClassification().getId(), "owners", ALL); if (parent.getDisabled() != null && parent.getDisabled()) { tag.setDisabled(true); } + inheritOwners(tag, fields, parent); } @Override diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/tags/TagResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/tags/TagResource.java index bcf21680e2c..9aef28141a4 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/tags/TagResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/tags/TagResource.java @@ -93,7 +93,7 @@ public class TagResource extends EntityResource { private final ClassificationMapper classificationMapper = new ClassificationMapper(); private final TagMapper mapper = new TagMapper(); public static final String TAG_COLLECTION_PATH = "/v1/tags/"; - static final String FIELDS = "children,usageCount"; + static final String FIELDS = "owners,children,usageCount"; static class TagList extends ResultList { /* Required for serde */ @@ -105,7 +105,7 @@ public class TagResource extends EntityResource { @Override protected List getEntitySpecificOperations() { - addViewOperation("children,usageCount", MetadataOperation.VIEW_BASIC); + addViewOperation("owners,children,usageCount", MetadataOperation.VIEW_BASIC); return null; } diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/resources/tags/TagResourceTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/resources/tags/TagResourceTest.java index 8c2a19f8754..1e50700babf 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/resources/tags/TagResourceTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/resources/tags/TagResourceTest.java @@ -18,11 +18,14 @@ import static jakarta.ws.rs.core.Response.Status.CREATED; import static jakarta.ws.rs.core.Response.Status.NOT_FOUND; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.openmetadata.common.utils.CommonUtil.listOf; import static org.openmetadata.common.utils.CommonUtil.listOrEmpty; +import static org.openmetadata.service.Entity.FIELD_OWNERS; import static org.openmetadata.service.exception.CatalogExceptionMessage.entityNotFound; +import static org.openmetadata.service.util.EntityUtil.fieldAdded; import static org.openmetadata.service.util.EntityUtil.fieldUpdated; import static org.openmetadata.service.util.TestUtils.ADMIN_AUTH_HEADERS; import static org.openmetadata.service.util.TestUtils.UpdateType.MINOR_UPDATE; @@ -510,6 +513,96 @@ public class TagResourceTest extends EntityResourceTest { new TagLabel().withTagFQN(getTag.getFullyQualifiedName()))); } + @Test + void test_ownerInheritance(TestInfo test) throws IOException { + // Create a classification with owners + CreateClassification create = classificationResourceTest.createRequest(getEntityName(test)); + Classification classification = + classificationResourceTest.createAndCheckEntity(create, ADMIN_AUTH_HEADERS); + assertTrue( + listOrEmpty(classification.getOwners()).isEmpty(), + "Classification should have no owners initially"); + + // Update classification owners as admin using PATCH + String json = JsonUtils.pojoToJson(classification); + classification.setOwners(List.of(USER1.getEntityReference())); + ChangeDescription change = getChangeDescription(classification, MINOR_UPDATE); + fieldAdded(change, FIELD_OWNERS, List.of(USER1.getEntityReference())); + Classification createdClassification = + classificationResourceTest.patchEntityAndCheck( + classification, json, ADMIN_AUTH_HEADERS, MINOR_UPDATE, change); + assertEquals( + 1, + listOrEmpty(createdClassification.getOwners()).size(), + "Classification should have one owner"); + assertEquals( + USER1.getId(), + createdClassification.getOwners().getFirst().getId(), + "Owner should match USER1"); + + // Create a tag under the classification + String tagName = "TestTagForInheritance"; + CreateTag createTag = + createRequest(tagName).withClassification(createdClassification.getName()); + Tag tag = createEntity(createTag, ADMIN_AUTH_HEADERS); + + // Verify that the tag inherited owners from classification + Tag getTag = getEntity(tag.getId(), FIELD_OWNERS, ADMIN_AUTH_HEADERS); + assertNotNull(getTag.getOwners(), "Tag should have inherited owners"); + assertEquals( + classification.getOwners().size(), + getTag.getOwners().size(), + "Tag should have inherited the correct number of owners"); + assertEquals( + USER1_REF.getId(), + getTag.getOwners().getFirst().getId(), + "Tag should have inherited the correct owner"); + assertTrue(getTag.getOwners().getFirst().getInherited(), "Owner should be marked as inherited"); + + // Update classification owners - replace existing owner with a new one + List previousOwners = new ArrayList<>(classification.getOwners()); + String classificationJson = JsonUtils.pojoToJson(classification); + classification.setOwners(List.of(USER2.getEntityReference())); + change = getChangeDescription(classification, MINOR_UPDATE); + fieldUpdated(change, FIELD_OWNERS, previousOwners, classification.getOwners()); + classification = + classificationResourceTest.patchEntity( + classification.getId(), classificationJson, classification, ADMIN_AUTH_HEADERS); + + // Verify that the tag's owners were updated + getTag = getEntity(tag.getId(), FIELD_OWNERS, ADMIN_AUTH_HEADERS); + assertNotNull(getTag.getOwners(), "Tag should have updated owners"); + assertEquals( + classification.getOwners().size(), + getTag.getOwners().size(), + "Tag should have inherited the correct number of owners after update"); + assertEquals( + USER2_REF.getId(), + getTag.getOwners().getFirst().getId(), + "Tag should have the updated owner"); + assertTrue(getTag.getOwners().getFirst().getInherited(), "Owner should be marked as inherited"); + + // Test that tags with explicit owners don't get updated + String tagWithOwnersName = "TagWithOwners"; + CreateTag createTagWithOwners = + createRequest(tagWithOwnersName) + .withClassification(classification.getName()) + .withOwners(List.of(USER1_REF)); + Tag tagWithOwners = createEntity(createTagWithOwners, ADMIN_AUTH_HEADERS); + + // Verify that the tag is having both inherited owner USER2 as well as explicit owner USER1 + Tag getTagWithOwners = getEntity(tagWithOwners.getId(), FIELD_OWNERS, ADMIN_AUTH_HEADERS); + assertNotNull(getTagWithOwners.getOwners(), "Tag should have owners"); + assertEquals(1, getTagWithOwners.getOwners().size(), "Tag should have one owner"); + assertEquals( + USER1_REF.getId(), + getTagWithOwners.getOwners().getFirst().getId(), + "Tag should have kept its original owner"); + assertNull( + getTagWithOwners.getOwners().getFirst().getInherited(), + "Owner should not be marked as inherited"); + } + public Tag createTag( String name, String classification, String parentFqn, String... associatedTags) throws IOException { diff --git a/openmetadata-spec/src/main/resources/json/schema/api/classification/createTag.json b/openmetadata-spec/src/main/resources/json/schema/api/classification/createTag.json index d6124631a93..811663d34c7 100644 --- a/openmetadata-spec/src/main/resources/json/schema/api/classification/createTag.json +++ b/openmetadata-spec/src/main/resources/json/schema/api/classification/createTag.json @@ -48,6 +48,10 @@ "domain" : { "description": "Fully qualified name of the domain the Table belongs to.", "type": "string" + }, + "owners": { + "description": "Owners of this glossary term.", + "$ref": "../../type/entityReferenceList.json" } }, "required": ["name", "description"], diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/classification/classification.json b/openmetadata-spec/src/main/resources/json/schema/entity/classification/classification.json index 51ae54efb61..87c2e93d010 100644 --- a/openmetadata-spec/src/main/resources/json/schema/entity/classification/classification.json +++ b/openmetadata-spec/src/main/resources/json/schema/entity/classification/classification.json @@ -86,6 +86,6 @@ "$ref": "../../type/entityReferenceList.json" } }, - "required": ["name", "description"], + "required": ["id", "name", "description"], "additionalProperties": false } diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/classification/tag.json b/openmetadata-spec/src/main/resources/json/schema/entity/classification/tag.json index 19b5f708c25..28596be120c 100644 --- a/openmetadata-spec/src/main/resources/json/schema/entity/classification/tag.json +++ b/openmetadata-spec/src/main/resources/json/schema/entity/classification/tag.json @@ -103,9 +103,14 @@ "dataProducts" : { "description": "List of data products this entity is part of.", "$ref" : "../../type/entityReferenceList.json" + }, + "owners": { + "description": "Owners of this glossary term.", + "$ref": "../../type/entityReferenceList.json" } }, "required": [ + "id", "name", "description" ], diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Tag.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Tag.spec.ts index c07af7d8416..3f43c716ec2 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Tag.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Tag.spec.ts @@ -14,6 +14,7 @@ import { expect, Page, test as base } from '@playwright/test'; import { PolicyClass } from '../../support/access-control/PoliciesClass'; import { RolesClass } from '../../support/access-control/RolesClass'; import { Domain } from '../../support/domain/Domain'; +import { EntityTypeEndpoint } from '../../support/entity/Entity.interface'; import { ClassificationClass } from '../../support/tag/ClassificationClass'; import { TagClass } from '../../support/tag/TagClass'; import { TeamClass } from '../../support/team/TeamClass'; @@ -25,6 +26,7 @@ import { redirectToHomePage, uuid, } from '../../utils/common'; +import { addMultiOwner, removeOwner } from '../../utils/entity'; import { addAssetsToTag, editTagPageDescription, @@ -38,6 +40,7 @@ import { verifyCertificationTagPageUI, verifyTagPageUI, } from '../../utils/tag'; +import { visitUserProfilePage } from '../../utils/user'; const adminUser = new UserClass(); const dataConsumerUser = new UserClass(); @@ -51,6 +54,12 @@ const classification = new ClassificationClass({ const tag = new TagClass({ classification: classification.data.name, }); +const classification1 = new ClassificationClass(); +const tag1 = new TagClass({ + classification: classification1.data.name, +}); +const user1 = new UserClass(); +const domain = new Domain(); const test = base.extend<{ adminPage: Page; @@ -93,7 +102,11 @@ base.beforeAll('Setup pre-requests', async ({ browser }) => { await dataStewardUser.setDataStewardRole(apiContext); await limitedAccessUser.create(apiContext); await classification.create(apiContext); + await classification1.create(apiContext); await tag.create(apiContext); + await tag1.create(apiContext); + await user1.create(apiContext); + await domain.create(apiContext); await afterAction(); }); @@ -104,27 +117,17 @@ base.afterAll('Cleanup', async ({ browser }) => { await dataStewardUser.delete(apiContext); await limitedAccessUser.delete(apiContext); await classification.delete(apiContext); + await classification1.delete(apiContext); await tag.delete(apiContext); + await tag1.delete(apiContext); + await user1.delete(apiContext); + await domain.delete?.(apiContext); await afterAction(); }); test.describe('Tag Page with Admin Roles', () => { test.slow(true); - let domain: Domain; - - test.beforeAll(async ({ browser }) => { - const { apiContext } = await performAdminLogin(browser); - domain = new Domain(); - await domain.create(apiContext); - }); - - test.afterAll(async ({ browser }) => { - const { apiContext, afterAction } = await performAdminLogin(browser); - await domain.delete?.(apiContext); - await afterAction(); - }); - test('Verify Tag UI', async ({ adminPage }) => { await verifyTagPageUI(adminPage, classification.data.name, tag); }); @@ -279,6 +282,47 @@ test.describe('Tag Page with Admin Roles', () => { domain.data.displayName ); }); + + test('Verify Owner Add Delete', async ({ adminPage }) => { + await tag1.visitPage(adminPage); + const OWNER1 = user1.getUserName(); + + await addMultiOwner({ + page: adminPage, + ownerNames: [OWNER1], + activatorBtnDataTestId: 'add-owner', + resultTestId: 'tag-owner-name', + endpoint: EntityTypeEndpoint.Tag, + isSelectableInsideForm: false, + type: 'Users', + }); + + // Verify in My Data page + await visitUserProfilePage(adminPage, user1.responseData.name); + await adminPage.waitForLoadState('networkidle'); + + const myDataRes = adminPage.waitForResponse( + `/api/v1/search/query?q=*&index=all&from=0&size=15` + ); + await adminPage.getByTestId('mydata').click(); + await myDataRes; + + await expect( + adminPage.getByTestId( + `table-data-card_${tag1?.responseData?.fullyQualifiedName}` + ) + ).toBeVisible(); + + await tag1.visitPage(adminPage); + + await removeOwner({ + page: adminPage, + endpoint: EntityTypeEndpoint.Tag, + ownerName: OWNER1, + type: 'Users', + dataTestId: 'tag-owner-name', + }); + }); }); test.describe('Tag Page with Data Consumer Roles', () => { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Tags.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Tags.spec.ts index 6334038d5c3..d4b07c5ce58 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Tags.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Tags.spec.ts @@ -12,9 +12,11 @@ */ import { expect, Page, test } from '@playwright/test'; import { SidebarItem } from '../../constant/sidebar'; +import { EntityTypeEndpoint } from '../../support/entity/Entity.interface'; import { TableClass } from '../../support/entity/TableClass'; import { ClassificationClass } from '../../support/tag/ClassificationClass'; import { TagClass } from '../../support/tag/TagClass'; +import { UserClass } from '../../support/user/UserClass'; import { clickOutside, createNewPage, @@ -22,6 +24,7 @@ import { redirectToHomePage, uuid, } from '../../utils/common'; +import { addMultiOwner, removeOwner } from '../../utils/entity'; import { sidebarClick } from '../../utils/sidebar'; import { addTagToTableColumn, submitForm, validateForm } from '../../utils/tag'; @@ -66,11 +69,20 @@ const tag = new TagClass({ classification: classification.data.name, }); +const classification1 = new ClassificationClass(); +const tag1 = new TagClass({ + classification: classification1.data.name, +}); +const user1 = new UserClass(); + test.beforeAll(async ({ browser }) => { const { apiContext, afterAction } = await createNewPage(browser); await table.create(apiContext); await classification.create(apiContext); + await classification1.create(apiContext); await tag.create(apiContext); + await tag1.create(apiContext); + await user1.create(apiContext); await afterAction(); }); @@ -78,7 +90,10 @@ test.afterAll(async ({ browser }) => { const { apiContext, afterAction } = await createNewPage(browser); await table.delete(apiContext); await classification.delete(apiContext); + await classification1.delete(apiContext); await tag.delete(apiContext); + await tag1.delete(apiContext); + await user1.delete(apiContext); await afterAction(); }); @@ -430,6 +445,9 @@ test.fixme('Classification Page', async ({ page }) => { await page.click('[data-testid="confirm-button"]'); await deleteClassification; + await user1.visitPage(page); + await page.waitForLoadState('networkidle'); + await expect( page .locator('[data-testid="data-summary-container"]') @@ -486,3 +504,36 @@ test('Search tag using classification display name should work', async ({ page.getByTestId(`tag-${tag.responseData.fullyQualifiedName}`) ).toBeVisible(); }); + +test('Verify Owner Add Delete', async ({ page }) => { + await classification1.visitPage(page); + const OWNER1 = user1.getUserName(); + + await addMultiOwner({ + page, + ownerNames: [OWNER1], + activatorBtnDataTestId: 'add-owner', + resultTestId: 'classification-owner-name', + endpoint: EntityTypeEndpoint.Classification, + isSelectableInsideForm: false, + type: 'Users', + }); + + await page.getByTestId(tag1.data.name).click(); + await page.waitForLoadState('networkidle'); + + await expect( + page.locator(`[data-testid="tag-owner-name"]`).getByTestId(OWNER1) + ).toBeVisible(); + + await classification1.visitPage(page); + await page.waitForLoadState('networkidle'); + + await removeOwner({ + page, + endpoint: EntityTypeEndpoint.Classification, + ownerName: OWNER1, + type: 'Users', + dataTestId: 'classification-owner-name', + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/Entity.interface.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/Entity.interface.ts index f52925652af..1591d346ac1 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/Entity.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/Entity.interface.ts @@ -43,6 +43,8 @@ export enum EntityTypeEndpoint { TestSuites = 'dataQuality/testSuites', Topic = 'topics', User = 'users', + Classification = 'classifications', + Tag = 'tags', } export type EntityDataType = { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/tag.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/tag.ts index 9259ba94390..d01636a84fd 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/tag.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/tag.ts @@ -486,7 +486,10 @@ export const fillTagForm = async (adminPage: Page, domain: Domain) => { await adminPage.locator(descriptionBox).fill(NEW_TAG.description); await adminPage.fill('[data-testid="icon-url"]', NEW_TAG.icon); await adminPage.fill('[data-testid="tags_color-color-input"]', NEW_TAG.color); - await adminPage.click('[data-testid="add-domain"]'); + + await adminPage.click( + '[data-testid="modal-container"] [data-testid="add-domain"]' + ); await adminPage .getByTestId(`tag-${domain.responseData.fullyQualifiedName}`) .click(); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Classifications/ClassificationDetails/ClassificationDetails.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Classifications/ClassificationDetails/ClassificationDetails.tsx index 1b2627f52ce..a82e05f3375 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Classifications/ClassificationDetails/ClassificationDetails.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Classifications/ClassificationDetails/ClassificationDetails.tsx @@ -11,7 +11,7 @@ * limitations under the License. */ import Icon from '@ant-design/icons/lib/components/Icon'; -import { Button, Col, Row, Space, Tooltip, Typography } from 'antd'; +import { Button, Card, Col, Row, Space, Tooltip, Typography } from 'antd'; import ButtonGroup from 'antd/lib/button/button-group'; import { ColumnsType } from 'antd/lib/table'; import { AxiosError } from 'axios'; @@ -31,12 +31,11 @@ import { ReactComponent as IconTag } from '../../../assets/svg/classification.sv import { ReactComponent as LockIcon } from '../../../assets/svg/closed-lock.svg'; import { ReactComponent as VersionIcon } from '../../../assets/svg/ic-version.svg'; import { DE_ACTIVE_COLOR } from '../../../constants/constants'; -import { EntityField } from '../../../constants/Feeds.constants'; +import { CustomizeEntityType } from '../../../constants/Customize.constants'; import { usePermissionProvider } from '../../../context/PermissionProvider/PermissionProvider'; import { ResourceEntity } from '../../../context/PermissionProvider/PermissionProvider.interface'; import { EntityType, TabSpecificField } from '../../../enums/entity.enum'; -import { ProviderType } from '../../../generated/api/classification/createClassification'; -import { ChangeDescription } from '../../../generated/entity/classification/classification'; +import { Classification } from '../../../generated/entity/classification/classification'; import { Tag } from '../../../generated/entity/classification/tag'; import { Operation } from '../../../generated/entity/policies/policy'; import { Paging } from '../../../generated/type/paging'; @@ -46,10 +45,10 @@ import { useFqn } from '../../../hooks/useFqn'; import { getTags } from '../../../rest/tagAPI'; import { getClassificationExtraDropdownContent, + getClassificationInfo, getTagsTableColumn, } from '../../../utils/ClassificationUtils'; import { getEntityName } from '../../../utils/EntityUtils'; -import { getEntityVersionByField } from '../../../utils/EntityVersionUtils'; import { checkPermission } from '../../../utils/PermissionsUtils'; import { getClassificationDetailsPath, @@ -63,7 +62,11 @@ import ManageButton from '../../common/EntityPageInfos/ManageButton/ManageButton import ErrorPlaceHolder from '../../common/ErrorWithPlaceholder/ErrorPlaceHolder'; import { NextPreviousProps } from '../../common/NextPrevious/NextPrevious.interface'; import Table from '../../common/Table/Table'; +import { GenericProvider } from '../../Customization/GenericProvider/GenericProvider'; +import { DomainLabelV2 } from '../../DataAssets/DomainLabelV2/DomainLabelV2'; +import { OwnerLabelV2 } from '../../DataAssets/OwnerLabelV2/OwnerLabelV2'; import EntityHeaderTitle from '../../Entity/EntityHeaderTitle/EntityHeaderTitle.component'; +import './classification-details.less'; import { ClassificationDetailsProps } from './ClassificationDetails.interface'; const ClassificationDetails = forwardRef( @@ -143,15 +146,17 @@ const ClassificationDetails = forwardRef( handlePageChange(currentPage); }; - const currentVersion = useMemo( - () => currentClassification?.version ?? '0.1', - [currentClassification] - ); - - const changeDescription = useMemo( - () => - currentClassification?.changeDescription ?? ({} as ChangeDescription), - [currentClassification] + const { + currentVersion, + isClassificationDisabled, + name, + displayName, + description, + isTier, + isSystemClassification, + } = useMemo( + () => getClassificationInfo(currentClassification, isVersionView), + [currentClassification, isVersionView] ); const versionHandler = useCallback(() => { @@ -165,83 +170,37 @@ const ClassificationDetails = forwardRef( ); }, [currentVersion, tagCategoryName]); - const isTier = useMemo( - () => currentClassification?.name === 'Tier', - [currentClassification] - ); - - const createTagPermission = useMemo( - () => - checkPermission(Operation.Create, ResourceEntity.TAG, permissions) || - classificationPermissions.EditAll, - [permissions, classificationPermissions] - ); - - const editClassificationPermission = useMemo( - () => classificationPermissions.EditAll, - [classificationPermissions] - ); - - const isClassificationDisabled = useMemo( - () => currentClassification?.disabled ?? false, - [currentClassification?.disabled] - ); - - const handleUpdateDisplayName = async (data: { - name: string; - displayName?: string; - }) => { - if ( - !isUndefined(currentClassification) && - !isUndefined(handleUpdateClassification) - ) { - return handleUpdateClassification({ - ...currentClassification, - ...data, - }); - } - }; - - const handleUpdateDescription = async (updatedHTML: string) => { - if ( - !isUndefined(currentClassification) && - !isUndefined(handleUpdateClassification) - ) { - handleUpdateClassification({ - ...currentClassification, - description: updatedHTML, - }); - } - }; - - const handleEnableDisableClassificationClick = useCallback(() => { - if ( - !isUndefined(currentClassification) && - !isUndefined(handleUpdateClassification) - ) { - handleUpdateClassification({ - ...currentClassification, - disabled: !isClassificationDisabled, - }); - } - }, [ - currentClassification, - handleUpdateClassification, - isClassificationDisabled, - ]); - - const editDescriptionPermission = useMemo( - () => - !isVersionView && - !isClassificationDisabled && - (classificationPermissions.EditAll || - classificationPermissions.EditDescription), - [classificationPermissions, isVersionView] - ); - - const isSystemClassification = useMemo( - () => currentClassification?.provider === ProviderType.System, - [currentClassification] + const { + editClassificationPermission, + editDescriptionPermission, + createPermission, + deletePermission, + editDisplayNamePermission, + } = useMemo( + () => ({ + editClassificationPermission: classificationPermissions.EditAll, + editDescriptionPermission: + !isVersionView && + !isClassificationDisabled && + (classificationPermissions.EditAll || + classificationPermissions.EditDescription), + createPermission: + !isVersionView && + (checkPermission(Operation.Create, ResourceEntity.TAG, permissions) || + classificationPermissions.EditAll), + deletePermission: + classificationPermissions.Delete && !isSystemClassification, + editDisplayNamePermission: + classificationPermissions.EditAll || + classificationPermissions.EditDisplayName, + }), + [ + permissions, + classificationPermissions, + isVersionView, + isClassificationDisabled, + isSystemClassification, + ] ); const headerBadge = useMemo( @@ -255,25 +214,6 @@ const ClassificationDetails = forwardRef( [isSystemClassification, currentClassification] ); - const createPermission = useMemo( - () => - !isVersionView && - (createTagPermission || classificationPermissions.EditAll), - [classificationPermissions, createTagPermission, isVersionView] - ); - - const deletePermission = useMemo( - () => classificationPermissions.Delete && !isSystemClassification, - [classificationPermissions, isSystemClassification] - ); - - const editDisplayNamePermission = useMemo( - () => - classificationPermissions.EditAll || - classificationPermissions.EditDisplayName, - [classificationPermissions] - ); - const showDisableOption = useMemo( () => !isTier && isSystemClassification && editClassificationPermission, [isTier, isSystemClassification, editClassificationPermission] @@ -291,6 +231,40 @@ const ClassificationDetails = forwardRef( ] ); + const handleUpdateDisplayName = async (data: { + name: string; + displayName?: string; + }) => { + if (!isUndefined(currentClassification)) { + return handleUpdateClassification?.({ + ...currentClassification, + ...data, + }); + } + }; + + const handleUpdateDescription = async (updatedHTML: string) => { + if (!isUndefined(currentClassification)) { + handleUpdateClassification?.({ + ...currentClassification, + description: updatedHTML, + }); + } + }; + + const handleEnableDisableClassificationClick = useCallback(() => { + if (!isUndefined(currentClassification)) { + handleUpdateClassification?.({ + ...currentClassification, + disabled: !isClassificationDisabled, + }); + } + }, [ + currentClassification, + handleUpdateClassification, + isClassificationDisabled, + ]); + const addTagButtonToolTip = useMemo(() => { if (isClassificationDisabled) { return t('message.disabled-classification-actions-message'); @@ -338,36 +312,6 @@ const ClassificationDetails = forwardRef( ] ); - const name = useMemo(() => { - return isVersionView - ? getEntityVersionByField( - changeDescription, - EntityField.NAME, - currentClassification?.name - ) - : currentClassification?.name; - }, [currentClassification, changeDescription]); - - const displayName = useMemo(() => { - return isVersionView - ? getEntityVersionByField( - changeDescription, - EntityField.DISPLAYNAME, - currentClassification?.displayName - ) - : currentClassification?.displayName; - }, [currentClassification, changeDescription]); - - const description = useMemo(() => { - return isVersionView - ? getEntityVersionByField( - changeDescription, - EntityField.DESCRIPTION, - currentClassification?.description - ) - : currentClassification?.description; - }, [currentClassification, changeDescription]); - useEffect(() => { if (currentClassification?.fullyQualifiedName && !isAddingTag) { fetchClassificationChildren(currentClassification.fullyQualifiedName); @@ -472,51 +416,77 @@ const ClassificationDetails = forwardRef( )} -
- -
- - ), - }} - pagination={false} - rowClassName={(record) => (record.disabled ? 'opacity-60' : '')} - rowKey="id" - size="small" - /> + + data={currentClassification as Classification} + isVersionView={isVersionView} + permissions={classificationPermissions} + type={EntityType.CLASSIFICATION as CustomizeEntityType} + onUpdate={(updatedData: Classification) => + Promise.resolve(handleUpdateClassification?.(updatedData)) + }> + + + +
+ +
+ +
+ ), + }} + pagination={false} + rowClassName={(record) => + record.disabled ? 'opacity-60' : '' + } + rowKey="id" + scroll={{ x: true }} + size="small" + /> + + + +
+ + +
+ + + ); } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Classifications/ClassificationDetails/classification-details.less b/openmetadata-ui/src/main/resources/ui/src/components/Classifications/ClassificationDetails/classification-details.less new file mode 100644 index 00000000000..f78059fe955 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Classifications/ClassificationDetails/classification-details.less @@ -0,0 +1,17 @@ +/* + * Copyright 2025 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. + */ +.classification-details-card { + > .ant-card-body { + padding: 16px; + } +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/AssetSelectionModal.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/AssetSelectionModal.interface.ts index afbc16604ac..b38c95535e7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/AssetSelectionModal.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/AssetSelectionModal.interface.ts @@ -11,6 +11,7 @@ * limitations under the License. */ import { EntityType } from '../../../enums/entity.enum'; +import { Classification } from '../../../generated/entity/classification/classification'; import { Tag } from '../../../generated/entity/classification/tag'; import { APICollection } from '../../../generated/entity/data/apiCollection'; import { APIEndpoint } from '../../../generated/entity/data/apiEndpoint'; @@ -106,4 +107,5 @@ export type MapPatchAPIResponse = { [EntityType.METRIC]: Metric; [EntityType.TAG]: Tag; [EntityType.DOMAIN]: Domain; + [EntityType.CLASSIFICATION]: Classification; }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Modals/FormModal/index.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Modals/FormModal/index.test.tsx index 05ab3a50aba..2c8f06bcff8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Modals/FormModal/index.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Modals/FormModal/index.test.tsx @@ -27,6 +27,7 @@ const mockForm = jest.fn().mockReturnValue(

data

); const mockInitionalData = { name: '', description: '', + id: '123', }; describe.skip('Test FormModal component', () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelectList/AsyncSelectList.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelectList/AsyncSelectList.interface.ts index ec011272919..5c39e976f2b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelectList/AsyncSelectList.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelectList/AsyncSelectList.interface.ts @@ -15,12 +15,12 @@ import { DefaultOptionType } from 'antd/lib/select'; import { PagingResponse } from 'Models'; import { Tag } from '../../../generated/entity/classification/tag'; import { GlossaryTerm } from '../../../generated/entity/data/glossaryTerm'; -import { TagSource } from '../../../generated/type/tagLabel'; +import { TagLabel, TagSource } from '../../../generated/type/tagLabel'; export type SelectOption = { label: string; value: string; - data?: Tag | GlossaryTerm; + data?: Tag | GlossaryTerm | TagLabel; }; export interface AsyncSelectListProps { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelectList/AsyncSelectList.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelectList/AsyncSelectList.tsx index 081220dc70d..1bb74b7843e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelectList/AsyncSelectList.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelectList/AsyncSelectList.tsx @@ -36,6 +36,7 @@ import React, { import { useTranslation } from 'react-i18next'; import { FQN_SEPARATOR_CHAR } from '../../../constants/char.constants'; import { TAG_START_WITH } from '../../../constants/Tag.constants'; +import { Tag } from '../../../generated/entity/classification/tag'; import { LabelType } from '../../../generated/entity/data/table'; import { Paging } from '../../../generated/type/paging'; import { TagLabel } from '../../../generated/type/tagLabel'; @@ -83,7 +84,7 @@ const AsyncSelectList: FC = ({ const filteredData = data.filter((item) => { const isFiltered = filterOptions.includes( - item.data?.fullyQualifiedName ?? '' + (item.data as Tag)?.fullyQualifiedName ?? '' ); if (isFiltered) { count = optionFilteredCount + 1; @@ -212,7 +213,7 @@ const AsyncSelectList: FC = ({ const { label, onClose } = data; const tagLabel = getTagDisplay(label as string); const tag = { - tagFQN: selectedTag?.data.fullyQualifiedName, + tagFQN: (selectedTag?.data as Tag)?.fullyQualifiedName, ...pick( selectedTag?.data, 'description', diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelectList/TreeAsyncSelectList.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelectList/TreeAsyncSelectList.tsx index 6fd522fdf40..b9c43321905 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelectList/TreeAsyncSelectList.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelectList/TreeAsyncSelectList.tsx @@ -37,6 +37,7 @@ import { useTranslation } from 'react-i18next'; import { ReactComponent as ArrowIcon } from '../../../assets/svg/ic-arrow-down.svg'; import { PAGE_SIZE_LARGE, TEXT_BODY_COLOR } from '../../../constants/constants'; import { TAG_START_WITH } from '../../../constants/Tag.constants'; +import { Tag } from '../../../generated/entity/classification/tag'; import { Glossary } from '../../../generated/entity/data/glossary'; import { LabelType } from '../../../generated/entity/data/table'; import { TagLabel } from '../../../generated/type/tagLabel'; @@ -150,7 +151,7 @@ const TreeAsyncSelectList: FC> = ({ const { value, onClose } = data; const tagLabel = getTagDisplay(value as string); const tag = { - tagFQN: selectedTag?.data.fullyQualifiedName, + tagFQN: (selectedTag?.data as Tag)?.fullyQualifiedName, ...pick( selectedTag?.data, 'description', diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/classification/createTag.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/classification/createTag.ts index 8321bc2653b..3e8fa4e6ca4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/api/classification/createTag.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/classification/createTag.ts @@ -44,6 +44,10 @@ export interface CreateTag { */ mutuallyExclusive?: boolean; name: string; + /** + * Owners of this glossary term. + */ + owners?: EntityReference[]; /** * Fully qualified name of the parent tag. When null, the term is at the root of the * classification. @@ -53,6 +57,62 @@ export interface CreateTag { style?: Style; } +/** + * Owners of this glossary term. + * + * This schema defines the EntityReferenceList type used for referencing an entity. + * EntityReference is used for capturing relationships from one entity to another. For + * example, a table has an attribute called database of type EntityReference that captures + * the relationship of a table `belongs to a` database. + * + * This schema defines the EntityReference type used for referencing an entity. + * EntityReference is used for capturing relationships from one entity to another. For + * example, a table has an attribute called database of type EntityReference that captures + * the relationship of a table `belongs to a` database. + */ +export interface EntityReference { + /** + * If true the entity referred to has been soft-deleted. + */ + deleted?: boolean; + /** + * Optional description of entity. + */ + description?: string; + /** + * Display Name that identifies this entity. + */ + displayName?: string; + /** + * Fully qualified name of the entity instance. For entities such as tables, databases + * fullyQualifiedName is returned in this field. For entities that don't have name hierarchy + * such as `user` and `team` this will be same as the `name` field. + */ + fullyQualifiedName?: string; + /** + * Link to the entity resource. + */ + href?: string; + /** + * Unique identifier that identifies an entity instance. + */ + id: string; + /** + * If true the relationship indicated by this entity reference is inherited from the parent + * entity. + */ + inherited?: boolean; + /** + * Name of the entity instance. + */ + name?: string; + /** + * Entity type/class name - Examples: `database`, `table`, `metrics`, `databaseService`, + * `dashboardService`... + */ + type: string; +} + /** * Type of provider of an entity. Some entities are provided by the `system`. Some are * entities created and provided by the `user`. Typically `system` provide entities can't be diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/classification/loadTags.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/classification/loadTags.ts index e7561d3f3cb..ba46837af1d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/api/classification/loadTags.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/classification/loadTags.ts @@ -153,6 +153,10 @@ export interface CreateTagRequest { */ mutuallyExclusive?: boolean; name: string; + /** + * Owners of this glossary term. + */ + owners?: EntityReference[]; /** * Fully qualified name of the parent tag. When null, the term is at the root of the * classification. diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/classification/classification.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/classification/classification.ts index 525958da794..68117bc19bf 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/classification/classification.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/classification/classification.ts @@ -51,7 +51,7 @@ export interface Classification { /** * Unique identifier of this entity instance. */ - id?: string; + id: string; /** * Change that lead to this version of the entity. */ diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/classification/tag.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/classification/tag.ts index ba6ccc09955..7879faf94fa 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/classification/tag.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/classification/tag.ts @@ -68,7 +68,7 @@ export interface Tag { /** * Unique identifier of this entity instance. */ - id?: string; + id: string; /** * Change that lead to this version of the entity. */ @@ -86,6 +86,10 @@ export interface Tag { * Name of the tag. */ name: string; + /** + * Owners of this glossary term. + */ + owners?: EntityReference[]; /** * Reference to the parent tag. When null, the term is at the root of the Classification. */ diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.test.tsx index b4b923c6c3f..54b576390c0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.test.tsx @@ -158,7 +158,7 @@ describe('TagPage', () => { // Verify initial API calls await waitFor(() => { expect(getTagByFqn).toHaveBeenCalledWith('PII.NonSensitive', { - fields: 'domain', + fields: ['domain', 'owners'], }); expect(searchData).toHaveBeenCalled(); }); @@ -177,7 +177,7 @@ describe('TagPage', () => { await waitFor(() => { expect(getTagByFqn).toHaveBeenCalledWith('Certification.Gold', { - fields: 'domain', + fields: ['domain', 'owners'], }); expect(searchData).toHaveBeenCalled(); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.tsx index 3958b678358..68b3e3bdf2e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.tsx @@ -12,6 +12,7 @@ */ import { Button, + Card, Col, Divider, Dropdown, @@ -38,7 +39,6 @@ import { ReactComponent as EditIcon } from '../../assets/svg/edit-new.svg'; import { ReactComponent as IconDelete } from '../../assets/svg/ic-delete.svg'; import { ReactComponent as IconDropdown } from '../../assets/svg/menu.svg'; import { ReactComponent as StyleIcon } from '../../assets/svg/style.svg'; -import { DomainLabel } from '../../components/common/DomainLabel/DomainLabel.component'; import DescriptionV1 from '../../components/common/EntityDescription/DescriptionV1'; import ErrorPlaceHolder from '../../components/common/ErrorWithPlaceholder/ErrorPlaceHolder'; import Loader from '../../components/common/Loader/Loader'; @@ -48,7 +48,10 @@ import StatusBadge from '../../components/common/StatusBadge/StatusBadge.compone import { StatusType } from '../../components/common/StatusBadge/StatusBadge.interface'; import TabsLabel from '../../components/common/TabsLabel/TabsLabel.component'; import { TitleBreadcrumbProps } from '../../components/common/TitleBreadcrumb/TitleBreadcrumb.interface'; +import { GenericProvider } from '../../components/Customization/GenericProvider/GenericProvider'; import { AssetSelectionModal } from '../../components/DataAssets/AssetsSelectionModal/AssetSelectionModal'; +import { DomainLabelV2 } from '../../components/DataAssets/DomainLabelV2/DomainLabelV2'; +import { OwnerLabelV2 } from '../../components/DataAssets/OwnerLabelV2/OwnerLabelV2'; import { EntityHeader } from '../../components/Entity/EntityHeader/EntityHeader.component'; import EntitySummaryPanel from '../../components/Explore/EntitySummaryPanel/EntitySummaryPanel.component'; import { EntityDetailsObjectInterface } from '../../components/Explore/ExplorePage.interface'; @@ -65,6 +68,7 @@ import { DE_ACTIVE_COLOR, ROUTES, } from '../../constants/constants'; +import { CustomizeEntityType } from '../../constants/Customize.constants'; import { TAGS_DOCS } from '../../constants/docs.constants'; import { COMMON_RESIZABLE_PANEL_CONFIG } from '../../constants/ResizablePanel.constants'; import { usePermissionProvider } from '../../context/PermissionProvider/PermissionProvider'; @@ -218,7 +222,7 @@ const TagPage = () => { setIsLoading(true); if (tagFqn) { const response = await getTagByFqn(tagFqn, { - fields: TabSpecificField.DOMAIN, + fields: [TabSpecificField.DOMAIN, TabSpecificField.OWNERS], }); setTagItem(response); } @@ -433,46 +437,38 @@ const TagPage = () => { label: , key: 'overview', children: ( - - - - - - + + data={tagItem as Tag} + isVersionView={false} + permissions={tagPermissions} + type={EntityType.TAG as CustomizeEntityType} + onUpdate={(updatedData: Tag) => + Promise.resolve(updateTag(updatedData)) + }> + + + + + + + +
+ +
- ), - ...COMMON_RESIZABLE_PANEL_CONFIG.LEFT_PANEL, - }} - secondPanel={{ - children: tagItem ? ( - - ) : null, - ...COMMON_RESIZABLE_PANEL_CONFIG.RIGHT_PANEL, - className: - 'entity-resizable-right-panel-container tag-resizable-panel-container', - }} - /> + + + ), }, { @@ -624,7 +620,7 @@ const TagPage = () => { {haveAssetEditPermission && (
- {!isCertificationClassification && ( + {!isCertificationClassification && !tagItem.disabled && (