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 };


Reply via email to