refactor: Migrate saveModalActions to TypeScript (#28046)

This commit is contained in:
Enzo Martellucci 2024-05-14 16:51:44 +02:00 committed by GitHub
parent b160fd40c8
commit 920f2f437e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 605 additions and 354 deletions

View File

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

View File

@ -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,
);
});

View File

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