feat: Add confirmation modal for unsaved changes (#33809)

This commit is contained in:
Gabriel Torres Ruiz
2025-07-01 13:38:51 -03:00
committed by GitHub
parent 050ccdcb3d
commit 057218d87f
28 changed files with 1799 additions and 489 deletions

View File

@@ -228,13 +228,16 @@ const CustomModal = ({
};
let FooterComponent;
if (isValidElement(footer)) {
// This safely avoids injecting "closeModal" into native elements like <div> or <span>
if (isValidElement(footer) && typeof footer.type === 'function')
// If a footer component is provided inject a closeModal function
// so the footer can provide a "close" button if desired
FooterComponent = cloneElement(footer, {
closeModal: handleOnHide,
} as Partial<unknown>);
}
else FooterComponent = footer;
const modalFooter = isNil(FooterComponent)
? [
<Button
@@ -309,7 +312,7 @@ const CustomModal = ({
open={show}
title={<ModalTitle />}
closeIcon={
<span className="close" aria-hidden="true">
<span data-test="close-modal-btn" className="close" aria-hidden="true">
×
</span>
}

View File

@@ -0,0 +1,47 @@
/**
* 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 { ReactElement } from 'react';
import { UnsavedChangesModal, type UnsavedChangesModalProps } from '.';
export default {
title: 'Components/UnsavedChangesModal',
component: UnsavedChangesModal,
};
export const InteractiveUnsavedChangesModal = (
props: UnsavedChangesModalProps,
): ReactElement => (
<UnsavedChangesModal {...props}>
If you don't save, changes will be lost.
</UnsavedChangesModal>
);
InteractiveUnsavedChangesModal.args = {
showModal: true,
onHide: () => {},
handleSave: () => {},
onConfirmNavigation: () => {},
title: 'Unsaved Changes',
};
InteractiveUnsavedChangesModal.argTypes = {
onHide: { action: 'onHide' },
handleSave: { action: 'handleSave' },
onConfirmNavigation: { action: 'onConfirmNavigation' },
};

View File

@@ -0,0 +1,96 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, userEvent } from '@superset-ui/core/spec';
import { UnsavedChangesModal } from '.';
test('should render nothing if showModal is false', () => {
const { queryByRole } = render(
<UnsavedChangesModal
showModal={false}
onHide={() => {}}
handleSave={() => {}}
onConfirmNavigation={() => {}}
/>,
);
expect(queryByRole('dialog')).not.toBeInTheDocument();
});
test('should render the UnsavedChangesModal component if showModal is true', async () => {
const { queryByRole } = render(
<UnsavedChangesModal
showModal
onHide={() => {}}
handleSave={() => {}}
onConfirmNavigation={() => {}}
/>,
);
expect(queryByRole('dialog')).toBeInTheDocument();
});
test('should only call onConfirmNavigation when clicking the Discard button', async () => {
const mockOnHide = jest.fn();
const mockHandleSave = jest.fn();
const mockOnConfirmNavigation = jest.fn();
render(
<UnsavedChangesModal
showModal
onHide={mockOnHide}
handleSave={mockHandleSave}
onConfirmNavigation={mockOnConfirmNavigation}
/>,
);
const discardButton: HTMLElement = await screen.findByRole('button', {
name: /discard/i,
});
userEvent.click(discardButton);
expect(mockOnConfirmNavigation).toHaveBeenCalled();
expect(mockHandleSave).not.toHaveBeenCalled();
expect(mockOnHide).not.toHaveBeenCalled();
});
test('should only call handleSave when clicking the Save button', async () => {
const mockOnHide = jest.fn();
const mockHandleSave = jest.fn();
const mockOnConfirmNavigation = jest.fn();
render(
<UnsavedChangesModal
showModal
onHide={mockOnHide}
handleSave={mockHandleSave}
onConfirmNavigation={mockOnConfirmNavigation}
/>,
);
const saveButton: HTMLElement = await screen.findByRole('button', {
name: /save/i,
});
userEvent.click(saveButton);
expect(mockHandleSave).toHaveBeenCalled();
expect(mockOnHide).not.toHaveBeenCalled();
expect(mockOnConfirmNavigation).not.toHaveBeenCalled();
});

View File

@@ -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 { t, styled, css } from '@superset-ui/core';
import { Icons, Modal, Typography } from '@superset-ui/core/components';
import { Button } from '@superset-ui/core/components/Button';
import type { FC, ReactElement } from 'react';
const StyledModalTitle = styled(Typography.Title)`
&& {
font-weight: 600;
margin: 0;
}
`;
const StyledModalBody = styled(Typography.Text)`
${({ theme }) => css`
padding: 0 ${theme.sizeUnit * 2}px;
&& {
margin: 0;
}
`}
`;
const StyledDiscardBtn = styled(Button)`
${({ theme }) => css`
min-width: ${theme.sizeUnit * 22}px;
height: ${theme.sizeUnit * 8}px;
`}
`;
const StyledSaveBtn = styled(Button)`
${({ theme }) => css`
min-width: ${theme.sizeUnit * 17}px;
height: ${theme.sizeUnit * 8}px;
span > :first-of-type {
margin-right: 0;
}
`}
`;
const StyledWarningIcon = styled(Icons.WarningOutlined)`
${({ theme }) => css`
color: ${theme.colors.warning.base};
margin-right: ${theme.sizeUnit * 4}px;
`}
`;
export type UnsavedChangesModalProps = {
showModal: boolean;
onHide: () => void;
handleSave: () => void;
onConfirmNavigation: () => void;
title?: string;
body?: string;
};
export const UnsavedChangesModal: FC<UnsavedChangesModalProps> = ({
showModal,
onHide,
handleSave,
onConfirmNavigation,
title = 'Unsaved Changes',
body = "If you don't save, changes will be lost.",
}: UnsavedChangesModalProps): ReactElement => (
<Modal
centered
responsive
onHide={onHide}
show={showModal}
width="444px"
title={
<div
css={css`
align-items: center;
display: flex;
`}
>
<StyledWarningIcon iconSize="xl" />
<StyledModalTitle type="secondary" level={5}>
{title}
</StyledModalTitle>
</div>
}
footer={
<div
css={css`
display: flex;
justify-content: flex-end;
width: 100%;
`}
>
<StyledDiscardBtn
htmlType="button"
buttonSize="small"
onClick={onConfirmNavigation}
>
{t('Discard')}
</StyledDiscardBtn>
<StyledSaveBtn
htmlType="button"
buttonSize="small"
buttonStyle="primary"
onClick={handleSave}
>
{t('Save')}
</StyledSaveBtn>
</div>
}
>
<StyledModalBody type="secondary">{body}</StyledModalBody>
</Modal>
);

View File

@@ -163,5 +163,6 @@ export * from './Steps';
export * from './Table';
export * from './TableView';
export * from './Tag';
export * from './UnsavedChangesModal';
export * from './constants';
export * from './Result';

View File

@@ -1,279 +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, userEvent } from 'spec/helpers/testing-library';
import {
AlteredSliceTag,
alterForComparison,
formatValueHandler,
isEqualish,
} from '.';
import { defaultProps } from './AlteredSliceTagMocks';
const controlsMap = {
b: { type: 'BoundsControl', label: 'Bounds' },
column_collection: { type: 'CollectionControl', label: 'Collection' },
metrics: { type: 'MetricsControl', label: 'Metrics' },
adhoc_filters: { type: 'AdhocFilterControl', label: 'Adhoc' },
other_control: { type: 'OtherControl', label: 'Other' },
};
test('renders the "Altered" label', () => {
render(
<AlteredSliceTag
origFormData={defaultProps.origFormData}
currentFormData={defaultProps.currentFormData}
/>,
);
const alteredLabel = screen.getByText('Altered');
expect(alteredLabel).toBeInTheDocument();
});
test('opens the modal on click', () => {
render(
<AlteredSliceTag
origFormData={defaultProps.origFormData}
currentFormData={defaultProps.currentFormData}
/>,
);
const alteredLabel = screen.getByText('Altered');
userEvent.click(alteredLabel);
const modalTitle = screen.getByText('Chart changes');
expect(modalTitle).toBeInTheDocument();
});
test('displays the differences in the modal', () => {
render(
<AlteredSliceTag
origFormData={defaultProps.origFormData}
currentFormData={defaultProps.currentFormData}
/>,
);
const alteredLabel = screen.getByText('Altered');
userEvent.click(alteredLabel);
const beforeValue = screen.getByText('1, 2, 3, 4');
const afterValue = screen.getByText('a, b, c, d');
expect(beforeValue).toBeInTheDocument();
expect(afterValue).toBeInTheDocument();
});
test('does not render anything if there are no differences', () => {
render(
<AlteredSliceTag
origFormData={defaultProps.origFormData}
currentFormData={defaultProps.origFormData}
/>,
);
const alteredLabel = screen.queryByText('Altered');
expect(alteredLabel).not.toBeInTheDocument();
});
test('alterForComparison returns null for undefined value', () => {
expect(alterForComparison(undefined)).toBeNull();
});
test('alterForComparison returns null for null value', () => {
expect(alterForComparison(null)).toBeNull();
});
test('alterForComparison returns null for empty string value', () => {
expect(alterForComparison('')).toBeNull();
});
test('alterForComparison returns null for empty array value', () => {
expect(alterForComparison([])).toBeNull();
});
test('alterForComparison returns null for empty object value', () => {
expect(alterForComparison({})).toBeNull();
});
test('alterForComparison returns value for non-empty array', () => {
const value = [1, 2, 3];
expect(alterForComparison(value)).toEqual(value);
});
test('alterForComparison returns value for non-empty object', () => {
const value = { key: 'value' };
expect(alterForComparison(value)).toEqual(value);
});
test('formatValueHandler handles undefined value', () => {
const value = undefined;
const key = 'b';
const formattedValue = formatValueHandler(value, key, controlsMap);
expect(formattedValue).toBe('N/A');
});
test('formatValueHandler handles null value', () => {
const value = null;
const key = 'b';
const formattedValue = formatValueHandler(value, key, controlsMap);
expect(formattedValue).toBe('null');
});
test('formatValueHandler returns "[]" for empty filters', () => {
const value = [];
const key = 'adhoc_filters';
const formattedValue = formatValueHandler(value, key, controlsMap);
expect(formattedValue).toBe('[]');
});
test('formatValueHandler formats filters with array values', () => {
const filters = [
{
clause: 'WHERE',
comparator: ['1', 'g', '7', 'ho'],
expressionType: 'SIMPLE',
operator: 'IN',
subject: 'a',
},
{
clause: 'WHERE',
comparator: ['hu', 'ho', 'ha'],
expressionType: 'SIMPLE',
operator: 'NOT IN',
subject: 'b',
},
];
const key = 'adhoc_filters';
const formattedValue = formatValueHandler(filters, key, controlsMap);
const expected = 'a IN [1, g, 7, ho], b NOT IN [hu, ho, ha]';
expect(formattedValue).toBe(expected);
});
test('formatValueHandler formats filters with string values', () => {
const filters = [
{
clause: 'WHERE',
comparator: 'gucci',
expressionType: 'SIMPLE',
operator: '==',
subject: 'a',
},
{
clause: 'WHERE',
comparator: 'moshi moshi',
expressionType: 'SIMPLE',
operator: 'LIKE',
subject: 'b',
},
];
const key = 'adhoc_filters';
const expected = 'a == gucci, b LIKE moshi moshi';
const formattedValue = formatValueHandler(filters, key, controlsMap);
expect(formattedValue).toBe(expected);
});
test('formatValueHandler formats "Min" and "Max" for BoundsControl', () => {
const value = [1, 2];
const key = 'b';
const result = formatValueHandler(value, key, controlsMap);
expect(result).toEqual('Min: 1, Max: 2');
});
test('formatValueHandler formats stringified objects for CollectionControl', () => {
const value = [{ a: 1 }, { b: 2 }];
const key = 'column_collection';
const result = formatValueHandler(value, key, controlsMap);
expect(result).toEqual(
`${JSON.stringify(value[0])}, ${JSON.stringify(value[1])}`,
);
});
test('formatValueHandler formats MetricsControl values correctly', () => {
const value = [{ label: 'SUM(Sales)' }, { label: 'Metric2' }];
const key = 'metrics';
const result = formatValueHandler(value, key, controlsMap);
expect(result).toEqual('SUM(Sales), Metric2');
});
test('formatValueHandler formats boolean values as string', () => {
const value1 = true;
const value2 = false;
const key = 'b';
const formattedValue1 = formatValueHandler(value1, key, controlsMap);
const formattedValue2 = formatValueHandler(value2, key, controlsMap);
expect(formattedValue1).toBe('true');
expect(formattedValue2).toBe('false');
});
test('formatValueHandler formats array values correctly', () => {
const value = [
{ label: 'Label1' },
{ label: 'Label2' },
5,
6,
7,
8,
'hello',
'goodbye',
];
const result = formatValueHandler(value, undefined, controlsMap);
const expected = 'Label1, Label2, 5, 6, 7, 8, hello, goodbye';
expect(result).toEqual(expected);
});
test('formatValueHandler formats string values correctly', () => {
const value = 'test';
const key = 'other_control';
const result = formatValueHandler(value, key, controlsMap);
expect(result).toEqual('test');
});
test('formatValueHandler formats number values correctly', () => {
const value = 123;
const key = 'other_control';
const result = formatValueHandler(value, key, controlsMap);
expect(result).toEqual(123);
});
test('formatValueHandler formats object values correctly', () => {
const value = { 1: 2, alpha: 'bravo' };
const key = 'other_control';
const expected = '{"1":2,"alpha":"bravo"}';
const result = formatValueHandler(value, key, controlsMap);
expect(result).toEqual(expected);
});
test('isEqualish considers null, undefined, {} and [] as equal', () => {
expect(isEqualish(null, undefined)).toBe(true);
expect(isEqualish(null, [])).toBe(true);
expect(isEqualish(null, {})).toBe(true);
expect(isEqualish(undefined, {})).toBe(true);
});
test('isEqualish considers empty strings equal to null', () => {
expect(isEqualish(undefined, '')).toBe(true);
expect(isEqualish(null, '')).toBe(true);
});
test('isEqualish considers deeply equal objects equal', () => {
const obj1 = { a: { b: { c: 1 } } };
const obj2 = { a: { b: { c: 1 } } };
expect(isEqualish('', '')).toBe(true);
expect(isEqualish(obj1, obj2)).toBe(true);
// Actually not equal
expect(isEqualish({ a: 1, b: 2, z: 9 }, { a: 1, b: 2, c: 3 })).toBe(false);
});

View File

@@ -0,0 +1,97 @@
/**
* 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 'spec/helpers/testing-library';
import { QueryFormData } from '@superset-ui/core';
import { AlteredSliceTag } from '.';
import { defaultProps, expectedDiffs } from './AlteredSliceTagMocks';
test('renders the "Altered" label', () => {
render(
<AlteredSliceTag
origFormData={defaultProps.origFormData}
currentFormData={defaultProps.currentFormData}
diffs={expectedDiffs}
/>,
);
const alteredLabel: HTMLElement = screen.getByText('Altered');
expect(alteredLabel).toBeInTheDocument();
});
test('opens the modal on click', () => {
render(
<AlteredSliceTag
origFormData={defaultProps.origFormData}
currentFormData={defaultProps.currentFormData}
diffs={expectedDiffs}
/>,
);
const alteredLabel: HTMLElement = screen.getByText('Altered');
userEvent.click(alteredLabel);
const modalTitle: HTMLElement = screen.getByText('Chart changes');
expect(modalTitle).toBeInTheDocument();
});
test('displays the differences in the modal', () => {
render(
<AlteredSliceTag
origFormData={defaultProps.origFormData}
currentFormData={defaultProps.currentFormData}
diffs={expectedDiffs}
/>,
);
const alteredLabel: HTMLElement = screen.getByText('Altered');
userEvent.click(alteredLabel);
const beforeValue: HTMLElement = screen.getByText('1, 2, 3, 4');
const afterValue: HTMLElement = screen.getByText('a, b, c, d');
expect(beforeValue).toBeInTheDocument();
expect(afterValue).toBeInTheDocument();
});
test('does not render anything if there are no differences', () => {
render(
<AlteredSliceTag
origFormData={defaultProps.origFormData}
currentFormData={defaultProps.origFormData}
diffs={{}}
/>,
);
const alteredLabel: HTMLElement | null = screen.queryByText('Altered');
expect(alteredLabel).not.toBeInTheDocument();
});
test('does not render the altered label if diffs is empty', () => {
render(
<AlteredSliceTag
origFormData={{ viz_type: 'altered_slice_tag_spec' } as QueryFormData}
currentFormData={{ viz_type: 'altered_slice_tag_spec' } as QueryFormData}
diffs={{}}
/>,
);
expect(screen.queryByText('Altered')).not.toBeInTheDocument();
});

View File

@@ -18,10 +18,16 @@
*/
import { QueryFormData } from '@superset-ui/core';
import { ControlPanelConfig } from '@superset-ui/chart-controls';
import type { DiffType, RowType } from './types';
import type { DiffType } from 'src/types/DiffType';
import { getChartFormDiffs } from 'src/utils/getChartFormDiffs';
import type { RowType } from './types';
export const defaultProps: Record<string, Partial<QueryFormData>> = {
export const defaultProps: {
origFormData: QueryFormData;
currentFormData: QueryFormData;
} = {
origFormData: {
datasource: '123__table',
viz_type: 'altered_slice_tag_spec',
adhoc_filters: [
{
@@ -40,7 +46,10 @@ export const defaultProps: Record<string, Partial<QueryFormData>> = {
never: 5,
ever: { a: 'b', c: 'd' },
},
currentFormData: {
datasource: '123__table',
viz_type: 'altered_slice_tag_spec',
adhoc_filters: [
{
clause: 'WHERE',
@@ -106,6 +115,7 @@ export const expectedDiffs: Record<string, DiffType> = {
after: { x: 'y', z: 'z' },
},
};
export const expectedRows: RowType[] = [
{
control: 'Fake Filters',
@@ -131,6 +141,7 @@ export const expectedRows: RowType[] = [
after: '{"x":"y","z":"z"}',
},
];
export const fakePluginControls: ControlPanelConfig = {
controlPanelSections: [
{
@@ -175,3 +186,6 @@ export const fakePluginControls: ControlPanelConfig = {
},
],
};
export const createDiffs = (original: QueryFormData, current: QueryFormData) =>
getChartFormDiffs(original, current);

View File

@@ -16,13 +16,10 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback, useEffect, useMemo, useState, FC } from 'react';
import { isEqual, isEmpty } from 'lodash';
import { useEffect, useMemo, useState, FC } from 'react';
import { isEmpty } from 'lodash';
import { t } from '@superset-ui/core';
import { sanitizeFormData } from 'src/explore/exploreUtils/formData';
import getControlsForVizType from 'src/utils/getControlsForVizType';
import { safeStringify } from 'src/utils/safeStringify';
import {
Label,
Icons,
@@ -30,130 +27,21 @@ import {
ModalTrigger,
TableView,
} from '@superset-ui/core/components';
import type {
AlteredSliceTagProps,
ControlMap,
DiffItemType,
DiffType,
FilterItemType,
RowType,
} from './types';
export const alterForComparison = (
value?: string | null | [],
): string | null => {
// Treat `null`, `undefined`, and empty strings as equivalent
if (value === undefined || value === null || value === '') {
return null;
}
// Treat empty arrays and objects as equivalent to null
if (Array.isArray(value) && value.length === 0) {
return null;
}
if (typeof value === 'object' && Object.keys(value).length === 0) {
return null;
}
return value;
};
export const formatValueHandler = (
value: DiffItemType,
key: string,
controlsMap: ControlMap,
): string | number => {
if (value === undefined) {
return 'N/A';
}
if (value === null) {
return 'null';
}
if (typeof value === 'boolean') {
return value ? 'true' : 'false';
}
if (controlsMap[key]?.type === 'AdhocFilterControl' && Array.isArray(value)) {
if (!value.length) {
return '[]';
}
return value
.map((v: FilterItemType) => {
const filterVal =
v.comparator && v.comparator.constructor === Array
? `[${v.comparator.join(', ')}]`
: v.comparator;
return `${v.subject} ${v.operator} ${filterVal}`;
})
.join(', ');
}
if (controlsMap[key]?.type === 'BoundsControl' && Array.isArray(value)) {
return `Min: ${value[0]}, Max: ${value[1]}`;
}
if (controlsMap[key]?.type === 'CollectionControl' && Array.isArray(value)) {
return value.map((v: FilterItemType) => safeStringify(v)).join(', ');
}
if (
controlsMap[key]?.type === 'MetricsControl' &&
value.constructor === Array
) {
const formattedValue = value.map((v: FilterItemType) => v?.label ?? v);
return formattedValue.length ? formattedValue.join(', ') : '[]';
}
if (Array.isArray(value)) {
const formattedValue = value.map((v: FilterItemType) => v?.label ?? v);
return formattedValue.length ? formattedValue.join(', ') : '[]';
}
if (typeof value === 'string' || typeof value === 'number') {
return value;
}
return safeStringify(value);
};
export const getRowsFromDiffs = (
diffs: { [key: string]: DiffType },
controlsMap: ControlMap,
): RowType[] =>
Object.entries(diffs).map(([key, diff]) => ({
control: controlsMap[key]?.label || key,
before: formatValueHandler(diff.before, key, controlsMap),
after: formatValueHandler(diff.after, key, controlsMap),
}));
export const isEqualish = (val1: string, val2: string): boolean =>
isEqual(alterForComparison(val1), alterForComparison(val2));
import type { AlteredSliceTagProps, ControlMap, RowType } from './types';
import { getRowsFromDiffs } from './utils';
export const AlteredSliceTag: FC<AlteredSliceTagProps> = props => {
const [rows, setRows] = useState<RowType[]>([]);
const [hasDiffs, setHasDiffs] = useState<boolean>(false);
const getDiffs = useCallback(() => {
// Returns all properties that differ in the
// current form data and the saved form data
const ofd = sanitizeFormData(props.origFormData);
const cfd = sanitizeFormData(props.currentFormData);
const fdKeys = Object.keys(cfd);
const diffs: { [key: string]: DiffType } = {};
fdKeys.forEach(fdKey => {
if (!ofd[fdKey] && !cfd[fdKey]) {
return;
}
if (['filters', 'having', 'where'].includes(fdKey)) {
return;
}
if (!isEqualish(ofd[fdKey], cfd[fdKey])) {
diffs[fdKey] = { before: ofd[fdKey], after: cfd[fdKey] };
}
});
return diffs;
}, [props.currentFormData, props.origFormData]);
useEffect(() => {
const diffs = getDiffs();
const controlsMap = getControlsForVizType(
props.origFormData?.viz_type,
) as ControlMap;
setRows(getRowsFromDiffs(diffs, controlsMap));
setHasDiffs(!isEmpty(diffs));
}, [getDiffs, props.origFormData?.viz_type]);
setRows(getRowsFromDiffs(props.diffs, controlsMap));
setHasDiffs(!isEmpty(props.diffs));
}, [props.diffs, props.origFormData?.viz_type]);
const modalBody = useMemo(() => {
const columns = [

View File

@@ -17,8 +17,11 @@
* under the License.
*/
import type { QueryFormData } from '@superset-ui/core';
import { DiffType } from 'src/types/DiffType';
export interface AlteredSliceTagProps {
className?: string;
diffs: Record<string, DiffType>;
origFormData: QueryFormData;
currentFormData: QueryFormData;
}
@@ -30,29 +33,6 @@ export interface ControlMap {
};
}
export type FilterItemType = {
comparator?: string | string[];
subject: string;
operator: string;
label?: string;
};
export type DiffItemType<
T = FilterItemType | number | string | Record<string | number, any>,
> =
| T[]
| boolean
| number
| string
| Record<string | number, any>
| null
| undefined;
export type DiffType = {
before: DiffItemType;
after: DiffItemType;
};
export type RowType = {
before: string | number;
after: string | number;

View File

@@ -0,0 +1,107 @@
/**
* 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 { DiffItemType, DiffType, FilterItemType } from 'src/types/DiffType';
import { safeStringify } from 'src/utils/safeStringify';
import { ControlMap, RowType } from '../types';
export const alterForComparison = (value?: unknown): unknown => {
// Treat `null`, `undefined`, and empty strings as equivalent
if (value === undefined || value === null || value === '') return null;
// Treat empty arrays and objects as equivalent to null
if (Array.isArray(value) && value.length === 0) return null;
if (typeof value === 'object' && Object.keys(value).length === 0) return null;
return value;
};
export const formatValueHandler = (
value: DiffItemType,
key = '',
controlsMap: ControlMap,
): string | number => {
if (value === undefined) return 'N/A';
if (value === null) return 'null';
if (typeof value === 'boolean') return value ? 'true' : 'false';
if (controlsMap[key]?.type === 'AdhocFilterControl' && Array.isArray(value)) {
if (!value.length) return '[]';
return value
.map((v: FilterItemType): string => {
const filterVal: string | string[] | undefined =
v.comparator && v.comparator.constructor === Array
? `[${v.comparator.join(', ')}]`
: v.comparator;
return `${v.subject} ${v.operator} ${filterVal}`;
})
.join(', ');
}
if (controlsMap[key]?.type === 'BoundsControl' && Array.isArray(value))
return `Min: ${value[0]}, Max: ${value[1]}`;
if (controlsMap[key]?.type === 'CollectionControl' && Array.isArray(value))
return value
.map((v: FilterItemType): string => safeStringify(v))
.join(', ');
if (
controlsMap[key]?.type === 'MetricsControl' &&
value.constructor === Array
) {
const formattedValue: (string | FilterItemType)[] = value.map(
(v: FilterItemType): string | FilterItemType => v?.label ?? v,
);
return formattedValue.length ? formattedValue.join(', ') : '[]';
}
if (Array.isArray(value)) {
const formattedValue = value.map((v: any) => {
if (
typeof v === 'object' &&
v !== null &&
'label' in v &&
typeof v.label === 'string'
)
return v.label;
return String(v);
});
return formattedValue.length ? formattedValue.join(', ') : '[]';
}
if (typeof value === 'string' || typeof value === 'number') return value;
return safeStringify(value);
};
export const getRowsFromDiffs = (
diffs: { [key: string]: DiffType },
controlsMap: ControlMap,
): RowType[] =>
Object.entries(diffs).map(([key, diff]: [string, DiffType]) => ({
control: controlsMap[key]?.label || key,
before: formatValueHandler(diff.before, key, controlsMap),
after: formatValueHandler(diff.after, key, controlsMap),
}));

View File

@@ -0,0 +1,291 @@
/**
* 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 { alterForComparison, formatValueHandler, getRowsFromDiffs } from '.';
import { RowType } from '../types';
describe('alterForComparison', () => {
it('returns null for undefined value', () => {
expect(alterForComparison(undefined)).toBeNull();
});
it('returns null for null value', () => {
expect(alterForComparison(null)).toBeNull();
});
it('returns null for empty string value', () => {
expect(alterForComparison('')).toBeNull();
});
it('returns null for empty array value', () => {
expect(alterForComparison([])).toBeNull();
});
it('returns null for empty object value', () => {
expect(alterForComparison({})).toBeNull();
});
it('returns value for non-empty array', () => {
const value = [1, 2, 3];
expect(alterForComparison(value)).toEqual(value);
});
it('returns value for non-empty object', () => {
const value = { key: 'value' };
expect(alterForComparison(value)).toEqual(value);
});
});
describe('formatValueHandler', () => {
const controlsMap = {
b: { type: 'BoundsControl', label: 'Bounds' },
column_collection: { type: 'CollectionControl', label: 'Collection' },
metrics: { type: 'MetricsControl', label: 'Metrics' },
adhoc_filters: { type: 'AdhocFilterControl', label: 'Adhoc' },
other_control: { type: 'OtherControl', label: 'Other' },
};
it('handles undefined value', () => {
const value = undefined;
const key = 'b';
const formattedValue: string | number = formatValueHandler(
value,
key,
controlsMap,
);
expect(formattedValue).toBe('N/A');
});
it('handles null value', () => {
const value = null;
const key = 'b';
const formattedValue: string | number = formatValueHandler(
value,
key,
controlsMap,
);
expect(formattedValue).toBe('null');
});
it('returns "[]" for empty filters', () => {
const value: unknown[] = [];
const key = 'adhoc_filters';
const formattedValue: string | number = formatValueHandler(
value,
key,
controlsMap,
);
expect(formattedValue).toBe('[]');
});
it('formats filters with array values', () => {
const filters = [
{
clause: 'WHERE',
comparator: ['1', 'g', '7', 'ho'],
expressionType: 'SIMPLE',
operator: 'IN',
subject: 'a',
},
{
clause: 'WHERE',
comparator: ['hu', 'ho', 'ha'],
expressionType: 'SIMPLE',
operator: 'NOT IN',
subject: 'b',
},
];
const key = 'adhoc_filters';
const expected = 'a IN [1, g, 7, ho], b NOT IN [hu, ho, ha]';
const formattedValue: string | number = formatValueHandler(
filters,
key,
controlsMap,
);
expect(formattedValue).toBe(expected);
});
it('formats filters with string values', () => {
const filters = [
{
clause: 'WHERE',
comparator: 'gucci',
expressionType: 'SIMPLE',
operator: '==',
subject: 'a',
},
{
clause: 'WHERE',
comparator: 'moshi moshi',
expressionType: 'SIMPLE',
operator: 'LIKE',
subject: 'b',
},
];
const key = 'adhoc_filters';
const expected = 'a == gucci, b LIKE moshi moshi';
const formattedValue: string | number = formatValueHandler(
filters,
key,
controlsMap,
);
expect(formattedValue).toBe(expected);
});
it('formats "Min" and "Max" for BoundsControl', () => {
const value: number[] = [1, 2];
const key = 'b';
const result: string | number = formatValueHandler(value, key, controlsMap);
expect(result).toEqual('Min: 1, Max: 2');
});
it('formats stringified objects for CollectionControl', () => {
const value = [{ a: 1 }, { b: 2 }];
const key = 'column_collection';
const result: string | number = formatValueHandler(value, key, controlsMap);
expect(result).toEqual(
`${JSON.stringify(value[0])}, ${JSON.stringify(value[1])}`,
);
});
it('formats MetricsControl values correctly', () => {
const value = [{ label: 'SUM(Sales)' }, { label: 'Metric2' }];
const key = 'metrics';
const result: string | number = formatValueHandler(value, key, controlsMap);
expect(result).toEqual('SUM(Sales), Metric2');
});
it('formats boolean values as string', () => {
const value1 = true;
const value2 = false;
const key = 'b';
const formattedValue1: string | number = formatValueHandler(
value1,
key,
controlsMap,
);
const formattedValue2: string | number = formatValueHandler(
value2,
key,
controlsMap,
);
expect(formattedValue1).toBe('true');
expect(formattedValue2).toBe('false');
});
it('formats array values correctly', () => {
const value = [
{ label: 'Label1' },
{ label: 'Label2' },
5,
6,
7,
8,
'hello',
'goodbye',
];
const expected = 'Label1, Label2, 5, 6, 7, 8, hello, goodbye';
const result: string | number = formatValueHandler(
value,
undefined,
controlsMap,
);
expect(result).toEqual(expected);
});
it('formats string values correctly', () => {
const value = 'test';
const key = 'other_control';
const result: string | number = formatValueHandler(value, key, controlsMap);
expect(result).toEqual('test');
});
it('formats number values correctly', () => {
const value = 123;
const key = 'other_control';
const result: string | number = formatValueHandler(value, key, controlsMap);
expect(result).toEqual(123);
});
it('formats object values correctly', () => {
const value = { 1: 2, alpha: 'bravo' };
const key = 'other_control';
const expected = '{"1":2,"alpha":"bravo"}';
const result: string | number = formatValueHandler(value, key, controlsMap);
expect(result).toEqual(expected);
});
});
describe('getRowsFromDiffs', () => {
it('returns formatted rows for diffs', () => {
const diffs = {
metric: { before: [{ label: 'old' }], after: [{ label: 'new' }] },
limit: { before: 10, after: 20 },
};
const controlsMap = {
metric: { label: 'Metric', type: 'MetricsControl' },
limit: { label: 'Row Limit', type: 'TextControl' },
};
const rows: RowType[] = getRowsFromDiffs(diffs, controlsMap);
expect(rows).toEqual([
{ control: 'Metric', before: 'old', after: 'new' },
{ control: 'Row Limit', before: 10, after: 20 },
]);
});
it('falls back to key if label is missing', () => {
const diffs = {
unknown: { before: 'a', after: 'b' },
};
const controlsMap = {};
const rows: RowType[] = getRowsFromDiffs(diffs, controlsMap);
expect(rows).toEqual([{ control: 'unknown', before: 'a', after: 'b' }]);
});
});

View File

@@ -17,11 +17,13 @@
* under the License.
*/
import * as redux from 'redux';
import { useUnsavedChangesPrompt } from 'src/hooks/useUnsavedChangesPrompt';
import {
render,
screen,
fireEvent,
userEvent,
within,
} from 'spec/helpers/testing-library';
import fetchMock from 'fetch-mock';
import { getExtensionsRegistry, JsonObject } from '@superset-ui/core';
@@ -166,6 +168,10 @@ const onRefresh = jest.fn();
const dashboardInfoChanged = jest.fn();
const dashboardTitleChanged = jest.fn();
jest.mock('src/hooks/useUnsavedChangesPrompt', () => ({
useUnsavedChangesPrompt: jest.fn(),
}));
beforeAll(() => {
jest.spyOn(redux, 'bindActionCreators').mockImplementation(() => ({
addSuccessToast,
@@ -195,9 +201,14 @@ beforeAll(() => {
beforeEach(() => {
jest.clearAllMocks();
});
beforeEach(() => {
(useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
showModal: false,
setShowModal: jest.fn(),
handleConfirmNavigation: jest.fn(),
handleSaveAndCloseModal: jest.fn(),
});
window.history.pushState({}, 'Test page', '/dashboard?standalone=1');
});
@@ -484,3 +495,91 @@ test('should render MetadataBar when not in edit mode and not embedded', () => {
screen.getByText(state.dashboardInfo.changed_on_delta_humanized),
).toBeInTheDocument();
});
test('should show UnsavedChangesModal when there are unsaved changes and user tries to navigate', async () => {
(useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
showModal: true,
setShowModal: jest.fn(),
handleConfirmNavigation: jest.fn(),
handleSaveAndCloseModal: jest.fn(),
});
setup({ ...editableState });
const modalTitle: HTMLElement = await screen.findByText(
'Save changes to your dashboard?',
);
const modalBody: HTMLElement = await screen.findByText(
"If you don't save, changes will be lost.",
);
expect(modalTitle).toBeInTheDocument();
expect(modalBody).toBeInTheDocument();
});
test('should call handleSaveAndCloseModal when Save is clicked in UnsavedChangesModal', async () => {
const handleSaveAndCloseModal = jest.fn();
(useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
showModal: true,
setShowModal: jest.fn(),
handleConfirmNavigation: jest.fn(),
handleSaveAndCloseModal,
});
setup({ ...editableState });
const modal: HTMLElement = await screen.findByRole('dialog');
const saveButton: HTMLElement = within(modal).getByRole('button', {
name: /save/i,
});
userEvent.click(saveButton);
expect(handleSaveAndCloseModal).toHaveBeenCalled();
});
test('should call handleConfirmNavigation when user confirms navigation in UnsavedChangesModal', async () => {
const handleConfirmNavigation = jest.fn();
(useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
showModal: true,
setShowModal: jest.fn(),
handleConfirmNavigation,
handleSaveAndCloseModal: jest.fn(),
});
setup({ ...editableState });
const modal: HTMLElement = await screen.findByRole('dialog');
const discardButton: HTMLElement = within(modal).getByRole('button', {
name: /discard/i,
});
userEvent.click(discardButton);
expect(handleConfirmNavigation).toHaveBeenCalled();
});
test('should call setShowUnsavedChangesModal(false) on cancel', async () => {
const setShowModal = jest.fn();
(useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
showModal: true,
setShowModal,
handleConfirmNavigation: jest.fn(),
handleSaveAndCloseModal: jest.fn(),
});
setup({ ...editableState });
const modal: HTMLElement = await screen.findByRole('dialog');
const closeButton: HTMLElement = within(modal).getByRole('button', {
name: /close/i,
});
userEvent.click(closeButton);
expect(setShowModal).toHaveBeenCalledWith(false);
});

View File

@@ -36,7 +36,12 @@ import {
LOG_ACTIONS_TOGGLE_EDIT_DASHBOARD,
} from 'src/logger/LogUtils';
import { Icons } from '@superset-ui/core/components/Icons';
import { Button, Tooltip, DeleteModal } from '@superset-ui/core/components';
import {
Button,
Tooltip,
DeleteModal,
UnsavedChangesModal,
} from '@superset-ui/core/components';
import { findPermission } from 'src/utils/findPermission';
import { safeStringify } from 'src/utils/safeStringify';
import PublishedStatus from 'src/dashboard/components/PublishedStatus';
@@ -54,6 +59,7 @@ import setPeriodicRunner, {
import ReportModal from 'src/features/reports/ReportModal';
import { deleteActiveReport } from 'src/features/reports/ReportModal/actions';
import { PageHeaderWithActions } from '@superset-ui/core/components/PageHeaderWithActions';
import { useUnsavedChangesPrompt } from 'src/hooks/useUnsavedChangesPrompt';
import DashboardEmbedModal from '../EmbeddedModal';
import OverwriteConfirm from '../OverwriteConfirm';
import {
@@ -460,6 +466,16 @@ const Header = () => {
slug,
]);
const {
showModal: showUnsavedChangesModal,
setShowModal: setShowUnsavedChangesModal,
handleConfirmNavigation,
handleSaveAndCloseModal,
} = useUnsavedChangesPrompt({
hasUnsavedChanges,
onSave: overwriteDashboard,
});
const showPropertiesModal = useCallback(() => {
setShowingPropertiesModal(true);
}, []);
@@ -816,6 +832,15 @@ const Header = () => {
}
`}
/>
<UnsavedChangesModal
title={t('Save changes to your dashboard?')}
body={t("If you don't save, changes will be lost.")}
showModal={showUnsavedChangesModal}
onHide={() => setShowUnsavedChangesModal(false)}
onConfirmNavigation={handleConfirmNavigation}
handleSave={handleSaveAndCloseModal}
/>
</div>
);
};

View File

@@ -305,12 +305,19 @@ test.skip('validates the default value', async () => {
});
test('validates the pre-filter value', async () => {
jest.useFakeTimers();
defaultRender();
userEvent.click(screen.getByText(FILTER_SETTINGS_REGEX));
userEvent.click(getCheckbox(PRE_FILTER_REGEX));
expect(
await screen.findByText(PRE_FILTER_REQUIRED_REGEX),
).toBeInTheDocument();
jest.runOnlyPendingTimers();
jest.useRealTimers();
await waitFor(() => {
expect(screen.getByText(PRE_FILTER_REQUIRED_REGEX)).toBeInTheDocument();
});
});
// eslint-disable-next-line jest/no-disabled-tests

View File

@@ -23,6 +23,7 @@ import {
screen,
userEvent,
waitFor,
within,
} from 'spec/helpers/testing-library';
import fetchMock from 'fetch-mock';
import * as chartAction from 'src/components/Chart/chartAction';
@@ -30,6 +31,7 @@ import * as saveModalActions from 'src/explore/actions/saveModalActions';
import * as downloadAsImage from 'src/utils/downloadAsImage';
import * as exploreUtils from 'src/explore/exploreUtils';
import { FeatureFlag, VizType } from '@superset-ui/core';
import { useUnsavedChangesPrompt } from 'src/hooks/useUnsavedChangesPrompt';
import ExploreHeader from '.';
const chartEndpoint = 'glob:*api/v1/chart/*';
@@ -40,6 +42,10 @@ window.featureFlags = {
[FeatureFlag.EmbeddableCharts]: true,
};
jest.mock('src/hooks/useUnsavedChangesPrompt', () => ({
useUnsavedChangesPrompt: jest.fn(),
}));
const createProps = (additionalProps = {}) => ({
chart: {
id: 1,
@@ -134,6 +140,18 @@ fetchMock.post(
describe('ExploreChartHeader', () => {
jest.setTimeout(15000); // ✅ Applies to all tests in this suite
beforeEach(() => {
jest.clearAllMocks();
(useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
showModal: false,
setShowModal: jest.fn(),
handleConfirmNavigation: jest.fn(),
handleSaveAndCloseModal: jest.fn(),
triggerManualSave: jest.fn(),
});
});
test('Cancelling changes to the properties should reset previous properties', async () => {
const props = createProps();
render(<ExploreHeader {...props} />, { useRedux: true });
@@ -201,35 +219,173 @@ describe('ExploreChartHeader', () => {
});
test('Save chart', async () => {
const setSaveChartModalVisibility = jest.spyOn(
const setSaveChartModalVisibilitySpy = jest.spyOn(
saveModalActions,
'setSaveChartModalVisibility',
);
const setSaveChartModalVisibilityMock =
setSaveChartModalVisibilitySpy as jest.Mock;
const triggerManualSave = jest.fn(() => {
setSaveChartModalVisibilityMock(true);
});
(useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
showModal: false,
setShowModal: jest.fn(),
handleConfirmNavigation: jest.fn(),
handleSaveAndCloseModal: jest.fn(),
triggerManualSave,
});
const props = createProps();
render(<ExploreHeader {...props} />, { useRedux: true });
expect(await screen.findByText('Save')).toBeInTheDocument();
userEvent.click(screen.getByText('Save'));
expect(setSaveChartModalVisibility).toHaveBeenCalledWith(true);
setSaveChartModalVisibility.mockClear();
const saveButton: HTMLElement = await screen.findByRole('button', {
name: /save/i,
});
userEvent.click(saveButton);
expect(triggerManualSave).toHaveBeenCalled();
expect(setSaveChartModalVisibilityMock).toHaveBeenCalledWith(true);
setSaveChartModalVisibilityMock.mockClear();
});
test('Save disabled', async () => {
const setSaveChartModalVisibility = jest.spyOn(
saveModalActions,
'setSaveChartModalVisibility',
);
const triggerManualSave = jest.fn();
(useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
showModal: false,
setShowModal: jest.fn(),
handleConfirmNavigation: jest.fn(),
handleSaveAndCloseModal: jest.fn(),
triggerManualSave,
});
const props = createProps();
render(<ExploreHeader {...props} saveDisabled />, { useRedux: true });
expect(await screen.findByText('Save')).toBeInTheDocument();
userEvent.click(screen.getByText('Save'));
expect(setSaveChartModalVisibility).not.toHaveBeenCalled();
setSaveChartModalVisibility.mockClear();
const saveButton: HTMLElement = await screen.findByRole('button', {
name: /save/i,
});
expect(saveButton).toBeDisabled();
userEvent.click(saveButton);
expect(triggerManualSave).not.toHaveBeenCalled();
});
test('should render UnsavedChangesModal when showModal is true', async () => {
const props = createProps();
(useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
showModal: true,
setShowModal: jest.fn(),
handleConfirmNavigation: jest.fn(),
handleSaveAndCloseModal: jest.fn(),
triggerManualSave: jest.fn(),
});
render(<ExploreHeader {...props} />, { useRedux: true });
expect(await screen.findByRole('dialog')).toBeInTheDocument();
expect(
await screen.findByText('Save changes to your chart?'),
).toBeInTheDocument();
expect(
await screen.findByText("If you don't save, changes will be lost."),
).toBeInTheDocument();
});
test('should call handleSaveAndCloseModal when clicking Save in UnsavedChangesModal', async () => {
const handleSaveAndCloseModal = jest.fn();
(useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
showModal: true,
setShowModal: jest.fn(),
handleConfirmNavigation: jest.fn(),
handleSaveAndCloseModal,
triggerManualSave: jest.fn(),
});
const props = createProps();
render(<ExploreHeader {...props} />, { useRedux: true });
const modal: HTMLElement = await screen.findByRole('dialog');
const saveButton: HTMLElement = within(modal).getByRole('button', {
name: /save/i,
});
userEvent.click(saveButton);
expect(handleSaveAndCloseModal).toHaveBeenCalled();
});
test('should call handleConfirmNavigation when clicking Discard in UnsavedChangesModal', async () => {
const handleConfirmNavigation = jest.fn();
(useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
showModal: true,
setShowModal: jest.fn(),
handleConfirmNavigation,
handleSaveAndCloseModal: jest.fn(),
triggerManualSave: jest.fn(),
});
const props = createProps();
render(<ExploreHeader {...props} />, { useRedux: true });
const modal: HTMLElement = await screen.findByRole('dialog');
const discardButton: HTMLElement = within(modal).getByRole('button', {
name: /discard/i,
});
userEvent.click(discardButton);
expect(handleConfirmNavigation).toHaveBeenCalled();
});
test('should call setShowModal(false) when clicking close button in UnsavedChangesModal', async () => {
const setShowModal = jest.fn();
(useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
showModal: true,
setShowModal,
handleConfirmNavigation: jest.fn(),
handleSaveAndCloseModal: jest.fn(),
triggerManualSave: jest.fn(),
});
const props = createProps();
render(<ExploreHeader {...props} />, { useRedux: true });
const closeButton: HTMLElement = await screen.findByRole('button', {
name: /close/i,
});
userEvent.click(closeButton);
expect(setShowModal).toHaveBeenCalledWith(false);
});
});
describe('Additional actions tests', () => {
jest.setTimeout(15000); // ✅ Applies to all tests in this suite
beforeEach(() => {
(useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
showModal: false,
setShowModal: jest.fn(),
handleConfirmNavigation: jest.fn(),
handleSaveAndCloseModal: jest.fn(),
triggerManualSave: jest.fn(),
});
});
test('Should render a button', async () => {
const props = createProps();
render(<ExploreHeader {...props} />, { useRedux: true });
@@ -356,6 +512,14 @@ describe('Additional actions tests', () => {
beforeEach(() => {
spyDownloadAsImage = sinon.spy(downloadAsImage, 'default');
spyExportChart = sinon.spy(exploreUtils, 'exportChart');
(useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
showModal: false,
setShowModal: jest.fn(),
handleConfirmNavigation: jest.fn(),
handleSaveAndCloseModal: jest.fn(),
triggerManualSave: jest.fn(),
});
});
afterEach(async () => {

View File

@@ -16,11 +16,16 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { useDispatch } from 'react-redux';
import PropTypes from 'prop-types';
import { Tooltip, Button, DeleteModal } from '@superset-ui/core/components';
import {
Tooltip,
Button,
DeleteModal,
UnsavedChangesModal,
} from '@superset-ui/core/components';
import { AlteredSliceTag } from 'src/components';
import { css, logging, SupersetClient, t } from '@superset-ui/core';
import { chartPropShape } from 'src/dashboard/util/propShapes';
@@ -32,6 +37,8 @@ import { setSaveChartModalVisibility } from 'src/explore/actions/saveModalAction
import { applyColors, resetColors } from 'src/utils/colorScheme';
import ReportModal from 'src/features/reports/ReportModal';
import { deleteActiveReport } from 'src/features/reports/ReportModal/actions';
import { useUnsavedChangesPrompt } from 'src/hooks/useUnsavedChangesPrompt';
import { getChartFormDiffs } from 'src/utils/getChartFormDiffs';
import { useExploreAdditionalActionsMenu } from '../useExploreAdditionalActionsMenu';
import { useExploreMetadataBar } from './useExploreMetadataBar';
@@ -50,6 +57,7 @@ const propTypes = {
timeout: PropTypes.number,
chart: chartPropShape,
saveDisabled: PropTypes.bool,
isSaveModalVisible: PropTypes.bool,
};
const saveButtonStyles = theme => css`
@@ -83,13 +91,16 @@ export const ExploreChartHeader = ({
sliceName,
saveDisabled,
metadata,
isSaveModalVisible,
}) => {
const dispatch = useDispatch();
const { latestQueryFormData, sliceFormData } = chart;
const [isPropertiesModalOpen, setIsPropertiesModalOpen] = useState(false);
const [isReportModalOpen, setIsReportModalOpen] = useState(false);
const [currentReportDeleting, setCurrentReportDeleting] = useState(null);
const updateCategoricalNamespace = async () => {
const [shouldForceCloseModal, setShouldForceCloseModal] = useState(false);
const updateCategoricalNamespace = useCallback(async () => {
const { dashboards } = metadata || {};
const dashboard =
dashboardId && dashboards && dashboards.find(d => d.id === dashboardId);
@@ -117,11 +128,11 @@ export const ExploreChartHeader = ({
logging.info(t('Unable to retrieve dashboard colors'));
}
}
};
}, [dashboardColorScheme, dashboardId, metadata]);
useEffect(() => {
updateCategoricalNamespace();
}, []);
}, [updateCategoricalNamespace]);
const openPropertiesModal = () => {
setIsPropertiesModalOpen(true);
@@ -139,10 +150,6 @@ export const ExploreChartHeader = ({
setIsReportModalOpen(false);
};
const showModal = useCallback(() => {
dispatch(setSaveChartModalVisibility(true));
}, [dispatch]);
const updateSlice = useCallback(
slice => {
dispatch(sliceUpdated(slice));
@@ -179,8 +186,53 @@ export const ExploreChartHeader = ({
);
const metadataBar = useExploreMetadataBar(metadata, slice);
const oldSliceName = slice?.slice_name;
const originalFormData = useMemo(() => {
if (!sliceFormData) return {};
return {
...sliceFormData,
chartTitle: oldSliceName,
};
}, [sliceFormData, oldSliceName]);
const currentFormData = useMemo(
() => ({ ...formData, chartTitle: sliceName }),
[formData, sliceName],
);
const formDiffs = useMemo(
() => getChartFormDiffs(originalFormData, currentFormData),
[originalFormData, currentFormData],
);
const {
showModal: showUnsavedChangesModal,
setShowModal: setShowUnsavedChangesModal,
handleConfirmNavigation,
handleSaveAndCloseModal,
triggerManualSave,
} = useUnsavedChangesPrompt({
hasUnsavedChanges: Object.keys(formDiffs).length > 0,
onSave: () => dispatch(setSaveChartModalVisibility(true)),
isSaveModalVisible,
manualSaveOnUnsavedChanges: true,
});
const showModal = useCallback(() => {
triggerManualSave();
}, [triggerManualSave]);
useEffect(() => {
if (!isSaveModalVisible) setShouldForceCloseModal(true);
}, [isSaveModalVisible, setShowUnsavedChangesModal]);
useEffect(() => {
if (!showUnsavedChangesModal && shouldForceCloseModal) {
setShouldForceCloseModal(false);
}
}, [showUnsavedChangesModal, shouldForceCloseModal]);
return (
<>
<PageHeaderWithActions
@@ -212,11 +264,9 @@ export const ExploreChartHeader = ({
{sliceFormData ? (
<AlteredSliceTag
className="altered"
origFormData={{
...sliceFormData,
chartTitle: oldSliceName,
}}
currentFormData={{ ...formData, chartTitle: sliceName }}
diffs={formDiffs}
origFormData={originalFormData}
currentFormData={currentFormData}
/>
) : null}
{metadataBar}
@@ -286,6 +336,15 @@ export const ExploreChartHeader = ({
title={t('Delete Report?')}
/>
)}
<UnsavedChangesModal
title={t('Save changes to your chart?')}
body={t("If you don't save, changes will be lost.")}
showModal={showUnsavedChangesModal}
onHide={() => setShowUnsavedChangesModal(false)}
onConfirmNavigation={handleConfirmNavigation}
handleSave={handleSaveAndCloseModal}
/>
</>
);
};

View File

@@ -593,6 +593,7 @@ function ExploreViewContainer(props) {
reports={props.reports}
saveDisabled={errorMessage || props.chart.chartStatus === 'loading'}
metadata={props.metadata}
isSaveModalVisible={props.isSaveModalVisible}
/>
<ExplorePanelContainer id="explore-container">
<Global

View File

@@ -16,13 +16,83 @@
* specific language governing permissions and limitations
* under the License.
*/
import { sanitizeFormData } from './formData';
import { SupersetClient } from '@superset-ui/core';
import { postFormData, putFormData } from './formData';
test('sanitizeFormData removes temporary control values', () => {
expect(
sanitizeFormData({
url_params: { foo: 'bar' },
metrics: ['foo', 'bar'],
}),
).toEqual({ metrics: ['foo', 'bar'] });
jest.mock('@superset-ui/core', () => ({
SupersetClient: {
post: jest.fn(),
put: jest.fn(),
},
}));
test('postFormData should call SupersetClient.post with correct payload and return key', async () => {
const mockKey = '123abc';
const mockResponse = { json: { key: mockKey } };
(SupersetClient.post as jest.Mock).mockResolvedValue(mockResponse);
const result = await postFormData(
1,
'table',
{ some: 'form', data: true },
42,
'tab-7',
);
expect(SupersetClient.post).toHaveBeenCalledWith({
endpoint: 'api/v1/explore/form_data?tab_id=tab-7',
jsonPayload: {
datasource_id: 1,
datasource_type: 'table',
form_data: JSON.stringify({ some: 'form', data: true }), // assuming sanitizeFormData is a passthrough
chart_id: 42,
},
});
expect(result).toBe(mockKey);
});
test('putFormData should call SupersetClient.put with correct payload and return message', async () => {
const mockMessage = 'Form data updated';
const mockResponse = { json: { message: mockMessage } };
(SupersetClient.put as jest.Mock).mockResolvedValue(mockResponse);
const result = await putFormData(
10,
'druid',
'abc123',
{ another: 'value' },
undefined,
'tab-5',
);
expect(SupersetClient.put).toHaveBeenCalledWith({
endpoint: 'api/v1/explore/form_data/abc123?tab_id=tab-5',
jsonPayload: {
datasource_id: 10,
datasource_type: 'druid',
form_data: JSON.stringify({ another: 'value' }), // again, assuming sanitizeFormData is passthrough
},
});
expect(result).toBe(mockMessage);
});
test('postFormData without optional params should work', async () => {
(SupersetClient.post as jest.Mock).mockResolvedValue({
json: { key: 'abc' },
});
await postFormData(2, 'table', { foo: 'bar' });
expect(SupersetClient.post).toHaveBeenCalledWith({
endpoint: 'api/v1/explore/form_data',
jsonPayload: {
datasource_id: 2,
datasource_type: 'table',
form_data: JSON.stringify({ foo: 'bar' }),
},
});
});

View File

@@ -16,8 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import { omit } from 'lodash';
import { SupersetClient, JsonObject } from '@superset-ui/core';
import { SupersetClient, JsonObject, JsonResponse } from '@superset-ui/core';
import { sanitizeFormData } from 'src/utils/sanitizeFormData';
type Payload = {
datasource_id: number;
@@ -26,19 +26,12 @@ type Payload = {
chart_id?: number;
};
const TEMPORARY_CONTROLS = ['url_params'];
export const sanitizeFormData = (formData: JsonObject): JsonObject =>
omit(formData, TEMPORARY_CONTROLS);
const assembleEndpoint = (key?: string, tabId?: string) => {
let endpoint = 'api/v1/explore/form_data';
if (key) {
endpoint = endpoint.concat(`/${key}`);
}
if (tabId) {
endpoint = endpoint.concat(`?tab_id=${tabId}`);
}
if (key) endpoint = endpoint.concat(`/${key}`);
if (tabId) endpoint = endpoint.concat(`?tab_id=${tabId}`);
return endpoint;
};
@@ -47,15 +40,15 @@ const assemblePayload = (
datasourceType: string,
formData: JsonObject,
chartId?: number,
) => {
): Payload => {
const payload: Payload = {
datasource_id: datasourceId,
datasource_type: datasourceType,
form_data: JSON.stringify(sanitizeFormData(formData)),
};
if (chartId) {
payload.chart_id = chartId;
}
if (chartId) payload.chart_id = chartId;
return payload;
};
@@ -74,7 +67,7 @@ export const postFormData = (
formData,
chartId,
),
}).then(r => r.json.key);
}).then((r: JsonResponse) => r.json.key);
export const putFormData = (
datasourceId: number,
@@ -92,4 +85,4 @@ export const putFormData = (
formData,
chartId,
),
}).then(r => r.json.message);
}).then((r: JsonResponse) => r.json.message);

View File

@@ -0,0 +1,124 @@
/**
* 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 { getClientErrorObject, t } from '@superset-ui/core';
import { useEffect, useRef, useCallback, useState } from 'react';
import { useHistory } from 'react-router-dom';
type UseUnsavedChangesPromptProps = {
hasUnsavedChanges: boolean;
onSave: () => Promise<void> | void;
isSaveModalVisible?: boolean;
manualSaveOnUnsavedChanges?: boolean;
};
export const useUnsavedChangesPrompt = ({
hasUnsavedChanges,
onSave,
isSaveModalVisible = false,
manualSaveOnUnsavedChanges = false,
}: UseUnsavedChangesPromptProps) => {
const history = useHistory();
const [showModal, setShowModal] = useState(false);
const confirmNavigationRef = useRef<(() => void) | null>(null);
const unblockRef = useRef<() => void>(() => {});
const manualSaveRef = useRef(false); // Track if save was user-initiated (not via navigation)
const handleConfirmNavigation = useCallback(() => {
confirmNavigationRef.current?.();
}, []);
const handleSaveAndCloseModal = useCallback(async () => {
try {
if (manualSaveOnUnsavedChanges) manualSaveRef.current = true;
await onSave();
setShowModal(false);
} catch (err) {
const clientError = await getClientErrorObject(err);
throw new Error(
clientError.message ||
clientError.error ||
t('Sorry, an error occurred'),
{ cause: err },
);
}
}, [manualSaveOnUnsavedChanges, onSave]);
const triggerManualSave = useCallback(() => {
manualSaveRef.current = true;
onSave();
}, [onSave]);
const blockCallback = useCallback(
({ pathname }: { pathname: string }) => {
if (manualSaveRef.current) {
manualSaveRef.current = false;
return undefined;
}
confirmNavigationRef.current = () => {
unblockRef.current?.();
history.push(pathname);
};
setShowModal(true);
return false;
},
[history],
);
useEffect(() => {
if (!hasUnsavedChanges) return undefined;
const unblock = history.block(blockCallback);
unblockRef.current = unblock;
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);
manualSaveRef.current = false;
}
}, [isSaveModalVisible]);
return {
showModal,
setShowModal,
handleConfirmNavigation,
handleSaveAndCloseModal,
triggerManualSave,
};
};

View File

@@ -0,0 +1,106 @@
/**
* 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 { useUnsavedChangesPrompt } from 'src/hooks/useUnsavedChangesPrompt';
import { Router } from 'react-router-dom';
import { createMemoryHistory } from 'history';
import { act } from 'spec/helpers/testing-library';
const history = createMemoryHistory({
initialEntries: ['/dashboard'],
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<Router history={history}>{children}</Router>
);
describe('useUnsavedChangesPrompt', () => {
it('should not show modal initially', () => {
const { result } = renderHook(
() =>
useUnsavedChangesPrompt({
hasUnsavedChanges: true,
onSave: jest.fn(),
}),
{ wrapper },
);
expect(result.current.showModal).toBe(false);
});
it('should block navigation and show modal if there are unsaved changes', () => {
const { result } = renderHook(
() =>
useUnsavedChangesPrompt({
hasUnsavedChanges: true,
onSave: jest.fn(),
}),
{ wrapper },
);
// Simulate blocked navigation
act(() => {
const unblock = history.block((tx: any) => tx);
unblock();
history.push('/another-page');
});
expect(result.current.showModal).toBe(true);
});
it('should trigger onSave and hide modal on handleSaveAndCloseModal', async () => {
const onSave = jest.fn().mockResolvedValue(undefined);
const { result } = renderHook(
() =>
useUnsavedChangesPrompt({
hasUnsavedChanges: true,
onSave,
}),
{ wrapper },
);
await act(async () => {
await result.current.handleSaveAndCloseModal();
});
expect(onSave).toHaveBeenCalled();
expect(result.current.showModal).toBe(false);
});
it('should trigger manual save and not show modal again', async () => {
const onSave = jest.fn().mockResolvedValue(undefined);
const { result } = renderHook(
() =>
useUnsavedChangesPrompt({
hasUnsavedChanges: true,
onSave,
}),
{ wrapper },
);
act(() => {
result.current.triggerManualSave();
});
expect(onSave).toHaveBeenCalled();
expect(result.current.showModal).toBe(false);
});
});

View File

@@ -30,9 +30,12 @@ import { LocalStorageKeys } from 'src/utils/localStorageHelpers';
import getFormDataWithExtraFilters from 'src/dashboard/util/charts/getFormDataWithExtraFilters';
import { URL_PARAMS } from 'src/constants';
import { JsonObject, VizType } from '@superset-ui/core';
import { useUnsavedChangesPrompt } from 'src/hooks/useUnsavedChangesPrompt';
import ChartPage from '.';
jest.mock('src/hooks/useUnsavedChangesPrompt', () => ({
useUnsavedChangesPrompt: jest.fn(),
}));
jest.mock('re-resizable', () => ({
Resizable: () => <div data-test="mock-re-resizable" />,
}));
@@ -48,6 +51,17 @@ jest.mock(
jest.mock('src/dashboard/util/charts/getFormDataWithExtraFilters');
describe('ChartPage', () => {
beforeEach(() => {
jest.clearAllMocks();
(useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
showModal: false,
setShowModal: jest.fn(),
handleConfirmNavigation: jest.fn(),
handleSaveAndCloseModal: jest.fn(),
});
});
afterEach(() => {
fetchMock.reset();
});

View File

@@ -0,0 +1,40 @@
/**
* 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.
*/
export type FilterItemType = {
comparator?: string | string[];
subject: string;
operator: string;
label?: string;
};
export type DiffItemType<
T = FilterItemType | number | string | Record<string | number, any>,
> =
| T[]
| boolean
| number
| string
| Record<string | number, any>
| null
| undefined;
export type DiffType = {
before: DiffItemType;
after: DiffItemType;
};

View File

@@ -0,0 +1,115 @@
/**
* 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 { JsonObject } from '@superset-ui/core';
import { alterForComparison, getChartFormDiffs, isEqualish } from '.';
jest.mock('../sanitizeFormData', () => ({
sanitizeFormData: (fd: JsonObject): JsonObject => ({
...fd,
_sanitized: true,
}),
}));
describe('alterForComparison', () => {
it.each([
[null, null],
['', null],
[[], null],
[{}, null],
[
[1, 2],
[1, 2],
],
[{ a: 1 }, { a: 1 }],
['foo', 'foo'],
])('normalizes %p to %p', (input, expected) => {
expect(alterForComparison(input)).toEqual(expected);
});
});
describe('isEqualish', () => {
it('returns true for semantically equal values with different formats', () => {
expect(isEqualish('', null)).toBe(true);
expect(isEqualish([], null)).toBe(true);
expect(isEqualish({}, null)).toBe(true);
expect(isEqualish([1], [1])).toBe(true);
});
it('returns false for clearly different values', () => {
expect(isEqualish([1], [2])).toBe(false);
expect(isEqualish({ a: 1 }, { a: 2 })).toBe(false);
expect(isEqualish('foo', 'bar')).toBe(false);
});
});
describe('getChartFormDiffs', () => {
it('returns diffs for changed values', () => {
const original = { metric: 'count', adhoc_filters: [] };
const current = { metric: 'sum__num', adhoc_filters: [] };
const diffs = getChartFormDiffs(original, current);
expect(diffs).toHaveProperty('metric');
expect(diffs.metric).toEqual({
before: 'count',
after: 'sum__num',
});
});
it('ignores noisy keys', () => {
const original = { where: 'a = 1', metric: 'count' };
const current = { where: 'a = 2', metric: 'sum__num' };
const diffs = getChartFormDiffs(original, current);
expect(diffs).not.toHaveProperty('where');
expect(diffs).toHaveProperty('metric');
});
it('does not include values that are equalish', () => {
const original = { filters: [], metric: 'count' };
const current = { filters: null, metric: 'count' };
const diffs = getChartFormDiffs(original, current);
expect(diffs).toEqual({});
});
it('handles missing keys in original or current gracefully', () => {
const original = { metric: 'count' };
const current = { metric: 'count', new_field: 'value' };
const diffs = getChartFormDiffs(original, current);
expect(diffs).toHaveProperty('new_field');
expect(diffs.new_field).toEqual({
before: undefined,
after: 'value',
});
});
it('ignores keys that are missing in current and not explicitly changed', () => {
const original = { metric: 'count', removed_field: 'gone' };
const current = { metric: 'count' };
const diffs = getChartFormDiffs(original, current);
expect(diffs).not.toHaveProperty('removed_field');
});
});

View File

@@ -0,0 +1,66 @@
/**
* 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 { isEqual } from 'lodash';
import { DiffType } from 'src/types/DiffType';
import { JsonObject } from '@superset-ui/core';
import { sanitizeFormData } from '../sanitizeFormData';
export const noisyKeys = new Set(['filters', 'having', 'where']);
export const alterForComparison = (value: unknown): unknown => {
if (value == null || value === '') return null;
if (Array.isArray(value) && value.length === 0) return null;
if (typeof value === 'object' && value && Object.keys(value).length === 0)
return null;
return value;
};
export const isEqualish = (a: unknown, b: unknown): boolean =>
isEqual(alterForComparison(a), alterForComparison(b));
export const getChartFormDiffs = (
originalFormData: Record<string, unknown>,
currentFormData: Record<string, unknown>,
): Record<string, DiffType> => {
const ofd: JsonObject = sanitizeFormData(originalFormData);
const cfd: JsonObject = sanitizeFormData(currentFormData);
const keys = new Set([...Object.keys(ofd), ...Object.keys(cfd)]);
const diffs: Record<string, DiffType> = {};
keys.forEach((key: string) => {
if (noisyKeys.has(key)) return;
const original = ofd[key];
const current = cfd[key];
const currentHasKey = Object.prototype.hasOwnProperty.call(cfd, key);
const originalHasKey = Object.prototype.hasOwnProperty.call(ofd, key);
const bothExplicit = currentHasKey && originalHasKey;
if (!bothExplicit && !currentHasKey) return;
if (!isEqualish(original, current))
diffs[key] = { before: original, after: current };
});
return diffs;
};

View File

@@ -0,0 +1,25 @@
/**
* 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 { JsonObject } from '@superset-ui/core';
import { omit } from 'lodash';
const TEMPORARY_CONTROLS: string[] = ['url_params'];
export const sanitizeFormData = (formData: JsonObject): JsonObject =>
omit(formData, TEMPORARY_CONTROLS);

View File

@@ -0,0 +1,28 @@
/**
* 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 { sanitizeFormData } from '.';
test('sanitizeFormData removes temporary control values', () => {
expect(
sanitizeFormData({
url_params: { foo: 'bar' },
metrics: ['foo', 'bar'],
}),
).toEqual({ metrics: ['foo', 'bar'] });
});