diff --git a/dashboard/src/components/EntityDisplayImage.tsx b/dashboard/src/components/EntityDisplayImage.tsx
index d9223d1d25b..64d5ef06469 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 }));
diff --git a/dashboard/src/components/ShowMore/DrawerBodyChipView.tsx b/dashboard/src/components/ShowMore/DrawerBodyChipView.tsx
index 8d6c06c6f14..c655d46e23c 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 { EntityStatus } from "@utils/EntityStatus";
const CHIP_MAX_WIDTH = "200px";
@@ -328,11 +329,11 @@ const DrawerBodyChipView = ({
}
onDelete={
- !isEmpty(removeApiMethod) && !isDeleteIcon
+ currentEntity?.status !== EntityStatus.DELETED && !isEmpty(removeApiMethod) && !isDeleteIcon
? () => {
handleDelete(obj[displayKey] || obj);
}
- : isDeleteIcon && obj.count > 1
+ : currentEntity?.status !== EntityStatus.DELETED && 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 d6543410ead..ca40eb250c2 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 { EntityStatus } 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 && currentEntity?.status !== EntityStatus.DELETED
? () => {
// 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 && currentEntity?.status !== EntityStatus.DELETED
? () => {
const searchParams = new URLSearchParams();
searchParams.set("tabActive", "classification");
diff --git a/dashboard/src/components/ShowMore/__tests__/ShowMoreView.test.tsx b/dashboard/src/components/ShowMore/__tests__/ShowMoreView.test.tsx
index 5e618258048..1ee9ca290da 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'
}))
}));
@@ -739,6 +742,26 @@ describe('ShowMoreView', () => {
});
describe('Delete Icon Functionality', () => {
+ it('should not show delete button when currentEntity status is DELETED', () => {
+ const dataWithCount = [
+ { typeName: 'Tag1' }
+ ];
+
+ render(
+
+
+
+ );
+
+ 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 },
diff --git a/dashboard/src/components/__tests__/EntityDisplayImage.test.tsx b/dashboard/src/components/__tests__/EntityDisplayImage.test.tsx
index 41f759ec1da..04dc1c5c6de 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 3c9076a1751..748c37198fb 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 00000000000..10e5d616f2b
--- /dev/null
+++ b/dashboard/src/utils/EntityStatus.ts
@@ -0,0 +1,21 @@
+/*
+ * 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"
+}
diff --git a/dashboard/src/views/DashboardOverview/ClassificationCoverage.tsx b/dashboard/src/views/DashboardOverview/ClassificationCoverage.tsx
index 03c27b94dca..faa54da6ae6 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).
diff --git a/dashboard/src/views/DetailPage/DetailPageAttributes.tsx b/dashboard/src/views/DetailPage/DetailPageAttributes.tsx
index 3bbe6fe402f..12cc51154a0 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 { EntityStatus } from "@utils/EntityStatus";
import { toast } from "react-toastify";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import { removeClassification } from "@api/apiMethods/classificationApiMethod";
@@ -147,7 +148,7 @@ const DetailPageAttribute = ({
{name}{" "}
- {isEmpty(bmguid) && (
+ {isEmpty(bmguid) && !loading && data?.status !== EntityStatus.DELETED && (
Classifications
-
- {
- setOpenAddTagModal(true);
- }}
- >
- {" "}
-
-
+ {!loading && data?.status !== EntityStatus.DELETED && (
+
+ {
+ setOpenAddTagModal(true);
+ }}
+ >
+ {" "}
+
+
+ )}
Terms
-
- {
- if (!hasAnyGlossaryTerms) {
- toast.dismiss();
- toast.info("There are no available terms");
- return;
- }
- setOpenAddTermModal(true);
- }}
- >
- {" "}
-
-
+ {!loading && data?.status !== EntityStatus.DELETED && (
+
+ {
+ if (!hasAnyGlossaryTerms) {
+ toast.dismiss();
+ toast.info("There are no available terms");
+ return;
+ }
+ setOpenAddTermModal(true);
+ }}
+ >
+ {" "}
+
+
+ )}
{
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 +328,7 @@ const EntityDetailPage: React.FC = () => {
className="detail-page-paper"
variant="outlined"
>
- {loading ? (
+ {loading || detailPageData === null ? (
{
}}
>
- {loading ? (
+ {loading || detailPageData === null ? (
{
>
Classifications
-
- {
- setOpenAddTagModal(true);
- }}
- >
- {" "}
-
-
+ {entity?.status !== EntityStatus.DELETED && (
+
+ {
+ setOpenAddTagModal(true);
+ }}
+ >
+ {" "}
+
+
+ )}
{
{!entity?.typeName?.includes("AtlasGlossary") && (
- {loading ? (
+ {loading || detailPageData === null ? (
{
>
Terms
-
- {
- if (!hasAnyGlossaryTerms) {
- toast.dismiss();
- toast.info("There are no available terms");
- return;
- }
- setOpenAddTermModal(true);
- }}
- >
-
-
-
+ {entity?.status !== EntityStatus.DELETED && (
+
+ {
+ if (!hasAnyGlossaryTerms) {
+ toast.dismiss();
+ toast.info("There are no available terms");
+ return;
+ }
+ setOpenAddTermModal(true);
+ }}
+ >
+
+
+
+ )}
{
- {entityUpdate && (
+ {entityUpdate && !loading && entity?.status !== EntityStatus.DELETED && (
= ({
let values = info.row.original;
return (
- {(guid == values?.entityGuid ||
+ {!loading && entity?.status !== EntityStatus.DELETED && (guid == values?.entityGuid ||
(guid != values?.entityGuid &&
values.entityStatus == "DELETED")) && (
@@ -279,7 +280,7 @@ const ClassificationsTab: React.FC = ({
)}
- {guid == values?.entityGuid && (
+ {!loading && entity?.status !== EntityStatus.DELETED && guid == values?.entityGuid && (
= ({
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 2fb291ebdd4..e8966fda881 100644
--- a/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/BMAttributes.tsx
+++ b/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/BMAttributes.tsx
@@ -57,27 +57,34 @@ import { toast } from "react-toastify";
import { cloneDeep } from "@utils/Helper";
import moment from "moment-timezone";
import { fetchDetailPageData } from "@redux/slice/detailPageSlice";
+import { EntityStatus } from "@utils/EntityStatus";
const defaultField = {
key: null,
value: ""
};
-const BMAttributes = ({ loading, bmAttributes, entity }: any) => {
+type BMAttributesProps = {
+ loading: boolean | undefined;
+ bmAttributes: Record;
+ 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(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 +218,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 = {};
@@ -300,7 +307,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 +383,24 @@ const BMAttributes = ({ loading, bmAttributes, entity }: any) => {
{addLabel ? (
- void }) => {
- e.stopPropagation();
- setExpanded("bmDataPanel");
- setAddLabel(false);
- if (!isEmpty(defaultFieldValues)) {
- reset({ businessMetadata: defaultFieldValues });
- }
- }}
- >
- {!isEmpty(bmAttributes) ? "Edit" : "Add"}
-
+ !loading && entity?.status !== EntityStatus.DELETED && (
+ void }) => {
+ e.stopPropagation();
+ setExpanded("bmDataPanel");
+ setAddLabel(false);
+ if (!isEmpty(defaultFieldValues)) {
+ reset({ businessMetadata: defaultFieldValues });
+ }
+ }}
+ >
+ {!isEmpty(bmAttributes) ? "Edit" : "Add"}
+
+ )
) : (
<>
{
- {Object.entries(obj).map(([key, value]: any) => {
+ {Object.entries(obj).map(([key, value]: [string, any]) => {
return (
<>
{key !=
@@ -536,41 +545,48 @@ const BMAttributes = ({ loading, bmAttributes, entity }: any) => {
justifyContent="center"
>
- No properties have been created yet. To add a
- property, click{" "}
- void }) => {
- e.stopPropagation();
- setAddLabel(false);
- }}
- style={{ textDecoration: "underline" }}
- >
- here
-
+ {entity?.status === EntityStatus.DELETED ? (
+ "No properties have been created yet."
+ ) : (
+ <>
+ No properties have been created yet. To add a
+ property, click{" "}
+ void }) => {
+ e.stopPropagation();
+ setAddLabel(false);
+ }}
+ >
+ here
+
+ >
+ )}
)}
>
) : (
<>
-
- {
- e.stopPropagation();
- append(defaultField);
- }}
- startIcon={}
- >
- Add New Attributes
-
-
+ {!loading && entity?.status !== EntityStatus.DELETED && (
+
+ {
+ e.stopPropagation();
+ append(defaultField);
+ }}
+ startIcon={}
+ >
+ Add New Attributes
+
+
+ )}
{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 77b4f21b7cd..d8481831ff1 100644
--- a/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/Labels.tsx
+++ b/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/Labels.tsx
@@ -43,12 +43,20 @@ import { useParams } from "react-router-dom";
import { getLabels } from "@api/apiMethods/detailpageApiMethod";
import { useAppDispatch } from "@hooks/reducerHook";
import { fetchDetailPageData } from "@redux/slice/detailPageSlice";
+import { EntityStatus } from "@utils/EntityStatus";
-const filter = createFilterOptions();
+type LabelOption = string | { inputValue?: string; value?: string };
+const filter = createFilterOptions();
-const Labels = ({ loading, labels }: any) => {
- const { guid }: any = useParams();
- const toastId: any = useRef(null);
+type LabelsProps = {
+ loading: boolean | undefined;
+ labels: string[];
+ entity: any;
+};
+
+const Labels = ({ loading, labels, entity }: LabelsProps) => {
+ const { guid } = useParams();
+ const toastId = useRef(null);
const dispatchApi = useAppDispatch();
const [addLabel, setAddLabel] = useState(true);
const [expanded, setExpanded] = useState(false);
@@ -100,7 +108,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 +143,15 @@ const Labels = ({ loading, labels }: any) => {
return out;
};
- const onSubmit = async (values: any) => {
+ const onSubmit = async (values: Record) => {
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 +162,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 +197,21 @@ const Labels = ({ loading, labels }: any) => {
{addLabel ? (
- void }) => {
- e.stopPropagation();
- setExpanded("labelsPanel");
- setAddLabel(false);
- }}
- >
- {!isEmpty(labels) ? "Edit" : "Add"}
-
+ !loading && entity?.status !== EntityStatus.DELETED && (
+ void }) => {
+ e.stopPropagation();
+ setExpanded("labelsPanel");
+ setAddLabel(false);
+ }}
+ >
+ {!isEmpty(labels) ? "Edit" : "Add"}
+
+ )
) : (
<>
{
})
) : (
- No labels have been created yet. To add a labels, click{" "}
- void }) => {
- e.stopPropagation();
- setAddLabel(false);
- }}
- style={{ textDecoration: "underline" }}
- >
- here
-
+ {entity?.status === EntityStatus.DELETED ? (
+ "No labels have been created yet."
+ ) : (
+ <>
+ No labels have been created yet. To add a labels, click{" "}
+ void }) => {
+ e.stopPropagation();
+ setAddLabel(false);
+ }}
+ className="text-color-green cursor-pointer text-underline"
+ >
+ here
+
+ >
+ )}
)}
>
diff --git a/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/PropertiesTab.tsx b/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/PropertiesTab.tsx
index 0a473032082..8b8705a9ee5 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}
/>
-
+
{
+type UserDefinedPropertiesProps = {
+ loading: boolean | undefined;
+ customAttributes: Record;
+ 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(null);
const [addLabel, setAddLabel] = useState(true);
const [expanded, setExpanded] = useState(false);
let attributes = cloneDeep(customAttributes);
@@ -101,7 +108,7 @@ const UserDefinedProperties = ({ loading, customAttributes, entity }: any) => {
}
};
- const structureAttributes = (list: any) => {
+ const structureAttributes = (list: { key: string; value: string }[]) => {
const obj: Record = {};
if (!Array.isArray(list)) {
return obj;
@@ -119,7 +126,7 @@ const UserDefinedProperties = ({ loading, customAttributes, entity }: any) => {
return obj;
};
- const onSubmit = async (values: any) => {
+ const onSubmit = async (values: Record) => {
let formData = { ...values };
let entityObj = cloneDeep(entity);
let properties = structureAttributes(formData.customAttributes);
@@ -152,10 +159,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 +201,24 @@ const UserDefinedProperties = ({ loading, customAttributes, entity }: any) => {
{addLabel ? (
- void }) => {
- e.stopPropagation();
- setExpanded("userDefinedPanel");
- setAddLabel(false);
- if (!isEmpty(defaultFieldValues)) {
- reset({ customAttributes: defaultFieldValues });
- }
- }}
- >
- {!isEmpty(customAttributes) ? "Edit" : "Add"}
-
+ !loading && entity?.status !== EntityStatus.DELETED && (
+ void }) => {
+ e.stopPropagation();
+ setExpanded("userDefinedPanel");
+ setAddLabel(false);
+ if (!isEmpty(defaultFieldValues)) {
+ reset({ customAttributes: defaultFieldValues });
+ }
+ }}
+ >
+ {!isEmpty(customAttributes) ? "Edit" : "Add"}
+
+ )
) : (
<>
{
{!isEmpty(customAttributes) ? (
Object.entries(customAttributes)
.sort()
- .map(([keys, value]: [string, any]) => {
+ .map(([keys, value]: [string, string]) => {
return (
<>
{
})
) : (
- No properties have been created yet. To add a
- property,click{" "}
- void }) => {
- e.stopPropagation();
- setAddLabel(false);
- }}
- style={{ textDecoration: "underline" }}
- >
- here
-
+ {entity?.status === EntityStatus.DELETED ? (
+ "No properties have been created yet."
+ ) : (
+ <>
+ No properties have been created yet. To add a
+ property,click{" "}
+ void }) => {
+ e.stopPropagation();
+ setAddLabel(false);
+ }}
+ >
+ here
+
+ >
+ )}
)}
@@ -323,7 +337,7 @@ const UserDefinedProperties = ({ loading, customAttributes, entity }: any) => {
>
) : (
<>
- {fields.map((item: any, index) => {
+ {fields.map((item: Record, index) => {
return (
{
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 00000000000..208439f0d14
--- /dev/null
+++ b/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/__tests__/BMAttributes.test.tsx
@@ -0,0 +1,203 @@
+/*
+ * 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 userEvent from '@testing-library/user-event';
+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 {props.obj?.name}
;
+ };
+});
+
+const TestWrapper: React.FC> = ({ children }) => (
+ {children}
+);
+
+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();
+
+ 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();
+
+ expect(screen.getByText(/No properties have been created yet/i)).toBeInTheDocument();
+ });
+
+ it('does not allow editing if entity is deleted', () => {
+ render();
+
+ 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('switches to edit mode on Edit button click', () => {
+ render();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+ });
+});
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 00000000000..09f8b2cc6c6
--- /dev/null
+++ b/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/__tests__/Labels.test.tsx
@@ -0,0 +1,174 @@
+/*
+ * 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 userEvent from '@testing-library/user-event';
+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> = ({ children }) => (
+ {children}
+);
+
+describe('Labels Component', () => {
+ const defaultProps = {
+ loading: false,
+ labels: ['Label1', 'Label2'],
+ entity: { status: 'ACTIVE' }
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders existing labels correctly', () => {
+ render();
+
+ // 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();
+
+ expect(screen.getByText(/No labels have been created yet/i)).toBeInTheDocument();
+ });
+
+ it('does not allow editing if entity is deleted', () => {
+ render();
+
+ 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('allows clicking edit to show autocomplete form', async () => {
+ render();
+
+ 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();
+
+ 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();
+
+ // 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();
+
+ // 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();
+
+ // 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' } }));
+ });
+ });
+});
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 00000000000..151f7e19b9f
--- /dev/null
+++ b/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/__tests__/UserDefinedProperties.test.tsx
@@ -0,0 +1,194 @@
+/*
+ * 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 userEvent from '@testing-library/user-event';
+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> = ({ children }) => (
+ {children}
+);
+
+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();
+
+ 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();
+
+ expect(screen.getByText(/No properties have been created yet/i)).toBeInTheDocument();
+ });
+
+ it('does not allow editing if entity is deleted', () => {
+ render();
+
+ 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('switches to edit mode on Edit button click', () => {
+ render();
+
+ 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();
+
+ // 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();
+
+ 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();
+
+ 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();
+
+ 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();
+ });
+});