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:
parent
f476ba23a2
commit
12bd1fcde5
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export type ClientErrorObject = {
|
|||
message?: string;
|
||||
severity?: string;
|
||||
stacktrace?: string;
|
||||
statusText?: string;
|
||||
} & Partial<SupersetClientResponse>;
|
||||
|
||||
interface ResponseWithTimeout extends Response {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue