This is an automated email from the ASF dual-hosted git repository.
elizabeth pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new e6bd03fe98 fix(theming): solve modal dark theme issues + styling and
code improvements (#35539)
e6bd03fe98 is described below
commit e6bd03fe98cf576b7a1c98a0d7c7b437d158463c
Author: Gabriel Torres Ruiz <[email protected]>
AuthorDate: Tue Oct 14 14:08:18 2025 -0400
fix(theming): solve modal dark theme issues + styling and code improvements
(#35539)
---
.../components/ConfirmModal/ConfirmModal.test.tsx | 129 +++++
.../src/components/ConfirmModal/index.tsx | 87 ++++
.../superset-ui-core/src/components/index.ts | 1 +
.../src/SqlLab/components/ResultSet/index.tsx | 183 +++----
.../EmbeddedModal/EmbeddedModal.test.tsx | 122 +----
.../dashboard/components/EmbeddedModal/index.tsx | 149 +++---
.../dashboard/components/PropertiesModal/index.tsx | 13 +-
.../explore/components/ControlPanelsContainer.tsx | 19 +-
.../PropertiesModal/PropertiesModal.test.tsx | 1 +
.../explore/components/PropertiesModal/index.tsx | 27 +-
.../src/hooks/useConfirmModal/index.tsx | 78 +++
.../hooks/useConfirmModal/useConfirmModal.test.tsx | 196 ++++++++
.../useUnsavedChangesPrompt.test.tsx | 31 +-
.../src/pages/ThemeList/ThemeList.test.tsx | 524 +++++++++++++++------
.../src/pages/ThemeList/index.test.tsx | 288 -----------
superset-frontend/src/pages/ThemeList/index.tsx | 151 +++---
16 files changed, 1191 insertions(+), 808 deletions(-)
diff --git
a/superset-frontend/packages/superset-ui-core/src/components/ConfirmModal/ConfirmModal.test.tsx
b/superset-frontend/packages/superset-ui-core/src/components/ConfirmModal/ConfirmModal.test.tsx
new file mode 100644
index 0000000000..9a153a0899
--- /dev/null
+++
b/superset-frontend/packages/superset-ui-core/src/components/ConfirmModal/ConfirmModal.test.tsx
@@ -0,0 +1,129 @@
+/**
+ * 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 { render, screen, userEvent } from '@superset-ui/core/spec';
+import { ThemeProvider, supersetTheme } from '@superset-ui/core';
+import { ConfirmModal } from '.';
+
+const defaultProps = {
+ show: true,
+ onHide: jest.fn(),
+ onConfirm: jest.fn(),
+ title: 'Confirm Action',
+ body: 'Are you sure you want to proceed?',
+};
+
+const renderWithTheme = (component: React.ReactElement) =>
+ render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
+
+test('renders modal with title and body', () => {
+ renderWithTheme(<ConfirmModal {...defaultProps} />);
+
+ expect(screen.getByText('Confirm Action')).toBeInTheDocument();
+ expect(
+ screen.getByText('Are you sure you want to proceed?'),
+ ).toBeInTheDocument();
+});
+
+test('renders default confirm and cancel buttons', () => {
+ renderWithTheme(<ConfirmModal {...defaultProps} />);
+
+ expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
+});
+
+test('renders custom button text', () => {
+ renderWithTheme(
+ <ConfirmModal {...defaultProps} confirmText="Delete" cancelText="Keep" />,
+ );
+
+ expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Keep' })).toBeInTheDocument();
+});
+
+test('calls onConfirm when confirm button is clicked', () => {
+ const onConfirm = jest.fn();
+ renderWithTheme(<ConfirmModal {...defaultProps} onConfirm={onConfirm} />);
+
+ userEvent.click(screen.getByRole('button', { name: 'Confirm' }));
+
+ expect(onConfirm).toHaveBeenCalledTimes(1);
+});
+
+test('calls onHide when cancel button is clicked', () => {
+ const onHide = jest.fn();
+ renderWithTheme(<ConfirmModal {...defaultProps} onHide={onHide} />);
+
+ userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
+
+ expect(onHide).toHaveBeenCalledTimes(1);
+});
+
+test('renders danger button style', () => {
+ renderWithTheme(
+ <ConfirmModal {...defaultProps} confirmButtonStyle="danger" />,
+ );
+
+ const confirmButton = screen.getByRole('button', { name: 'Confirm' });
+ expect(confirmButton).toBeInTheDocument();
+});
+
+test('shows loading state on confirm button', () => {
+ renderWithTheme(<ConfirmModal {...defaultProps} loading />);
+
+ const confirmButton = screen.getByRole('button', { name: /Confirm/ });
+ expect(confirmButton).toBeInTheDocument();
+ expect(confirmButton).toHaveClass('ant-btn-loading');
+});
+
+test('disables buttons when loading', () => {
+ renderWithTheme(<ConfirmModal {...defaultProps} loading />);
+
+ const cancelButton = screen.getByRole('button', { name: 'Cancel' });
+ expect(cancelButton).toBeDisabled();
+});
+
+test('renders custom icon', () => {
+ const CustomIcon = () => <span data-test="custom-icon">!</span>;
+ renderWithTheme(<ConfirmModal {...defaultProps} icon={<CustomIcon />} />);
+
+ expect(screen.getByTestId('custom-icon')).toBeInTheDocument();
+});
+
+test('renders ReactNode as body', () => {
+ renderWithTheme(
+ <ConfirmModal
+ {...defaultProps}
+ body={
+ <div>
+ <p>Line 1</p>
+ <p>Line 2</p>
+ </div>
+ }
+ />,
+ );
+
+ expect(screen.getByText('Line 1')).toBeInTheDocument();
+ expect(screen.getByText('Line 2')).toBeInTheDocument();
+});
+
+test('does not render when show is false', () => {
+ renderWithTheme(<ConfirmModal {...defaultProps} show={false} />);
+
+ expect(screen.queryByText('Confirm Action')).not.toBeInTheDocument();
+});
diff --git
a/superset-frontend/packages/superset-ui-core/src/components/ConfirmModal/index.tsx
b/superset-frontend/packages/superset-ui-core/src/components/ConfirmModal/index.tsx
new file mode 100644
index 0000000000..ee26403ee5
--- /dev/null
+++
b/superset-frontend/packages/superset-ui-core/src/components/ConfirmModal/index.tsx
@@ -0,0 +1,87 @@
+/**
+ * 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 { t, styled } from '@superset-ui/core';
+import { Icons, Modal, Typography, Button } from
'@superset-ui/core/components';
+import type { FC, ReactElement, ReactNode } from 'react';
+
+const IconWrapper = styled.span`
+ margin-right: ${({ theme }) => theme.sizeUnit * 2}px;
+`;
+
+const DEFAULT_ICON = <Icons.QuestionCircleOutlined iconSize="m" />;
+
+export type ConfirmModalProps = {
+ show: boolean;
+ onHide: () => void;
+ onConfirm: () => void;
+ title: string;
+ body: string | ReactNode;
+ confirmText?: string;
+ cancelText?: string;
+ confirmButtonStyle?: 'primary' | 'danger' | 'dashed';
+ icon?: ReactNode;
+ loading?: boolean;
+};
+
+export const ConfirmModal: FC<ConfirmModalProps> = ({
+ show,
+ onHide,
+ onConfirm,
+ title,
+ body,
+ confirmText = t('Confirm'),
+ cancelText = t('Cancel'),
+ confirmButtonStyle = 'primary',
+ icon = DEFAULT_ICON,
+ loading = false,
+}: ConfirmModalProps): ReactElement => (
+ <Modal
+ centered
+ responsive
+ onHide={onHide}
+ show={show}
+ width="600px"
+ title={
+ <>
+ <IconWrapper>{icon}</IconWrapper>
+ {title}
+ </>
+ }
+ footer={
+ <>
+ <Button buttonStyle="secondary" onClick={onHide} disabled={loading}>
+ {cancelText}
+ </Button>
+ <Button
+ buttonStyle={confirmButtonStyle}
+ onClick={onConfirm}
+ loading={loading}
+ >
+ {confirmText}
+ </Button>
+ </>
+ }
+ >
+ {typeof body === 'string' ? (
+ <Typography.Text>{body}</Typography.Text>
+ ) : (
+ body
+ )}
+ </Modal>
+);
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 6590688076..f9b6dc217f 100644
--- a/superset-frontend/packages/superset-ui-core/src/components/index.ts
+++ b/superset-frontend/packages/superset-ui-core/src/components/index.ts
@@ -66,6 +66,7 @@ export {
type CheckboxProps,
type CheckboxChangeEvent,
} from './Checkbox';
+export { ConfirmModal, type ConfirmModalProps } from './ConfirmModal';
export {
ColorPicker,
type ColorPickerProps,
diff --git a/superset-frontend/src/SqlLab/components/ResultSet/index.tsx
b/superset-frontend/src/SqlLab/components/ResultSet/index.tsx
index 643e4a712c..b90de2cafc 100644
--- a/superset-frontend/src/SqlLab/components/ResultSet/index.tsx
+++ b/superset-frontend/src/SqlLab/components/ResultSet/index.tsx
@@ -35,7 +35,6 @@ import {
ButtonGroup,
Tooltip,
Card,
- Modal,
Input,
Label,
Loading,
@@ -87,6 +86,7 @@ import {
} from 'src/logger/LogUtils';
import { Icons } from '@superset-ui/core/components/Icons';
import { findPermission } from 'src/utils/findPermission';
+import { useConfirmModal } from 'src/hooks/useConfirmModal';
import ExploreCtasResultsButton from '../ExploreCtasResultsButton';
import ExploreResultsButton from '../ExploreResultsButton';
import HighlightedSql from '../HighlightedSql';
@@ -156,12 +156,14 @@ const ResultSetButtons = styled.div`
padding-right: ${({ theme }) => 2 * theme.sizeUnit}px;
`;
-const copyButtonStyles = css`
+const CopyStyledButton = styled(Button)`
&:hover {
+ color: ${({ theme }) => theme.colorPrimary};
text-decoration: unset;
}
+
span > :first-of-type {
- margin: 0px;
+ margin: 0;
}
`;
@@ -226,6 +228,7 @@ const ResultSet = ({
const history = useHistory();
const dispatch = useDispatch();
const logAction = useLogAction({ queryId, sqlEditorId: query.sqlEditorId });
+ const { showConfirm, ConfirmModal } = useConfirmModal();
const reRunQueryIfSessionTimeoutErrorOnMount = useCallback(() => {
if (
@@ -302,7 +305,7 @@ const ResultSet = ({
const renderControls = () => {
if (search || visualize || csv) {
- const { results, queryLimit, limitingFactor, rows } = query;
+ const { limitingFactor, queryLimit, results, rows } = query;
const limit = queryLimit || results.query.limit;
const rowsCount = Math.min(rows || 0, results?.data?.length || 0);
let { data } = query.results;
@@ -310,7 +313,6 @@ const ResultSet = ({
data = cachedData;
}
const { columns } = query.results;
- // Added compute logic to stop user from being able to Save & Explore
const datasource: ISaveableDatasource = {
columns: query.results.columns as ISimpleColumn[],
@@ -327,6 +329,27 @@ const ResultSet = ({
user?.roles,
);
+ const handleDownloadCsv = (event: React.MouseEvent<HTMLElement>) => {
+ logAction(LOG_ACTIONS_SQLLAB_DOWNLOAD_CSV, {});
+
+ if (limitingFactor === LimitingFactor.Dropdown && limit === rowsCount)
{
+ event.preventDefault();
+
+ showConfirm({
+ title: t('Download is on the way'),
+ body: t(
+ 'Downloading %(rows)s rows based on the LIMIT configuration. If
you want the entire result set, you need to adjust the LIMIT.',
+ { rows: rowsCount.toLocaleString() },
+ ),
+ onConfirm: () => {
+ window.location.href = getExportCsvUrl(query.id);
+ },
+ confirmText: t('OK'),
+ cancelText: t('Close'),
+ });
+ }
+ };
+
return (
<ResultSetControls>
<SaveDatasetModal
@@ -347,45 +370,28 @@ const ResultSet = ({
/>
)}
{csv && canExportData && (
- <Button
- css={copyButtonStyles}
+ <CopyStyledButton
buttonSize="small"
buttonStyle="secondary"
href={getExportCsvUrl(query.id)}
data-test="export-csv-button"
- onClick={() => {
- logAction(LOG_ACTIONS_SQLLAB_DOWNLOAD_CSV, {});
- if (
- limitingFactor === LimitingFactor.Dropdown &&
- limit === rowsCount
- ) {
- Modal.warning({
- title: t('Download is on the way'),
- content: t(
- 'Downloading %(rows)s rows based on the LIMIT
configuration. If you want the entire result set, you need to adjust the
LIMIT.',
- { rows: rowsCount.toLocaleString() },
- ),
- });
- }
- }}
+ onClick={handleDownloadCsv}
>
<Icons.DownloadOutlined iconSize="m" /> {t('Download to CSV')}
- </Button>
+ </CopyStyledButton>
)}
-
{canExportData && (
<CopyToClipboard
text={prepareCopyToClipboardTabularData(data, columns)}
wrapped={false}
copyNode={
- <Button
- css={copyButtonStyles}
+ <CopyStyledButton
buttonSize="small"
buttonStyle="secondary"
data-test="copy-to-clipboard-button"
>
<Icons.CopyOutlined iconSize="s" /> {t('Copy to
Clipboard')}
- </Button>
+ </CopyStyledButton>
}
hideTooltip
onCopyEnd={() =>
@@ -653,68 +659,71 @@ const ResultSet = ({
true,
);
return (
- <ResultContainer>
- {renderControls()}
- {showSql && showSqlInline ? (
- <>
- <div
- css={css`
- display: flex;
- justify-content: space-between;
- align-items: center;
- gap: ${GAP}px;
- `}
- >
- <Card
- css={[
- css`
- height: 28px;
- width: calc(100% - ${ROWS_CHIP_WIDTH + GAP}px);
- code {
- width: 100%;
- overflow: hidden;
- white-space: nowrap !important;
- text-overflow: ellipsis;
- display: block;
- }
- `,
- ]}
+ <>
+ <ResultContainer>
+ {renderControls()}
+ {showSql && showSqlInline ? (
+ <>
+ <div
+ css={css`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: ${GAP}px;
+ `}
>
- {sql}
- </Card>
+ <Card
+ css={[
+ css`
+ height: 28px;
+ width: calc(100% - ${ROWS_CHIP_WIDTH + GAP}px);
+ code {
+ width: 100%;
+ overflow: hidden;
+ white-space: nowrap !important;
+ text-overflow: ellipsis;
+ display: block;
+ }
+ `,
+ ]}
+ >
+ {sql}
+ </Card>
+ {renderRowsReturned(false)}
+ </div>
+ {renderRowsReturned(true)}
+ </>
+ ) : (
+ <>
{renderRowsReturned(false)}
- </div>
- {renderRowsReturned(true)}
- </>
- ) : (
- <>
- {renderRowsReturned(false)}
- {renderRowsReturned(true)}
- {sql}
- </>
- )}
- <div
- css={css`
- flex: 1 1 auto;
- `}
- >
- <AutoSizer disableWidth>
- {({ height }) => (
- <ResultTable
- data={data}
- queryId={query.id}
- orderedColumnKeys={results.columns.map(
- col => col.column_name,
- )}
- height={height}
- filterText={searchText}
- expandedColumns={expandedColumns}
- allowHTML={allowHTML}
- />
- )}
- </AutoSizer>
- </div>
- </ResultContainer>
+ {renderRowsReturned(true)}
+ {sql}
+ </>
+ )}
+ <div
+ css={css`
+ flex: 1 1 auto;
+ `}
+ >
+ <AutoSizer disableWidth>
+ {({ height }) => (
+ <ResultTable
+ data={data}
+ queryId={query.id}
+ orderedColumnKeys={results.columns.map(
+ col => col.column_name,
+ )}
+ height={height}
+ filterText={searchText}
+ expandedColumns={expandedColumns}
+ allowHTML={allowHTML}
+ />
+ )}
+ </AutoSizer>
+ </div>
+ </ResultContainer>
+ {ConfirmModal}
+ </>
);
}
if (data && data.length === 0) {
diff --git
a/superset-frontend/src/dashboard/components/EmbeddedModal/EmbeddedModal.test.tsx
b/superset-frontend/src/dashboard/components/EmbeddedModal/EmbeddedModal.test.tsx
index b4a2b5aa19..492654ede3 100644
---
a/superset-frontend/src/dashboard/components/EmbeddedModal/EmbeddedModal.test.tsx
+++
b/superset-frontend/src/dashboard/components/EmbeddedModal/EmbeddedModal.test.tsx
@@ -16,11 +16,10 @@
* specific language governing permissions and limitations
* under the License.
*/
-
import {
render,
screen,
- fireEvent,
+ userEvent,
waitFor,
} from 'spec/helpers/testing-library';
import {
@@ -28,9 +27,8 @@ import {
getExtensionsRegistry,
makeApi,
} from '@superset-ui/core';
-import { Modal } from '@superset-ui/core/components';
import setupCodeOverrides from 'src/setup/setupCodeOverrides';
-import DashboardEmbedModal from './index';
+import DashboardEmbedModal from '.';
const defaultResponse = {
result: { uuid: 'uuid', dashboard_id: '1', allowed_domains: ['example.com']
},
@@ -58,16 +56,16 @@ const setMockApiNotFound = () => {
};
const setup = () => {
- resetMockApi();
render(<DashboardEmbedModal {...defaultProps} />, { useRedux: true });
};
beforeEach(() => {
jest.clearAllMocks();
resetMockApi();
+ getExtensionsRegistry().set('embedded.modal', undefined);
});
-test('renders', async () => {
+test('renders the embed modal', async () => {
setup();
expect(await screen.findByText('Embed')).toBeInTheDocument();
});
@@ -79,15 +77,15 @@ test('renders loading state', async () => {
});
});
-test('renders the modal default content', async () => {
- render(<DashboardEmbedModal {...defaultProps} />, { useRedux: true });
+test('renders modal content with settings', async () => {
+ setup();
expect(await screen.findByText('Settings')).toBeInTheDocument();
expect(
screen.getByText(new RegExp(/Allowed Domains/, 'i')),
).toBeInTheDocument();
});
-test('renders the correct actions when dashboard is ready to embed', async ()
=> {
+test('shows Deactivate and Save changes buttons when ready to embed', async ()
=> {
setup();
expect(
await screen.findByRole('button', { name: 'Deactivate' }),
@@ -97,7 +95,7 @@ test('renders the correct actions when dashboard is ready to
embed', async () =>
).toBeInTheDocument();
});
-test('renders the correct actions when dashboard is not ready to embed', async
() => {
+test('shows Enable embedding button when not ready to embed', async () => {
setMockApiNotFound();
render(<DashboardEmbedModal {...defaultProps} />, { useRedux: true });
expect(
@@ -105,7 +103,7 @@ test('renders the correct actions when dashboard is not
ready to embed', async (
).toBeInTheDocument();
});
-test('enables embedding', async () => {
+test('enables embedding when Enable embedding button is clicked', async () => {
setMockApiNotFound();
render(<DashboardEmbedModal {...defaultProps} />, { useRedux: true });
@@ -115,33 +113,33 @@ test('enables embedding', async () => {
expect(enableEmbed).toBeInTheDocument();
resetMockApi();
- fireEvent.click(enableEmbed);
+ await userEvent.click(enableEmbed);
expect(
await screen.findByRole('button', { name: 'Deactivate' }),
).toBeInTheDocument();
});
-test('shows and hides the confirmation modal on deactivation', async () => {
+test('shows and hides confirmation alert when deactivating', async () => {
setup();
const deactivate = await screen.findByRole('button', { name: 'Deactivate' });
- fireEvent.click(deactivate);
+ await userEvent.click(deactivate);
expect(await screen.findByText('Disable embedding?')).toBeInTheDocument();
expect(
screen.getByText('This will remove your current embed configuration.'),
).toBeInTheDocument();
- const okBtn = screen.getByRole('button', { name: 'OK' });
- fireEvent.click(okBtn);
+ const confirmBtn = screen.getByRole('button', { name: 'Deactivate' });
+ await userEvent.click(confirmBtn);
await waitFor(() => {
expect(screen.queryByText('Disable embedding?')).not.toBeInTheDocument();
});
});
-test('enables the "Save Changes" button', async () => {
+test('enables Save Changes button when allowed domains are modified', async ()
=> {
setup();
const allowedDomainsInput = await screen.findByRole('textbox', {
@@ -153,11 +151,12 @@ test('enables the "Save Changes" button', async () => {
expect(saveChangesBtn).toBeDisabled();
expect(allowedDomainsInput).toBeInTheDocument();
- fireEvent.change(allowedDomainsInput, { target: { value: 'test.com' } });
+ await userEvent.clear(allowedDomainsInput);
+ await userEvent.type(allowedDomainsInput, 'test.com');
expect(saveChangesBtn).toBeEnabled();
});
-test('adds extension to DashboardEmbedModal', async () => {
+test('renders extension component when registered', async () => {
const extensionsRegistry = getExtensionsRegistry();
extensionsRegistry.set('embedded.modal', () => (
@@ -165,7 +164,7 @@ test('adds extension to DashboardEmbedModal', async () => {
));
setupCodeOverrides();
- render(<DashboardEmbedModal {...defaultProps} />, { useRedux: true });
+ setup();
expect(
await screen.findByText('dashboard.embed.modal.extension component'),
@@ -173,86 +172,3 @@ test('adds extension to DashboardEmbedModal', async () => {
extensionsRegistry.set('embedded.modal', undefined);
});
-
-// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from
describe blocks
-describe('Modal.useModal integration', () => {
- beforeEach(() => {
- jest.clearAllMocks();
- });
-
- test('uses Modal.useModal hook for confirmation dialogs', () => {
- const useModalSpy = jest.spyOn(Modal, 'useModal');
- setup();
-
- // Verify that useModal is called when the component mounts
- expect(useModalSpy).toHaveBeenCalled();
-
- useModalSpy.mockRestore();
- });
-
- test('renders contextHolder for proper theming', async () => {
- const { container } = render(<DashboardEmbedModal {...defaultProps} />, {
- useRedux: true,
- });
-
- // Wait for component to be rendered
- await screen.findByText('Embed');
-
- // The contextHolder is rendered in the component tree
- // Check that modal root elements exist for theming
- const modalRootElements = container.querySelectorAll('.ant-modal-root');
- expect(modalRootElements).toBeDefined();
- });
-
- test('confirmation modal inherits theme context', async () => {
- setup();
-
- // Click deactivate to trigger the confirmation modal
- const deactivate = await screen.findByRole('button', {
- name: 'Deactivate',
- });
- fireEvent.click(deactivate);
-
- // Wait for the modal to appear
- const modalTitle = await screen.findByText('Disable embedding?');
- expect(modalTitle).toBeInTheDocument();
-
- // Check that the modal is rendered within the component tree (not on body
directly)
- const modal = modalTitle.closest('.ant-modal-wrap');
- expect(modal).toBeInTheDocument();
- });
-
- test('does not use Modal.confirm directly', () => {
- // Spy on the static Modal.confirm method
- const confirmSpy = jest.spyOn(Modal, 'confirm');
-
- setup();
-
- // The component should not call Modal.confirm directly
- expect(confirmSpy).not.toHaveBeenCalled();
-
- confirmSpy.mockRestore();
- });
-
- test('modal actions work correctly with useModal', async () => {
- setup();
-
- // Click deactivate
- const deactivate = await screen.findByRole('button', {
- name: 'Deactivate',
- });
- fireEvent.click(deactivate);
-
- // Modal should appear
- expect(await screen.findByText('Disable embedding?')).toBeInTheDocument();
-
- // Click Cancel to close modal
- const cancelBtn = screen.getByRole('button', { name: 'Cancel' });
- fireEvent.click(cancelBtn);
-
- // Modal should close
- await waitFor(() => {
- expect(screen.queryByText('Disable embedding?')).not.toBeInTheDocument();
- });
- });
-});
diff --git a/superset-frontend/src/dashboard/components/EmbeddedModal/index.tsx
b/superset-frontend/src/dashboard/components/EmbeddedModal/index.tsx
index 1928c9d9a4..1ad5fec6ad 100644
--- a/superset-frontend/src/dashboard/components/EmbeddedModal/index.tsx
+++ b/superset-frontend/src/dashboard/components/EmbeddedModal/index.tsx
@@ -33,6 +33,8 @@ import {
Modal,
Loading,
Form,
+ Alert,
+ Space,
} from '@superset-ui/core/components';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import { EmbeddedDashboard } from 'src/dashboard/types';
@@ -64,9 +66,7 @@ export const DashboardEmbedControls = ({ dashboardId, onHide
}: Props) => {
const [loading, setLoading] = useState(false); // whether we are currently
doing an async thing
const [embedded, setEmbedded] = useState<EmbeddedDashboard | null>(null); //
the embedded dashboard config
const [allowedDomains, setAllowedDomains] = useState<string>('');
-
- // Use Modal.useModal hook to ensure proper theming
- const [modal, contextHolder] = Modal.useModal();
+ const [showDeactivateConfirm, setShowDeactivateConfirm] = useState(false);
const endpoint = `/api/v1/dashboard/${dashboardId}/embedded`;
// whether saveable changes have been made to the config
@@ -103,35 +103,33 @@ export const DashboardEmbedControls = ({ dashboardId,
onHide }: Props) => {
}, [endpoint, allowedDomains]);
const disableEmbedded = useCallback(() => {
- modal.confirm({
- title: t('Disable embedding?'),
- content: t('This will remove your current embed configuration.'),
- okType: 'danger',
- onOk: () => {
- setLoading(true);
- makeApi<{}>({ method: 'DELETE', endpoint })({})
- .then(
- () => {
- setEmbedded(null);
- setAllowedDomains('');
- addInfoToast(t('Embedding deactivated.'));
- onHide();
- },
- err => {
- console.error(err);
- addDangerToast(
- t(
- 'Sorry, something went wrong. Embedding could not be
deactivated.',
- ),
- );
- },
- )
- .finally(() => {
- setLoading(false);
- });
- },
- });
- }, [endpoint, modal]);
+ setShowDeactivateConfirm(true);
+ }, []);
+
+ const confirmDeactivate = useCallback(() => {
+ setLoading(true);
+ makeApi<{}>({ method: 'DELETE', endpoint })({})
+ .then(
+ () => {
+ setEmbedded(null);
+ setAllowedDomains('');
+ setShowDeactivateConfirm(false);
+ addInfoToast(t('Embedding deactivated.'));
+ onHide();
+ },
+ err => {
+ console.error(err);
+ addDangerToast(
+ t(
+ 'Sorry, something went wrong. Embedding could not be
deactivated.',
+ ),
+ );
+ },
+ )
+ .finally(() => {
+ setLoading(false);
+ });
+ }, [endpoint, addInfoToast, addDangerToast, onHide]);
useEffect(() => {
setReady(false);
@@ -170,7 +168,6 @@ export const DashboardEmbedControls = ({ dashboardId,
onHide }: Props) => {
return (
<>
- {contextHolder}
{embedded ? (
DocsConfigDetails ? (
<DocsConfigDetails embeddedId={embedded.uuid} />
@@ -222,39 +219,71 @@ export const DashboardEmbedControls = ({ dashboardId,
onHide }: Props) => {
/>
</FormItem>
</Form>
- <ButtonRow
- css={theme => css`
- margin-top: ${theme.margin}px;
- `}
- >
- {embedded ? (
- <>
- <Button
- onClick={disableEmbedded}
- buttonStyle="secondary"
- loading={loading}
- >
- {t('Deactivate')}
- </Button>
+ {showDeactivateConfirm ? (
+ <Alert
+ closable={false}
+ type="warning"
+ message={t('Disable embedding?')}
+ description={t('This will remove your current embed configuration.')}
+ css={{
+ textAlign: 'left',
+ marginTop: '16px',
+ }}
+ action={
+ <Space>
+ <Button
+ key="cancel"
+ buttonStyle="secondary"
+ onClick={() => setShowDeactivateConfirm(false)}
+ >
+ {t('Cancel')}
+ </Button>
+ <Button
+ key="deactivate"
+ buttonStyle="danger"
+ onClick={confirmDeactivate}
+ loading={loading}
+ >
+ {t('Deactivate')}
+ </Button>
+ </Space>
+ }
+ />
+ ) : (
+ <ButtonRow
+ css={theme => css`
+ margin-top: ${theme.margin}px;
+ `}
+ >
+ {embedded ? (
+ <>
+ <Button
+ onClick={disableEmbedded}
+ buttonStyle="secondary"
+ loading={loading}
+ >
+ {t('Deactivate')}
+ </Button>
+ <Button
+ onClick={enableEmbedded}
+ buttonStyle="primary"
+ disabled={!isDirty}
+ loading={loading}
+ >
+ {t('Save changes')}
+ </Button>
+ </>
+ ) : (
<Button
onClick={enableEmbedded}
buttonStyle="primary"
- disabled={!isDirty}
loading={loading}
>
- {t('Save changes')}
+ {t('Enable embedding')}
</Button>
- </>
- ) : (
- <Button
- onClick={enableEmbedded}
- buttonStyle="primary"
- loading={loading}
- >
- {t('Enable embedding')}
- </Button>
- )}
- </ButtonRow>
+ )}
+ </ButtonRow>
+ )}
</>
);
};
diff --git
a/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx
b/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx
index 614f042ba5..cf3015b5e6 100644
--- a/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx
+++ b/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx
@@ -21,7 +21,6 @@ import { omit } from 'lodash';
import jsonStringify from 'json-stringify-pretty-compact';
import {
Form,
- Modal,
Collapse,
CollapseLabelInModal,
JsonEditor,
@@ -151,11 +150,7 @@ const PropertiesModal = ({
}
}
- Modal.error({
- title: t('Error'),
- content: errorText,
- okButtonProps: { danger: true, className: 'btn-danger' },
- });
+ addDangerToast(errorText);
};
const handleDashboardData = useCallback(
@@ -267,11 +262,7 @@ const PropertiesModal = ({
// only fire if the color_scheme is present and invalid
if (colorScheme && !colorChoices.includes(colorScheme)) {
- Modal.error({
- title: t('Error'),
- content: t('A valid color scheme is required'),
- okButtonProps: { danger: true, className: 'btn-danger' },
- });
+ addDangerToast(t('A valid color scheme is required'));
onHide();
throw new Error('A valid color scheme is required');
}
diff --git
a/superset-frontend/src/explore/components/ControlPanelsContainer.tsx
b/superset-frontend/src/explore/components/ControlPanelsContainer.tsx
index 7e8d5f147c..6a53656077 100644
--- a/superset-frontend/src/explore/components/ControlPanelsContainer.tsx
+++ b/superset-frontend/src/explore/components/ControlPanelsContainer.tsx
@@ -59,13 +59,13 @@ import { kebabCase, isEqual } from 'lodash';
import {
Collapse,
- Modal,
Loading,
Label,
Tooltip,
} from '@superset-ui/core/components';
import Tabs from '@superset-ui/core/components/Tabs';
import { PluginContext } from 'src/components';
+import { useConfirmModal } from 'src/hooks/useConfirmModal';
import { getSectionsToRender } from 'src/explore/controlUtils';
import { ExploreActions } from 'src/explore/actions/exploreActions';
@@ -285,7 +285,7 @@ function useResetOnChangeRef(initialValue: () => any,
resetOnChangeValue: any) {
export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => {
const theme = useTheme();
const pluginContext = useContext(PluginContext);
- const [modal, contextHolder] = Modal.useModal();
+ const { showConfirm, ConfirmModal } = useConfirmModal();
const prevState = usePrevious(props.exploreState);
const prevDatasource = usePrevious(props.exploreState.datasource);
@@ -323,12 +323,12 @@ export const ControlPanelsContainer = (props:
ControlPanelsContainerProps) => {
filter.subject === x_axis,
);
if (noFilter) {
- modal.confirm({
+ showConfirm({
title: t('The X-axis is not on the filters list'),
- content:
- t(`The X-axis is not on the filters list which will prevent it
from being used in
- time range filters in dashboards. Would you like to add it to the
filters list?`),
- onOk: () => {
+ body: t(
+ `The X-axis is not on the filters list which will prevent it from
being used in time range filters in dashboards. Would you like to add it to the
filters list?`,
+ ),
+ onConfirm: () => {
setControlValue('adhoc_filters', [
...(adhoc_filters || []),
{
@@ -340,6 +340,8 @@ export const ControlPanelsContainer = (props:
ControlPanelsContainerProps) => {
},
]);
},
+ confirmText: t('Yes'),
+ cancelText: t('No'),
});
}
}
@@ -350,6 +352,7 @@ export const ControlPanelsContainer = (props:
ControlPanelsContainerProps) => {
defaultTimeFilter,
previousXAxis,
props.exploreState.datasource,
+ showConfirm,
]);
useEffect(() => {
@@ -851,7 +854,6 @@ export const ControlPanelsContainer = (props:
ControlPanelsContainerProps) => {
return (
<>
- {contextHolder}
<Styles ref={containerRef}>
<Tabs
id="controlSections"
@@ -959,6 +961,7 @@ export const ControlPanelsContainer = (props:
ControlPanelsContainerProps) => {
/>
</div>
</Styles>
+ {ConfirmModal}
</>
);
};
diff --git
a/superset-frontend/src/explore/components/PropertiesModal/PropertiesModal.test.tsx
b/superset-frontend/src/explore/components/PropertiesModal/PropertiesModal.test.tsx
index 5bbc04563a..dc50d430c8 100644
---
a/superset-frontend/src/explore/components/PropertiesModal/PropertiesModal.test.tsx
+++
b/superset-frontend/src/explore/components/PropertiesModal/PropertiesModal.test.tsx
@@ -47,6 +47,7 @@ const createProps = () =>
onHide: jest.fn(),
onSave: jest.fn(),
addSuccessToast: jest.fn(),
+ addDangerToast: jest.fn(),
}) as PropertiesModalProps;
fetchMock.get('glob:*/api/v1/chart/318*', {
diff --git a/superset-frontend/src/explore/components/PropertiesModal/index.tsx
b/superset-frontend/src/explore/components/PropertiesModal/index.tsx
index efcf69912c..4ff4637a29 100644
--- a/superset-frontend/src/explore/components/PropertiesModal/index.tsx
+++ b/superset-frontend/src/explore/components/PropertiesModal/index.tsx
@@ -21,7 +21,6 @@ import { ChangeEvent, useMemo, useState, useCallback,
useEffect } from 'react';
import {
Input,
AsyncSelect,
- Modal,
Collapse,
CollapseLabelInModal,
type SelectValue,
@@ -54,6 +53,7 @@ export type PropertiesModalProps = {
permissionsError?: string;
existingOwners?: SelectValue;
addSuccessToast: (msg: string) => void;
+ addDangerToast: (msg: string) => void;
};
function PropertiesModal({
@@ -62,6 +62,7 @@ function PropertiesModal({
onSave,
show,
addSuccessToast,
+ addDangerToast,
}: PropertiesModalProps) {
const [submitting, setSubmitting] = useState(false);
// values of form inputs
@@ -133,17 +134,17 @@ function PropertiesModal({
return selectTags;
}, [tags.length]);
- function showError({ error, statusText, message }: any) {
- let errorText = error || statusText || t('An error has occurred');
- if (message === 'Forbidden') {
- errorText = t('You do not have permission to edit this chart');
- }
- Modal.error({
- title: t('Error'),
- content: errorText,
- okButtonProps: { danger: true, className: 'btn-danger' },
- });
- }
+ const showError = useCallback(
+ ({ error, statusText, message }: any) => {
+ let errorText = error || statusText || t('An error has occurred');
+ if (message === 'Forbidden') {
+ errorText = t('You do not have permission to edit this chart');
+ }
+
+ addDangerToast(errorText);
+ },
+ [addDangerToast, t],
+ );
const fetchChartProperties = useCallback(
async function fetchChartProperties() {
@@ -179,7 +180,7 @@ function PropertiesModal({
showError(clientError);
}
},
- [slice.slice_id],
+ [showError, slice.slice_id],
);
const loadOptions = useMemo(
diff --git a/superset-frontend/src/hooks/useConfirmModal/index.tsx
b/superset-frontend/src/hooks/useConfirmModal/index.tsx
new file mode 100644
index 0000000000..e28eb91977
--- /dev/null
+++ b/superset-frontend/src/hooks/useConfirmModal/index.tsx
@@ -0,0 +1,78 @@
+/**
+ * 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 { useState, useCallback, ReactNode } from 'react';
+import { ConfirmModal } from '@superset-ui/core/components';
+
+export interface ConfirmConfig {
+ title: string;
+ body: string | ReactNode;
+ onConfirm: () => void | Promise<void>;
+ confirmText?: string;
+ cancelText?: string;
+ confirmButtonStyle?: 'primary' | 'danger' | 'dashed';
+ icon?: ReactNode;
+}
+
+export const useConfirmModal = () => {
+ const [config, setConfig] = useState<ConfirmConfig | null>(null);
+ const [loading, setLoading] = useState(false);
+
+ const showConfirm = useCallback((options: ConfirmConfig) => {
+ setConfig(options);
+ }, []);
+
+ const handleHide = useCallback(() => {
+ if (!loading) {
+ setConfig(null);
+ }
+ }, [loading]);
+
+ const handleConfirm = useCallback(async () => {
+ if (!config) return;
+
+ try {
+ setLoading(true);
+ await config.onConfirm();
+ setConfig(null);
+ } catch (error) {
+ // Let the error propagate but keep modal open
+ // eslint-disable-next-line no-console
+ console.error('Confirm action failed:', error);
+ } finally {
+ setLoading(false);
+ }
+ }, [config]);
+
+ const ConfirmModalComponent = config ? (
+ <ConfirmModal
+ show={!!config}
+ onHide={handleHide}
+ onConfirm={handleConfirm}
+ title={config.title}
+ body={config.body}
+ confirmText={config.confirmText}
+ cancelText={config.cancelText}
+ confirmButtonStyle={config.confirmButtonStyle}
+ icon={config.icon}
+ loading={loading}
+ />
+ ) : null;
+
+ return { showConfirm, ConfirmModal: ConfirmModalComponent };
+};
diff --git
a/superset-frontend/src/hooks/useConfirmModal/useConfirmModal.test.tsx
b/superset-frontend/src/hooks/useConfirmModal/useConfirmModal.test.tsx
new file mode 100644
index 0000000000..351e0af2ec
--- /dev/null
+++ b/superset-frontend/src/hooks/useConfirmModal/useConfirmModal.test.tsx
@@ -0,0 +1,196 @@
+/**
+ * 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 { render, screen, userEvent, waitFor } from '@superset-ui/core/spec';
+import { renderHook } from '@testing-library/react-hooks';
+import { ThemeProvider, supersetTheme } from '@superset-ui/core';
+import { useConfirmModal } from '.';
+
+const renderWithTheme = (component: React.ReactElement) =>
+ render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
+
+test('initially returns null ConfirmModal', () => {
+ const { result } = renderHook(() => useConfirmModal());
+
+ expect(result.current.ConfirmModal).toBeNull();
+});
+
+test('showConfirm creates modal with config', () => {
+ const { result } = renderHook(() => useConfirmModal());
+
+ result.current.showConfirm({
+ title: 'Test Title',
+ body: 'Test Body',
+ onConfirm: jest.fn(),
+ });
+
+ expect(result.current.ConfirmModal).not.toBeNull();
+});
+
+test('renders modal when showConfirm is called', () => {
+ const { result } = renderHook(() => useConfirmModal());
+
+ result.current.showConfirm({
+ title: 'Delete Item',
+ body: 'Are you sure you want to delete this item?',
+ onConfirm: jest.fn(),
+ });
+
+ renderWithTheme(<>{result.current.ConfirmModal}</>);
+
+ expect(screen.getByText('Delete Item')).toBeInTheDocument();
+ expect(
+ screen.getByText('Are you sure you want to delete this item?'),
+ ).toBeInTheDocument();
+});
+
+test('calls onConfirm when confirm button is clicked', async () => {
+ const onConfirm = jest.fn();
+ const { result } = renderHook(() => useConfirmModal());
+
+ result.current.showConfirm({
+ title: 'Confirm',
+ body: 'Proceed?',
+ onConfirm,
+ });
+
+ renderWithTheme(<>{result.current.ConfirmModal}</>);
+
+ await userEvent.click(screen.getByRole('button', { name: 'Confirm' }));
+
+ await waitFor(() => {
+ expect(onConfirm).toHaveBeenCalledTimes(1);
+ });
+});
+
+test('handles async onConfirm', async () => {
+ const onConfirm = jest.fn().mockResolvedValue(undefined);
+ const { result } = renderHook(() => useConfirmModal());
+
+ result.current.showConfirm({
+ title: 'Async Action',
+ body: 'This will take some time',
+ onConfirm,
+ });
+
+ renderWithTheme(<>{result.current.ConfirmModal}</>);
+
+ await userEvent.click(screen.getByRole('button', { name: /Confirm/ }));
+
+ await waitFor(() => {
+ expect(onConfirm).toHaveBeenCalled();
+ });
+});
+
+test('closes modal on cancel', async () => {
+ const { result } = renderHook(() => useConfirmModal());
+
+ result.current.showConfirm({
+ title: 'Test',
+ body: 'Test',
+ onConfirm: jest.fn(),
+ });
+
+ expect(result.current.ConfirmModal).not.toBeNull();
+
+ renderWithTheme(<>{result.current.ConfirmModal}</>);
+
+ await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
+
+ await waitFor(() => {
+ expect(result.current.ConfirmModal).toBeNull();
+ });
+});
+
+test('supports custom button text', () => {
+ const { result } = renderHook(() => useConfirmModal());
+
+ result.current.showConfirm({
+ title: 'Delete',
+ body: 'Remove this?',
+ onConfirm: jest.fn(),
+ confirmText: 'Remove',
+ cancelText: 'Keep',
+ });
+
+ renderWithTheme(<>{result.current.ConfirmModal}</>);
+
+ expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Keep' })).toBeInTheDocument();
+});
+
+test('supports danger button style', () => {
+ const { result } = renderHook(() => useConfirmModal());
+
+ result.current.showConfirm({
+ title: 'Delete',
+ body: 'This is dangerous',
+ onConfirm: jest.fn(),
+ confirmButtonStyle: 'danger',
+ });
+
+ renderWithTheme(<>{result.current.ConfirmModal}</>);
+
+ const confirmButton = screen.getByRole('button', { name: 'Confirm' });
+ expect(confirmButton).toHaveClass('superset-button-danger');
+});
+
+test('handles errors in onConfirm gracefully', async () => {
+ const consoleError = jest.spyOn(console, 'error').mockImplementation();
+ const onConfirm = jest.fn().mockRejectedValue(new Error('Test error'));
+ const { result } = renderHook(() => useConfirmModal());
+
+ result.current.showConfirm({
+ title: 'Error Test',
+ body: 'This will fail',
+ onConfirm,
+ });
+
+ renderWithTheme(<>{result.current.ConfirmModal}</>);
+
+ await userEvent.click(screen.getByRole('button', { name: /Confirm/ }));
+
+ await waitFor(() => {
+ expect(consoleError).toHaveBeenCalled();
+ expect(result.current.ConfirmModal).not.toBeNull(); // Modal stays open on
error
+ });
+
+ consoleError.mockRestore();
+});
+
+test('closes modal after successful confirm', async () => {
+ const onConfirm = jest.fn().mockResolvedValue(undefined);
+ const { result } = renderHook(() => useConfirmModal());
+
+ result.current.showConfirm({
+ title: 'Success Test',
+ body: 'This will succeed',
+ onConfirm,
+ });
+
+ expect(result.current.ConfirmModal).not.toBeNull();
+
+ renderWithTheme(<>{result.current.ConfirmModal}</>);
+
+ await userEvent.click(screen.getByRole('button', { name: /Confirm/ }));
+
+ await waitFor(() => {
+ expect(onConfirm).toHaveBeenCalled();
+ expect(result.current.ConfirmModal).toBeNull();
+ });
+});
diff --git
a/superset-frontend/src/hooks/useUnsavedChangesPrompt/useUnsavedChangesPrompt.test.tsx
b/superset-frontend/src/hooks/useUnsavedChangesPrompt/useUnsavedChangesPrompt.test.tsx
index 37cde732cd..444b53197d 100644
---
a/superset-frontend/src/hooks/useUnsavedChangesPrompt/useUnsavedChangesPrompt.test.tsx
+++
b/superset-frontend/src/hooks/useUnsavedChangesPrompt/useUnsavedChangesPrompt.test.tsx
@@ -17,10 +17,9 @@
* under the License.
*/
import { renderHook } from '@testing-library/react-hooks';
-import { useUnsavedChangesPrompt } from 'src/hooks/useUnsavedChangesPrompt';
import { Router } from 'react-router-dom';
import { createMemoryHistory } from 'history';
-import { act } from 'spec/helpers/testing-library';
+import { useUnsavedChangesPrompt } from '.';
let history = createMemoryHistory({
initialEntries: ['/dashboard'],
@@ -58,11 +57,9 @@ test('should block navigation and show modal if there are
unsaved changes', () =
);
// Simulate blocked navigation
- act(() => {
- const unblock = history.block((tx: any) => tx);
- unblock();
- history.push('/another-page');
- });
+ const unblock = history.block((tx: any) => tx);
+ unblock();
+ history.push('/another-page');
expect(result.current.showModal).toBe(true);
});
@@ -79,9 +76,7 @@ test('should trigger onSave and hide modal on
handleSaveAndCloseModal', async ()
{ wrapper },
);
- await act(async () => {
- await result.current.handleSaveAndCloseModal();
- });
+ await result.current.handleSaveAndCloseModal();
expect(onSave).toHaveBeenCalled();
expect(result.current.showModal).toBe(false);
@@ -99,9 +94,7 @@ test('should trigger manual save and not show modal again',
async () => {
{ wrapper },
);
- act(() => {
- result.current.triggerManualSave();
- });
+ result.current.triggerManualSave();
expect(onSave).toHaveBeenCalled();
expect(result.current.showModal).toBe(false);
@@ -120,18 +113,14 @@ test('should close modal when handleConfirmNavigation is
called', () => {
);
// First, trigger navigation to show the modal
- act(() => {
- const unblock = history.block((tx: any) => tx);
- unblock();
- history.push('/another-page');
- });
+ const unblock = history.block((tx: any) => tx);
+ unblock();
+ history.push('/another-page');
expect(result.current.showModal).toBe(true);
// Then call handleConfirmNavigation to discard changes
- act(() => {
- result.current.handleConfirmNavigation();
- });
+ result.current.handleConfirmNavigation();
expect(result.current.showModal).toBe(false);
});
diff --git a/superset-frontend/src/pages/ThemeList/ThemeList.test.tsx
b/superset-frontend/src/pages/ThemeList/ThemeList.test.tsx
index d455cf7f78..eb52646775 100644
--- a/superset-frontend/src/pages/ThemeList/ThemeList.test.tsx
+++ b/superset-frontend/src/pages/ThemeList/ThemeList.test.tsx
@@ -16,51 +16,97 @@
* specific language governing permissions and limitations
* under the License.
*/
-import fetchMock from 'fetch-mock';
import {
render,
screen,
- fireEvent,
+ userEvent,
waitFor,
} from 'spec/helpers/testing-library';
-import { Modal } from '@superset-ui/core/components';
+import fetchMock from 'fetch-mock';
+import * as hooks from 'src/views/CRUD/hooks';
+import { useThemeContext } from 'src/theme/ThemeProvider';
import ThemesList from './index';
-const themesInfoEndpoint = 'glob:*/api/v1/theme/_info*';
-const themesEndpoint = 'glob:*/api/v1/theme/?*';
-const themeEndpoint = 'glob:*/api/v1/theme/*';
+// Mock the getBootstrapData function
+jest.mock('src/utils/getBootstrapData', () => ({
+ __esModule: true,
+ default: () => ({
+ common: {
+ theme: {
+ enableUiThemeAdministration: true,
+ },
+ },
+ user: {
+ userId: 1,
+ username: 'admin',
+ roles: {
+ Admin: [['can_write', 'Theme']],
+ },
+ },
+ }),
+}));
+
+// Mock theme API functions
+jest.mock('src/features/themes/api', () => ({
+ setSystemDefaultTheme: jest.fn(() => Promise.resolve()),
+ setSystemDarkTheme: jest.fn(() => Promise.resolve()),
+ unsetSystemDefaultTheme: jest.fn(() => Promise.resolve()),
+ unsetSystemDarkTheme: jest.fn(() => Promise.resolve()),
+}));
+
+// Mock the CRUD hooks
+jest.mock('src/views/CRUD/hooks', () => ({
+ ...jest.requireActual('src/views/CRUD/hooks'),
+ useListViewResource: jest.fn(),
+}));
+
+// Mock the useThemeContext hook
+const mockSetTemporaryTheme = jest.fn();
+jest.mock('src/theme/ThemeProvider', () => ({
+ ...jest.requireActual('src/theme/ThemeProvider'),
+ useThemeContext: jest.fn(),
+}));
const mockThemes = [
{
id: 1,
- theme_name: 'Dark Theme',
- json_data: '{"colors": {"primary": "#1890ff"}}',
+ theme_name: 'Light Theme',
+ is_system_default: true,
+ is_system_dark: false,
is_system: false,
- changed_on_delta_humanized: '2 days ago',
+ json_data: '{"colors": {"primary": "#ffffff"}}',
+ created_by: { id: 1, first_name: 'Admin', last_name: 'User' },
+ changed_on_delta_humanized: '1 day ago',
changed_by: {
- first_name: 'John',
- last_name: 'Doe',
+ first_name: 'Admin',
+ last_name: 'User',
},
},
{
id: 2,
- theme_name: 'Light Theme',
- json_data: '{"colors": {"primary": "#ffffff"}}',
+ theme_name: 'Dark Theme',
+ is_system_default: false,
+ is_system_dark: true,
is_system: true,
- changed_on_delta_humanized: '1 week ago',
+ json_data: '{"colors": {"primary": "#1890ff"}}',
+ created_by: { id: 1, first_name: 'Admin', last_name: 'User' },
+ changed_on_delta_humanized: '2 days ago',
changed_by: {
- first_name: 'Jane',
- last_name: 'Smith',
+ first_name: 'John',
+ last_name: 'Doe',
},
},
{
id: 3,
theme_name: 'Custom Theme',
- json_data: '{"colors": {"primary": "#52c41a"}}',
+ is_system_default: false,
+ is_system_dark: false,
is_system: false,
- changed_on_delta_humanized: '1 day ago',
+ json_data: '{"colors": {"primary": "#52c41a"}}',
+ created_by: { id: 2, first_name: 'Test', last_name: 'User' },
+ changed_on_delta_humanized: '3 days ago',
changed_by: {
- first_name: 'Admin',
+ first_name: 'Test',
last_name: 'User',
},
},
@@ -72,25 +118,58 @@ const mockUser = {
lastName: 'User',
};
-fetchMock.get(themesInfoEndpoint, {
- permissions: ['can_read', 'can_write', 'can_export'],
-});
+const themesInfoEndpoint = 'glob:*/api/v1/theme/_info*';
+const themesEndpoint = 'glob:*/api/v1/theme/?*';
+const themeEndpoint = 'glob:*/api/v1/theme/*';
+
+const mockRefreshData = jest.fn();
-fetchMock.get(themesEndpoint, {
- ids: [1, 2, 3],
- result: mockThemes,
- count: mockThemes.length,
+beforeEach(() => {
+ // Mock the useListViewResource hook
+ (hooks.useListViewResource as jest.Mock).mockReturnValue({
+ state: {
+ loading: false,
+ resourceCollection: mockThemes,
+ resourceCount: 3,
+ bulkSelectEnabled: false,
+ },
+ setResourceCollection: jest.fn(),
+ hasPerm: jest.fn().mockReturnValue(true),
+ refreshData: mockRefreshData,
+ fetchData: jest.fn(),
+ toggleBulkSelect: jest.fn(),
+ });
+
+ // Mock useThemeContext
+ (useThemeContext as jest.Mock).mockReturnValue({
+ getCurrentCrudThemeId: jest.fn().mockReturnValue('1'),
+ appliedTheme: { theme_name: 'Light Theme', id: 1 },
+ setTemporaryTheme: mockSetTemporaryTheme,
+ });
+
+ fetchMock.reset();
+ fetchMock.get(themesInfoEndpoint, {
+ permissions: ['can_read', 'can_write', 'can_export'],
+ });
+ fetchMock.get(themesEndpoint, {
+ ids: [1, 2, 3],
+ count: 3,
+ result: mockThemes,
+ });
+ fetchMock.delete(themeEndpoint, {});
});
-fetchMock.delete(themeEndpoint, {});
+afterEach(() => {
+ fetchMock.restore();
+ jest.clearAllMocks();
+});
-const renderThemesList = (props = {}) =>
+test('renders themes list with all theme names', async () => {
render(
<ThemesList
user={mockUser}
- addDangerToast={() => {}}
- addSuccessToast={() => {}}
- {...props}
+ addDangerToast={jest.fn()}
+ addSuccessToast={jest.fn()}
/>,
{
useRedux: true,
@@ -100,163 +179,312 @@ const renderThemesList = (props = {}) =>
},
);
-// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from
describe blocks
-describe('ThemesList', () => {
- beforeEach(() => {
- fetchMock.resetHistory();
+ await waitFor(() => {
+ expect(screen.getByText('Light Theme')).toBeInTheDocument();
+ expect(screen.getByText('Dark Theme')).toBeInTheDocument();
+ expect(screen.getByText('Custom Theme')).toBeInTheDocument();
});
+});
- test('renders', async () => {
- renderThemesList();
- expect(await screen.findByText('Themes')).toBeInTheDocument();
- });
+test('shows system tag for system themes', async () => {
+ render(
+ <ThemesList
+ user={mockUser}
+ addDangerToast={jest.fn()}
+ addSuccessToast={jest.fn()}
+ />,
+ {
+ useRedux: true,
+ useRouter: true,
+ useQueryParams: true,
+ useTheme: true,
+ },
+ );
- test('renders a ListView', async () => {
- renderThemesList();
- expect(await screen.findByTestId('themes-list-view')).toBeInTheDocument();
- });
+ await screen.findByText('Dark Theme');
- test('renders theme information', async () => {
- renderThemesList();
+ expect(screen.getByText('System')).toBeInTheDocument();
+});
- // Wait for list to load
- await screen.findByTestId('themes-list-view');
+test('shows default tag for system default theme', async () => {
+ render(
+ <ThemesList
+ user={mockUser}
+ addDangerToast={jest.fn()}
+ addSuccessToast={jest.fn()}
+ />,
+ {
+ useRedux: true,
+ useRouter: true,
+ useQueryParams: true,
+ useTheme: true,
+ },
+ );
- // Wait for data to load
- await waitFor(() => {
- mockThemes.forEach(theme => {
- expect(screen.getByText(theme.theme_name)).toBeInTheDocument();
- });
- });
- });
+ await screen.findByText('Light Theme');
- test('shows system theme tags correctly', async () => {
- renderThemesList();
+ expect(screen.getByText('Default')).toBeInTheDocument();
+});
- // Wait for list to load
- await screen.findByTestId('themes-list-view');
+test('shows dark tag for system dark theme', async () => {
+ render(
+ <ThemesList
+ user={mockUser}
+ addDangerToast={jest.fn()}
+ addSuccessToast={jest.fn()}
+ />,
+ {
+ useRedux: true,
+ useRouter: true,
+ useQueryParams: true,
+ useTheme: true,
+ },
+ );
- // System theme should have a "System" tag
- await waitFor(() => {
- expect(screen.getByText('System')).toBeInTheDocument();
- });
- });
+ await screen.findByText('Dark Theme');
- test('handles theme deletion for non-system themes', async () => {
- renderThemesList();
+ expect(screen.getByText('Dark')).toBeInTheDocument();
+});
- // Wait for list to load
- await screen.findByTestId('themes-list-view');
+test('shows apply action button for all themes', async () => {
+ render(
+ <ThemesList
+ user={mockUser}
+ addDangerToast={jest.fn()}
+ addSuccessToast={jest.fn()}
+ />,
+ {
+ useRedux: true,
+ useRouter: true,
+ useQueryParams: true,
+ useTheme: true,
+ },
+ );
- // Find delete buttons (should only exist for non-system themes)
- const deleteButtons = await screen.findAllByTestId('delete-action');
- expect(deleteButtons.length).toBeGreaterThan(0);
+ await screen.findByText('Custom Theme');
- fireEvent.click(deleteButtons[0]);
+ const applyButtons = await screen.findAllByTestId('apply-action');
+ expect(applyButtons.length).toBe(3);
+});
- // Confirm deletion modal should appear
- await waitFor(() => {
- expect(screen.getByText('Delete Theme?')).toBeInTheDocument();
- });
- });
+test('shows delete button only for non-system themes', async () => {
+ render(
+ <ThemesList
+ user={mockUser}
+ addDangerToast={jest.fn()}
+ addSuccessToast={jest.fn()}
+ />,
+ {
+ useRedux: true,
+ useRouter: true,
+ useQueryParams: true,
+ useTheme: true,
+ },
+ );
- test('shows apply action for themes', async () => {
- renderThemesList();
+ await screen.findByText('Custom Theme');
- // Wait for list to load
- await screen.findByTestId('themes-list-view');
+ const deleteButtons = await screen.findAllByTestId('delete-action');
+ // Should have delete buttons for Light Theme and Custom Theme (not Dark
Theme which is system)
+ expect(deleteButtons.length).toBe(2);
+});
- // Find apply buttons
- const applyButtons = await screen.findAllByTestId('apply-action');
- expect(applyButtons.length).toBe(mockThemes.length);
- });
+test('shows set default action for non-default themes', async () => {
+ render(
+ <ThemesList
+ user={mockUser}
+ addDangerToast={jest.fn()}
+ addSuccessToast={jest.fn()}
+ />,
+ {
+ useRedux: true,
+ useRouter: true,
+ useQueryParams: true,
+ useTheme: true,
+ },
+ );
- test('fetches themes data on load', async () => {
- renderThemesList();
+ await screen.findByText('Custom Theme');
- await waitFor(() => {
- const calls = fetchMock.calls(/api\/v1\/theme\/\?/);
- expect(calls.length).toBeGreaterThan(0);
- });
- });
+ const setDefaultButtons = await screen.findAllByTestId('set-default-action');
+ // Should have set default buttons for Dark Theme and Custom Theme (not
Light Theme which is already default)
+ expect(setDefaultButtons.length).toBe(2);
+});
- test('shows bulk select when user has permissions', async () => {
- renderThemesList();
+test('shows unset default action for system default theme', async () => {
+ render(
+ <ThemesList
+ user={mockUser}
+ addDangerToast={jest.fn()}
+ addSuccessToast={jest.fn()}
+ />,
+ {
+ useRedux: true,
+ useRouter: true,
+ useQueryParams: true,
+ useTheme: true,
+ },
+ );
- // Wait for list to load
- await screen.findByText('Themes');
+ await screen.findByText('Light Theme');
- // Should show bulk select button
- expect(screen.getByText('Bulk select')).toBeInTheDocument();
- });
+ const unsetDefaultButtons = await screen.findAllByTestId(
+ 'unset-default-action',
+ );
+ expect(unsetDefaultButtons.length).toBe(1);
+});
- test('shows create theme button when user has permissions', async () => {
- renderThemesList();
+test('shows set dark action for non-dark themes', async () => {
+ render(
+ <ThemesList
+ user={mockUser}
+ addDangerToast={jest.fn()}
+ addSuccessToast={jest.fn()}
+ />,
+ {
+ useRedux: true,
+ useRouter: true,
+ useQueryParams: true,
+ useTheme: true,
+ },
+ );
- // Wait for list to load
- await screen.findByText('Themes');
+ await screen.findByText('Custom Theme');
- // Should show theme creation button
- const addButton = screen.getByLabelText('plus');
- expect(addButton).toBeInTheDocument();
- });
+ const setDarkButtons = await screen.findAllByTestId('set-dark-action');
+ // Should have set dark buttons for Light Theme and Custom Theme (not Dark
Theme which is already dark)
+ expect(setDarkButtons.length).toBe(2);
+});
- // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from
describe blocks
- describe('Modal.useModal integration', () => {
- beforeEach(() => {
- jest.clearAllMocks();
- });
+test('shows unset dark action for system dark theme', async () => {
+ render(
+ <ThemesList
+ user={mockUser}
+ addDangerToast={jest.fn()}
+ addSuccessToast={jest.fn()}
+ />,
+ {
+ useRedux: true,
+ useRouter: true,
+ useQueryParams: true,
+ useTheme: true,
+ },
+ );
- test('uses Modal.useModal hook instead of Modal.confirm', () => {
- const useModalSpy = jest.spyOn(Modal, 'useModal');
- renderThemesList();
+ await screen.findByText('Dark Theme');
- // Verify that useModal is called when the component mounts
- expect(useModalSpy).toHaveBeenCalled();
+ const unsetDarkButtons = await screen.findAllByTestId('unset-dark-action');
+ expect(unsetDarkButtons.length).toBe(1);
+});
- useModalSpy.mockRestore();
- });
+test('shows export action for all themes when user has permission', async ()
=> {
+ render(
+ <ThemesList
+ user={mockUser}
+ addDangerToast={jest.fn()}
+ addSuccessToast={jest.fn()}
+ />,
+ {
+ useRedux: true,
+ useRouter: true,
+ useQueryParams: true,
+ useTheme: true,
+ },
+ );
- test('renders contextHolder for modal theming', async () => {
- const { container } = renderThemesList();
+ await screen.findByText('Custom Theme');
- // Wait for component to be rendered
- await screen.findByText('Themes');
+ const exportButtons = await screen.findAllByTestId('export-action');
+ expect(exportButtons.length).toBe(3);
+});
- // The contextHolder is rendered but invisible, so we check for its
presence in the DOM
- // Modal.useModal returns elements that get rendered in the component
tree
- const contextHolderExists = container.querySelector('.ant-modal-root');
- expect(contextHolderExists).toBeDefined();
- });
+test('shows edit action for all themes when user has permission', async () => {
+ render(
+ <ThemesList
+ user={mockUser}
+ addDangerToast={jest.fn()}
+ addSuccessToast={jest.fn()}
+ />,
+ {
+ useRedux: true,
+ useRouter: true,
+ useQueryParams: true,
+ useTheme: true,
+ },
+ );
- test('confirms system theme changes using themed modal', async () => {
- const mockSetSystemDefault = jest.fn().mockResolvedValue({});
- fetchMock.post(
- 'glob:*/api/v1/theme/*/set_system_default',
- mockSetSystemDefault,
- );
+ await screen.findByText('Custom Theme');
- renderThemesList();
+ const editButtons = await screen.findAllByTestId('edit-action');
+ expect(editButtons.length).toBe(3);
+});
- // Wait for list to load
- await screen.findByTestId('themes-list-view');
+test('shows bulk select button when user has permissions', async () => {
+ render(
+ <ThemesList
+ user={mockUser}
+ addDangerToast={jest.fn()}
+ addSuccessToast={jest.fn()}
+ />,
+ {
+ useRedux: true,
+ useRouter: true,
+ useQueryParams: true,
+ useTheme: true,
+ },
+ );
- // Since the test data doesn't render actual action buttons, we'll verify
- // that the modal system is properly set up by checking the hook was
called
- // This is validated in the "uses Modal.useModal hook" test
- expect(true).toBe(true);
- });
+ await screen.findByText('Themes');
+
+ expect(screen.getByText('Bulk select')).toBeInTheDocument();
+});
+
+test('shows create theme button when user has permissions', async () => {
+ render(
+ <ThemesList
+ user={mockUser}
+ addDangerToast={jest.fn()}
+ addSuccessToast={jest.fn()}
+ />,
+ {
+ useRedux: true,
+ useRouter: true,
+ useQueryParams: true,
+ useTheme: true,
+ },
+ );
+
+ await screen.findByText('Themes');
+
+ const addButton = screen.getByLabelText('plus');
+ expect(addButton).toBeInTheDocument();
+});
+
+test('clicking apply button calls setTemporaryTheme with parsed theme data',
async () => {
+ render(
+ <ThemesList
+ user={mockUser}
+ addDangerToast={jest.fn()}
+ addSuccessToast={jest.fn()}
+ />,
+ {
+ useRedux: true,
+ useRouter: true,
+ useQueryParams: true,
+ useTheme: true,
+ },
+ );
- test('does not use deprecated Modal.confirm directly', () => {
- // Create a spy on the static Modal.confirm method
- const confirmSpy = jest.spyOn(Modal, 'confirm');
+ await screen.findByText('Custom Theme');
- renderThemesList();
+ const applyButtons = await screen.findAllByTestId('apply-action');
- // The component should not call Modal.confirm directly
- expect(confirmSpy).not.toHaveBeenCalled();
+ // Click the first apply button (Light Theme)
+ await userEvent.click(applyButtons[0]);
- confirmSpy.mockRestore();
+ await waitFor(() => {
+ expect(mockSetTemporaryTheme).toHaveBeenCalledWith({
+ colors: { primary: '#ffffff' },
});
});
});
diff --git a/superset-frontend/src/pages/ThemeList/index.test.tsx
b/superset-frontend/src/pages/ThemeList/index.test.tsx
deleted file mode 100644
index 12d6f11360..0000000000
--- a/superset-frontend/src/pages/ThemeList/index.test.tsx
+++ /dev/null
@@ -1,288 +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 { render, screen, waitFor } from 'spec/helpers/testing-library';
-import fetchMock from 'fetch-mock';
-import * as hooks from 'src/views/CRUD/hooks';
-import * as themeApi from 'src/features/themes/api';
-import * as getBootstrapData from 'src/utils/getBootstrapData';
-import ThemesList from './index';
-
-// Mock the getBootstrapData function
-jest.mock('src/utils/getBootstrapData', () => ({
- __esModule: true,
- default: () => ({
- common: {
- theme: {
- enableUiThemeAdministration: true,
- },
- },
- user: {
- userId: 1,
- username: 'admin',
- roles: {
- Admin: [['can_write', 'Theme']],
- },
- },
- }),
-}));
-
-// Mock theme API functions
-jest.mock('src/features/themes/api', () => ({
- setSystemDefaultTheme: jest.fn(() => Promise.resolve()),
- setSystemDarkTheme: jest.fn(() => Promise.resolve()),
- unsetSystemDefaultTheme: jest.fn(() => Promise.resolve()),
- unsetSystemDarkTheme: jest.fn(() => Promise.resolve()),
-}));
-
-// Mock the CRUD hooks
-jest.mock('src/views/CRUD/hooks', () => ({
- ...jest.requireActual('src/views/CRUD/hooks'),
- useListViewResource: jest.fn(),
-}));
-
-// Mock the useThemeContext hook
-jest.mock('src/theme/ThemeProvider', () => ({
- ...jest.requireActual('src/theme/ThemeProvider'),
- useThemeContext: jest.fn().mockReturnValue({
- getCurrentCrudThemeId: jest.fn().mockReturnValue('1'),
- appliedTheme: { theme_name: 'Light Theme', id: 1 },
- }),
-}));
-
-const mockThemes = [
- {
- id: 1,
- theme_name: 'Light Theme',
- is_system_default: true,
- is_system_dark: false,
- created_by: { id: 1, first_name: 'Admin', last_name: 'User' },
- changed_on_delta_humanized: '1 day ago',
- },
- {
- id: 2,
- theme_name: 'Dark Theme',
- is_system_default: false,
- is_system_dark: true,
- created_by: { id: 1, first_name: 'Admin', last_name: 'User' },
- changed_on_delta_humanized: '2 days ago',
- },
- {
- id: 3,
- theme_name: 'Custom Theme',
- is_system_default: false,
- is_system_dark: false,
- created_by: { id: 2, first_name: 'Test', last_name: 'User' },
- changed_on_delta_humanized: '3 days ago',
- },
-];
-
-// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from
describe blocks
-describe('ThemesList', () => {
- beforeEach(() => {
- // Mock the useListViewResource hook
- (hooks.useListViewResource as jest.Mock).mockReturnValue({
- state: {
- loading: false,
- resourceCollection: mockThemes,
- resourceCount: 3,
- bulkSelectEnabled: false,
- },
- setResourceCollection: jest.fn(),
- hasPerm: jest.fn().mockReturnValue(true),
- refreshData: jest.fn(),
- fetchData: jest.fn(),
- toggleBulkSelect: jest.fn(),
- });
-
- fetchMock.reset();
- fetchMock.get('glob:*/api/v1/theme/*', {
- count: 3,
- result: mockThemes,
- });
- });
-
- afterEach(() => {
- fetchMock.restore();
- jest.clearAllMocks();
- });
-
- test('renders the themes list with proper structure', async () => {
- render(
- <ThemesList addDangerToast={jest.fn()} addSuccessToast={jest.fn()} />,
- {
- useRedux: true,
- useDnd: true,
- useQueryParams: true,
- },
- );
-
- await waitFor(() => {
- expect(screen.getByText('Light Theme')).toBeInTheDocument();
- expect(screen.getByText('Dark Theme')).toBeInTheDocument();
- expect(screen.getByText('Custom Theme')).toBeInTheDocument();
- });
- });
-
- test('shows system theme badges for default and dark themes', async () => {
- render(
- <ThemesList addDangerToast={jest.fn()} addSuccessToast={jest.fn()} />,
- {
- useRedux: true,
- useDnd: true,
- useQueryParams: true,
- },
- );
-
- await waitFor(() => {
- // Check for system badges
- expect(screen.getByText('Light Theme')).toBeInTheDocument();
- expect(screen.getByText('Dark Theme')).toBeInTheDocument();
- });
-
- // Verify that themes with system flags are marked appropriately
- expect(mockThemes[0].is_system_default).toBe(true);
- expect(mockThemes[1].is_system_dark).toBe(true);
- expect(mockThemes[2].is_system_default).toBe(false);
- expect(mockThemes[2].is_system_dark).toBe(false);
- });
-
- test('uses flat theme structure for enableUiThemeAdministration', () => {
- // Verify the component accesses the correct bootstrap data structure
- const { common } = getBootstrapData.default();
-
- // Should access flat structure
- expect(common.theme.enableUiThemeAdministration).toBe(true);
-
- // Should NOT have nested settings structure
- expect((common.theme as any).settings).toBeUndefined();
- });
-
- test('shows admin controls when user has proper permissions', async () => {
- render(
- <ThemesList addDangerToast={jest.fn()} addSuccessToast={jest.fn()} />,
- {
- useRedux: true,
- useDnd: true,
- useQueryParams: true,
- },
- );
-
- await waitFor(() => {
- expect(screen.getByText('Light Theme')).toBeInTheDocument();
- });
-
- // Admin controls should be visible
- const adminElements = screen.queryAllByRole('button');
- expect(adminElements.length).toBeGreaterThan(0);
- });
-
- test('calls setSystemDefaultTheme API when setting a theme as default',
async () => {
- const { setSystemDefaultTheme } = themeApi;
- const addSuccessToast = jest.fn();
- const refreshData = jest.fn();
-
- (hooks.useListViewResource as jest.Mock).mockReturnValue({
- state: {
- loading: false,
- resourceCollection: mockThemes,
- resourceCount: 3,
- bulkSelectEnabled: false,
- },
- setResourceCollection: jest.fn(),
- hasPerm: jest.fn().mockReturnValue(true),
- refreshData,
- fetchData: jest.fn(),
- toggleBulkSelect: jest.fn(),
- });
-
- render(
- <ThemesList
- addDangerToast={jest.fn()}
- addSuccessToast={addSuccessToast}
- />,
- {
- useRedux: true,
- useDnd: true,
- useQueryParams: true,
- },
- );
-
- await waitFor(() => {
- expect(screen.getByText('Custom Theme')).toBeInTheDocument();
- });
-
- // Since the actual UI interaction would be complex to test,
- // we verify the API function exists and can be called
- expect(setSystemDefaultTheme).toBeDefined();
-
- // Test calling the API directly
- await setSystemDefaultTheme(3);
- expect(setSystemDefaultTheme).toHaveBeenCalledWith(3);
- });
-
- test('configures theme deletion endpoint', async () => {
- const addDangerToast = jest.fn();
- const addSuccessToast = jest.fn();
- const refreshData = jest.fn();
-
- // Setup delete mock before rendering
- fetchMock.delete('glob:*/api/v1/theme/*', 200);
-
- // Mock the useListViewResource hook with refreshData
- (hooks.useListViewResource as jest.Mock).mockReturnValue({
- state: {
- loading: false,
- resourceCollection: mockThemes,
- resourceCount: 3,
- bulkSelectEnabled: false,
- },
- setResourceCollection: jest.fn(),
- hasPerm: jest.fn().mockReturnValue(true),
- refreshData,
- fetchData: jest.fn(),
- toggleBulkSelect: jest.fn(),
- });
-
- render(
- <ThemesList
- addDangerToast={addDangerToast}
- addSuccessToast={addSuccessToast}
- />,
- {
- useRedux: true,
- useDnd: true,
- useQueryParams: true,
- },
- );
-
- await waitFor(() => {
- expect(screen.getByText('Custom Theme')).toBeInTheDocument();
- });
-
- // Verify that themes are rendered correctly
- expect(screen.getByText('Light Theme')).toBeInTheDocument();
- expect(screen.getByText('Dark Theme')).toBeInTheDocument();
- expect(screen.getByText('Custom Theme')).toBeInTheDocument();
-
- // Verify that action buttons are present (which include delete for
non-system themes)
- const actionButtons = screen.getAllByRole('button');
- expect(actionButtons.length).toBeGreaterThan(0);
- });
-});
diff --git a/superset-frontend/src/pages/ThemeList/index.tsx
b/superset-frontend/src/pages/ThemeList/index.tsx
index 75755170b9..adef9153a3 100644
--- a/superset-frontend/src/pages/ThemeList/index.tsx
+++ b/superset-frontend/src/pages/ThemeList/index.tsx
@@ -27,7 +27,6 @@ import {
Alert,
Tooltip,
Space,
- Modal,
} from '@superset-ui/core/components';
import rison from 'rison';
@@ -53,6 +52,7 @@ import ThemeModal from 'src/features/themes/ThemeModal';
import { ThemeObject } from 'src/features/themes/types';
import { QueryObjectColumns } from 'src/views/CRUD/types';
import { Icons } from '@superset-ui/core/components/Icons';
+import { useConfirmModal } from 'src/hooks/useConfirmModal';
import {
setSystemDefaultTheme,
setSystemDarkTheme,
@@ -117,8 +117,7 @@ function ThemesList({
const [importingTheme, showImportModal] = useState<boolean>(false);
const [appliedThemeId, setAppliedThemeId] = useState<number | null>(null);
- // Use Modal.useModal hook to ensure proper theming
- const [modal, contextHolder] = Modal.useModal();
+ const { showConfirm, ConfirmModal } = useConfirmModal();
const canCreate = hasPerm('can_write');
const canEdit = hasPerm('can_write');
@@ -250,83 +249,97 @@ function ThemesList({
addSuccessToast(t('Theme imported'));
};
- // Generic confirmation modal utility to reduce code duplication
- const showThemeConfirmation = (config: {
- title: string;
- content: string;
- onConfirm: () => Promise<any>;
- successMessage: string;
- errorMessage: string;
- }) => {
- modal.confirm({
- title: config.title,
- content: config.content,
- onOk: () => {
- config
- .onConfirm()
- .then(() => {
+ const handleSetSystemDefault = useCallback(
+ (theme: ThemeObject) => {
+ showConfirm({
+ title: t('Set System Default Theme'),
+ body: t(
+ 'Are you sure you want to set "%s" as the system default theme? This
will apply to all users who haven\'t set a personal preference.',
+ theme.theme_name,
+ ),
+ onConfirm: async () => {
+ try {
+ await setSystemDefaultTheme(theme.id!);
refreshData();
- addSuccessToast(config.successMessage);
- })
- .catch(err => {
- addDangerToast(t(config.errorMessage, err.message));
- });
- },
- });
- };
-
- const handleSetSystemDefault = (theme: ThemeObject) => {
- showThemeConfirmation({
- title: t('Set System Default Theme'),
- content: t(
- 'Are you sure you want to set "%s" as the system default theme? This
will apply to all users who haven\'t set a personal preference.',
- theme.theme_name,
- ),
- onConfirm: () => setSystemDefaultTheme(theme.id!),
- successMessage: t(
- '"%s" is now the system default theme',
- theme.theme_name,
- ),
- errorMessage: 'Failed to set system default theme: %s',
- });
- };
+ addSuccessToast(
+ t('"%s" is now the system default theme', theme.theme_name),
+ );
+ } catch (err: any) {
+ addDangerToast(
+ t('Failed to set system default theme: %s', err.message),
+ );
+ }
+ },
+ });
+ },
+ [showConfirm, refreshData, addSuccessToast, addDangerToast],
+ );
- const handleSetSystemDark = (theme: ThemeObject) => {
- showThemeConfirmation({
- title: t('Set System Dark Theme'),
- content: t(
- 'Are you sure you want to set "%s" as the system dark theme? This will
apply to all users who haven\'t set a personal preference.',
- theme.theme_name,
- ),
- onConfirm: () => setSystemDarkTheme(theme.id!),
- successMessage: t('"%s" is now the system dark theme', theme.theme_name),
- errorMessage: 'Failed to set system dark theme: %s',
- });
- };
+ const handleSetSystemDark = useCallback(
+ (theme: ThemeObject) => {
+ showConfirm({
+ title: t('Set System Dark Theme'),
+ body: t(
+ 'Are you sure you want to set "%s" as the system dark theme? This
will apply to all users who haven\'t set a personal preference.',
+ theme.theme_name,
+ ),
+ onConfirm: async () => {
+ try {
+ await setSystemDarkTheme(theme.id!);
+ refreshData();
+ addSuccessToast(
+ t('"%s" is now the system dark theme', theme.theme_name),
+ );
+ } catch (err: any) {
+ addDangerToast(
+ t('Failed to set system dark theme: %s', err.message),
+ );
+ }
+ },
+ });
+ },
+ [showConfirm, refreshData, addSuccessToast, addDangerToast],
+ );
- const handleUnsetSystemDefault = () => {
- showThemeConfirmation({
+ const handleUnsetSystemDefault = useCallback(() => {
+ showConfirm({
title: t('Remove System Default Theme'),
- content: t(
+ body: t(
'Are you sure you want to remove the system default theme? The
application will fall back to the configuration file default.',
),
- onConfirm: () => unsetSystemDefaultTheme(),
- successMessage: t('System default theme removed'),
- errorMessage: 'Failed to remove system default theme: %s',
+ onConfirm: async () => {
+ try {
+ await unsetSystemDefaultTheme();
+ refreshData();
+ addSuccessToast(t('System default theme removed'));
+ } catch (err: any) {
+ addDangerToast(
+ t('Failed to remove system default theme: %s', err.message),
+ );
+ }
+ },
});
- };
+ }, [showConfirm, refreshData, addSuccessToast, addDangerToast]);
- const handleUnsetSystemDark = () => {
- showThemeConfirmation({
+ const handleUnsetSystemDark = useCallback(() => {
+ showConfirm({
title: t('Remove System Dark Theme'),
- content: t(
+ body: t(
'Are you sure you want to remove the system dark theme? The
application will fall back to the configuration file dark theme.',
),
- onConfirm: () => unsetSystemDarkTheme(),
- successMessage: t('System dark theme removed'),
- errorMessage: 'Failed to remove system dark theme: %s',
+ onConfirm: async () => {
+ try {
+ await unsetSystemDarkTheme();
+ refreshData();
+ addSuccessToast(t('System dark theme removed'));
+ } catch (err: any) {
+ addDangerToast(
+ t('Failed to remove system dark theme: %s', err.message),
+ );
+ }
+ },
});
- };
+ }, [showConfirm, refreshData, addSuccessToast, addDangerToast]);
const initialSort = [{ id: 'theme_name', desc: true }];
const columns = useMemo(
@@ -598,7 +611,6 @@ function ThemesList({
return (
<>
- {contextHolder}
<SubMenu {...menuData} />
<ThemeModal
addDangerToast={addDangerToast}
@@ -705,6 +717,7 @@ function ThemesList({
}}
</ConfirmStatusChange>
{preparingExport && <Loading />}
+ {ConfirmModal}
</>
);
}