fix: Save properties after applying changes in Dashboard (#17570)

* Refactor PropertiesModal

* Update json_metadata fully

* Clean up

* Verify values

* Catch changed to metadata

* Always updated dashboard info on update

* Avoid unnecessary fetches

* Formt

* Fix copy dashboards

* Fixes onUpdate onCopy handlers

* Pylint

* Update tests

* Clean up

* Handle data on show

* Change Save to Apply

* Update Cypress save test

* Update Cypress edit prop test

* Update PropertiesModal test

* Fix duplicate request with cross filters

* Improve code style

* Fix typo

* Lint
This commit is contained in:
Geido 2021-12-09 19:03:07 +02:00 committed by GitHub
parent f476ba23a2
commit 12bd1fcde5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 979 additions and 830 deletions

View File

@ -97,17 +97,13 @@ describe('Dashboard edit action', () => {
.get('[data-test="dashboard-title-input"]')
.type(`{selectall}{backspace}${dashboardTitle}`);
cy.wait('@dashboardGet').then(() => {
selectColorScheme('d3Category20b');
});
// save edit changes
cy.get('.ant-modal-footer')
.contains('Save')
.contains('Apply')
.click()
.then(() => {
// assert that modal edit window has closed
cy.get('.ant-modal-body').should('not.exist');
cy.get('.ant-modal-body').should('not.be.visible');
// assert title has been updated
cy.get('.editable-title input').should('have.value', dashboardTitle);

View File

@ -71,28 +71,26 @@ describe('Dashboard save action', () => {
cy.get('[aria-label="edit-alt"]').click({ timeout: 5000 });
cy.get('[data-test="dashboard-delete-component-button"]')
.last()
.trigger('moustenter')
.trigger('mouseenter')
.click();
cy.get('[data-test="grid-container"]')
.find('.box_plot')
.should('not.exist');
cy.get('[data-test="grid-container"]').find('.treemap').should('not.exist');
cy.intercept('POST', '/superset/save_dash/**/').as('saveRequest');
cy.intercept('PUT', '/api/v1/dashboard/**').as('putDashboardRequest');
cy.get('[data-test="dashboard-header"]')
.find('[data-test="header-save-button"]')
.contains('Save')
.click();
// go back to view mode
cy.wait('@saveRequest');
cy.wait('@putDashboardRequest');
cy.get('[data-test="dashboard-header"]')
.find('[aria-label="edit-alt"]')
.click();
// deleted boxplot should still not exist
// deleted treemap should still not exist
cy.get('[data-test="grid-container"]')
.find('.box_plot', { timeout: 20000 })
.find('.treemap', { timeout: 20000 })
.should('not.exist');
});

View File

@ -59,7 +59,8 @@ fetchMock.get('glob:*/api/v1/dashboard/*', {
},
});
describe('PropertiesModal', () => {
// all these tests need to be moved to dashboard/components/PropertiesModal/PropertiesModal.test.tsx
describe.skip('PropertiesModal', () => {
afterEach(() => {
jest.restoreAllMocks();
jest.resetAllMocks();

View File

@ -18,7 +18,7 @@
*/
/* eslint camelcase: 0 */
import { ActionCreators as UndoActionCreators } from 'redux-undo';
import { t, SupersetClient } from '@superset-ui/core';
import { ensureIsArray, t, SupersetClient } from '@superset-ui/core';
import { addChart, removeChart, refreshChart } from 'src/chart/chartAction';
import { chart as initChart } from 'src/chart/chartReducer';
import { applyDefaultFormData } from 'src/explore/store';
@ -35,7 +35,11 @@ import { getActiveFilters } from 'src/dashboard/util/activeDashboardFilters';
import { safeStringify } from 'src/utils/safeStringify';
import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags';
import { UPDATE_COMPONENTS_PARENTS_LIST } from './dashboardLayout';
import { setChartConfiguration } from './dashboardInfo';
import {
setChartConfiguration,
dashboardInfoChanged,
SET_CHART_CONFIG_COMPLETE,
} from './dashboardInfo';
import { fetchDatasourceMetadata } from './datasources';
import {
addFilter,
@ -176,8 +180,6 @@ export function saveDashboardRequestSuccess(lastModifiedTime) {
}
export function saveDashboardRequest(data, id, saveType) {
const path = saveType === SAVE_TYPE_OVERWRITE ? 'save_dash' : 'copy_dash';
return (dispatch, getState) => {
dispatch({ type: UPDATE_COMPONENTS_PARENTS_LIST });
@ -194,52 +196,178 @@ export function saveDashboardRequest(data, id, saveType) {
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;
// 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 || '',
expanded_slices: data.metadata?.expanded_slices || {},
label_colors: data.metadata?.label_colors || {},
refresh_frequency: data.metadata?.refresh_frequency || 0,
timed_refresh_immune_slices:
data.metadata?.timed_refresh_immune_slices || [],
},
};
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(addSuccessToast(t('This dashboard was saved successfully.')));
return response;
};
const onUpdateSuccess = response => {
const updatedDashboard = response.json.result;
const lastModifiedTime = response.json.last_modified_time;
// synching 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 (lastModifiedTime) {
dispatch(saveDashboardRequestSuccess(lastModifiedTime));
}
// 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.')));
return response;
};
const onError = async response => {
const { error, message } = await getClientErrorObject(response);
let errorText = t('Sorry, an unknown error occured');
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(addDangerToast(errorText));
};
if (saveType === SAVE_TYPE_OVERWRITE) {
let chartConfiguration = {};
if (isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS)) {
chartConfiguration = handleChartConfiguration();
}
const updatedDashboard = {
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,
}),
};
return SupersetClient.put({
endpoint: `/api/v1/dashboard/${id}`,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedDashboard),
})
.then(response => onUpdateSuccess(response))
.catch(response => onError(response));
}
// 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/${path}/${id}/`,
endpoint: `/superset/copy_dash/${id}/`,
postPayload: {
data: {
...data,
...finalCopyData,
default_filters: safeStringify(serializedFilters),
filter_scopes: safeStringify(serializedFilterScopes),
},
},
})
.then(response => {
if (isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS)) {
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;
},
{},
);
dispatch(setChartConfiguration(chartConfiguration));
}
dispatch(saveDashboardRequestSuccess(response.json.last_modified_time));
dispatch(addSuccessToast(t('This dashboard was saved successfully.')));
return response;
})
.catch(response =>
getClientErrorObject(response).then(({ error }) =>
dispatch(
addDangerToast(
t('Sorry, there was an error saving this dashboard: %s', error),
),
),
),
);
.then(response => onCopySuccess(response))
.catch(response => onError(response));
};
}

View File

@ -117,11 +117,12 @@ const REPORT_ENDPOINT = 'glob:*/api/v1/report*';
fetchMock.get('glob:*/csstemplateasyncmodelview/api/read', {});
fetchMock.get(REPORT_ENDPOINT, {});
function setup(props: HeaderProps) {
return (
function setup(props: HeaderProps, initialState = {}) {
return render(
<div className="dashboard">
<Header {...props} />
</div>
</div>,
{ useRedux: true, initialState },
);
}
@ -133,23 +134,23 @@ async function openActionsDropdown() {
test('should render', () => {
const mockedProps = createProps();
const { container } = render(setup(mockedProps));
const { container } = setup(mockedProps);
expect(container).toBeInTheDocument();
});
test('should render the title', () => {
const mockedProps = createProps();
render(setup(mockedProps));
setup(mockedProps);
expect(screen.getByText('Dashboard Title')).toBeInTheDocument();
});
test('should render the editable title', () => {
render(setup(editableProps));
setup(editableProps);
expect(screen.getByDisplayValue('Dashboard Title')).toBeInTheDocument();
});
test('should edit the title', () => {
render(setup(editableProps));
setup(editableProps);
const editableTitle = screen.getByDisplayValue('Dashboard Title');
expect(editableProps.onChange).not.toHaveBeenCalled();
userEvent.click(editableTitle);
@ -162,12 +163,12 @@ test('should edit the title', () => {
test('should render the "Draft" status', () => {
const mockedProps = createProps();
render(setup(mockedProps));
setup(mockedProps);
expect(screen.getByText('Draft')).toBeInTheDocument();
});
test('should publish', () => {
render(setup(editableProps));
setup(editableProps);
const draft = screen.getByText('Draft');
expect(editableProps.savePublished).not.toHaveBeenCalled();
userEvent.click(draft);
@ -175,12 +176,12 @@ test('should publish', () => {
});
test('should render the "Undo" action as disabled', () => {
render(setup(editableProps));
setup(editableProps);
expect(screen.getByTitle('Undo').parentElement).toBeDisabled();
});
test('should undo', () => {
render(setup(undoProps));
setup(undoProps);
const undo = screen.getByTitle('Undo');
expect(undoProps.onUndo).not.toHaveBeenCalled();
userEvent.click(undo);
@ -189,19 +190,19 @@ test('should undo', () => {
test('should undo with key listener', () => {
undoProps.onUndo.mockReset();
render(setup(undoProps));
setup(undoProps);
expect(undoProps.onUndo).not.toHaveBeenCalled();
fireEvent.keyDown(document.body, { key: 'z', code: 'KeyZ', ctrlKey: true });
expect(undoProps.onUndo).toHaveBeenCalledTimes(1);
});
test('should render the "Redo" action as disabled', () => {
render(setup(editableProps));
setup(editableProps);
expect(screen.getByTitle('Redo').parentElement).toBeDisabled();
});
test('should redo', () => {
render(setup(redoProps));
setup(redoProps);
const redo = screen.getByTitle('Redo');
expect(redoProps.onRedo).not.toHaveBeenCalled();
userEvent.click(redo);
@ -210,19 +211,19 @@ test('should redo', () => {
test('should redo with key listener', () => {
redoProps.onRedo.mockReset();
render(setup(redoProps));
setup(redoProps);
expect(redoProps.onRedo).not.toHaveBeenCalled();
fireEvent.keyDown(document.body, { key: 'y', code: 'KeyY', ctrlKey: true });
expect(redoProps.onRedo).toHaveBeenCalledTimes(1);
});
test('should render the "Discard changes" button', () => {
render(setup(editableProps));
setup(editableProps);
expect(screen.getByText('Discard changes')).toBeInTheDocument();
});
test('should render the "Save" button as disabled', () => {
render(setup(editableProps));
setup(editableProps);
expect(screen.getByText('Save').parentElement).toBeDisabled();
});
@ -231,7 +232,7 @@ test('should save', () => {
...editableProps,
hasUnsavedChanges: true,
};
render(setup(unsavedProps));
setup(unsavedProps);
const save = screen.getByText('Save');
expect(unsavedProps.onSave).not.toHaveBeenCalled();
userEvent.click(save);
@ -244,13 +245,13 @@ test('should NOT render the "Draft" status', () => {
...mockedProps,
isPublished: true,
};
render(setup(publishedProps));
setup(publishedProps);
expect(screen.queryByText('Draft')).not.toBeInTheDocument();
});
test('should render the unselected fave icon', () => {
const mockedProps = createProps();
render(setup(mockedProps));
setup(mockedProps);
expect(mockedProps.fetchFaveStar).toHaveBeenCalled();
expect(
screen.getByRole('img', { name: 'favorite-unselected' }),
@ -263,7 +264,7 @@ test('should render the selected fave icon', () => {
...mockedProps,
isStarred: true,
};
render(setup(favedProps));
setup(favedProps);
expect(
screen.getByRole('img', { name: 'favorite-selected' }),
).toBeInTheDocument();
@ -275,7 +276,7 @@ test('should NOT render the fave icon on anonymous user', () => {
...mockedProps,
user: undefined,
};
render(setup(anonymousUserProps));
setup(anonymousUserProps);
expect(() =>
screen.getByRole('img', { name: 'favorite-unselected' }),
).toThrowError('Unable to find');
@ -286,7 +287,7 @@ test('should NOT render the fave icon on anonymous user', () => {
test('should fave', async () => {
const mockedProps = createProps();
render(setup(mockedProps));
setup(mockedProps);
const fave = screen.getByRole('img', { name: 'favorite-unselected' });
expect(mockedProps.saveFaveStar).not.toHaveBeenCalled();
userEvent.click(fave);
@ -302,7 +303,7 @@ test('should toggle the edit mode', () => {
dash_edit_perm: true,
},
};
render(setup(canEditProps));
setup(canEditProps);
const editDashboard = screen.getByTitle('Edit dashboard');
expect(screen.queryByTitle('Edit dashboard')).toBeInTheDocument();
userEvent.click(editDashboard);
@ -311,13 +312,13 @@ test('should toggle the edit mode', () => {
test('should render the dropdown icon', () => {
const mockedProps = createProps();
render(setup(mockedProps));
setup(mockedProps);
expect(screen.getByRole('img', { name: 'more-horiz' })).toBeInTheDocument();
});
test('should refresh the charts', async () => {
const mockedProps = createProps();
render(setup(mockedProps));
setup(mockedProps);
await openActionsDropdown();
userEvent.click(screen.getByText('Refresh dashboard'));
expect(mockedProps.onRefresh).toHaveBeenCalledTimes(1);
@ -341,7 +342,7 @@ describe('Email Report Modal', () => {
it('creates a new email report', async () => {
// ---------- Render/value setup ----------
const mockedProps = createProps();
render(setup(mockedProps), { useRedux: true });
setup(mockedProps);
const reportValues = {
id: 1,
@ -423,10 +424,7 @@ describe('Email Report Modal', () => {
};
// getMockStore({ reports: reportValues });
render(setup(mockedProps), {
useRedux: true,
initialState: mockState,
});
setup(mockedProps, mockState);
// TODO (lyndsiWilliams): currently fetchMock detects this PUT
// address as 'glob:*/api/v1/report/undefined', is not detected
// on fetchMock.calls()
@ -468,7 +466,7 @@ describe('Email Report Modal', () => {
it('Should render report header', async () => {
const mockedProps = createProps();
render(setup(mockedProps));
setup(mockedProps);
expect(
screen.getByRole('button', { name: 'Schedule email report' }),
).toBeInTheDocument();
@ -487,7 +485,7 @@ describe('Email Report Modal', () => {
},
},
};
render(setup(anonymousUserProps));
setup(anonymousUserProps);
expect(
screen.queryByRole('button', { name: 'Schedule email report' }),
).not.toBeInTheDocument();

View File

@ -340,36 +340,38 @@ class Header extends React.PureComponent {
const {
dashboardTitle,
layout: positions,
expandedSlices,
customCss,
colorNamespace,
colorScheme,
colorNamespace,
customCss,
dashboardInfo,
refreshFrequency: currentRefreshFrequency,
shouldPersistRefreshFrequency,
lastModifiedTime,
slug,
} = this.props;
const labelColors =
colorScheme && dashboardInfo?.metadata?.label_colors
? dashboardInfo.metadata.label_colors
: {};
// check refresh frequency is for current session or persist
const refreshFrequency = shouldPersistRefreshFrequency
? currentRefreshFrequency
: dashboardInfo.metadata?.refresh_frequency; // eslint-disable-line camelcase
: dashboardInfo.metadata?.refresh_frequency;
const data = {
positions,
expanded_slices: expandedSlices,
certified_by: dashboardInfo.certified_by,
certification_details: dashboardInfo.certification_details,
css: customCss,
color_namespace: colorNamespace,
color_scheme: colorScheme,
label_colors: labelColors,
dashboard_title: dashboardTitle,
refresh_frequency: refreshFrequency,
last_modified_time: lastModifiedTime,
owners: dashboardInfo.owners,
roles: dashboardInfo.roles,
slug,
metadata: {
...dashboardInfo?.metadata,
color_namespace:
dashboardInfo?.metadata?.color_namespace || colorNamespace,
color_scheme: dashboardInfo?.metadata?.color_scheme || colorScheme,
positions,
refresh_frequency: refreshFrequency,
},
};
// make sure positions data less than DB storage limitation:
@ -492,6 +494,20 @@ class Header extends React.PureComponent {
dashboardInfo.common.conf
.SUPERSET_DASHBOARD_PERIODICAL_REFRESH_WARNING_MESSAGE;
const handleOnPropertiesChange = updates => {
const { dashboardInfoChanged, dashboardTitleChanged } = this.props;
dashboardInfoChanged({
slug: updates.slug,
metadata: JSON.parse(updates.jsonMetadata || '{}'),
certified_by: updates.certifiedBy,
certification_details: updates.certificationDetails,
owners: updates.owners,
roles: updates.roles,
});
setColorSchemeAndUnsavedChanges(updates.colorScheme);
dashboardTitleChanged(updates.title);
};
return (
<StyledDashboardHeader
className="dashboard-header"
@ -613,33 +629,16 @@ class Header extends React.PureComponent {
)}
{shouldShowReport && this.renderReportModal()}
{this.state.showingPropertiesModal && (
<PropertiesModal
dashboardId={dashboardInfo.id}
show={this.state.showingPropertiesModal}
onHide={this.hidePropertiesModal}
colorScheme={this.props.colorScheme}
onSubmit={updates => {
const { dashboardInfoChanged, dashboardTitleChanged } =
this.props;
dashboardInfoChanged({
slug: updates.slug,
metadata: JSON.parse(updates.jsonMetadata),
certified_by: updates.certifiedBy,
certification_details: updates.certificationDetails,
});
setColorSchemeAndUnsavedChanges(updates.colorScheme);
dashboardTitleChanged(updates.title);
if (updates.slug) {
window.history.pushState(
{ event: 'dashboard_properties_changed' },
'',
`/superset/dashboard/${updates.slug}/`,
);
}
}}
/>
)}
<PropertiesModal
dashboardId={dashboardInfo.id}
dashboardInfo={dashboardInfo}
dashboardTitle={dashboardTitle}
show={this.state.showingPropertiesModal}
onHide={this.hidePropertiesModal}
colorScheme={this.props.colorScheme}
onSubmit={handleOnPropertiesChange}
onlyApply
/>
{this.state.showingReportModal && (
<ReportModal

File diff suppressed because one or more lines are too long

View File

@ -1,614 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { Row, Col, Input } from 'src/common/components';
import { Form, FormItem } from 'src/components/Form';
import jsonStringify from 'json-stringify-pretty-compact';
import Button from 'src/components/Button';
import { Select } from 'src/components';
import rison from 'rison';
import {
styled,
t,
SupersetClient,
getCategoricalSchemeRegistry,
} from '@superset-ui/core';
import Modal from 'src/components/Modal';
import { JsonEditor } from 'src/components/AsyncAceEditor';
import ColorSchemeControlWrapper from 'src/dashboard/components/ColorSchemeControlWrapper';
import { getClientErrorObject } from 'src/utils/getClientErrorObject';
import withToasts from 'src/components/MessageToasts/withToasts';
import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags';
const StyledJsonEditor = styled(JsonEditor)`
border-radius: ${({ theme }) => theme.borderRadius}px;
border: 1px solid ${({ theme }) => theme.colors.secondary.light2};
`;
const propTypes = {
dashboardId: PropTypes.number.isRequired,
show: PropTypes.bool,
onHide: PropTypes.func,
colorScheme: PropTypes.string,
setColorSchemeAndUnsavedChanges: PropTypes.func,
onSubmit: PropTypes.func,
addSuccessToast: PropTypes.func.isRequired,
onlyApply: PropTypes.bool,
};
const defaultProps = {
onHide: () => {},
setColorSchemeAndUnsavedChanges: () => {},
onSubmit: () => {},
show: false,
colorScheme: undefined,
onlyApply: false,
};
const handleErrorResponse = async response => {
const { error, statusText, message } = await getClientErrorObject(response);
let errorText = error || statusText || t('An error has occurred');
if (typeof message === 'object' && message.json_metadata) {
errorText = message.json_metadata;
} else if (typeof message === 'string') {
errorText = message;
if (message === 'Forbidden') {
errorText = t('You do not have permission to edit this dashboard');
}
}
Modal.error({
title: 'Error',
content: errorText,
okButtonProps: { danger: true, className: 'btn-danger' },
});
};
const loadAccessOptions =
accessType =>
(input = '') => {
const query = rison.encode({ filter: input });
return SupersetClient.get({
endpoint: `/api/v1/dashboard/related/${accessType}?q=${query}`,
}).then(
response => ({
data: response.json.result.map(item => ({
value: item.value,
label: item.text,
})),
totalCount: response.json.count,
}),
badResponse => {
handleErrorResponse(badResponse);
return [];
},
);
};
const loadOwners = loadAccessOptions('owners');
const loadRoles = loadAccessOptions('roles');
class PropertiesModal extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
errors: [],
values: {
dashboard_title: '',
slug: '',
owners: [],
roles: [],
json_metadata: '',
colorScheme: props.colorScheme,
certified_by: '',
certification_details: '',
},
isDashboardLoaded: false,
isAdvancedOpen: false,
};
this.onChange = this.onChange.bind(this);
this.onMetadataChange = this.onMetadataChange.bind(this);
this.onOwnersChange = this.onOwnersChange.bind(this);
this.onRolesChange = this.onRolesChange.bind(this);
this.submit = this.submit.bind(this);
this.toggleAdvanced = this.toggleAdvanced.bind(this);
this.onColorSchemeChange = this.onColorSchemeChange.bind(this);
this.getRowsWithRoles = this.getRowsWithRoles.bind(this);
this.getRowsWithoutRoles = this.getRowsWithoutRoles.bind(this);
this.getJsonMetadata = this.getJsonMetadata.bind(this);
}
componentDidMount() {
this.fetchDashboardDetails();
JsonEditor.preload();
}
getJsonMetadata() {
const { json_metadata: jsonMetadata } = this.state.values;
try {
const jsonMetadataObj = jsonMetadata?.length
? JSON.parse(jsonMetadata)
: {};
return jsonMetadataObj;
} catch (_) {
return {};
}
}
onColorSchemeChange(colorScheme, { updateMetadata = true } = {}) {
// check that color_scheme is valid
const colorChoices = getCategoricalSchemeRegistry().keys();
const jsonMetadataObj = this.getJsonMetadata();
// only fire if the color_scheme is present and invalid
if (colorScheme && !colorChoices.includes(colorScheme)) {
Modal.error({
title: 'Error',
content: t('A valid color scheme is required'),
okButtonProps: { danger: true, className: 'btn-danger' },
});
throw new Error('A valid color scheme is required');
}
// update metadata to match selection
if (updateMetadata) {
jsonMetadataObj.color_scheme = colorScheme;
jsonMetadataObj.label_colors = jsonMetadataObj.label_colors || {};
this.onMetadataChange(jsonStringify(jsonMetadataObj));
}
this.updateFormState('colorScheme', colorScheme);
}
onOwnersChange(value) {
this.updateFormState('owners', value);
}
onRolesChange(value) {
this.updateFormState('roles', value);
}
onMetadataChange(metadata) {
this.updateFormState('json_metadata', metadata);
}
onChange(e) {
const { name, value } = e.target;
this.updateFormState(name, value);
}
fetchDashboardDetails() {
// We fetch the dashboard details because not all code
// that renders this component have all the values we need.
// At some point when we have a more consistent frontend
// datamodel, the dashboard could probably just be passed as a prop.
SupersetClient.get({
endpoint: `/api/v1/dashboard/${this.props.dashboardId}`,
}).then(response => {
const dashboard = response.json.result;
const jsonMetadataObj = dashboard.json_metadata?.length
? JSON.parse(dashboard.json_metadata)
: {};
this.setState(state => ({
isDashboardLoaded: true,
values: {
...state.values,
dashboard_title: dashboard.dashboard_title || '',
slug: dashboard.slug || '',
// format json with 2-space indentation
json_metadata: dashboard.json_metadata
? jsonStringify(jsonMetadataObj)
: '',
colorScheme: jsonMetadataObj.color_scheme,
certified_by: dashboard.certified_by || '',
certification_details: dashboard.certification_details || '',
},
}));
const initialSelectedOwners = dashboard.owners.map(owner => ({
value: owner.id,
label: `${owner.first_name} ${owner.last_name}`,
}));
const initialSelectedRoles = dashboard.roles.map(role => ({
value: role.id,
label: `${role.name}`,
}));
this.onOwnersChange(initialSelectedOwners);
this.onRolesChange(initialSelectedRoles);
}, handleErrorResponse);
}
updateFormState(name, value) {
this.setState(state => ({
values: {
...state.values,
[name]: value,
},
}));
}
toggleAdvanced() {
this.setState(state => ({
isAdvancedOpen: !state.isAdvancedOpen,
}));
}
submit(e) {
e.preventDefault();
e.stopPropagation();
const {
values: {
json_metadata: jsonMetadata,
slug,
dashboard_title: dashboardTitle,
colorScheme,
certified_by: certifiedBy,
certification_details: certificationDetails,
owners: ownersValue,
roles: rolesValue,
},
} = this.state;
const { onlyApply } = this.props;
const owners = ownersValue?.map(o => o.value) ?? [];
const roles = rolesValue?.map(o => o.value) ?? [];
let currentColorScheme = colorScheme;
// color scheme in json metadata has precedence over selection
if (jsonMetadata?.length) {
const metadata = JSON.parse(jsonMetadata);
currentColorScheme = metadata?.color_scheme || colorScheme;
}
this.onColorSchemeChange(currentColorScheme, {
updateMetadata: false,
});
const moreProps = {};
const morePutProps = {};
if (isFeatureEnabled(FeatureFlag.DASHBOARD_RBAC)) {
moreProps.rolesIds = roles;
morePutProps.roles = roles;
}
if (onlyApply) {
this.props.onSubmit({
id: this.props.dashboardId,
title: dashboardTitle,
slug,
jsonMetadata,
ownerIds: owners,
colorScheme: currentColorScheme,
certifiedBy,
certificationDetails,
...moreProps,
});
this.props.onHide();
} else {
SupersetClient.put({
endpoint: `/api/v1/dashboard/${this.props.dashboardId}`,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
dashboard_title: dashboardTitle,
slug: slug || null,
json_metadata: jsonMetadata || null,
owners,
certified_by: certifiedBy || null,
certification_details:
certifiedBy && certificationDetails ? certificationDetails : null,
...morePutProps,
}),
}).then(({ json: { result } }) => {
const moreResultProps = {};
if (isFeatureEnabled(FeatureFlag.DASHBOARD_RBAC)) {
moreResultProps.rolesIds = result.roles;
}
this.props.addSuccessToast(t('The dashboard has been saved'));
this.props.onSubmit({
id: this.props.dashboardId,
title: result.dashboard_title,
slug: result.slug,
jsonMetadata: result.json_metadata,
ownerIds: result.owners,
colorScheme: currentColorScheme,
certifiedBy: result.certified_by,
certificationDetails: result.certification_details,
...moreResultProps,
});
this.props.onHide();
}, handleErrorResponse);
}
}
getRowsWithoutRoles() {
const { values, isDashboardLoaded } = this.state;
const jsonMetadataObj = this.getJsonMetadata();
const hasCustomLabelColors = !!Object.keys(
jsonMetadataObj?.label_colors || {},
).length;
return (
<Row gutter={16}>
<Col xs={24} md={12}>
<h3 style={{ marginTop: '1em' }}>{t('Access')}</h3>
<FormItem label={t('Owners')}>
<Select
allowClear
ariaLabel={t('Owners')}
disabled={!isDashboardLoaded}
name="owners"
mode="multiple"
value={values.owners}
options={loadOwners}
onChange={this.onOwnersChange}
/>
<p className="help-block">
{t(
'Owners is a list of users who can alter the dashboard. Searchable by name or username.',
)}
</p>
</FormItem>
</Col>
<Col xs={24} md={12}>
<h3 style={{ marginTop: '1em' }}>{t('Colors')}</h3>
<ColorSchemeControlWrapper
hasCustomLabelColors={hasCustomLabelColors}
onChange={this.onColorSchemeChange}
colorScheme={values.colorScheme}
labelMargin={4}
/>
</Col>
</Row>
);
}
getRowsWithRoles() {
const { values, isDashboardLoaded } = this.state;
const jsonMetadataObj = this.getJsonMetadata();
const hasCustomLabelColors = !!Object.keys(
jsonMetadataObj?.label_colors || {},
).length;
return (
<>
<Row>
<Col xs={24} md={24}>
<h3 style={{ marginTop: '1em' }}>{t('Access')}</h3>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12}>
<FormItem label={t('Owners')}>
<Select
allowClear
ariaLabel={t('Owners')}
disabled={!isDashboardLoaded}
name="owners"
mode="multiple"
value={values.owners}
options={loadOwners}
onChange={this.onOwnersChange}
/>
<p className="help-block">
{t(
'Owners is a list of users who can alter the dashboard. Searchable by name or username.',
)}
</p>
</FormItem>
</Col>
<Col xs={24} md={12}>
<FormItem label={t('Roles')}>
<Select
allowClear
ariaLabel={t('Roles')}
disabled={!isDashboardLoaded}
name="roles"
mode="multiple"
value={values.roles}
options={loadRoles}
onChange={this.onRolesChange}
/>
<p className="help-block">
{t(
'Roles is a list which defines access to the dashboard. Granting a role access to a dashboard will bypass dataset level checks. If no roles defined then the dashboard is available to all roles.',
)}
</p>
</FormItem>
</Col>
</Row>
<Row>
<Col xs={24} md={12}>
<ColorSchemeControlWrapper
hasCustomLabelColors={hasCustomLabelColors}
onChange={this.onColorSchemeChange}
colorScheme={values.colorScheme}
labelMargin={4}
/>
</Col>
</Row>
</>
);
}
render() {
const { values, isDashboardLoaded, isAdvancedOpen, errors } = this.state;
const { onHide, onlyApply } = this.props;
const saveLabel = onlyApply ? t('Apply') : t('Save');
return (
<Modal
show={this.props.show}
onHide={this.props.onHide}
title={t('Dashboard properties')}
footer={
<>
<Button
htmlType="button"
buttonSize="small"
onClick={onHide}
data-test="properties-modal-cancel-button"
cta
>
{t('Cancel')}
</Button>
<Button
onClick={this.submit}
buttonSize="small"
buttonStyle="primary"
className="m-r-5"
disabled={errors.length > 0}
cta
>
{saveLabel}
</Button>
</>
}
responsive
>
<Form
data-test="dashboard-edit-properties-form"
onSubmit={this.submit}
layout="vertical"
>
<Row>
<Col xs={24} md={24}>
<h3>{t('Basic information')}</h3>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12}>
<FormItem label={t('Title')}>
<Input
data-test="dashboard-title-input"
name="dashboard_title"
type="text"
value={values.dashboard_title}
onChange={this.onChange}
disabled={!isDashboardLoaded}
/>
</FormItem>
</Col>
<Col xs={24} md={12}>
<FormItem label={t('URL slug')}>
<Input
name="slug"
type="text"
value={values.slug || ''}
onChange={this.onChange}
disabled={!isDashboardLoaded}
/>
<p className="help-block">
{t('A readable URL for your dashboard')}
</p>
</FormItem>
</Col>
</Row>
{isFeatureEnabled(FeatureFlag.DASHBOARD_RBAC)
? this.getRowsWithRoles()
: this.getRowsWithoutRoles()}
<Row>
<Col xs={24} md={24}>
<h3>{t('Certification')}</h3>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12}>
<FormItem label={t('Certified by')}>
<Input
aria-label={t('Certified by')}
name="certified_by"
type="text"
value={values.certified_by}
onChange={this.onChange}
disabled={!isDashboardLoaded}
/>
<p className="help-block">
{t('Person or group that has certified this dashboard.')}
</p>
</FormItem>
</Col>
<Col xs={24} md={12}>
<FormItem label={t('Certification details')}>
<Input
aria-label={t('Certification details')}
name="certification_details"
type="text"
value={values.certification_details || ''}
onChange={this.onChange}
disabled={!isDashboardLoaded}
/>
<p className="help-block">
{t(
'Any additional detail to show in the certification tooltip.',
)}
</p>
</FormItem>
</Col>
</Row>
<Row>
<Col xs={24} md={24}>
<h3 style={{ marginTop: '1em' }}>
<Button buttonStyle="link" onClick={this.toggleAdvanced}>
<i
className={`fa fa-angle-${
isAdvancedOpen ? 'down' : 'right'
}`}
style={{ minWidth: '1em' }}
/>
{t('Advanced')}
</Button>
</h3>
{isAdvancedOpen && (
<FormItem label={t('JSON metadata')}>
<StyledJsonEditor
showLoadingForImport
name="json_metadata"
defaultValue={this.defaultMetadataValue}
value={values.json_metadata}
onChange={this.onMetadataChange}
tabSize={2}
width="100%"
height="200px"
wrapEnabled
/>
<p className="help-block">
{t(
'This JSON object is generated dynamically when clicking the save or overwrite button in the dashboard view. It is exposed here for reference and for power users who may want to alter specific parameters.',
)}
</p>
</FormItem>
)}
</Col>
</Row>
</Form>
</Modal>
);
}
}
PropertiesModal.propTypes = propTypes;
PropertiesModal.defaultProps = defaultProps;
export default withToasts(PropertiesModal);

View File

@ -0,0 +1,603 @@
/**
* 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, { useCallback, useEffect, useState } from 'react';
import { Form, Row, Col, Input } from 'src/common/components';
import { FormItem } from 'src/components/Form';
import jsonStringify from 'json-stringify-pretty-compact';
import Button from 'src/components/Button';
import { Select } from 'src/components';
import rison from 'rison';
import {
styled,
t,
SupersetClient,
getCategoricalSchemeRegistry,
ensureIsArray,
} from '@superset-ui/core';
import Modal from 'src/components/Modal';
import { JsonEditor } from 'src/components/AsyncAceEditor';
import ColorSchemeControlWrapper from 'src/dashboard/components/ColorSchemeControlWrapper';
import { getClientErrorObject } from 'src/utils/getClientErrorObject';
import withToasts from 'src/components/MessageToasts/withToasts';
import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags';
const StyledFormItem = styled(FormItem)`
margin-bottom: 0;
`;
const StyledJsonEditor = styled(JsonEditor)`
border-radius: ${({ theme }) => theme.borderRadius}px;
border: 1px solid ${({ theme }) => theme.colors.secondary.light2};
`;
type PropertiesModalProps = {
dashboardId: number;
dashboardTitle?: string;
dashboardInfo?: Record<string, any>;
show?: boolean;
onHide?: () => void;
colorScheme?: string;
setColorSchemeAndUnsavedChanges?: () => void;
onSubmit?: (params: Record<string, any>) => void;
addSuccessToast: (message: string) => void;
onlyApply?: boolean;
};
type Roles = { id: number; name: string }[];
type Owners = {
id: number;
full_name?: string;
first_name?: string;
last_name?: string;
}[];
type DashboardInfo = {
id: number;
title: string;
slug: string;
certifiedBy: string;
certificationDetails: string;
};
const PropertiesModal = ({
addSuccessToast,
colorScheme: currentColorScheme,
dashboardId,
dashboardInfo: currentDashboardInfo,
dashboardTitle,
onHide = () => {},
onlyApply = false,
onSubmit = () => {},
show = false,
}: PropertiesModalProps) => {
const [form] = Form.useForm();
const [isLoading, setIsLoading] = useState(false);
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
const [colorScheme, setColorScheme] = useState(currentColorScheme);
const [jsonMetadata, setJsonMetadata] = useState('');
const [dashboardInfo, setDashboardInfo] = useState<DashboardInfo>();
const [owners, setOwners] = useState<Owners>([]);
const [roles, setRoles] = useState<Roles>([]);
const saveLabel = onlyApply ? t('Apply') : t('Save');
const handleErrorResponse = async (response: Response) => {
const { error, statusText, message } = await getClientErrorObject(response);
let errorText = error || statusText || t('An error has occurred');
if (typeof message === 'object' && 'json_metadata' in message) {
errorText = (message as { json_metadata: string }).json_metadata;
} else if (typeof message === 'string') {
errorText = message;
if (message === 'Forbidden') {
errorText = t('You do not have permission to edit this dashboard');
}
}
Modal.error({
title: 'Error',
content: errorText,
okButtonProps: { danger: true, className: 'btn-danger' },
});
};
const loadAccessOptions = useCallback(
(accessType = 'owners', input = '', page: number, pageSize: number) => {
const query = rison.encode({
filter: input,
page,
page_size: pageSize,
});
return SupersetClient.get({
endpoint: `/api/v1/dashboard/related/${accessType}?q=${query}`,
}).then(response => ({
data: response.json.result.map(
(item: { value: number; text: string }) => ({
value: item.value,
label: item.text,
}),
),
totalCount: response.json.count,
}));
},
[],
);
const handleDashboardData = useCallback(
dashboardData => {
const {
id,
dashboard_title,
slug,
certified_by,
certification_details,
owners,
roles,
metadata,
} = dashboardData;
const dashboardInfo = {
id,
title: dashboard_title,
slug: slug || '',
certifiedBy: certified_by || '',
certificationDetails: certification_details || '',
};
form.setFieldsValue(dashboardInfo);
setDashboardInfo(dashboardInfo);
setJsonMetadata(metadata ? jsonStringify(metadata) : '');
setOwners(owners);
setRoles(roles);
setColorScheme(metadata.color_scheme);
},
[form],
);
const fetchDashboardDetails = useCallback(() => {
setIsLoading(true);
// We fetch the dashboard details because not all code
// that renders this component have all the values we need.
// At some point when we have a more consistent frontend
// datamodel, the dashboard could probably just be passed as a prop.
SupersetClient.get({
endpoint: `/api/v1/dashboard/${dashboardId}`,
}).then(response => {
const dashboard = response.json.result;
const jsonMetadataObj = dashboard.json_metadata?.length
? JSON.parse(dashboard.json_metadata)
: {};
handleDashboardData({
...dashboard,
metadata: jsonMetadataObj,
});
setIsLoading(false);
}, handleErrorResponse);
}, [dashboardId, handleDashboardData]);
const getJsonMetadata = () => {
try {
const jsonMetadataObj = jsonMetadata?.length
? JSON.parse(jsonMetadata)
: {};
return jsonMetadataObj;
} catch (_) {
return {};
}
};
const handleOnChangeOwners = (owners: { value: number; label: string }[]) => {
const parsedOwners: Owners = ensureIsArray(owners).map(o => ({
id: o.value,
full_name: o.label,
}));
setOwners(parsedOwners);
};
const handleOnChangeRoles = (roles: { value: number; label: string }[]) => {
const parsedRoles: Roles = ensureIsArray(roles).map(r => ({
id: r.value,
name: r.label,
}));
setRoles(parsedRoles);
};
const handleOwnersSelectValue = () => {
const parsedOwners = (owners || []).map(
(owner: {
id: number;
first_name?: string;
last_name?: string;
full_name?: string;
}) => ({
value: owner.id,
label: owner.full_name || `${owner.first_name} ${owner.last_name}`,
}),
);
return parsedOwners;
};
const handleRolesSelectValue = () => {
const parsedRoles = (roles || []).map(
(role: { id: number; name: string }) => ({
value: role.id,
label: `${role.name}`,
}),
);
return parsedRoles;
};
const onColorSchemeChange = (
colorScheme?: string,
{ updateMetadata = true } = {},
) => {
// check that color_scheme is valid
const colorChoices = getCategoricalSchemeRegistry().keys();
const jsonMetadataObj = getJsonMetadata();
// only fire if the color_scheme is present and invalid
if (colorScheme && !colorChoices.includes(colorScheme)) {
Modal.error({
title: 'Error',
content: t('A valid color scheme is required'),
okButtonProps: { danger: true, className: 'btn-danger' },
});
throw new Error('A valid color scheme is required');
}
// update metadata to match selection
if (updateMetadata) {
jsonMetadataObj.color_scheme = colorScheme;
jsonMetadataObj.label_colors = jsonMetadataObj.label_colors || {};
setJsonMetadata(jsonStringify(jsonMetadataObj));
}
setColorScheme(colorScheme);
};
const onFinish = () => {
const { title, slug, certifiedBy, certificationDetails } =
form.getFieldsValue();
let currentColorScheme = colorScheme;
let colorNamespace = '';
// color scheme in json metadata has precedence over selection
if (jsonMetadata?.length) {
const metadata = JSON.parse(jsonMetadata);
currentColorScheme = metadata?.color_scheme || colorScheme;
colorNamespace = metadata?.color_namespace || '';
}
onColorSchemeChange(currentColorScheme, {
updateMetadata: false,
});
const moreOnSubmitProps: { roles?: Roles } = {};
const morePutProps: { roles?: number[] } = {};
if (isFeatureEnabled(FeatureFlag.DASHBOARD_RBAC)) {
moreOnSubmitProps.roles = roles;
morePutProps.roles = (roles || []).map(r => r.id);
}
const onSubmitProps = {
id: dashboardId,
title,
slug,
jsonMetadata,
owners,
colorScheme: currentColorScheme,
colorNamespace,
certifiedBy,
certificationDetails,
...moreOnSubmitProps,
};
if (onlyApply) {
onSubmit(onSubmitProps);
onHide();
} else {
SupersetClient.put({
endpoint: `/api/v1/dashboard/${dashboardId}`,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
dashboard_title: title,
slug: slug || null,
json_metadata: jsonMetadata || null,
owners: (owners || []).map(o => o.id),
certified_by: certifiedBy || null,
certification_details:
certifiedBy && certificationDetails ? certificationDetails : null,
...morePutProps,
}),
}).then(() => {
addSuccessToast(t('The dashboard has been saved'));
onSubmit(onSubmitProps);
onHide();
}, handleErrorResponse);
}
};
const getRowsWithoutRoles = () => {
const jsonMetadataObj = getJsonMetadata();
const hasCustomLabelColors = !!Object.keys(
jsonMetadataObj?.label_colors || {},
).length;
return (
<Row gutter={16}>
<Col xs={24} md={12}>
<h3 style={{ marginTop: '1em' }}>{t('Access')}</h3>
<StyledFormItem label={t('Owners')}>
<Select
allowClear
ariaLabel={t('Owners')}
disabled={isLoading}
mode="multiple"
onChange={handleOnChangeOwners}
options={(input, page, pageSize) =>
loadAccessOptions('owners', input, page, pageSize)
}
value={handleOwnersSelectValue()}
/>
</StyledFormItem>
<p className="help-block">
{t(
'Owners is a list of users who can alter the dashboard. Searchable by name or username.',
)}
</p>
</Col>
<Col xs={24} md={12}>
<h3 style={{ marginTop: '1em' }}>{t('Colors')}</h3>
<ColorSchemeControlWrapper
hasCustomLabelColors={hasCustomLabelColors}
onChange={onColorSchemeChange}
colorScheme={colorScheme}
labelMargin={4}
/>
</Col>
</Row>
);
};
const getRowsWithRoles = () => {
const jsonMetadataObj = getJsonMetadata();
const hasCustomLabelColors = !!Object.keys(
jsonMetadataObj?.label_colors || {},
).length;
return (
<>
<Row>
<Col xs={24} md={24}>
<h3 style={{ marginTop: '1em' }}>{t('Access')}</h3>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12}>
<StyledFormItem label={t('Owners')}>
<Select
allowClear
ariaLabel={t('Owners')}
disabled={isLoading}
mode="multiple"
onChange={handleOnChangeOwners}
options={(input, page, pageSize) =>
loadAccessOptions('owners', input, page, pageSize)
}
value={handleOwnersSelectValue()}
/>
</StyledFormItem>
<p className="help-block">
{t(
'Owners is a list of users who can alter the dashboard. Searchable by name or username.',
)}
</p>
</Col>
<Col xs={24} md={12}>
<StyledFormItem label={t('Roles')}>
<Select
allowClear
ariaLabel={t('Roles')}
disabled={isLoading}
mode="multiple"
onChange={handleOnChangeRoles}
options={(input, page, pageSize) =>
loadAccessOptions('roles', input, page, pageSize)
}
value={handleRolesSelectValue()}
/>
</StyledFormItem>
<p className="help-block">
{t(
'Roles is a list which defines access to the dashboard. Granting a role access to a dashboard will bypass dataset level checks. If no roles are defined, then the dashboard is available to all roles.',
)}
</p>
</Col>
</Row>
<Row>
<Col xs={24} md={12}>
<ColorSchemeControlWrapper
hasCustomLabelColors={hasCustomLabelColors}
onChange={onColorSchemeChange}
colorScheme={colorScheme}
labelMargin={4}
/>
</Col>
</Row>
</>
);
};
useEffect(() => {
if (show) {
if (!currentDashboardInfo) {
fetchDashboardDetails();
} else {
handleDashboardData(currentDashboardInfo);
}
}
JsonEditor.preload();
}, [currentDashboardInfo, fetchDashboardDetails, handleDashboardData, show]);
useEffect(() => {
// the title can be changed inline in the dashboard, this catches it
if (
dashboardTitle &&
dashboardInfo &&
dashboardInfo.title !== dashboardTitle
) {
form.setFieldsValue({
...dashboardInfo,
title: dashboardTitle,
});
}
}, [dashboardInfo, dashboardTitle, form]);
return (
<Modal
show={show}
onHide={onHide}
title={t('Dashboard properties')}
footer={
<>
<Button
htmlType="button"
buttonSize="small"
onClick={onHide}
data-test="properties-modal-cancel-button"
cta
>
{t('Cancel')}
</Button>
<Button
onClick={form.submit}
buttonSize="small"
buttonStyle="primary"
className="m-r-5"
cta
>
{saveLabel}
</Button>
</>
}
responsive
>
<Form
form={form}
onFinish={onFinish}
data-test="dashboard-edit-properties-form"
layout="vertical"
initialValues={dashboardInfo}
>
<Row>
<Col xs={24} md={24}>
<h3>{t('Basic information')}</h3>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12}>
<FormItem label={t('Title')} name="title">
<Input
data-test="dashboard-title-input"
type="text"
disabled={isLoading}
/>
</FormItem>
</Col>
<Col xs={24} md={12}>
<StyledFormItem label={t('URL slug')} name="slug">
<Input type="text" disabled={isLoading} />
</StyledFormItem>
<p className="help-block">
{t('A readable URL for your dashboard')}
</p>
</Col>
</Row>
{isFeatureEnabled(FeatureFlag.DASHBOARD_RBAC)
? getRowsWithRoles()
: getRowsWithoutRoles()}
<Row>
<Col xs={24} md={24}>
<h3>{t('Certification')}</h3>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12}>
<StyledFormItem label={t('Certified by')} name="certifiedBy">
<Input type="text" disabled={isLoading} />
</StyledFormItem>
<p className="help-block">
{t('Person or group that has certified this dashboard.')}
</p>
</Col>
<Col xs={24} md={12}>
<StyledFormItem
label={t('Certification details')}
name="certificationDetails"
>
<Input type="text" disabled={isLoading} />
</StyledFormItem>
<p className="help-block">
{t('Any additional detail to show in the certification tooltip.')}
</p>
</Col>
</Row>
<Row>
<Col xs={24} md={24}>
<h3 style={{ marginTop: '1em' }}>
<Button
buttonStyle="link"
onClick={() => setIsAdvancedOpen(!isAdvancedOpen)}
>
<i
className={`fa fa-angle-${isAdvancedOpen ? 'down' : 'right'}`}
style={{ minWidth: '1em' }}
/>
{t('Advanced')}
</Button>
</h3>
{isAdvancedOpen && (
<>
<StyledFormItem label={t('JSON metadata')}>
<StyledJsonEditor
showLoadingForImport
name="json_metadata"
value={jsonMetadata}
onChange={setJsonMetadata}
tabSize={2}
width="100%"
height="200px"
wrapEnabled
/>
</StyledFormItem>
<p className="help-block">
{t(
'This JSON object is generated dynamically when clicking the save or overwrite button in the dashboard view. It is exposed here for reference and for power users who may want to alter specific parameters.',
)}
</p>
</>
)}
</Col>
</Row>
</Form>
</Modal>
);
};
export default withToasts(PropertiesModal);

View File

@ -122,37 +122,32 @@ class SaveModal extends React.PureComponent<SaveModalProps, SaveModalState> {
dashboardInfo,
layout: positions,
customCss,
colorNamespace,
colorScheme,
expandedSlices,
dashboardId,
refreshFrequency: currentRefreshFrequency,
shouldPersistRefreshFrequency,
lastModifiedTime,
} = this.props;
const labelColors =
colorScheme && dashboardInfo?.metadata?.label_colors
? dashboardInfo.metadata.label_colors
: {};
// check refresh frequency is for current session or persist
const refreshFrequency = shouldPersistRefreshFrequency
? currentRefreshFrequency
: dashboardInfo.metadata?.refresh_frequency; // eslint-disable camelcase
const data = {
positions,
certified_by: dashboardInfo.certified_by,
certification_details: dashboardInfo.certification_details,
css: customCss,
color_namespace: colorNamespace,
color_scheme: colorScheme,
label_colors: labelColors,
expanded_slices: expandedSlices,
dashboard_title:
saveType === SAVE_TYPE_NEWDASHBOARD ? newDashName : dashboardTitle,
duplicate_slices: this.state.duplicateSlices,
refresh_frequency: refreshFrequency,
last_modified_time: lastModifiedTime,
owners: dashboardInfo.owners,
roles: dashboardInfo.roles,
metadata: {
...dashboardInfo?.metadata,
positions,
refresh_frequency: refreshFrequency,
},
};
if (saveType === SAVE_TYPE_NEWDASHBOARD && !newDashName) {

View File

@ -34,6 +34,7 @@ export type ClientErrorObject = {
message?: string;
severity?: string;
stacktrace?: string;
statusText?: string;
} & Partial<SupersetClientResponse>;
interface ResponseWithTimeout extends Response {

View File

@ -525,6 +525,8 @@ class DashboardRestApi(BaseSupersetModelRestApi):
type: number
result:
$ref: '#/components/schemas/{{self.__class__.__name__}}.put'
last_modified_time:
type: number
400:
$ref: '#/components/responses/400'
401:
@ -547,7 +549,15 @@ class DashboardRestApi(BaseSupersetModelRestApi):
return self.response_400(message=error.messages)
try:
changed_model = UpdateDashboardCommand(g.user, pk, item).run()
response = self.response(200, id=changed_model.id, result=item)
last_modified_time = changed_model.changed_on.replace(
microsecond=0
).timestamp()
response = self.response(
200,
id=changed_model.id,
result=item,
last_modified_time=last_modified_time,
)
except DashboardNotFoundError:
response = self.response_404()
except DashboardForbiddenError:

View File

@ -14,6 +14,7 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import json
import logging
from typing import Any, Dict, List, Optional
@ -51,6 +52,12 @@ class UpdateDashboardCommand(UpdateMixin, BaseCommand):
try:
dashboard = DashboardDAO.update(self._model, self._properties, commit=False)
dashboard = DashboardDAO.update_charts_owners(dashboard, commit=True)
if self._properties.get("json_metadata"):
dashboard = DashboardDAO.set_dash_metadata(
dashboard,
data=json.loads(self._properties.get("json_metadata", "{}")),
commit=True,
)
except DAOUpdateFailedError as ex:
logger.exception(ex.exception)
raise DashboardUpdateFailedError() from ex

View File

@ -173,79 +173,102 @@ class DashboardDAO(BaseDAO):
raise ex
@staticmethod
def set_dash_metadata(
def set_dash_metadata( # pylint: disable=too-many-locals
dashboard: Dashboard,
data: Dict[Any, Any],
old_to_new_slice_ids: Optional[Dict[int, int]] = None,
) -> None:
positions = data["positions"]
# find slices in the position data
slice_ids = [
value.get("meta", {}).get("chartId")
for value in positions.values()
if isinstance(value, dict)
]
session = db.session()
current_slices = session.query(Slice).filter(Slice.id.in_(slice_ids)).all()
dashboard.slices = current_slices
# add UUID to positions
uuid_map = {slice.id: str(slice.uuid) for slice in current_slices}
for obj in positions.values():
if (
isinstance(obj, dict)
and obj["type"] == "CHART"
and obj["meta"]["chartId"]
):
chart_id = obj["meta"]["chartId"]
obj["meta"]["uuid"] = uuid_map.get(chart_id)
# remove leading and trailing white spaces in the dumped json
dashboard.position_json = json.dumps(
positions, indent=None, separators=(",", ":"), sort_keys=True
)
md = dashboard.params_dict
dashboard.css = data.get("css")
dashboard.dashboard_title = data["dashboard_title"]
if "timed_refresh_immune_slices" not in md:
md["timed_refresh_immune_slices"] = []
commit: bool = False,
) -> Dashboard:
positions = data.get("positions")
new_filter_scopes = {}
if "filter_scopes" in data:
# replace filter_id and immune ids from old slice id to new slice id:
# and remove slice ids that are not in dash anymore
slc_id_dict: Dict[int, int] = {}
if old_to_new_slice_ids:
slc_id_dict = {
old: new
for old, new in old_to_new_slice_ids.items()
if new in slice_ids
}
else:
slc_id_dict = {sid: sid for sid in slice_ids}
new_filter_scopes = copy_filter_scopes(
old_to_new_slc_id_dict=slc_id_dict,
old_filter_scopes=json.loads(data["filter_scopes"] or "{}"),
md = dashboard.params_dict
if positions is not None:
# find slices in the position data
slice_ids = [
value.get("meta", {}).get("chartId")
for value in positions.values()
if isinstance(value, dict)
]
session = db.session()
current_slices = session.query(Slice).filter(Slice.id.in_(slice_ids)).all()
dashboard.slices = current_slices
# add UUID to positions
uuid_map = {slice.id: str(slice.uuid) for slice in current_slices}
for obj in positions.values():
if (
isinstance(obj, dict)
and obj["type"] == "CHART"
and obj["meta"]["chartId"]
):
chart_id = obj["meta"]["chartId"]
obj["meta"]["uuid"] = uuid_map.get(chart_id)
# remove leading and trailing white spaces in the dumped json
dashboard.position_json = json.dumps(
positions, indent=None, separators=(",", ":"), sort_keys=True
)
if "filter_scopes" in data:
# replace filter_id and immune ids from old slice id to new slice id:
# and remove slice ids that are not in dash anymore
slc_id_dict: Dict[int, int] = {}
if old_to_new_slice_ids:
slc_id_dict = {
old: new
for old, new in old_to_new_slice_ids.items()
if new in slice_ids
}
else:
slc_id_dict = {sid: sid for sid in slice_ids}
new_filter_scopes = copy_filter_scopes(
old_to_new_slc_id_dict=slc_id_dict,
old_filter_scopes=json.loads(data["filter_scopes"] or "{}")
if isinstance(data["filter_scopes"], str)
else data["filter_scopes"],
)
default_filters_data = json.loads(data.get("default_filters", "{}"))
applicable_filters = {
key: v
for key, v in default_filters_data.items()
if int(key) in slice_ids
}
md["default_filters"] = json.dumps(applicable_filters)
# The css and dashboard_title properties are not part of the metadata
# TODO (geido): remove by refactoring/deprecating save_dash endpoint
if data.get("css") is not None:
dashboard.css = data.get("css")
if data.get("dashboard_title") is not None:
dashboard.dashboard_title = data.get("dashboard_title")
if new_filter_scopes:
md["filter_scopes"] = new_filter_scopes
else:
md.pop("filter_scopes", None)
md.setdefault("timed_refresh_immune_slices", [])
if data.get("color_namespace") is None:
md.pop("color_namespace", None)
else:
md["color_namespace"] = data.get("color_namespace")
md["expanded_slices"] = data.get("expanded_slices", {})
md["refresh_frequency"] = data.get("refresh_frequency", 0)
default_filters_data = json.loads(data.get("default_filters", "{}"))
applicable_filters = {
key: v for key, v in default_filters_data.items() if int(key) in slice_ids
}
md["default_filters"] = json.dumps(applicable_filters)
md["color_scheme"] = data.get("color_scheme")
md["label_colors"] = data.get("label_colors")
if data.get("color_namespace"):
md["color_namespace"] = data.get("color_namespace")
md["color_scheme"] = data.get("color_scheme", "")
md["label_colors"] = data.get("label_colors", {})
dashboard.json_metadata = json.dumps(md)
if commit:
db.session.commit()
return dashboard
@staticmethod
def favorited_ids(
dashboards: List[Dashboard], current_user_id: int

View File

@ -125,6 +125,8 @@ class DashboardJSONMetadataSchema(Schema):
stagger_refresh = fields.Boolean()
stagger_time = fields.Integer()
color_scheme = fields.Str(allow_none=True)
color_namespace = fields.Str(allow_none=True)
positions = fields.Dict(allow_none=True)
label_colors = fields.Dict()
# used for v0 import/export
import_time = fields.Integer()

View File

@ -70,7 +70,7 @@ class TestDashboardApi(SupersetTestCase, ApiOwnersTestCaseMixin, InsertChartMixi
"slug": "slug1_changed",
"position_json": '{"b": "B"}',
"css": "css_changed",
"json_metadata": '{"refresh_frequency": 30}',
"json_metadata": '{"refresh_frequency": 30, "timed_refresh_immune_slices": [], "expanded_slices": {}, "color_scheme": "", "label_colors": {}}',
"published": False,
}