This is an automated email from the ASF dual-hosted git repository.
diegopucci pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 920f2f437e refactor: Migrate saveModalActions to TypeScript (#28046)
920f2f437e is described below
commit 920f2f437ea62688d39c76d6596ed02b82e3a6f4
Author: Enzo Martellucci <[email protected]>
AuthorDate: Tue May 14 16:51:44 2024 +0200
refactor: Migrate saveModalActions to TypeScript (#28046)
---
.../src/explore/actions/saveModalActions.js | 259 --------------
...dalActions.test.js => saveModalActions.test.ts} | 379 +++++++++++++++------
.../src/explore/actions/saveModalActions.ts | 321 +++++++++++++++++
3 files changed, 605 insertions(+), 354 deletions(-)
diff --git a/superset-frontend/src/explore/actions/saveModalActions.js
b/superset-frontend/src/explore/actions/saveModalActions.js
deleted file mode 100644
index 9f4ff94778..0000000000
--- a/superset-frontend/src/explore/actions/saveModalActions.js
+++ /dev/null
@@ -1,259 +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 rison from 'rison';
-import { SupersetClient, t } from '@superset-ui/core';
-import { addSuccessToast } from 'src/components/MessageToasts/actions';
-import { isEmpty } from 'lodash';
-import { buildV1ChartDataPayload } from '../exploreUtils';
-import { Operators } from '../constants';
-
-const ADHOC_FILTER_REGEX = /^adhoc_filters/;
-
-export const FETCH_DASHBOARDS_SUCCEEDED = 'FETCH_DASHBOARDS_SUCCEEDED';
-export function fetchDashboardsSucceeded(choices) {
- return { type: FETCH_DASHBOARDS_SUCCEEDED, choices };
-}
-
-export const FETCH_DASHBOARDS_FAILED = 'FETCH_DASHBOARDS_FAILED';
-export function fetchDashboardsFailed(userId) {
- return { type: FETCH_DASHBOARDS_FAILED, userId };
-}
-
-export const SET_SAVE_CHART_MODAL_VISIBILITY =
- 'SET_SAVE_CHART_MODAL_VISIBILITY';
-export function setSaveChartModalVisibility(isVisible) {
- return { type: SET_SAVE_CHART_MODAL_VISIBILITY, isVisible };
-}
-
-export const SAVE_SLICE_FAILED = 'SAVE_SLICE_FAILED';
-export function saveSliceFailed() {
- return { type: SAVE_SLICE_FAILED };
-}
-export const SAVE_SLICE_SUCCESS = 'SAVE_SLICE_SUCCESS';
-export function saveSliceSuccess(data) {
- return { type: SAVE_SLICE_SUCCESS, data };
-}
-
-const extractAdhocFiltersFromFormData = formDataToHandle =>
- Object.entries(formDataToHandle).reduce(
- (acc, [key, value]) =>
- ADHOC_FILTER_REGEX.test(key)
- ? { ...acc, [key]: value?.filter(f => !f.isExtra) }
- : acc,
- {},
- );
-
-const hasTemporalRangeFilter = formData =>
- (formData?.adhoc_filters || []).some(
- filter => filter.operator === Operators.TemporalRange,
- );
-
-export const getSlicePayload = (
- sliceName,
- formDataWithNativeFilters,
- dashboards,
- owners,
- formDataFromSlice = {},
-) => {
- const adhocFilters = extractAdhocFiltersFromFormData(
- formDataWithNativeFilters,
- );
-
- // Retain adhoc_filters from the slice if no adhoc_filters are present
- // after overwriting a chart. This ensures the dashboard can continue
- // to filter the chart. Before, any time range filter applied in the
dashboard
- // would end up as an extra filter and when overwriting the chart the
original
- // time range adhoc_filter was lost
- if (!isEmpty(formDataFromSlice)) {
- Object.keys(adhocFilters || {}).forEach(adhocFilterKey => {
- if (isEmpty(adhocFilters[adhocFilterKey])) {
- formDataFromSlice?.[adhocFilterKey]?.forEach(filter => {
- if (filter.operator === Operators.TemporalRange && !filter.isExtra) {
- adhocFilters[adhocFilterKey].push({
- ...filter,
- comparator: 'No filter',
- });
- }
- });
- }
- });
- }
-
- // This loop iterates through the adhoc_filters array in
formDataWithNativeFilters.
- // If a filter is of type TEMPORAL_RANGE and isExtra, it sets its comparator
to
- // 'No filter' and adds the modified filter to the adhocFilters array. This
ensures that all
- // TEMPORAL_RANGE filters are converted to 'No filter' when saving a chart.
- if (!hasTemporalRangeFilter(adhocFilters)) {
- formDataWithNativeFilters?.adhoc_filters?.forEach(filter => {
- if (filter.operator === Operators.TemporalRange && filter.isExtra) {
- adhocFilters.adhoc_filters.push({ ...filter, comparator: 'No filter'
});
- }
- });
- }
-
- const formData = {
- ...formDataWithNativeFilters,
- ...adhocFilters,
- dashboards,
- };
-
- const [datasourceId, datasourceType] = formData.datasource.split('__');
- const payload = {
- params: JSON.stringify(formData),
- slice_name: sliceName,
- viz_type: formData.viz_type,
- datasource_id: parseInt(datasourceId, 10),
- datasource_type: datasourceType,
- dashboards,
- owners,
- query_context: JSON.stringify(
- buildV1ChartDataPayload({
- formData,
- force: false,
- resultFormat: 'json',
- resultType: 'full',
- setDataMask: null,
- ownState: null,
- }),
- ),
- };
- return payload;
-};
-
-const addToasts = (isNewSlice, sliceName, addedToDashboard) => {
- const toasts = [];
- if (isNewSlice) {
- toasts.push(addSuccessToast(t('Chart [%s] has been saved', sliceName)));
- } else {
- toasts.push(
- addSuccessToast(t('Chart [%s] has been overwritten', sliceName)),
- );
- }
-
- if (addedToDashboard) {
- if (addedToDashboard.new) {
- toasts.push(
- addSuccessToast(
- t(
- 'Dashboard [%s] just got created and chart [%s] was added to it',
- addedToDashboard.title,
- sliceName,
- ),
- ),
- );
- } else {
- toasts.push(
- addSuccessToast(
- t(
- 'Chart [%s] was added to dashboard [%s]',
- sliceName,
- addedToDashboard.title,
- ),
- ),
- );
- }
- }
-
- return toasts;
-};
-
-// Update existing slice
-export const updateSlice =
- (slice, sliceName, dashboards, addedToDashboard) =>
- async (dispatch, getState) => {
- const { slice_id: sliceId, owners, form_data: formDataFromSlice } = slice;
- const {
- explore: {
- form_data: { url_params: _, ...formData },
- },
- } = getState();
- try {
- const response = await SupersetClient.put({
- endpoint: `/api/v1/chart/${sliceId}`,
- jsonPayload: getSlicePayload(
- sliceName,
- formData,
- dashboards,
- owners,
- formDataFromSlice,
- ),
- });
-
- dispatch(saveSliceSuccess());
- addToasts(false, sliceName, addedToDashboard).map(dispatch);
- return response.json;
- } catch (error) {
- dispatch(saveSliceFailed());
- throw error;
- }
- };
-
-// Create new slice
-export const createSlice =
- (sliceName, dashboards, addedToDashboard) => async (dispatch, getState) => {
- const {
- explore: {
- form_data: { url_params: _, ...formData },
- },
- } = getState();
- try {
- const response = await SupersetClient.post({
- endpoint: `/api/v1/chart/`,
- jsonPayload: getSlicePayload(sliceName, formData, dashboards),
- });
-
- dispatch(saveSliceSuccess());
- addToasts(true, sliceName, addedToDashboard).map(dispatch);
- return response.json;
- } catch (error) {
- dispatch(saveSliceFailed());
- throw error;
- }
- };
-
-// Create new dashboard
-export const createDashboard = dashboardName => async dispatch => {
- try {
- const response = await SupersetClient.post({
- endpoint: `/api/v1/dashboard/`,
- jsonPayload: { dashboard_title: dashboardName },
- });
-
- return response.json;
- } catch (error) {
- dispatch(saveSliceFailed());
- throw error;
- }
-};
-
-// Get dashboards the slice is added to
-export const getSliceDashboards = slice => async dispatch => {
- try {
- const response = await SupersetClient.get({
- endpoint: `/api/v1/chart/${slice.slice_id}?q=${rison.encode({
- columns: ['dashboards.id'],
- })}`,
- });
-
- return response.json.result.dashboards.map(({ id }) => id);
- } catch (error) {
- dispatch(saveSliceFailed());
- throw error;
- }
-};
diff --git a/superset-frontend/src/explore/actions/saveModalActions.test.js
b/superset-frontend/src/explore/actions/saveModalActions.test.ts
similarity index 55%
rename from superset-frontend/src/explore/actions/saveModalActions.test.js
rename to superset-frontend/src/explore/actions/saveModalActions.test.ts
index fc50e3d3cf..2e7be05874 100644
--- a/superset-frontend/src/explore/actions/saveModalActions.test.js
+++ b/superset-frontend/src/explore/actions/saveModalActions.test.ts
@@ -16,10 +16,11 @@
* specific language governing permissions and limitations
* under the License.
*/
-
import sinon from 'sinon';
import fetchMock from 'fetch-mock';
+import { Dispatch } from 'redux';
import { ADD_TOAST } from 'src/components/MessageToasts/actions';
+import { DatasourceType } from '@superset-ui/core';
import {
createDashboard,
createSlice,
@@ -28,24 +29,62 @@ import {
SAVE_SLICE_SUCCESS,
updateSlice,
getSlicePayload,
+ PayloadSlice,
+ QueryFormData,
} from './saveModalActions';
+// Define test constants and mock data using imported types
const sliceId = 10;
const sliceName = 'New chart';
const vizType = 'sample_viz_type';
-const datasourceId = 11;
-const datasourceType = 'sample_datasource_type';
+const datasourceId = 22;
+const datasourceType = DatasourceType.Table;
const dashboards = [12, 13];
const queryContext = { sampleKey: 'sampleValue' };
-const formData = {
+const owners = [0];
+
+const formData: Partial<QueryFormData> = {
viz_type: vizType,
datasource: `${datasourceId}__${datasourceType}`,
dashboards,
};
-const mockExploreState = { explore: { form_data: formData } };
-const sliceResponsePayload = {
- id: 10,
+const mockExploreState: Partial<QueryFormData> = {
+ explore: {
+ can_add: false,
+ can_download: false,
+ can_overwrite: false,
+ isDatasourceMetaLoading: false,
+ isStarred: false,
+ triggerRender: false,
+ datasource: `${datasourceId}__${datasourceType}`,
+ verbose_map: { '': '' },
+ main_dttm_col: '',
+ datasource_name: null,
+ description: null,
+ },
+ controls: {},
+ form_data: {
+ datasource: `${datasourceId}__${datasourceType}`,
+ viz_type: '',
+ },
+ slice: {
+ slice_id: 0,
+ slice_name: '',
+ description: null,
+ cache_timeout: null,
+ is_managed_externally: false,
+ },
+ controlsTransferred: [],
+ standalone: false,
+ force: false,
+ common: {},
+};
+
+const sliceResponsePayload: Partial<PayloadSlice> = {
+ slice_id: sliceId,
+ owners: [],
+ form_data: formData,
};
const sampleError = new Error('sampleError');
@@ -57,66 +96,124 @@ jest.mock('../exploreUtils', () => ({
/**
* Tests updateSlice action
*/
-
const updateSliceEndpoint = `glob:*/api/v1/chart/${sliceId}`;
test('updateSlice handles success', async () => {
fetchMock.reset();
fetchMock.put(updateSliceEndpoint, sliceResponsePayload);
- const dispatch = sinon.spy();
- const getState = sinon.spy(() => mockExploreState);
+ const dispatchSpy = sinon.spy();
+ const dispatch = (action: any) => {
+ dispatchSpy(action);
+ };
+ const getState = () => mockExploreState;
+
const slice = await updateSlice(
- { slice_id: sliceId },
+ {
+ slice_id: sliceId,
+ owners: owners as [],
+ form_data: formData,
+ slice_name: '',
+ description: '',
+ description_markdown: '',
+ slice_url: '',
+ viz_type: '',
+ thumbnail_url: '',
+ changed_on: 0,
+ changed_on_humanized: '',
+ modified: '',
+ datasource_id: 0,
+ datasource_type: datasourceType,
+ datasource_url: '',
+ datasource_name: '',
+ created_by: {
+ id: 0,
+ },
+ },
sliceName,
[],
- )(dispatch, getState);
-
+ )(dispatch as Dispatch<any>, getState);
expect(fetchMock.calls(updateSliceEndpoint)).toHaveLength(1);
- expect(dispatch.callCount).toBe(2);
- expect(dispatch.getCall(0).args[0].type).toBe(SAVE_SLICE_SUCCESS);
- expect(dispatch.getCall(1).args[0].type).toBe(ADD_TOAST);
- expect(dispatch.getCall(1).args[0].payload.toastType).toBe('SUCCESS_TOAST');
- expect(dispatch.getCall(1).args[0].payload.text).toBe(
+ expect(dispatchSpy.callCount).toBe(2);
+ expect(dispatchSpy.getCall(0).args[0].type).toBe(SAVE_SLICE_SUCCESS);
+ expect(dispatchSpy.getCall(1).args[0].type).toBe('ADD_TOAST');
+ expect(dispatchSpy.getCall(1).args[0].payload.toastType).toBe(
+ 'SUCCESS_TOAST',
+ );
+ expect(dispatchSpy.getCall(1).args[0].payload.text).toBe(
'Chart [New chart] has been overwritten',
);
-
expect(slice).toEqual(sliceResponsePayload);
});
test('updateSlice handles failure', async () => {
fetchMock.reset();
fetchMock.put(updateSliceEndpoint, { throws: sampleError });
- const dispatch = sinon.spy();
- const getState = sinon.spy(() => mockExploreState);
+
+ const dispatchSpy = sinon.spy();
+ const dispatch = (action: any) => {
+ dispatchSpy(action);
+ };
+
+ const getState = () => mockExploreState;
+
let caughtError;
try {
- await updateSlice({ slice_id: sliceId }, sliceName, [])(dispatch,
getState);
+ await updateSlice(
+ {
+ slice_id: sliceId,
+ owners: [],
+ form_data: formData,
+ slice_name: '',
+ description: '',
+ description_markdown: '',
+ slice_url: '',
+ viz_type: '',
+ thumbnail_url: '',
+ changed_on: 0,
+ changed_on_humanized: '',
+ modified: '',
+ datasource_id: 0,
+ datasource_type: datasourceType,
+ datasource_url: '',
+ datasource_name: '',
+ created_by: {
+ id: 0,
+ },
+ },
+ sliceName,
+ [],
+ )(dispatch as Dispatch<any>, getState);
} catch (error) {
caughtError = error;
}
expect(caughtError).toEqual(sampleError);
expect(fetchMock.calls(updateSliceEndpoint)).toHaveLength(4);
- expect(dispatch.callCount).toBe(1);
- expect(dispatch.getCall(0).args[0].type).toBe(SAVE_SLICE_FAILED);
+ expect(dispatchSpy.callCount).toBe(1);
+ expect(dispatchSpy.getCall(0).args[0].type).toBe(SAVE_SLICE_FAILED);
});
/**
* Tests createSlice action
*/
-
const createSliceEndpoint = `glob:*/api/v1/chart/`;
test('createSlice handles success', async () => {
fetchMock.reset();
fetchMock.post(createSliceEndpoint, sliceResponsePayload);
- const dispatch = sinon.spy();
- const getState = sinon.spy(() => mockExploreState);
- const slice = await createSlice(sliceName, [])(dispatch, getState);
+ const dispatchSpy = sinon.spy();
+ const dispatch = (action: any) => dispatchSpy(action);
+ const getState = () => mockExploreState;
+ const slice: Partial<PayloadSlice> = await createSlice(sliceName, [])(
+ dispatch as Dispatch,
+ getState,
+ );
expect(fetchMock.calls(createSliceEndpoint)).toHaveLength(1);
- expect(dispatch.callCount).toBe(2);
- expect(dispatch.getCall(0).args[0].type).toBe(SAVE_SLICE_SUCCESS);
- expect(dispatch.getCall(1).args[0].type).toBe(ADD_TOAST);
- expect(dispatch.getCall(1).args[0].payload.toastType).toBe('SUCCESS_TOAST');
- expect(dispatch.getCall(1).args[0].payload.text).toBe(
+ expect(dispatchSpy.callCount).toBe(2);
+ expect(dispatchSpy.getCall(0).args[0].type).toBe(SAVE_SLICE_SUCCESS);
+ expect(dispatchSpy.getCall(1).args[0].type).toBe(ADD_TOAST);
+ expect(dispatchSpy.getCall(1).args[0].payload.toastType).toBe(
+ 'SUCCESS_TOAST',
+ );
+ expect(dispatchSpy.getCall(1).args[0].payload.text).toBe(
'Chart [New chart] has been saved',
);
@@ -126,19 +223,22 @@ test('createSlice handles success', async () => {
test('createSlice handles failure', async () => {
fetchMock.reset();
fetchMock.post(createSliceEndpoint, { throws: sampleError });
- const dispatch = sinon.spy();
- const getState = sinon.spy(() => mockExploreState);
- let caughtError;
+
+ const dispatchSpy = sinon.spy();
+ const dispatch = (action: any) => dispatchSpy(action);
+ const getState = () => mockExploreState;
+
+ let caughtError: Error | undefined;
try {
- await createSlice(sliceName, [])(dispatch, getState);
+ await createSlice(sliceName, [])(dispatch as Dispatch, getState);
} catch (error) {
caughtError = error;
}
expect(caughtError).toEqual(sampleError);
expect(fetchMock.calls(createSliceEndpoint)).toHaveLength(4);
- expect(dispatch.callCount).toBe(1);
- expect(dispatch.getCall(0).args[0].type).toBe(SAVE_SLICE_FAILED);
+ expect(dispatchSpy.callCount).toBe(1);
+ expect(dispatchSpy.getCall(0).args[0].type).toBe(SAVE_SLICE_FAILED);
});
const dashboardName = 'New dashboard';
@@ -155,7 +255,9 @@ test('createDashboard handles success', async () => {
fetchMock.reset();
fetchMock.post(createDashboardEndpoint, dashboardResponsePayload);
const dispatch = sinon.spy();
- const dashboard = await createDashboard(dashboardName)(dispatch);
+ const dashboard = await createDashboard(dashboardName)(
+ dispatch as Dispatch<any>,
+ );
expect(fetchMock.calls(createDashboardEndpoint)).toHaveLength(1);
expect(dispatch.callCount).toBe(0);
expect(dashboard).toEqual(dashboardResponsePayload);
@@ -167,7 +269,7 @@ test('createDashboard handles failure', async () => {
const dispatch = sinon.spy();
let caughtError;
try {
- await createDashboard(dashboardName)(dispatch);
+ await createDashboard(dashboardName)(dispatch as Dispatch<any>);
} catch (error) {
caughtError = error;
}
@@ -181,24 +283,60 @@ test('createDashboard handles failure', async () => {
test('updateSlice with add to new dashboard handles success', async () => {
fetchMock.reset();
fetchMock.put(updateSliceEndpoint, sliceResponsePayload);
- const dispatch = sinon.spy();
- const getState = sinon.spy(() => mockExploreState);
- const slice = await updateSlice({ slice_id: sliceId }, sliceName, [], {
- new: true,
- title: dashboardName,
- })(dispatch, getState);
+ const dispatchSpy = sinon.spy();
+ const dispatch = (action: any) => dispatchSpy(action);
+ const getState = () => mockExploreState;
+
+ const slice = await updateSlice(
+ {
+ slice_id: sliceId,
+ owners: [],
+ form_data: {
+ datasource: `${datasourceId}__${datasourceType}`,
+ viz_type: '',
+ adhoc_filters: [],
+ dashboards: [],
+ },
+ slice_name: '',
+ description: '',
+ description_markdown: '',
+ slice_url: '',
+ viz_type: '',
+ thumbnail_url: '',
+ changed_on: 0,
+ changed_on_humanized: '',
+ modified: '',
+ datasource_id: 0,
+ datasource_type: datasourceType,
+ datasource_url: '',
+ datasource_name: '',
+ created_by: {
+ id: 0,
+ },
+ },
+ sliceName,
+ [],
+ {
+ new: true,
+ title: dashboardName,
+ },
+ )(dispatch as Dispatch<any>, getState);
expect(fetchMock.calls(updateSliceEndpoint)).toHaveLength(1);
- expect(dispatch.callCount).toBe(3);
- expect(dispatch.getCall(0).args[0].type).toBe(SAVE_SLICE_SUCCESS);
- expect(dispatch.getCall(1).args[0].type).toBe(ADD_TOAST);
- expect(dispatch.getCall(1).args[0].payload.toastType).toBe('SUCCESS_TOAST');
- expect(dispatch.getCall(1).args[0].payload.text).toBe(
+ expect(dispatchSpy.callCount).toBe(3);
+ expect(dispatchSpy.getCall(0).args[0].type).toBe(SAVE_SLICE_SUCCESS);
+ expect(dispatchSpy.getCall(1).args[0].type).toBe(ADD_TOAST);
+ expect(dispatchSpy.getCall(1).args[0].payload.toastType).toBe(
+ 'SUCCESS_TOAST',
+ );
+ expect(dispatchSpy.getCall(1).args[0].payload.text).toBe(
'Chart [New chart] has been overwritten',
);
- expect(dispatch.getCall(2).args[0].type).toBe(ADD_TOAST);
- expect(dispatch.getCall(2).args[0].payload.toastType).toBe('SUCCESS_TOAST');
- expect(dispatch.getCall(2).args[0].payload.text).toBe(
+ expect(dispatchSpy.getCall(2).args[0].type).toBe(ADD_TOAST);
+ expect(dispatchSpy.getCall(2).args[0].payload.toastType).toBe(
+ 'SUCCESS_TOAST',
+ );
+ expect(dispatchSpy.getCall(2).args[0].payload.text).toBe(
'Dashboard [New dashboard] just got created and chart [New chart] was
added to it',
);
@@ -208,39 +346,71 @@ test('updateSlice with add to new dashboard handles
success', async () => {
test('updateSlice with add to existing dashboard handles success', async () =>
{
fetchMock.reset();
fetchMock.put(updateSliceEndpoint, sliceResponsePayload);
- const dispatch = sinon.spy();
- const getState = sinon.spy(() => mockExploreState);
- const slice = await updateSlice({ slice_id: sliceId }, sliceName, [], {
- new: false,
- title: dashboardName,
- })(dispatch, getState);
+ const dispatchSpy = sinon.spy();
+ const dispatch = (action: any) => dispatchSpy(action);
+ const getState = () => mockExploreState;
+ const slice = await updateSlice(
+ {
+ slice_id: sliceId,
+ owners: [],
+ form_data: {
+ datasource: `${datasourceId}__${datasourceType}`,
+ viz_type: '',
+ adhoc_filters: [],
+ dashboards: [],
+ },
+ slice_name: '',
+ description: '',
+ description_markdown: '',
+ slice_url: '',
+ viz_type: '',
+ thumbnail_url: '',
+ changed_on: 0,
+ changed_on_humanized: '',
+ modified: '',
+ datasource_id: 0,
+ datasource_type: datasourceType,
+ datasource_url: '',
+ datasource_name: '',
+ created_by: {
+ id: 0,
+ },
+ },
+ sliceName,
+ [],
+ {
+ new: false,
+ title: dashboardName,
+ },
+ )(dispatch as Dispatch<any>, getState);
expect(fetchMock.calls(updateSliceEndpoint)).toHaveLength(1);
- expect(dispatch.callCount).toBe(3);
- expect(dispatch.getCall(0).args[0].type).toBe(SAVE_SLICE_SUCCESS);
- expect(dispatch.getCall(1).args[0].type).toBe(ADD_TOAST);
- expect(dispatch.getCall(1).args[0].payload.toastType).toBe('SUCCESS_TOAST');
- expect(dispatch.getCall(1).args[0].payload.text).toBe(
+ expect(dispatchSpy.callCount).toBe(3);
+ expect(dispatchSpy.getCall(0).args[0].type).toBe(SAVE_SLICE_SUCCESS);
+ expect(dispatchSpy.getCall(1).args[0].type).toBe(ADD_TOAST);
+ expect(dispatchSpy.getCall(1).args[0].payload.toastType).toBe(
+ 'SUCCESS_TOAST',
+ );
+ expect(dispatchSpy.getCall(1).args[0].payload.text).toBe(
'Chart [New chart] has been overwritten',
);
- expect(dispatch.getCall(2).args[0].type).toBe(ADD_TOAST);
- expect(dispatch.getCall(2).args[0].payload.toastType).toBe('SUCCESS_TOAST');
- expect(dispatch.getCall(2).args[0].payload.text).toBe(
+ expect(dispatchSpy.getCall(2).args[0].type).toBe(ADD_TOAST);
+ expect(dispatchSpy.getCall(2).args[0].payload.toastType).toBe(
+ 'SUCCESS_TOAST',
+ );
+ expect(dispatchSpy.getCall(2).args[0].payload.text).toBe(
'Chart [New chart] was added to dashboard [New dashboard]',
);
expect(slice).toEqual(sliceResponsePayload);
});
-const slice = { slice_id: 10 };
const dashboardSlicesResponsePayload = {
result: {
dashboards: [{ id: 21 }, { id: 22 }, { id: 23 }],
},
};
-
const getDashboardSlicesReturnValue = [21, 22, 23];
-
/**
* Tests getSliceDashboards action
*/
@@ -249,10 +419,20 @@ const getSliceDashboardsEndpoint =
`glob:*/api/v1/chart/${sliceId}?q=(columns:!(
test('getSliceDashboards with slice handles success', async () => {
fetchMock.reset();
fetchMock.get(getSliceDashboardsEndpoint, dashboardSlicesResponsePayload);
- const dispatch = sinon.spy();
- const sliceDashboards = await getSliceDashboards(slice)(dispatch);
+ const dispatchSpy = sinon.spy();
+ const dispatch = (action: any) => dispatchSpy(action);
+ const sliceDashboards = await getSliceDashboards({
+ slice_id: 10,
+ owners: [],
+ form_data: {
+ datasource: `${datasourceId}__${datasourceType}`,
+ viz_type: '',
+ adhoc_filters: [],
+ dashboards: [],
+ },
+ })(dispatch as Dispatch<any>);
expect(fetchMock.calls(getSliceDashboardsEndpoint)).toHaveLength(1);
- expect(dispatch.callCount).toBe(0);
+ expect(dispatchSpy.callCount).toBe(0);
expect(sliceDashboards).toEqual(getDashboardSlicesReturnValue);
});
@@ -262,7 +442,16 @@ test('getSliceDashboards with slice handles failure',
async () => {
const dispatch = sinon.spy();
let caughtError;
try {
- await getSliceDashboards(slice)(dispatch);
+ await getSliceDashboards({
+ slice_id: sliceId,
+ owners: [],
+ form_data: {
+ datasource: `${datasourceId}__${datasourceType}`,
+ viz_type: '',
+ adhoc_filters: [],
+ dashboards: [],
+ },
+ })(dispatch as Dispatch<any>);
} catch (error) {
caughtError = error;
}
@@ -276,14 +465,14 @@ test('getSliceDashboards with slice handles failure',
async () => {
describe('getSlicePayload', () => {
const sliceName = 'Test Slice';
const formDataWithNativeFilters = {
- datasource: '22__table',
+ datasource: `${datasourceId}__${datasourceType}`,
viz_type: 'pie',
adhoc_filters: [],
};
const dashboards = [5];
- const owners = [1];
- const formDataFromSlice = {
- datasource: '22__table',
+ const owners = [0];
+ const formDataFromSlice: QueryFormData = {
+ datasource: `${datasourceId}__${datasourceType}`,
viz_type: 'pie',
adhoc_filters: [
{
@@ -294,6 +483,7 @@ describe('getSlicePayload', () => {
expressionType: 'SIMPLE',
},
],
+ dashboards: [],
};
test('should return the correct payload when no adhoc_filters are present in
formDataWithNativeFilters', () => {
@@ -301,7 +491,7 @@ describe('getSlicePayload', () => {
sliceName,
formDataWithNativeFilters,
dashboards,
- owners,
+ owners as [],
formDataFromSlice,
);
expect(result).toHaveProperty('params');
@@ -315,13 +505,13 @@ describe('getSlicePayload', () => {
expect(result).toHaveProperty('dashboards', dashboards);
expect(result).toHaveProperty('owners', owners);
expect(result).toHaveProperty('query_context');
- expect(JSON.parse(result.params).adhoc_filters).toEqual(
- formDataFromSlice.adhoc_filters,
+ expect(JSON.parse(result.params as string).adhoc_filters).toEqual(
+ formDataWithNativeFilters.adhoc_filters,
);
});
test('should return the correct payload when adhoc_filters are present in
formDataWithNativeFilters', () => {
- const formDataWithAdhocFilters = {
+ const formDataWithAdhocFilters: QueryFormData = {
...formDataWithNativeFilters,
adhoc_filters: [
{
@@ -337,7 +527,7 @@ describe('getSlicePayload', () => {
sliceName,
formDataWithAdhocFilters,
dashboards,
- owners,
+ owners as [],
formDataFromSlice,
);
expect(result).toHaveProperty('params');
@@ -351,13 +541,13 @@ describe('getSlicePayload', () => {
expect(result).toHaveProperty('dashboards', dashboards);
expect(result).toHaveProperty('owners', owners);
expect(result).toHaveProperty('query_context');
- expect(JSON.parse(result.params).adhoc_filters).toEqual(
+ expect(JSON.parse(result.params as string).adhoc_filters).toEqual(
formDataWithAdhocFilters.adhoc_filters,
);
});
test('should return the correct payload when formDataWithNativeFilters has a
filter with isExtra set to true', () => {
- const formDataWithAdhocFiltersWithExtra = {
+ const formDataWithAdhocFiltersWithExtra: QueryFormData = {
...formDataWithNativeFilters,
adhoc_filters: [
{
@@ -373,7 +563,7 @@ describe('getSlicePayload', () => {
sliceName,
formDataWithAdhocFiltersWithExtra,
dashboards,
- owners,
+ owners as [],
formDataFromSlice,
);
expect(result).toHaveProperty('params');
@@ -387,13 +577,13 @@ describe('getSlicePayload', () => {
expect(result).toHaveProperty('dashboards', dashboards);
expect(result).toHaveProperty('owners', owners);
expect(result).toHaveProperty('query_context');
- expect(JSON.parse(result.params).adhoc_filters).toEqual(
+ expect(JSON.parse(result.params as string).adhoc_filters).toEqual(
formDataFromSlice.adhoc_filters,
);
});
test('should return the correct payload when formDataWithNativeFilters has a
filter with isExtra set to true in mixed chart', () => {
- const formDataFromSliceWithAdhocFilterB = {
+ const formDataFromSliceWithAdhocFilterB: QueryFormData = {
...formDataFromSlice,
adhoc_filters_b: [
{
@@ -405,7 +595,7 @@ describe('getSlicePayload', () => {
},
],
};
- const formDataWithAdhocFiltersWithExtra = {
+ const formDataWithAdhocFiltersWithExtra: QueryFormData = {
...formDataWithNativeFilters,
viz_type: 'mixed_timeseries',
adhoc_filters: [
@@ -433,14 +623,13 @@ describe('getSlicePayload', () => {
sliceName,
formDataWithAdhocFiltersWithExtra,
dashboards,
- owners,
+ owners as [],
formDataFromSliceWithAdhocFilterB,
);
-
- expect(JSON.parse(result.params).adhoc_filters).toEqual(
+ expect(JSON.parse(result.params as string).adhoc_filters).toEqual(
formDataFromSliceWithAdhocFilterB.adhoc_filters,
);
- expect(JSON.parse(result.params).adhoc_filters_b).toEqual(
+ expect(JSON.parse(result.params as string).adhoc_filters).toEqual(
formDataFromSliceWithAdhocFilterB.adhoc_filters_b,
);
});
diff --git a/superset-frontend/src/explore/actions/saveModalActions.ts
b/superset-frontend/src/explore/actions/saveModalActions.ts
new file mode 100644
index 0000000000..549dc92c58
--- /dev/null
+++ b/superset-frontend/src/explore/actions/saveModalActions.ts
@@ -0,0 +1,321 @@
+/**
+ * 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 rison from 'rison';
+import { Dispatch } from 'redux';
+import {
+ DatasourceType,
+ QueryFormData,
+ SimpleAdhocFilter,
+ SupersetClient,
+ t,
+} from '@superset-ui/core';
+import { addSuccessToast } from 'src/components/MessageToasts/actions';
+import { isEmpty } from 'lodash';
+import { Slice } from 'src/dashboard/types';
+import { Operators } from '../constants';
+import { buildV1ChartDataPayload } from '../exploreUtils';
+
+export interface PayloadSlice extends Slice {
+ params: string;
+ dashboards: number[];
+ query_context: string;
+}
+const ADHOC_FILTER_REGEX = /^adhoc_filters/;
+
+export const FETCH_DASHBOARDS_SUCCEEDED = 'FETCH_DASHBOARDS_SUCCEEDED';
+export function fetchDashboardsSucceeded(choices: string[]) {
+ return { type: FETCH_DASHBOARDS_SUCCEEDED, choices };
+}
+
+export const FETCH_DASHBOARDS_FAILED = 'FETCH_DASHBOARDS_FAILED';
+export function fetchDashboardsFailed(userId: string) {
+ return { type: FETCH_DASHBOARDS_FAILED, userId };
+}
+
+export const SET_SAVE_CHART_MODAL_VISIBILITY =
+ 'SET_SAVE_CHART_MODAL_VISIBILITY';
+export function setSaveChartModalVisibility(isVisible: boolean) {
+ return { type: SET_SAVE_CHART_MODAL_VISIBILITY, isVisible };
+}
+
+export const SAVE_SLICE_FAILED = 'SAVE_SLICE_FAILED';
+export function saveSliceFailed() {
+ return { type: SAVE_SLICE_FAILED };
+}
+
+export const SAVE_SLICE_SUCCESS = 'SAVE_SLICE_SUCCESS';
+export function saveSliceSuccess(data: Partial<QueryFormData>) {
+ return { type: SAVE_SLICE_SUCCESS, data };
+}
+
+function extractAdhocFiltersFromFormData(
+ formDataToHandle: QueryFormData,
+): Partial<QueryFormData> {
+ const result: Partial<QueryFormData> = {};
+ Object.entries(formDataToHandle).forEach(([key, value]) => {
+ if (ADHOC_FILTER_REGEX.test(key) && Array.isArray(value)) {
+ result[key] = (value as SimpleAdhocFilter[]).filter(
+ (f: SimpleAdhocFilter) => !f.isExtra,
+ );
+ }
+ });
+ return result;
+}
+
+const hasTemporalRangeFilter = (formData: Partial<QueryFormData>): boolean =>
+ (formData?.adhoc_filters || []).some(
+ (filter: SimpleAdhocFilter) => filter.operator === Operators.TemporalRange,
+ );
+
+export const getSlicePayload = (
+ sliceName: string,
+ formDataWithNativeFilters: QueryFormData = {} as QueryFormData,
+ dashboards: number[],
+ owners: [],
+ formDataFromSlice: QueryFormData = {} as QueryFormData,
+): Partial<PayloadSlice> => {
+ const adhocFilters: Partial<QueryFormData> = extractAdhocFiltersFromFormData(
+ formDataWithNativeFilters,
+ );
+
+ if (
+ !isEmpty(formDataFromSlice) &&
+ formDataWithNativeFilters.adhoc_filters &&
+ formDataWithNativeFilters.adhoc_filters.length > 0
+ ) {
+ Object.keys(adhocFilters).forEach(adhocFilterKey => {
+ if (isEmpty(adhocFilters[adhocFilterKey])) {
+ const sourceFilters = formDataFromSlice[adhocFilterKey];
+ if (Array.isArray(sourceFilters)) {
+ const targetArray = adhocFilters[adhocFilterKey] || [];
+ sourceFilters.forEach(filter => {
+ if (filter.operator === Operators.TemporalRange) {
+ targetArray.push({
+ ...filter,
+ comparator: filter.comparator || 'No filter',
+ });
+ }
+ });
+ adhocFilters[adhocFilterKey] = targetArray;
+ }
+ }
+ });
+ }
+
+ if (!hasTemporalRangeFilter(adhocFilters)) {
+ formDataWithNativeFilters.adhoc_filters?.forEach(
+ (filter: SimpleAdhocFilter) => {
+ if (filter.operator === Operators.TemporalRange && filter.isExtra) {
+ if (!adhocFilters.adhoc_filters) {
+ adhocFilters.adhoc_filters = [];
+ }
+ adhocFilters.adhoc_filters.push({
+ ...filter,
+ comparator: 'No filter',
+ });
+ }
+ },
+ );
+ }
+ const formData = {
+ ...formDataWithNativeFilters,
+ ...adhocFilters,
+ dashboards,
+ };
+ let datasourceId = 0;
+ let datasourceType: DatasourceType = DatasourceType.Table;
+
+ if (formData.datasource) {
+ const [id, typeString] = formData.datasource.split('__');
+ datasourceId = parseInt(id, 10);
+
+ const formattedTypeString =
+ typeString.charAt(0).toUpperCase() + typeString.slice(1);
+ if (formattedTypeString in DatasourceType) {
+ datasourceType =
+ DatasourceType[formattedTypeString as keyof typeof DatasourceType];
+ }
+ }
+
+ const payload: Partial<PayloadSlice> = {
+ params: JSON.stringify(formData),
+ slice_name: sliceName,
+ viz_type: formData.viz_type,
+ datasource_id: datasourceId,
+ datasource_type: datasourceType,
+ dashboards,
+ owners,
+ query_context: JSON.stringify(
+ buildV1ChartDataPayload({
+ formData,
+ force: false,
+ resultFormat: 'json',
+ resultType: 'full',
+ setDataMask: null,
+ ownState: null,
+ }),
+ ),
+ };
+
+ return payload;
+};
+
+const addToasts = (
+ isNewSlice: boolean,
+ sliceName: string,
+ addedToDashboard?: {
+ title: string;
+ new?: boolean;
+ },
+) => {
+ const toasts = [];
+ if (isNewSlice) {
+ toasts.push(addSuccessToast(t('Chart [%s] has been saved', sliceName)));
+ } else {
+ toasts.push(
+ addSuccessToast(t('Chart [%s] has been overwritten', sliceName)),
+ );
+ }
+
+ if (addedToDashboard) {
+ if (addedToDashboard.new) {
+ toasts.push(
+ addSuccessToast(
+ t(
+ 'Dashboard [%s] just got created and chart [%s] was added to it',
+ addedToDashboard.title,
+ sliceName,
+ ),
+ ),
+ );
+ } else {
+ toasts.push(
+ addSuccessToast(
+ t(
+ 'Chart [%s] was added to dashboard [%s]',
+ sliceName,
+ addedToDashboard.title,
+ ),
+ ),
+ );
+ }
+ }
+
+ return toasts;
+};
+
+export const updateSlice =
+ (
+ slice: Slice,
+ sliceName: string,
+ dashboards: number[],
+ addedToDashboard?: {
+ title: string;
+ new?: boolean;
+ },
+ ) =>
+ async (dispatch: Dispatch, getState: () => Partial<QueryFormData>) => {
+ const { slice_id: sliceId, owners, form_data: formDataFromSlice } = slice;
+ const formData = getState().explore?.form_data;
+ try {
+ const response = await SupersetClient.put({
+ endpoint: `/api/v1/chart/${sliceId}`,
+ jsonPayload: getSlicePayload(
+ sliceName,
+ formData,
+ dashboards,
+ owners as [],
+ formDataFromSlice,
+ ),
+ });
+
+ dispatch(saveSliceSuccess(response.json));
+ addToasts(false, sliceName, addedToDashboard).map(dispatch);
+ return response.json;
+ } catch (error) {
+ dispatch(saveSliceFailed());
+ throw error;
+ }
+ };
+
+export const createSlice =
+ (
+ sliceName: string,
+ dashboards: number[],
+ addedToDashboard?: {
+ title: string;
+ new?: boolean;
+ },
+ ) =>
+ async (dispatch: Dispatch, getState: () => Partial<QueryFormData>) => {
+ const formData = getState().explore?.form_data;
+ try {
+ const response = await SupersetClient.post({
+ endpoint: `/api/v1/chart/`,
+ jsonPayload: getSlicePayload(
+ sliceName,
+ formData,
+ dashboards,
+ [],
+ {} as QueryFormData,
+ ),
+ });
+
+ dispatch(saveSliceSuccess(response.json));
+ addToasts(true, sliceName, addedToDashboard).map(dispatch);
+ return response.json;
+ } catch (error) {
+ dispatch(saveSliceFailed());
+ throw error;
+ }
+ };
+
+export const createDashboard =
+ (dashboardName: string) => async (dispatch: Dispatch) => {
+ try {
+ const response = await SupersetClient.post({
+ endpoint: `/api/v1/dashboard/`,
+ jsonPayload: { dashboard_title: dashboardName },
+ });
+
+ return response.json;
+ } catch (error) {
+ dispatch(saveSliceFailed());
+ throw error;
+ }
+ };
+
+export const getSliceDashboards =
+ (slice: Partial<Slice>) => async (dispatch: Dispatch) => {
+ try {
+ const response = await SupersetClient.get({
+ endpoint: `/api/v1/chart/${slice.slice_id}?q=${rison.encode({
+ columns: ['dashboards.id'],
+ })}`,
+ });
+
+ return response.json.result.dashboards.map(
+ ({ id }: { id: number }) => id,
+ );
+ } catch (error) {
+ dispatch(saveSliceFailed());
+ throw error;
+ }
+ };
+export { QueryFormData };