diff --git a/frontend/packages/console-shared/src/utils/__mocks__/error-modal-handler.tsx b/frontend/packages/console-shared/src/utils/__mocks__/error-modal-handler.tsx
new file mode 100644
index 00000000000..3c92f4646f8
--- /dev/null
+++ b/frontend/packages/console-shared/src/utils/__mocks__/error-modal-handler.tsx
@@ -0,0 +1,11 @@
+/**
+ * Mock implementation of error-modal-handler for Jest tests
+ */
+
+export const mockLaunchErrorModal = jest.fn();
+
+export const SyncErrorModalLauncher = () => null;
+
+export const useErrorModalLauncher = jest.fn(() => mockLaunchErrorModal);
+
+export const launchErrorModal = mockLaunchErrorModal;
diff --git a/frontend/packages/console-shared/src/utils/__tests__/error-modal-handler.spec.tsx b/frontend/packages/console-shared/src/utils/__tests__/error-modal-handler.spec.tsx
new file mode 100644
index 00000000000..25e452acdef
--- /dev/null
+++ b/frontend/packages/console-shared/src/utils/__tests__/error-modal-handler.spec.tsx
@@ -0,0 +1,99 @@
+import { render } from '@testing-library/react';
+import {
+ SyncErrorModalLauncher,
+ useErrorModalLauncher,
+ launchErrorModal,
+} from '../error-modal-handler';
+
+// Mock useOverlay
+const mockLauncher = jest.fn();
+jest.mock('@console/dynamic-plugin-sdk/src/app/modal-support/useOverlay', () => ({
+ useOverlay: () => mockLauncher,
+}));
+
+describe('error-modal-handler', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('SyncErrorModalLauncher', () => {
+ it('should sync the launcher on mount', () => {
+ render();
+
+ // Call the module-level function
+ launchErrorModal({ error: 'Test error', title: 'Test' });
+
+ // Should have called the mocked overlay launcher
+ expect(mockLauncher).toHaveBeenCalledWith(expect.anything(), {
+ error: 'Test error',
+ title: 'Test',
+ });
+ });
+
+ it('should cleanup launcher on unmount', () => {
+ const { unmount } = render();
+
+ unmount();
+
+ // Should log error instead of crashing
+ const consoleError = jest.spyOn(console, 'error').mockImplementation();
+ launchErrorModal({ error: 'Test error' });
+
+ expect(consoleError).toHaveBeenCalledWith(
+ expect.stringContaining('Error modal launcher not initialized'),
+ expect.any(Object),
+ );
+
+ consoleError.mockRestore();
+ });
+ });
+
+ describe('useErrorModalLauncher', () => {
+ it('should return a function that launches error modals', () => {
+ let capturedLauncher: any;
+
+ const TestComponent = () => {
+ capturedLauncher = useErrorModalLauncher();
+ return null;
+ };
+
+ render();
+
+ capturedLauncher({ error: 'Test error', title: 'Test Title' });
+
+ expect(mockLauncher).toHaveBeenCalledWith(expect.anything(), {
+ error: 'Test error',
+ title: 'Test Title',
+ });
+ });
+ });
+
+ describe('launchErrorModal', () => {
+ it('should launch error modal when launcher is initialized', () => {
+ render();
+
+ launchErrorModal({
+ error: 'Connection failed',
+ title: 'Network Error',
+ });
+
+ expect(mockLauncher).toHaveBeenCalledWith(expect.anything(), {
+ error: 'Connection failed',
+ title: 'Network Error',
+ });
+ });
+
+ it('should log error when launcher is not initialized', () => {
+ const consoleError = jest.spyOn(console, 'error').mockImplementation();
+
+ launchErrorModal({ error: 'Test error' });
+
+ expect(consoleError).toHaveBeenCalledWith(
+ expect.stringContaining('Error modal launcher not initialized'),
+ { error: 'Test error' },
+ );
+
+ consoleError.mockRestore();
+ });
+ });
+});
diff --git a/frontend/packages/console-shared/src/utils/error-modal-handler.tsx b/frontend/packages/console-shared/src/utils/error-modal-handler.tsx
new file mode 100644
index 00000000000..8138bc3a307
--- /dev/null
+++ b/frontend/packages/console-shared/src/utils/error-modal-handler.tsx
@@ -0,0 +1,99 @@
+import { useCallback, useEffect } from 'react';
+import { useOverlay } from '@console/dynamic-plugin-sdk/src/app/modal-support/useOverlay';
+import { ErrorModal, ErrorModalProps } from '@console/internal/components/modals/error-modal';
+
+// Module-level reference for non-React contexts
+// This is populated by SyncErrorModalLauncher and should not be set directly
+let moduleErrorModalLauncher: ((props: ErrorModalProps) => void) | null = null;
+
+/**
+ * Component that syncs the error modal launcher to module-level for non-React contexts.
+ * This should be mounted once in the app root, after OverlayProvider.
+ *
+ * @example
+ * ```tsx
+ * const App = () => (
+ *
+ *
+ *
+ *
+ * );
+ * ```
+ */
+export const SyncErrorModalLauncher = () => {
+ const launcher = useOverlay();
+
+ useEffect(() => {
+ moduleErrorModalLauncher = (props: ErrorModalProps) => {
+ launcher(ErrorModal, props);
+ };
+
+ return () => {
+ moduleErrorModalLauncher = null;
+ };
+ }, [launcher]);
+
+ return null;
+};
+
+/**
+ * Hook to launch error modals from React components.
+ * Must be used within an OverlayProvider.
+ *
+ * @example
+ * ```tsx
+ * const MyComponent = () => {
+ * const launchErrorModal = useErrorModalLauncher();
+ *
+ * const handleError = (error: Error) => {
+ * launchErrorModal({
+ * title: 'Operation Failed',
+ * error: error.message,
+ * });
+ * };
+ *
+ * // ...
+ * };
+ * ```
+ */
+export const useErrorModalLauncher = (): ((props: ErrorModalProps) => void) => {
+ const launcher = useOverlay();
+
+ return useCallback(
+ (props: ErrorModalProps) => {
+ launcher(ErrorModal, props);
+ },
+ [launcher],
+ );
+};
+
+/**
+ * Launch an error modal from non-React contexts (callbacks, promises, utilities).
+ * The SyncErrorModalLauncher component must be mounted in the app root.
+ *
+ * @deprecated Use React component modals within component code instead.
+ * For new code, write modals directly within React components using useOverlay or other modal patterns.
+ * This function should only be used for legacy non-React contexts like promise callbacks.
+ *
+ * @example
+ * ```tsx
+ * // In a promise callback or utility function
+ * createConnection(source, target).catch((error) => {
+ * launchErrorModal({
+ * title: 'Connection Failed',
+ * error: error.message,
+ * });
+ * });
+ * ```
+ */
+export const launchErrorModal = (props: ErrorModalProps): void => {
+ if (moduleErrorModalLauncher) {
+ moduleErrorModalLauncher(props);
+ } else {
+ // eslint-disable-next-line no-console
+ console.error(
+ 'Error modal launcher not initialized. Ensure SyncErrorModalLauncher is mounted after OverlayProvider.',
+ props,
+ );
+ }
+};
diff --git a/frontend/packages/dev-console/src/components/pipeline-section/pipeline/__tests__/pipeline-template-utils.spec.ts b/frontend/packages/dev-console/src/components/pipeline-section/pipeline/__tests__/pipeline-template-utils.spec.ts
index 7450e69abec..7f708343cdc 100644
--- a/frontend/packages/dev-console/src/components/pipeline-section/pipeline/__tests__/pipeline-template-utils.spec.ts
+++ b/frontend/packages/dev-console/src/components/pipeline-section/pipeline/__tests__/pipeline-template-utils.spec.ts
@@ -19,6 +19,7 @@ import {
jest.mock('@console/internal/module/k8s', () => ({
k8sCreate: jest.fn(),
k8sUpdate: jest.fn(),
+ referenceForModel: jest.fn((model) => model?.kind || 'UnknownModel'),
}));
const getDefaultLabel = (name: string) => ({
diff --git a/frontend/packages/dev-console/src/components/pipeline-section/pipeline/pipeline-template-utils.ts b/frontend/packages/dev-console/src/components/pipeline-section/pipeline/pipeline-template-utils.ts
index 20c7e3efdd9..ae85448801b 100644
--- a/frontend/packages/dev-console/src/components/pipeline-section/pipeline/pipeline-template-utils.ts
+++ b/frontend/packages/dev-console/src/components/pipeline-section/pipeline/pipeline-template-utils.ts
@@ -3,7 +3,6 @@ import * as _ from 'lodash';
import { compare, gte, parse, SemVer } from 'semver';
import { k8sGet, k8sList, k8sListResourceItems } from '@console/dynamic-plugin-sdk/src/utils/k8s';
import { getActiveUserName } from '@console/internal/actions/ui';
-import { errorModal } from '@console/internal/components/modals/error-modal';
import {
ClusterServiceVersionModel,
RouteModel,
@@ -28,6 +27,7 @@ import {
NameValueFromPair,
NameValuePair,
} from '@console/shared/src/components/formik-fields/field-types';
+import { launchErrorModal } from '@console/shared/src/utils/error-modal-handler';
import { getRandomChars } from '@console/shared/src/utils/utils';
import { TektonResourceLabel } from '@console/shipwright-plugin/src/components/logs/TektonTaskRunLog';
import {
@@ -928,7 +928,7 @@ export const exposeRoute = async (elName: string, ns: string, iteration = 0) =>
);
await k8sCreate(RouteModel, route, { ns });
} catch (e) {
- errorModal({
+ launchErrorModal({
title: 'Error Exposing Route',
error: e.message || 'Unknown error exposing the Webhook route',
});
diff --git a/frontend/packages/knative-plugin/src/topology/components/knativeComponentUtils.ts b/frontend/packages/knative-plugin/src/topology/components/knativeComponentUtils.ts
index 1883d25a687..d0f01eda322 100644
--- a/frontend/packages/knative-plugin/src/topology/components/knativeComponentUtils.ts
+++ b/frontend/packages/knative-plugin/src/topology/components/knativeComponentUtils.ts
@@ -13,7 +13,7 @@ import {
isGraph,
} from '@patternfly/react-topology';
import i18next from 'i18next';
-import { errorModal } from '@console/internal/components/modals';
+import { launchErrorModal } from '@console/shared/src/utils/error-modal-handler';
import {
NodeComponentProps,
NODE_DRAG_TYPE,
@@ -188,10 +188,9 @@ export const eventSourceLinkDragSourceSpec = (): DragSourceSpec<
canDropEventSourceSinkOnNode(monitor.getOperation().type, edge, dropResult)
) {
createSinkConnection(edge.getSource(), dropResult).catch((error) => {
- errorModal({
+ launchErrorModal({
title: i18next.t('knative-plugin~Error moving event source sink'),
error: error.message,
- showIcon: true,
});
});
}
@@ -222,10 +221,9 @@ export const eventSourceKafkaLinkDragSourceSpec = (): DragSourceSpec<
edge.setEndPoint();
if (monitor.didDrop() && dropResult) {
createEventSourceKafkaConnection(edge.getSource(), dropResult).catch((error) => {
- errorModal({
+ launchErrorModal({
title: i18next.t('knative-plugin~Error moving event source kafka connector'),
error: error?.message,
- showIcon: true,
});
});
}
@@ -260,10 +258,9 @@ export const eventingPubSubLinkDragSourceSpec = (): DragSourceSpec<
canDropPubSubSinkOnNode(monitor.getOperation().type, edge, dropResult)
) {
createSinkPubSubConnection(edge, dropResult).catch((error) => {
- errorModal({
+ launchErrorModal({
title: i18next.t('knative-plugin~Error while sink'),
error: error.message,
- showIcon: true,
});
});
}
diff --git a/frontend/packages/knative-plugin/src/topology/create-connector-utils.ts b/frontend/packages/knative-plugin/src/topology/create-connector-utils.ts
index a597110735c..4b4ad1a7187 100644
--- a/frontend/packages/knative-plugin/src/topology/create-connector-utils.ts
+++ b/frontend/packages/knative-plugin/src/topology/create-connector-utils.ts
@@ -1,6 +1,6 @@
import { Node } from '@patternfly/react-topology/src/types';
import i18next from 'i18next';
-import { errorModal } from '@console/internal/components/modals';
+import { launchErrorModal } from '@console/shared/src/utils/error-modal-handler';
import { getResource } from '@console/topology/src/utils';
import { addPubSubConnectionModal } from '../components/pub-sub/PubSubModalLauncher';
import { createEventSourceKafkaConnection } from './knative-topology-utils';
@@ -15,10 +15,9 @@ const createKafkaConnection = (source: Node, target: Node) =>
createEventSourceKafkaConnection(source, target)
.then(() => null)
.catch((error) => {
- errorModal({
+ launchErrorModal({
title: i18next.t('knative-plugin~Error moving event source kafka connector'),
error: error.message,
- showIcon: true,
});
});
diff --git a/frontend/packages/shipwright-plugin/src/components/logs/logs-utils.ts b/frontend/packages/shipwright-plugin/src/components/logs/logs-utils.ts
index 8dfcd5cbee4..36a5396dfc2 100644
--- a/frontend/packages/shipwright-plugin/src/components/logs/logs-utils.ts
+++ b/frontend/packages/shipwright-plugin/src/components/logs/logs-utils.ts
@@ -2,7 +2,6 @@ import { saveAs } from 'file-saver';
import i18next from 'i18next';
import * as _ from 'lodash';
import { coFetchText } from '@console/internal/co-fetch';
-import { errorModal } from '@console/internal/components/modals';
import {
LOG_SOURCE_RESTARTING,
LOG_SOURCE_RUNNING,
@@ -18,6 +17,7 @@ import {
resourceURL,
k8sGet,
} from '@console/internal/module/k8s';
+import { launchErrorModal } from '@console/shared/src/utils/error-modal-handler';
import { TaskRunKind } from '../../types';
import { ComputedStatus, SucceedConditionReason } from './log-snippet-types';
import { getTaskRunLog } from './tekton-results';
@@ -81,7 +81,9 @@ const getOrderedStepsFromPod = (name: string, ns: string): Promise {
- errorModal({ error: err.message || i18next.t('shipwright-plugin~Error downloading logs.') });
+ launchErrorModal({
+ error: err.message || i18next.t('shipwright-plugin~Error downloading logs.'),
+ });
return [];
});
};
diff --git a/frontend/packages/topology/locales/en/topology.json b/frontend/packages/topology/locales/en/topology.json
index 1a825fac996..51a919cd32a 100644
--- a/frontend/packages/topology/locales/en/topology.json
+++ b/frontend/packages/topology/locales/en/topology.json
@@ -10,6 +10,7 @@
"Delete Connector?": "Delete Connector?",
"Deleting the visual connector removes the `connects-to` annotation from the resources. Are you sure you want to delete the visual connector?": "Deleting the visual connector removes the `connects-to` annotation from the resources. Are you sure you want to delete the visual connector?",
"Delete": "Delete",
+ "Error deleting connector": "Error deleting connector",
"Delete connector": "Delete connector",
"Edit application grouping": "Edit application grouping",
"View all {{size}}": "View all {{size}}",
diff --git a/frontend/packages/topology/src/actions/edgeActions.ts b/frontend/packages/topology/src/actions/edgeActions.ts
index 969e6827da2..4782f64d714 100644
--- a/frontend/packages/topology/src/actions/edgeActions.ts
+++ b/frontend/packages/topology/src/actions/edgeActions.ts
@@ -4,7 +4,6 @@ import { Edge, isNode, Node } from '@patternfly/react-topology';
import i18next from 'i18next';
import { useTranslation } from 'react-i18next';
import { Action, K8sModel } from '@console/dynamic-plugin-sdk';
-import { errorModal } from '@console/internal/components/modals';
import { asAccessReview } from '@console/internal/components/utils';
import {
TYPE_EVENT_SOURCE,
@@ -17,6 +16,7 @@ import {
TYPE_MANAGED_KAFKA_CONNECTION,
} from '@console/knative-plugin/src/topology/const';
import { useWarningModal } from '@console/shared/src/hooks/useWarningModal';
+import { launchErrorModal } from '@console/shared/src/utils/error-modal-handler';
import { useMoveConnectionModalLauncher } from '../components/modals/MoveConnectionModal';
import { TYPE_CONNECTS_TO, TYPE_TRAFFIC_CONNECTOR } from '../const';
import { removeTopologyResourceConnection, getResource } from '../utils/topology-utils';
@@ -102,7 +102,12 @@ export const useDeleteConnectorAction = (
confirmButtonVariant: ButtonVariant.danger,
onConfirm: () => {
return removeTopologyResourceConnection(element, resource).catch((err) => {
- err && errorModal({ error: err.message });
+ if (err) {
+ launchErrorModal({
+ title: t('topology~Error deleting connector'),
+ error: err.message,
+ });
+ }
});
},
ouiaId: 'TopologyDeleteConnectorConfirmation',
diff --git a/frontend/packages/topology/src/components/graph-view/components/componentUtils.ts b/frontend/packages/topology/src/components/graph-view/components/componentUtils.ts
index 21a4efcd1e1..5cc18149ad8 100644
--- a/frontend/packages/topology/src/components/graph-view/components/componentUtils.ts
+++ b/frontend/packages/topology/src/components/graph-view/components/componentUtils.ts
@@ -21,9 +21,9 @@ import {
} from '@patternfly/react-topology';
import i18next from 'i18next';
import { action } from 'mobx';
-import { errorModal } from '@console/internal/components/modals';
import { K8sResourceKind } from '@console/internal/module/k8s';
import { ActionContext } from '@console/shared';
+import { launchErrorModal } from '@console/shared/src/utils/error-modal-handler';
import { createConnection, moveNodeToGroup } from '../../../utils';
import { isWorkloadRegroupable, graphContextMenu, groupContextMenu } from './nodeContextMenu';
import withTopologyContextMenu from './withTopologyContextMenu';
@@ -136,6 +136,7 @@ const nodeDragSourceSpec = (
if (!monitor.isCancelled() && monitor.getOperation()?.type === REGROUP_OPERATION) {
if (monitor.didDrop() && dropResult && props && props.element.getParent() !== dropResult) {
const controller = props.element.getController();
+
await moveNodeToGroup(
props.element as Node,
isNode(dropResult) ? (dropResult as Node) : null,
@@ -308,8 +309,9 @@ const edgeDragSourceSpec = (
) {
const title =
failureTitle !== undefined ? failureTitle : i18next.t('topology~Error moving connection');
+
callback(edge.getSource(), dropResult, edge.getTarget()).catch((error) => {
- errorModal({ title, error: error.message, showIcon: true });
+ launchErrorModal({ title, error: error.message });
});
}
},
@@ -346,7 +348,10 @@ const createVisualConnector = (source: Node, target: Node | Graph): ReactElement
}
createConnection(source, target, null).catch((error) => {
- errorModal({ title: i18next.t('topology~Error creating connection'), error: error.message });
+ launchErrorModal({
+ title: i18next.t('topology~Error creating connection'),
+ error: error.message,
+ });
});
return null;
diff --git a/frontend/packages/topology/src/utils/moveNodeToGroup.tsx b/frontend/packages/topology/src/utils/moveNodeToGroup.tsx
index 7550bac2ce1..84876a9d826 100644
--- a/frontend/packages/topology/src/utils/moveNodeToGroup.tsx
+++ b/frontend/packages/topology/src/utils/moveNodeToGroup.tsx
@@ -1,9 +1,14 @@
import { Node } from '@patternfly/react-topology';
import { Trans } from 'react-i18next';
-import { confirmModal, errorModal } from '@console/internal/components/modals';
+import { confirmModal } from '@console/internal/components/modals';
+import { launchErrorModal } from '@console/shared/src/utils/error-modal-handler';
import { updateTopologyResourceApplication } from './topology-utils';
-export const moveNodeToGroup = (node: Node, targetGroup: Node): Promise => {
+export const moveNodeToGroup = (
+ node: Node,
+ targetGroup: Node,
+ onError?: (error: string) => void,
+): Promise => {
const sourceGroup = node.getParent() !== node.getGraph() ? (node.getParent() as Node) : undefined;
if (sourceGroup === targetGroup) {
return Promise.reject();
@@ -51,7 +56,11 @@ export const moveNodeToGroup = (node: Node, targetGroup: Node): Promise =>
.then(resolve)
.catch((err) => {
const error = err.message;
- errorModal({ error });
+ if (onError) {
+ onError(error);
+ } else {
+ launchErrorModal({ error });
+ }
reject(err);
});
},
@@ -61,6 +70,10 @@ export const moveNodeToGroup = (node: Node, targetGroup: Node): Promise =>
return updateTopologyResourceApplication(node, targetGroup.getLabel()).catch((err) => {
const error = err.message;
- errorModal({ error });
+ if (onError) {
+ onError(error);
+ } else {
+ launchErrorModal({ error });
+ }
});
};
diff --git a/frontend/public/components/app.tsx b/frontend/public/components/app.tsx
index aa24440fe3c..1941524eea5 100644
--- a/frontend/public/components/app.tsx
+++ b/frontend/public/components/app.tsx
@@ -52,6 +52,7 @@ import { QuickStartDrawer } from '@console/app/src/components/quick-starts/Quick
import { ModalProvider } from '@console/dynamic-plugin-sdk/src/app/modal-support/ModalProvider';
import { OverlayProvider } from '@console/dynamic-plugin-sdk/src/app/modal-support/OverlayProvider';
import ToastProvider from '@console/shared/src/components/toast/ToastProvider';
+import { SyncErrorModalLauncher } from '@console/shared/src/utils/error-modal-handler';
import { useTelemetry } from '@console/shared/src/hooks/useTelemetry';
import { useDebounceCallback } from '@console/shared/src/hooks/debounce';
import { LOGIN_ERROR_PATH } from '@console/internal/module/auth';
@@ -308,6 +309,7 @@ const App: FC<{
+
}>
{contextProviderExtensions.reduce(
(children, e) => (
diff --git a/frontend/public/components/modals/error-modal.tsx b/frontend/public/components/modals/error-modal.tsx
index b3556859df0..6ef082221c3 100644
--- a/frontend/public/components/modals/error-modal.tsx
+++ b/frontend/public/components/modals/error-modal.tsx
@@ -12,13 +12,7 @@ import {
import { useTranslation } from 'react-i18next';
import { OverlayComponent } from '@console/dynamic-plugin-sdk/src/app/modal-support/OverlayProvider';
import { useOverlay } from '@console/dynamic-plugin-sdk/src/app/modal-support/useOverlay';
-import {
- createModalLauncher,
- ModalTitle,
- ModalBody,
- ModalFooter,
- ModalComponentProps,
-} from '../factory/modal';
+import { ModalTitle, ModalBody, ModalFooter, ModalComponentProps } from '../factory/modal';
import { YellowExclamationTriangleIcon } from '@console/shared/src/components/status/icons';
export const ModalErrorContent = (props: ErrorModalProps) => {
@@ -64,9 +58,6 @@ export const useErrorModalLauncher = (props) => {
return useCallback(() => launcher(ErrorModal, props), [launcher, props]);
};
-/** @deprecated Use useErrorModalLauncher hook instead */
-export const errorModal = createModalLauncher(ModalErrorContent);
-
export type ErrorModalProps = {
error: string | React.ReactNode;
title?: string;
diff --git a/frontend/public/components/modals/index.ts b/frontend/public/components/modals/index.ts
index cdab8aca62a..fe0bdb7c72e 100644
--- a/frontend/public/components/modals/index.ts
+++ b/frontend/public/components/modals/index.ts
@@ -17,10 +17,6 @@ export const confirmModal = (props) =>
m.confirmModal(props),
);
-/** @deprecated Use useErrorModalLauncher hook instead */
-export const errorModal = (props) =>
- import('./error-modal' /* webpackChunkName: "error-modal" */).then((m) => m.errorModal(props));
-
// Lazy-loaded OverlayComponent for Configure Namespace Pull Secret Modal
export const LazyConfigureNamespacePullSecretModalOverlay = lazy(() =>
import(