diff --git a/superset-frontend/packages/superset-ui-core/src/components/index.ts b/superset-frontend/packages/superset-ui-core/src/components/index.ts index 6590688076ff..769355790a2e 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/index.ts +++ b/superset-frontend/packages/superset-ui-core/src/components/index.ts @@ -161,7 +161,6 @@ export { export { Image, type ImageProps } from './Image'; export { Popconfirm, type PopconfirmProps } from './Popconfirm'; export { Upload, type UploadFile, type UploadChangeParam } from './Upload'; -// Add these to your index.ts export * from './Menu'; export * from './Popover'; export * from './Radio'; diff --git a/superset-frontend/spec/helpers/testing-library.tsx b/superset-frontend/spec/helpers/testing-library.tsx index ea14a8e17b63..7ae6ebf340c0 100644 --- a/superset-frontend/spec/helpers/testing-library.tsx +++ b/superset-frontend/spec/helpers/testing-library.tsx @@ -44,6 +44,7 @@ import { configureStore, Store } from '@reduxjs/toolkit'; import { api } from 'src/hooks/apiResources/queryApi'; import userEvent from '@testing-library/user-event'; import { ExtensionsProvider } from 'src/extensions/ExtensionsContext'; +import { NotificationProvider } from 'src/components/MessageToasts/NotificationProvider'; type Options = Omit & { useRedux?: boolean; @@ -87,7 +88,9 @@ export function createWrapper(options?: Options) { return ({ children }: { children?: ReactNode }) => { let result = ( - {children} + + {children} + ); diff --git a/superset-frontend/src/components/MessageToasts/NotificationProvider.tsx b/superset-frontend/src/components/MessageToasts/NotificationProvider.tsx new file mode 100644 index 000000000000..1dff70d7df0a --- /dev/null +++ b/superset-frontend/src/components/MessageToasts/NotificationProvider.tsx @@ -0,0 +1,96 @@ +/** + * 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 { notification as antdNotification } from 'antd'; +import { createContext, useContext, useMemo, ReactNode } from 'react'; +import type { NotificationInstance } from 'antd/es/notification/interface'; + +export type NotificationType = 'success' | 'info' | 'warning' | 'error'; + +export type NotificationPlacement = + | 'top' + | 'topLeft' + | 'topRight' + | 'bottom' + | 'bottomLeft' + | 'bottomRight'; + +export interface NotificationArgsProps { + message: ReactNode; + description?: ReactNode; + btn?: ReactNode; + key?: string; + onClose?: () => void; + duration?: number | null; + icon?: ReactNode; + placement?: NotificationPlacement; + className?: string; + onClick?: () => void; + closeIcon?: boolean | ReactNode; + role?: 'alert' | 'status'; +} + +export type NotificationApi = NotificationInstance; + +export interface NotificationContextType { + api: NotificationApi; + success: (args: NotificationArgsProps) => void; + error: (args: NotificationArgsProps) => void; + warning: (args: NotificationArgsProps) => void; + info: (args: NotificationArgsProps) => void; + open: (args: NotificationArgsProps & { type?: NotificationType }) => void; + destroy: (key?: string) => void; +} + +const NotificationContext = createContext( + undefined, +); + +export const NotificationProvider = ({ children }: { children: ReactNode }) => { + const [api, contextHolder] = antdNotification.useNotification(); + + const value = useMemo( + () => ({ + api, + success: api.success, + error: api.error, + warning: api.warning, + info: api.info, + open: api.open, + destroy: api.destroy, + }), + [api], + ); + + return ( + + {contextHolder} + {children} + + ); +}; + +export const useNotification = (): NotificationContextType => { + const context = useContext(NotificationContext); + if (!context) { + throw new Error( + 'useNotification must be used within a NotificationProvider', + ); + } + return context; +}; diff --git a/superset-frontend/src/components/MessageToasts/Toast.test.jsx b/superset-frontend/src/components/MessageToasts/Toast.test.jsx deleted file mode 100644 index 130eaf156fb7..000000000000 --- a/superset-frontend/src/components/MessageToasts/Toast.test.jsx +++ /dev/null @@ -1,46 +0,0 @@ -/** - * 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 { fireEvent, render, waitFor } from 'spec/helpers/testing-library'; -import Toast from 'src/components/MessageToasts/Toast'; -import mockMessageToasts from './mockMessageToasts'; - -const props = { - toast: mockMessageToasts[0], - onCloseToast() {}, -}; - -const setup = overrideProps => render(); - -test('should render', () => { - const { getByTestId } = setup(); - expect(getByTestId('toast-container')).toBeInTheDocument(); -}); - -test('should render toastText within the div', () => { - const { getByTestId } = setup(); - expect(getByTestId('toast-container')).toHaveTextContent(props.toast.text); -}); - -test('should call onCloseToast upon toast dismissal', async () => { - const onCloseToast = jest.fn(); - const { getByTestId } = setup({ onCloseToast }); - fireEvent.click(getByTestId('close-button')); - await waitFor(() => expect(onCloseToast).toHaveBeenCalledTimes(1)); - expect(onCloseToast).toHaveBeenCalledWith(props.toast.id); -}); diff --git a/superset-frontend/src/components/MessageToasts/Toast.tsx b/superset-frontend/src/components/MessageToasts/Toast.tsx deleted file mode 100644 index a187abf2fb6d..000000000000 --- a/superset-frontend/src/components/MessageToasts/Toast.tsx +++ /dev/null @@ -1,150 +0,0 @@ -/** - * 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 { styled, css, SupersetTheme, useTheme } from '@superset-ui/core'; -import cx from 'classnames'; -import { Interweave } from 'interweave'; -import { useCallback, useEffect, useRef, useState } from 'react'; -import { Icons } from '@superset-ui/core/components/Icons'; -import { ToastType, ToastMeta } from './types'; - -const ToastContainer = styled.div` - ${({ theme }) => css` - display: flex; - justify-content: space-between; // Changed from center to space-between - align-items: center; - - // Content container for icon and text - .toast__content { - display: flex; - align-items: center; - flex: 1; // Take available space - } - - .anticon { - padding: 0 ${theme.sizeUnit}px; - } - - .toast__close, - .toast__close span { - padding-left: ${theme.sizeUnit * 4}px; - } - `} -`; - -const notificationStyledIcon = (theme: SupersetTheme) => css` - min-width: ${theme.sizeUnit * 5}px; - color: ${theme.colorTextLightSolid}; - margin-right: 0; -`; - -interface ToastPresenterProps { - toast: ToastMeta; - onCloseToast: (id: string) => void; -} - -export default function Toast({ toast, onCloseToast }: ToastPresenterProps) { - const hideTimer = useRef>(); - const [visible, setVisible] = useState(false); - - const showToast = () => { - setVisible(true); - }; - - const handleClosePress = useCallback(() => { - if (hideTimer.current) { - clearTimeout(hideTimer.current); - } - // Wait for the transition - setVisible(() => { - setTimeout(() => { - onCloseToast(toast.id); - }, 150); - return false; - }); - }, [onCloseToast, toast.id]); - - useEffect(() => { - setTimeout(showToast); - if (toast.duration > 0) { - hideTimer.current = setTimeout(handleClosePress, toast.duration); - } - return () => { - if (hideTimer.current) { - clearTimeout(hideTimer.current); - } - }; - }, [handleClosePress, toast.duration]); - - const theme = useTheme(); - let className = 'toast--success'; - let icon = ( - notificationStyledIcon(theme)} - iconColor={theme.colorSuccess} - /> - ); - - if (toast.toastType === ToastType.Warning) { - icon = ( - - ); - className = 'toast--warning'; - } else if (toast.toastType === ToastType.Danger) { - icon = ( - - ); - className = 'toast--danger'; - } else if (toast.toastType === ToastType.Info) { - icon = ( - - ); - className = 'toast--info'; - } - - return ( - -
- {icon} - -
- -
- ); -} diff --git a/superset-frontend/src/components/MessageToasts/ToastContainer.tsx b/superset-frontend/src/components/MessageToasts/ToastContainer.tsx index 0157ff8a4d98..668e1ea533d1 100644 --- a/superset-frontend/src/components/MessageToasts/ToastContainer.tsx +++ b/superset-frontend/src/components/MessageToasts/ToastContainer.tsx @@ -16,11 +16,85 @@ * specific language governing permissions and limitations * under the License. */ +import { useEffect, useRef } from 'react'; import { bindActionCreators } from 'redux'; import { connect } from 'react-redux'; -import ToastPresenter from './ToastPresenter'; - +import { Interweave } from 'interweave'; +import { useNotification } from './NotificationProvider'; import { removeToast } from './actions'; +import { ToastMeta, ToastType } from './types'; + +interface ToastContainerProps { + toasts: ToastMeta[]; + removeToast: (id: string) => void; +} + +function ToastPresenter({ toasts, removeToast }: ToastContainerProps) { + const notification = useNotification(); + const toastKeyMap = useRef(new Map()); + const displayedToasts = useRef(new Set()); + + useEffect(() => { + // Process new toasts + toasts.forEach(toast => { + if (!displayedToasts.current.has(toast.id)) { + displayedToasts.current.add(toast.id); + + const notificationKey = `toast-${toast.id}`; + toastKeyMap.current.set(toast.id, notificationKey); + + const message = toast.allowHtml ? ( + + ) : ( + toast.text + ); + + const config = { + key: notificationKey, + message, + duration: toast.duration > 0 ? toast.duration / 1000 : 0, + placement: 'bottomRight' as const, + onClose: () => { + toastKeyMap.current.delete(toast.id); + displayedToasts.current.delete(toast.id); + removeToast(toast.id); + }, + }; + + switch (toast.toastType) { + case ToastType.Success: + notification.success(config); + break; + case ToastType.Warning: + notification.warning(config); + break; + case ToastType.Danger: + notification.error(config); + break; + case ToastType.Info: + default: + notification.info(config); + break; + } + } + }); + + // Clean up removed toasts + const currentToastIds = new Set(toasts.map(t => t.id)); + displayedToasts.current.forEach(id => { + if (!currentToastIds.has(id)) { + const key = toastKeyMap.current.get(id); + if (key) { + notification.destroy(key); + toastKeyMap.current.delete(id); + } + displayedToasts.current.delete(id); + } + }); + }, [toasts, removeToast, notification]); + + return null; +} const ToastContainer = connect( ({ messageToasts: toasts }: any) => ({ toasts }), diff --git a/superset-frontend/src/components/MessageToasts/ToastPresenter.test.jsx b/superset-frontend/src/components/MessageToasts/ToastPresenter.test.jsx deleted file mode 100644 index f0b4ba738c0d..000000000000 --- a/superset-frontend/src/components/MessageToasts/ToastPresenter.test.jsx +++ /dev/null @@ -1,48 +0,0 @@ -/** - * 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 { fireEvent, render, waitFor } from 'spec/helpers/testing-library'; - -import ToastPresenter from 'src/components/MessageToasts/ToastPresenter'; -import mockMessageToasts from './mockMessageToasts'; - -const props = { - toasts: mockMessageToasts, - removeToast() {}, -}; - -function setup(overrideProps) { - return render(); -} - -test('should render a div with id toast-presenter', () => { - const { container } = setup(); - expect(container.querySelector('#toast-presenter')).toBeInTheDocument(); -}); - -test('should render a Toast for each toast object', () => { - const { getAllByRole } = setup(); - expect(getAllByRole('alert')).toHaveLength(props.toasts.length); -}); - -test('should pass removeToast to the Toast component', async () => { - const removeToast = jest.fn(); - const { getAllByTestId } = setup({ removeToast }); - fireEvent.click(getAllByTestId('close-button')[0]); - await waitFor(() => expect(removeToast).toHaveBeenCalledTimes(1)); -}); diff --git a/superset-frontend/src/components/MessageToasts/ToastPresenter.tsx b/superset-frontend/src/components/MessageToasts/ToastPresenter.tsx deleted file mode 100644 index 1de0e159786d..000000000000 --- a/superset-frontend/src/components/MessageToasts/ToastPresenter.tsx +++ /dev/null @@ -1,98 +0,0 @@ -/** - * 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 { styled } from '@superset-ui/core'; -import { ToastMeta } from 'src/components/MessageToasts/types'; -import Toast from './Toast'; - -export interface VisualProps { - position: 'bottom' | 'top'; -} - -const StyledToastPresenter = styled.div( - ({ theme, position }) => - // Single access to theme, using dot notation - ` - max-width: 600px; - position: fixed; - ${position === 'bottom' ? 'bottom' : 'top'}: 0px; - right: 0px; - margin-right: 50px; - margin-bottom: 50px; - z-index: ${theme.zIndexPopupBase + 1}; - word-break: break-word; - - .toast { - padding: ${theme.sizeUnit * 4}px; - margin: ${theme.sizeUnit * 4}px; - background: ${theme.colorBgSpotlight}; - border-radius: ${theme.borderRadius}px; - box-shadow: ${theme.boxShadow}; - color: ${theme.colorTextLightSolid}; - opacity: 0; - position: relative; - transform: translateY(-100%); - white-space: pre-line; - will-change: transform, opacity; - transition: - transform ${theme.motionDurationMid}, - opacity ${theme.motionDurationMid}; - &:after { - content: ''; - position: absolute; - top: 0; - left: 0; - width: 6px; - height: 100%; - } - } - - .toast > button { - color: ${theme.colorTextLightSolid}; - opacity: 1; - } - - .toast--visible { - opacity: 1; - transform: translateY(0); - } - `, -); - -type ToastPresenterProps = Partial & { - toasts: Array; - removeToast: () => any; -}; - -export default function ToastPresenter({ - toasts, - removeToast, - position = 'bottom', -}: ToastPresenterProps) { - return ( - <> - {toasts.length > 0 && ( - - {toasts.map(toast => ( - - ))} - - )} - - ); -} diff --git a/superset-frontend/src/components/MessageToasts/actions.ts b/superset-frontend/src/components/MessageToasts/actions.ts index 83d7910dec68..6ac610c12970 100644 --- a/superset-frontend/src/components/MessageToasts/actions.ts +++ b/superset-frontend/src/components/MessageToasts/actions.ts @@ -17,9 +17,7 @@ * under the License. */ import { nanoid } from 'nanoid'; -import { ToastType, ToastMeta } from './types'; - -type ToastOptions = Partial>; +import { ToastType, ToastMeta, ToastOptions } from './types'; export function getToastUuid(type: ToastType) { return `${type}-${nanoid()}`; diff --git a/superset-frontend/src/components/MessageToasts/types.ts b/superset-frontend/src/components/MessageToasts/types.ts index 07090ea46d00..ab0eea234fb5 100644 --- a/superset-frontend/src/components/MessageToasts/types.ts +++ b/superset-frontend/src/components/MessageToasts/types.ts @@ -33,4 +33,13 @@ export interface ToastMeta { noDuplicate?: boolean; /** For security reasons, HTML rendering is disabled by default. Use this property to enable it. */ allowHtml?: boolean; + description?: string; } +export type ToastOptions = Partial< + Omit +>; + +export type ToastTriggerFunction = ( + message: string, + options?: ToastOptions, +) => void; diff --git a/superset-frontend/src/embedded/EmbeddedContextProviders.tsx b/superset-frontend/src/embedded/EmbeddedContextProviders.tsx index 207a1d320d39..3c3f47e1cab3 100644 --- a/superset-frontend/src/embedded/EmbeddedContextProviders.tsx +++ b/superset-frontend/src/embedded/EmbeddedContextProviders.tsx @@ -23,6 +23,7 @@ import { QueryParamProvider } from 'use-query-params'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { FlashProvider, DynamicPluginProvider } from 'src/components'; +import { NotificationProvider } from 'src/components/MessageToasts/NotificationProvider'; import { EmbeddedUiConfigProvider } from 'src/components/UiConfigContext'; import { SupersetThemeProvider } from 'src/theme/ThemeProvider'; import { ThemeController } from 'src/theme/ThemeController'; @@ -71,18 +72,20 @@ export const EmbeddedContextProviders: React.FC = ({ children }) => { - - {RootContextProviderExtension ? ( - - {children} - - ) : ( - children - )} - + + + {RootContextProviderExtension ? ( + + {children} + + ) : ( + children + )} + + diff --git a/superset-frontend/src/embedded/index.tsx b/superset-frontend/src/embedded/index.tsx index 9f9123909cad..8190e9f1cdd8 100644 --- a/superset-frontend/src/embedded/index.tsx +++ b/superset-frontend/src/embedded/index.tsx @@ -96,7 +96,7 @@ const EmbeddedRoute = () => ( - + ); diff --git a/superset-frontend/src/pages/ThemeList/index.tsx b/superset-frontend/src/pages/ThemeList/index.tsx index 8c570f46cf58..ca44682ec574 100644 --- a/superset-frontend/src/pages/ThemeList/index.tsx +++ b/superset-frontend/src/pages/ThemeList/index.tsx @@ -59,6 +59,7 @@ import { unsetSystemDefaultTheme, unsetSystemDarkTheme, } from 'src/features/themes/api'; +import { ToastTriggerFunction } from 'src/components/MessageToasts/types'; const PAGE_SIZE = 25; @@ -79,8 +80,8 @@ const CONFIRM_OVERWRITE_MESSAGE = t( ); interface ThemesListProps { - addDangerToast: (msg: string) => void; - addSuccessToast: (msg: string) => void; + addDangerToast: ToastTriggerFunction; + addSuccessToast: ToastTriggerFunction; user: { userId: string | number; firstName: string; diff --git a/superset-frontend/src/views/RootContextProviders.tsx b/superset-frontend/src/views/RootContextProviders.tsx index fbff76b64c90..1ca31defbbe2 100644 --- a/superset-frontend/src/views/RootContextProviders.tsx +++ b/superset-frontend/src/views/RootContextProviders.tsx @@ -25,6 +25,7 @@ import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import getBootstrapData from 'src/utils/getBootstrapData'; import { FlashProvider, DynamicPluginProvider } from 'src/components'; +import { NotificationProvider } from 'src/components/MessageToasts/NotificationProvider'; import { EmbeddedUiConfigProvider } from 'src/components/UiConfigContext'; import { SupersetThemeProvider } from 'src/theme/ThemeProvider'; import { ThemeController } from 'src/theme/ThemeController'; @@ -48,20 +49,22 @@ export const RootContextProviders: React.FC = ({ children }) => { - - - {RootContextProviderExtension ? ( - - {children} - - ) : ( - children - )} - - + + + + {RootContextProviderExtension ? ( + + {children} + + ) : ( + children + )} + + + diff --git a/superset-frontend/src/views/menu.tsx b/superset-frontend/src/views/menu.tsx index 1eefb78b0066..1ae9db133e0f 100644 --- a/superset-frontend/src/views/menu.tsx +++ b/superset-frontend/src/views/menu.tsx @@ -29,6 +29,7 @@ import createCache from '@emotion/cache'; import { ThemeProvider, theme } from '@superset-ui/core'; import Menu from 'src/features/home/Menu'; import getBootstrapData from 'src/utils/getBootstrapData'; +import { NotificationProvider } from 'src/components/MessageToasts/NotificationProvider'; import { setupStore } from './store'; // Disable connecting to redux debugger so that the React app injected @@ -45,16 +46,18 @@ const app = ( // @ts-ignore: emotion types defs are incompatible between core and cache - - - - - - - + + + + + + + + + );