/** * 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. */ /* eslint camelcase: 0 */ import { ActionCreators as UndoActionCreators } from 'redux-undo'; import { ensureIsArray, t, SupersetClient, getSharedLabelColor, } from '@superset-ui/core'; import { addChart, removeChart, refreshChart, } from 'src/components/Chart/chartAction'; import { chart as initChart } from 'src/components/Chart/chartReducer'; import { applyDefaultFormData } from 'src/explore/store'; import { getClientErrorObject } from 'src/utils/getClientErrorObject'; import { SAVE_TYPE_OVERWRITE, SAVE_TYPE_OVERWRITE_CONFIRMED, } from 'src/dashboard/util/constants'; import { isCrossFiltersEnabled } from 'src/dashboard/util/crossFilters'; import { addSuccessToast, addWarningToast, addDangerToast, } from 'src/components/MessageToasts/actions'; import serializeActiveFilterValues from 'src/dashboard/util/serializeActiveFilterValues'; import serializeFilterScopes from 'src/dashboard/util/serializeFilterScopes'; import { getActiveFilters } from 'src/dashboard/util/activeDashboardFilters'; import { safeStringify } from 'src/utils/safeStringify'; import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags'; import { logEvent } from 'src/logger/actions'; import { LOG_ACTIONS_CONFIRM_OVERWRITE_DASHBOARD_METADATA } from 'src/logger/LogUtils'; import { UPDATE_COMPONENTS_PARENTS_LIST } from './dashboardLayout'; import { setChartConfiguration, dashboardInfoChanged, SET_CHART_CONFIG_COMPLETE, } from './dashboardInfo'; import { fetchDatasourceMetadata } from './datasources'; import { addFilter, removeFilter, updateDirectPathToFilter, } from './dashboardFilters'; import { SET_FILTER_CONFIG_COMPLETE } from './nativeFilters'; import getOverwriteItems from '../util/getOverwriteItems'; export const SET_UNSAVED_CHANGES = 'SET_UNSAVED_CHANGES'; export function setUnsavedChanges(hasUnsavedChanges) { return { type: SET_UNSAVED_CHANGES, payload: { hasUnsavedChanges } }; } export const ADD_SLICE = 'ADD_SLICE'; export function addSlice(slice) { return { type: ADD_SLICE, slice }; } export const REMOVE_SLICE = 'REMOVE_SLICE'; export function removeSlice(sliceId) { return { type: REMOVE_SLICE, sliceId }; } const FAVESTAR_BASE_URL = '/superset/favstar/Dashboard'; export const TOGGLE_FAVE_STAR = 'TOGGLE_FAVE_STAR'; export function toggleFaveStar(isStarred) { return { type: TOGGLE_FAVE_STAR, isStarred }; } export const FETCH_FAVE_STAR = 'FETCH_FAVE_STAR'; export function fetchFaveStar(id) { return function fetchFaveStarThunk(dispatch) { return SupersetClient.get({ endpoint: `${FAVESTAR_BASE_URL}/${id}/count/`, }) .then(({ json }) => { if (json.count > 0) dispatch(toggleFaveStar(true)); }) .catch(() => dispatch( addDangerToast( t( 'There was an issue fetching the favorite status of this dashboard.', ), ), ), ); }; } export const SAVE_FAVE_STAR = 'SAVE_FAVE_STAR'; export function saveFaveStar(id, isStarred) { return function saveFaveStarThunk(dispatch) { const urlSuffix = isStarred ? 'unselect' : 'select'; return SupersetClient.get({ endpoint: `${FAVESTAR_BASE_URL}/${id}/${urlSuffix}/`, }) .then(() => { dispatch(toggleFaveStar(!isStarred)); }) .catch(() => dispatch( addDangerToast(t('There was an issue favoriting this dashboard.')), ), ); }; } export const TOGGLE_PUBLISHED = 'TOGGLE_PUBLISHED'; export function togglePublished(isPublished) { return { type: TOGGLE_PUBLISHED, isPublished }; } export function savePublished(id, isPublished) { return function savePublishedThunk(dispatch) { return SupersetClient.put({ endpoint: `/api/v1/dashboard/${id}`, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ published: isPublished, }), }) .then(() => { dispatch( addSuccessToast( isPublished ? t('This dashboard is now published') : t('This dashboard is now hidden'), ), ); dispatch(togglePublished(isPublished)); }) .catch(() => { dispatch( addDangerToast( t('You do not have permissions to edit this dashboard.'), ), ); }); }; } export const TOGGLE_EXPAND_SLICE = 'TOGGLE_EXPAND_SLICE'; export function toggleExpandSlice(sliceId) { return { type: TOGGLE_EXPAND_SLICE, sliceId }; } export const UPDATE_CSS = 'UPDATE_CSS'; export function updateCss(css) { return { type: UPDATE_CSS, css }; } export const SET_EDIT_MODE = 'SET_EDIT_MODE'; export function setEditMode(editMode) { return { type: SET_EDIT_MODE, editMode }; } export const ON_CHANGE = 'ON_CHANGE'; export function onChange() { return { type: ON_CHANGE }; } export const ON_SAVE = 'ON_SAVE'; export function onSave(lastModifiedTime) { return { type: ON_SAVE, lastModifiedTime }; } export const SET_REFRESH_FREQUENCY = 'SET_REFRESH_FREQUENCY'; export function setRefreshFrequency(refreshFrequency, isPersistent = false) { return { type: SET_REFRESH_FREQUENCY, refreshFrequency, isPersistent }; } export function saveDashboardRequestSuccess(lastModifiedTime) { return dispatch => { dispatch(onSave(lastModifiedTime)); // clear layout undo history dispatch(UndoActionCreators.clearHistory()); }; } export const SET_OVERRIDE_CONFIRM = 'SET_OVERRIDE_CONFIRM'; export function setOverrideConfirm(overwriteConfirmMetadata) { return { type: SET_OVERRIDE_CONFIRM, overwriteConfirmMetadata, }; } export const SAVE_DASHBOARD_STARTED = 'SAVE_DASHBOARD_STARTED'; export function saveDashboardStarted() { return { type: SAVE_DASHBOARD_STARTED }; } export const SAVE_DASHBOARD_FINISHED = 'SAVE_DASHBOARD_FINISHED'; export function saveDashboardFinished() { return { type: SAVE_DASHBOARD_FINISHED }; } export function saveDashboardRequest(data, id, saveType) { return (dispatch, getState) => { dispatch({ type: UPDATE_COMPONENTS_PARENTS_LIST }); dispatch(saveDashboardStarted()); const { dashboardFilters, dashboardLayout } = getState(); const layout = dashboardLayout.present; Object.values(dashboardFilters).forEach(filter => { const { chartId } = filter; const componentId = filter.directPathToFilter.slice().pop(); const directPathToFilter = (layout[componentId]?.parents || []).slice(); directPathToFilter.push(componentId); dispatch(updateDirectPathToFilter(chartId, directPathToFilter)); }); // serialize selected values for each filter field, grouped by filter id const serializedFilters = serializeActiveFilterValues(getActiveFilters()); // serialize filter scope for each filter field, grouped by filter id const serializedFilterScopes = serializeFilterScopes(dashboardFilters); const { certified_by, certification_details, css, dashboard_title, owners, roles, slug, } = data; const hasId = item => item.id !== undefined; const metadataCrossFiltersEnabled = data.metadata?.cross_filters_enabled; // making sure the data is what the backend expects const cleanedData = { ...data, certified_by: certified_by || '', certification_details: certified_by && certification_details ? certification_details : '', css: css || '', dashboard_title: dashboard_title || t('[ untitled dashboard ]'), owners: ensureIsArray(owners).map(o => (hasId(o) ? o.id : o)), roles: !isFeatureEnabled(FeatureFlag.DASHBOARD_RBAC) ? undefined : ensureIsArray(roles).map(r => (hasId(r) ? r.id : r)), slug: slug || null, metadata: { ...data.metadata, color_namespace: data.metadata?.color_namespace || undefined, color_scheme: data.metadata?.color_scheme || '', color_scheme_domain: data.metadata?.color_scheme_domain || [], expanded_slices: data.metadata?.expanded_slices || {}, label_colors: data.metadata?.label_colors || {}, shared_label_colors: data.metadata?.shared_label_colors || {}, refresh_frequency: data.metadata?.refresh_frequency || 0, timed_refresh_immune_slices: data.metadata?.timed_refresh_immune_slices || [], // cross-filters should be enabled by default cross_filters_enabled: isCrossFiltersEnabled( metadataCrossFiltersEnabled, ), }, }; const handleChartConfiguration = () => { const { dashboardInfo: { metadata: { chart_configuration = {} }, }, } = getState(); const chartConfiguration = Object.values(chart_configuration).reduce( (prev, next) => { // If chart removed from dashboard - remove it from metadata if ( Object.values(layout).find( layoutItem => layoutItem?.meta?.chartId === next.id, ) ) { return { ...prev, [next.id]: next }; } return prev; }, {}, ); return chartConfiguration; }; const onCopySuccess = response => { const lastModifiedTime = response.json.last_modified_time; if (lastModifiedTime) { dispatch(saveDashboardRequestSuccess(lastModifiedTime)); } if (isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS)) { const chartConfiguration = handleChartConfiguration(); dispatch(setChartConfiguration(chartConfiguration)); } dispatch(saveDashboardFinished()); dispatch(addSuccessToast(t('This dashboard was saved successfully.'))); return response; }; const onUpdateSuccess = response => { const updatedDashboard = response.json.result; const lastModifiedTime = response.json.last_modified_time; // syncing with the backend transformations of the metadata if (updatedDashboard.json_metadata) { const metadata = JSON.parse(updatedDashboard.json_metadata); dispatch( dashboardInfoChanged({ metadata, }), ); if (metadata.chart_configuration) { dispatch({ type: SET_CHART_CONFIG_COMPLETE, chartConfiguration: metadata.chart_configuration, }); } if (metadata.native_filter_configuration) { dispatch({ type: SET_FILTER_CONFIG_COMPLETE, filterConfig: metadata.native_filter_configuration, }); } } if (lastModifiedTime) { dispatch(saveDashboardRequestSuccess(lastModifiedTime)); } dispatch(saveDashboardFinished()); // redirect to the new slug or id window.history.pushState( { event: 'dashboard_properties_changed' }, '', `/superset/dashboard/${slug || id}/`, ); dispatch(addSuccessToast(t('This dashboard was saved successfully.'))); dispatch(setOverrideConfirm(undefined)); return response; }; const onError = async response => { const { error, message } = await getClientErrorObject(response); let errorText = t('Sorry, an unknown error occurred'); if (error) { errorText = t( 'Sorry, there was an error saving this dashboard: %s', error, ); } if (typeof message === 'string' && message === 'Forbidden') { errorText = t('You do not have permission to edit this dashboard'); } dispatch(saveDashboardFinished()); dispatch(addDangerToast(errorText)); }; if ( [SAVE_TYPE_OVERWRITE, SAVE_TYPE_OVERWRITE_CONFIRMED].includes(saveType) ) { let chartConfiguration = {}; if (isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS)) { chartConfiguration = handleChartConfiguration(); } const updatedDashboard = saveType === SAVE_TYPE_OVERWRITE_CONFIRMED ? data : { certified_by: cleanedData.certified_by, certification_details: cleanedData.certification_details, css: cleanedData.css, dashboard_title: cleanedData.dashboard_title, slug: cleanedData.slug, owners: cleanedData.owners, roles: cleanedData.roles, json_metadata: safeStringify({ ...(cleanedData?.metadata || {}), default_filters: safeStringify(serializedFilters), filter_scopes: serializedFilterScopes, chart_configuration: chartConfiguration, }), }; const updateDashboard = () => SupersetClient.put({ endpoint: `/api/v1/dashboard/${id}`, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updatedDashboard), }) .then(response => onUpdateSuccess(response)) .catch(response => onError(response)); return new Promise((resolve, reject) => { if ( !isFeatureEnabled(FeatureFlag.CONFIRM_DASHBOARD_DIFF) || saveType === SAVE_TYPE_OVERWRITE_CONFIRMED ) { // skip overwrite precheck resolve(); return; } // precheck for overwrite items SupersetClient.get({ endpoint: `/api/v1/dashboard/${id}`, }).then(response => { const dashboard = response.json.result; const overwriteConfirmItems = getOverwriteItems( dashboard, updatedDashboard, ); if (overwriteConfirmItems.length > 0) { dispatch( setOverrideConfirm({ updatedAt: dashboard.changed_on, updatedBy: dashboard.changed_by_name, overwriteConfirmItems, dashboardId: id, data: updatedDashboard, }), ); return reject(overwriteConfirmItems); } return resolve(); }); }) .then(updateDashboard) .catch(overwriteConfirmItems => { const errorText = t('Please confirm the overwrite values.'); dispatch( logEvent(LOG_ACTIONS_CONFIRM_OVERWRITE_DASHBOARD_METADATA, { dashboard_id: id, items: overwriteConfirmItems, }), ); dispatch(addDangerToast(errorText)); }); } // changing the data as the endpoint requires const copyData = { ...cleanedData }; if (copyData.metadata) { delete copyData.metadata; } const finalCopyData = { ...copyData, // the endpoint is expecting the metadata to be flat ...(cleanedData?.metadata || {}), }; return SupersetClient.post({ endpoint: `/superset/copy_dash/${id}/`, postPayload: { data: { ...finalCopyData, default_filters: safeStringify(serializedFilters), filter_scopes: safeStringify(serializedFilterScopes), }, }, }) .then(response => onCopySuccess(response)) .catch(response => onError(response)); }; } export function fetchCharts( chartList = [], force = false, interval = 0, dashboardId, ) { return (dispatch, getState) => { if (!interval) { chartList.forEach(chartKey => dispatch(refreshChart(chartKey, force, dashboardId)), ); return; } const { metadata: meta } = getState().dashboardInfo; const refreshTime = Math.max(interval, meta.stagger_time || 5000); // default 5 seconds if (typeof meta.stagger_refresh !== 'boolean') { meta.stagger_refresh = meta.stagger_refresh === undefined ? true : meta.stagger_refresh === 'true'; } const delay = meta.stagger_refresh ? refreshTime / (chartList.length - 1) : 0; chartList.forEach((chartKey, i) => { setTimeout( () => dispatch(refreshChart(chartKey, force, dashboardId)), delay * i, ); }); }; } const refreshCharts = (chartList, force, interval, dashboardId, dispatch) => new Promise(resolve => { dispatch(fetchCharts(chartList, force, interval, dashboardId)); resolve(); }); export const ON_FILTERS_REFRESH = 'ON_FILTERS_REFRESH'; export function onFiltersRefresh() { return { type: ON_FILTERS_REFRESH }; } export const ON_FILTERS_REFRESH_SUCCESS = 'ON_FILTERS_REFRESH_SUCCESS'; export function onFiltersRefreshSuccess() { return { type: ON_FILTERS_REFRESH_SUCCESS }; } export const ON_REFRESH_SUCCESS = 'ON_REFRESH_SUCCESS'; export function onRefreshSuccess() { return { type: ON_REFRESH_SUCCESS }; } export const ON_REFRESH = 'ON_REFRESH'; export function onRefresh( chartList = [], force = false, interval = 0, dashboardId, ) { return dispatch => { dispatch({ type: ON_REFRESH }); refreshCharts(chartList, force, interval, dashboardId, dispatch).then( () => { dispatch(onRefreshSuccess()); dispatch(onFiltersRefresh()); }, ); }; } export const SHOW_BUILDER_PANE = 'SHOW_BUILDER_PANE'; export function showBuilderPane() { return { type: SHOW_BUILDER_PANE }; } export function addSliceToDashboard(id, component) { return (dispatch, getState) => { const { sliceEntities } = getState(); const selectedSlice = sliceEntities.slices[id]; if (!selectedSlice) { return dispatch( addWarningToast( 'Sorry, there is no chart definition associated with the chart trying to be added.', ), ); } const form_data = { ...selectedSlice.form_data, slice_id: selectedSlice.slice_id, }; const newChart = { ...initChart, id, form_data: applyDefaultFormData(form_data), }; return Promise.all([ dispatch(addChart(newChart, id)), dispatch(fetchDatasourceMetadata(form_data.datasource)), ]).then(() => { dispatch(addSlice(selectedSlice)); if (selectedSlice && selectedSlice.viz_type === 'filter_box') { dispatch(addFilter(id, component, selectedSlice.form_data)); } }); }; } export function removeSliceFromDashboard(id) { return (dispatch, getState) => { const sliceEntity = getState().sliceEntities.slices[id]; if (sliceEntity && sliceEntity.viz_type === 'filter_box') { dispatch(removeFilter(id)); } dispatch(removeSlice(id)); dispatch(removeChart(id)); getSharedLabelColor().removeSlice(id); }; } export const SET_COLOR_SCHEME = 'SET_COLOR_SCHEME'; export function setColorScheme(colorScheme) { return { type: SET_COLOR_SCHEME, colorScheme }; } export function setColorSchemeAndUnsavedChanges(colorScheme) { return dispatch => { dispatch(setColorScheme(colorScheme)); dispatch(setUnsavedChanges(true)); }; } export const SET_DIRECT_PATH = 'SET_DIRECT_PATH'; export function setDirectPathToChild(path) { return { type: SET_DIRECT_PATH, path }; } export const SET_ACTIVE_TABS = 'SET_ACTIVE_TABS'; export function setActiveTabs(tabId, prevTabId) { return { type: SET_ACTIVE_TABS, tabId, prevTabId }; } export const SET_FOCUSED_FILTER_FIELD = 'SET_FOCUSED_FILTER_FIELD'; export function setFocusedFilterField(chartId, column) { return { type: SET_FOCUSED_FILTER_FIELD, chartId, column }; } export const UNSET_FOCUSED_FILTER_FIELD = 'UNSET_FOCUSED_FILTER_FIELD'; export function unsetFocusedFilterField(chartId, column) { return { type: UNSET_FOCUSED_FILTER_FIELD, chartId, column }; } export const SET_FULL_SIZE_CHART_ID = 'SET_FULL_SIZE_CHART_ID'; export function setFullSizeChartId(chartId) { return { type: SET_FULL_SIZE_CHART_ID, chartId }; } // Undo history --------------------------------------------------------------- export const SET_MAX_UNDO_HISTORY_EXCEEDED = 'SET_MAX_UNDO_HISTORY_EXCEEDED'; export function setMaxUndoHistoryExceeded(maxUndoHistoryExceeded = true) { return { type: SET_MAX_UNDO_HISTORY_EXCEEDED, payload: { maxUndoHistoryExceeded }, }; } export function maxUndoHistoryToast() { return (dispatch, getState) => { const { dashboardLayout } = getState(); const historyLength = dashboardLayout.past.length; return dispatch( addWarningToast( t( 'You have used all %(historyLength)s undo slots and will not be able to fully undo subsequent actions. You may save your current state to reset the history.', { historyLength }, ), ), ); }; } export const SET_DATASETS_STATUS = 'SET_DATASETS_STATUS'; export function setDatasetsStatus(status) { return { type: SET_DATASETS_STATUS, status, }; }