diff --git a/superset-frontend/src/explore/actions/saveModalActions.js b/superset-frontend/src/explore/actions/saveModalActions.js deleted file mode 100644 index 9f4ff94778991..0000000000000 --- 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 fc50e3d3cf8fb..2e7be0587413c 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 = { viz_type: vizType, datasource: `${datasourceId}__${datasourceType}`, dashboards, }; -const mockExploreState = { explore: { form_data: formData } }; -const sliceResponsePayload = { - id: 10, +const mockExploreState: Partial = { + 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 = { + 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, 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, 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 = 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, + ); 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); } 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, 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, 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); 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); } 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 0000000000000..549dc92c588ea --- /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) { + return { type: SAVE_SLICE_SUCCESS, data }; +} + +function extractAdhocFiltersFromFormData( + formDataToHandle: QueryFormData, +): Partial { + const result: Partial = {}; + 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): 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 => { + const adhocFilters: Partial = 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 = { + 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) => { + 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) => { + 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) => 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 };