diff --git a/dashboard/src/api/apiMethods/fetchApi.ts b/dashboard/src/api/apiMethods/fetchApi.ts index 8ea64d7c968..7f316a74672 100644 --- a/dashboard/src/api/apiMethods/fetchApi.ts +++ b/dashboard/src/api/apiMethods/fetchApi.ts @@ -20,6 +20,40 @@ import { globalSessionData } from "../../utils/Enum"; import { toast } from "react-toastify"; import { serverErrorHandler } from "@utils/Utils"; +/** Keep 403 toasts readable (Atlas authorization message). */ +const FORBIDDEN_ERROR_TOAST_MS = 5_000; +/** + * Callers often `toast.dismiss(ref.current)` in catch when `ref.current` is null; + * react-toastify then clears all toasts and removes the 403 toast we just showed. + * Queue the toast as a macrotask so it runs after that dismiss. + */ +const FETCH_API_FORBIDDEN_TOAST_ID = "fetch-api-http-403"; + +const showForbiddenToastLater = ( + responseData: unknown, + defaultMessage: string +) => { + setTimeout(() => { + let message = defaultMessage; + if (responseData && typeof responseData === "object") { + const d = responseData as { + errorMessage?: unknown; + message?: unknown; + error?: unknown; + }; + message = + (d.errorMessage as string | undefined) || + (d.message as string | undefined) || + (d.error as string | undefined) || + message; + } + toast.error(message, { + toastId: FETCH_API_FORBIDDEN_TOAST_ID, + autoClose: FORBIDDEN_ERROR_TOAST_MS + }); + }, 0); +}; + let prevNetworkErrorTime = 0; function errorHandelingForAbortAndStatus0() { @@ -61,11 +95,14 @@ const fetchApi = async (url: string, config: AxiosRequestConfig) => { window.location.replace("login.jsp"); break; case 403: - serverErrorHandler( - { responseJSON: error.response?.data }, + // Match classic UI: toast only, no redirect. Defer toast so callers + // that dismiss all toasts in catch (e.g. toast.dismiss(null ref)) do + // not remove this notification before it is shown — see + // showForbiddenToastLater. + showForbiddenToastLater( + error.response?.data, "You are not authorized" ); - window.location.replace("login.jsp"); break; case 404: serverErrorHandler( @@ -99,7 +136,13 @@ const fetchApi = async (url: string, config: AxiosRequestConfig) => { break; } } - if (error.response?.statusText != "abort") { + // Only treat as offline / connection failure when there is no HTTP + // response (or status 0). Do not run this for 403/404/5xx — those are + // handled above and would wrongly show a network toast. + const res = error.response; + const isAbort = + error.code === "ERR_CANCELED" || res?.statusText === "abort"; + if (!isAbort && (!res || Number(res.status) === 0)) { errorHandelingForAbortAndStatus0(); } } diff --git a/dashboard/src/utils/Utils.ts b/dashboard/src/utils/Utils.ts index 93b878186cf..b404c9d040f 100644 --- a/dashboard/src/utils/Utils.ts +++ b/dashboard/src/utils/Utils.ts @@ -340,6 +340,10 @@ const getEntityIconPath = (options: any) => { }; const serverError = (error: any, toastId: any) => { + // fetchApi already surfaces 403 via serverErrorHandler (toast); avoid duplicate. + if (error?.response?.status === 403) { + return; + } if ( error.response !== undefined && error.response.data.errorMessage !== undefined diff --git a/dashboard/src/views/BusinessMetadata/BusinessMetadataAtrributeForm.tsx b/dashboard/src/views/BusinessMetadata/BusinessMetadataAtrributeForm.tsx index 6c360731c33..6e161f68f04 100644 --- a/dashboard/src/views/BusinessMetadata/BusinessMetadataAtrributeForm.tsx +++ b/dashboard/src/views/BusinessMetadata/BusinessMetadataAtrributeForm.tsx @@ -33,6 +33,7 @@ import { FormControlLabel, Checkbox, Autocomplete, + Chip, Tooltip, tooltipClasses, TooltipProps, @@ -235,6 +236,18 @@ const BusinessMetadataAttributeForm = ({ : []; let enumTypeOptions = [...selectedEnumValues]; + const isBmAttributeEdit = !isEmpty(editbmAttribute); + const nonRemovableApplicableTypes = + isBmAttributeEdit && + Array.isArray(field?.options?.applicableEntityTypes) + ? new Set( + field.options.applicableEntityTypes.filter( + (t: unknown): t is string => + typeof t === "string" && t.length > 0 + ) + ) + : null; + return ( <>
+ tagValue.map((option: string, tagIndex) => { + const tagProps = getTagProps({ + index: tagIndex + }); + const stripDelete = + nonRemovableApplicableTypes.has(option); + return ( + + ); + }) + : undefined + } renderInput={(params) => ( - - {file.name} - + + + {file.name} + +