Feat(ui): add owners field in tags and classification (#21757)

* add owners field in classification

* fix tests

* add owners field in tags page

* Tag Inherits owners from classification

* Added loadTags.ts for owners field

* add owners for tags page

* fix tests

* review comments

* add owners field in create form

* fix domain test

---------

Co-authored-by: Ram Narayan Balaji <ramnarayanb3005@gmail.com>
Co-authored-by: Mohit Yadav <105265192+mohityadav766@users.noreply.github.com>
This commit is contained in:
Karan Hotchandani 2025-06-18 16:52:53 +05:30 committed by GitHub
parent d98c762501
commit 631c6f58fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 1140 additions and 321 deletions

View File

@ -83,10 +83,11 @@ public class TagRepository extends EntityRepository<Tag> {
@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

View File

@ -93,7 +93,7 @@ public class TagResource extends EntityResource<Tag, TagRepository> {
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<Tag> {
/* Required for serde */
@ -105,7 +105,7 @@ public class TagResource extends EntityResource<Tag, TagRepository> {
@Override
protected List<MetadataOperation> getEntitySpecificOperations() {
addViewOperation("children,usageCount", MetadataOperation.VIEW_BASIC);
addViewOperation("owners,children,usageCount", MetadataOperation.VIEW_BASIC);
return null;
}

View File

@ -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<Tag, CreateTag> {
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<EntityReference> 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 {

View File

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

View File

@ -86,6 +86,6 @@
"$ref": "../../type/entityReferenceList.json"
}
},
"required": ["name", "description"],
"required": ["id", "name", "description"],
"additionalProperties": false
}

View File

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

View File

@ -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', () => {

View File

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

View File

@ -43,6 +43,8 @@ export enum EntityTypeEndpoint {
TestSuites = 'dataQuality/testSuites',
Topic = 'topics',
User = 'users',
Classification = 'classifications',
Tag = 'tags',
}
export type EntityDataType = {

View File

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

View File

@ -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(
</Col>
</Row>
)}
<div className="m-b-sm m-t-xs" data-testid="description-container">
<DescriptionV1
className={classNames({
'opacity-60': isClassificationDisabled,
})}
description={description}
entityName={getEntityName(currentClassification)}
entityType={EntityType.CLASSIFICATION}
hasEditAccess={editDescriptionPermission}
isDescriptionExpanded={isEmpty(tags)}
showCommentsIcon={false}
onDescriptionUpdate={handleUpdateDescription}
/>
</div>
<Table
className={classNames({
'opacity-60': isClassificationDisabled,
})}
columns={tableColumn}
customPaginationProps={{
currentPage,
isLoading: isTagsLoading,
pageSize,
paging,
showPagination,
pagingHandler: handleTagsPageChange,
onShowSizeChange: handlePageSizeChange,
}}
data-testid="table"
dataSource={tags}
loading={isTagsLoading}
locale={{
emptyText: (
<ErrorPlaceHolder
className="m-y-md"
placeholderText={t('message.no-tags-description')}
/>
),
}}
pagination={false}
rowClassName={(record) => (record.disabled ? 'opacity-60' : '')}
rowKey="id"
size="small"
/>
<GenericProvider<Classification>
data={currentClassification as Classification}
isVersionView={isVersionView}
permissions={classificationPermissions}
type={EntityType.CLASSIFICATION as CustomizeEntityType}
onUpdate={(updatedData: Classification) =>
Promise.resolve(handleUpdateClassification?.(updatedData))
}>
<Row className="m-t-md" gutter={16}>
<Col span={18}>
<Card className="classification-details-card">
<div className="m-b-sm" data-testid="description-container">
<DescriptionV1
wrapInCard
className={classNames({
'opacity-60': isClassificationDisabled,
})}
description={description}
entityName={getEntityName(currentClassification)}
entityType={EntityType.CLASSIFICATION}
hasEditAccess={editDescriptionPermission}
isDescriptionExpanded={isEmpty(tags)}
showCommentsIcon={false}
onDescriptionUpdate={handleUpdateDescription}
/>
</div>
<Table
className={classNames({
'opacity-60': isClassificationDisabled,
})}
columns={tableColumn}
customPaginationProps={{
currentPage,
isLoading: isTagsLoading,
pageSize,
paging,
showPagination,
pagingHandler: handleTagsPageChange,
onShowSizeChange: handlePageSizeChange,
}}
data-testid="table"
dataSource={tags}
loading={isTagsLoading}
locale={{
emptyText: (
<ErrorPlaceHolder
className="m-y-md"
placeholderText={t('message.no-tags-description')}
/>
),
}}
pagination={false}
rowClassName={(record) =>
record.disabled ? 'opacity-60' : ''
}
rowKey="id"
scroll={{ x: true }}
size="small"
/>
</Card>
</Col>
<Col span={6}>
<div className="d-flex flex-column gap-5">
<DomainLabelV2 showDomainHeading />
<OwnerLabelV2 dataTestId="classification-owner-name" />
</div>
</Col>
</Row>
</GenericProvider>
</div>
);
}

View File

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

View File

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

View File

@ -27,6 +27,7 @@ const mockForm = jest.fn().mockReturnValue(<p data-testid="form">data</p>);
const mockInitionalData = {
name: '',
description: '',
id: '123',
};
describe.skip('Test FormModal component', () => {

View File

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

View File

@ -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<AsyncSelectListProps & SelectProps> = ({
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<AsyncSelectListProps & SelectProps> = ({
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',

View File

@ -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<Omit<AsyncSelectListProps, 'fetchOptions'>> = ({
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',

View File

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

View File

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

View File

@ -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.
*/

View File

@ -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.
*/

View File

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

View File

@ -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: <TabsLabel id={TagTabs.OVERVIEW} name={t('label.overview')} />,
key: 'overview',
children: (
<ResizablePanels
className="tag-height-with-resizable-panel"
firstPanel={{
className: 'tag-resizable-panel-container',
children: (
<div className="tag-overview-tab">
<Row>
<Col span={24}>
<DescriptionV1
removeBlur
description={tagItem?.description}
entityName={getEntityName(tagItem)}
entityType={EntityType.TAG}
hasEditAccess={editDescriptionPermission}
showActions={!tagItem?.deleted}
showCommentsIcon={false}
onDescriptionUpdate={onDescriptionUpdate}
/>
</Col>
</Row>
<GenericProvider<Tag>
data={tagItem as Tag}
isVersionView={false}
permissions={tagPermissions}
type={EntityType.TAG as CustomizeEntityType}
onUpdate={(updatedData: Tag) =>
Promise.resolve(updateTag(updatedData))
}>
<Row gutter={16}>
<Col span={18}>
<Card className="card-padding-md">
<DescriptionV1
removeBlur
wrapInCard
description={tagItem?.description}
entityName={getEntityName(tagItem)}
entityType={EntityType.TAG}
hasEditAccess={editDescriptionPermission}
showActions={!tagItem?.deleted}
showCommentsIcon={false}
onDescriptionUpdate={onDescriptionUpdate}
/>
</Card>
</Col>
<Col span={6}>
<div className="d-flex flex-column gap-5">
<DomainLabelV2 showDomainHeading />
<OwnerLabelV2 dataTestId="tag-owner-name" />
</div>
),
...COMMON_RESIZABLE_PANEL_CONFIG.LEFT_PANEL,
}}
secondPanel={{
children: tagItem ? (
<DomainLabel
showDomainHeading
domain={tagItem.domain}
entityFqn={tagItem.fullyQualifiedName ?? ''}
entityId={tagItem.id ?? ''}
entityType={EntityType.TAG}
hasPermission={editTagsPermission}
/>
) : null,
...COMMON_RESIZABLE_PANEL_CONFIG.RIGHT_PANEL,
className:
'entity-resizable-right-panel-container tag-resizable-panel-container',
}}
/>
</Col>
</Row>
</GenericProvider>
),
},
{
@ -624,7 +620,7 @@ const TagPage = () => {
{haveAssetEditPermission && (
<Col className="p-x-md">
<div className="d-flex self-end">
{!isCertificationClassification && (
{!isCertificationClassification && !tagItem.disabled && (
<Button
data-testid="data-classification-add-button"
type="primary"
@ -672,7 +668,7 @@ const TagPage = () => {
<Tabs
destroyInactiveTabPane
activeKey={activeTab}
className="tabs-new"
className="tabs-new tag-page-tabs"
items={tabItems}
onChange={activeTabHandler}
/>

View File

@ -15,3 +15,9 @@
.tag-height-with-resizable-panel {
height: @tag-page-height;
}
.tag-page-tabs.ant-tabs.tabs-new {
.ant-tabs-tabpane {
background-color: transparent;
}
}

View File

@ -24,6 +24,7 @@ jest.mock('../../components/common/RichTextEditor/RichTextEditor', () => {
jest.mock('../../utils/CommonUtils', () => ({
isUrlFriendlyName: jest.fn().mockReturnValue(true),
getCountBadge: jest.fn().mockReturnValue(''),
}));
const mockCancel = jest.fn();

View File

@ -12,11 +12,12 @@
*/
import { PlusOutlined } from '@ant-design/icons';
import { Button, Form, Modal, Typography } from 'antd';
import { Button, Form, Modal, Space, Typography } from 'antd';
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { DomainLabel } from '../../components/common/DomainLabel/DomainLabel.component';
import { EntityAttachmentProvider } from '../../components/common/EntityDescription/EntityAttachmentProvider/EntityAttachmentProvider';
import { OwnerLabel } from '../../components/common/OwnerLabel/OwnerLabel.component';
import { VALIDATION_MESSAGES } from '../../constants/constants';
import {
HEX_COLOR_CODE_REGEX,
@ -32,7 +33,7 @@ import {
FormItemLayout,
HelperTextType,
} from '../../interface/FormUtils.interface';
import { generateFormFields } from '../../utils/formUtils';
import { generateFormFields, getField } from '../../utils/formUtils';
import { RenameFormProps, SubmitProps } from './TagsPage.interface';
const TagsForm = ({
@ -61,6 +62,12 @@ const TagsForm = ({
'mutuallyExclusive',
form
);
const selectedOwners =
Form.useWatch<EntityReference | EntityReference[]>('owners', form) ?? [];
const ownersList = Array.isArray(selectedOwners)
? selectedOwners
: [selectedOwners];
const { activeDomainEntityRef } = useDomainStore();
useEffect(() => {
@ -105,6 +112,56 @@ const TagsForm = ({
[isEditing, isClassification, permissions]
);
const ownerField: FieldProp = {
name: 'owners',
id: 'root/owner',
required: false,
label: t('label.owner-plural'),
type: FieldTypes.USER_TEAM_SELECT,
props: {
hasPermission: true,
children: (
<Button
data-testid="add-owner"
icon={<PlusOutlined style={{ color: 'white', fontSize: '12px' }} />}
size="small"
type="primary"
/>
),
multiple: { user: true, team: false },
},
formItemLayout: FormItemLayout.HORIZONTAL,
formItemProps: {
valuePropName: 'owners',
trigger: 'onUpdate',
},
};
const domainField: FieldProp = {
name: 'domain',
id: 'root/domain',
required: false,
label: t('label.domain'),
type: FieldTypes.DOMAIN_SELECT,
props: {
selectedDomain: activeDomainEntityRef,
children: (
<Button
data-testid="add-domain"
icon={<PlusOutlined style={{ color: 'white', fontSize: '12px' }} />}
size="small"
type="primary"
/>
),
},
formItemLayout: FormItemLayout.HORIZONTAL,
formItemProps: {
valuePropName: 'selectedDomain',
trigger: 'onUpdate',
initialValue: activeDomainEntityRef,
},
};
const formFields: FieldProp[] = [
{
name: 'name',
@ -228,30 +285,6 @@ const TagsForm = ({
},
] as FieldProp[])
: []),
{
name: 'domain',
id: 'root/domain',
required: false,
label: t('label.domain'),
type: FieldTypes.DOMAIN_SELECT,
props: {
selectedDomain: activeDomainEntityRef,
children: (
<Button
data-testid="add-domain"
icon={<PlusOutlined style={{ color: 'white', fontSize: '12px' }} />}
size="small"
type="primary"
/>
),
},
formItemLayout: FormItemLayout.HORIZONTAL,
formItemProps: {
valuePropName: 'selectedDomain',
trigger: 'onUpdate',
initialValue: activeDomainEntityRef,
},
},
];
const handleSave = async (data: SubmitProps) => {
@ -307,18 +340,29 @@ const TagsForm = ({
validateMessages={VALIDATION_MESSAGES}
onFinish={handleSave}>
{generateFormFields(formFields)}
<div className="m-y-xs">
{getField(ownerField)}
{Boolean(ownersList.length) && (
<Space wrap data-testid="owner-container" size={[8, 8]}>
<OwnerLabel owners={ownersList} />
</Space>
)}
</div>
<div className="m-t-xss">
{getField(domainField)}
{selectedDomain && (
<DomainLabel
domain={selectedDomain}
entityFqn=""
entityId=""
entityType={
isClassification ? EntityType.CLASSIFICATION : EntityType.TAG
}
hasPermission={false}
/>
)}
</div>
</Form>
{selectedDomain && (
<DomainLabel
domain={selectedDomain}
entityFqn=""
entityId=""
entityType={
isClassification ? EntityType.CLASSIFICATION : EntityType.TAG
}
hasPermission={false}
/>
)}
</EntityAttachmentProvider>
</Modal>
);

View File

@ -43,7 +43,7 @@ export interface RenameFormProps {
isTier: boolean;
onCancel: () => void;
header: string;
initialValues?: Tag;
initialValues?: Omit<Tag, 'id'>;
onSubmit: (value: SubmitProps) => Promise<void>;
showMutuallyExclusive?: boolean;
isClassification?: boolean;

View File

@ -65,6 +65,7 @@ const mockCategory = [
version: 0.1,
updatedAt: 1649665563400,
updatedBy: 'admin',
owners: [],
href: 'http://localhost:8585/api/v1/tags/PersonalData',
usageCount: 0,
children: [
@ -108,6 +109,7 @@ const mockCategory = [
updatedAt: 1649665563410,
updatedBy: 'admin',
provider: 'user',
owners: [],
href: 'http://localhost:8585/api/v1/tags/PII',
usageCount: 0,
children: [
@ -283,6 +285,69 @@ jest.mock('../../components/common/EntityDescription/DescriptionV1', () => {
return jest.fn().mockReturnValue(<p>DescriptionComponent</p>);
});
jest.mock('../../components/DataAssets/OwnerLabelV2/OwnerLabelV2', () => ({
OwnerLabelV2: jest.fn().mockImplementation(() => <div>OwnerLabelV2</div>),
}));
jest.mock(
'../../components/Customization/GenericProvider/GenericProvider',
() => ({
useGenericContext: jest.fn().mockReturnValue({
data: {
id: '93285c04-d8b6-4833-997e-56dc5f973427',
name: 'PersonalData',
description: 'description',
version: 0.1,
updatedAt: 1649665563400,
updatedBy: 'admin',
owners: [],
href: 'http://localhost:8585/api/v1/tags/PersonalData',
usageCount: 0,
children: [
{
id: '8a218558-7b8f-446f-ace7-29b031c856b3',
name: 'Personal',
fullyQualifiedName: 'PersonalData.Personal',
description:
'Data that can be used to directly or indirectly identify a person.',
version: 0.1,
updatedAt: 1649665563400,
updatedBy: 'admin',
href: 'http://localhost:8585/api/v1/tags/PersonalData/Personal',
usageCount: 0,
deprecated: false,
deleted: false,
associatedTags: [],
},
{
id: '4a2d7e47-9129-4cfe-91e8-e4f4df15f41d',
name: 'SpecialCategory',
fullyQualifiedName: 'PersonalData.SpecialCategory',
description: 'description',
version: 0.1,
updatedAt: 1649665563400,
updatedBy: 'admin',
href: 'http://localhost:8585/api/v1/tags/PersonalData/SpecialCategory',
usageCount: 0,
deprecated: false,
deleted: false,
associatedTags: [],
},
],
deleted: false,
},
onUpdate: jest.fn(),
filterWidgets: jest.fn(),
}),
GenericProvider: jest.fn().mockImplementation(({ children }) => children),
_esModule: true,
})
);
jest.mock('../../utils/TableColumn.util', () => ({
ownerTableObject: jest.fn().mockReturnValue({}),
}));
describe('Test TagsPage page', () => {
it('Component should render', async () => {
await act(async () => {

View File

@ -90,7 +90,6 @@ const TagsPage = () => {
const [editTag, setEditTag] = useState<Tag>();
const [error, setError] = useState<string>('');
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isUpdateLoading, setIsUpdateLoading] = useState<boolean>(false);
const classificationDetailsRef = useRef<ClassificationDetailsRef>(null);
const [deleteTags, setDeleteTags] = useState<DeleteTagsType>({
@ -142,7 +141,11 @@ const TagsPage = () => {
try {
const response = await getAllClassifications({
fields: TabSpecificField.TERM_COUNT,
fields: [
TabSpecificField.TERM_COUNT,
TabSpecificField.OWNERS,
TabSpecificField.DOMAIN,
],
limit: 1000,
});
setClassifications(response.data);
@ -170,7 +173,12 @@ const TagsPage = () => {
setIsLoading(true);
try {
const currentClassification = await getClassificationByName(fqn, {
fields: [TabSpecificField.USAGE_COUNT, TabSpecificField.TERM_COUNT],
fields: [
TabSpecificField.OWNERS,
TabSpecificField.USAGE_COUNT,
TabSpecificField.TERM_COUNT,
TabSpecificField.DOMAIN,
],
});
if (currentClassification) {
setClassifications((prevClassifications) =>
@ -200,7 +208,7 @@ const TagsPage = () => {
);
showErrorToast(errMsg);
setError(errMsg);
setCurrentClassification({ name: fqn, description: '' });
setCurrentClassification(undefined);
setIsLoading(false);
}
}
@ -318,8 +326,6 @@ const TagsPage = () => {
const handleUpdateClassification = useCallback(
async (updatedClassification: Classification) => {
if (!isUndefined(currentClassification)) {
setIsUpdateLoading(true);
const patchData = compare(currentClassification, updatedClassification);
try {
const response = await patchClassification(
@ -370,8 +376,6 @@ const TagsPage = () => {
})
);
}
} finally {
setIsUpdateLoading(false);
}
}
},
@ -716,23 +720,19 @@ const TagsPage = () => {
secondPanel={{
children: (
<>
{isUpdateLoading ? (
<Loader />
) : (
<ClassificationDetails
classificationPermissions={classificationPermissions}
currentClassification={currentClassification}
deleteTags={deleteTags}
disableEditButton={disableEditButton}
handleActionDeleteTag={handleActionDeleteTag}
handleAddNewTagClick={handleAddNewTagClick}
handleAfterDeleteAction={handleAfterDeleteAction}
handleEditTagClick={handleEditTagClick}
handleUpdateClassification={handleUpdateClassification}
isAddingTag={isAddingTag}
ref={classificationDetailsRef}
/>
)}
<ClassificationDetails
classificationPermissions={classificationPermissions}
currentClassification={currentClassification}
deleteTags={deleteTags}
disableEditButton={disableEditButton}
handleActionDeleteTag={handleActionDeleteTag}
handleAddNewTagClick={handleAddNewTagClick}
handleAfterDeleteAction={handleAfterDeleteAction}
handleEditTagClick={handleEditTagClick}
handleUpdateClassification={handleUpdateClassification}
isAddingTag={isAddingTag}
ref={classificationDetailsRef}
/>
{/* Classification Form */}
{isAddingClassification && (

View File

@ -74,7 +74,9 @@ describe('API functions should work properly', () => {
});
it('createClassification function should work properly', async () => {
const mockPostData = { name: 'testCategory' } as Classification;
const mockPostData = {
name: 'testCategory',
} as Classification;
const result = await createClassification({
...mockPostData,
domain: undefined,
@ -87,7 +89,7 @@ describe('API functions should work properly', () => {
});
it('createTag function should work properly', async () => {
const mockPostData = { name: 'newTag' } as Classification;
const mockPostData = { name: 'newTag', id: 'tagId' } as Classification;
const result = await createTag({ ...mockPostData, domain: undefined });
expect(result).toEqual({
@ -100,6 +102,7 @@ describe('API functions should work properly', () => {
const mockUpdateData = {
name: 'tagName',
description: 'newDescription',
id: 'tagId',
};
const result = await updateTag(mockUpdateData);

View File

@ -43,6 +43,36 @@
}
}
.card-padding-xss {
> .ant-card-body {
padding: @padding-xss;
}
}
.card-padding-xs {
> .ant-card-body {
padding: @padding-xs;
}
}
.card-padding-sm {
> .ant-card-body {
padding: @padding-sm;
}
}
.card-padding-md {
> .ant-card-body {
padding: @padding-md;
}
}
.card-padding-lg {
> .ant-card-body {
padding: @padding-lg;
}
}
.card-padding-y-0 {
.ant-card-body {
padding-top: 0;

View File

@ -65,7 +65,12 @@ import {
patchStoredProceduresDetails,
} from '../../rest/storedProceduresAPI';
import { getTableDetailsByFQN, patchTableDetails } from '../../rest/tableAPI';
import { getTagByFqn, patchTag } from '../../rest/tagAPI';
import {
getClassificationByName,
getTagByFqn,
patchClassification,
patchTag,
} from '../../rest/tagAPI';
import { getTeamByName, patchTeamDetail } from '../../rest/teamsAPI';
import { getTopicByFqn, patchTopicDetails } from '../../rest/topicsAPI';
import { getUserByName, updateUserDetail } from '../../rest/userAPI';
@ -102,6 +107,8 @@ export const getAPIfromSource = (
return patchGlossaries;
case EntityType.TAG:
return patchTag;
case EntityType.CLASSIFICATION:
return patchClassification;
case EntityType.DATABASE_SCHEMA:
return patchDatabaseSchemaDetails;
case EntityType.DATABASE:
@ -161,6 +168,8 @@ export const getEntityAPIfromSource = (
return getGlossaryTermByFQN;
case EntityType.GLOSSARY:
return getGlossariesByName;
case EntityType.CLASSIFICATION:
return getClassificationByName;
case EntityType.TAG:
return getTagByFqn;
case EntityType.DATABASE_SCHEMA:

View File

@ -0,0 +1,363 @@
/*
* 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.
*/
import { EntityField } from '../constants/Feeds.constants';
import { ProviderType } from '../generated/entity/bot';
import { Classification } from '../generated/entity/classification/classification';
import { ChangeDescription } from '../generated/entity/type';
import { getClassificationInfo } from './ClassificationUtils';
import { getEntityVersionByField } from './EntityVersionUtils';
// Mock dependencies
jest.mock('./EntityVersionUtils', () => ({
getEntityVersionByField: jest.fn(),
}));
const mockGetEntityVersionByField =
getEntityVersionByField as jest.MockedFunction<
typeof getEntityVersionByField
>;
describe('ClassificationUtils', () => {
describe('getClassificationInfo', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return default values when no classification is provided', () => {
const result = getClassificationInfo();
expect(result).toEqual({
currentVersion: '0.1',
isClassificationDisabled: false,
isTier: false,
isSystemClassification: false,
name: undefined,
displayName: undefined,
description: undefined,
});
});
it('should return correct values for a regular classification', () => {
const mockClassification: Classification = {
id: 'test-id',
name: 'TestClassification',
displayName: 'Test Classification',
description: 'Test classification description',
version: 1.2,
disabled: false,
provider: ProviderType.User,
fullyQualifiedName: 'test.classification',
deleted: false,
href: 'http://test.com',
updatedAt: 1234567890,
updatedBy: 'test-user',
};
const result = getClassificationInfo(mockClassification);
expect(result).toEqual({
currentVersion: 1.2,
isClassificationDisabled: false,
isTier: false,
isSystemClassification: false,
name: 'TestClassification',
displayName: 'Test Classification',
description: 'Test classification description',
});
});
it('should identify Tier classification correctly', () => {
const mockTierClassification: Classification = {
id: 'tier-id',
name: 'Tier',
displayName: 'Tier Classification',
description: 'Tier classification description',
version: 1.0,
disabled: false,
provider: ProviderType.System,
fullyQualifiedName: 'tier.classification',
deleted: false,
href: 'http://test.com',
updatedAt: 1234567890,
updatedBy: 'system',
};
const result = getClassificationInfo(mockTierClassification);
expect(result).toEqual({
currentVersion: 1,
isClassificationDisabled: false,
isTier: true,
isSystemClassification: true,
name: 'Tier',
displayName: 'Tier Classification',
description: 'Tier classification description',
});
});
it('should identify system classification correctly', () => {
const mockSystemClassification: Classification = {
id: 'system-id',
name: 'SystemClassification',
displayName: 'System Classification',
description: 'System classification description',
version: 2,
disabled: true,
provider: ProviderType.System,
fullyQualifiedName: 'system.classification',
deleted: false,
href: 'http://test.com',
updatedAt: 1234567890,
updatedBy: 'system',
};
const result = getClassificationInfo(mockSystemClassification);
expect(result).toEqual({
currentVersion: 2,
isClassificationDisabled: true,
isTier: false,
isSystemClassification: true,
name: 'SystemClassification',
displayName: 'System Classification',
description: 'System classification description',
});
});
it('should handle disabled classification', () => {
const mockDisabledClassification: Classification = {
id: 'disabled-id',
name: 'DisabledClassification',
displayName: 'Disabled Classification',
description: 'Disabled classification description',
version: 1.5,
disabled: true,
provider: ProviderType.User,
fullyQualifiedName: 'disabled.classification',
deleted: false,
href: 'http://test.com',
updatedAt: 1234567890,
updatedBy: 'test-user',
};
const result = getClassificationInfo(mockDisabledClassification);
expect(result).toEqual({
currentVersion: 1.5,
isClassificationDisabled: true,
isTier: false,
isSystemClassification: false,
name: 'DisabledClassification',
displayName: 'Disabled Classification',
description: 'Disabled classification description',
});
});
it('should handle missing optional fields gracefully', () => {
const mockMinimalClassification: Classification = {
id: 'minimal-id',
name: 'MinimalClassification',
description: 'Minimal description',
fullyQualifiedName: 'minimal.classification',
deleted: false,
href: 'http://test.com',
updatedAt: 1234567890,
updatedBy: 'test-user',
};
const result = getClassificationInfo(mockMinimalClassification);
expect(result).toEqual({
currentVersion: '0.1',
isClassificationDisabled: false,
isTier: false,
isSystemClassification: false,
name: 'MinimalClassification',
displayName: undefined,
description: 'Minimal description',
});
});
describe('Version view functionality', () => {
const mockChangeDescription: ChangeDescription = {
fieldsAdded: [],
fieldsUpdated: [],
fieldsDeleted: [],
previousVersion: 1.0,
};
const mockClassificationWithChangeDescription: Classification = {
id: 'versioned-id',
name: 'VersionedClassification',
displayName: 'Versioned Classification',
description: 'Versioned classification description',
version: 2,
disabled: false,
provider: ProviderType.User,
fullyQualifiedName: 'versioned.classification',
deleted: false,
href: 'http://test.com',
updatedAt: 1234567890,
updatedBy: 'test-user',
changeDescription: mockChangeDescription,
};
beforeEach(() => {
mockGetEntityVersionByField.mockImplementation((_, field, fallback) => {
switch (field) {
case EntityField.NAME:
return 'VersionedName';
case EntityField.DISPLAYNAME:
return 'Versioned Display Name';
case EntityField.DESCRIPTION:
return 'Versioned description';
default:
return fallback || '';
}
});
});
it('should use EntityVersionUtils when isVersionView is true', () => {
const result = getClassificationInfo(
mockClassificationWithChangeDescription,
true
);
expect(result).toEqual({
currentVersion: 2,
isClassificationDisabled: false,
isTier: false,
isSystemClassification: false,
name: 'VersionedName',
displayName: 'Versioned Display Name',
description: 'Versioned description',
});
expect(mockGetEntityVersionByField).toHaveBeenCalledTimes(3);
expect(mockGetEntityVersionByField).toHaveBeenCalledWith(
mockChangeDescription,
EntityField.NAME,
'VersionedClassification'
);
expect(mockGetEntityVersionByField).toHaveBeenCalledWith(
mockChangeDescription,
EntityField.DISPLAYNAME,
'Versioned Classification'
);
expect(mockGetEntityVersionByField).toHaveBeenCalledWith(
mockChangeDescription,
EntityField.DESCRIPTION,
'Versioned classification description'
);
});
it('should handle classification with no changeDescription in version view', () => {
const classificationWithoutChangeDescription: Classification = {
...mockClassificationWithChangeDescription,
changeDescription: undefined,
};
const result = getClassificationInfo(
classificationWithoutChangeDescription,
true
);
expect(result).toEqual({
currentVersion: 2,
isClassificationDisabled: false,
isTier: false,
isSystemClassification: false,
name: 'VersionedName',
displayName: 'Versioned Display Name',
description: 'Versioned description',
});
// Should pass empty object as changeDescription
expect(mockGetEntityVersionByField).toHaveBeenCalledWith(
{},
EntityField.NAME,
'VersionedClassification'
);
});
it('should not use EntityVersionUtils when isVersionView is false', () => {
const result = getClassificationInfo(
mockClassificationWithChangeDescription,
false
);
expect(result).toEqual({
currentVersion: 2,
isClassificationDisabled: false,
isTier: false,
isSystemClassification: false,
name: 'VersionedClassification',
displayName: 'Versioned Classification',
description: 'Versioned classification description',
});
expect(mockGetEntityVersionByField).not.toHaveBeenCalled();
});
});
describe('Edge cases', () => {
it('should handle undefined values correctly', () => {
const result = getClassificationInfo(undefined);
expect(result).toEqual({
currentVersion: '0.1',
isClassificationDisabled: false,
isTier: false,
isSystemClassification: false,
name: undefined,
displayName: undefined,
description: undefined,
});
});
it('should handle null values correctly', () => {
// TypeScript would normally prevent this, but testing runtime behavior
const result = getClassificationInfo(null as any);
expect(result).toEqual({
currentVersion: '0.1',
isClassificationDisabled: false,
isTier: false,
isSystemClassification: false,
name: undefined,
displayName: undefined,
description: undefined,
});
});
it('should default isVersionView to false when not provided', () => {
const mockClassification: Classification = {
id: 'test-id',
name: 'TestClassification',
description: 'Test description',
fullyQualifiedName: 'test.classification',
deleted: false,
href: 'http://test.com',
updatedAt: 1234567890,
updatedBy: 'test-user',
};
const result = getClassificationInfo(mockClassification);
expect(mockGetEntityVersionByField).not.toHaveBeenCalled();
expect(result.name).toBe('TestClassification');
});
});
});
});

View File

@ -21,10 +21,14 @@ import { ReactComponent as EditIcon } from '../assets/svg/edit-new.svg';
import { ManageButtonItemLabel } from '../components/common/ManageButtonContentItem/ManageButtonContentItem.component';
import RichTextEditorPreviewerNew from '../components/common/RichTextEditor/RichTextEditorPreviewNew';
import { NO_DATA_PLACEHOLDER } from '../constants/constants';
import { EntityField } from '../constants/Feeds.constants';
import { OperationPermission } from '../context/PermissionProvider/PermissionProvider.interface';
import { ProviderType } from '../generated/entity/bot';
import { Classification } from '../generated/entity/classification/classification';
import { Tag } from '../generated/entity/classification/tag';
import { ChangeDescription } from '../generated/entity/type';
import { DeleteTagsType } from '../pages/TagsPage/TagsPage.interface';
import { getEntityVersionByField } from './EntityVersionUtils';
import { getClassificationTagPath } from './RouterUtils';
import { getDeleteIcon, getTagImageSrc } from './TagsUtils';
@ -96,6 +100,7 @@ export const getCommonColumns = (): ColumnsType<Tag> => [
title: t('label.description'),
dataIndex: 'description',
key: 'description',
width: 300,
render: (text: string) => (
<>
<div className="cursor-pointer d-flex">
@ -238,3 +243,37 @@ export const getClassificationExtraDropdownContent = (
]
: []),
];
export const getClassificationInfo = (
currentClassification?: Classification,
isVersionView = false
) => {
return {
currentVersion: currentClassification?.version ?? '0.1',
isClassificationDisabled: currentClassification?.disabled ?? false,
isTier: currentClassification?.name === 'Tier',
isSystemClassification:
currentClassification?.provider === ProviderType.System,
name: isVersionView
? getEntityVersionByField(
currentClassification?.changeDescription ?? ({} as ChangeDescription),
EntityField.NAME,
currentClassification?.name
)
: currentClassification?.name,
displayName: isVersionView
? getEntityVersionByField(
currentClassification?.changeDescription ?? ({} as ChangeDescription),
EntityField.DISPLAYNAME,
currentClassification?.displayName
)
: currentClassification?.displayName,
description: isVersionView
? getEntityVersionByField(
currentClassification?.changeDescription ?? ({} as ChangeDescription),
EntityField.DESCRIPTION,
currentClassification?.description
)
: currentClassification?.description,
};
};