diff --git a/superset-frontend/cypress-base/cypress/integration/dashboard/load.test.ts b/superset-frontend/cypress-base/cypress/integration/dashboard/load.test.ts index b03cdd27965f2..99f5f729f99b2 100644 --- a/superset-frontend/cypress-base/cypress/integration/dashboard/load.test.ts +++ b/superset-frontend/cypress-base/cypress/integration/dashboard/load.test.ts @@ -23,12 +23,28 @@ import { } from './dashboard.helper'; describe('Dashboard load', () => { - before(() => { + beforeEach(() => { cy.login(); - cy.visit(WORLD_HEALTH_DASHBOARD); }); it('should load dashboard', () => { + cy.visit(WORLD_HEALTH_DASHBOARD); WORLD_HEALTH_CHARTS.forEach(waitForChartLoad); }); + + it('should load in edit mode', () => { + cy.visit(`${WORLD_HEALTH_DASHBOARD}?edit=true&standalone=true`); + cy.get('[data-test="discard-changes-button"]').should('be.visible'); + }); + + it('should load in standalone mode', () => { + cy.visit(`${WORLD_HEALTH_DASHBOARD}?edit=true&standalone=true`); + cy.get('#app-menu').should('not.exist'); + }); + + it('should load in edit/standalone mode', () => { + cy.visit(`${WORLD_HEALTH_DASHBOARD}?edit=true&standalone=true`); + cy.get('[data-test="discard-changes-button"]').should('be.visible'); + cy.get('#app-menu').should('not.exist'); + }); }); diff --git a/superset-frontend/cypress-base/cypress/integration/dashboard/nativeFilters.test.ts b/superset-frontend/cypress-base/cypress/integration/dashboard/nativeFilters.test.ts index f279b8e0643d8..fbb1aea9d0614 100644 --- a/superset-frontend/cypress-base/cypress/integration/dashboard/nativeFilters.test.ts +++ b/superset-frontend/cypress-base/cypress/integration/dashboard/nativeFilters.test.ts @@ -61,9 +61,12 @@ describe('Nativefilters', () => { .click() .type('Country name'); - cy.get('.ant-modal').find('[data-test="datasource-input"]').click(); + cy.get('.ant-modal') + .find('[data-test="datasource-input"]') + .click() + .type('wb_health_population'); - cy.get('[data-test="datasource-input"]') + cy.get('.ant-modal [data-test="datasource-input"] .Select__menu') .contains('wb_health_population') .click(); @@ -155,9 +158,12 @@ describe('Nativefilters', () => { .click() .type('Country name'); - cy.get('.ant-modal').find('[data-test="datasource-input"]').click(); + cy.get('.ant-modal') + .find('[data-test="datasource-input"]') + .click() + .type('wb_health_population'); - cy.get('[data-test="datasource-input"]') + cy.get('.ant-modal [data-test="datasource-input"] .Select__menu') .contains('wb_health_population') .click(); @@ -187,9 +193,10 @@ describe('Nativefilters', () => { cy.get('.ant-modal') .find('[data-test="datasource-input"]') .last() - .click(); + .click() + .type('wb_health_population'); - cy.get('[data-test="datasource-input"]') + cy.get('.ant-modal [data-test="datasource-input"] .Select__menu') .last() .contains('wb_health_population') .click(); diff --git a/superset-frontend/src/chart/chartReducer.ts b/superset-frontend/src/chart/chartReducer.ts index 3f68c043428d7..d6e42dfa87f60 100644 --- a/superset-frontend/src/chart/chartReducer.ts +++ b/superset-frontend/src/chart/chartReducer.ts @@ -18,9 +18,10 @@ */ /* eslint camelcase: 0 */ import { t } from '@superset-ui/core'; +import { HYDRATE_DASHBOARD } from 'src/dashboard/actions/hydrate'; import { ChartState } from 'src/explore/types'; import { getFormDataFromControls } from 'src/explore/controlUtils'; -import { now } from '../modules/dates'; +import { now } from 'src/modules/dates'; import * as actions from './chartAction'; export const chart: ChartState = { @@ -192,7 +193,9 @@ export default function chartReducer( delete charts[key]; return charts; } - + if (action.type === HYDRATE_DASHBOARD) { + return { ...action.data.charts }; + } if (action.type in actionHandlers) { return { ...charts, diff --git a/superset-frontend/src/common/hooks/apiResources/dashboards.ts b/superset-frontend/src/common/hooks/apiResources/dashboards.ts new file mode 100644 index 0000000000000..0bb21f16bfb60 --- /dev/null +++ b/superset-frontend/src/common/hooks/apiResources/dashboards.ts @@ -0,0 +1,41 @@ +/** + * 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 Dashboard from 'src/types/Dashboard'; +import { useApiV1Resource, useTransformedResource } from './apiResources'; + +export const useDashboard = (idOrSlug: string | number) => + useTransformedResource( + useApiV1Resource(`/api/v1/dashboard/${idOrSlug}`), + dashboard => ({ + ...dashboard, + metadata: JSON.parse(dashboard.json_metadata), + position_data: JSON.parse(dashboard.position_json), + }), + ); + +// gets the chart definitions for a dashboard +export const useDashboardCharts = (idOrSlug: string | number) => + useApiV1Resource(`/api/v1/dashboard/${idOrSlug}/charts`); + +// gets the datasets for a dashboard +// important: this endpoint only returns the fields in the dataset +// that are necessary for rendering the given dashboard +export const useDashboardDatasets = (idOrSlug: string | number) => + useApiV1Resource(`/api/v1/dashboard/${idOrSlug}/datasets`); diff --git a/superset-frontend/src/common/hooks/apiResources/index.ts b/superset-frontend/src/common/hooks/apiResources/index.ts index 8befc73735770..5e63920731144 100644 --- a/superset-frontend/src/common/hooks/apiResources/index.ts +++ b/superset-frontend/src/common/hooks/apiResources/index.ts @@ -26,4 +26,5 @@ export { // A central catalog of API Resource hooks. // Add new API hooks here, organized under // different files for different resource types. -export { useChartOwnerNames } from './charts'; +export * from './charts'; +export * from './dashboards'; diff --git a/superset-frontend/src/components/ErrorBoundary/index.jsx b/superset-frontend/src/components/ErrorBoundary/index.jsx index 7bc00758afd98..0a1d0c7c46510 100644 --- a/superset-frontend/src/components/ErrorBoundary/index.jsx +++ b/superset-frontend/src/components/ErrorBoundary/index.jsx @@ -38,7 +38,7 @@ export default class ErrorBoundary extends React.Component { } componentDidCatch(error, info) { - this.props.onError(error, info); + if (this.props.onError) this.props.onError(error, info); this.setState({ error, info }); } diff --git a/superset-frontend/src/dashboard/App.jsx b/superset-frontend/src/dashboard/App.jsx index da06a0130fa31..43d00f5a579c5 100644 --- a/superset-frontend/src/dashboard/App.jsx +++ b/superset-frontend/src/dashboard/App.jsx @@ -25,22 +25,28 @@ import { HTML5Backend } from 'react-dnd-html5-backend'; import { DynamicPluginProvider } from 'src/components/DynamicPlugins'; import setupApp from '../setup/setupApp'; import setupPlugins from '../setup/setupPlugins'; -import DashboardContainer from './containers/Dashboard'; +import DashboardPage from './containers/DashboardPage'; import { theme } from '../preamble'; setupApp(); setupPlugins(); -const App = ({ store }) => ( - - - - - - - - - -); +const App = ({ store }) => { + const dashboardIdOrSlug = window.location.pathname.split('/')[3]; + return ( + + + + + + + + + + ); +}; export default hot(App); diff --git a/superset-frontend/src/dashboard/reducers/getInitialState.js b/superset-frontend/src/dashboard/actions/hydrate.js similarity index 60% rename from superset-frontend/src/dashboard/reducers/getInitialState.js rename to superset-frontend/src/dashboard/actions/hydrate.js index 19ea54e789f1f..7cdaa2396d3ef 100644 --- a/superset-frontend/src/dashboard/reducers/getInitialState.js +++ b/superset-frontend/src/dashboard/actions/hydrate.js @@ -17,46 +17,82 @@ * under the License. */ /* eslint-disable camelcase */ -import { isString } from 'lodash'; +import { isString, keyBy } from 'lodash'; import shortid from 'shortid'; import { CategoricalColorNamespace } from '@superset-ui/core'; +import querystring from 'query-string'; +import { chart } from 'src/chart/chartReducer'; import { initSliceEntities } from 'src/dashboard/reducers/sliceEntities'; import { getInitialState as getInitialNativeFilterState } from 'src/dashboard/reducers/nativeFilters'; import { getParam } from 'src/modules/utils'; import { applyDefaultFormData } from 'src/explore/store'; import { buildActiveFilters } from 'src/dashboard/util/activeDashboardFilters'; +import getPermissions from 'src/dashboard/util/getPermissions'; import { DASHBOARD_FILTER_SCOPE_GLOBAL, dashboardFilter, -} from './dashboardFilters'; -import { chart } from '../../chart/chartReducer'; +} from 'src/dashboard/reducers/dashboardFilters'; import { DASHBOARD_HEADER_ID, GRID_DEFAULT_CHART_WIDTH, GRID_COLUMN_COUNT, -} from '../util/constants'; +} from 'src/dashboard/util/constants'; import { DASHBOARD_HEADER_TYPE, CHART_TYPE, ROW_TYPE, -} from '../util/componentTypes'; -import findFirstParentContainerId from '../util/findFirstParentContainer'; -import getEmptyLayout from '../util/getEmptyLayout'; -import getFilterConfigsFromFormdata from '../util/getFilterConfigsFromFormdata'; -import getLocationHash from '../util/getLocationHash'; -import newComponentFactory from '../util/newComponentFactory'; -import { TIME_RANGE } from '../../visualizations/FilterBox/FilterBox'; +} from 'src/dashboard/util/componentTypes'; +import findFirstParentContainerId from 'src/dashboard/util/findFirstParentContainer'; +import getEmptyLayout from 'src/dashboard/util/getEmptyLayout'; +import getFilterConfigsFromFormdata from 'src/dashboard/util/getFilterConfigsFromFormdata'; +import getLocationHash from 'src/dashboard/util/getLocationHash'; +import newComponentFactory from 'src/dashboard/util/newComponentFactory'; +import { TIME_RANGE } from 'src/visualizations/FilterBox/FilterBox'; -export default function getInitialState(bootstrapData) { - const { user_id, datasources, common, editMode, urlParams } = bootstrapData; +const reservedQueryParams = new Set(['standalone', 'edit']); + +/** + * Returns the url params that are used to customize queries + * in datasets built using sql lab. + * We may want to extract this to some kind of util in the future. + */ +const extractUrlParams = queryParams => + Object.entries(queryParams).reduce((acc, [key, value]) => { + if (reservedQueryParams.has(key)) return acc; + // if multiple url params share the same key (?foo=bar&foo=baz), they will appear as an array. + // Only one value can be used for a given query param, so we just take the first one. + if (Array.isArray(value)) { + return { + ...acc, + [key]: value[0], + }; + } + return { ...acc, [key]: value }; + }, {}); + +export const HYDRATE_DASHBOARD = 'HYDRATE_DASHBOARD'; + +export const hydrateDashboard = (dashboardData, chartData, datasourcesData) => ( + dispatch, + getState, +) => { + const { user, common } = getState(); + const { metadata } = dashboardData; + const queryParams = querystring.parse(window.location.search); + const urlParams = extractUrlParams(queryParams); + const editMode = queryParams.edit === 'true'; - const dashboard = { ...bootstrapData.dashboard_data }; let preselectFilters = {}; + + chartData.forEach(chart => { + // eslint-disable-next-line no-param-reassign + chart.slice_id = chart.form_data.slice_id; + }); try { // allow request parameter overwrite dashboard metadata preselectFilters = JSON.parse( - getParam('preselect_filters') || dashboard.metadata.default_filters, + getParam('preselect_filters') || metadata.default_filters, ); } catch (e) { // @@ -64,12 +100,12 @@ export default function getInitialState(bootstrapData) { // Priming the color palette with user's label-color mapping provided in // the dashboard's JSON metadata - if (dashboard.metadata && dashboard.metadata.label_colors) { - const scheme = dashboard.metadata.color_scheme; - const namespace = dashboard.metadata.color_namespace; - const colorMap = isString(dashboard.metadata.label_colors) - ? JSON.parse(dashboard.metadata.label_colors) - : dashboard.metadata.label_colors; + if (metadata?.label_colors) { + const scheme = metadata.color_scheme; + const namespace = metadata.color_namespace; + const colorMap = isString(metadata.label_colors) + ? JSON.parse(metadata.label_colors) + : metadata.label_colors; Object.keys(colorMap).forEach(label => { CategoricalColorNamespace.getScale(scheme, namespace).setColor( label, @@ -79,11 +115,11 @@ export default function getInitialState(bootstrapData) { } // dashboard layout - const { position_json: positionJson } = dashboard; - // new dash: positionJson could be {} or null + const { position_data } = dashboardData; + // new dash: position_json could be {} or null const layout = - positionJson && Object.keys(positionJson).length > 0 - ? positionJson + position_data && Object.keys(position_data).length > 0 + ? position_data : getEmptyLayout(); // create a lookup to sync layout names with slice names @@ -100,13 +136,13 @@ export default function getInitialState(bootstrapData) { let newSlicesContainer; let newSlicesContainerWidth = 0; - const filterScopes = dashboard.metadata.filter_scopes || {}; + const filterScopes = metadata?.filter_scopes || {}; const chartQueries = {}; const dashboardFilters = {}; const slices = {}; const sliceIds = new Set(); - dashboard.slices.forEach(slice => { + chartData.forEach(slice => { const key = slice.slice_id; const form_data = { ...slice.form_data, @@ -240,7 +276,7 @@ export default function getInitialState(bootstrapData) { id: DASHBOARD_HEADER_ID, type: DASHBOARD_HEADER_TYPE, meta: { - text: dashboard.dashboard_title, + text: dashboardData.dashboard_title, }, }; @@ -259,54 +295,57 @@ export default function getInitialState(bootstrapData) { } const nativeFilters = getInitialNativeFilterState({ - filterConfig: dashboard.metadata.native_filter_configuration || [], - filterSetsConfig: dashboard.metadata.filter_sets_configuration || [], + filterConfig: metadata?.native_filter_configuration || [], + filterSetsConfig: metadata?.filter_sets_configuration || [], }); - return { - datasources, - sliceEntities: { ...initSliceEntities, slices, isLoading: false }, - charts: chartQueries, - // read-only data - dashboardInfo: { - id: dashboard.id, - slug: dashboard.slug, - metadata: dashboard.metadata, - userId: user_id, - dash_edit_perm: dashboard.dash_edit_perm, - dash_save_perm: dashboard.dash_save_perm, - superset_can_explore: dashboard.superset_can_explore, - superset_can_csv: dashboard.superset_can_csv, - slice_can_edit: dashboard.slice_can_edit, - common: { - flash_messages: common.flash_messages, - conf: common.conf, + const { roles } = getState().user; + + return dispatch({ + type: HYDRATE_DASHBOARD, + data: { + datasources: keyBy(datasourcesData, 'uid'), + sliceEntities: { ...initSliceEntities, slices, isLoading: false }, + charts: chartQueries, + // read-only data + dashboardInfo: { + ...dashboardData, + userId: String(user.userId), // legacy, please use state.user instead + dash_edit_perm: getPermissions('can_write', 'Dashboard', roles), + dash_save_perm: getPermissions('can_save_dash', 'Superset', roles), + superset_can_explore: getPermissions('can_explore', 'Superset', roles), + superset_can_csv: getPermissions('can_csv', 'Superset', roles), + slice_can_edit: getPermissions('can_slice', 'Superset', roles), + common: { + // legacy, please use state.common instead + flash_messages: common.flash_messages, + conf: common.conf, + }, }, - lastModifiedTime: dashboard.last_modified_time, - }, - dashboardFilters, - nativeFilters, - dashboardState: { - sliceIds: Array.from(sliceIds), - directPathToChild, - directPathLastUpdated: Date.now(), - focusedFilterField: null, - expandedSlices: dashboard.metadata.expanded_slices || {}, - refreshFrequency: dashboard.metadata.refresh_frequency || 0, - // dashboard viewers can set refresh frequency for the current visit, - // only persistent refreshFrequency will be saved to backend - shouldPersistRefreshFrequency: false, - css: dashboard.css || '', - colorNamespace: dashboard.metadata.color_namespace, - colorScheme: dashboard.metadata.color_scheme, - editMode: dashboard.dash_edit_perm && editMode, - isPublished: dashboard.published, - hasUnsavedChanges: false, - maxUndoHistoryExceeded: false, - lastModifiedTime: dashboard.last_modified_time, + dashboardFilters, + nativeFilters, + dashboardState: { + sliceIds: Array.from(sliceIds), + directPathToChild, + directPathLastUpdated: Date.now(), + focusedFilterField: null, + expandedSlices: metadata?.expanded_slices || {}, + refreshFrequency: metadata?.refresh_frequency || 0, + // dashboard viewers can set refresh frequency for the current visit, + // only persistent refreshFrequency will be saved to backend + shouldPersistRefreshFrequency: false, + css: dashboardData.css || '', + colorNamespace: metadata?.color_namespace || null, + colorScheme: metadata?.color_scheme || null, + editMode: getPermissions('can_write', 'Dashboard', roles) && editMode, + isPublished: dashboardData.published, + hasUnsavedChanges: false, + maxUndoHistoryExceeded: false, + lastModifiedTime: dashboardData.changed_on, + }, + dashboardLayout, + messageToasts: [], + impressionId: shortid.generate(), }, - dashboardLayout, - messageToasts: [], - impressionId: shortid.generate(), - }; -} + }); +}; diff --git a/superset-frontend/src/dashboard/actions/nativeFilters.ts b/superset-frontend/src/dashboard/actions/nativeFilters.ts index fca59f2bafd70..2fc5541164da0 100644 --- a/superset-frontend/src/dashboard/actions/nativeFilters.ts +++ b/superset-frontend/src/dashboard/actions/nativeFilters.ts @@ -19,12 +19,16 @@ import { makeApi } from '@superset-ui/core'; import { Dispatch } from 'redux'; -import { FilterConfiguration } from 'src/dashboard/components/nativeFilters/types'; +import { + Filter, + FilterConfiguration, +} from 'src/dashboard/components/nativeFilters/types'; import { DataMaskType, DataMaskStateWithId } from 'src/dataMask/types'; import { SET_DATA_MASK_FOR_FILTER_CONFIG_COMPLETE, SET_DATA_MASK_FOR_FILTER_CONFIG_FAIL, } from 'src/dataMask/actions'; +import { HYDRATE_DASHBOARD } from './hydrate'; import { dashboardInfoChanged } from './dashboardInfo'; import { DashboardInfo, FilterSet } from '../reducers/types'; @@ -105,6 +109,18 @@ export const setFilterConfiguration = ( } }; +type BootstrapData = { + nativeFilters: { + filters: Filter; + filtersState: object; + }; +}; + +export interface SetBooststapData { + type: typeof HYDRATE_DASHBOARD; + data: BootstrapData; +} + export const setFilterSetsConfiguration = ( filterSetsConfig: FilterSet[], ) => async (dispatch: Dispatch, getState: () => any) => { @@ -173,4 +189,5 @@ export type AnyFilterAction = | SetFilterSetsConfigBegin | SetFilterSetsConfigComplete | SetFilterSetsConfigFail - | SaveFilterSets; + | SaveFilterSets + | SetBooststapData; diff --git a/superset-frontend/src/dashboard/components/DashboardGrid.jsx b/superset-frontend/src/dashboard/components/DashboardGrid.jsx index 6889c91ab3de2..9fb0fb5fd55e5 100644 --- a/superset-frontend/src/dashboard/components/DashboardGrid.jsx +++ b/superset-frontend/src/dashboard/components/DashboardGrid.jsx @@ -123,7 +123,6 @@ class DashboardGrid extends React.PureComponent { width, isComponentVisible, } = this.props; - const columnPlusGutterWidth = (width + GRID_GUTTER_SIZE) / GRID_COLUMN_COUNT; diff --git a/superset-frontend/src/dashboard/components/SaveModal.tsx b/superset-frontend/src/dashboard/components/SaveModal.tsx index 0bbc327767558..1d3141df05f8e 100644 --- a/superset-frontend/src/dashboard/components/SaveModal.tsx +++ b/superset-frontend/src/dashboard/components/SaveModal.tsx @@ -140,7 +140,7 @@ class SaveModal extends React.PureComponent { // check refresh frequency is for current session or persist const refreshFrequency = shouldPersistRefreshFrequency ? currentRefreshFrequency - : dashboardInfo.metadata.refresh_frequency; // eslint-disable camelcase + : dashboardInfo.metadata?.refresh_frequency; // eslint-disable camelcase const data = { positions, diff --git a/superset-frontend/src/dashboard/containers/DashboardHeader.jsx b/superset-frontend/src/dashboard/containers/DashboardHeader.jsx index 3ffd51a6bbe93..6351561c71fc9 100644 --- a/superset-frontend/src/dashboard/containers/DashboardHeader.jsx +++ b/superset-frontend/src/dashboard/containers/DashboardHeader.jsx @@ -85,7 +85,7 @@ function mapStateToProps({ maxUndoHistoryExceeded: !!dashboardState.maxUndoHistoryExceeded, lastModifiedTime: Math.max( dashboardState.lastModifiedTime, - dashboardInfo.lastModifiedTime, + dashboardInfo.last_modified_time, ), editMode: !!dashboardState.editMode, slug: dashboardInfo.slug, diff --git a/superset-frontend/src/dashboard/containers/DashboardPage.tsx b/superset-frontend/src/dashboard/containers/DashboardPage.tsx new file mode 100644 index 0000000000000..6e391e0b00c31 --- /dev/null +++ b/superset-frontend/src/dashboard/containers/DashboardPage.tsx @@ -0,0 +1,90 @@ +/** + * 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 React, { useEffect, useState, FC } from 'react'; +import { useDispatch } from 'react-redux'; +import Loading from 'src/components/Loading'; +import ErrorBoundary from 'src/components/ErrorBoundary'; +import { + useDashboard, + useDashboardCharts, + useDashboardDatasets, +} from 'src/common/hooks/apiResources'; +import { ResourceStatus } from 'src/common/hooks/apiResources/apiResources'; +import { usePrevious } from 'src/common/hooks/usePrevious'; +import { hydrateDashboard } from 'src/dashboard/actions/hydrate'; +import DashboardContainer from 'src/dashboard/containers/Dashboard'; + +interface DashboardRouteProps { + dashboardIdOrSlug: string; +} + +const DashboardPage: FC = ({ + dashboardIdOrSlug, // eventually get from react router +}) => { + const dispatch = useDispatch(); + const [isLoaded, setLoaded] = useState(false); + const dashboardResource = useDashboard(dashboardIdOrSlug); + const chartsResource = useDashboardCharts(dashboardIdOrSlug); + const datasetsResource = useDashboardDatasets(dashboardIdOrSlug); + const isLoading = [dashboardResource, chartsResource, datasetsResource].some( + resource => resource.status === ResourceStatus.LOADING, + ); + const wasLoading = usePrevious(isLoading); + const error = [dashboardResource, chartsResource, datasetsResource].find( + resource => resource.status === ResourceStatus.ERROR, + )?.error; + useEffect(() => { + if ( + wasLoading && + dashboardResource.status === ResourceStatus.COMPLETE && + chartsResource.status === ResourceStatus.COMPLETE && + datasetsResource.status === ResourceStatus.COMPLETE + ) { + dispatch( + hydrateDashboard( + dashboardResource.result, + chartsResource.result, + datasetsResource.result, + ), + ); + setLoaded(true); + } + }, [ + dispatch, + wasLoading, + dashboardResource, + chartsResource, + datasetsResource, + ]); + + if (error) throw error; // caught in error boundary + + if (!isLoaded) return ; + return ; +}; + +const DashboardPageWithErrorBoundary = ({ + dashboardIdOrSlug, +}: DashboardRouteProps) => ( + + + +); + +export default DashboardPageWithErrorBoundary; diff --git a/superset-frontend/src/dashboard/index.jsx b/superset-frontend/src/dashboard/index.jsx index 5d696bd3f1e15..1a287c072ecf3 100644 --- a/superset-frontend/src/dashboard/index.jsx +++ b/superset-frontend/src/dashboard/index.jsx @@ -22,7 +22,6 @@ import thunk from 'redux-thunk'; import { createStore, applyMiddleware, compose } from 'redux'; import { initFeatureFlags } from 'src/featureFlags'; import { initEnhancer } from '../reduxUtils'; -import getInitialState from './reducers/getInitialState'; import rootReducer from './reducers/index'; import logger from '../middleware/loggerMiddleware'; import App from './App'; @@ -30,10 +29,16 @@ import App from './App'; const appContainer = document.getElementById('app'); const bootstrapData = JSON.parse(appContainer.getAttribute('data-bootstrap')); initFeatureFlags(bootstrapData.common.feature_flags); -const initState = getInitialState(bootstrapData); + +const initialState = { + user: bootstrapData.user, + common: bootstrapData.common, + datasources: bootstrapData.datasources, +}; + const store = createStore( rootReducer, - initState, + initialState, compose(applyMiddleware(thunk, logger), initEnhancer(false)), ); diff --git a/superset-frontend/src/dashboard/reducers/dashboardFilters.js b/superset-frontend/src/dashboard/reducers/dashboardFilters.js index f508c1bfe3868..d31af825717bd 100644 --- a/superset-frontend/src/dashboard/reducers/dashboardFilters.js +++ b/superset-frontend/src/dashboard/reducers/dashboardFilters.js @@ -25,6 +25,7 @@ import { UPDATE_LAYOUT_COMPONENTS, UPDATE_DASHBOARD_FILTERS_SCOPE, } from '../actions/dashboardFilters'; +import { HYDRATE_DASHBOARD } from '../actions/hydrate'; import { TIME_RANGE } from '../../visualizations/FilterBox/FilterBox'; import { DASHBOARD_ROOT_ID } from '../util/constants'; import getFilterConfigsFromFormdata from '../util/getFilterConfigsFromFormdata'; @@ -161,6 +162,10 @@ export default function dashboardFiltersReducer(dashboardFilters = {}, action) { return updatedFilters; } + if (action.type === HYDRATE_DASHBOARD) { + return action.data.dashboardFilters; + } + if (action.type in actionHandlers) { const updatedFilters = { ...dashboardFilters, @@ -168,7 +173,6 @@ export default function dashboardFiltersReducer(dashboardFilters = {}, action) { dashboardFilters[action.chartId], ), }; - if (CHANGE_FILTER_VALUE_ACTIONS.includes(action.type)) { buildActiveFilters({ dashboardFilters: updatedFilters }); } diff --git a/superset-frontend/src/dashboard/reducers/dashboardInfo.js b/superset-frontend/src/dashboard/reducers/dashboardInfo.js index 01346d7a4f29f..fdd39fae12324 100644 --- a/superset-frontend/src/dashboard/reducers/dashboardInfo.js +++ b/superset-frontend/src/dashboard/reducers/dashboardInfo.js @@ -18,6 +18,7 @@ */ import { DASHBOARD_INFO_UPDATED } from '../actions/dashboardInfo'; +import { HYDRATE_DASHBOARD } from '../actions/hydrate'; export default function dashboardStateReducer(state = {}, action) { switch (action.type) { @@ -26,7 +27,13 @@ export default function dashboardStateReducer(state = {}, action) { ...state, ...action.newInfo, // server-side compare last_modified_time in second level - lastModifiedTime: Math.round(new Date().getTime() / 1000), + last_modified_time: Math.round(new Date().getTime() / 1000), + }; + case HYDRATE_DASHBOARD: + return { + ...state, + ...action.data.dashboardInfo, + // set async api call data }; default: return state; diff --git a/superset-frontend/src/dashboard/reducers/dashboardLayout.js b/superset-frontend/src/dashboard/reducers/dashboardLayout.js index ffc56132a83c5..30ad33c62b6c1 100644 --- a/superset-frontend/src/dashboard/reducers/dashboardLayout.js +++ b/superset-frontend/src/dashboard/reducers/dashboardLayout.js @@ -43,7 +43,15 @@ import { DASHBOARD_TITLE_CHANGED, } from '../actions/dashboardLayout'; +import { HYDRATE_DASHBOARD } from '../actions/hydrate'; + const actionHandlers = { + [HYDRATE_DASHBOARD](state, action) { + return { + ...action.data.dashboardLayout.present, + }; + }, + [UPDATE_COMPONENTS](state, action) { const { payload: { nextComponents }, diff --git a/superset-frontend/src/dashboard/reducers/dashboardState.js b/superset-frontend/src/dashboard/reducers/dashboardState.js index b948e2c4a3497..6f162084708f4 100644 --- a/superset-frontend/src/dashboard/reducers/dashboardState.js +++ b/superset-frontend/src/dashboard/reducers/dashboardState.js @@ -36,9 +36,13 @@ import { SET_FOCUSED_FILTER_FIELD, UNSET_FOCUSED_FILTER_FIELD, } from '../actions/dashboardState'; +import { HYDRATE_DASHBOARD } from '../actions/hydrate'; export default function dashboardStateReducer(state = {}, action) { const actionHandlers = { + [HYDRATE_DASHBOARD]() { + return { ...state, ...action.data.dashboardState }; + }, [UPDATE_CSS]() { return { ...state, css: action.css }; }, diff --git a/superset-frontend/src/dashboard/reducers/datasources.js b/superset-frontend/src/dashboard/reducers/datasources.js index 0cf7e1bac4558..616c3c134ffdb 100644 --- a/superset-frontend/src/dashboard/reducers/datasources.js +++ b/superset-frontend/src/dashboard/reducers/datasources.js @@ -17,22 +17,29 @@ * under the License. */ import { SET_DATASOURCE } from '../actions/datasources'; +import { HYDRATE_DASHBOARD } from '../actions/hydrate'; export default function datasourceReducer(datasources = {}, action) { const actionHandlers = { + [HYDRATE_DASHBOARD]() { + return action.data.datasources; + }, [SET_DATASOURCE]() { return action.datasource; }, }; if (action.type in actionHandlers) { - return { - ...datasources, - [action.key]: actionHandlers[action.type]( - datasources[action.key], - action, - ), - }; + if (action.key) { + return { + ...datasources, + [action.key]: actionHandlers[action.type]( + datasources[action.key], + action, + ), + }; + } + return actionHandlers[action.type](); } return datasources; } diff --git a/superset-frontend/src/dashboard/reducers/index.js b/superset-frontend/src/dashboard/reducers/index.js index 481f1675ff4aa..28804a7209069 100644 --- a/superset-frontend/src/dashboard/reducers/index.js +++ b/superset-frontend/src/dashboard/reducers/index.js @@ -32,6 +32,8 @@ import messageToasts from '../../messageToasts/reducers'; const impressionId = (state = '') => state; export default combineReducers({ + user: (state = null) => state, + common: (state = null) => state, charts, datasources, dashboardInfo, diff --git a/superset-frontend/src/dashboard/reducers/nativeFilters.ts b/superset-frontend/src/dashboard/reducers/nativeFilters.ts index d860cbede4d8d..8b8dc4fd7b593 100644 --- a/superset-frontend/src/dashboard/reducers/nativeFilters.ts +++ b/superset-frontend/src/dashboard/reducers/nativeFilters.ts @@ -24,6 +24,7 @@ import { } from 'src/dashboard/actions/nativeFilters'; import { FilterSet, NativeFiltersState } from './types'; import { FilterConfiguration } from '../components/nativeFilters/types'; +import { HYDRATE_DASHBOARD } from '../actions/hydrate'; export function getInitialState({ filterSetsConfig, @@ -69,6 +70,10 @@ export default function nativeFilterReducer( ) { const { filterSets } = state; switch (action.type) { + case HYDRATE_DASHBOARD: + return { + filters: action.data.nativeFilters.filters, + }; case SAVE_FILTER_SETS: return { ...state, diff --git a/superset-frontend/src/dashboard/reducers/sliceEntities.js b/superset-frontend/src/dashboard/reducers/sliceEntities.js index f34a0b61215e7..70b66db72475d 100644 --- a/superset-frontend/src/dashboard/reducers/sliceEntities.js +++ b/superset-frontend/src/dashboard/reducers/sliceEntities.js @@ -23,6 +23,7 @@ import { FETCH_ALL_SLICES_STARTED, SET_ALL_SLICES, } from '../actions/sliceEntities'; +import { HYDRATE_DASHBOARD } from '../actions/hydrate'; export const initSliceEntities = { slices: {}, @@ -36,6 +37,11 @@ export default function sliceEntitiesReducer( action, ) { const actionHandlers = { + [HYDRATE_DASHBOARD]() { + return { + ...action.data.sliceEntities, + }; + }, [FETCH_ALL_SLICES_STARTED]() { return { ...state, diff --git a/superset-frontend/src/dashboard/reducers/undoableDashboardLayout.js b/superset-frontend/src/dashboard/reducers/undoableDashboardLayout.js index 49e0186e2b49e..2edb51d00fae2 100644 --- a/superset-frontend/src/dashboard/reducers/undoableDashboardLayout.js +++ b/superset-frontend/src/dashboard/reducers/undoableDashboardLayout.js @@ -29,13 +29,17 @@ import { HANDLE_COMPONENT_DROP, } from '../actions/dashboardLayout'; +import { HYDRATE_DASHBOARD } from '../actions/hydrate'; + import dashboardLayout from './dashboardLayout'; export default undoable(dashboardLayout, { // +1 because length of history seems max out at limit - 1 // +1 again so we can detect if we've exceeded the limit limit: UNDO_LIMIT + 2, + ignoreInitialState: true, filter: includeAction([ + HYDRATE_DASHBOARD, UPDATE_COMPONENTS, DELETE_COMPONENT, CREATE_COMPONENT, diff --git a/superset-frontend/src/dashboard/util/getPermissions.ts b/superset-frontend/src/dashboard/util/getPermissions.ts new file mode 100644 index 0000000000000..3e7cb19765ddf --- /dev/null +++ b/superset-frontend/src/dashboard/util/getPermissions.ts @@ -0,0 +1,42 @@ +/** + * 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 memoizeOne from 'memoize-one'; + +export default function getPermissions( + perm: string, + view: string, + roles: object, +) { + return memoizeOne(() => { + const roleList = Object.entries(roles); + if (roleList.length === 0) return false; + let bool; + + roleList.forEach(([role, permissions]) => { + bool = Boolean( + permissions.find( + (permission: Array) => + permission[0] === perm && permission[1] === view, + ), + ); + }); + console.log('bool', bool); + return bool; + }); +} diff --git a/superset-frontend/src/types/Dashboard.ts b/superset-frontend/src/types/Dashboard.ts new file mode 100644 index 0000000000000..9608cc1d08b18 --- /dev/null +++ b/superset-frontend/src/types/Dashboard.ts @@ -0,0 +1,40 @@ +/** + * 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 Owner from './Owner'; +import Role from './Role'; + +type Dashboard = { + id: number; + slug: string; + url: string; + dashboard_title: string; + thumbnail_url: string; + published: boolean; + css: string; + json_metadata: string; + position_json: string; + changed_by_name: string; + changed_by: Owner; + changed_on: string; + charts: string[]; // just chart names, unfortunately... + owners: Owner[]; + roles: Role[]; +}; + +export default Dashboard; diff --git a/superset-frontend/src/types/Role.ts b/superset-frontend/src/types/Role.ts new file mode 100644 index 0000000000000..54f6876f493a6 --- /dev/null +++ b/superset-frontend/src/types/Role.ts @@ -0,0 +1,24 @@ +/** + * 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. + */ +type Role = { + id: number; + name: string; +}; + +export default Role; diff --git a/superset/charts/schemas.py b/superset/charts/schemas.py index 7703df7fcd433..ebe958710b2a7 100644 --- a/superset/charts/schemas.py +++ b/superset/charts/schemas.py @@ -155,6 +155,7 @@ class ChartEntityResponseSchema(Schema): slice_name = fields.String(description=slice_name_description) cache_timeout = fields.Integer(description=cache_timeout_description) changed_on = fields.String(description=changed_on_description) + modified = fields.String() datasource = fields.String(description=datasource_name_description) description = fields.String(description=description_description) description_markeddown = fields.String( diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py index 9d6258e450b1b..458e59c47dc99 100644 --- a/superset/dashboards/api.py +++ b/superset/dashboards/api.py @@ -307,7 +307,7 @@ def get_datasets(self, id_or_slug: str) -> Response: except DashboardNotFoundError: return self.response_404() - @expose("//charts", methods=["GET"]) + @expose("//charts", methods=["GET"]) @protect() @safe @statsd_metrics @@ -315,7 +315,7 @@ def get_datasets(self, id_or_slug: str) -> Response: action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.get_charts", log_to_statsd=False, ) - def get_charts(self, pk: int) -> Response: + def get_charts(self, id_or_slug: str) -> Response: """Gets the chart definitions for a given dashboard --- get: @@ -324,8 +324,8 @@ def get_charts(self, pk: int) -> Response: parameters: - in: path schema: - type: integer - name: pk + type: string + name: id_or_slug responses: 200: description: Dashboard chart definitions @@ -348,8 +348,16 @@ def get_charts(self, pk: int) -> Response: $ref: '#/components/responses/404' """ try: - charts = DashboardDAO.get_charts_for_dashboard(pk) + charts = DashboardDAO.get_charts_for_dashboard(id_or_slug) result = [self.chart_entity_response_schema.dump(chart) for chart in charts] + + if is_feature_enabled("REMOVE_SLICE_LEVEL_LABEL_COLORS"): + # dashboard metadata has dashboard-level label_colors, + # so remove slice-level label_colors from its form_data + for chart in result: + form_data = chart.get("form_data") + form_data.pop("label_colors", None) + return self.response(200, result=result) except DashboardNotFoundError: return self.response_404() diff --git a/superset/dashboards/dao.py b/superset/dashboards/dao.py index 0951f47e3710f..800ee66c9070b 100644 --- a/superset/dashboards/dao.py +++ b/superset/dashboards/dao.py @@ -82,12 +82,12 @@ def get_datasets_for_dashboard(id_or_slug: str) -> List[Any]: return data @staticmethod - def get_charts_for_dashboard(dashboard_id: int) -> List[Slice]: + def get_charts_for_dashboard(id_or_slug: str) -> List[Slice]: query = ( db.session.query(Dashboard) .outerjoin(Slice, Dashboard.slices) .outerjoin(Slice.table) - .filter(Dashboard.id == dashboard_id) + .filter(id_or_slug_filter(id_or_slug)) .options(contains_eager(Dashboard.slices)) ) # Apply dashboard base filters diff --git a/superset/views/core.py b/superset/views/core.py index 26fb722447344..c9b1fb3cadf1f 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -807,6 +807,7 @@ def explore( # pylint: disable=too-many-locals,too-many-return-statements,too-m "slice": slc.data if slc else None, "standalone": standalone_mode, "user_id": user_id, + "user": bootstrap_user_data(g.user, include_perms=True), "forced_height": request.args.get("height"), "common": common_bootstrap_payload(), } @@ -1818,13 +1819,11 @@ def dashboard( # pylint: disable=too-many-locals if not dashboard: abort(404) - data = dashboard.full_data() - if config["ENABLE_ACCESS_REQUEST"]: - for datasource in data["datasources"].values(): + for datasource in dashboard.datasources: datasource = ConnectorRegistry.get_datasource( - datasource_type=datasource["type"], - datasource_id=datasource["id"], + datasource_type=datasource.type, + datasource_id=datasource.id, session=db.session(), ) if datasource and not security_manager.can_access_datasource( @@ -1843,10 +1842,6 @@ def dashboard( # pylint: disable=too-many-locals dash_edit_perm = check_ownership( dashboard, raise_if_false=False ) and security_manager.can_access("can_save_dash", "Superset") - dash_save_perm = security_manager.can_access("can_save_dash", "Superset") - superset_can_explore = security_manager.can_access("can_explore", "Superset") - superset_can_csv = security_manager.can_access("can_csv", "Superset") - slice_can_edit = security_manager.can_access("can_edit", "SliceModelView") standalone_mode = ReservedUrlParameters.is_standalone_mode() edit_mode = ( request.args.get(utils.ReservedUrlParameters.EDIT_MODE.value) == "true" @@ -1859,41 +1854,11 @@ def dashboard( # pylint: disable=too-many-locals edit_mode=edit_mode, ) - if is_feature_enabled("REMOVE_SLICE_LEVEL_LABEL_COLORS"): - # dashboard metadata has dashboard-level label_colors, - # so remove slice-level label_colors from its form_data - for slc in data["slices"]: - form_data = slc.get("form_data") - form_data.pop("label_colors", None) - - url_params = { - key: value - for key, value in request.args.items() - if key not in [param.value for param in utils.ReservedUrlParameters] - } - bootstrap_data = { - "user_id": g.user.get_id(), + "user": bootstrap_user_data(g.user, include_perms=True), "common": common_bootstrap_payload(), - "editMode": edit_mode, - "urlParams": url_params, - "dashboard_data": { - **data["dashboard"], - "standalone_mode": standalone_mode, - "dash_save_perm": dash_save_perm, - "dash_edit_perm": dash_edit_perm, - "superset_can_explore": superset_can_explore, - "superset_can_csv": superset_can_csv, - "slice_can_edit": slice_can_edit, - }, - "datasources": data["datasources"], } - if request.args.get("json") == "true": - return json_success( - json.dumps(bootstrap_data, default=utils.pessimistic_json_iso_dttm_ser) - ) - return self.render_template( "superset/dashboard.html", entry="dashboard", diff --git a/tests/dashboard_tests.py b/tests/dashboard_tests.py index f24340095b1a0..97d9e7b5440aa 100644 --- a/tests/dashboard_tests.py +++ b/tests/dashboard_tests.py @@ -128,24 +128,9 @@ def test_new_dashboard(self): dash_count_before = db.session.query(func.count(Dashboard.id)).first()[0] url = "/dashboard/new/" resp = self.get_resp(url) - self.assertIn("[ untitled dashboard ]", resp) dash_count_after = db.session.query(func.count(Dashboard.id)).first()[0] self.assertEqual(dash_count_before + 1, dash_count_after) - @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices") - def test_dashboard_modes(self): - self.login(username="admin") - dash = db.session.query(Dashboard).filter_by(slug="births").first() - url = dash.url - if dash.url.find("?") == -1: - url += "?" - else: - url += "&" - resp = self.get_resp(url + "edit=true&standalone=true") - self.assertIn("editMode": true", resp) - self.assertIn("standalone_mode": true", resp) - self.assertIn('', resp) - @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices") def test_save_dash(self, username="admin"): self.login(username=username) @@ -190,9 +175,6 @@ def test_save_dash_with_filter(self, username="admin"): self.assertIn("world_health", new_url) self.assertNotIn("preselect_filters", new_url) - resp = self.get_resp(new_url) - self.assertIn("North America", resp) - @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices") def test_save_dash_with_invalid_filters(self, username="admin"): self.login(username=username) @@ -408,8 +390,6 @@ def test_public_user_dashboard_access(self): resp = self.get_resp("/api/v1/dashboard/") self.assertIn("/superset/dashboard/births/", resp) - self.assertIn("Births", self.get_resp("/superset/dashboard/births/")) - # Confirm that public doesn't have access to other datasets. resp = self.get_resp("/api/v1/chart/") self.assertNotIn("wb_health_population", resp) diff --git a/tests/dashboards/api_tests.py b/tests/dashboards/api_tests.py index fadabe2c5fe0f..4a329e4776bb0 100644 --- a/tests/dashboards/api_tests.py +++ b/tests/dashboards/api_tests.py @@ -238,6 +238,22 @@ def test_get_dashboard_charts(self): data["result"][0]["slice_name"], dashboard.slices[0].slice_name ) + @pytest.mark.usefixtures("create_dashboards") + def test_get_dashboard_charts_by_slug(self): + """ + Dashboard API: Test getting charts belonging to a dashboard + """ + self.login(username="admin") + dashboard = self.dashboards[0] + uri = f"api/v1/dashboard/{dashboard.slug}/charts" + response = self.get_assert_metric(uri, "get_charts") + self.assertEqual(response.status_code, 200) + data = json.loads(response.data.decode("utf-8")) + self.assertEqual(len(data["result"]), 1) + self.assertEqual( + data["result"][0]["slice_name"], dashboard.slices[0].slice_name + ) + @pytest.mark.usefixtures("create_dashboards") def test_get_dashboard_charts_not_found(self): """