mirror of
https://github.com/apache/superset.git
synced 2026-07-19 21:25:38 +00:00
feat: Add confirmation modal for unsaved changes (#33809)
This commit is contained in:
committed by
GitHub
parent
050ccdcb3d
commit
057218d87f
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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;
|
||||
|
||||
107
superset-frontend/src/components/AlteredSliceTag/utils/index.ts
Normal file
107
superset-frontend/src/components/AlteredSliceTag/utils/index.ts
Normal 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),
|
||||
}));
|
||||
@@ -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' }]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
124
superset-frontend/src/hooks/useUnsavedChangesPrompt/index.ts
Normal file
124
superset-frontend/src/hooks/useUnsavedChangesPrompt/index.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
40
superset-frontend/src/types/DiffType.ts
Normal file
40
superset-frontend/src/types/DiffType.ts
Normal 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;
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
66
superset-frontend/src/utils/getChartFormDiffs/index.ts
Normal file
66
superset-frontend/src/utils/getChartFormDiffs/index.ts
Normal 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;
|
||||
};
|
||||
25
superset-frontend/src/utils/sanitizeFormData/index.ts
Normal file
25
superset-frontend/src/utils/sanitizeFormData/index.ts
Normal 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);
|
||||
@@ -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'] });
|
||||
});
|
||||
Reference in New Issue
Block a user