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(