mirror of
https://github.com/apache/superset.git
synced 2026-04-19 16:14:52 +00:00
fix(theme-crud): add unsaved changes modal (#35254)
This commit is contained in:
committed by
GitHub
parent
9d50f1b8a2
commit
a90928766b
@@ -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: 'admin@example.com',
|
||||
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: 'admin@example.com',
|
||||
// user_id: 1,
|
||||
// first_name: 'Admin',
|
||||
// last_name: 'User',
|
||||
// };
|
||||
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 },
|
||||
);
|
||||
|
||||
// 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();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
test('should export ThemeModal component', () => {
|
||||
const ThemeModalModule = jest.requireActual('./ThemeModal');
|
||||
expect(ThemeModalModule.default).toBeDefined();
|
||||
expect(typeof ThemeModalModule.default).toBe('object'); // HOC wrapped component
|
||||
});
|
||||
|
||||
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('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('should handle theme data parsing', () => {
|
||||
const parsedTheme = JSON.parse(mockTheme.json_data || '{}');
|
||||
expect(parsedTheme).toMatchObject({
|
||||
colors: {
|
||||
primary: '#1890ff',
|
||||
secondary: '#52c41a',
|
||||
},
|
||||
typography: {
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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('should handle API response structure', () => {
|
||||
// Test that fetch mock is properly configured
|
||||
expect(fetchMock.called()).toBe(false);
|
||||
|
||||
// Test API structure expectations
|
||||
const expectedResponse = {
|
||||
result: mockTheme,
|
||||
};
|
||||
|
||||
expect(expectedResponse.result).toMatchObject({
|
||||
id: 1,
|
||||
theme_name: 'Test Theme',
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle create theme API call', () => {
|
||||
const newTheme = {
|
||||
theme_name: 'New Theme',
|
||||
json_data: '{"colors": {"primary": "#ff0000"}}',
|
||||
};
|
||||
|
||||
// Test request structure
|
||||
expect(newTheme).toMatchObject({
|
||||
theme_name: expect.any(String),
|
||||
json_data: expect.any(String),
|
||||
});
|
||||
|
||||
// Test that JSON is valid
|
||||
expect(() => JSON.parse(newTheme.json_data)).not.toThrow();
|
||||
});
|
||||
|
||||
test('should handle update theme API call', () => {
|
||||
const updatedTheme = {
|
||||
theme_name: 'Updated Theme',
|
||||
json_data: '{"colors": {"primary": "#00ff00"}}',
|
||||
};
|
||||
|
||||
// Test request structure
|
||||
expect(updatedTheme).toMatchObject({
|
||||
theme_name: expect.any(String),
|
||||
json_data: expect.any(String),
|
||||
});
|
||||
|
||||
// Test that JSON is valid
|
||||
expect(() => JSON.parse(updatedTheme.json_data)).not.toThrow();
|
||||
});
|
||||
|
||||
test('should validate theme name requirements', () => {
|
||||
const validateThemeName = (name: string) => !!(name && name.length > 0);
|
||||
|
||||
expect(validateThemeName('Valid Theme')).toBe(true);
|
||||
expect(validateThemeName('')).toBe(false);
|
||||
expect(validateThemeName('Test')).toBe(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);
|
||||
});
|
||||
|
||||
test('should handle permission-based feature availability', () => {
|
||||
const permissionUtils = jest.requireMock(
|
||||
'src/dashboard/util/permissionUtils',
|
||||
);
|
||||
|
||||
expect(permissionUtils.isUserAdmin).toBeDefined();
|
||||
expect(typeof permissionUtils.isUserAdmin).toBe('function');
|
||||
expect(permissionUtils.isUserAdmin()).toBe(true);
|
||||
|
||||
// Test with non-admin user
|
||||
(permissionUtils.isUserAdmin as jest.Mock).mockReturnValue(false);
|
||||
expect(permissionUtils.isUserAdmin()).toBe(false);
|
||||
});
|
||||
|
||||
test('should handle theme context override state', () => {
|
||||
expect(mockThemeContext.hasDevOverride()).toBe(false);
|
||||
|
||||
// Test with override
|
||||
mockThemeContext.hasDevOverride.mockReturnValue(true);
|
||||
expect(mockThemeContext.hasDevOverride()).toBe(true);
|
||||
});
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
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('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('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('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('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('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('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('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();
|
||||
});
|
||||
|
||||
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('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('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('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('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('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('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('handles API errors gracefully', async () => {
|
||||
fetchMock.restore();
|
||||
fetchMock.post('glob:*/api/v1/theme/', 500);
|
||||
|
||||
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, 'New Theme');
|
||||
|
||||
const saveButton = await screen.findByRole('button', { name: 'Add' });
|
||||
expect(saveButton).toBeEnabled();
|
||||
|
||||
await userEvent.click(saveButton);
|
||||
|
||||
await screen.findByRole('dialog');
|
||||
expect(fetchMock.called()).toBe(true);
|
||||
});
|
||||
|
||||
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('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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
const themeData = omit(currentTheme, [
|
||||
'id',
|
||||
'created_by',
|
||||
'changed_by',
|
||||
'changed_on_delta_humanized',
|
||||
]);
|
||||
|
||||
updateResource(update_id, currentTheme).then(response => {
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onThemeAdd) {
|
||||
onThemeAdd();
|
||||
}
|
||||
updateResource(currentTheme.id, themeData).then(response => {
|
||||
if (!response) return;
|
||||
if (onThemeAdd) onThemeAdd();
|
||||
|
||||
hide();
|
||||
});
|
||||
@@ -160,59 +178,34 @@ 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 = () => {
|
||||
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'));
|
||||
}
|
||||
} catch (error) {
|
||||
addDangerToast(t('Failed to apply theme: Invalid JSON'));
|
||||
}
|
||||
const handleCancel = useCallback(() => {
|
||||
if (hasUnsavedChanges()) {
|
||||
setShowConfirmAlert(true);
|
||||
} else {
|
||||
hide();
|
||||
}
|
||||
};
|
||||
}, [hasUnsavedChanges, hide]);
|
||||
|
||||
const onThemeNameChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const { target } = event;
|
||||
const handleConfirmCancel = useCallback(() => {
|
||||
hide();
|
||||
}, [hide]);
|
||||
|
||||
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) => {
|
||||
const isValidJson = useCallback((str?: string) => {
|
||||
if (!str) return false;
|
||||
try {
|
||||
JSON.parse(str);
|
||||
@@ -220,9 +213,80 @@ const ThemeModal: FunctionComponent<ThemeModalProps> = ({
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const validate = () => {
|
||||
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'));
|
||||
} catch (error) {
|
||||
addDangerToast(t('Failed to apply theme: Invalid JSON'));
|
||||
}
|
||||
}
|
||||
}, [
|
||||
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 = 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
|
||||
|
||||
43
superset-frontend/src/hooks/useBeforeUnload/index.ts
Normal file
43
superset-frontend/src/hooks/useBeforeUnload/index.ts
Normal file
@@ -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]);
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user