This is an automated email from the ASF dual-hosted git repository.

msyavuz 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 a90928766b fix(theme-crud): add unsaved changes modal (#35254)
a90928766b is described below

commit a90928766b1072d8ca31a3f86ed72ac5fb747055
Author: Gabriel Torres Ruiz <[email protected]>
AuthorDate: Tue Oct 7 12:26:01 2025 -0400

    fix(theme-crud): add unsaved changes modal (#35254)
---
 .../src/features/themes/ThemeModal.test.tsx        | 698 +++++++++++++++------
 .../src/features/themes/ThemeModal.tsx             | 345 ++++++----
 .../src/hooks/useBeforeUnload/index.ts             |  43 ++
 .../hooks/useBeforeUnload/useBeforeUnload.test.ts  | 156 +++++
 .../src/hooks/useUnsavedChangesPrompt/index.ts     |  17 +-
 5 files changed, 920 insertions(+), 339 deletions(-)

diff --git a/superset-frontend/src/features/themes/ThemeModal.test.tsx 
b/superset-frontend/src/features/themes/ThemeModal.test.tsx
index 633a8c44bd..995b3c902f 100644
--- a/superset-frontend/src/features/themes/ThemeModal.test.tsx
+++ b/superset-frontend/src/features/themes/ThemeModal.test.tsx
@@ -17,10 +17,12 @@
  * under the License.
  */
 
+import { render, screen } from 'spec/helpers/testing-library';
+import userEvent from '@testing-library/user-event';
 import fetchMock from 'fetch-mock';
+import ThemeModal from './ThemeModal';
 import { ThemeObject } from './types';
 
-// Mock theme provider
 const mockThemeContext = {
   setTemporaryTheme: jest.fn(),
   clearLocalOverrides: jest.fn(),
@@ -31,49 +33,17 @@ jest.mock('src/theme/ThemeProvider', () => ({
   useThemeContext: () => mockThemeContext,
 }));
 
-// Mock permission utils
 jest.mock('src/dashboard/util/permissionUtils', () => ({
   isUserAdmin: jest.fn(() => true),
 }));
 
-// Mock bootstrap data
-jest.mock('src/utils/getBootstrapData', () => ({
-  __esModule: true,
-  default: () => ({
-    user: {
-      userId: 1,
-      firstName: 'Admin',
-      lastName: 'User',
-      roles: { Admin: [['can_write', 'Dashboard']] },
-      permissions: {},
-      isActive: true,
-      isAnonymous: false,
-      username: 'admin',
-      email: '[email protected]',
-      user_id: 1,
-      first_name: 'Admin',
-      last_name: 'User',
-    },
-    common: {
-      feature_flags: {},
-      conf: {
-        SUPERSET_WEBSERVER_DOMAINS: [],
-      },
-    },
-  }),
-}));
-
 const mockTheme: ThemeObject = {
   id: 1,
   theme_name: 'Test Theme',
   json_data: JSON.stringify(
     {
-      colors: {
-        primary: '#1890ff',
-        secondary: '#52c41a',
-      },
-      typography: {
-        fontSize: 14,
+      token: {
+        colorPrimary: '#1890ff',
       },
     },
     null,
@@ -87,201 +57,527 @@ const mockTheme: ThemeObject = {
   },
 };
 
-// Mock theme API endpoints
-fetchMock.get('glob:*/api/v1/theme/1', {
-  result: mockTheme,
+const mockSystemTheme: ThemeObject = {
+  ...mockTheme,
+  id: 2,
+  theme_name: 'System Theme',
+  is_system: true,
+};
+
+beforeEach(() => {
+  fetchMock.reset();
+  fetchMock.get('glob:*/api/v1/theme/1', { result: mockTheme });
+  fetchMock.get('glob:*/api/v1/theme/2', { result: mockSystemTheme });
+  fetchMock.get('glob:*/api/v1/theme/*', { result: mockTheme });
+  fetchMock.post('glob:*/api/v1/theme/', { result: { ...mockTheme, id: 3 } });
+  fetchMock.put('glob:*/api/v1/theme/*', { result: mockTheme });
 });
 
-fetchMock.post('glob:*/api/v1/theme/', {
-  result: { ...mockTheme, id: 2 },
+afterEach(() => {
+  fetchMock.restore();
+  jest.clearAllMocks();
 });
 
-fetchMock.put('glob:*/api/v1/theme/1', {
-  result: mockTheme,
+test('renders modal with add theme dialog when show is true', () => {
+  render(
+    <ThemeModal
+      addDangerToast={jest.fn()}
+      addSuccessToast={jest.fn()}
+      onThemeAdd={jest.fn()}
+      onHide={jest.fn()}
+      show
+      canDevelop={false}
+    />,
+    { useRedux: true, useRouter: true },
+  );
+
+  expect(screen.getByRole('dialog')).toBeInTheDocument();
+  expect(screen.getByText('Add theme')).toBeInTheDocument();
 });
 
-// These are defined but not used in the simplified tests
-// const mockUser = {
-//   userId: 1,
-//   firstName: 'Admin',
-//   lastName: 'User',
-//   roles: { Admin: [['can_write', 'Dashboard']] },
-//   permissions: {},
-//   isActive: true,
-//   isAnonymous: false,
-//   username: 'admin',
-//   email: '[email protected]',
-//   user_id: 1,
-//   first_name: 'Admin',
-//   last_name: 'User',
-// };
-
-// const defaultProps = {
-//   addDangerToast: jest.fn(),
-//   addSuccessToast: jest.fn(),
-//   onThemeAdd: jest.fn(),
-//   onHide: jest.fn(),
-//   show: true,
-// };
-
-// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from 
describe blocks
-describe('ThemeModal', () => {
-  beforeEach(() => {
-    fetchMock.resetHistory();
-    jest.clearAllMocks();
-  });
+test('does not render modal when show is false', () => {
+  render(
+    <ThemeModal
+      addDangerToast={jest.fn()}
+      addSuccessToast={jest.fn()}
+      onThemeAdd={jest.fn()}
+      onHide={jest.fn()}
+      show={false}
+      canDevelop={false}
+    />,
+    { useRedux: true, useRouter: true },
+  );
+
+  expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+});
 
-  afterEach(() => {
-    fetchMock.restore();
-  });
+test('renders edit mode title when theme is provided', async () => {
+  render(
+    <ThemeModal
+      addDangerToast={jest.fn()}
+      addSuccessToast={jest.fn()}
+      onThemeAdd={jest.fn()}
+      onHide={jest.fn()}
+      show
+      canDevelop={false}
+      theme={mockTheme}
+    />,
+    { useRedux: true, useRouter: true },
+  );
+
+  // Wait for theme name to be loaded in the input field
+  expect(await screen.findByDisplayValue('Test Theme')).toBeInTheDocument();
+  expect(screen.getByText('Edit theme properties')).toBeInTheDocument();
+});
 
-  test('should export ThemeModal component', () => {
-    const ThemeModalModule = jest.requireActual('./ThemeModal');
-    expect(ThemeModalModule.default).toBeDefined();
-    expect(typeof ThemeModalModule.default).toBe('object'); // HOC wrapped 
component
-  });
+test('renders view mode title for system themes', async () => {
+  render(
+    <ThemeModal
+      addDangerToast={jest.fn()}
+      addSuccessToast={jest.fn()}
+      onThemeAdd={jest.fn()}
+      onHide={jest.fn()}
+      show
+      canDevelop={false}
+      theme={mockSystemTheme}
+    />,
+    { useRedux: true, useRouter: true },
+  );
+
+  // Wait for system theme indicator to appear
+  expect(
+    await screen.findByText('System Theme - Read Only'),
+  ).toBeInTheDocument();
+  expect(screen.getByText('View theme properties')).toBeInTheDocument();
+});
 
-  test('should have correct type definitions', () => {
-    expect(mockTheme).toMatchObject({
-      id: expect.any(Number),
-      theme_name: expect.any(String),
-      json_data: expect.any(String),
-      changed_on_delta_humanized: expect.any(String),
-      changed_by: expect.objectContaining({
-        first_name: expect.any(String),
-        last_name: expect.any(String),
-      }),
-    });
-  });
+test('renders theme name input field', () => {
+  render(
+    <ThemeModal
+      addDangerToast={jest.fn()}
+      addSuccessToast={jest.fn()}
+      onThemeAdd={jest.fn()}
+      onHide={jest.fn()}
+      show
+      canDevelop={false}
+    />,
+    { useRedux: true, useRouter: true },
+  );
+
+  expect(screen.getByPlaceholderText('Enter theme name')).toBeInTheDocument();
+});
 
-  test('should validate JSON data structure', () => {
-    const isValidJson = (str: string) => {
-      try {
-        JSON.parse(str);
-        return true;
-      } catch (e) {
-        return false;
-      }
-    };
-
-    expect(isValidJson(mockTheme.json_data || '')).toBe(true);
-    expect(isValidJson('invalid json')).toBe(false);
-    expect(isValidJson('{"valid": "json"}')).toBe(true);
-  });
+test('renders JSON configuration field', () => {
+  render(
+    <ThemeModal
+      addDangerToast={jest.fn()}
+      addSuccessToast={jest.fn()}
+      onThemeAdd={jest.fn()}
+      onHide={jest.fn()}
+      show
+      canDevelop={false}
+    />,
+    { useRedux: true, useRouter: true },
+  );
+
+  expect(screen.getByText('JSON Configuration')).toBeInTheDocument();
+});
 
-  test('should handle theme data parsing', () => {
-    const parsedTheme = JSON.parse(mockTheme.json_data || '{}');
-    expect(parsedTheme).toMatchObject({
-      colors: {
-        primary: '#1890ff',
-        secondary: '#52c41a',
-      },
-      typography: {
-        fontSize: 14,
-      },
-    });
-  });
+test('disables inputs for read-only system themes', async () => {
+  render(
+    <ThemeModal
+      addDangerToast={jest.fn()}
+      addSuccessToast={jest.fn()}
+      onThemeAdd={jest.fn()}
+      onHide={jest.fn()}
+      show
+      canDevelop={false}
+      theme={mockSystemTheme}
+    />,
+    { useRedux: true, useRouter: true },
+  );
+
+  const nameInput = await screen.findByPlaceholderText('Enter theme name');
+
+  expect(nameInput).toHaveAttribute('readOnly');
+});
 
-  test('should mock theme context functions', () => {
-    expect(mockThemeContext.setTemporaryTheme).toBeDefined();
-    expect(mockThemeContext.clearLocalOverrides).toBeDefined();
-    expect(mockThemeContext.hasDevOverride).toBeDefined();
-    expect(typeof mockThemeContext.setTemporaryTheme).toBe('function');
-    expect(typeof mockThemeContext.clearLocalOverrides).toBe('function');
-    expect(typeof mockThemeContext.hasDevOverride).toBe('function');
-  });
+test('shows Apply button when canDevelop is true and theme exists', async () 
=> {
+  render(
+    <ThemeModal
+      addDangerToast={jest.fn()}
+      addSuccessToast={jest.fn()}
+      onThemeAdd={jest.fn()}
+      onHide={jest.fn()}
+      show
+      canDevelop
+      theme={mockTheme}
+    />,
+    { useRedux: true, useRouter: true },
+  );
+
+  expect(
+    await screen.findByRole('button', { name: /apply/i }),
+  ).toBeInTheDocument();
+});
 
-  test('should handle API response structure', () => {
-    // Test that fetch mock is properly configured
-    expect(fetchMock.called()).toBe(false);
+test('does not show Apply button when canDevelop is false', () => {
+  render(
+    <ThemeModal
+      addDangerToast={jest.fn()}
+      addSuccessToast={jest.fn()}
+      onThemeAdd={jest.fn()}
+      onHide={jest.fn()}
+      show
+      canDevelop={false}
+    />,
+    { useRedux: true, useRouter: true },
+  );
+
+  expect(
+    screen.queryByRole('button', { name: 'Apply' }),
+  ).not.toBeInTheDocument();
+});
 
-    // Test API structure expectations
-    const expectedResponse = {
-      result: mockTheme,
-    };
+test('disables save button when theme name is empty', () => {
+  render(
+    <ThemeModal
+      addDangerToast={jest.fn()}
+      addSuccessToast={jest.fn()}
+      onThemeAdd={jest.fn()}
+      onHide={jest.fn()}
+      show
+      canDevelop={false}
+    />,
+    { useRedux: true, useRouter: true },
+  );
+
+  const saveButton = screen.getByRole('button', { name: 'Add' });
+
+  expect(saveButton).toBeDisabled();
+});
 
-    expect(expectedResponse.result).toMatchObject({
-      id: 1,
-      theme_name: 'Test Theme',
-    });
-  });
+test('enables save button when theme name is entered', async () => {
+  render(
+    <ThemeModal
+      addDangerToast={jest.fn()}
+      addSuccessToast={jest.fn()}
+      onThemeAdd={jest.fn()}
+      onHide={jest.fn()}
+      show
+      canDevelop={false}
+    />,
+    { useRedux: true, useRouter: true },
+  );
+
+  const nameInput = screen.getByPlaceholderText('Enter theme name');
+  await userEvent.type(nameInput, 'My New Theme');
+
+  const saveButton = await screen.findByRole('button', { name: 'Add' });
+
+  expect(saveButton).toBeEnabled();
+});
 
-  test('should handle create theme API call', () => {
-    const newTheme = {
-      theme_name: 'New Theme',
-      json_data: '{"colors": {"primary": "#ff0000"}}',
-    };
+test('validates JSON format and enables save button', async () => {
+  render(
+    <ThemeModal
+      addDangerToast={jest.fn()}
+      addSuccessToast={jest.fn()}
+      onThemeAdd={jest.fn()}
+      onHide={jest.fn()}
+      show
+      canDevelop={false}
+    />,
+    { useRedux: true, useRouter: true },
+  );
+
+  const nameInput = screen.getByPlaceholderText('Enter theme name');
+  await userEvent.type(nameInput, 'Test Theme');
+
+  const saveButton = await screen.findByRole('button', { name: 'Add' });
+
+  expect(saveButton).toBeEnabled();
+});
 
-    // Test request structure
-    expect(newTheme).toMatchObject({
-      theme_name: expect.any(String),
-      json_data: expect.any(String),
-    });
+test('shows unsaved changes alert when closing modal with modifications', 
async () => {
+  render(
+    <ThemeModal
+      addDangerToast={jest.fn()}
+      addSuccessToast={jest.fn()}
+      onThemeAdd={jest.fn()}
+      onHide={jest.fn()}
+      show
+      canDevelop={false}
+    />,
+    { useRedux: true, useRouter: true },
+  );
+
+  const nameInput = screen.getByPlaceholderText('Enter theme name');
+  await userEvent.type(nameInput, 'Modified Theme');
+
+  const cancelButton = screen.getByRole('button', { name: 'Cancel' });
+  await userEvent.click(cancelButton);
+
+  expect(
+    await screen.findByText('You have unsaved changes'),
+  ).toBeInTheDocument();
+  expect(
+    screen.getByText('Your changes will be lost if you leave without saving.'),
+  ).toBeInTheDocument();
+  expect(
+    screen.getByRole('button', { name: 'Keep editing' }),
+  ).toBeInTheDocument();
+  expect(screen.getByRole('button', { name: 'Discard' })).toBeInTheDocument();
+  expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
+});
+
+test('does not show unsaved changes alert when closing without modifications', 
async () => {
+  const onHide = jest.fn();
+  render(
+    <ThemeModal
+      addDangerToast={jest.fn()}
+      addSuccessToast={jest.fn()}
+      onThemeAdd={jest.fn()}
+      onHide={onHide}
+      show
+      canDevelop={false}
+    />,
+    { useRedux: true, useRouter: true },
+  );
+
+  const cancelButton = screen.getByRole('button', { name: 'Cancel' });
+  await userEvent.click(cancelButton);
+
+  expect(onHide).toHaveBeenCalled();
+  expect(
+    screen.queryByText('You have unsaved changes'),
+  ).not.toBeInTheDocument();
+});
 
-    // Test that JSON is valid
-    expect(() => JSON.parse(newTheme.json_data)).not.toThrow();
+test('allows user to keep editing after triggering cancel alert', async () => {
+  render(
+    <ThemeModal
+      addDangerToast={jest.fn()}
+      addSuccessToast={jest.fn()}
+      onThemeAdd={jest.fn()}
+      onHide={jest.fn()}
+      show
+      canDevelop={false}
+    />,
+    { useRedux: true, useRouter: true },
+  );
+
+  const nameInput = screen.getByPlaceholderText('Enter theme name');
+  await userEvent.type(nameInput, 'Modified Theme');
+
+  const cancelButton = screen.getByRole('button', { name: 'Cancel' });
+  await userEvent.click(cancelButton);
+
+  expect(
+    await screen.findByText('You have unsaved changes'),
+  ).toBeInTheDocument();
+
+  const keepEditingButton = screen.getByRole('button', {
+    name: 'Keep editing',
   });
+  await userEvent.click(keepEditingButton);
+
+  expect(
+    screen.queryByText('You have unsaved changes'),
+  ).not.toBeInTheDocument();
+  expect(screen.getByPlaceholderText('Enter theme name')).toHaveValue(
+    'Modified Theme',
+  );
+});
+
+test('saves changes when clicking Save button in unsaved changes alert', async 
() => {
+  const onHide = jest.fn();
+  const onThemeAdd = jest.fn();
+  render(
+    <ThemeModal
+      addDangerToast={jest.fn()}
+      addSuccessToast={jest.fn()}
+      onThemeAdd={onThemeAdd}
+      onHide={onHide}
+      show
+      canDevelop={false}
+    />,
+    { useRedux: true, useRouter: true },
+  );
+
+  const nameInput = screen.getByPlaceholderText('Enter theme name');
+  await userEvent.type(nameInput, 'Modified Theme');
+
+  const cancelButton = screen.getByRole('button', { name: 'Cancel' });
+  await userEvent.click(cancelButton);
+
+  expect(
+    await screen.findByText('You have unsaved changes'),
+  ).toBeInTheDocument();
+
+  const saveButton = screen.getByRole('button', { name: 'Save' });
+  await userEvent.click(saveButton);
+
+  // Wait for API call to complete
+  await screen.findByRole('dialog');
+  expect(fetchMock.called()).toBe(true);
+});
 
-  test('should handle update theme API call', () => {
-    const updatedTheme = {
-      theme_name: 'Updated Theme',
-      json_data: '{"colors": {"primary": "#00ff00"}}',
-    };
+test('discards changes when clicking Discard button in unsaved changes alert', 
async () => {
+  const onHide = jest.fn();
+  render(
+    <ThemeModal
+      addDangerToast={jest.fn()}
+      addSuccessToast={jest.fn()}
+      onThemeAdd={jest.fn()}
+      onHide={onHide}
+      show
+      canDevelop={false}
+    />,
+    { useRedux: true, useRouter: true },
+  );
+
+  const nameInput = screen.getByPlaceholderText('Enter theme name');
+  await userEvent.type(nameInput, 'Modified Theme');
+
+  const cancelButton = screen.getByRole('button', { name: 'Cancel' });
+  await userEvent.click(cancelButton);
+
+  expect(
+    await screen.findByText('You have unsaved changes'),
+  ).toBeInTheDocument();
+
+  const discardButton = screen.getByRole('button', { name: 'Discard' });
+  await userEvent.click(discardButton);
+
+  expect(onHide).toHaveBeenCalled();
+  expect(fetchMock.called('glob:*/api/v1/theme/', 'POST')).toBe(false);
+  expect(fetchMock.called('glob:*/api/v1/theme/*', 'PUT')).toBe(false);
+});
 
-    // Test request structure
-    expect(updatedTheme).toMatchObject({
-      theme_name: expect.any(String),
-      json_data: expect.any(String),
-    });
+test('creates new theme when saving', async () => {
+  const onHide = jest.fn();
+  const onThemeAdd = jest.fn();
+  render(
+    <ThemeModal
+      addDangerToast={jest.fn()}
+      addSuccessToast={jest.fn()}
+      onThemeAdd={onThemeAdd}
+      onHide={onHide}
+      show
+      canDevelop={false}
+    />,
+    { useRedux: true, useRouter: true },
+  );
+
+  const nameInput = screen.getByPlaceholderText('Enter theme name');
+  await userEvent.type(nameInput, 'New Theme');
+
+  const saveButton = await screen.findByRole('button', { name: 'Add' });
+  await userEvent.click(saveButton);
+
+  expect(await screen.findByRole('dialog')).toBeInTheDocument();
+  expect(fetchMock.called('glob:*/api/v1/theme/', 'POST')).toBe(true);
+});
 
-    // Test that JSON is valid
-    expect(() => JSON.parse(updatedTheme.json_data)).not.toThrow();
-  });
+test('updates existing theme when saving', async () => {
+  const onHide = jest.fn();
+  const onThemeAdd = jest.fn();
+  render(
+    <ThemeModal
+      addDangerToast={jest.fn()}
+      addSuccessToast={jest.fn()}
+      onThemeAdd={onThemeAdd}
+      onHide={onHide}
+      show
+      canDevelop={false}
+      theme={mockTheme}
+    />,
+    { useRedux: true, useRouter: true },
+  );
+
+  const nameInput = await screen.findByDisplayValue('Test Theme');
+  await userEvent.clear(nameInput);
+  await userEvent.type(nameInput, 'Updated Theme');
+
+  const saveButton = screen.getByRole('button', { name: 'Save' });
+  await userEvent.click(saveButton);
+
+  expect(await screen.findByRole('dialog')).toBeInTheDocument();
+  expect(fetchMock.called('glob:*/api/v1/theme/*', 'PUT')).toBe(true);
+});
 
-  test('should validate theme name requirements', () => {
-    const validateThemeName = (name: string) => !!(name && name.length > 0);
+test('handles API errors gracefully', async () => {
+  fetchMock.restore();
+  fetchMock.post('glob:*/api/v1/theme/', 500);
 
-    expect(validateThemeName('Valid Theme')).toBe(true);
-    expect(validateThemeName('')).toBe(false);
-    expect(validateThemeName('Test')).toBe(true);
-  });
+  render(
+    <ThemeModal
+      addDangerToast={jest.fn()}
+      addSuccessToast={jest.fn()}
+      onThemeAdd={jest.fn()}
+      onHide={jest.fn()}
+      show
+      canDevelop={false}
+    />,
+    { useRedux: true, useRouter: true },
+  );
 
-  test('should validate JSON configuration requirements', () => {
-    const validateJsonData = (jsonData: string) => {
-      if (!jsonData || jsonData.length === 0) return false;
-      try {
-        JSON.parse(jsonData);
-        return true;
-      } catch (e) {
-        return false;
-      }
-    };
-
-    expect(validateJsonData(mockTheme.json_data || '')).toBe(true);
-    expect(validateJsonData('')).toBe(false);
-    expect(validateJsonData('invalid')).toBe(false);
-    expect(validateJsonData('{}')).toBe(true);
-  });
+  const nameInput = screen.getByPlaceholderText('Enter theme name');
+  await userEvent.type(nameInput, 'New Theme');
 
-  test('should handle permission-based feature availability', () => {
-    const permissionUtils = jest.requireMock(
-      'src/dashboard/util/permissionUtils',
-    );
+  const saveButton = await screen.findByRole('button', { name: 'Add' });
+  expect(saveButton).toBeEnabled();
 
-    expect(permissionUtils.isUserAdmin).toBeDefined();
-    expect(typeof permissionUtils.isUserAdmin).toBe('function');
-    expect(permissionUtils.isUserAdmin()).toBe(true);
+  await userEvent.click(saveButton);
 
-    // Test with non-admin user
-    (permissionUtils.isUserAdmin as jest.Mock).mockReturnValue(false);
-    expect(permissionUtils.isUserAdmin()).toBe(false);
-  });
+  await screen.findByRole('dialog');
+  expect(fetchMock.called()).toBe(true);
+});
 
-  test('should handle theme context override state', () => {
-    expect(mockThemeContext.hasDevOverride()).toBe(false);
+test('applies theme locally when clicking Apply button', async () => {
+  const onThemeApply = jest.fn();
+  render(
+    <ThemeModal
+      addDangerToast={jest.fn()}
+      addSuccessToast={jest.fn()}
+      onThemeAdd={jest.fn()}
+      onHide={jest.fn()}
+      show
+      canDevelop
+      theme={mockTheme}
+      onThemeApply={onThemeApply}
+    />,
+    { useRedux: true, useRouter: true },
+  );
+
+  const applyButton = await screen.findByRole('button', { name: /apply/i });
+  expect(applyButton).toBeEnabled();
+
+  await userEvent.click(applyButton);
+
+  expect(mockThemeContext.setTemporaryTheme).toHaveBeenCalled();
+});
 
-    // Test with override
-    mockThemeContext.hasDevOverride.mockReturnValue(true);
-    expect(mockThemeContext.hasDevOverride()).toBe(true);
+test('disables Apply button when JSON configuration is invalid', async () => {
+  fetchMock.reset();
+  fetchMock.get('glob:*/api/v1/theme/*', {
+    result: { ...mockTheme, json_data: 'invalid json' },
   });
+
+  render(
+    <ThemeModal
+      addDangerToast={jest.fn()}
+      addSuccessToast={jest.fn()}
+      onThemeAdd={jest.fn()}
+      onHide={jest.fn()}
+      show
+      canDevelop
+      theme={mockTheme}
+    />,
+    { useRedux: true, useRouter: true },
+  );
+
+  const applyButton = await screen.findByRole('button', { name: /apply/i });
+  expect(applyButton).toBeDisabled();
 });
diff --git a/superset-frontend/src/features/themes/ThemeModal.tsx 
b/superset-frontend/src/features/themes/ThemeModal.tsx
index 96a7936bf3..30ef18df4f 100644
--- a/superset-frontend/src/features/themes/ThemeModal.tsx
+++ b/superset-frontend/src/features/themes/ThemeModal.tsx
@@ -16,23 +16,33 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { FunctionComponent, useState, useEffect, ChangeEvent } from 'react';
+import {
+  FunctionComponent,
+  useState,
+  useEffect,
+  useCallback,
+  useMemo,
+  ChangeEvent,
+} from 'react';
+import { omit } from 'lodash';
 
 import { css, styled, t, useTheme } from '@superset-ui/core';
 import { useSingleViewResource } from 'src/views/CRUD/hooks';
 import { useThemeContext } from 'src/theme/ThemeProvider';
+import { useBeforeUnload } from 'src/hooks/useBeforeUnload';
 import SupersetText from 'src/utils/textUtils';
 
 import { Icons } from '@superset-ui/core/components/Icons';
 import withToasts from 'src/components/MessageToasts/withToasts';
 import {
-  Input,
-  Modal,
-  JsonEditor,
+  Alert,
   Button,
   Form,
+  Input,
+  JsonEditor,
+  Modal,
+  Space,
   Tooltip,
-  Alert,
 } from '@superset-ui/core/components';
 import { useJsonValidation } from 
'@superset-ui/core/components/AsyncAceEditor';
 import { Typography } from '@superset-ui/core/components/Typography';
@@ -53,7 +63,7 @@ interface ThemeModalProps {
 
 type ThemeStringKeys = keyof Pick<
   ThemeObject,
-  OnlyKeyWithType<ThemeObject, String>
+  OnlyKeyWithType<ThemeObject, string>
 >;
 
 const StyledJsonEditor = styled.div`
@@ -100,7 +110,9 @@ const ThemeModal: FunctionComponent<ThemeModalProps> = ({
   const { setTemporaryTheme } = useThemeContext();
   const [disableSave, setDisableSave] = useState<boolean>(true);
   const [currentTheme, setCurrentTheme] = useState<ThemeObject | null>(null);
+  const [initialTheme, setInitialTheme] = useState<ThemeObject | null>(null);
   const [isHidden, setIsHidden] = useState<boolean>(true);
+  const [showConfirmAlert, setShowConfirmAlert] = useState<boolean>(false);
   const isEditMode = theme !== null;
   const isSystemTheme = currentTheme?.is_system === true;
   const isReadOnly = isSystemTheme;
@@ -130,29 +142,35 @@ const ThemeModal: FunctionComponent<ThemeModalProps> = ({
   } = useSingleViewResource<ThemeObject>('theme', t('theme'), addDangerToast);
 
   // Functions
-  const hide = () => {
+  const hasUnsavedChanges = useCallback(() => {
+    if (!currentTheme || !initialTheme || isReadOnly) return false;
+    return (
+      currentTheme.theme_name !== initialTheme.theme_name ||
+      currentTheme.json_data !== initialTheme.json_data
+    );
+  }, [currentTheme, initialTheme, isReadOnly]);
+
+  const hide = useCallback(() => {
     onHide();
     setCurrentTheme(null);
-  };
+    setInitialTheme(null);
+    setShowConfirmAlert(false);
+  }, [onHide]);
 
-  const onSave = () => {
+  const onSave = useCallback(() => {
     if (isEditMode) {
       // Edit
       if (currentTheme?.id) {
-        const update_id = currentTheme.id;
-        delete currentTheme.id;
-        delete currentTheme.created_by;
-        delete currentTheme.changed_by;
-        delete currentTheme.changed_on_delta_humanized;
-
-        updateResource(update_id, currentTheme).then(response => {
-          if (!response) {
-            return;
-          }
+        const themeData = omit(currentTheme, [
+          'id',
+          'created_by',
+          'changed_by',
+          'changed_on_delta_humanized',
+        ]);
 
-          if (onThemeAdd) {
-            onThemeAdd();
-          }
+        updateResource(currentTheme.id, themeData).then(response => {
+          if (!response) return;
+          if (onThemeAdd) onThemeAdd();
 
           hide();
         });
@@ -160,69 +178,115 @@ const ThemeModal: FunctionComponent<ThemeModalProps> = ({
     } else if (currentTheme) {
       // Create
       createResource(currentTheme).then(response => {
-        if (!response) {
-          return;
-        }
-
-        if (onThemeAdd) {
-          onThemeAdd();
-        }
+        if (!response) return;
+        if (onThemeAdd) onThemeAdd();
 
         hide();
       });
     }
-  };
+  }, [
+    currentTheme,
+    isEditMode,
+    updateResource,
+    createResource,
+    onThemeAdd,
+    hide,
+  ]);
 
-  const onApply = () => {
+  const handleCancel = useCallback(() => {
+    if (hasUnsavedChanges()) {
+      setShowConfirmAlert(true);
+    } else {
+      hide();
+    }
+  }, [hasUnsavedChanges, hide]);
+
+  const handleConfirmCancel = useCallback(() => {
+    hide();
+  }, [hide]);
+
+  const isValidJson = useCallback((str?: string) => {
+    if (!str) return false;
+    try {
+      JSON.parse(str);
+      return true;
+    } catch (e) {
+      return false;
+    }
+  }, []);
+
+  const onApply = useCallback(() => {
     if (currentTheme?.json_data && isValidJson(currentTheme.json_data)) {
       try {
         const themeConfig = JSON.parse(currentTheme.json_data);
+
         setTemporaryTheme(themeConfig);
-        if (onThemeApply) {
-          onThemeApply();
-        }
-        if (addSuccessToast) {
-          addSuccessToast(t('Local theme set for preview'));
-        }
+
+        if (onThemeApply) onThemeApply();
+        if (addSuccessToast) addSuccessToast(t('Local theme set for preview'));
       } catch (error) {
         addDangerToast(t('Failed to apply theme: Invalid JSON'));
       }
     }
-  };
-
-  const onThemeNameChange = (event: ChangeEvent<HTMLInputElement>) => {
-    const { target } = event;
-
-    const data = {
-      ...currentTheme,
-      theme_name: currentTheme ? currentTheme.theme_name : '',
-      json_data: currentTheme ? currentTheme.json_data : '',
-    };
-
-    data[target.name as ThemeStringKeys] = target.value;
-    setCurrentTheme(data);
-  };
-
-  const onJsonDataChange = (jsonData: string) => {
-    const data = {
-      ...currentTheme,
-      theme_name: currentTheme ? currentTheme.theme_name : '',
-      json_data: jsonData,
-    };
-    setCurrentTheme(data);
-  };
-
-  const isValidJson = (str?: string) => {
-    if (!str) return false;
-    try {
-      JSON.parse(str);
-      return true;
-    } catch (e) {
-      return false;
+  }, [
+    currentTheme?.json_data,
+    isValidJson,
+    setTemporaryTheme,
+    onThemeApply,
+    addSuccessToast,
+    addDangerToast,
+  ]);
+
+  const modalTitle = useMemo(() => {
+    if (isEditMode) {
+      return isReadOnly
+        ? t('View theme properties')
+        : t('Edit theme properties');
     }
-  };
+    return t('Add theme');
+  }, [isEditMode, isReadOnly]);
+
+  const modalIcon = useMemo(() => {
+    const Icon = isEditMode ? Icons.EditOutlined : Icons.PlusOutlined;
+    return (
+      <Icon
+        iconSize="l"
+        css={css`
+          margin: auto ${supersetTheme.sizeUnit * 2}px auto 0;
+        `}
+      />
+    );
+  }, [isEditMode, supersetTheme.sizeUnit]);
+
+  const onThemeNameChange = useCallback(
+    (event: ChangeEvent<HTMLInputElement>) => {
+      const { target } = event;
+
+      const data = {
+        ...currentTheme,
+        theme_name: currentTheme?.theme_name || '',
+        json_data: currentTheme?.json_data || '',
+      };
+
+      data[target.name as ThemeStringKeys] = target.value;
+      setCurrentTheme(data);
+    },
+    [currentTheme],
+  );
+
+  const onJsonDataChange = useCallback(
+    (jsonData: string) => {
+      const data = {
+        ...currentTheme,
+        theme_name: currentTheme?.theme_name || '',
+        json_data: jsonData,
+      };
+      setCurrentTheme(data);
+    },
+    [currentTheme],
+  );
 
-  const validate = () => {
+  const validate = useCallback(() => {
     if (isReadOnly) {
       setDisableSave(true);
       return;
@@ -237,7 +301,12 @@ const ThemeModal: FunctionComponent<ThemeModalProps> = ({
     } else {
       setDisableSave(true);
     }
-  };
+  }, [
+    currentTheme?.theme_name,
+    currentTheme?.json_data,
+    isReadOnly,
+    isValidJson,
+  ]);
 
   // Initialize
   useEffect(() => {
@@ -247,87 +316,115 @@ const ThemeModal: FunctionComponent<ThemeModalProps> = ({
         (theme && theme?.id !== currentTheme.id) ||
         (isHidden && show))
     ) {
-      if (theme?.id && !loading) {
-        fetchResource(theme.id);
-      }
+      if (theme?.id && !loading) fetchResource(theme.id);
     } else if (
       !isEditMode &&
       (!currentTheme || currentTheme.id || (isHidden && show))
     ) {
-      setCurrentTheme({
+      const newTheme = {
         theme_name: '',
         json_data: JSON.stringify({}, null, 2),
-      });
+      };
+      setCurrentTheme(newTheme);
+      setInitialTheme(newTheme);
     }
-  }, [theme, show]);
+  }, [theme, show, isEditMode, currentTheme, isHidden, loading, 
fetchResource]);
 
   useEffect(() => {
     if (resource) {
       setCurrentTheme(resource);
+      setInitialTheme(resource);
     }
   }, [resource]);
 
   // Validation
   useEffect(() => {
     validate();
-  }, [
-    currentTheme ? currentTheme.theme_name : '',
-    currentTheme ? currentTheme.json_data : '',
-    isReadOnly,
-  ]);
+  }, [validate]);
 
   // Show/hide
-  if (isHidden && show) {
-    setIsHidden(false);
-  }
+  useEffect(() => {
+    if (isHidden && show) setIsHidden(false);
+  }, [isHidden, show]);
+
+  // Handle browser navigation/reload with unsaved changes
+  useBeforeUnload(show && hasUnsavedChanges());
 
   return (
     <Modal
       disablePrimaryButton={isReadOnly || disableSave}
       onHandledPrimaryAction={isReadOnly ? undefined : onSave}
-      onHide={hide}
+      onHide={handleCancel}
       primaryButtonName={isEditMode ? t('Save') : t('Add')}
       show={show}
       width="55%"
-      footer={[
-        <Button key="cancel" onClick={hide} buttonStyle="secondary">
-          {isReadOnly ? t('Close') : t('Cancel')}
-        </Button>,
-        ...(!isReadOnly
-          ? [
-              <Button
-                key="save"
-                onClick={onSave}
-                disabled={disableSave}
-                buttonStyle="primary"
-              >
-                {isEditMode ? t('Save') : t('Add')}
-              </Button>,
-            ]
-          : []),
-      ]}
+      centered
+      footer={
+        showConfirmAlert ? (
+          <Alert
+            closable={false}
+            type="warning"
+            message={t('You have unsaved changes')}
+            description={t(
+              'Your changes will be lost if you leave without saving.',
+            )}
+            css={{
+              textAlign: 'left',
+            }}
+            action={
+              <Space>
+                <Button
+                  key="keep-editing"
+                  buttonStyle="tertiary"
+                  onClick={() => setShowConfirmAlert(false)}
+                >
+                  {t('Keep editing')}
+                </Button>
+                <Button
+                  key="discard"
+                  buttonStyle="secondary"
+                  onClick={handleConfirmCancel}
+                >
+                  {t('Discard')}
+                </Button>
+                <Button
+                  key="save"
+                  buttonStyle="primary"
+                  onClick={() => {
+                    setShowConfirmAlert(false);
+                    onSave();
+                  }}
+                  disabled={disableSave}
+                >
+                  {t('Save')}
+                </Button>
+              </Space>
+            }
+          />
+        ) : (
+          [
+            <Button key="cancel" onClick={handleCancel} 
buttonStyle="secondary">
+              {isReadOnly ? t('Close') : t('Cancel')}
+            </Button>,
+            ...(!isReadOnly
+              ? [
+                  <Button
+                    key="save"
+                    onClick={onSave}
+                    disabled={disableSave}
+                    buttonStyle="primary"
+                  >
+                    {isEditMode ? t('Save') : t('Add')}
+                  </Button>,
+                ]
+              : []),
+          ]
+        )
+      }
       title={
         <Typography.Title level={4} data-test="theme-modal-title">
-          {isEditMode ? (
-            <Icons.EditOutlined
-              iconSize="l"
-              css={css`
-                margin: auto ${supersetTheme.sizeUnit * 2}px auto 0;
-              `}
-            />
-          ) : (
-            <Icons.PlusOutlined
-              iconSize="l"
-              css={css`
-                margin: auto ${supersetTheme.sizeUnit * 2}px auto 0;
-              `}
-            />
-          )}
-          {isEditMode
-            ? isReadOnly
-              ? t('View theme properties')
-              : t('Edit theme properties')
-            : t('Add theme')}
+          {modalIcon}
+          {modalTitle}
         </Typography.Title>
       }
     >
@@ -385,7 +482,7 @@ const ThemeModal: FunctionComponent<ThemeModalProps> = ({
                 onChange={onJsonDataChange}
                 tabSize={2}
                 width="100%"
-                height="300px"
+                height="250px"
                 wrapEnabled
                 readOnly={isReadOnly}
                 showGutter
diff --git a/superset-frontend/src/hooks/useBeforeUnload/index.ts 
b/superset-frontend/src/hooks/useBeforeUnload/index.ts
new file mode 100644
index 0000000000..1befb3b892
--- /dev/null
+++ b/superset-frontend/src/hooks/useBeforeUnload/index.ts
@@ -0,0 +1,43 @@
+/**
+ * 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 { useEffect } from 'react';
+
+/**
+ * Custom hook to handle browser navigation/reload with unsaved changes
+ * @param shouldWarn - Boolean indicating if there are unsaved changes
+ * @param message - Optional custom message (most browsers ignore this and 
show their own)
+ */
+export const useBeforeUnload = (
+  shouldWarn: boolean,
+  message?: string,
+): void => {
+  useEffect(() => {
+    const handleBeforeUnload = (event: BeforeUnloadEvent) => {
+      if (!shouldWarn) return;
+
+      event.preventDefault();
+      // Most browsers require returnValue to be set, even though they ignore 
custom messages
+      // eslint-disable-next-line no-param-reassign
+      event.returnValue = message || '';
+    };
+
+    window.addEventListener('beforeunload', handleBeforeUnload);
+    return () => window.removeEventListener('beforeunload', 
handleBeforeUnload);
+  }, [shouldWarn, message]);
+};
diff --git 
a/superset-frontend/src/hooks/useBeforeUnload/useBeforeUnload.test.ts 
b/superset-frontend/src/hooks/useBeforeUnload/useBeforeUnload.test.ts
new file mode 100644
index 0000000000..c11abec297
--- /dev/null
+++ b/superset-frontend/src/hooks/useBeforeUnload/useBeforeUnload.test.ts
@@ -0,0 +1,156 @@
+/**
+ * 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 { renderHook } from '@testing-library/react-hooks';
+import { useBeforeUnload } from './index';
+
+function createMockEvent() {
+  return {
+    preventDefault: jest.fn(),
+    returnValue: undefined as string | undefined,
+  } as unknown as BeforeUnloadEvent;
+}
+
+let addEventListenerSpy: jest.SpyInstance;
+let removeEventListenerSpy: jest.SpyInstance;
+let getMockHandler: () => (e: BeforeUnloadEvent) => void;
+let handlers: Array<(e: BeforeUnloadEvent) => void>;
+
+beforeEach(() => {
+  jest.clearAllMocks();
+  jest.restoreAllMocks();
+
+  handlers = [];
+
+  addEventListenerSpy = jest
+    .spyOn(window, 'addEventListener')
+    .mockImplementation((type, handler) => {
+      if (type === 'beforeunload') {
+        handlers.push(handler as (e: BeforeUnloadEvent) => void);
+      }
+    });
+
+  removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
+
+  getMockHandler = () => handlers[handlers.length - 1];
+});
+
+test('should add event listener when shouldWarn is true', () => {
+  renderHook(() => useBeforeUnload(true));
+
+  expect(addEventListenerSpy).toHaveBeenCalledWith(
+    'beforeunload',
+    expect.any(Function),
+  );
+});
+
+test('should not prevent default when shouldWarn is false', () => {
+  renderHook(() => useBeforeUnload(false));
+
+  const event = createMockEvent();
+  const handler = getMockHandler();
+  handler(event);
+
+  expect(event.preventDefault).not.toHaveBeenCalled();
+  expect(event.returnValue).toBeUndefined();
+});
+
+test('should prevent default and set returnValue when shouldWarn is true', () 
=> {
+  renderHook(() => useBeforeUnload(true));
+
+  const event = createMockEvent();
+  const handler = getMockHandler();
+  handler(event);
+
+  expect(event.preventDefault).toHaveBeenCalled();
+  expect(event.returnValue).toBe('');
+});
+
+test('should use custom message when provided', () => {
+  const customMessage = 'You have unsaved changes!';
+  renderHook(() => useBeforeUnload(true, customMessage));
+
+  const event = createMockEvent();
+  const handler = getMockHandler();
+  handler(event);
+
+  expect(event.preventDefault).toHaveBeenCalled();
+  expect(event.returnValue).toBe(customMessage);
+});
+
+test('should remove event listener on unmount', () => {
+  const { unmount } = renderHook(() => useBeforeUnload(true));
+
+  unmount();
+
+  expect(removeEventListenerSpy).toHaveBeenCalledWith(
+    'beforeunload',
+    expect.any(Function),
+  );
+});
+
+test('should update handler when shouldWarn changes', () => {
+  const { rerender } = renderHook(
+    ({ shouldWarn }) => useBeforeUnload(shouldWarn),
+    {
+      initialProps: { shouldWarn: false },
+    },
+  );
+
+  const event = createMockEvent();
+
+  const initialHandler = getMockHandler();
+  initialHandler(event);
+  expect(event.preventDefault).not.toHaveBeenCalled();
+
+  (event.preventDefault as jest.Mock).mockClear();
+  event.returnValue = undefined;
+
+  const initialAddCalls = addEventListenerSpy.mock.calls.length;
+
+  rerender({ shouldWarn: true });
+
+  expect(removeEventListenerSpy).toHaveBeenCalled();
+  expect(addEventListenerSpy.mock.calls.length).toBeGreaterThan(
+    initialAddCalls,
+  );
+
+  const newHandler = getMockHandler();
+  newHandler(event);
+  expect(event.preventDefault).toHaveBeenCalled();
+  expect(event.returnValue).toBe('');
+});
+
+test('should handle multiple instances independently', () => {
+  const { unmount: unmount1 } = renderHook(() => useBeforeUnload(true));
+  const { unmount: unmount2 } = renderHook(() => useBeforeUnload(false));
+
+  const beforeunloadCalls = addEventListenerSpy.mock.calls.filter(
+    call => call[0] === 'beforeunload',
+  );
+  expect(beforeunloadCalls.length).toBeGreaterThanOrEqual(2);
+
+  unmount1();
+  expect(removeEventListenerSpy).toHaveBeenCalled();
+
+  const removalCount = removeEventListenerSpy.mock.calls.length;
+  unmount2();
+  expect(removeEventListenerSpy.mock.calls.length).toBeGreaterThan(
+    removalCount,
+  );
+});
diff --git a/superset-frontend/src/hooks/useUnsavedChangesPrompt/index.ts 
b/superset-frontend/src/hooks/useUnsavedChangesPrompt/index.ts
index 172e389c24..b79523b72f 100644
--- a/superset-frontend/src/hooks/useUnsavedChangesPrompt/index.ts
+++ b/superset-frontend/src/hooks/useUnsavedChangesPrompt/index.ts
@@ -19,6 +19,7 @@
 import { getClientErrorObject, t } from '@superset-ui/core';
 import { useEffect, useRef, useCallback, useState } from 'react';
 import { useHistory } from 'react-router-dom';
+import { useBeforeUnload } from 'src/hooks/useBeforeUnload';
 
 type UseUnsavedChangesPromptProps = {
   hasUnsavedChanges: boolean;
@@ -94,20 +95,6 @@ export const useUnsavedChangesPrompt = ({
     return () => unblock();
   }, [blockCallback, hasUnsavedChanges, history]);
 
-  useEffect(() => {
-    const handleBeforeUnload = (event: BeforeUnloadEvent) => {
-      if (!hasUnsavedChanges) return;
-      event.preventDefault();
-
-      // Most browsers require a "returnValue" set to empty string
-      const evt = event as any;
-      evt.returnValue = '';
-    };
-
-    window.addEventListener('beforeunload', handleBeforeUnload);
-    return () => window.removeEventListener('beforeunload', 
handleBeforeUnload);
-  }, [hasUnsavedChanges]);
-
   useEffect(() => {
     if (!isSaveModalVisible && manualSaveRef.current) {
       setShowModal(false);
@@ -115,6 +102,8 @@ export const useUnsavedChangesPrompt = ({
     }
   }, [isSaveModalVisible]);
 
+  useBeforeUnload(hasUnsavedChanges);
+
   return {
     showModal,
     setShowModal,


Reply via email to