Compare commits

...
Author SHA1 Message Date
Mehmet Salih Yavuz 0eda633b45 fix(dataset): stop a stale edit modal from silently reverting a saved change (#43583) 2026-08-28 21:07:36 +03:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>hainenber
5c24f72d92 chore(deps): bump content-disposition from 2.0.1 to 3.0.0 in /superset-frontend (#43382)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: hainenber <dotronghai96@gmail.com>
2026-08-29 00:53:43 +07:00
Gabriel Torres Ruiz 60479fb958 feat(dashboard): add dashboard.slice.header.menu extension slot (#43624)
Signed-off-by: Gabriel Torres Ruiz <gabo2595@gmail.com>
2026-08-28 14:52:18 -03:00
19 changed files with 744 additions and 54 deletions
+1 -1
View File
@@ -77,7 +77,7 @@ module.exports = {
// @ant-design/colors and @ant-design/fast-color are allowed through because
// @ant-design/icons >= 6.3 deep-imports the ESM build of @ant-design/colors
// from its CJS output, so babel-jest must transform those files.
'node_modules/(?!@ant-design/(colors|fast-color)|@formatjs/.*|d3-(array|interpolate|color|time|scale|time-format|format|selection)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|@x0k/.*|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|hastscript|refractor|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|character-reference-invalid|is-alphanumerical|is-alphabetical|is-decimal|is-hexadecimal|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued|storybook/*.|json-stringify-pretty-compact|@x0k/json-schema-merge)',
'node_modules/(?!@ant-design/(colors|fast-color)|@formatjs/.*|d3-(array|interpolate|color|time|scale|time-format|format|selection)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|@x0k/.*|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|hastscript|refractor|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|character-reference-invalid|is-alphanumerical|is-alphabetical|is-decimal|is-hexadecimal|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued|storybook/*.|json-stringify-pretty-compact|@x0k/json-schema-merge|content-disposition)',
],
preset: 'ts-jest',
transform: {
+5 -5
View File
@@ -84,7 +84,7 @@
"antd": "^6.6.1",
"chrono-node": "^2.10.1",
"classnames": "^2.2.5",
"content-disposition": "^2.0.1",
"content-disposition": "^3.0.0",
"d3-scale": "^4.0.2",
"dayjs": "^1.11.23",
"dom-to-image-more": "^3.10.2",
@@ -17451,12 +17451,12 @@
}
},
"node_modules/content-disposition": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz",
"integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==",
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-3.0.0.tgz",
"integrity": "sha512-ZH/0Xs9rMIFWCOmGdmS9eHBTF62qqQYNz4nVjQhkdIO/a0fCP4UIM3mRz/wiqL0L14YgAz/1xio4OaSY4+ON/A==",
"license": "MIT",
"engines": {
"node": ">=18"
"node": ">=22"
},
"funding": {
"type": "opencollective",
+1 -1
View File
@@ -161,7 +161,7 @@
"antd": "^6.6.1",
"chrono-node": "^2.10.1",
"classnames": "^2.2.5",
"content-disposition": "^2.0.1",
"content-disposition": "^3.0.0",
"d3-scale": "^4.0.2",
"dayjs": "^1.11.23",
"dom-to-image-more": "^3.10.2",
@@ -30,6 +30,7 @@ import type {
QueryFormData,
} from '../query';
import type { JsonResponse } from '../connection';
import type { MenuItem } from '../components/Menu';
/**
* A function which returns text (or marked-up text)
@@ -164,6 +165,13 @@ export interface SliceHeaderExtension {
dashboardId: number;
}
/**
* Interface for extensions to the Slice Header more-options menu
*/
export interface SliceHeaderMenuExtension extends SliceHeaderExtension {
sliceName: string;
}
/**
* Interface for extensions to Embed Modal
*/
@@ -262,6 +270,9 @@ export type Extensions = Partial<{
'sqleditor.extension.form': ComponentType<SQLFormExtensionProps>;
'sqleditor.extension.resultTable': ComponentType<SQLResultTableExtensionProps>;
'dashboard.slice.header': ComponentType<SliceHeaderExtension>;
'dashboard.slice.header.menu': (
context: SliceHeaderMenuExtension,
) => MenuItem[];
'sqleditor.extension.customAutocomplete': (
args: CustomAutoCompleteArgs,
) => CustomAutocomplete[] | undefined;
@@ -67,14 +67,23 @@ async function renderAndWait(props = mockedProps) {
container = renderedContainer;
}
beforeEach(() => {
// A modal that wasn't handed an `etag` reads the dataset itself and can't save
// until that lands, so tests must wait before acting on the Save button.
async function waitForSaveEnabled() {
await waitFor(() =>
expect(screen.getByTestId('datasource-modal-save')).toBeEnabled(),
);
}
beforeEach(async () => {
fetchMock.clearHistory().removeRoutes();
cleanup();
renderAndWait();
fetchMock.post(SAVE_ENDPOINT, SAVE_PAYLOAD);
fetchMock.put(SAVE_DATASOURCE_ENDPOINT, {});
fetchMock.get(GET_DATASOURCE_ENDPOINT, { result: {} });
fetchMock.get(GET_DATABASE_ENDPOINT, { result: [] });
renderAndWait();
await waitForSaveEnabled();
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
@@ -118,6 +127,7 @@ describe('DatasourceModal', () => {
onDatasourceSave:
onDatasourceSave as unknown as typeof mockedProps.onDatasourceSave,
});
await waitForSaveEnabled();
const saveButton = screen.getByTestId('datasource-modal-save');
fireEvent.click(saveButton);
const okButton = await screen.findByRole('button', { name: 'Confirm' });
@@ -151,6 +161,96 @@ describe('DatasourceModal', () => {
putSpy.mockRestore();
});
test('sends the supplied etag as If-Match so a stale save is refused', async () => {
cleanup();
renderAndWait({ ...mockedProps, etag: '"v1"' } as typeof mockedProps);
fireEvent.click(screen.getByTestId('datasource-modal-save'));
fireEvent.click(await screen.findByRole('button', { name: 'Confirm' }));
await waitFor(() => {
const putCall = fetchMock.callHistory
.calls()
.find(call => call.options?.method === 'put');
expect(
new Headers(putCall?.options?.headers as HeadersInit).get('If-Match'),
).toEqual('"v1"');
});
});
test('reads the etag from the dataset when the caller supplies none', async () => {
cleanup();
fetchMock.clearHistory().removeRoutes();
fetchMock.put(SAVE_DATASOURCE_ENDPOINT, {});
fetchMock.get(GET_DATASOURCE_ENDPOINT, {
body: { result: {} },
headers: { ETag: '"v2"' },
});
fetchMock.get(GET_DATABASE_ENDPOINT, { result: [] });
renderAndWait();
// The form is seeded from the same read as the validator, so saving is
// unavailable until it lands.
expect(screen.getByTestId('datasource-modal-save')).toBeDisabled();
await screen.findByTestId('datasource-editor');
fireEvent.click(screen.getByTestId('datasource-modal-save'));
fireEvent.click(await screen.findByRole('button', { name: 'Confirm' }));
await waitFor(() => {
const putCall = fetchMock.callHistory
.calls()
.find(call => call.options?.method === 'put');
expect(
new Headers(putCall?.options?.headers as HeadersInit).get('If-Match'),
).toEqual('"v2"');
});
});
test('never saves unguarded while the validator read is in flight', async () => {
cleanup();
fetchMock.clearHistory().removeRoutes();
fetchMock.put(SAVE_DATASOURCE_ENDPOINT, {});
// A read that never resolves: the save path must stay closed rather than
// fall through to an unconditional PUT.
fetchMock.get(GET_DATASOURCE_ENDPOINT, new Promise(() => {}));
fetchMock.get(GET_DATABASE_ENDPOINT, { result: [] });
renderAndWait();
const saveButton = await screen.findByTestId('datasource-modal-save');
expect(saveButton).toBeDisabled();
fireEvent.click(saveButton);
expect(
fetchMock.callHistory
.calls()
.find(call => call.options?.method === 'put'),
).toBeUndefined();
});
test('shows a conflict dialog instead of a generic error on 412', async () => {
const putSpy = jest
.spyOn(SupersetClient, 'put')
.mockRejectedValue(new Response('', { status: 412 }));
try {
fireEvent.click(screen.getByTestId('datasource-modal-save'));
fireEvent.click(await screen.findByRole('button', { name: 'Confirm' }));
const conflictElements = await screen.findAllByText(
'Dataset changed since you opened it',
);
expect(conflictElements.length).toBeGreaterThan(0);
expect(
screen.queryByText('Error saving dataset'),
).not.toBeInTheDocument();
} finally {
putSpy.mockRestore();
}
});
test('shows sync columns checkbox when SQL changes', async () => {
cleanup();
const datasourceWithSQL = {
@@ -163,15 +263,24 @@ describe('DatasourceModal', () => {
};
const { rerender } = render(
<DatasourceModal {...mockedProps} datasource={datasourceWithSQL} />,
<DatasourceModal
{...mockedProps}
datasource={datasourceWithSQL}
etag='"v1"'
/>,
{ store, useRouter: true },
);
// Update with modified SQL
rerender(
<DatasourceModal {...mockedProps} datasource={modifiedDatasource} />,
<DatasourceModal
{...mockedProps}
datasource={modifiedDatasource}
etag='"v1"'
/>,
);
await waitForSaveEnabled();
const saveButton = screen.getByTestId('datasource-modal-save');
fireEvent.click(saveButton);
@@ -208,15 +317,24 @@ describe('DatasourceModal', () => {
fetchMock.get(GET_DATABASE_ENDPOINT, { result: [] });
const { rerender } = render(
<DatasourceModal {...mockedProps} datasource={datasourceWithSQL} />,
<DatasourceModal
{...mockedProps}
datasource={datasourceWithSQL}
etag='"v1"'
/>,
{ store, useRouter: true },
);
// Update with modified SQL to trigger checkbox
rerender(
<DatasourceModal {...mockedProps} datasource={modifiedDatasource} />,
<DatasourceModal
{...mockedProps}
datasource={modifiedDatasource}
etag='"v1"'
/>,
);
await waitForSaveEnabled();
const saveButton = screen.getByTestId('datasource-modal-save');
fireEvent.click(saveButton);
@@ -269,15 +387,24 @@ describe('DatasourceModal', () => {
fetchMock.get(GET_DATABASE_ENDPOINT, { result: [] });
const { rerender } = render(
<DatasourceModal {...mockedProps} datasource={datasourceWithSQL} />,
<DatasourceModal
{...mockedProps}
datasource={datasourceWithSQL}
etag='"v1"'
/>,
{ store, useRouter: true },
);
// Update with modified SQL to trigger checkbox
rerender(
<DatasourceModal {...mockedProps} datasource={modifiedDatasource} />,
<DatasourceModal
{...mockedProps}
datasource={modifiedDatasource}
etag='"v1"'
/>,
);
await waitForSaveEnabled();
const saveButton = screen.getByTestId('datasource-modal-save');
fireEvent.click(saveButton);
@@ -21,6 +21,7 @@ import {
screen,
fireEvent,
act,
waitFor,
defaultStore as store,
} from 'spec/helpers/testing-library';
import fetchMock from 'fetch-mock';
@@ -72,6 +73,9 @@ test('DatasourceModal - should handle sync columns state without imperative moda
render(<DatasourceModal {...mockedProps} />, { store });
const saveButton = screen.getByTestId('datasource-modal-save');
// The modal fetches the current dataset version on open; save stays disabled
// until that settles
await waitFor(() => expect(saveButton).toBeEnabled());
// This should not throw any DOM errors
await act(async () => {
@@ -33,12 +33,14 @@ import {
Icons,
Button,
Checkbox,
Loading,
Modal,
AsyncEsmComponent,
} from '@superset-ui/core/components';
import withToasts from 'src/components/MessageToasts/withToasts';
import { ErrorMessageWithStackTrace } from 'src/components';
import type { DatasetObject } from 'src/features/datasets/types';
import { withCertificationFields } from '../utils';
import { mapSubjectValuesToIds } from 'src/features/subjects/SubjectPicker';
import type { DatasourceModalProps } from '../types';
@@ -91,12 +93,18 @@ export function buildExtraJsonObject(
const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
addSuccessToast,
datasource,
etag,
onDatasourceSave,
onHide,
show,
}) => {
const theme = useTheme();
const [currentDatasource, setCurrentDatasource] = useState(datasource);
// SQL of the server snapshot the form started from. The caller's, unless
// this modal read the dataset itself — then "did the SQL change?" has to be
// asked against the snapshot the payload is actually built from.
const [seededSql, setSeededSql] = useState<string | undefined>();
const [versionEtag, setVersionEtag] = useState(etag);
const [syncColumns, setSyncColumns] = useState(false);
const currencies = useSelector<
{
@@ -111,6 +119,52 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
const [isEditing, setIsEditing] = useState<boolean>(false);
const [modal, contextHolder] = Modal.useModal();
const [confirmModalOpen, setConfirmModalOpen] = useState(false);
const [isLoadingDatasource, setIsLoadingDatasource] = useState(false);
// Callers that read the dataset themselves (the dataset list) hand down the
// ETag of that read. The rest — Explore, where `datasource` comes from the
// page's bootstrap state — read it here, and must seed the form from the
// *same* response: a payload built from an older snapshot than the ETag
// guarding it would still be accepted, and would still clobber.
useEffect(() => {
setVersionEtag(etag);
if (etag || !show || !datasource.id) {
return undefined;
}
let cancelled = false;
setIsLoadingDatasource(true);
SupersetClient.get({
endpoint: `/api/v1/dataset/${datasource.id}`,
})
.then(({ json, response }) => {
if (cancelled) {
return;
}
const seeded = {
...datasource,
...json.result,
columns: withCertificationFields(json.result.columns),
};
setSeededSql(seeded.sql);
setCurrentDatasource(seeded);
setVersionEtag(response.headers.get('ETag') ?? undefined);
})
.catch(() => {
// The read failed outright, so there is no fresher snapshot to edit
// and no validator to send. Fall back to the caller's snapshot and an
// unconditional save, which is what this modal did before the guard.
})
.finally(() => {
if (!cancelled) {
setIsLoadingDatasource(false);
}
});
return () => {
cancelled = true;
};
}, [datasource.id, etag, show]);
const baselineSql = seededSql ?? datasource.sql;
const buildPayload = (datasource: Record<string, any>) => {
const payload: Record<string, any> = {
table_name: datasource.table_name,
@@ -197,11 +251,13 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
await SupersetClient.put({
endpoint: `/api/v1/dataset/${currentDatasource.id}?override_columns=${syncColumns}`,
jsonPayload: buildPayload(currentDatasource),
...(versionEtag ? { headers: { 'If-Match': versionEtag } } : {}),
});
const { json } = await SupersetClient.get({
const { json, response } = await SupersetClient.get({
endpoint: `/api/v1/dataset/${currentDatasource?.id}`,
});
setVersionEtag(response.headers.get('ETag') ?? undefined);
addSuccessToast(t('The dataset has been saved'));
// eslint-disable-next-line no-param-reassign
@@ -213,6 +269,19 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
onHide();
} catch (response) {
setIsSaving(false);
if ((response as Response)?.status === 412) {
modal.error({
title: t('Dataset changed since you opened it'),
okButtonProps: { danger: true, className: 'btn-danger' },
content: t(
'Someone else, or another one of your browser tabs, saved this ' +
'dataset after you opened it. Saving now would undo those ' +
'changes, so it was cancelled. Copy your edits, close this ' +
'dialog, and reopen the dataset to reapply them.',
),
});
return;
}
const error = await getClientErrorObject(response);
let errorResponse: SupersetError | undefined;
let errorText: string | undefined;
@@ -264,7 +333,7 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
here may affect other charts
in undesirable ways.`)}
/>
{datasource.sql !== currentDatasource.sql && (
{baselineSql !== currentDatasource.sql && (
<div
css={theme => ({
marginBottom: theme.marginMD,
@@ -298,14 +367,14 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
{t('Are you sure you want to save and apply changes?')}
</div>
),
[currentDatasource.sql, datasource.sql, syncColumns],
[currentDatasource.sql, baselineSql, syncColumns],
);
useEffect(() => {
if (datasource.sql !== currentDatasource.sql) {
if (baselineSql !== currentDatasource.sql) {
setSyncColumns(true);
}
}, [datasource.sql, currentDatasource.sql]);
}, [baselineSql, currentDatasource.sql]);
const onClickSave = () => {
setConfirmModalOpen(true);
@@ -356,6 +425,7 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
onClick={onClickSave}
disabled={
isSaving ||
isLoadingDatasource ||
errors.length > 0 ||
currentDatasource.is_managed_externally
}
@@ -381,14 +451,18 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
}}
draggable
>
<DatasourceEditor
showLoadingForImport
height={500}
datasource={currentDatasource}
onChange={onDatasourceChange}
setIsEditing={setIsEditing}
currencies={currencies}
/>
{isLoadingDatasource ? (
<Loading />
) : (
<DatasourceEditor
showLoadingForImport
height={500}
datasource={currentDatasource}
onChange={onDatasourceChange}
setIsEditing={setIsEditing}
currencies={currencies}
/>
)}
{contextHolder}
<Modal
title={t('Confirm save')}
@@ -20,4 +20,5 @@ import ChangeDatasourceModal from './ChangeDatasourceModal';
import DatasourceModal from './DatasourceModal';
export { ChangeDatasourceModal, DatasourceModal };
export { withCertificationFields } from './utils';
export type { DatasourceModalProps, ChangeDatasourceModalProps } from './types';
@@ -29,6 +29,12 @@ export interface DatasourceModalProps {
addSuccessToast: (msg: string) => void;
addDangerToast: (msg: string) => void;
datasource: DatasetObject;
/**
* ETag of the dataset read the form was seeded from. Replayed as `If-Match`
* on save so a stale form can't clobber a newer write. Fetched by the modal
* when the caller doesn't already have one.
*/
etag?: string;
onChange: () => {};
onDatasourceSave: (datasource: object, errors?: Array<any>) => {};
onHide: () => {};
@@ -27,6 +27,7 @@ import { nanoid } from 'nanoid';
import { SupersetClient } from '@superset-ui/core';
import { tn } from '@apache-superset/core/translation';
import rison from 'rison';
import type { ColumnObject } from 'src/features/datasets/types';
// Type definitions
@@ -248,3 +249,29 @@ export async function fetchSyncedColumns(
const { json } = await SupersetClient.get({ endpoint, signal });
return json as ColumnMetadata[];
}
/**
* Lift each column's certification out of its `extra` JSON into the flat
* fields the datasource editor binds to.
*/
export function withCertificationFields(columns: ColumnObject[] = []) {
return columns.map(column => {
// Malformed `extra` must not take out the whole column list, the way an
// uncaught parse would — same fallback as `hydrateMetricExtra`.
let parsedExtra;
try {
parsedExtra = JSON.parse(column.extra || '{}') || {};
} catch {
parsedExtra = {};
}
const {
certification: { details = '', certified_by: certifiedBy = '' } = {},
} = parsedExtra;
return {
...column,
certification_details: details || '',
certified_by: certifiedBy || '',
is_certified: details || certifiedBy,
};
});
}
@@ -23,7 +23,7 @@ import {
userEvent,
waitFor,
} from 'spec/helpers/testing-library';
import { FeatureFlag, VizType } from '@superset-ui/core';
import { FeatureFlag, VizType, getExtensionsRegistry } from '@superset-ui/core';
import mockState from 'spec/fixtures/mockState';
import { cachedSupersetGet } from 'src/utils/cachedSupersetGet';
import downloadAsImage from 'src/utils/downloadAsImage';
@@ -165,6 +165,9 @@ beforeEach(() => {
afterEach(() => {
Reflect.deleteProperty(document, 'fullscreenElement');
// TypedRegistry has no remove(); reset to a no-op so a registered slot does
// not leak into other tests (the empty array is guarded, so nothing injects).
getExtensionsRegistry().set('dashboard.slice.header.menu', () => []);
});
test('Should render', () => {
@@ -173,6 +176,58 @@ test('Should render', () => {
expect(screen.getByTestId(`slice_${SLICE_ID}-menu`)).toBeInTheDocument();
});
test('Injects dashboard.slice.header.menu items at the top of the menu', () => {
getExtensionsRegistry().set('dashboard.slice.header.menu', () => [
{ key: 'custom-ext', label: 'Custom Menu Extension' },
]);
renderWrapper();
openMenu();
const injected = screen.getByText('Custom Menu Extension');
expect(injected).toBeInTheDocument();
// Sits above the built-in entries.
const forceRefresh = screen.getByText('Force refresh');
expect(
injected.compareDocumentPosition(forceRefresh) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
test('Injects nothing when dashboard.slice.header.menu returns no items', () => {
getExtensionsRegistry().set('dashboard.slice.header.menu', () => []);
renderWrapper();
openMenu();
expect(screen.queryByText('Custom Menu Extension')).not.toBeInTheDocument();
// The menu still renders its built-in entries unchanged (no dangling divider
// is added since the empty array is guarded).
expect(screen.getByText('Force refresh')).toBeInTheDocument();
});
test('Menu survives a dashboard.slice.header.menu extension that throws', () => {
getExtensionsRegistry().set('dashboard.slice.header.menu', () => {
throw new Error('boom');
});
renderWrapper();
openMenu();
// The throw is isolated: the built-in menu still renders.
expect(screen.getByText('Force refresh')).toBeInTheDocument();
expect(screen.getByText('Enter fullscreen')).toBeInTheDocument();
});
test('Injects nothing when the extension returns a non-array', () => {
getExtensionsRegistry().set(
'dashboard.slice.header.menu',
// JS registrations bypass the MenuItem[] type; a bad return must not crash.
(() => undefined) as never,
);
renderWrapper();
openMenu();
expect(screen.getByText('Force refresh')).toBeInTheDocument();
});
test('Should render default props', () => {
const props = createProps();
@@ -34,11 +34,13 @@ import {
isFeatureEnabled,
FeatureFlag,
getChartMetadataRegistry,
getExtensionsRegistry,
VizType,
BinaryQueryObjectFilterClause,
JsonObject,
QueryFormData,
} from '@superset-ui/core';
import { logging } from '@apache-superset/core/utils';
import { css, useTheme, styled } from '@apache-superset/core/theme';
import { useSelector } from 'react-redux';
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
@@ -165,6 +167,8 @@ const queueChartResize = () => {
}, 300);
};
const extensionsRegistry = getExtensionsRegistry();
const SliceHeaderControls = (
props: SliceHeaderControlsPropsWithRouter | SliceHeaderControlsProps,
) => {
@@ -514,6 +518,26 @@ const SliceHeaderControls = (
},
];
const sliceHeaderMenuExtension = extensionsRegistry.get(
'dashboard.slice.header.menu',
);
if (sliceHeaderMenuExtension) {
// Isolate the extension: a bad registration (throwing, or returning a
// non-array) must not take down the whole dashboard render.
try {
const extensionItems = sliceHeaderMenuExtension({
sliceId: slice.slice_id,
sliceName: slice.slice_name,
dashboardId,
});
if (Array.isArray(extensionItems) && extensionItems.length) {
newMenuItems.unshift(...extensionItems, { type: 'divider' });
}
} catch (error) {
logging.error('dashboard.slice.header.menu extension failed', error);
}
}
if (slice.description) {
newMenuItems.push({
key: MenuKeys.ToggleChartDescription,
@@ -297,6 +297,12 @@ test('Click on Edit dataset', async () => {
const props = createProps();
fetchMock.removeRoute(getDbWithQuery);
fetchMock.get(getDbWithQuery, { result: [] }, { name: getDbWithQuery });
fetchMock.removeRoute(getDatasetWithAllMockRouteName);
fetchMock.get(
getDatasetWithAll,
{ result: {} },
{ name: getDatasetWithAllMockRouteName },
);
render(<DatasourceControl {...props} />, {
useRedux: true,
useRouter: true,
@@ -307,7 +313,9 @@ test('Click on Edit dataset', async () => {
await userEvent.click(screen.getByText('Edit dataset'));
});
expect(screen.getByTestId('mock-datasource-editor')).toBeInTheDocument();
expect(
await screen.findByTestId('mock-datasource-editor'),
).toBeInTheDocument();
});
test('Edit dataset should be disabled when user is not admin', async () => {
@@ -43,7 +43,6 @@ import {
} from 'src/views/CRUD/utils';
import { SUBJECT_OPTION_FILTER_PROPS } from 'src/features/subjects/SubjectSelectLabel';
import { SubjectPile } from 'src/features/subjects/SubjectPile';
import { ColumnObject } from 'src/features/datasets/types';
import { useListViewResource } from 'src/views/CRUD/hooks';
import {
ActionButton,
@@ -62,6 +61,7 @@ import {
} from '@superset-ui/core/components';
import {
DatasourceModal,
withCertificationFields,
GenericLink,
ImportModal as ImportModelsModal,
ModifiedInfo,
@@ -496,6 +496,8 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
const [datasetCurrentlyEditing, setDatasetCurrentlyEditing] =
useState<Dataset | null>(null);
const [datasetCurrentlyEditingEtag, setDatasetCurrentlyEditingEtag] =
useState<string | undefined>();
const [datasetCurrentlyDuplicating, setDatasetCurrentlyDuplicating] =
useState<VirtualDataset | null>(null);
@@ -565,24 +567,11 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
SupersetClient.get({
endpoint: `/api/v1/dataset/${id}`,
})
.then(({ json = {} }) => {
const addCertificationFields = json.result.columns.map(
(column: ColumnObject) => {
const {
certification: {
details = '',
certified_by: certifiedBy = '',
} = {},
} = JSON.parse(column.extra || '{}') || {};
return {
...column,
certification_details: details || '',
certified_by: certifiedBy || '',
is_certified: details || certifiedBy,
};
},
.then(({ json = {}, response }) => {
setDatasetCurrentlyEditingEtag(
response.headers.get('ETag') ?? undefined,
);
json.result.columns = [...addCertificationFields];
json.result.columns = withCertificationFields(json.result.columns);
setDatasetCurrentlyEditing(json.result);
})
.catch(() => {
@@ -1524,6 +1513,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
{datasetCurrentlyEditing && (
<DatasourceModal
datasource={datasetCurrentlyEditing}
etag={datasetCurrentlyEditingEtag}
onDatasourceSave={refreshData}
onHide={closeDatasetEditModal}
show
+54 -6
View File
@@ -29,7 +29,7 @@ from flask_appbuilder.api import expose, protect, rison as parse_rison, safe
from flask_appbuilder.api.schemas import get_item_schema
from flask_appbuilder.const import API_RESULT_RES_KEY, API_SELECT_COLUMNS_RIS_KEY
from flask_appbuilder.models.sqla.interface import SQLAInterface
from flask_babel import ngettext
from flask_babel import gettext as _, ngettext
from jinja2.exceptions import TemplateError
from marshmallow import ValidationError
from sqlalchemy.orm.exc import MultipleResultsFound
@@ -95,13 +95,20 @@ from superset.subjects.filters import FilterRelatedSubjects, subject_type_filter
from superset.utils import json
from superset.utils.core import parse_boolean_string, send_export_zip
from superset.versioning.api_helpers import (
current_entity_etag_uuid,
concurrency_token_from,
current_entity_version_info,
entity_concurrency_token,
get_version_endpoint,
list_versions_endpoint,
lock_entity_for_update,
restore_version_endpoint,
)
from superset.versioning.etag import set_version_etag
from superset.versioning.etag import (
is_conditional_write,
raise_for_stale_write,
set_version_etag,
StaleEntityError,
)
from superset.versioning.schemas import VersionListItemSchema
from superset.views.base import DatasourceFilter
from superset.views.base_api import (
@@ -542,6 +549,14 @@ class DatasetRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
schema:
type: boolean
name: override_columns
- in: header
schema:
type: string
name: If-Match
description: >-
Optional optimistic-concurrency guard. Pass the ``ETag`` returned
by a prior read of this dataset; the update is rejected with 412
if the dataset has changed since.
requestBody:
description: Dataset schema
required: true
@@ -618,6 +633,17 @@ class DatasetRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
412:
description: >-
The dataset changed since the version identified by the
request's ``If-Match`` header; the update was not applied.
content:
application/json:
schema:
type: object
properties:
message:
type: string
422:
$ref: '#/components/responses/422'
500:
@@ -634,10 +660,32 @@ class DatasetRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
except ValidationError as error:
return self.response_400(message=error.messages)
# Serialise conditional saves on this dataset: the guard below reads
# the live version, the command writes, and the two must not interleave
# with another request's. Only a conditional save pays for the lock; an
# unconditional PUT behaves exactly as it did before the guard existed.
if is_conditional_write():
lock_entity_for_update(SqlaTable, pk)
# Live version identifiers before the update (empty + query-free when
# ``ENABLE_VERSIONING_CAPTURE`` is off).
old_info = current_entity_version_info(SqlaTable, pk)
try:
raise_for_stale_write(concurrency_token_from(old_info))
except StaleEntityError:
return set_version_etag(
self.response(
412,
message=_(
"The dataset was changed by another user or browser tab "
"after you opened it. Reopen it to pick up the latest "
"version, then reapply your changes."
),
),
concurrency_token_from(old_info),
)
try:
# Two commands, two commits, two Continuum transactions for an
# ``override_columns`` save — deliberately NOT merged into one
@@ -661,13 +709,13 @@ class DatasetRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
new_info = current_entity_version_info(
SqlaTable, changed_model.id, changed_model.uuid
)
etag_version_uuid = new_info.version_uuid
etag_version_uuid = concurrency_token_from(new_info)
if override_columns:
RefreshDatasetCommand(pk).run()
# The ETag must reflect the entity's *current live* version,
# which after the refresh is the refresh's transaction —
# re-read it rather than reusing the pre-refresh uuid.
etag_version_uuid = current_entity_etag_uuid(
etag_version_uuid = entity_concurrency_token(
SqlaTable, changed_model.id, changed_model.uuid
)
response = self.response(
@@ -1700,7 +1748,7 @@ class DatasetRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
return set_version_etag(
self.response(200, **response),
current_entity_etag_uuid(SqlaTable, table.id, table.uuid),
entity_concurrency_token(SqlaTable, table.id, table.uuid),
)
@expose("/<int:pk>/drill_info/", methods=("GET",))
+77
View File
@@ -72,6 +72,11 @@ class EntityVersionInfo:
version: int | None = None
transaction_id: int | None = None
version_uuid: str | None = None
#: Resolved uuid of the entity itself, carried so callers that need a
#: concurrency token for an entity with no version rows yet don't have to
#: re-run the ``SELECT uuid`` this helper already issued. Not part of the
#: API response.
entity_uuid: UUID | None = None
def _capture_enabled() -> bool:
@@ -123,6 +128,7 @@ def current_entity_version_info(
version=version,
transaction_id=transaction_id,
version_uuid=str(version_uuid) if version_uuid else None,
entity_uuid=entity_uuid,
)
@@ -144,6 +150,77 @@ def current_entity_etag_uuid(
return str(version_uuid) if version_uuid else None
# Sentinel Continuum transaction id for an entity that has no version rows
# yet. Continuum sequences start at 1, so it can never collide with a real
# one, and the derived uuid stops matching the moment the first version row
# lands — which is exactly the transition a concurrency guard must catch.
_UNVERSIONED_TRANSACTION_ID = 0
def unversioned_entity_token(entity_uuid: UUID) -> str:
"""Concurrency token for an entity Continuum hasn't versioned yet."""
return str(VersionDAO.derive_version_uuid(entity_uuid, _UNVERSIONED_TRANSACTION_ID))
def entity_concurrency_token(
model_cls: type[Model],
entity_id: int | None,
entity_uuid: UUID | None,
) -> str | None:
"""Resolve the optimistic-concurrency validator for *entity*.
Differs from :func:`current_entity_etag_uuid` in what it does for an
entity with no version rows: baseline rows are written lazily, on the
first update after the versioning migration, so a never-since-saved
entity has none. Reporting ``None`` there would leave the *first*
concurrent save on every such entity unguarded the exact case a
two-tab race hits on a pristine entity. Those entities get a
deterministic unversioned token instead.
``None`` still means "no validator exists": capture is off, or the
entity is missing.
"""
if entity_id is None or entity_uuid is None or not _capture_enabled():
return None
return current_entity_etag_uuid(
model_cls, entity_id, entity_uuid
) or unversioned_entity_token(entity_uuid)
def lock_entity_for_update(model_cls: type[Model], entity_id: int | None) -> None:
"""Row-lock *entity* so a conditional write's check and its update are atomic.
``If-Match`` is verified against a read taken before the update command
runs. Without a lock two overlapping requests can both read the same live
version, both pass the check, and then commit one after the other,
reintroducing the lost update the check exists to prevent. The lock is
held until the command commits, because both run in the same scoped
session.
Renders no ``FOR UPDATE`` on SQLite, which serialises writers anyway.
"""
try:
# The PUT route declares ``/<pk>`` (a string segment), so a non-numeric
# id must not raise a SQL cast error ahead of the command's 404.
entity_id = int(entity_id) # type: ignore[arg-type]
except (TypeError, ValueError):
return
db.session.execute(
sa.select(model_cls.id).where(model_cls.id == entity_id).with_for_update()
)
def concurrency_token_from(info: EntityVersionInfo) -> str | None:
"""Concurrency token for an already-resolved :class:`EntityVersionInfo`.
Lets a write endpoint reuse the pre-update version lookup it already
made rather than issuing a second one.
"""
if info.entity_uuid is None:
return None
return info.version_uuid or unversioned_entity_token(info.entity_uuid)
# Maps the versioned model class name to the keyword argument
# ``security_manager.raise_for_access`` expects for the per-resource
# gate. Slice → ``chart=``, Dashboard → ``dashboard=``, SqlaTable →
+44
View File
@@ -22,6 +22,7 @@ from typing import TYPE_CHECKING
from uuid import UUID
import sqlalchemy as sa
from flask import request
from flask_appbuilder import Model
from superset.extensions import db
@@ -76,3 +77,46 @@ def set_version_etag_by_uuid(
response,
VersionDAO.current_live_version_uuid(model_cls, entity_id, entity_uuid),
)
class StaleEntityError(Exception):
"""The request's ``If-Match`` doesn't match the entity's live version."""
def _entity_tag(tag: str) -> str:
"""Strip the content-coding suffix ``Flask-Compress`` appends to ETags.
A compressed response legitimately carries a different validator than the
identity one Flask-Compress rewrites ``"<uuid>"`` to ``"<uuid>:zstd"``
(see ``flask_compress``) so a client replaying the ETag it read never
matches the raw version uuid. Version uuids contain no ``:``, so cutting
at the first one recovers the entity identity from either form.
"""
return tag.split(":", 1)[0]
def is_conditional_write() -> bool:
"""Whether the request carries an ``If-Match`` precondition."""
return bool(request.if_match)
def raise_for_stale_write(current_version_uuid: str | None) -> None:
"""Enforce ``If-Match`` on a write request, if the client sent one.
Clients that read an entity's ``ETag`` may replay it as ``If-Match`` on a
subsequent write to get optimistic concurrency: the write is rejected when
the entity moved on in the meantime, instead of silently clobbering
whatever landed in between.
The condition is skipped rather than failing closed when the caller
has no validator to offer (``ENABLE_VERSIONING_CAPTURE`` off). Failing
closed there would block every conditional write on deployments running
without version capture, and those are no worse off than before they sent
the header.
"""
if_match = request.if_match
if not if_match or if_match.star_tag or current_version_uuid is None:
return
live = _entity_tag(str(current_version_uuid))
if not any(_entity_tag(tag) == live for tag in if_match.as_set(True)):
raise StaleEntityError()
+85
View File
@@ -217,6 +217,91 @@ def test_handle_filters_args_returns_request_scoped_filters(
assert fresh_filters.get_joined_filters.call_count == 2
def _create_dataset(name: str) -> Any:
from superset.connectors.sqla.models import SqlaTable
from superset.models.core import Database
SqlaTable.metadata.create_all(db.session.get_bind())
dataset = SqlaTable(
table_name=name,
database=Database(database_name=f"{name}_db", sqlalchemy_uri="sqlite://"),
)
db.session.add(dataset)
db.session.flush()
return dataset
def test_put_dataset_rejects_stale_if_match(
session: Session,
client: Any,
full_api_access: None,
) -> None:
"""
A PUT carrying an ``If-Match`` from an older version is refused with 412.
"""
from superset.versioning.api_helpers import EntityVersionInfo
dataset = _create_dataset("test_put_stale_if_match")
with patch(
"superset.datasets.api.current_entity_version_info",
return_value=EntityVersionInfo(
version=1,
transaction_id=2,
version_uuid="new",
entity_uuid=dataset.uuid,
),
):
response = client.put(
f"/api/v1/dataset/{dataset.id}",
json={"description": "from a stale tab"},
headers={"If-Match": '"old"'},
)
assert response.status_code == 412
assert response.headers["ETag"] == '"new"'
db.session.expire(dataset)
assert dataset.description is None
def test_put_dataset_guards_a_dataset_with_no_version_rows(
session: Session,
client: Any,
full_api_access: None,
) -> None:
"""Baseline rows are written lazily on the first update, so a dataset that
has never been saved has no version rows it must still be guarded, or
the first concurrent save on every pristine dataset goes unprotected.
"""
from superset.versioning.api_helpers import (
EntityVersionInfo,
unversioned_entity_token,
)
dataset = _create_dataset("test_put_unversioned_guard")
entity_uuid = dataset.uuid
with patch(
"superset.datasets.api.current_entity_version_info",
# A dataset that has since been versioned by another tab's save.
return_value=EntityVersionInfo(
version=0,
transaction_id=1,
version_uuid="written-by-the-other-tab",
entity_uuid=entity_uuid,
),
):
response = client.put(
f"/api/v1/dataset/{dataset.id}",
json={"description": "from the tab that opened first"},
headers={"If-Match": f'"{unversioned_entity_token(entity_uuid)}"'},
)
assert response.status_code == 412
db.session.expire(dataset)
assert dataset.description is None
def test_get_dataset_exposes_certification_metadata(
session: Session,
client: Any,
+109
View File
@@ -0,0 +1,109 @@
# 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.
from uuid import UUID
import pytest
from flask import Flask
from superset.versioning.etag import raise_for_stale_write, StaleEntityError
LIVE = "9f1f4c1e-0000-4000-8000-000000000001"
ENTITY = UUID("9f1f4c1e-0000-4000-8000-0000000000aa")
def _put(app: Flask, if_match: str | None):
headers = {"If-Match": if_match} if if_match is not None else {}
return app.test_request_context("/api/v1/dataset/1", method="PUT", headers=headers)
def test_no_if_match_header_passes(app: Flask) -> None:
with _put(app, None):
raise_for_stale_write(LIVE)
def test_matching_if_match_passes(app: Flask) -> None:
with _put(app, f'"{LIVE}"'):
raise_for_stale_write(LIVE)
def test_star_if_match_passes(app: Flask) -> None:
with _put(app, "*"):
raise_for_stale_write(LIVE)
def test_compressed_if_match_passes(app: Flask) -> None:
"""Flask-Compress rewrites the ETag of a compressed response to
``"<uuid>:<algorithm>"``; a client replaying that must still match."""
with _put(app, f'"{LIVE}:zstd"'):
raise_for_stale_write(LIVE)
def test_compressed_stale_if_match_still_raises(app: Flask) -> None:
with _put(app, '"9f1f4c1e-0000-4000-8000-000000000002:gzip"'):
with pytest.raises(StaleEntityError):
raise_for_stale_write(LIVE)
def test_stale_if_match_raises(app: Flask) -> None:
with _put(app, '"9f1f4c1e-0000-4000-8000-000000000002"'):
with pytest.raises(StaleEntityError):
raise_for_stale_write(LIVE)
def test_if_match_list_containing_live_passes(app: Flask) -> None:
with _put(app, f'"9f1f4c1e-0000-4000-8000-000000000002", "{LIVE}"'):
raise_for_stale_write(LIVE)
def test_no_validator_available_passes(app: Flask) -> None:
"""Version capture off (or no version rows yet) degrades to an
unconditional write rather than blocking every save."""
with _put(app, f'"{LIVE}"'):
raise_for_stale_write(None)
def test_unversioned_token_is_stable_and_entity_specific() -> None:
"""A not-yet-versioned entity still gets a validator, derived from its own
uuid so two such entities never share one."""
from superset.versioning.api_helpers import unversioned_entity_token
other = UUID("9f1f4c1e-0000-4000-8000-0000000000ff")
assert unversioned_entity_token(ENTITY) == unversioned_entity_token(ENTITY)
assert unversioned_entity_token(ENTITY) != unversioned_entity_token(other)
def test_unversioned_token_differs_from_first_real_version(app: Flask) -> None:
"""The first version row must invalidate the unversioned token, or the
first concurrent save on a pristine entity would go unguarded."""
from superset.daos.version import derive_version_uuid
from superset.versioning.api_helpers import unversioned_entity_token
stale = unversioned_entity_token(ENTITY)
first_real = str(derive_version_uuid(ENTITY, 1))
assert stale != first_real
with _put(app, f'"{stale}"'):
with pytest.raises(StaleEntityError):
raise_for_stale_write(first_real)
def test_unversioned_token_matches_while_still_unversioned(app: Flask) -> None:
from superset.versioning.api_helpers import unversioned_entity_token
token = unversioned_entity_token(ENTITY)
with _put(app, f'"{token}"'):
raise_for_stale_write(token)