chore(spa refactor): refactoring dashboard to use api's instead of bootstrapdata (#13306)

* add hook for future async api calls

* test to see conflict

* add async middleware and update reducers

* working async dashboard load

* implement getcharts api

* add user permissions to explore and dashboard bootstrap data

* integrate api calls with getinitial state

* update namings

* accept an id or a slug in the dashboard charts api

* add permissions function

* fix merge

* update state

* get dashboard charts by id or slug

* fix undefined states

* variable names

* stop using some more bootstrap data

* fix metadata reference

* remove unused bootstrap from the template

* add errorboundry to dashboard

* refactoring, fixing

* update permissions

* add just roles

* id is supposed to be a string

* unused vars

* get datasources from api

* make onError optional

* use resource hooks, better error boundary

* add loading state for dashboardroute

* remove console

* add conditional

* more conditionals

* testing out a possible fix for cypress

* convert edit/standalone test to cypress

* remove bootstrappy assertions

* lint

* fix dashboard edit history issue

* rename stuff

* address recent native filters schema change

* remove unused getInitialState

* remove .only from test

* hooksy redux usage

* Revert "more conditionals"

This reverts commit 25c8ed61b477ce30f87746af4421ce1bf4d5ea99.

* cleanup

* undo unnecessary change

* actually need conditions here

* certainty

* Revert "certainty"

This reverts commit 77dea1915beee2761c9fc214f1196ef8d79832c8.

* more permutations (untested yolo)

* Update superset-frontend/src/chart/chartReducer.ts

Co-authored-by: Evan Rusackas <evan@preset.io>

* import style

* comment

* cleaner dashboardInfo

* remove debug code

* use memo for getPermissions

* fix lint

* adjust name/location of DashboardPage

* move logic for REMOVE_SLICE_LEVEL_LABEL_COLORS to DAO

* stop using full_data()

* remove unused (and now useless) json=true query param

Co-authored-by: David Aaron Suddjian <aasuddjian@gmail.com>
Co-authored-by: David Aaron Suddjian <1858430+suddjian@users.noreply.github.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
This commit is contained in:
Phillip Kelley-Dotson 2021-04-12 16:10:29 -07:00 committed by GitHub
parent 89f5785666
commit 4bb29b6f04
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 530 additions and 183 deletions

View File

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

View File

@ -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();

View File

@ -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,

View File

@ -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<Dashboard>(`/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`);

View File

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

View File

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

View File

@ -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 }) => (
<Provider store={store}>
<DndProvider backend={HTML5Backend}>
<ThemeProvider theme={theme}>
<DynamicPluginProvider>
<DashboardContainer />
</DynamicPluginProvider>
</ThemeProvider>
</DndProvider>
</Provider>
);
const App = ({ store }) => {
const dashboardIdOrSlug = window.location.pathname.split('/')[3];
return (
<Provider store={store}>
<DndProvider backend={HTML5Backend}>
<ThemeProvider theme={theme}>
<DynamicPluginProvider>
<DashboardPage
store={store}
dashboardIdOrSlug={dashboardIdOrSlug}
/>
</DynamicPluginProvider>
</ThemeProvider>
</DndProvider>
</Provider>
);
};
export default hot(App);

View File

@ -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: 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(),
},
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,
},
dashboardLayout,
messageToasts: [],
impressionId: shortid.generate(),
};
}
});
};

View File

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

View File

@ -123,7 +123,6 @@ class DashboardGrid extends React.PureComponent {
width,
isComponentVisible,
} = this.props;
const columnPlusGutterWidth =
(width + GRID_GUTTER_SIZE) / GRID_COLUMN_COUNT;

View File

@ -140,7 +140,7 @@ class SaveModal extends React.PureComponent<SaveModalProps, SaveModalState> {
// 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,

View File

@ -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,

View File

@ -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<DashboardRouteProps> = ({
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 <Loading />;
return <DashboardContainer />;
};
const DashboardPageWithErrorBoundary = ({
dashboardIdOrSlug,
}: DashboardRouteProps) => (
<ErrorBoundary>
<DashboardPage dashboardIdOrSlug={dashboardIdOrSlug} />
</ErrorBoundary>
);
export default DashboardPageWithErrorBoundary;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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<string>) =>
permission[0] === perm && permission[1] === view,
),
);
});
console.log('bool', bool);
return bool;
});
}

View File

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

View File

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

View File

@ -139,6 +139,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(

View File

@ -307,7 +307,7 @@ class DashboardRestApi(BaseSupersetModelRestApi):
except DashboardNotFoundError:
return self.response_404()
@expose("/<pk>/charts", methods=["GET"])
@expose("/<id_or_slug>/charts", methods=["GET"])
@protect()
@safe
@statsd_metrics
@ -315,7 +315,7 @@ class DashboardRestApi(BaseSupersetModelRestApi):
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 @@ class DashboardRestApi(BaseSupersetModelRestApi):
parameters:
- in: path
schema:
type: integer
name: pk
type: string
name: id_or_slug
responses:
200:
description: Dashboard chart definitions
@ -348,8 +348,16 @@ class DashboardRestApi(BaseSupersetModelRestApi):
$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()

View File

@ -82,12 +82,12 @@ class DashboardDAO(BaseDAO):
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

View File

@ -800,6 +800,7 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
"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(),
}
@ -1811,13 +1812,11 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
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(
@ -1836,10 +1835,6 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
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"
@ -1852,41 +1847,11 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
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",

View File

@ -128,24 +128,9 @@ class TestDashboard(SupersetTestCase):
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&#34;: true", resp)
self.assertIn("standalone_mode&#34;: true", resp)
self.assertIn('<body class="standalone">', 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 @@ class TestDashboard(SupersetTestCase):
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 @@ class TestDashboard(SupersetTestCase):
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)

View File

@ -238,6 +238,22 @@ class TestDashboardApi(SupersetTestCase, ApiOwnersTestCaseMixin, InsertChartMixi
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):
"""