You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@atlas.apache.org by pp...@apache.org on 2026/08/24 12:51:13 UTC
(atlas) branch master updated: ATLAS-5342: Atlas React UI: Entity modification functionalities (Add Classifications, Terms, Labels, Business Metadata) remain active for DELETED entities (#697)
This is an automated email from the ASF dual-hosted git repository.
pawarprasad123 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/atlas.git
The following commit(s) were added to refs/heads/master by this push:
new 7c6d24ec03 ATLAS-5342: Atlas React UI: Entity modification functionalities (Add Classifications, Terms, Labels, Business Metadata) remain active for DELETED entities (#697)
7c6d24ec03 is described below
commit 7c6d24ec035da5775acd63140d3b81c54736a764
Author: Brijesh Bhalala <32...@users.noreply.github.com>
AuthorDate: Mon Aug 24 18:21:07 2026 +0530
ATLAS-5342: Atlas React UI: Entity modification functionalities (Add Classifications, Terms, Labels, Business Metadata) remain active for DELETED entities (#697)
* ATLAS-5342: Atlas React UI: Entity modification functionalities (Add Classifications, Terms, Labels, Business Metadata) remain active for DELETED entities
* ATLAS-5342: Atlas React UI: Entity modification functionalities (Add Classifications, Terms, Labels, Business Metadata) remain active for DELETED entities
* ATLAS-5342: Atlas React UI: Entity modification functionalities (Add Classifications, Terms, Labels, Business Metadata) remain active for DELETED entities
* ATLAS-5342: Atlas React UI: Entity modification functionalities (Add Classifications, Terms, Labels, Business Metadata) remain active for DELETED entities
* ATLAS-5342: Atlas React UI: Entity modification functionalities (Add Classifications, Terms, Labels, Business Metadata) remain active for DELETED entities
* ATLAS-5342: Atlas React UI: Entity modification functionalities (Add Classifications, Terms, Labels, Business Metadata) remain active for DELETED entities
---
dashboard/src/components/EntityDisplayImage.tsx | 11 +-
.../src/components/ShowMore/DrawerBodyChipView.tsx | 5 +-
dashboard/src/components/ShowMore/ShowMoreView.tsx | 15 +-
.../ShowMore/__tests__/DrawerBodyChipView.test.tsx | 38 ++++
.../ShowMore/__tests__/ShowMoreView.test.tsx | 51 ++++-
.../__tests__/EntityDisplayImage.test.tsx | 22 +-
dashboard/src/styles/propertiesTab.scss | 4 +
dashboard/src/utils/EntityStatus.ts | 27 +++
dashboard/src/utils/__tests__/EntityStatus.test.ts | 43 ++++
.../DashboardOverview/ClassificationCoverage.tsx | 2 +-
.../src/views/DetailPage/DetailPageAttributes.tsx | 158 +++++++-------
.../src/views/DetailPage/EntityDetailPage.tsx | 96 +++++----
.../EntityDetailTabs/AttributeProperties.tsx | 4 +-
.../EntityDetailTabs/ClassificationsTab.tsx | 8 +-
.../PropertiesTab/BMAttributes.tsx | 125 ++++++-----
.../EntityDetailTabs/PropertiesTab/Labels.tsx | 84 +++++---
.../PropertiesTab/PropertiesTab.tsx | 2 +-
.../PropertiesTab/UserDefinedProperties.tsx | 93 ++++----
.../PropertiesTab/__tests__/BMAttributes.test.tsx | 235 +++++++++++++++++++++
.../PropertiesTab/__tests__/Labels.test.tsx | 206 ++++++++++++++++++
.../__tests__/UserDefinedProperties.test.tsx | 226 ++++++++++++++++++++
.../__tests__/AttributeProperties.test.tsx | 58 +++++
.../__tests__/ClassificationsTab.test.tsx | 34 +++
.../__tests__/DetailPageAttributes.test.tsx | 65 ++++++
.../src/views/__tests__/EntityDetailPage.test.tsx | 65 +++++-
25 files changed, 1394 insertions(+), 283 deletions(-)
diff --git a/dashboard/src/components/EntityDisplayImage.tsx b/dashboard/src/components/EntityDisplayImage.tsx
index d9223d1d25..a4d67c7e52 100644
--- a/dashboard/src/components/EntityDisplayImage.tsx
+++ b/dashboard/src/components/EntityDisplayImage.tsx
@@ -18,6 +18,7 @@
import { useEffect, useState } from "react";
import { Avatar, Skeleton } from "@mui/material";
import { getEntityIconPath } from "../utils/Utils";
+import axios from "axios";
const DisplayImage = ({
entity,
@@ -36,10 +37,12 @@ const DisplayImage = ({
let entityData = { ...entity, ...{ isProcess: isProcess } };
let imagePath: any = getEntityIconPath({ entityData: entityData });
try {
- const response = await fetch(imagePath);
- const contentType: any = response.headers.get("Content-Type");
+ const response = await axios.get(imagePath, {
+ responseType: "blob"
+ });
+ const contentType: any = response.headers["content-type"];
- if (contentType.startsWith("image/")) {
+ if (contentType && contentType.startsWith("image/")) {
let cache = { [entityData.guid]: imagePath };
setCheckEntityImage(cache);
setImageUrl(getEntityIconPath({ entityData: entityData }));
@@ -48,7 +51,7 @@ const DisplayImage = ({
getEntityIconPath({ entityData: entityData, errorUrl: imagePath })
);
}
- } catch (error) {
+ } catch (_error) {
setImageUrl(
getEntityIconPath({ entityData: entityData, errorUrl: imagePath })
);
diff --git a/dashboard/src/components/ShowMore/DrawerBodyChipView.tsx b/dashboard/src/components/ShowMore/DrawerBodyChipView.tsx
index 8d6c06c6f1..01dd38a220 100644
--- a/dashboard/src/components/ShowMore/DrawerBodyChipView.tsx
+++ b/dashboard/src/components/ShowMore/DrawerBodyChipView.tsx
@@ -37,6 +37,7 @@ import SearchIcon from "@mui/icons-material/Search";
import ErrorRoundedIcon from "@mui/icons-material/ErrorRounded";
import { Link as MuiLink } from "@mui/material";
import { cloneDeep } from "@utils/Helper";
+import { isEntityModificationAllowed } from "@utils/EntityStatus";
const CHIP_MAX_WIDTH = "200px";
@@ -328,11 +329,11 @@ const DrawerBodyChipView = ({
</EllipsisText>
}
onDelete={
- !isEmpty(removeApiMethod) && !isDeleteIcon
+ isEntityModificationAllowed(currentEntity?.status) && !isEmpty(removeApiMethod) && !isDeleteIcon
? () => {
handleDelete(obj[displayKey] || obj);
}
- : isDeleteIcon && obj.count > 1
+ : isEntityModificationAllowed(currentEntity?.status) && isDeleteIcon && obj.count > 1
? () => {
const searchParams = new URLSearchParams();
searchParams.set("tabActive", "classification");
diff --git a/dashboard/src/components/ShowMore/ShowMoreView.tsx b/dashboard/src/components/ShowMore/ShowMoreView.tsx
index d6543410ea..a21c0980d4 100644
--- a/dashboard/src/components/ShowMore/ShowMoreView.tsx
+++ b/dashboard/src/components/ShowMore/ShowMoreView.tsx
@@ -19,7 +19,7 @@ import Typography from "@mui/material/Typography";
import Chip from "@mui/material/Chip";
import MuiLink from "@mui/material/Link";
import { LightTooltip } from "../muiComponents";
-import { useRef, useState } from "react";
+import { useEffect, useRef, useState } from "react";
import { EllipsisText } from "../commonComponents";
import { extractKeyValueFromEntity, isEmpty, serverError } from "@utils/Utils";
import { useAppDispatch, useAppSelector } from "@hooks/reducerHook";
@@ -31,8 +31,9 @@ import ErrorRoundedIcon from "@mui/icons-material/ErrorRounded";
import { fetchGlossaryData } from "@redux/slice/glossarySlice";
import { fetchGlossaryDetails } from "@redux/slice/glossaryDetailsSlice";
import ShowMoreDrawer from "./ShowMoreDrawer";
-import { openDrawer } from "@redux/slice/drawerSlice";
+import { openDrawer, closeDrawer } from "@redux/slice/drawerSlice";
import { cloneDeep } from "@utils/Helper";
+import { isEntityModificationAllowed } from "@utils/EntityStatus";
const CHIP_MAX_WIDTH = "200px";
@@ -73,6 +74,12 @@ const ShowMoreView = ({
const gType = searchParams.get("gtype");
const dispatchApi = useAppDispatch();
+ useEffect(() => {
+ return () => {
+ dispatchApi(closeDrawer());
+ };
+ }, [dispatchApi]);
+
const { classificationData = {} }: any = useAppSelector(
(state: any) => state.classification
);
@@ -310,13 +317,13 @@ const ShowMoreView = ({
}
component="a"
onDelete={
- !isEmpty(removeApiMethod) && !isDeleteIcon
+ !isEmpty(removeApiMethod) && !isDeleteIcon && isEntityModificationAllowed(currentEntity?.status)
? () => {
// Handle undefined displayKey by extracting a string value
const deleteValue = obj[displayKey] || obj.displayText || obj.text || obj.name || '';
handleDelete(deleteValue);
}
- : isDeleteIcon && obj.count > 1
+ : isDeleteIcon && obj.count > 1 && isEntityModificationAllowed(currentEntity?.status)
? () => {
const searchParams = new URLSearchParams();
searchParams.set("tabActive", "classification");
diff --git a/dashboard/src/components/ShowMore/__tests__/DrawerBodyChipView.test.tsx b/dashboard/src/components/ShowMore/__tests__/DrawerBodyChipView.test.tsx
index b5a3775114..a1f9bbbf8e 100644
--- a/dashboard/src/components/ShowMore/__tests__/DrawerBodyChipView.test.tsx
+++ b/dashboard/src/components/ShowMore/__tests__/DrawerBodyChipView.test.tsx
@@ -980,6 +980,44 @@ describe('DrawerBodyChipView', () => {
expect(mockNavigate).toHaveBeenCalled();
}
});
+
+ it('should not show delete icon for DELETED entities', () => {
+ render(
+ <TestWrapper>
+ <DrawerBodyChipView
+ {...defaultProps}
+ currentEntity={{ ...mockCurrentEntity, status: 'DELETED' }}
+ />
+ </TestWrapper>
+ );
+
+ const chips = screen.getAllByTestId('chip');
+ chips.forEach(chip => {
+ const deleteIcon = chip.querySelector('[data-testid="chip-delete-icon"]');
+ expect(deleteIcon).toBeNull();
+ const deleteButton = chip.querySelector('[data-testid="chip-delete-button"]');
+ expect(deleteButton).toBeNull();
+ });
+ });
+
+ it('should not show delete icon for PURGED entities', () => {
+ render(
+ <TestWrapper>
+ <DrawerBodyChipView
+ {...defaultProps}
+ currentEntity={{ ...mockCurrentEntity, status: 'PURGED' }}
+ />
+ </TestWrapper>
+ );
+
+ const chips = screen.getAllByTestId('chip');
+ chips.forEach(chip => {
+ const deleteIcon = chip.querySelector('[data-testid="chip-delete-icon"]');
+ expect(deleteIcon).toBeNull();
+ const deleteButton = chip.querySelector('[data-testid="chip-delete-button"]');
+ expect(deleteButton).toBeNull();
+ });
+ });
});
describe('Modal Functionality', () => {
diff --git a/dashboard/src/components/ShowMore/__tests__/ShowMoreView.test.tsx b/dashboard/src/components/ShowMore/__tests__/ShowMoreView.test.tsx
index 5e61825804..d70bef79e5 100644
--- a/dashboard/src/components/ShowMore/__tests__/ShowMoreView.test.tsx
+++ b/dashboard/src/components/ShowMore/__tests__/ShowMoreView.test.tsx
@@ -151,6 +151,9 @@ jest.mock('@redux/slice/drawerSlice', () => ({
openDrawer: jest.fn((id: string) => ({
type: 'drawer/openDrawer',
payload: id
+ })),
+ closeDrawer: jest.fn(() => ({
+ type: 'drawer/closeDrawer'
}))
}));
@@ -270,7 +273,7 @@ const { cloneDeep } = require('@utils/Helper');
const { fetchDetailPageData } = require('@redux/slice/detailPageSlice');
const { fetchGlossaryData } = require('@redux/slice/glossarySlice');
const { fetchGlossaryDetails } = require('@redux/slice/glossaryDetailsSlice');
-const { openDrawer } = require('@redux/slice/drawerSlice');
+const { openDrawer, closeDrawer } = require('@redux/slice/drawerSlice');
const { toast } = require('react-toastify');
const TestWrapper: React.FC<React.PropsWithChildren<{}>> = ({ children }) => (
@@ -739,6 +742,45 @@ describe('ShowMoreView', () => {
});
describe('Delete Icon Functionality', () => {
+ it('should not show delete button when currentEntity status is DELETED', () => {
+ const dataWithCount = [
+ { typeName: 'Tag1' }
+ ];
+
+ render(
+ <TestWrapper>
+ <ShowMoreView
+ {...defaultProps}
+ data={dataWithCount}
+ removeApiMethod={jest.fn()}
+ currentEntity={{ guid: 'entity-guid-123', status: 'DELETED' }}
+ />
+ </TestWrapper>
+ );
+
+ expect(screen.queryByTestId('chip-ondelete-button')).not.toBeInTheDocument();
+ });
+
+ it('should not show delete button when currentEntity status is PURGED', () => {
+ const dataWithCount = [
+ { typeName: 'Tag1' }
+ ];
+
+ render(
+ <TestWrapper>
+ <ShowMoreView
+ {...defaultProps}
+ data={dataWithCount}
+ removeApiMethod={jest.fn()}
+ currentEntity={{ guid: 'entity-guid-123', status: 'PURGED' }}
+ />
+ </TestWrapper>
+ );
+
+ expect(screen.queryByTestId('chip-ondelete-button')).not.toBeInTheDocument();
+ });
+
+
it('should show count when isDeleteIcon is true and count > 1', () => {
const dataWithCount = [
{ typeName: 'Tag1', count: 2 },
@@ -1726,6 +1768,7 @@ describe('ShowMoreView', () => {
expect(removeApiMethod).toHaveBeenCalled();
}, { timeout: 3000 });
});
+ });
describe('Display Key Variations', () => {
it('should handle different displayKey values', () => {
@@ -1797,6 +1840,10 @@ describe('ShowMoreView', () => {
expect(screen.queryByTestId('custom-modal')).not.toBeInTheDocument();
});
});
-});
+ it('should dispatch closeDrawer on unmount', () => {
+ const { unmount } = render(<TestWrapper><ShowMoreView {...defaultProps} /></TestWrapper>);
+ unmount();
+ expect(closeDrawer).toHaveBeenCalled();
+ });
});
diff --git a/dashboard/src/components/__tests__/EntityDisplayImage.test.tsx b/dashboard/src/components/__tests__/EntityDisplayImage.test.tsx
index 41f759ec1d..04dc1c5c6d 100644
--- a/dashboard/src/components/__tests__/EntityDisplayImage.test.tsx
+++ b/dashboard/src/components/__tests__/EntityDisplayImage.test.tsx
@@ -29,6 +29,7 @@
import React from 'react'
import { render, waitFor, act } from '@testing-library/react'
import DisplayImage from '../EntityDisplayImage'
+import axios from 'axios'
// Import Utils to spy on it
import * as Utils from '../../utils/Utils'
@@ -41,18 +42,15 @@ jest.mock('../../utils/Utils', () => ({
}))
const mockFetch = (contentType: string | null, shouldReject?: boolean) => {
- if (shouldReject) {
- (global as any).fetch = jest.fn().mockRejectedValue(new Error('fetch failed'))
- return
- }
- (global as any).fetch = jest.fn().mockResolvedValue({
- ok: true,
- headers: {
- get: jest.fn((header: string) => {
- return header === 'Content-Type' ? (contentType || '') : null
- })
- }
- })
+ if (shouldReject) {
+ jest.spyOn(axios, 'get').mockRejectedValue(new Error('fetch failed'))
+ return
+ }
+ jest.spyOn(axios, 'get').mockResolvedValue({
+ headers: {
+ "content-type": contentType || ''
+ }
+ })
}
describe('EntityDisplayImage', () => {
diff --git a/dashboard/src/styles/propertiesTab.scss b/dashboard/src/styles/propertiesTab.scss
index 3c9076a175..748c37198f 100644
--- a/dashboard/src/styles/propertiesTab.scss
+++ b/dashboard/src/styles/propertiesTab.scss
@@ -78,3 +78,7 @@
.audit-attributes-item:nth-child(3) {
flex: 0 0 100%;
}
+
+.text-underline {
+ text-decoration: underline;
+}
diff --git a/dashboard/src/utils/EntityStatus.ts b/dashboard/src/utils/EntityStatus.ts
new file mode 100644
index 0000000000..a063bdc88f
--- /dev/null
+++ b/dashboard/src/utils/EntityStatus.ts
@@ -0,0 +1,27 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+export enum EntityStatus {
+ ACTIVE = "ACTIVE",
+ DELETED = "DELETED",
+ PURGED = "PURGED"
+}
+
+export const isEntityModificationAllowed = (status: EntityStatus | string | undefined): boolean => {
+ if (!status) return true;
+ return status !== EntityStatus.DELETED && status !== EntityStatus.PURGED;
+};
diff --git a/dashboard/src/utils/__tests__/EntityStatus.test.ts b/dashboard/src/utils/__tests__/EntityStatus.test.ts
new file mode 100644
index 0000000000..59ce1c0397
--- /dev/null
+++ b/dashboard/src/utils/__tests__/EntityStatus.test.ts
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 { EntityStatus, isEntityModificationAllowed } from '../EntityStatus';
+
+describe('isEntityModificationAllowed', () => {
+ it('should return true for ACTIVE status', () => {
+ expect(isEntityModificationAllowed(EntityStatus.ACTIVE)).toBe(true);
+ expect(isEntityModificationAllowed('ACTIVE')).toBe(true);
+ });
+
+ it('should return false for DELETED status', () => {
+ expect(isEntityModificationAllowed(EntityStatus.DELETED)).toBe(false);
+ expect(isEntityModificationAllowed('DELETED')).toBe(false);
+ });
+
+ it('should return false for PURGED status', () => {
+ expect(isEntityModificationAllowed(EntityStatus.PURGED)).toBe(false);
+ expect(isEntityModificationAllowed('PURGED')).toBe(false);
+ });
+
+ it('should return true for undefined status', () => {
+ expect(isEntityModificationAllowed(undefined)).toBe(true);
+ });
+
+ it('should return true for unknown status', () => {
+ expect(isEntityModificationAllowed('UNKNOWN_STATUS')).toBe(true);
+ });
+});
diff --git a/dashboard/src/views/DashboardOverview/ClassificationCoverage.tsx b/dashboard/src/views/DashboardOverview/ClassificationCoverage.tsx
index 03c27b94dc..faa54da6ae 100644
--- a/dashboard/src/views/DashboardOverview/ClassificationCoverage.tsx
+++ b/dashboard/src/views/DashboardOverview/ClassificationCoverage.tsx
@@ -289,7 +289,7 @@ const ClassificationCoverage = memo(
aria-label="Open classification search"
>
{numberFormatWithComma(typesInUse)} of{" "}
- {numberFormatWithComma(classificationTypeDefinitions)}
+ {numberFormatWithComma(classificationTypeDefinitions)}{" "}
classification types are in use (have at least one entity).
</Typography>
</Stack>
diff --git a/dashboard/src/views/DetailPage/DetailPageAttributes.tsx b/dashboard/src/views/DetailPage/DetailPageAttributes.tsx
index 3bbe6fe402..c7e160b499 100644
--- a/dashboard/src/views/DetailPage/DetailPageAttributes.tsx
+++ b/dashboard/src/views/DetailPage/DetailPageAttributes.tsx
@@ -41,6 +41,7 @@ const getDescriptionForDisplay = (desc: unknown): string => {
};
import { useState } from "react";
import { useAppSelector } from "@hooks/reducerHook";
+
import { toast } from "react-toastify";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import { removeClassification } from "@api/apiMethods/classificationApiMethod";
@@ -57,6 +58,7 @@ import AddTagAttributes from "@views/Classification/AddTagAttributes";
import AssignCategory from "@views/Glossary/AssignCategory";
import AssignTerm from "@views/Glossary/AssignTerm";
import ShowMoreText from "@components/ShowMore/ShowMoreText";
+import { isEntityModificationAllowed } from "@utils/EntityStatus";
const DetailPageAttribute = ({
data,
@@ -147,7 +149,7 @@ const DetailPageAttribute = ({
{name}{" "}
</Typography>
</LightTooltip>
- {isEmpty(bmguid) && (
+ {isEmpty(bmguid) && !loading && isEntityModificationAllowed(data?.status) && (
<LightTooltip title={"Edit Classification"}>
<CustomButton
variant="outlined"
@@ -315,23 +317,25 @@ const DetailPageAttribute = ({
>
Classifications
</Typography>
- <LightTooltip title={"Add Classifications"}>
- <IconButton
- component="label"
- role={undefined}
- tabIndex={-1}
- size="small"
- color="primary"
- onClick={() => {
- setOpenAddTagModal(true);
- }}
- >
- <AddCircleOutlineIcon
- className="mr-0"
- fontSize="small"
- />{" "}
- </IconButton>
- </LightTooltip>
+ {!loading && isEntityModificationAllowed(data?.status) && (
+ <LightTooltip title={"Add Classifications"}>
+ <IconButton
+ component="label"
+ role={undefined}
+ tabIndex={-1}
+ size="small"
+ color="primary"
+ onClick={() => {
+ setOpenAddTagModal(true);
+ }}
+ >
+ <AddCircleOutlineIcon
+ className="mr-0"
+ fontSize="small"
+ />{" "}
+ </IconButton>
+ </LightTooltip>
+ )}
</Stack>
<Stack
data-cy="tagListTerm"
@@ -383,28 +387,30 @@ const DetailPageAttribute = ({
>
Terms
</Typography>
- <LightTooltip title={"Add Term"}>
- <IconButton
- component="label"
- role={undefined}
- tabIndex={-1}
- size="small"
- color="primary"
- onClick={() => {
- if (!hasAnyGlossaryTerms) {
- toast.dismiss();
- toast.info("There are no available terms");
- return;
- }
- setOpenAddTermModal(true);
- }}
- >
- <AddCircleOutlineIcon
- className="mr-0"
- fontSize="small"
- />{" "}
- </IconButton>
- </LightTooltip>
+ {!loading && isEntityModificationAllowed(data?.status) && (
+ <LightTooltip title={"Add Term"}>
+ <IconButton
+ component="label"
+ role={undefined}
+ tabIndex={-1}
+ size="small"
+ color="primary"
+ onClick={() => {
+ if (!hasAnyGlossaryTerms) {
+ toast.dismiss();
+ toast.info("There are no available terms");
+ return;
+ }
+ setOpenAddTermModal(true);
+ }}
+ >
+ <AddCircleOutlineIcon
+ className="mr-0"
+ fontSize="small"
+ />{" "}
+ </IconButton>
+ </LightTooltip>
+ )}
</Stack>
<Stack
data-cy="termList"
@@ -455,23 +461,25 @@ const DetailPageAttribute = ({
>
Categories
</Typography>
- <LightTooltip title={"Add Categories"}>
- <IconButton
- component="label"
- role={undefined}
- tabIndex={-1}
- size="small"
- color="primary"
- onClick={() => {
- setCategoryModal(true);
- }}
- >
- <AddCircleOutlineIcon
- className="mr-0"
- fontSize="small"
- />{" "}
- </IconButton>
- </LightTooltip>
+ {!loading && isEntityModificationAllowed(data?.status) && (
+ <LightTooltip title={"Add Categories"}>
+ <IconButton
+ component="label"
+ role={undefined}
+ tabIndex={-1}
+ size="small"
+ color="primary"
+ onClick={() => {
+ setCategoryModal(true);
+ }}
+ >
+ <AddCircleOutlineIcon
+ className="mr-0"
+ fontSize="small"
+ />{" "}
+ </IconButton>
+ </LightTooltip>
+ )}
</Stack>
<Stack
data-cy="categoryList"
@@ -614,23 +622,25 @@ const DetailPageAttribute = ({
>
Attributes:
</Typography>
- <LightTooltip title={"Add Attributes"}>
- <IconButton
- component="label"
- role={undefined}
- tabIndex={-1}
- size="small"
- color="primary"
- onClick={() => {
- setAttributeModal(true);
- }}
- >
- <AddCircleOutlineIcon
- className="mr-0"
- fontSize="small"
- />{" "}
- </IconButton>
- </LightTooltip>
+ {!loading && isEntityModificationAllowed(data?.status) && (
+ <LightTooltip title={"Add Attributes"}>
+ <IconButton
+ component="label"
+ role={undefined}
+ tabIndex={-1}
+ size="small"
+ color="primary"
+ onClick={() => {
+ setAttributeModal(true);
+ }}
+ >
+ <AddCircleOutlineIcon
+ className="mr-0"
+ fontSize="small"
+ />{" "}
+ </IconButton>
+ </LightTooltip>
+ )}
</Stack>
<Stack
diff --git a/dashboard/src/views/DetailPage/EntityDetailPage.tsx b/dashboard/src/views/DetailPage/EntityDetailPage.tsx
index 2b20e15a72..27c9b84c27 100644
--- a/dashboard/src/views/DetailPage/EntityDetailPage.tsx
+++ b/dashboard/src/views/DetailPage/EntityDetailPage.tsx
@@ -39,11 +39,13 @@ import { fetchDetailPageData } from "@redux/slice/detailPageSlice";
import { normalizeSchemaElementsAttribute } from "@utils/schemaElementsAttributeUtils";
import { SchemaTabCacheState } from "@models/schemaTabTypes";
import React from "react";
+
import AddTag from "@views/Classification/AddTag";
import AssignTerm from "@views/Glossary/AssignTerm";
import { removeTerm } from "@api/apiMethods/glossaryApiMethod";
import AddCircleOutlineIcon from "@mui/icons-material/AddCircleOutline";
import ShowMoreView from "@components/ShowMore/ShowMoreView";
+import { isEntityModificationAllowed } from "@utils/EntityStatus";
const PropertiesTab = lazy(
() => import("./EntityDetailTabs/PropertiesTab/PropertiesTab")
@@ -115,14 +117,14 @@ const EntityDetailPage: React.FC = () => {
let entityObj =
!isEmpty(entityDefObj) && !isEmpty(entity)
? entityDefObj.find((obj: { name: string }) => {
- return obj.name == entity.typeName;
- })
+ return obj.name == entity.typeName;
+ })
: {};
let superTypes = !isEmpty(entityDefObj)
? getNestedSuperTypes({
- data: entityObj,
- collection: entityDefObj
- })
+ data: entityObj,
+ collection: entityDefObj
+ })
: [];
let isLineageRender: boolean | null = superTypes.find((type) => {
if (type === "DataSet" || type === "Process") {
@@ -327,7 +329,7 @@ const EntityDetailPage: React.FC = () => {
className="detail-page-paper"
variant="outlined"
>
- {loading ? (
+ {loading || detailPageData === null ? (
<Stack direction="row" spacing={2} alignItems="center">
<SkeletonLoader
count={1}
@@ -387,7 +389,7 @@ const EntityDetailPage: React.FC = () => {
}}
>
<Stack>
- {loading ? (
+ {loading || detailPageData === null ? (
<Stack direction="column" spacing={2} alignItems="left">
<SkeletonLoader
count={1}
@@ -408,20 +410,22 @@ const EntityDetailPage: React.FC = () => {
>
Classifications
</Typography>
- <LightTooltip title={"Add Classifications"}>
- <IconButton
- component="label"
- role={undefined}
- tabIndex={-1}
- size="small"
- color="primary"
- onClick={() => {
- setOpenAddTagModal(true);
- }}
- >
- <AddCircleOutlineIcon className="mr-0" fontSize="small" />{" "}
- </IconButton>
- </LightTooltip>
+ {!loading && isEntityModificationAllowed(entity?.status) && (
+ <LightTooltip title={"Add Classifications"}>
+ <IconButton
+ component="label"
+ role={undefined}
+ tabIndex={-1}
+ size="small"
+ color="primary"
+ onClick={() => {
+ setOpenAddTagModal(true);
+ }}
+ >
+ <AddCircleOutlineIcon className="mr-0" fontSize="small" />{" "}
+ </IconButton>
+ </LightTooltip>
+ )}
</Stack>
<Stack
@@ -448,7 +452,7 @@ const EntityDetailPage: React.FC = () => {
{!entity?.typeName?.includes("AtlasGlossary") && (
<Stack>
- {loading ? (
+ {loading || detailPageData === null ? (
<Stack direction="column" spacing={2} alignItems="flex-start">
<SkeletonLoader
count={1}
@@ -469,28 +473,30 @@ const EntityDetailPage: React.FC = () => {
>
Terms
</Typography>
- <LightTooltip title="Add Term">
- <IconButton
- component="label"
- role={undefined}
- tabIndex={-1}
- size="small"
- color="primary"
- onClick={() => {
- if (!hasAnyGlossaryTerms) {
- toast.dismiss();
- toast.info("There are no available terms");
- return;
- }
- setOpenAddTermModal(true);
- }}
- >
- <AddCircleOutlineIcon
- className="mr-0"
- fontSize="small"
- />
- </IconButton>
- </LightTooltip>
+ {!loading && isEntityModificationAllowed(entity?.status) && (
+ <LightTooltip title="Add Term">
+ <IconButton
+ component="label"
+ role={undefined}
+ tabIndex={-1}
+ size="small"
+ color="primary"
+ onClick={() => {
+ if (!hasAnyGlossaryTerms) {
+ toast.dismiss();
+ toast.info("There are no available terms");
+ return;
+ }
+ setOpenAddTermModal(true);
+ }}
+ >
+ <AddCircleOutlineIcon
+ className="mr-0"
+ fontSize="small"
+ />
+ </IconButton>
+ </LightTooltip>
+ )}
</Stack>
<Stack
@@ -589,7 +595,7 @@ const EntityDetailPage: React.FC = () => {
<LinkTab
label={
entity.typeName == "hive_db" ||
- entity.typeName == "hbase_namespace"
+ entity.typeName == "hbase_namespace"
? "Tables"
: "Table"
}
diff --git a/dashboard/src/views/DetailPage/EntityDetailTabs/AttributeProperties.tsx b/dashboard/src/views/DetailPage/EntityDetailTabs/AttributeProperties.tsx
index 8efb029e7b..de70ac7b2c 100644
--- a/dashboard/src/views/DetailPage/EntityDetailTabs/AttributeProperties.tsx
+++ b/dashboard/src/views/DetailPage/EntityDetailTabs/AttributeProperties.tsx
@@ -35,6 +35,8 @@ import { useAppSelector } from "@hooks/reducerHook";
import { AntSwitch } from "@utils/Muiutils";
import { cloneDeep } from "@utils/Helper";
+import { isEntityModificationAllowed } from "@utils/EntityStatus";
+
const AttributeProperties = ({
entity,
referredEntities,
@@ -188,7 +190,7 @@ const AttributeProperties = ({
inputProps={{ "aria-label": "controlled" }}
/>
</LightTooltip>
- {entityUpdate && (
+ {entityUpdate && !loading && isEntityModificationAllowed(entity?.status) && (
<LightTooltip title={"Edit Entity"}>
<CustomButton
variant="outlined"
diff --git a/dashboard/src/views/DetailPage/EntityDetailTabs/ClassificationsTab.tsx b/dashboard/src/views/DetailPage/EntityDetailTabs/ClassificationsTab.tsx
index 6b215d9ed7..8c3954c039 100644
--- a/dashboard/src/views/DetailPage/EntityDetailTabs/ClassificationsTab.tsx
+++ b/dashboard/src/views/DetailPage/EntityDetailTabs/ClassificationsTab.tsx
@@ -42,6 +42,7 @@ import DeleteOutlinedIcon from "@mui/icons-material/DeleteOutlined";
import { isEntityPurged } from "@utils/Enum";
import CustomModal from "@components/Modal";
import ErrorRoundedIcon from "@mui/icons-material/ErrorRounded";
+
import { removeClassification } from "@api/apiMethods/classificationApiMethod";
import { toast } from "react-toastify";
import AttributeTable from "../AttributeTable";
@@ -50,6 +51,7 @@ import moment from "moment";
import { useAppDispatch } from "@hooks/reducerHook";
import { AntSwitch } from "@utils/Muiutils";
import { fetchDetailPageData } from "@redux/slice/detailPageSlice";
+import { isEntityModificationAllowed } from "@utils/EntityStatus";
const ClassificationsTab: React.FC<EntityDetailTabProps> = ({
entity,
@@ -255,7 +257,7 @@ const ClassificationsTab: React.FC<EntityDetailTabProps> = ({
let values = info.row.original;
return (
<Stack direction="row" gap={1}>
- {(guid == values?.entityGuid ||
+ {!loading && isEntityModificationAllowed(entity?.status) && (guid == values?.entityGuid ||
(guid != values?.entityGuid &&
values.entityStatus == "DELETED")) && (
<LightTooltip title={"Delete Classification"}>
@@ -279,7 +281,7 @@ const ClassificationsTab: React.FC<EntityDetailTabProps> = ({
</CustomButton>
</LightTooltip>
)}
- {guid == values?.entityGuid && (
+ {!loading && isEntityModificationAllowed(entity?.status) && guid == values?.entityGuid && (
<LightTooltip title={"Edit Classification"}>
<CustomButton
variant="outlined"
@@ -305,7 +307,7 @@ const ClassificationsTab: React.FC<EntityDetailTabProps> = ({
enableSorting: false
}
],
- [updateTable]
+ [updateTable, entity]
);
return (
diff --git a/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/BMAttributes.tsx b/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/BMAttributes.tsx
index 2fb291ebdd..b9c20418a1 100644
--- a/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/BMAttributes.tsx
+++ b/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/BMAttributes.tsx
@@ -58,26 +58,34 @@ import { cloneDeep } from "@utils/Helper";
import moment from "moment-timezone";
import { fetchDetailPageData } from "@redux/slice/detailPageSlice";
+import { isEntityModificationAllowed } from "@utils/EntityStatus";
+
const defaultField = {
key: null,
value: ""
};
-const BMAttributes = ({ loading, bmAttributes, entity }: any) => {
+type BMAttributesProps = {
+ loading: boolean | undefined;
+ bmAttributes: Record<string, any>;
+ entity: any;
+};
+
+const BMAttributes = ({ loading, bmAttributes, entity }: BMAttributesProps) => {
const dispatchApi = useAppDispatch();
- const { guid }: any = useParams();
- const toastId: any = useRef(null);
- const { entityData }: any = useAppSelector((state: any) => state.entity);
+ const { guid } = useParams();
+ const toastId = useRef<number | string | null>(null);
+ const { entityData } = useAppSelector((state: any) => state.entity);
const { entityDefs } = entityData || {};
let filterEntityData = cloneDeep(entityDefs);
- const { businessMetaData }: any = useAppSelector(
+ const { businessMetaData } = useAppSelector(
(state: any) => state.businessMetaData
);
const { businessMetadataDefs } = businessMetaData || {};
let businessAttributes = cloneDeep(bmAttributes);
let bmAttributesData = Object.entries(businessAttributes).map(
- ([key, value]: any) => {
+ ([key, value]: [string, any]) => {
let foundBusinessMetadata = businessMetadataDefs.find(
(obj: { name: any }) => obj.name == key
);
@@ -211,7 +219,7 @@ const BMAttributes = ({ loading, bmAttributes, entity }: any) => {
control
});
- const onSubmit = async (values: { businessMetadata: any }) => {
+ const onSubmit = async (values: { businessMetadata: any[] }) => {
let formData = { ...values };
const { businessMetadata } = formData;
let data: Record<string, any> = {};
@@ -300,7 +308,7 @@ const BMAttributes = ({ loading, bmAttributes, entity }: any) => {
handleSubmit(onSubmit)();
};
- const renderValues = (values: any) => {
+ const renderValues = (values: { value: any; typeName: string }) => {
const { value, typeName } = values;
if (
@@ -376,22 +384,24 @@ const BMAttributes = ({ loading, bmAttributes, entity }: any) => {
<Stack direction="row" alignItems="center" gap="0.5rem">
{addLabel ? (
- <CustomButton
- key="edit-Add-button"
- variant="outlined"
- color="success"
- size="small"
- onClick={(e: { stopPropagation: () => void }) => {
- e.stopPropagation();
- setExpanded("bmDataPanel");
- setAddLabel(false);
- if (!isEmpty(defaultFieldValues)) {
- reset({ businessMetadata: defaultFieldValues });
- }
- }}
- >
- {!isEmpty(bmAttributes) ? "Edit" : "Add"}
- </CustomButton>
+ !loading && isEntityModificationAllowed(entity?.status) && (
+ <CustomButton
+ key="edit-Add-button"
+ variant="outlined"
+ color="success"
+ size="small"
+ onClick={(e: { stopPropagation: () => void }) => {
+ e.stopPropagation();
+ setExpanded("bmDataPanel");
+ setAddLabel(false);
+ if (!isEmpty(defaultFieldValues)) {
+ reset({ businessMetadata: defaultFieldValues });
+ }
+ }}
+ >
+ {!isEmpty(bmAttributes) ? "Edit" : "Add"}
+ </CustomButton>
+ )
) : (
<>
<CustomButton
@@ -477,7 +487,7 @@ const BMAttributes = ({ loading, bmAttributes, entity }: any) => {
</Stack>
</AccordionSummary>
- {Object.entries(obj).map(([key, value]: any) => {
+ {Object.entries(obj).map(([key, value]: [string, any]) => {
return (
<>
{key !=
@@ -536,41 +546,48 @@ const BMAttributes = ({ loading, bmAttributes, entity }: any) => {
justifyContent="center"
>
<span>
- No properties have been created yet. To add a
- property, click{" "}
- <Typography
- className="text-color-green cursor-pointer"
- component="span"
- onClick={(e: { stopPropagation: () => void }) => {
- e.stopPropagation();
- setAddLabel(false);
- }}
- style={{ textDecoration: "underline" }}
- >
- here
- </Typography>
+ {!isEntityModificationAllowed(entity?.status) ? (
+ "No properties have been created yet."
+ ) : (
+ <>
+ No properties have been created yet. To add a
+ property, click{" "}
+ <Typography
+ className="text-color-green cursor-pointer text-underline"
+ component="span"
+ onClick={(e: { stopPropagation: () => void }) => {
+ e.stopPropagation();
+ setAddLabel(false);
+ }}
+ >
+ here
+ </Typography>
+ </>
+ )}
</span>
</Stack>
)}
</>
) : (
<>
- <LightTooltip title={"Add New Attribute"}>
- <CustomButton
- sx={{
- alignSelf: "flex-start"
- }}
- variant="outlined"
- size="small"
- onClick={(e: any) => {
- e.stopPropagation();
- append(defaultField);
- }}
- startIcon={<AddIcon fontSize="small" />}
- >
- Add New Attributes
- </CustomButton>
- </LightTooltip>
+ {!loading && isEntityModificationAllowed(entity?.status) && (
+ <LightTooltip title={"Add New Attribute"}>
+ <CustomButton
+ sx={{
+ alignSelf: "flex-start"
+ }}
+ variant="outlined"
+ size="small"
+ onClick={(e: React.MouseEvent) => {
+ e.stopPropagation();
+ append(defaultField);
+ }}
+ startIcon={<AddIcon fontSize="small" />}
+ >
+ Add New Attributes
+ </CustomButton>
+ </LightTooltip>
+ )}
{fields.map((field, index) => {
const keySelected = !isEmpty(
bmAttributesValues?.[index]?.key
diff --git a/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/Labels.tsx b/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/Labels.tsx
index 77b4f21b7c..85c5265e78 100644
--- a/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/Labels.tsx
+++ b/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/Labels.tsx
@@ -44,11 +44,20 @@ import { getLabels } from "@api/apiMethods/detailpageApiMethod";
import { useAppDispatch } from "@hooks/reducerHook";
import { fetchDetailPageData } from "@redux/slice/detailPageSlice";
-const filter = createFilterOptions<any>();
+import { isEntityModificationAllowed } from "@utils/EntityStatus";
-const Labels = ({ loading, labels }: any) => {
- const { guid }: any = useParams();
- const toastId: any = useRef(null);
+type LabelOption = string | { inputValue?: string; value?: string };
+const filter = createFilterOptions<LabelOption>();
+
+type LabelsProps = {
+ loading: boolean | undefined;
+ labels: string[];
+ entity: any;
+};
+
+const Labels = ({ loading, labels, entity }: LabelsProps) => {
+ const { guid } = useParams();
+ const toastId = useRef<number | string | null>(null);
const dispatchApi = useAppDispatch();
const [addLabel, setAddLabel] = useState<boolean>(true);
const [expanded, setExpanded] = useState<string | false>(false);
@@ -100,7 +109,7 @@ const Labels = ({ loading, labels }: any) => {
setLoader(false);
setOpen(false);
};
- const onInputChange = (_event: any, value: string) => {
+ const onInputChange = (_event: React.SyntheticEvent, value: string) => {
if (value) {
setOpen(true);
setLoader(true);
@@ -135,15 +144,15 @@ const Labels = ({ loading, labels }: any) => {
return out;
};
- const onSubmit = async (values: any) => {
+ const onSubmit = async (values: Record<string, any>) => {
const formData = { ...values };
const payload = normalizeLabelsPayload(formData.labels);
if (payload.length === 0 && (!labels || labels.length === 0)) {
return;
}
try {
- await getLabels(guid, payload);
- toast.dismiss(toastId.current);
+ await getLabels(guid as string, payload);
+ if (toastId.current) { toast.dismiss(toastId.current); }
toastId.current = toast.success(
"One or more labels were updated successfully"
);
@@ -154,7 +163,7 @@ const Labels = ({ loading, labels }: any) => {
setAddLabel(true);
} catch (error) {
- toast.dismiss(toastId.current);
+ if (toastId.current) { toast.dismiss(toastId.current); }
serverError(error, toastId);
}
};
@@ -189,19 +198,21 @@ const Labels = ({ loading, labels }: any) => {
<Stack direction="row" alignItems="center" gap="0.5rem">
{addLabel ? (
- <CustomButton
- key="edit-Add-button"
- variant="outlined"
- color="success"
- size="small"
- onClick={(e: { stopPropagation: () => void }) => {
- e.stopPropagation();
- setExpanded("labelsPanel");
- setAddLabel(false);
- }}
- >
- {!isEmpty(labels) ? "Edit" : "Add"}
- </CustomButton>
+ !loading && isEntityModificationAllowed(entity?.status) && (
+ <CustomButton
+ key="edit-Add-button"
+ variant="outlined"
+ color="success"
+ size="small"
+ onClick={(e: { stopPropagation: () => void }) => {
+ e.stopPropagation();
+ setExpanded("labelsPanel");
+ setAddLabel(false);
+ }}
+ >
+ {!isEmpty(labels) ? "Edit" : "Add"}
+ </CustomButton>
+ )
) : (
<>
<CustomButton
@@ -275,18 +286,23 @@ const Labels = ({ loading, labels }: any) => {
})
) : (
<span>
- No labels have been created yet. To add a labels, click{" "}
- <Typography
- className="text-color-green cursor-pointer"
- component="span"
- onClick={(e: { stopPropagation: () => void }) => {
- e.stopPropagation();
- setAddLabel(false);
- }}
- style={{ textDecoration: "underline" }}
- >
- here
- </Typography>
+ {!isEntityModificationAllowed(entity?.status) ? (
+ "No labels have been created yet."
+ ) : (
+ <>
+ No labels have been created yet. To add a labels, click{" "}
+ <Typography
+ component="span"
+ onClick={(e: { stopPropagation: () => void }) => {
+ e.stopPropagation();
+ setAddLabel(false);
+ }}
+ className="text-color-green cursor-pointer text-underline"
+ >
+ here
+ </Typography>
+ </>
+ )}
</span>
)}
</>
diff --git a/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/PropertiesTab.tsx b/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/PropertiesTab.tsx
index 0a47303208..8b8705a9ee 100644
--- a/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/PropertiesTab.tsx
+++ b/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/PropertiesTab.tsx
@@ -56,7 +56,7 @@ const PropertiesTab = (props: {
customAttributes={customAttributes}
entity={entity}
/>
- <Labels loading={loading} labels={labels} />
+ <Labels loading={loading} labels={labels} entity={entity} />
<BMAttributes
loading={loading}
diff --git a/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/UserDefinedProperties.tsx b/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/UserDefinedProperties.tsx
index e198fb07e1..735dbc05ea 100644
--- a/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/UserDefinedProperties.tsx
+++ b/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/UserDefinedProperties.tsx
@@ -51,15 +51,23 @@ import { cloneDeep } from "@utils/Helper";
import { fetchDetailPageData } from "@redux/slice/detailPageSlice";
import { enrichEntityPayloadForRelationshipSave } from "@utils/entityPayloadEnrichmentUtils";
+import { isEntityModificationAllowed } from "@utils/EntityStatus";
+
const defaultField = {
key: "",
value: ""
};
-const UserDefinedProperties = ({ loading, customAttributes, entity }: any) => {
+type UserDefinedPropertiesProps = {
+ loading: boolean | undefined;
+ customAttributes: Record<string, any>;
+ entity: any;
+};
+
+const UserDefinedProperties = ({ loading, customAttributes, entity }: UserDefinedPropertiesProps) => {
const dispatchApi = useAppDispatch();
- const { guid }: any = useParams();
- const toastId: any = useRef(null);
+ const { guid } = useParams();
+ const toastId = useRef<number | string | null>(null);
const [addLabel, setAddLabel] = useState<boolean>(true);
const [expanded, setExpanded] = useState<string | false>(false);
let attributes = cloneDeep(customAttributes);
@@ -101,7 +109,7 @@ const UserDefinedProperties = ({ loading, customAttributes, entity }: any) => {
}
};
- const structureAttributes = (list: any) => {
+ const structureAttributes = (list: { key: string; value: string }[]) => {
const obj: Record<string, string> = {};
if (!Array.isArray(list)) {
return obj;
@@ -119,7 +127,7 @@ const UserDefinedProperties = ({ loading, customAttributes, entity }: any) => {
return obj;
};
- const onSubmit = async (values: any) => {
+ const onSubmit = async (values: Record<string, any>) => {
let formData = { ...values };
let entityObj = cloneDeep(entity);
let properties = structureAttributes(formData.customAttributes);
@@ -152,10 +160,10 @@ const UserDefinedProperties = ({ loading, customAttributes, entity }: any) => {
handleSubmit(onSubmit)();
};
- const validateKeyUnique = (value: any, index: number): any => {
+ const validateKeyUnique = (value: string, index: number): string | boolean => {
const items = getValues("customAttributes");
const duplicate = items.some(
- (item: { key: any }, i: any) => item.key === value && i !== index
+ (item: { key: string }, i: number) => item.key === value && i !== index
);
return duplicate ? "Key must be unique" : true;
};
@@ -194,22 +202,24 @@ const UserDefinedProperties = ({ loading, customAttributes, entity }: any) => {
<Stack direction="row" alignItems="center" gap="0.5rem">
{addLabel ? (
- <CustomButton
- key="edit-Add-button"
- variant="outlined"
- color="success"
- size="small"
- onClick={(e: { stopPropagation: () => void }) => {
- e.stopPropagation();
- setExpanded("userDefinedPanel");
- setAddLabel(false);
- if (!isEmpty(defaultFieldValues)) {
- reset({ customAttributes: defaultFieldValues });
- }
- }}
- >
- {!isEmpty(customAttributes) ? "Edit" : "Add"}
- </CustomButton>
+ !loading && isEntityModificationAllowed(entity?.status) && (
+ <CustomButton
+ key="edit-Add-button"
+ variant="outlined"
+ color="success"
+ size="small"
+ onClick={(e: { stopPropagation: () => void }) => {
+ e.stopPropagation();
+ setExpanded("userDefinedPanel");
+ setAddLabel(false);
+ if (!isEmpty(defaultFieldValues)) {
+ reset({ customAttributes: defaultFieldValues });
+ }
+ }}
+ >
+ {!isEmpty(customAttributes) ? "Edit" : "Add"}
+ </CustomButton>
+ )
) : (
<>
<CustomButton
@@ -267,7 +277,7 @@ const UserDefinedProperties = ({ loading, customAttributes, entity }: any) => {
{!isEmpty(customAttributes) ? (
Object.entries(customAttributes)
.sort()
- .map(([keys, value]: [string, any]) => {
+ .map(([keys, value]: [string, string]) => {
return (
<>
<Stack
@@ -303,19 +313,24 @@ const UserDefinedProperties = ({ loading, customAttributes, entity }: any) => {
})
) : (
<span>
- No properties have been created yet. To add a
- property,click{" "}
- <Typography
- className="text-color-green cursor-pointer"
- component="span"
- onClick={(e: { stopPropagation: () => void }) => {
- e.stopPropagation();
- setAddLabel(false);
- }}
- style={{ textDecoration: "underline" }}
- >
- here
- </Typography>
+ {!isEntityModificationAllowed(entity?.status) ? (
+ "No properties have been created yet."
+ ) : (
+ <>
+ No properties have been created yet. To add a
+ property,click{" "}
+ <Typography
+ className="text-color-green cursor-pointer text-underline"
+ component="span"
+ onClick={(e: { stopPropagation: () => void }) => {
+ e.stopPropagation();
+ setAddLabel(false);
+ }}
+ >
+ here
+ </Typography>
+ </>
+ )}
</span>
)}
</Stack>
@@ -323,7 +338,7 @@ const UserDefinedProperties = ({ loading, customAttributes, entity }: any) => {
</>
) : (
<>
- {fields.map((item: any, index) => {
+ {fields.map((item: Record<string, any>, index) => {
return (
<Stack
gap="0.5rem"
@@ -402,7 +417,7 @@ const UserDefinedProperties = ({ loading, customAttributes, entity }: any) => {
size="small"
aria-label="add"
className="cursor-pointer"
- onClick={(e: any) => {
+ onClick={(e: React.MouseEvent) => {
e.stopPropagation();
append(defaultField);
}}
diff --git a/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/__tests__/BMAttributes.test.tsx b/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/__tests__/BMAttributes.test.tsx
new file mode 100644
index 0000000000..6751e3b210
--- /dev/null
+++ b/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/__tests__/BMAttributes.test.tsx
@@ -0,0 +1,235 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 React from 'react';
+import { render, screen, fireEvent, waitFor, act } from '@utils/test-utils';
+import '@testing-library/jest-dom';
+import BMAttributes from '../BMAttributes';
+import { ThemeProvider, createTheme } from '@mui/material/styles';
+
+const theme = createTheme();
+
+// Mock dependencies
+const mockDispatch = jest.fn();
+jest.mock('@hooks/reducerHook', () => ({
+ useAppDispatch: () => mockDispatch,
+ useAppSelector: jest.fn((selector) => {
+ const state = {
+ entity: {
+ entityData: {
+ entityDefs: [
+ {
+ name: 'DataSet',
+ businessAttributeDefs: {
+ 'Group1': [
+ { name: 'attr1', typeName: 'string' },
+ { name: 'attr2', typeName: 'int' }
+ ]
+ }
+ }
+ ]
+ }
+ },
+ businessMetaData: {
+ businessMetaData: {
+ businessMetadataDefs: [
+ {
+ name: 'Group1',
+ attributeDefs: [
+ { name: 'attr1', typeName: 'string' },
+ { name: 'attr2', typeName: 'int' }
+ ]
+ }
+ ]
+ }
+ }
+ };
+ return selector(state);
+ })
+}));
+
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useParams: () => ({ guid: 'test-guid-123' })
+}));
+
+const mockGetEntityBusinessMetadata = jest.fn();
+jest.mock('@api/apiMethods/detailpageApiMethod', () => ({
+ getEntityBusinessMetadata: (...args: any[]) => mockGetEntityBusinessMetadata(...args)
+}));
+
+jest.mock('react-toastify', () => ({
+ toast: {
+ dismiss: jest.fn(),
+ success: jest.fn(() => 'toast-id'),
+ error: jest.fn(() => 'toast-id')
+ }
+}));
+
+jest.mock('@utils/Utils', () => ({
+ ...jest.requireActual('@utils/Utils'),
+ serverError: jest.fn()
+}));
+
+jest.mock('@redux/slice/detailPageSlice', () => ({
+ fetchDetailPageData: jest.fn((guid: string) => ({ type: 'fetchDetailPageData', payload: guid }))
+}));
+
+// Mock BMAttributesFields to avoid complex form input mocks unless needed
+jest.mock('../BMAttributesFields', () => {
+ return function MockBMAttributesFields(props: any) {
+ return <div data-testid="bm-fields-mock">{props.obj?.name}</div>;
+ };
+});
+
+const TestWrapper: React.FC<React.PropsWithChildren<{}>> = ({ children }) => (
+ <ThemeProvider theme={theme}>{children}</ThemeProvider>
+);
+
+describe('BMAttributes Component', () => {
+ const defaultProps = {
+ loading: false,
+ bmAttributes: {
+ 'Group1': {
+ 'attr1': 'value1',
+ 'attr2': 100
+ }
+ },
+ entity: { guid: 'test-guid-123', status: 'ACTIVE', typeName: 'DataSet' }
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders existing business metadata correctly', () => {
+ render(<TestWrapper><BMAttributes {...defaultProps} /></TestWrapper>);
+
+ expect(screen.getByText('Business Metadata')).toBeInTheDocument();
+ expect(screen.getByText('Group1')).toBeInTheDocument();
+ expect(screen.getByText('attr1 (string)')).toBeInTheDocument();
+ expect(screen.getByText('attr2 (int)')).toBeInTheDocument();
+ // BMAttributes renders HTML for string values
+ expect(screen.getByText('value1')).toBeInTheDocument();
+ expect(screen.getByText('100')).toBeInTheDocument();
+ });
+
+ it('shows empty state message when empty', () => {
+ render(<TestWrapper><BMAttributes loading={false} bmAttributes={{}} entity={{ status: 'ACTIVE', typeName: 'DataSet' }} /></TestWrapper>);
+
+ expect(screen.getByText(/No properties have been created yet/i)).toBeInTheDocument();
+ });
+
+ it('does not allow editing if entity is deleted', () => {
+ render(<TestWrapper><BMAttributes loading={false} bmAttributes={{}} entity={{ status: 'DELETED', typeName: 'DataSet' }} /></TestWrapper>);
+
+ expect(screen.getByText(/No properties have been created yet/i)).toBeInTheDocument();
+ expect(screen.queryByText(/To add a property, click/i)).not.toBeInTheDocument();
+ expect(screen.queryByText('Add')).not.toBeInTheDocument();
+ });
+
+ it('does not allow editing if entity is purged', () => {
+ render(<TestWrapper><BMAttributes loading={false} bmAttributes={{}} entity={{ status: 'PURGED', typeName: 'DataSet' }} /></TestWrapper>);
+
+ expect(screen.getByText(/No properties have been created yet/i)).toBeInTheDocument();
+ expect(screen.queryByText(/To add a property, click/i)).not.toBeInTheDocument();
+ expect(screen.queryByText('Add')).not.toBeInTheDocument();
+ });
+
+ it('hides edit button for deleted entity even when business metadata exists', () => {
+ render(<TestWrapper><BMAttributes {...defaultProps} entity={{ ...defaultProps.entity, status: 'DELETED' }} /></TestWrapper>);
+
+ expect(screen.getByText('value1')).toBeInTheDocument();
+ expect(screen.queryByText('Edit')).not.toBeInTheDocument();
+ });
+
+ it('hides edit button for purged entity even when business metadata exists', () => {
+ render(<TestWrapper><BMAttributes {...defaultProps} entity={{ ...defaultProps.entity, status: 'PURGED' }} /></TestWrapper>);
+
+ expect(screen.getByText('value1')).toBeInTheDocument();
+ expect(screen.queryByText('Edit')).not.toBeInTheDocument();
+ });
+
+ it('switches to edit mode on Edit button click', () => {
+ render(<TestWrapper><BMAttributes {...defaultProps} /></TestWrapper>);
+
+ const editBtn = screen.getByText('Edit').closest('button')!;
+ fireEvent.click(editBtn);
+
+ expect(screen.getAllByTestId('bm-fields-mock')).toHaveLength(2);
+ expect(screen.getByText('attr1')).toBeInTheDocument();
+ expect(screen.getByText('attr2')).toBeInTheDocument();
+ });
+
+ it('adds new attribute dynamically', async () => {
+ render(<TestWrapper><BMAttributes {...defaultProps} /></TestWrapper>);
+
+ fireEvent.click(screen.getByText('Edit').closest('button')!);
+
+ const addAttrBtn = screen.getByRole('button', { name: /Add New Attribute/i });
+ fireEvent.click(addAttrBtn);
+
+ // There should be 3 items now
+ const removeBtns = screen.getAllByTestId('RemoveOutlinedIcon');
+ expect(removeBtns).toHaveLength(3);
+ });
+
+ it('submits form successfully', async () => {
+ mockGetEntityBusinessMetadata.mockResolvedValueOnce({ data: {} });
+
+ render(<TestWrapper><BMAttributes {...defaultProps} /></TestWrapper>);
+
+ fireEvent.click(screen.getByText('Edit').closest('button')!);
+
+ const saveBtn = screen.getAllByRole('button', { name: /save/i })[0];
+ await act(async () => { fireEvent.submit(saveBtn.closest('form')!); });
+
+ await waitFor(() => {
+ expect(mockGetEntityBusinessMetadata).toHaveBeenCalledWith('test-guid-123', expect.any(Object));
+ expect(mockDispatch).toHaveBeenCalled();
+ });
+ });
+
+ it('handles API failure on save gracefully', async () => {
+ mockGetEntityBusinessMetadata.mockRejectedValueOnce(new Error('Network error'));
+
+ render(<TestWrapper><BMAttributes {...defaultProps} /></TestWrapper>);
+
+ fireEvent.click(screen.getByText('Edit').closest('button')!);
+
+ const saveBtn = screen.getAllByRole('button', { name: /save/i })[0];
+ await act(async () => { fireEvent.submit(saveBtn.closest('form')!); });
+
+ await waitFor(() => {
+ expect(mockGetEntityBusinessMetadata).toHaveBeenCalled();
+ });
+
+ const { serverError } = require('@utils/Utils');
+ expect(serverError).toHaveBeenCalled();
+ });
+
+ it('hides Add button while loading', () => {
+ render(<TestWrapper><BMAttributes loading={true} bmAttributes={{}} entity={{ status: 'ACTIVE', typeName: 'DataSet' }} /></TestWrapper>);
+ expect(screen.queryByText('Add')).not.toBeInTheDocument();
+ });
+
+ it('hides Edit button while loading even when business metadata exists', () => {
+ render(<TestWrapper><BMAttributes {...defaultProps} loading={true} entity={{ ...defaultProps.entity, status: 'ACTIVE' }} /></TestWrapper>);
+ expect(screen.queryByText('Edit')).not.toBeInTheDocument();
+ });
+
+});
diff --git a/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/__tests__/Labels.test.tsx b/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/__tests__/Labels.test.tsx
new file mode 100644
index 0000000000..588d75300c
--- /dev/null
+++ b/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/__tests__/Labels.test.tsx
@@ -0,0 +1,206 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 React from 'react';
+import { render, screen, fireEvent, waitFor, act } from '@utils/test-utils';
+import '@testing-library/jest-dom';
+import Labels from '../Labels';
+import { ThemeProvider, createTheme } from '@mui/material/styles';
+
+const theme = createTheme();
+
+// Mock dependencies
+const mockDispatch = jest.fn();
+jest.mock('@hooks/reducerHook', () => ({
+ useAppDispatch: () => mockDispatch
+}));
+
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useParams: () => ({ guid: 'test-guid-123' })
+}));
+
+const mockGetLabels = jest.fn();
+const mockGetGlobalSearchResult = jest.fn();
+jest.mock('@api/apiMethods/detailpageApiMethod', () => ({
+ getLabels: (...args: any[]) => mockGetLabels(...args)
+}));
+jest.mock('@api/apiMethods/searchApiMethod', () => ({
+ getGlobalSearchResult: (...args: any[]) => mockGetGlobalSearchResult(...args)
+}));
+
+jest.mock('react-toastify', () => ({
+ toast: {
+ dismiss: jest.fn(),
+ success: jest.fn(() => 'toast-id'),
+ error: jest.fn(() => 'toast-id')
+ }
+}));
+
+jest.mock('@utils/Utils', () => ({
+ ...jest.requireActual('@utils/Utils'),
+ serverError: jest.fn()
+}));
+
+jest.mock('@redux/slice/detailPageSlice', () => ({
+ fetchDetailPageData: jest.fn((guid: string) => ({ type: 'fetchDetailPageData', payload: guid }))
+}));
+
+const TestWrapper: React.FC<React.PropsWithChildren<{}>> = ({ children }) => (
+ <ThemeProvider theme={theme}>{children}</ThemeProvider>
+);
+
+describe('Labels Component', () => {
+ const defaultProps = {
+ loading: false,
+ labels: ['Label1', 'Label2'],
+ entity: { status: 'ACTIVE' }
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders existing labels correctly', () => {
+ render(<TestWrapper><Labels {...defaultProps} /></TestWrapper>);
+
+ // Labels are shown in an accordion that is expanded by default since labels exist
+ expect(screen.getByText('Labels')).toBeInTheDocument();
+ expect(screen.getByText('Label1')).toBeInTheDocument();
+ expect(screen.getByText('Label2')).toBeInTheDocument();
+ });
+
+ it('shows no labels message when empty', () => {
+ render(<TestWrapper><Labels loading={false} labels={[]} entity={{ status: 'ACTIVE' }} /></TestWrapper>);
+
+ expect(screen.getByText(/No labels have been created yet/i)).toBeInTheDocument();
+ });
+
+ it('does not allow editing if entity is deleted', () => {
+ render(<TestWrapper><Labels loading={false} labels={[]} entity={{ status: 'DELETED' }} /></TestWrapper>);
+
+ expect(screen.getByText(/No labels have been created yet/i)).toBeInTheDocument();
+ expect(screen.queryByText(/To add a labels, click/i)).not.toBeInTheDocument();
+ expect(screen.queryByText('Add')).not.toBeInTheDocument();
+ });
+
+ it('does not allow editing if entity is purged', () => {
+ render(<TestWrapper><Labels loading={false} labels={[]} entity={{ status: 'PURGED' }} /></TestWrapper>);
+
+ expect(screen.getByText(/No labels have been created yet/i)).toBeInTheDocument();
+ expect(screen.queryByText(/To add a labels, click/i)).not.toBeInTheDocument();
+ expect(screen.queryByText('Add')).not.toBeInTheDocument();
+ });
+
+ it('hides edit button for deleted entity even when labels exist', () => {
+ render(<TestWrapper><Labels loading={false} labels={['Label1']} entity={{ status: 'DELETED' }} /></TestWrapper>);
+
+ expect(screen.getByText('Label1')).toBeInTheDocument();
+ expect(screen.queryByText('Edit')).not.toBeInTheDocument();
+ });
+
+ it('hides edit button for purged entity even when labels exist', () => {
+ render(<TestWrapper><Labels loading={false} labels={['Label1']} entity={{ status: 'PURGED' }} /></TestWrapper>);
+
+ expect(screen.getByText('Label1')).toBeInTheDocument();
+ expect(screen.queryByText('Edit')).not.toBeInTheDocument();
+ });
+
+ it('allows clicking edit to show autocomplete form', async () => {
+ render(<TestWrapper><Labels {...defaultProps} /></TestWrapper>);
+
+ const editBtn = screen.getByText('Edit').closest('button')!;
+ fireEvent.click(editBtn);
+
+ expect(await screen.findByPlaceholderText('Select Label')).toBeInTheDocument();
+ });
+
+ it('allows clicking "here" to add label when empty', async () => {
+ render(<TestWrapper><Labels loading={false} labels={[]} entity={{ status: 'ACTIVE' }} /></TestWrapper>);
+
+ const hereText = screen.getByText('here');
+ fireEvent.click(hereText);
+
+ expect(await screen.findByPlaceholderText('Select Label')).toBeInTheDocument();
+ });
+
+ it('calls API and dispatches action on save', async () => {
+ mockGetLabels.mockResolvedValueOnce({ data: {} });
+
+ render(<TestWrapper><Labels {...defaultProps} /></TestWrapper>);
+
+ // Click Edit
+ fireEvent.click(screen.getByText('Edit').closest('button')!);
+
+ // Since autocomplete already has default value, let's just save
+ const saveBtn = screen.getAllByRole('button', { name: /save/i })[0];
+ await act(async () => { fireEvent.submit(saveBtn.closest('form')!); });
+
+ await waitFor(() => {
+ expect(mockGetLabels).toHaveBeenCalledWith('test-guid-123', ['Label1', 'Label2']);
+ expect(mockDispatch).toHaveBeenCalled();
+ });
+ });
+
+ it('handles API failure on save gracefully', async () => {
+ mockGetLabels.mockRejectedValueOnce(new Error('Network error'));
+
+ render(<TestWrapper><Labels {...defaultProps} /></TestWrapper>);
+
+ // Click Edit
+ fireEvent.click(screen.getByText('Edit').closest('button')!);
+
+ // Save
+ const saveBtn = screen.getAllByRole('button', { name: /save/i })[0];
+ await act(async () => { fireEvent.submit(saveBtn.closest('form')!); });
+
+ await waitFor(() => {
+ expect(mockGetLabels).toHaveBeenCalled();
+ });
+
+ // Error is handled via serverError util mock
+ const { serverError } = require('@utils/Utils');
+ expect(serverError).toHaveBeenCalled();
+ });
+
+ it('fetches label suggestions on open', async () => {
+ mockGetGlobalSearchResult.mockResolvedValueOnce({ data: { suggestions: ['Label3', 'Label4'] } });
+
+ render(<TestWrapper><Labels {...defaultProps} /></TestWrapper>);
+
+ // Click Edit
+ fireEvent.click(screen.getByText('Edit').closest('button')!);
+
+ const input = await screen.findByPlaceholderText('Select Label');
+ fireEvent.mouseDown(input);
+
+ await waitFor(() => {
+ expect(mockGetGlobalSearchResult).toHaveBeenCalledWith('suggestions', expect.objectContaining({ params: { fieldName: '__labels' } }));
+ });
+ });
+
+ it('hides Add button while loading', () => {
+ render(<TestWrapper><Labels loading={true} labels={[]} entity={{ status: 'ACTIVE' }} /></TestWrapper>);
+ expect(screen.queryByText('Add')).not.toBeInTheDocument();
+ });
+
+ it('hides Edit button while loading even when labels exist', () => {
+ render(<TestWrapper><Labels loading={true} labels={['Label1']} entity={{ status: 'ACTIVE' }} /></TestWrapper>);
+ expect(screen.queryByText('Edit')).not.toBeInTheDocument();
+ });
+
+});
diff --git a/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/__tests__/UserDefinedProperties.test.tsx b/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/__tests__/UserDefinedProperties.test.tsx
new file mode 100644
index 0000000000..b7fdedd9fd
--- /dev/null
+++ b/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/__tests__/UserDefinedProperties.test.tsx
@@ -0,0 +1,226 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 React from 'react';
+import { render, screen, fireEvent, waitFor, act } from '@utils/test-utils';
+import '@testing-library/jest-dom';
+import UserDefinedProperties from '../UserDefinedProperties';
+import { ThemeProvider, createTheme } from '@mui/material/styles';
+
+const theme = createTheme();
+
+// Mock dependencies
+const mockDispatch = jest.fn();
+jest.mock('@hooks/reducerHook', () => ({
+ useAppDispatch: () => mockDispatch
+}));
+
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useParams: () => ({ guid: 'test-guid-123' })
+}));
+
+const mockCreateEntity = jest.fn();
+jest.mock('@api/apiMethods/entityFormApiMethod', () => ({
+ createEntity: (...args: any[]) => mockCreateEntity(...args)
+}));
+
+jest.mock('@utils/entityPayloadEnrichmentUtils', () => ({
+ enrichEntityPayloadForRelationshipSave: jest.fn(async (entity) => entity)
+}));
+
+jest.mock('react-toastify', () => ({
+ toast: {
+ dismiss: jest.fn(),
+ success: jest.fn(() => 'toast-id'),
+ error: jest.fn(() => 'toast-id')
+ }
+}));
+
+jest.mock('@utils/Utils', () => ({
+ ...jest.requireActual('@utils/Utils'),
+ serverError: jest.fn()
+}));
+
+jest.mock('@redux/slice/detailPageSlice', () => ({
+ fetchDetailPageData: jest.fn((guid: string) => ({ type: 'fetchDetailPageData', payload: guid }))
+}));
+
+const TestWrapper: React.FC<React.PropsWithChildren<{}>> = ({ children }) => (
+ <ThemeProvider theme={theme}>{children}</ThemeProvider>
+);
+
+describe('UserDefinedProperties Component', () => {
+ const defaultProps = {
+ loading: false,
+ customAttributes: { key1: 'value1', key2: 'value2' },
+ entity: { guid: 'test-guid-123', status: 'ACTIVE', customAttributes: {} }
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders existing properties correctly', () => {
+ render(<TestWrapper><UserDefinedProperties {...defaultProps} /></TestWrapper>);
+
+ expect(screen.getByText('User-defined properties')).toBeInTheDocument();
+ expect(screen.getByText('key1')).toBeInTheDocument();
+ expect(screen.getByText('value1')).toBeInTheDocument();
+ expect(screen.getByText('key2')).toBeInTheDocument();
+ expect(screen.getByText('value2')).toBeInTheDocument();
+ });
+
+ it('shows empty state message when empty', () => {
+ render(<TestWrapper><UserDefinedProperties loading={false} customAttributes={{}} entity={{ status: 'ACTIVE' }} /></TestWrapper>);
+
+ expect(screen.getByText(/No properties have been created yet/i)).toBeInTheDocument();
+ });
+
+ it('does not allow editing if entity is deleted', () => {
+ render(<TestWrapper><UserDefinedProperties loading={false} customAttributes={{}} entity={{ status: 'DELETED' }} /></TestWrapper>);
+
+ expect(screen.getByText(/No properties have been created yet/i)).toBeInTheDocument();
+ expect(screen.queryByText(/To add a property,click/i)).not.toBeInTheDocument();
+ expect(screen.queryByText('Add')).not.toBeInTheDocument();
+ });
+
+ it('does not allow editing if entity is purged', () => {
+ render(<TestWrapper><UserDefinedProperties loading={false} customAttributes={{}} entity={{ status: 'PURGED' }} /></TestWrapper>);
+
+ expect(screen.getByText(/No properties have been created yet/i)).toBeInTheDocument();
+ expect(screen.queryByText(/To add a property,click/i)).not.toBeInTheDocument();
+ expect(screen.queryByText('Add')).not.toBeInTheDocument();
+ });
+
+ it('hides edit button for deleted entity even when properties exist', () => {
+ render(<TestWrapper><UserDefinedProperties {...defaultProps} entity={{ ...defaultProps.entity, status: 'DELETED' }} /></TestWrapper>);
+
+ expect(screen.getByText('value1')).toBeInTheDocument();
+ expect(screen.queryByText('Edit')).not.toBeInTheDocument();
+ });
+
+ it('hides edit button for purged entity even when properties exist', () => {
+ render(<TestWrapper><UserDefinedProperties {...defaultProps} entity={{ ...defaultProps.entity, status: 'PURGED' }} /></TestWrapper>);
+
+ expect(screen.getByText('value1')).toBeInTheDocument();
+ expect(screen.queryByText('Edit')).not.toBeInTheDocument();
+ });
+
+ it('switches to edit mode on Edit button click', () => {
+ render(<TestWrapper><UserDefinedProperties {...defaultProps} /></TestWrapper>);
+
+ const editBtn = screen.getByText('Edit').closest('button')!;
+ fireEvent.click(editBtn);
+
+ expect(screen.getByDisplayValue('key1')).toBeInTheDocument();
+ expect(screen.getByDisplayValue('value1')).toBeInTheDocument();
+ });
+
+ it('adds and removes fields dynamically', async () => {
+ render(<TestWrapper><UserDefinedProperties {...defaultProps} /></TestWrapper>);
+
+ // Edit mode
+ fireEvent.click(screen.getByText('Edit').closest('button')!);
+
+ // Should have 2 inputs initially for keys
+ let keyInputs = screen.getAllByPlaceholderText('key');
+ expect(keyInputs).toHaveLength(2);
+
+ // Add new field
+ const addBtns = screen.getAllByTestId('AddOutlinedIcon');
+ fireEvent.click(addBtns[0]);
+
+ keyInputs = screen.getAllByPlaceholderText('key');
+ expect(keyInputs).toHaveLength(3);
+
+ // Remove field
+ const removeBtns = screen.getAllByTestId('RemoveOutlinedIcon');
+ fireEvent.click(removeBtns[0]); // Removing first
+
+ keyInputs = screen.getAllByPlaceholderText('key');
+ expect(keyInputs).toHaveLength(2);
+ });
+
+ it('validates unique keys', async () => {
+ render(<TestWrapper><UserDefinedProperties {...defaultProps} /></TestWrapper>);
+
+ fireEvent.click(screen.getByText('Edit').closest('button')!);
+
+ const keyInputs = screen.getAllByPlaceholderText('key');
+ // Change second key to 'key1' to cause duplicate
+ fireEvent.change(keyInputs[1], { target: { value: 'key1' } });
+
+ // Form submission
+ const saveBtn = screen.getAllByRole('button', { name: /save/i })[0];
+ await act(async () => { fireEvent.submit(saveBtn.closest('form')!); });
+
+ await waitFor(() => {
+ expect(screen.getAllByText('Key must be unique')[0]).toBeInTheDocument();
+ expect(mockCreateEntity).not.toHaveBeenCalled();
+ });
+ });
+
+ it('submits form successfully', async () => {
+ mockCreateEntity.mockResolvedValueOnce({ data: {} });
+
+ render(<TestWrapper><UserDefinedProperties {...defaultProps} /></TestWrapper>);
+
+ fireEvent.click(screen.getByText('Edit').closest('button')!);
+
+ const saveBtn = screen.getAllByRole('button', { name: /save/i })[0];
+ await act(async () => { fireEvent.submit(saveBtn.closest('form')!); });
+
+ await waitFor(() => {
+ expect(mockCreateEntity).toHaveBeenCalledWith({
+ entity: expect.objectContaining({
+ customAttributes: { key1: 'value1', key2: 'value2' }
+ })
+ });
+ expect(mockDispatch).toHaveBeenCalled();
+ });
+ });
+
+ it('handles API failure on save gracefully', async () => {
+ mockCreateEntity.mockRejectedValueOnce(new Error('Network error'));
+
+ render(<TestWrapper><UserDefinedProperties {...defaultProps} /></TestWrapper>);
+
+ fireEvent.click(screen.getByText('Edit').closest('button')!);
+
+ const saveBtn = screen.getAllByRole('button', { name: /save/i })[0];
+ await act(async () => { fireEvent.submit(saveBtn.closest('form')!); });
+
+ await waitFor(() => {
+ expect(mockCreateEntity).toHaveBeenCalled();
+ });
+
+ const { serverError } = require('@utils/Utils');
+ expect(serverError).toHaveBeenCalled();
+ });
+
+ it('hides Add button while loading', () => {
+ render(<TestWrapper><UserDefinedProperties loading={true} customAttributes={{}} entity={{ status: 'ACTIVE' }} /></TestWrapper>);
+ expect(screen.queryByText('Add')).not.toBeInTheDocument();
+ });
+
+ it('hides Edit button while loading even when properties exist', () => {
+ render(<TestWrapper><UserDefinedProperties {...defaultProps} loading={true} entity={{ ...defaultProps.entity, status: 'ACTIVE' }} /></TestWrapper>);
+ expect(screen.queryByText('Edit')).not.toBeInTheDocument();
+ });
+
+});
diff --git a/dashboard/src/views/DetailPage/EntityDetailTabs/__tests__/AttributeProperties.test.tsx b/dashboard/src/views/DetailPage/EntityDetailTabs/__tests__/AttributeProperties.test.tsx
index ba2b8fef87..f8a302595e 100644
--- a/dashboard/src/views/DetailPage/EntityDetailTabs/__tests__/AttributeProperties.test.tsx
+++ b/dashboard/src/views/DetailPage/EntityDetailTabs/__tests__/AttributeProperties.test.tsx
@@ -710,6 +710,64 @@ describe('AttributeProperties', () => {
expect(screen.queryByTestId('custom-button')).not.toBeInTheDocument();
});
+ it('should not show Edit button for DELETED entities', () => {
+ mockUseAppSelector.mockImplementation((selector: any) => {
+ const mockState = {
+ session: {
+ sessionObj: {
+ data: {
+ 'atlas.entity.update.allowed': true,
+ 'atlas.ui.editable.entity.types': '*'
+ }
+ }
+ }
+ };
+ return selector(mockState);
+ });
+
+ render(
+ <TestWrapper>
+ <AttributeProperties
+ entity={{ ...defaultMockEntity, status: 'DELETED' }}
+ referredEntities={defaultMockReferredEntities}
+ loading={false}
+ propertiesName="Technical"
+ />
+ </TestWrapper>
+ );
+
+ expect(screen.queryByTestId('custom-button')).not.toBeInTheDocument();
+ });
+
+ it('should not show Edit button for PURGED entities', () => {
+ mockUseAppSelector.mockImplementation((selector: any) => {
+ const mockState = {
+ session: {
+ sessionObj: {
+ data: {
+ 'atlas.entity.update.allowed': true,
+ 'atlas.ui.editable.entity.types': '*'
+ }
+ }
+ }
+ };
+ return selector(mockState);
+ });
+
+ render(
+ <TestWrapper>
+ <AttributeProperties
+ entity={{ ...defaultMockEntity, status: 'PURGED' }}
+ referredEntities={defaultMockReferredEntities}
+ loading={false}
+ propertiesName="Technical"
+ />
+ </TestWrapper>
+ );
+
+ expect(screen.queryByTestId('custom-button')).not.toBeInTheDocument();
+ });
+
it('should not show Edit button in audit details mode', () => {
render(
<TestWrapper>
diff --git a/dashboard/src/views/DetailPage/EntityDetailTabs/__tests__/ClassificationsTab.test.tsx b/dashboard/src/views/DetailPage/EntityDetailTabs/__tests__/ClassificationsTab.test.tsx
index f451599dfa..d4703a94f0 100644
--- a/dashboard/src/views/DetailPage/EntityDetailTabs/__tests__/ClassificationsTab.test.tsx
+++ b/dashboard/src/views/DetailPage/EntityDetailTabs/__tests__/ClassificationsTab.test.tsx
@@ -1001,6 +1001,40 @@ describe('ClassificationsTab', () => {
});
describe('Edge Cases', () => {
+ it('should not render action buttons when parent entity is DELETED', () => {
+ const deletedEntity = {
+ ...mockEntity,
+ status: 'DELETED'
+ };
+
+ render(
+ <TestWrapper>
+ <ClassificationsTab entity={deletedEntity} loading={false} tags={mockTags} />
+ </TestWrapper>
+ );
+
+ // Delete and Edit buttons should not be rendered
+ const actionButtons = screen.queryAllByTestId(/custom-button-addTag/);
+ expect(actionButtons).toHaveLength(0);
+ });
+
+ it('should not render action buttons when parent entity is PURGED', () => {
+ const purgedEntity = {
+ ...mockEntity,
+ status: 'PURGED'
+ };
+
+ render(
+ <TestWrapper>
+ <ClassificationsTab entity={purgedEntity} loading={false} tags={mockTags} />
+ </TestWrapper>
+ );
+
+ // Delete and Edit buttons should not be rendered
+ const actionButtons = screen.queryAllByTestId(/custom-button-addTag/);
+ expect(actionButtons).toHaveLength(0);
+ });
+
it('should handle empty tags prop', () => {
render(
<TestWrapper>
diff --git a/dashboard/src/views/DetailPage/__tests__/DetailPageAttributes.test.tsx b/dashboard/src/views/DetailPage/__tests__/DetailPageAttributes.test.tsx
index bc3893b94a..4775c09fbc 100644
--- a/dashboard/src/views/DetailPage/__tests__/DetailPageAttributes.test.tsx
+++ b/dashboard/src/views/DetailPage/__tests__/DetailPageAttributes.test.tsx
@@ -392,6 +392,71 @@ describe('DetailPageAttribute', () => {
fireEvent.click(screen.getByText('close-assign-cat'))
})
+ it('hides Add Classifications button for DELETED entities', () => {
+ mockGtype = 'term'
+ renderComp({ data: { ...baseData, status: 'DELETED' } })
+ expect(document.querySelector('[data-title="Add Classifications"]')).toBeNull()
+ })
+
+ it('hides Add Attributes button for DELETED entities', () => {
+ mockGtype = 'term'
+ renderComp({ data: { ...baseData, status: 'DELETED' } })
+ expect(document.querySelector('[data-title="Add Attributes"]')).toBeNull()
+ })
+
+ it('hides Edit Classification button for DELETED entities', () => {
+ mockGtype = 'term'
+ renderComp({ data: { ...baseData, status: 'DELETED' } })
+ expect(document.querySelector('[data-title="Edit Classification"]')).toBeNull()
+ })
+
+ it('hides Add Term button for DELETED entities', () => {
+ mockGtype = 'category'
+ mockParams.guid = 'gc'
+ renderComp({ data: { ...baseData, status: 'DELETED' } })
+ expect(document.querySelector('[data-title="Add Term"]')).toBeNull()
+ })
+
+ it('hides Add Categories button for DELETED entities', () => {
+ mockGtype = 'term'
+ mockParams.guid = 'gt'
+ renderComp({ data: { ...baseData, status: 'DELETED' } })
+ expect(document.querySelector('[data-title="Add Categories"]')).toBeNull()
+ })
+
+ it('hides Add Classifications button for PURGED entities', () => {
+ mockGtype = 'term'
+ renderComp({ data: { ...baseData, status: 'PURGED' } })
+ expect(document.querySelector('[data-title="Add Classifications"]')).toBeNull()
+ })
+
+ it('hides Add Attributes button for PURGED entities', () => {
+ mockGtype = 'term'
+ renderComp({ data: { ...baseData, status: 'PURGED' } })
+ expect(document.querySelector('[data-title="Add Attributes"]')).toBeNull()
+ })
+
+ it('hides Edit Classification button for PURGED entities', () => {
+ mockGtype = 'term'
+ renderComp({ data: { ...baseData, status: 'PURGED' } })
+ expect(document.querySelector('[data-title="Edit Classification"]')).toBeNull()
+ })
+
+ it('hides Add Term button for PURGED entities', () => {
+ mockGtype = 'category'
+ mockParams.guid = 'gc'
+ renderComp({ data: { ...baseData, status: 'PURGED' } })
+ expect(document.querySelector('[data-title="Add Term"]')).toBeNull()
+ })
+
+ it('hides Add Categories button for PURGED entities', () => {
+ mockGtype = 'term'
+ mockParams.guid = 'gt'
+ renderComp({ data: { ...baseData, status: 'PURGED' } })
+ expect(document.querySelector('[data-title="Add Categories"]')).toBeNull()
+ })
+
+
it('superTypes section loading and loaded', () => {
const { rerender } = render(
<MemoryRouter>
diff --git a/dashboard/src/views/__tests__/EntityDetailPage.test.tsx b/dashboard/src/views/__tests__/EntityDetailPage.test.tsx
index 2e95138d44..d985f6156d 100644
--- a/dashboard/src/views/__tests__/EntityDetailPage.test.tsx
+++ b/dashboard/src/views/__tests__/EntityDetailPage.test.tsx
@@ -164,6 +164,12 @@ const renderWithProviders = (ui: React.ReactElement) => {
return render(ui, { withRouter: false });
};
+
+jest.mock('@components/muiComponents', () => ({
+ ...jest.requireActual('@components/muiComponents'),
+ LightTooltip: ({ title, children }: any) => <span data-title={title}>{children}</span>
+}));
+
describe('EntityDetailPage', () => {
const mockEntity = {
guid: 'test-guid',
@@ -216,6 +222,20 @@ describe('EntityDetailPage', () => {
expect(screen.getAllByTestId('skeleton-loader').length).toBeGreaterThan(0);
});
+ it('should render loading skeleton when detailPageData is null', () => {
+ const store = configureStore({
+ reducer: {
+ detailPage: (state = { detailPageData: null, loading: false }) => state,
+ entity: (state = { entityData: {} }) => state,
+ glossary: (state = { glossaryData: [] }) => state
+ }
+ });
+
+ renderWithProviders(<TestWrapper store={store} />);
+
+ expect(screen.getAllByTestId('skeleton-loader').length).toBeGreaterThan(0);
+ });
+
it('should display entity name and type', () => {
const store = createMockStore(mockDetailPageData, mockEntityData);
renderWithProviders(<TestWrapper store={store} />);
@@ -256,9 +276,9 @@ describe('EntityDetailPage', () => {
const store = createMockStore(mockDetailPageData, mockEntityData);
renderWithProviders(<TestWrapper store={store} />);
- const addTagButton = screen.queryByLabelText(/add.*tag/i) || screen.queryByText(/add.*classification/i);
+ const addTagButton = document.querySelector('[data-title="Add Classifications"]');
if (addTagButton) {
- fireEvent.click(addTagButton);
+ fireEvent.click(addTagButton.querySelector('.MuiIconButton-root') || addTagButton);
expect(screen.getByTestId('add-tag-modal')).toBeTruthy();
}
});
@@ -267,9 +287,9 @@ describe('EntityDetailPage', () => {
const store = createMockStore(mockDetailPageData, mockEntityData);
renderWithProviders(<TestWrapper store={store} />);
- const addTagButton = screen.queryByLabelText(/add.*tag/i) || screen.queryByText(/add.*classification/i);
+ const addTagButton = document.querySelector('[data-title="Add Classifications"]');
if (addTagButton) {
- fireEvent.click(addTagButton);
+ fireEvent.click(addTagButton.querySelector('.MuiIconButton-root') || addTagButton);
const closeButton = screen.getByText('Close Add Tag');
fireEvent.click(closeButton);
expect(screen.queryByTestId('add-tag-modal')).toBeNull();
@@ -277,16 +297,47 @@ describe('EntityDetailPage', () => {
});
it('should open assign term modal when assign term button is clicked', () => {
- const store = createMockStore(mockDetailPageData, mockEntityData, [{ terms: [] }]);
+ const store = createMockStore(mockDetailPageData, mockEntityData, [{ terms: [{ termGuid: '123' }] }]);
renderWithProviders(<TestWrapper store={store} />);
- const assignTermButton = screen.queryByLabelText(/assign.*term/i) || screen.queryByText(/assign.*term/i);
+ const assignTermButton = document.querySelector('[data-title="Add Term"]');
if (assignTermButton) {
- fireEvent.click(assignTermButton);
+ fireEvent.click(assignTermButton.querySelector('.MuiIconButton-root') || assignTermButton);
expect(screen.getByTestId('assign-term-modal')).toBeTruthy();
}
});
+ it('should hide Add Classification and Add Term buttons for DELETED entities', () => {
+ const deletedEntity = {
+ ...mockEntity,
+ status: 'DELETED'
+ };
+ const store = createMockStore({ entity: deletedEntity, referredEntities: {} }, mockEntityData, [{ terms: [] }]);
+ renderWithProviders(<TestWrapper store={store} />);
+
+ const addClassificationButton = document.querySelector('[data-title="Add Classifications"]');
+ const addTermButton = document.querySelector('[data-title="Add Term"]');
+
+ expect(addClassificationButton).toBeNull();
+ expect(addTermButton).toBeNull();
+ });
+
+ it('should hide Add Classification and Add Term buttons for PURGED entities', () => {
+ const purgedEntity = {
+ ...mockEntity,
+ status: 'PURGED'
+ };
+ const store = createMockStore({ entity: purgedEntity, referredEntities: {} }, mockEntityData, [{ terms: [] }]);
+ renderWithProviders(<TestWrapper store={store} />);
+
+ const addClassificationButton = document.querySelector('[data-title="Add Classifications"]');
+ const addTermButton = document.querySelector('[data-title="Add Term"]');
+
+ expect(addClassificationButton).toBeNull();
+ expect(addTermButton).toBeNull();
+ });
+
+
it('should handle entity not found', () => {
const store = createMockStore({ entity: null }, mockEntityData);
renderWithProviders(<TestWrapper store={store} />);