Compare commits

...
Author SHA1 Message Date
Enzo MartellucciandClaude Sonnet 5 390be50f37 fix(dataset): let OAuth2RedirectError reach caller through virtual-table path
get_columns_description wrapped every exception (including OAuth2RedirectError)
raised during query execution into SupersetGenericDBErrorException, so a
virtual dataset's OAuth2 challenge never reached the create.py guard meant to
preserve it. Also restore @safe's opaque-500 behavior for unexpected
exceptions on DatasetRestApi.post, since dropping @safe to let
OAuth2RedirectError through otherwise let raw exception text leak via
Flask's global error handler.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 18:28:34 +02:00
Enzo Martellucci 7aa6fcaf1e Merge branch 'master' into enxdev/fix/sql-lab-save-dataset 2026-08-28 14:53:30 +02:00
Enzo MartellucciandClaude Sonnet 5 8dbdfb4fc6 fix(dataset): don't report infra failures as invalid table/sql on create
Query timeouts and unreachable-database errors were being caught by the
broad SupersetException handler in CreateDatasetCommand and coerced into
a 422 DatasetInvalidError, indistinguishable from a bad table name. Let
SupersetTimeoutException and SupersetDBAPIConnectionError/OperationalError/
DatabaseError propagate so they reach the client with their own status via
the existing SupersetException error handler.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 14:52:12 +02:00
Mehmet Salih Yavuz e518b21994 fix(dashboard): keep the chart menu usable in fullscreen on production builds (#43555) 2026-08-28 15:47:48 +03:00
Mehmet Salih Yavuz e18f27e1ce fix(native-filters): allow clearing an optional "select first value" filter (#43405) 2026-08-28 15:46:46 +03:00
Enzo Martellucci 2b1df8d462 Merge branch 'master' into enxdev/fix/sql-lab-save-dataset 2026-08-28 14:42:12 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 9bf457dea6 chore(deps): bump github/codeql-action/analyze from 4.37.7 to 4.37.8 (#43643)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-28 15:26:18 +03:00
Enzo Martellucci cb7b790733 fix(dataset): surface real validation message in save-error dialog (#43459) 2026-08-28 13:35:09 +02:00
Alexandru Soare 8bec85158c feat(modals): add renderExtraFields prop to chart and dashboard properties modals (#43622) 2026-08-28 13:18:51 +03:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> a30e4a4350 chore(deps): bump github/codeql-action/init from 4.37.7 to 4.37.8 (#43644)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-28 02:39:11 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 933dbbc2a2 chore(deps-dev): bump eslint-plugin-react-you-might-not-need-an-effect from 1.0.1 to 1.0.2 in /superset-frontend (#43645)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-28 02:39:06 -07:00
Enzo Martellucci feee3dea2f Merge branch 'master' into enxdev/fix/sql-lab-save-dataset
# Conflicts:
#	superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.tsx
2026-08-28 11:28:21 +02:00
Enzo MartellucciandClaude Sonnet 5 192ddf9a6d fix(dataset): let OAuth2RedirectError survive DatasetRestApi.post
msyavuz's review noted that CreateDatasetCommand's OAuth2RedirectError
re-raise doesn't survive the API layer: DatasetRestApi.post is wrapped
in flask-appbuilder's @safe, which catches any uncaught exception and
flattens it into an opaque 500, dropping the url/tab_id extras the
frontend needs to start the OAuth2 dance. Mirror the pattern already
used by DatabaseRestApi (no @safe + explicit except OAuth2RedirectError:
raise) so the app-level SupersetErrorException handler can build the
correct 403 response instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 11:19:18 +02:00
Enzo Martellucci a59b96c4f5 fix(explore): keep the row count label visible when the row limit is reached (#43296) 2026-08-28 10:51:27 +02:00
MafiandMatt Fitzgerald 12cd259c55 fix(dataset): preserve validation error messages (#43631)
Co-authored-by: Matt Fitzgerald <matt.fitzgerald@preset.io>
2026-08-28 01:01:17 -04:00
Chandan P 3ddc3b1d56 fix(cache): fall back to default timeout when cache_timeout is None (#43149) 2026-08-27 21:50:53 -07:00
Chandan P 98ec6018df fix(embedded): honor "can view query" permission for guest users (#43151) 2026-08-27 21:49:52 -07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Joe Li
2ebd415b8a chore(deps-dev): bump mcp from 1.29.1 to 2.0.0 (#43611)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-08-27 21:49:50 -07:00
81b3e85522 fix(echarts): guard cross-filter when labelMap entry is missing (#42559)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: rusackas <evan@rusackas.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 21:49:21 -07:00
Viktor Högberg fd64efd72d fix(uploads): set catalog on datasets created by file upload (#43301) 2026-08-27 21:46:51 -07:00
bucketbase26andJoe Li e39bfb255b fix(explore): prevent duplicate Date Range tooltips (#43425)
Co-authored-by: Joe Li <joe@preset.io>
2026-08-27 21:02:57 -07:00
b7301ac88a fix: Dashboard export with charts from multiple databases (#37120)
Co-authored-by: rusackas <evan@rusackas.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 20:27:54 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> fa59b44cfe chore(deps): bump mapbox-gl from 3.28.1 to 3.29.0 in /superset-frontend (#43619)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-27 19:58:55 -07:00
Enzo MartellucciandClaude Sonnet 5 53b88da3b9 fix(dataset): preserve OAuth2 redirects and clean up error messages in CreateDatasetCommand
Addresses review feedback on #43334:
- OAuth2RedirectError is a SupersetException too, so it was being
  flattened into a generic DatasetInvalidError, dropping the
  url/tab_id/redirect_uri the frontend needs to start the OAuth2 dance.
  Re-raise it unchanged, matching the pattern already used elsewhere
  (e.g. commands/database/create.py).
- get_virtual_table_metadata() built its "Template processing error"
  message from str() of a SupersetSyntaxErrorException, which stringifies
  the raw SupersetError list (enum reprs included) instead of the actual
  error text. Use the errors' own .message instead.
- Documented why the SupersetGenericDBErrorException backstop in
  create_virtual_dataset.py is kept despite being unreachable via
  CreateDatasetCommand's current fetch_metadata() path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 12:53:59 +02:00
Enzo Martellucci 3f4fdf5f07 Merge branch 'master' into enxdev/fix/sql-lab-save-dataset 2026-08-24 17:29:09 +02:00
Enzo MartellucciandClaude Sonnet 5 5a6c1b977b refactor(sqllab): drop the redundant canSaveDataset prop from SaveQuery
SaveQuery already receives the query's result columns and computes its
own SQL-staleness check, so master's canSaveDataset prop (threaded
through SqlEditor -> SaveQuery -> SaveDatasetActionButton) duplicated
the same "did the query succeed" condition. Fold it into the single
check SaveQuery already owns instead of ANDing two independently
computed booleans together.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 14:17:12 +02:00
Enzo Martellucci e8577368d3 Merge branch 'master' into enxdev/fix/sql-lab-save-dataset
# Conflicts:
#	superset-frontend/src/SqlLab/components/SaveDatasetActionButton/SaveDatasetActionButton.test.tsx
#	superset-frontend/src/SqlLab/components/SaveDatasetActionButton/index.tsx
#	superset-frontend/src/SqlLab/components/SaveQuery/index.tsx
2026-08-21 14:15:55 +02:00
Enzo Martellucci 540f8cb2d0 fix(sqllab): invalidate the dataset-save gate when the editor SQL changes 2026-08-19 16:52:40 +02:00
Enzo Martellucci aae997e546 fix(sql_lab): return 400 not 500 when raise_for_access hits malformed Jinja 2026-08-19 16:02:09 +02:00
73 changed files with 2135 additions and 201 deletions
+2 -2
View File
@@ -67,7 +67,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -78,6 +78,6 @@ jobs:
# queries: security-extended,security-and-quality
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
with:
category: "/language:${{matrix.language}}"
+1 -1
View File
@@ -196,7 +196,7 @@ excel = ["xlrd>=2.0.2, <2.1"]
excel-export = ["boto3"]
fastmcp = [
"fastmcp>=3.4.7,<4.0",
"mcp>=1.29.1,<2.0",
"mcp>=1.29.1,<3.0",
# tiktoken backs the response-size-guard token estimator. Without
# it, the middleware falls back to a coarser character-based
# heuristic that under-counts JSON-heavy MCP responses.
+9 -9
View File
@@ -108,7 +108,7 @@
"json-stringify-pretty-compact": "^4.0.0",
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",
"mapbox-gl": "^3.28.1",
"mapbox-gl": "^3.29.0",
"markdown-to-jsx": "^9.10.2",
"match-sorter": "^8.3.0",
"memoize-one": "^6.0.0",
@@ -234,7 +234,7 @@
"eslint-plugin-lodash": "^8.0.0",
"eslint-plugin-no-only-tests": "^3.4.0",
"eslint-plugin-react-prefer-function-component": "^5.0.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.1",
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.2",
"eslint-plugin-storybook": "10.5.10",
"eslint-plugin-testing-library": "^7.16.2",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
@@ -20208,9 +20208,9 @@
"license": "MIT"
},
"node_modules/eslint-plugin-react-you-might-not-need-an-effect": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-you-might-not-need-an-effect/-/eslint-plugin-react-you-might-not-need-an-effect-1.0.1.tgz",
"integrity": "sha512-oOhQTYhor88Xp8RVytq25tvBfiAjU0r9SCDC51Qop+3Wg5BR1xGMAkM+/dV4MZbcMhdaU1L9bkv6LC95JmiTig==",
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-you-might-not-need-an-effect/-/eslint-plugin-react-you-might-not-need-an-effect-1.0.2.tgz",
"integrity": "sha512-HgYol2zhH3KbnW9Q4FY/FcIINbsYVL6rwQESChqBC2s9SLWtw1wg05+J7SCrr3BfQpNY2ReYhf8xjpd2JhKJOQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -28488,9 +28488,9 @@
}
},
"node_modules/mapbox-gl": {
"version": "3.28.1",
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.28.1.tgz",
"integrity": "sha512-f8bCHFzZ51bKig7rnD7e08aoFLOV3MNFZduspZ4lgOgiaNVp9sw4NSWcgo3IWTyekYKKXjiICD2BP2o4DiYfxw==",
"version": "3.29.0",
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.29.0.tgz",
"integrity": "sha512-Fnh1WLsZMfihwRZY5scp456iQuZo9G97tTpb26bf/Ejsi/L7O+4dE9+I03VoOor2ul3DEOp6F2P3273omyVsNw==",
"license": "SEE LICENSE IN LICENSE.txt",
"workspaces": [
"src/style-spec",
@@ -43490,7 +43490,7 @@
"license": "Apache-2.0",
"dependencies": {
"@math.gl/web-mercator": "^4.1.0",
"mapbox-gl": "^3.28.1",
"mapbox-gl": "^3.29.0",
"maplibre-gl": "^5.24.0",
"react-map-gl": "^8.1.2",
"supercluster": "^9.0.0"
+2 -2
View File
@@ -185,7 +185,7 @@
"json-stringify-pretty-compact": "^4.0.0",
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",
"mapbox-gl": "^3.28.1",
"mapbox-gl": "^3.29.0",
"markdown-to-jsx": "^9.10.2",
"match-sorter": "^8.3.0",
"memoize-one": "^6.0.0",
@@ -311,7 +311,7 @@
"eslint-plugin-lodash": "^8.0.0",
"eslint-plugin-no-only-tests": "^3.4.0",
"eslint-plugin-react-prefer-function-component": "^5.0.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.1",
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.2",
"eslint-plugin-storybook": "10.5.10",
"eslint-plugin-testing-library": "^7.16.2",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
@@ -65,6 +65,7 @@ export type AntdExposedProps = Pick<
| 'onOpenChange'
| 'optionRender'
| 'placeholder'
| 'prefix'
| 'showArrow'
| 'showSearch'
| 'tokenSeparators'
@@ -119,6 +119,20 @@ export function retrieveErrorMessage(
return statusError || parseStringResponse(str);
}
function getFirstValidationError(message: JsonObject): string | undefined {
const [firstError] = Object.values(message);
if (typeof firstError === 'string') {
return firstError;
}
if (Array.isArray(firstError)) {
return firstError.find((item): item is string => typeof item === 'string');
}
return undefined;
}
export function parseErrorJson(responseJson: JsonObject): ClientErrorObject {
let error = { ...responseJson };
// Backwards compatibility for old error renderers with the new error object
@@ -126,13 +140,12 @@ export function parseErrorJson(responseJson: JsonObject): ClientErrorObject {
error.error = error.description = error.errors[0].message;
error.link = error.errors[0]?.extra?.link;
}
// Marshmallow field validation returns the error message in the format
// of { message: { field1: [msg1, msg2], field2: [msg], } }
// Marshmallow field validation returns arrays for string messages, but
// serializes lazy translation messages as strings instead.
if (!error.error && error.message) {
if (typeof error.message === 'object') {
error.error =
Object.values(error.message as Record<string, string[]>)[0]?.[0] ||
t('Invalid input');
getFirstValidationError(error.message) || t('Invalid input');
}
if (typeof error.message === 'string') {
if (checkForHtml(error.message)) {
@@ -244,6 +244,24 @@ test('parseErrorJson with message', () => {
});
});
test('parseErrorJson preserves string-valued validation messages', () => {
const calculatedColumnError =
'Custom SQL fields cannot be parsed as a single SQL statement.';
expect(
parseErrorJson({
message: {
'columns.0.expression': calculatedColumnError,
},
}),
).toEqual({
message: {
'columns.0.expression': calculatedColumnError,
},
error: calculatedColumnError,
});
});
test('parseErrorJson with HTML message', () => {
expect(
parseErrorJson({
@@ -47,6 +47,9 @@ const getCrossFilterDataMask =
) =>
(value: string) => {
const selected = Object.values(selectedValues);
if (!labelMap[value] && !selected.includes(value)) {
return undefined;
}
let values: string[];
if (selected.includes(value)) {
values = selected.filter(v => v !== value);
@@ -180,3 +180,46 @@ test('cross-filter does nothing when emitCrossFilters is false', () => {
expect(setDataMask).not.toHaveBeenCalled();
});
test('cross-filter does nothing when name is missing from labelMap', () => {
const setDataMask = jest.fn();
const props = buildProps({
groupby: ['topics'],
labelMap: {
cancellations: ['cancellations'],
},
selectedValues: {},
setDataMask,
});
const handlers = allEventHandlers(props);
// e.g. Pie "Other" category is not present in labelMap
handlers.click({ name: 'Other' });
expect(setDataMask).not.toHaveBeenCalled();
});
test('cross-filter still deselects a previously selected value that is missing from labelMap', () => {
const setDataMask = jest.fn();
const props = buildProps({
groupby: ['topics'],
labelMap: {
cancellations: ['cancellations'],
},
// "Other" was selected before it dropped out of labelMap (e.g. a stale
// cross-filter from an earlier render or dashboard state).
selectedValues: { 0: 'Other' },
setDataMask,
});
const handlers = allEventHandlers(props);
handlers.click({ name: 'Other' });
expect(setDataMask).toHaveBeenCalledWith(
expect.objectContaining({
extraFormData: {
filters: [],
},
}),
);
});
@@ -30,7 +30,7 @@
},
"dependencies": {
"@math.gl/web-mercator": "^4.1.0",
"mapbox-gl": "^3.28.1",
"mapbox-gl": "^3.29.0",
"maplibre-gl": "^5.24.0",
"react-map-gl": "^8.1.2",
"supercluster": "^9.0.0"
@@ -43,6 +43,23 @@ describe('SaveDatasetActionButton', () => {
expect(saveDatasetBtn).toBeVisible();
});
test('disables only the dataset button when canSaveDataset is false', () => {
const onSaveAsExplore = jest.fn();
render(
<SaveDatasetActionButton
setShowSave={() => true}
onSaveAsExplore={onSaveAsExplore}
canSaveDataset={false}
/>,
);
// Saving the query needs no results.
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled();
expect(
screen.getByRole('button', { name: /save dataset/i }),
).toBeDisabled();
});
test('disables the save dataset button when the query did not run successfully', async () => {
render(
<SaveDatasetActionButton
@@ -19,12 +19,14 @@
import { act, type ComponentProps } from 'react';
import {
cleanup,
createStore,
fireEvent,
render,
screen,
userEvent,
waitFor,
} from 'spec/helpers/testing-library';
import reducerIndex from 'spec/helpers/reducerIndex';
import fetchMock from 'fetch-mock';
import { SaveDatasetModal } from 'src/SqlLab/components/SaveDatasetModal';
import { createDatasource } from 'src/SqlLab/actions/sqlLab';
@@ -63,6 +65,12 @@ beforeEach(() => {
cleanup();
});
afterEach(() => {
// In-body restores are skipped when an assertion throws, leaking a
// configured spy into later tests.
jest.restoreAllMocks();
});
// Mock createDatasource to return a thunk that resolves with the dataset's
// new id. The test's mock store includes redux-thunk middleware (from RTK's
// getDefaultMiddleware), so dispatch(createDatasource(...)) properly unwraps
@@ -518,6 +526,39 @@ describe('SaveDatasetModal', () => {
});
});
test('surfaces the error and keeps the modal open when saving fails', async () => {
// The chart-payload step's toast was built but never dispatched, so a
// failure there was silent.
const postFormData = jest.spyOn(
require('src/explore/exploreUtils/formData'),
'postFormData',
);
postFormData.mockRejectedValue(new Error('Boom'));
const onHide = jest.fn();
const store = createStore({ user }, reducerIndex);
render(<SaveDatasetModal {...mockedProps} onHide={onHide} />, { store });
fireEvent.change(screen.getByDisplayValue(/unimportant/i), {
target: { value: 'my dataset' },
});
userEvent.click(screen.getByRole('button', { name: /save/i }));
// `createStore` builds its reducer map at runtime, so state isn't typed.
const toasts = () =>
(
store.getState() as unknown as {
messageToasts: { toastType: string }[];
}
).messageToasts;
await waitFor(() => {
expect(toasts()).toHaveLength(1);
});
expect(toasts()[0].toastType).toBe('DANGER_TOAST');
expect(onHide).not.toHaveBeenCalled();
});
test('clearDatasetCache is imported and available', () => {
const { clearDatasetCache } = require('src/utils/cachedSupersetGet');
@@ -61,6 +61,9 @@ import type Subject from 'src/types/Subject';
import { openInNewTab, redirect } from 'src/utils/navigationUtils';
import { mapSubjectValuesToIds } from 'src/features/subjects/SubjectPicker';
// Derived so it can't drift from what `getClientErrorObject` accepts.
type SaveErrorSource = Parameters<typeof getClientErrorObject>[0];
interface QueryDatabase {
id?: number;
}
@@ -391,9 +394,18 @@ export const SaveDatasetModal = ({
setDatasetName(getDefaultDatasetName());
onHide();
})
.catch(() => {
.catch((error?: SaveErrorSource) => {
setLoading(false);
addDangerToast(t('An error occurred saving dataset'));
// `createDatasource` already toasted the server's message and rejects
// with nothing; only the chart-payload step needs its own.
if (!error) {
return;
}
getClientErrorObject(error).then(e =>
dispatch(
addDangerToast(e.error || t('An error occurred saving dataset')),
),
);
});
};
@@ -27,6 +27,8 @@ import {
import SaveQuery from 'src/SqlLab/components/SaveQuery';
import { initialState, databases } from 'src/SqlLab/fixtures';
const RESULT_COLUMNS = [{ column_name: 'col', type: 'STRING' }];
const mockedProps = {
queryEditorId: '123',
animation: false,
@@ -35,7 +37,6 @@ const mockedProps = {
onSave: () => {},
saveQueryWarning: null,
columns: [],
canSaveDataset: true,
};
const mockState = {
@@ -60,8 +61,31 @@ const splitSaveBtnProps = {
...mockedProps.database,
allows_virtual_table_explore: true,
},
columns: RESULT_COLUMNS,
};
const EDITOR_SQL = 'SELECT * FROM t';
const stateWithLatestQuery = ({
id,
state,
sql = EDITOR_SQL,
}: {
id: string;
state: string;
sql?: string;
}) => ({
...mockState,
sqlLab: {
...mockState.sqlLab,
queryEditors: mockState.sqlLab.queryEditors.map(qe => ({
...qe,
latestQueryId: id,
})),
queries: { [id]: { id, state, sql } },
},
});
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
@@ -97,6 +121,71 @@ describe('SavedQuery', () => {
expect(saveBtn).toBeVisible();
});
test('blocks "Save dataset" until the query has run successfully', () => {
// Without a successful run the save can only fail server-side.
render(<SaveQuery {...splitSaveBtnProps} />, {
useRedux: true,
store: mockStore(stateWithLatestQuery({ id: 'qid-1', state: 'failed' })),
});
expect(
screen.getByRole('button', { name: /save dataset/i }),
).toBeDisabled();
// Saving the query itself is unaffected.
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled();
});
test('blocks "Save dataset" when no query has been run at all', () => {
render(<SaveQuery {...splitSaveBtnProps} />, {
useRedux: true,
store: mockStore(mockState),
});
expect(
screen.getByRole('button', { name: /save dataset/i }),
).toBeDisabled();
});
test('blocks "Save dataset" when the SQL changed after a successful run', () => {
// The run succeeded, but not for what is in the editor now -- and it is
// the editor's SQL that gets saved.
render(<SaveQuery {...splitSaveBtnProps} />, {
useRedux: true,
store: mockStore(
stateWithLatestQuery({
id: 'qid-1',
state: 'success',
sql: 'SELECT 1 AS ran_earlier',
}),
),
});
expect(
screen.getByRole('button', { name: /save dataset/i }),
).toBeDisabled();
});
test('blocks "Save dataset" when the successful query returned no columns', () => {
// e.g. a DDL/DML statement -- there is nothing to introspect into a dataset.
render(<SaveQuery {...splitSaveBtnProps} columns={[]} />, {
useRedux: true,
store: mockStore(stateWithLatestQuery({ id: 'qid-1', state: 'success' })),
});
expect(
screen.getByRole('button', { name: /save dataset/i }),
).toBeDisabled();
});
test('enables "Save dataset" once the query has succeeded', () => {
render(<SaveQuery {...splitSaveBtnProps} />, {
useRedux: true,
store: mockStore(stateWithLatestQuery({ id: 'qid-1', state: 'success' })),
});
expect(screen.getByRole('button', { name: /save dataset/i })).toBeEnabled();
});
test('renders a save query modal when user clicks save button', () => {
render(<SaveQuery {...mockedProps} />, {
useRedux: true,
@@ -234,7 +323,7 @@ describe('SavedQuery', () => {
test('renders a save dataset modal when user clicks "save dataset" menu item', async () => {
render(<SaveQuery {...splitSaveBtnProps} />, {
useRedux: true,
store: mockStore(mockState),
store: mockStore(stateWithLatestQuery({ id: 'qid-1', state: 'success' })),
});
const saveDatasetMenuItem = await screen.findByLabelText(/save dataset/i);
@@ -248,7 +337,7 @@ describe('SavedQuery', () => {
test('renders the save dataset modal UI', async () => {
render(<SaveQuery {...splitSaveBtnProps} />, {
useRedux: true,
store: mockStore(mockState),
store: mockStore(stateWithLatestQuery({ id: 'qid-1', state: 'success' })),
});
const saveDatasetMenuItem = await screen.findByLabelText(/save dataset/i);
userEvent.click(saveDatasetMenuItem);
@@ -17,6 +17,8 @@
* under the License.
*/
import { useState, useEffect, useMemo, ChangeEvent } from 'react';
import { useSelector } from 'react-redux';
import { Query, QueryState } from '@superset-ui/core';
import type { DatabaseObject } from 'src/features/databases/types';
import { t } from '@apache-superset/core/translation';
import { styled } from '@apache-superset/core/theme';
@@ -37,7 +39,7 @@ import {
} from 'src/SqlLab/components/SaveDatasetModal';
import { getDatasourceAsSaveableDataset } from 'src/utils/datasourceUtils';
import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
import { QueryEditor } from 'src/SqlLab/types';
import { QueryEditor, SqlLabRootState } from 'src/SqlLab/types';
import useLogAction from 'src/logger/useLogAction';
import {
LOG_ACTIONS_SQLLAB_CREATE_CHART,
@@ -52,7 +54,6 @@ interface SaveQueryProps {
onUpdate: (arg0: QueryPayload, id: string) => void;
saveQueryWarning: string | null;
database: Partial<DatabaseObject> | undefined;
canSaveDataset: boolean;
}
export type QueryPayload = {
@@ -82,7 +83,6 @@ const SaveQuery = ({
saveQueryWarning,
database,
columns,
canSaveDataset,
}: SaveQueryProps) => {
const queryEditor = useQueryEditor(queryEditorId, [
'autorun',
@@ -113,6 +113,17 @@ const SaveQuery = ({
const [label, setLabel] = useState<string>(defaultLabel);
const [showSave, setShowSave] = useState<boolean>(false);
const [showSaveDatasetModal, setShowSaveDatasetModal] = useState(false);
// Saving a dataset runs the SQL to introspect columns, so it needs a
// successful run of the SQL being saved that produced at least one column
// -- editing after a run invalidates it, and running a selection only
// validates that selection.
const latestQuery = useSelector<SqlLabRootState, Query | undefined>(
({ sqlLab }) => sqlLab.queries[queryEditor.latestQueryId || ''],
);
const canSaveDataset =
latestQuery?.state === QueryState.Success &&
latestQuery.sql === queryEditor.sql &&
columns.length > 0;
const isSaved = !!query.remoteId;
const isLabelEmpty = label.trim().length === 0;
const canExploreDatabase = !!database?.allows_virtual_table_explore;
@@ -355,25 +355,32 @@ describe('SqlEditor', () => {
}),
);
// findByRole('button', { name }) walks every stylesheet rule via nwsapi to
// compute the accessible name, which can crash on an unrelated antd Tabs
// "more" button style; findByLabelText matches the same aria-label without
// that traversal.
test('enables the save dataset button when the latest query succeeded', async () => {
const { findByRole } = setupWithLatestQuery({ state: QueryState.Success });
expect(await findByRole('button', { name: 'Save dataset' })).toBeEnabled();
const { findByLabelText } = setupWithLatestQuery({
state: QueryState.Success,
sql: mockedProps.queryEditor.sql,
});
expect(await findByLabelText('Save dataset')).toBeEnabled();
});
test('disables the save dataset button when the latest query failed', async () => {
const { findByRole } = setupWithLatestQuery({
const { findByLabelText } = setupWithLatestQuery({
state: QueryState.Failed,
results: undefined,
});
expect(await findByRole('button', { name: 'Save dataset' })).toBeDisabled();
expect(await findByLabelText('Save dataset')).toBeDisabled();
});
test('disables the save dataset button when the results are not loaded', async () => {
const { findByRole } = setupWithLatestQuery({
const { findByLabelText } = setupWithLatestQuery({
state: QueryState.Success,
results: undefined,
});
expect(await findByRole('button', { name: 'Save dataset' })).toBeDisabled();
expect(await findByLabelText('Save dataset')).toBeDisabled();
});
test('renders an Extension if provided', async () => {
@@ -868,7 +868,6 @@ const SqlEditor: FC<Props> = ({
}
saveQueryWarning={saveQueryWarning}
database={database}
canSaveDataset={successful && resultColumns.length > 0}
/>
<ShareSqlLabQuery queryEditorId={queryEditor.id} />
</>
@@ -53,6 +53,22 @@ test('RowCountLabel renders limit with danger and tooltip', async () => {
expect(tooltip).toHaveTextContent('The row limit');
});
test('RowCountLabel uses a caller-provided limitReachedMessage instead of the default', async () => {
render(
<RowCountLabel
rowcount={100}
limit={100}
limitReachedMessage="Custom limit message"
/>,
);
const expectedText = '100 rows';
expect(screen.getByText(expectedText)).toBeInTheDocument();
userEvent.hover(screen.getByText(expectedText));
const tooltip = await screen.findByRole('tooltip');
expect(tooltip).toHaveTextContent('Custom limit message');
expect(tooltip).not.toHaveTextContent('The row limit set for the chart');
});
test('RowCountLabel renders loading', () => {
render(<RowCountLabel loading />);
const expectedText = 'Loading...';
@@ -26,6 +26,9 @@ type RowCountLabelProps = {
limit?: number;
loading?: boolean;
label?: JSX.Element;
// Overrides the default "chart" wording for panes (e.g. samples) where the
// limit reached isn't the chart's own row_limit.
limitReachedMessage?: React.ReactNode;
};
const limitReachedMsg = t(
@@ -33,7 +36,13 @@ const limitReachedMsg = t(
);
export default function RowCountLabel(props: RowCountLabelProps) {
const { rowcount = 0, limit = null, loading, label } = props;
const {
rowcount = 0,
limit = null,
loading,
label,
limitReachedMessage,
} = props;
const limitReached = limit && rowcount >= limit;
const type =
limitReached || (rowcount === 0 && !loading) ? 'error' : 'default';
@@ -50,7 +59,10 @@ export default function RowCountLabel(props: RowCountLabelProps) {
</Label>
);
return limitReached ? (
<Tooltip id="tt-rowcount-tooltip" title={<span>{limitReachedMsg}</span>}>
<Tooltip
id="tt-rowcount-tooltip"
title={<span>{limitReachedMessage ?? limitReachedMsg}</span>}
>
{label || labelText}
</Tooltip>
) : (
@@ -117,7 +117,7 @@ const StyledDiv = styled.div`
${
isMobileConsumptionEnabled()
? `@media (max-width: ${theme.screenSMMax}px) {
[data-test='slice-header'] .header-title {
.slice-header .header-title {
-webkit-line-clamp: unset;
display: block;
white-space: normal;
@@ -79,6 +79,15 @@ type PropertiesModalProps = {
addSuccessToast: (message: string) => void;
addDangerToast: (message: string) => void;
onlyApply?: boolean;
renderExtraFields?: (context: {
assetId: number;
assetType: 'dashboard';
accessorCount: number;
}) => {
content: React.ReactNode;
saveDisabled?: boolean;
saveTooltip?: string;
};
};
type DashboardInfo = {
@@ -107,6 +116,7 @@ const PropertiesModal = ({
onlyApply = false,
onSubmit = () => {},
show = false,
renderExtraFields,
}: PropertiesModalProps) => {
const dispatch = useDispatch();
const [form] = Form.useForm();
@@ -123,6 +133,17 @@ const PropertiesModal = ({
});
const [editors, setEditors] = useState<Subject[]>([]);
const [viewers, setViewers] = useState<Subject[]>([]);
const extraFields = useMemo(
() =>
renderExtraFields?.({
assetId: dashboardId,
assetType: 'dashboard',
accessorCount: editors.length + viewers.length,
}),
[renderExtraFields, dashboardId, editors.length, viewers.length],
);
const saveLabel = onlyApply ? t('Apply') : t('Save');
const [tags, setTags] = useState<TagType[]>([]);
const [customCss, setCustomCss] = useState('');
@@ -698,15 +719,21 @@ const PropertiesModal = ({
}}
title={t('Dashboard properties')}
isEditMode
saveDisabled={dashboardInfo?.isManagedExternally || hasErrors}
saveDisabled={
dashboardInfo?.isManagedExternally ||
hasErrors ||
extraFields?.saveDisabled
}
saveLoading={isApplying}
contentLoading={isLoading}
errorTooltip={
dashboardInfo?.isManagedExternally
? t(
"This dashboard is managed externally, and can't be edited in Superset",
)
: errorTooltip
extraFields?.saveDisabled && extraFields?.saveTooltip
? extraFields.saveTooltip
: dashboardInfo?.isManagedExternally
? t(
"This dashboard is managed externally, and can't be edited in Superset",
)
: errorTooltip
}
saveText={saveLabel}
wrapProps={{ 'data-test': 'properties-edit-modal' }}
@@ -769,6 +796,7 @@ const PropertiesModal = ({
onChangeViewers={handleOnChangeViewers}
onChangeTags={handleChangeTags}
onClearTags={handleClearTags}
renderExtraFields={extraFields}
/>
),
},
@@ -38,6 +38,11 @@ interface AccessSectionProps {
onChangeViewers: (viewers: SubjectPickerValue[]) => void;
onChangeTags: (tags: { label: string; value: number }[]) => void;
onClearTags: () => void;
renderExtraFields?: {
content: React.ReactNode;
saveDisabled?: boolean;
saveTooltip?: string;
};
}
const AccessSection = ({
@@ -49,6 +54,7 @@ const AccessSection = ({
onChangeViewers,
onChangeTags,
onClearTags,
renderExtraFields,
}: AccessSectionProps) => {
const enableViewers = isFeatureEnabled(FeatureFlag.EnableViewers);
@@ -134,6 +140,7 @@ const AccessSection = ({
/>
</ModalFormField>
)}
{renderExtraFields?.content}
</>
);
};
@@ -208,6 +208,18 @@ test('Should render', () => {
expect(screen.getByTestId('slice-header')).toBeInTheDocument();
});
test('Should expose a class hook, not just data-test, for fullscreen styling', () => {
const props = createProps();
render(<SliceHeader {...props} />, {
useRedux: true,
useRouter: true,
initialState,
});
// The production build strips data-test attributes, so CSS that targets the
// header must hang off a class instead.
expect(screen.getByTestId('slice-header')).toHaveClass('slice-header');
});
test('Should render - default props', () => {
const props = createProps();
@@ -270,7 +270,11 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
);
return (
<ChartHeaderStyles data-test="slice-header" ref={ref}>
<ChartHeaderStyles
className="slice-header"
data-test="slice-header"
ref={ref}
>
<div className="header-title" ref={headerRef}>
<Tooltip title={headerTooltip}>
{/* this div ensures the hover event triggers correctly and prevents flickering */}
@@ -19,20 +19,13 @@
import { css, SupersetTheme } from '@apache-superset/core/theme';
export const fullscreenStyles = (theme: SupersetTheme) => css`
[data-test='dashboard-component-chart-holder']:fullscreen {
.dashboard-component-chart-holder:fullscreen {
background-color: ${theme.colorBgBase};
width: 100vw;
height: 100vh;
box-sizing: border-box;
display: flex;
flex-direction: column;
padding: ${theme.sizeUnit * 4}px;
overflow: visible;
position: relative;
pointer-events: auto;
z-index: ${theme.zIndexPopupBase};
opacity: 1;
visibility: visible;
/* Ensure children take up available space */
.dashboard-chart,
@@ -58,13 +51,8 @@ export const fullscreenStyles = (theme: SupersetTheme) => css`
}
}
/* Interaction and Header fixes */
[data-test='dashboard-component-chart-holder']:fullscreen * {
pointer-events: auto;
}
[data-test='dashboard-component-chart-holder']:fullscreen
[data-test='slice-header'] {
/* Keep the header above the chart it shares the fullscreen layer with */
.dashboard-component-chart-holder:fullscreen .slice-header {
z-index: ${theme.zIndexPopupBase};
position: relative;
}
@@ -337,13 +337,17 @@ const ChartHolder = ({
)}
>
<AntdThemeProvider
getPopupContainer={(triggerNode: HTMLElement) =>
document.fullscreenElement
? (triggerNode?.closest?.(
'[data-test="dashboard-component-chart-holder"]',
) as HTMLElement) || document.body
: document.body
}
getPopupContainer={(triggerNode?: HTMLElement) => {
// Only the fullscreen element's subtree is painted, so popups
// have to be portaled into it rather than to document.body.
// Resolve it directly instead of matching a selector: the
// production build strips data-test attributes.
const fullscreenElement =
document.fullscreenElement as HTMLElement | null;
return triggerNode && fullscreenElement?.contains(triggerNode)
? fullscreenElement
: document.body;
}}
>
{!editMode && (
<AnchorLink
@@ -97,3 +97,29 @@ test('does not render DeckglLayerVisibilityTooltip for standard filter type', ()
screen.queryByTestId('deckgl-layer-visibility-tooltip-icon'),
).not.toBeInTheDocument();
});
test('does not mark a defaultToFirstItem-only filter as required', () => {
render(
<FilterControl
filter={{
...nativeFilter,
controlValues: { defaultToFirstItem: true },
}}
onFilterSelectionChange={jest.fn()}
/>,
);
expect(screen.queryByText('*')).not.toBeInTheDocument();
});
test('marks an enableEmptyFilter filter as required', () => {
render(
<FilterControl
filter={{
...nativeFilter,
controlValues: { enableEmptyFilter: true },
}}
onFilterSelectionChange={jest.fn()}
/>,
);
expect(screen.getByText('*')).toBeInTheDocument();
});
@@ -65,9 +65,7 @@ const FilterControl = ({
isFilterInScope(filter) &&
checkIsMissingRequiredValue(filter, filter.dataMask?.filterState);
const validateStatus = isMissingRequiredValue ? 'error' : undefined;
const isRequired =
!!filter.controlValues?.enableEmptyFilter ||
!!filter.controlValues?.defaultToFirstItem;
const isRequired = !!filter.controlValues?.enableEmptyFilter;
const inverseSelection = !!filter.controlValues?.inverseSelection;
const {
@@ -211,6 +211,16 @@ test('checkIsMissingRequiredValue returns false for non-required filter with und
expect(checkIsMissingRequiredValue(filter, filterState)).toBe(false);
});
test('checkIsMissingRequiredValue returns false when only defaultToFirstItem is set', () => {
const filter = createFilter('test-filter', {
enableEmptyFilter: false,
controlValues: { defaultToFirstItem: true },
});
expect(checkIsMissingRequiredValue(filter, { value: null })).toBe(false);
expect(checkIsMissingRequiredValue(filter, { value: undefined })).toBe(false);
});
test('checkIsMissingRequiredValue returns falsy for filter without controlValues', () => {
const filter = { id: 'test-filter' } as Filter;
const filterState: FilterState = { value: undefined };
@@ -299,6 +309,48 @@ test('checkIsApplyDisabled returns true when required filter is missing value in
);
});
test('checkIsApplyDisabled enables Apply after clearing a cascading defaultToFirstItem child', () => {
// Regression: a child filter that is dependent on a parent and configured with
// "Select first filter value by default" but NOT "Filter value is required"
// must stay clearable — clearing it may not disable Apply.
const parent = createFilter('parent', {
enableEmptyFilter: true,
controlValues: { defaultToFirstItem: true },
});
const child = createFilter('child', {
enableEmptyFilter: false,
controlValues: { defaultToFirstItem: true },
});
const dataMaskSelected: DataMaskStateWithId = {
parent: {
id: 'parent',
filterState: { value: ['USA'] },
extraFormData: createExtraFormDataWithFilter('country', ['USA']),
},
child: {
id: 'child',
filterState: { value: null },
extraFormData: {},
},
};
const dataMaskApplied: DataMaskStateWithId = {
parent: {
id: 'parent',
filterState: { value: ['USA'] },
extraFormData: createExtraFormDataWithFilter('country', ['USA']),
},
child: {
id: 'child',
filterState: { value: ['CA'] },
extraFormData: createExtraFormDataWithFilter('state', ['CA']),
},
};
expect(
checkIsApplyDisabled(dataMaskSelected, dataMaskApplied, [parent, child]),
).toBe(false);
});
test('checkIsApplyDisabled enables Apply when Selected has a filter value not yet in Applied', () => {
// Regression: when a required filter's default isn't applied (Applied missing
// the entry) and the user types a value, Selected gains an entry Applied
@@ -48,9 +48,10 @@ export const checkIsMissingRequiredValue = (
filter: FilterElement,
filterState?: FilterState,
) => {
const isRequired =
!!filter.controlValues?.enableEmptyFilter ||
!!filter.controlValues?.defaultToFirstItem;
// Only `enableEmptyFilter` ("Filter value is required") makes a value
// mandatory. `defaultToFirstItem` merely seeds an initial selection, so a
// filter cleared by the user must stay clearable, with Apply enabled.
const isRequired = !!filter.controlValues?.enableEmptyFilter;
if (!isRequired) return false;
@@ -16,7 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import {
render,
screen,
waitFor,
userEvent,
fireEvent,
} from 'spec/helpers/testing-library';
import Control, { ControlProps } from 'src/explore/components/Control';
const defaultProps: ControlProps = {
@@ -77,3 +83,72 @@ test('call setControlValue if isVisible is false', async () => {
expect(defaultProps.actions.setControlValue).toHaveBeenCalled(),
);
});
test('shows the description icon while the control is hovered', async () => {
render(
setup({
label: 'My checkbox',
description: 'Help text',
}),
);
expect(
screen.queryByRole('button', { name: 'Show info tooltip' }),
).not.toBeInTheDocument();
await userEvent.hover(screen.getByTestId('checkbox'));
expect(
screen.getByRole('button', { name: 'Show info tooltip' }),
).toBeInTheDocument();
await userEvent.unhover(screen.getByTestId('checkbox'));
expect(
screen.queryByRole('button', { name: 'Show info tooltip' }),
).not.toBeInTheDocument();
});
test('shows the description icon while the control has keyboard focus', () => {
render(
setup({
label: 'My checkbox',
description: 'Help text',
}),
);
expect(
screen.queryByRole('button', { name: 'Show info tooltip' }),
).not.toBeInTheDocument();
fireEvent.focus(screen.getByRole('checkbox'));
const infoIcon = screen.getByRole('button', { name: 'Show info tooltip' });
expect(infoIcon).toBeInTheDocument();
fireEvent.blur(screen.getByRole('checkbox'), { relatedTarget: infoIcon });
expect(
screen.getByRole('button', { name: 'Show info tooltip' }),
).toBeInTheDocument();
fireEvent.blur(infoIcon, { relatedTarget: document.body });
expect(
screen.queryByRole('button', { name: 'Show info tooltip' }),
).not.toBeInTheDocument();
});
test('keeps the description icon visible when the pointer leaves a focused control', () => {
render(
setup({
label: 'My checkbox',
description: 'Help text',
}),
);
fireEvent.focus(screen.getByRole('checkbox'));
expect(
screen.getByRole('button', { name: 'Show info tooltip' }),
).toBeInTheDocument();
fireEvent.mouseLeave(screen.getByTestId('checkbox'));
expect(
screen.getByRole('button', { name: 'Show info tooltip' }),
).toBeInTheDocument();
});
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { ReactNode, useCallback, useState, useEffect } from 'react';
import { ReactNode, useCallback, useState, useEffect, FocusEvent } from 'react';
import { isEqual } from 'lodash-es';
import {
ControlType,
@@ -70,7 +70,18 @@ export default function Control(props: ControlProps) {
} = props;
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
const wasVisible = usePrevious(isVisible);
const handleBlur = (event: FocusEvent<HTMLDivElement>) => {
if (
!(event.relatedTarget instanceof Node) ||
!event.currentTarget.contains(event.relatedTarget)
) {
setFocused(false);
}
};
const onChange = useCallback(
(value: any, errors: any[]) => setControlValue(name, value, errors),
[name, setControlValue],
@@ -119,9 +130,15 @@ export default function Control(props: ControlProps) {
style={hidden ? { display: 'none' } : undefined}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onFocus={() => setFocused(true)}
onBlur={handleBlur}
>
<ErrorBoundary>
<ControlComponent onChange={onChange} hovered={hovered} {...props} />
<ControlComponent
onChange={onChange}
hovered={hovered || focused}
{...props}
/>
</ErrorBoundary>
</StyledControl>
);
@@ -0,0 +1,112 @@
/**
* 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 {
render,
screen,
userEvent,
fireEvent,
} from 'spec/helpers/testing-library';
import ControlHeader from './ControlHeader';
const description = 'This control filters the whole chart.';
test('does not render the description icon until the control is hovered', () => {
const { rerender } = render(
<ControlHeader
name="time_range"
label="Date Range"
description={description}
/>,
);
expect(
screen.queryByRole('button', { name: 'Show info tooltip' }),
).not.toBeInTheDocument();
rerender(
<ControlHeader
name="time_range"
label="Date Range"
description={description}
hovered
/>,
);
expect(
screen.getByRole('button', { name: 'Show info tooltip' }),
).toBeInTheDocument();
});
test('notifies onDescriptionHoverChange when the info icon is hovered', async () => {
const onDescriptionHoverChange = jest.fn();
render(
<ControlHeader
name="time_range"
label="Date Range"
description={description}
hovered
onDescriptionHoverChange={onDescriptionHoverChange}
/>,
);
const infoIcon = screen.getByRole('button', { name: 'Show info tooltip' });
await userEvent.hover(infoIcon);
expect(onDescriptionHoverChange).toHaveBeenCalledWith(true);
await userEvent.unhover(infoIcon);
expect(onDescriptionHoverChange).toHaveBeenCalledWith(false);
});
test('notifies onDescriptionHoverChange when the info icon is focused', () => {
const onDescriptionHoverChange = jest.fn();
render(
<ControlHeader
name="time_range"
label="Date Range"
description={description}
hovered
onDescriptionHoverChange={onDescriptionHoverChange}
/>,
);
const infoIcon = screen.getByRole('button', { name: 'Show info tooltip' });
fireEvent.focus(infoIcon);
expect(onDescriptionHoverChange).toHaveBeenCalledWith(true);
fireEvent.blur(infoIcon);
expect(onDescriptionHoverChange).toHaveBeenCalledWith(false);
});
test('activates tooltipOnClick from the keyboard', () => {
const tooltipOnClick = jest.fn();
render(
<ControlHeader
name="time_range"
label="Date Range"
description={description}
hovered
tooltipOnClick={tooltipOnClick}
/>,
);
fireEvent.keyDown(screen.getByRole('button', { name: 'Show info tooltip' }), {
key: 'Enter',
});
expect(tooltipOnClick).toHaveBeenCalledTimes(1);
});
@@ -38,6 +38,7 @@ export type ControlHeaderProps = {
tooltipOnClick?: () => void;
warning?: string;
danger?: string;
onDescriptionHoverChange?: (hovered: boolean) => void;
// Allow extra props from control spread patterns (e.g. {...this.props})
[key: string]: unknown;
};
@@ -71,6 +72,7 @@ const ControlHeader: FC<ControlHeaderProps> = ({
tooltipOnClick = () => {},
warning,
danger,
onDescriptionHoverChange,
}) => {
const theme = useTheme();
@@ -89,24 +91,44 @@ const ControlHeader: FC<ControlHeaderProps> = ({
position: absolute;
top: 50%;
right: 0;
z-index: 1;
padding-left: ${theme.sizeUnit}px;
transform: translate(100%, -50%);
white-space: nowrap;
pointer-events: auto;
`}
>
{description && (
<span>
<>
<Tooltip
id="description-tooltip"
title={description}
placement="top"
mouseLeaveDelay={0}
trigger={['hover', 'focus']}
>
<Icons.InfoCircleOutlined
css={iconStyles}
{/* Same role="button" pattern as the label text: a real <button>
is not valid inside FormLabel's <label>. */}
<span
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role
role="button"
tabIndex={0}
data-test={`${name}-description-icon`}
aria-label={t('Show info tooltip')}
onMouseEnter={() => onDescriptionHoverChange?.(true)}
onMouseLeave={() => onDescriptionHoverChange?.(false)}
onFocus={() => onDescriptionHoverChange?.(true)}
onBlur={() => onDescriptionHoverChange?.(false)}
onClick={tooltipOnClick}
/>
onKeyDown={handleKeyboardActivation(tooltipOnClick)}
css={css`
cursor: pointer;
`}
>
<Icons.InfoCircleOutlined css={iconStyles} />
</span>
</Tooltip>{' '}
</span>
</>
)}
{renderTrigger && (
<span>
@@ -68,6 +68,8 @@ export const TableControls = ({
canDownload,
rowLimit,
rowLimitOptions,
effectiveRowLimit,
limitReachedMessage,
onRowLimitChange,
onDownloadCSV,
onDownloadXLSX,
@@ -111,14 +113,19 @@ export const TableControls = ({
value={rowLimit}
onChange={onRowLimitChange}
options={rowLimitOptions ?? []}
// Labelled as the applied limit to avoid a second row count next to RowCountLabel.
prefix={t('Limit')}
css={css`
min-width: 110px;
min-width: 160px;
`}
/>
)}
{(!onRowLimitChange || rowcount < (rowLimit ?? Infinity)) && (
<RowCountLabel rowcount={rowcount} loading={isLoading} />
)}
<RowCountLabel
rowcount={rowcount}
limit={effectiveRowLimit ?? rowLimit}
limitReachedMessage={limitReachedMessage}
loading={isLoading}
/>
{canDownload && onDownloadCSV && onDownloadXLSX && (
<DownloadDropdown
onDownloadCSV={onDownloadCSV}
@@ -136,6 +136,12 @@ export const SamplesPane = ({
const columns = useGridColumns(colnames, coltypes, data);
const keywordFilter = useKeywordFilter(filterText);
// Samples aren't capped by a chart's row_limit, just this pane's own
// page-size selector, so RowCountLabel's default "chart" wording is wrong here.
const limitReachedMessage = t(
'The sample row limit was reached. This %s may contain more rows.',
datasetLabelLower(),
);
const handleInputChange = useCallback(
(input: string) => setFilterText(input),
@@ -161,6 +167,7 @@ export const SamplesPane = ({
canDownload={canDownload}
rowLimit={rowLimit}
rowLimitOptions={ROW_LIMIT_OPTIONS}
limitReachedMessage={limitReachedMessage}
onRowLimitChange={handleRowLimitChange}
/>
<ErrorAlertWrapper>
@@ -197,6 +204,7 @@ export const SamplesPane = ({
canDownload={canDownload}
rowLimit={rowLimit}
rowLimitOptions={ROW_LIMIT_OPTIONS}
limitReachedMessage={limitReachedMessage}
onRowLimitChange={handleRowLimitChange}
/>
<GridContainer>
@@ -56,6 +56,8 @@ export const SingleQueryResultPane = ({
columnDisplayNames,
rowLimit,
rowLimitOptions,
effectiveRowLimit,
limitReachedMessage,
onRowLimitChange,
onDownloadCSV,
onDownloadXLSX,
@@ -86,6 +88,8 @@ export const SingleQueryResultPane = ({
canDownload={canDownload}
rowLimit={rowLimit}
rowLimitOptions={rowLimitOptions}
effectiveRowLimit={effectiveRowLimit}
limitReachedMessage={limitReachedMessage}
onRowLimitChange={onRowLimitChange}
onDownloadCSV={onDownloadCSV}
onDownloadXLSX={onDownloadXLSX}
@@ -84,6 +84,17 @@ export const useResultsPane = ({
// Never exceed the chart's own row_limit
const effectiveRowLimit = Math.min(rowLimit, chartRowLimit);
// When this pane's own row-limit selector is stricter than the chart's
// row_limit, it - not the chart - is what caps the result, so
// RowCountLabel's default "chart" wording would be misleading (the chart's
// configured row_limit was never actually reached).
const limitReachedMessage =
rowLimit < chartRowLimit
? t(
'The row limit selected for this pane was reached. There may be more matching rows.',
)
: undefined;
const cappedFormData = useMemo(
() => ({ ...queryFormData, row_limit: effectiveRowLimit }),
[queryFormData, effectiveRowLimit],
@@ -236,6 +247,8 @@ export const useResultsPane = ({
columnDisplayNames={columnDisplayNames}
rowLimit={rowLimit}
rowLimitOptions={ROW_LIMIT_OPTIONS}
effectiveRowLimit={effectiveRowLimit}
limitReachedMessage={limitReachedMessage}
onRowLimitChange={handleRowLimitChange}
/>
</StyledDiv>
@@ -0,0 +1,94 @@
/**
* 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 {
act,
render,
screen,
sleep,
userEvent,
} from 'spec/helpers/testing-library';
import { GenericDataType } from '@apache-superset/core/common';
import {
TableControls,
ROW_LIMIT_OPTIONS,
} from '../components/DataTableControls';
import { TableControlsProps } from '../types';
const setup = (overrides: Partial<TableControlsProps> = {}) =>
render(
<TableControls
data={[]}
columnNames={['name']}
columnTypes={[GenericDataType.String]}
rowcount={0}
onInputChange={jest.fn()}
isLoading={false}
canDownload
rowLimit={100}
rowLimitOptions={ROW_LIMIT_OPTIONS}
onRowLimitChange={jest.fn()}
{...overrides}
/>,
{ useRedux: true },
);
test('shows the row count when the result fills the selected row limit', () => {
setup({ rowcount: 100, rowLimit: 100 });
expect(screen.getByTestId('row-count-label')).toHaveTextContent('100 rows');
});
test('warns that the row limit was reached when the result fills it', async () => {
setup({ rowcount: 100, rowLimit: 100 });
userEvent.hover(screen.getByTestId('row-count-label'));
expect(await screen.findByRole('tooltip')).toHaveTextContent(
'The row limit set for the chart was reached',
);
});
test('does not warn when the result is smaller than the selected row limit', async () => {
setup({ rowcount: 42, rowLimit: 100 });
expect(screen.getByTestId('row-count-label')).toHaveTextContent('42 rows');
userEvent.hover(screen.getByTestId('row-count-label'));
// Wait past antd's 0.1s mouseEnterDelay so a regression that made the
// tooltip appear would be caught here instead of racing the delay.
await act(() => sleep(150));
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
test("warns when the chart's own row limit truncates below the selected row limit", async () => {
setup({ rowcount: 250, rowLimit: 1000, effectiveRowLimit: 250 });
userEvent.hover(screen.getByTestId('row-count-label'));
expect(await screen.findByRole('tooltip')).toHaveTextContent(
'The row limit set for the chart was reached',
);
});
test('labels the row limit selector so it is not read as a second row count', () => {
setup({ rowcount: 100, rowLimit: 100 });
expect(screen.getByText('Limit')).toBeInTheDocument();
});
@@ -16,7 +16,12 @@
* specific language governing permissions and limitations
* under the License.
*/
import { screen, render, waitFor } from 'spec/helpers/testing-library';
import {
screen,
render,
waitFor,
userEvent,
} from 'spec/helpers/testing-library';
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
import { getChartDataRequest } from 'src/components/Chart/chartAction';
import { ResultsPaneOnDashboard } from '../components';
@@ -157,6 +162,42 @@ describe('useResultsPane query data reuse', () => {
expect(screen.queryByText('Sci-Fi')).not.toBeInTheDocument();
expect(screen.getByText('2 rows')).toBeVisible();
expect(mockedGetChartDataRequest).not.toHaveBeenCalled();
userEvent.hover(screen.getByText('2 rows'));
expect(await screen.findByRole('tooltip')).toHaveTextContent(
'The row limit set for the chart was reached',
);
});
test("warns about this pane's own row limit, not the chart's, when the pane's selector is what caps the result", async () => {
// chart row_limit (2000) is well above this pane's default 1000-row
// selector, so the selector - not the chart - is what truncates here.
const props = createResultsPaneOnDashboardProps({
sliceId: 208,
rowLimit: 2000,
queriesResponse: [
{
colnames: ['genre'],
coltypes: [1],
data: Array.from({ length: 1500 }, (_, i) => ({
genre: `genre-${i}`,
})),
rowcount: 1500,
},
],
});
render(<ResultsPaneOnDashboard {...props} />, { useRedux: true });
const rowCountLabel = await screen.findByTestId('row-count-label');
expect(rowCountLabel).toHaveTextContent('1k rows');
userEvent.hover(rowCountLabel);
const tooltip = await screen.findByRole('tooltip');
expect(tooltip).toHaveTextContent(
'The row limit selected for this pane was reached',
);
expect(tooltip).not.toHaveTextContent('for the chart');
});
test('renders an empty (0 rows) result from reused data without an API call', async () => {
@@ -84,6 +84,12 @@ export interface TableControlsProps extends DrillControlsProps {
canDownload: boolean;
rowLimit?: number;
rowLimitOptions?: { value: number; label: string }[];
// Effective result limit, capped by the chart's row limit.
// Defaults to `rowLimit` and controls the "row limit reached" warning.
effectiveRowLimit?: number;
// Overrides RowCountLabel's default "chart" wording for panes (e.g.
// samples) where the limit reached isn't the chart's own row_limit.
limitReachedMessage?: React.ReactNode;
onRowLimitChange?: (limit: number) => void;
}
@@ -104,5 +110,9 @@ export interface SingleQueryResultPaneProp
columnDisplayNames?: Record<string, string>;
rowLimit?: number;
rowLimitOptions?: { value: number; label: string }[];
effectiveRowLimit?: number;
// Overrides RowCountLabel's default "chart" wording when the pane's own
// row-limit selector, not the chart's row_limit, is what capped the result.
limitReachedMessage?: React.ReactNode;
onRowLimitChange?: (limit: number) => void;
}
@@ -16,7 +16,14 @@
* specific language governing permissions and limitations
* under the License.
*/
import { ChangeEvent, useMemo, useState, useCallback, useEffect } from 'react';
import {
type ReactNode,
ChangeEvent,
useMemo,
useState,
useCallback,
useEffect,
} from 'react';
import {
Input,
@@ -56,6 +63,12 @@ export type PropertiesModalProps = {
permissionsError?: string;
addSuccessToast: (msg: string) => void;
addDangerToast: (msg: string) => void;
/** Optional render prop for injecting extra fields (e.g. folder selector). */
renderExtraFields?: (context: {
assetId: number;
assetType: 'chart';
accessorCount: number;
}) => { content: ReactNode; saveDisabled?: boolean; saveTooltip?: string };
};
function PropertiesModal({
@@ -65,6 +78,7 @@ function PropertiesModal({
show,
addSuccessToast,
addDangerToast,
renderExtraFields,
}: PropertiesModalProps) {
const [submitting, setSubmitting] = useState(false);
// values of form inputs
@@ -87,6 +101,25 @@ function PropertiesModal({
>(null);
const [tags, setTags] = useState<TagType[]>([]);
const chartId = slice.slice_id;
const extraFields = useMemo(
() =>
chartId
? renderExtraFields?.({
assetId: chartId,
assetType: 'chart',
accessorCount:
(selectedEditors?.length ?? 0) + (selectedViewers?.length ?? 0),
})
: undefined,
[
chartId,
renderExtraFields,
selectedEditors?.length,
selectedViewers?.length,
],
);
// Validation setup
const modalSections = useMemo(
() => [
@@ -281,14 +314,20 @@ function PropertiesModal({
title={t('Chart properties')}
isEditMode
saveDisabled={
submitting || !name || slice.is_managed_externally || hasErrors
submitting ||
!name ||
slice.is_managed_externally ||
hasErrors ||
extraFields?.saveDisabled
}
errorTooltip={
slice.is_managed_externally
? t(
"This chart is managed externally, and can't be edited in Superset",
)
: errorTooltip
extraFields?.saveDisabled && extraFields?.saveTooltip
? extraFields.saveTooltip
: slice.is_managed_externally
? t(
"This chart is managed externally, and can't be edited in Superset",
)
: errorTooltip
}
wrapProps={{ 'data-test': 'properties-edit-modal' }}
>
@@ -395,6 +434,7 @@ function PropertiesModal({
/>
</ModalFormField>
)}
{extraFields?.content}
</>
),
},
@@ -147,6 +147,7 @@ export default function DateFilterLabel(props: DateFilterControlProps) {
onOpenPopover = noOp,
onClosePopover = noOp,
isOverflowingFilterBar = false,
hovered: isControlHovered = false,
} = props;
const defaultTimeFilter = useDefaultTimeFilter();
@@ -161,9 +162,16 @@ export default function DateFilterLabel(props: DateFilterControlProps) {
const [validTimeRange, setValidTimeRange] = useState<boolean>(false);
const [evalResponse, setEvalResponse] = useState<string>(value);
const [tooltipTitle, setTooltipTitle] = useState<ReactNode | null>(t(value));
const [isDescriptionHovered, setIsDescriptionHovered] = useState(false);
const theme = useTheme();
const [labelRef, labelIsTruncated] = useCSSTextTruncation<HTMLSpanElement>();
useEffect(() => {
if (!isControlHovered) {
setIsDescriptionHovered(false);
}
}, [isControlHovered]);
useEffect(() => {
if (value === NO_TIME_RANGE) {
setActualTimeRange(NO_TIME_RANGE);
@@ -368,7 +376,12 @@ export default function DateFilterLabel(props: DateFilterControlProps) {
}
overlayClassName="time-range-popover"
>
<Tooltip placement="top" title={tooltipTitle}>
<Tooltip
placement="top"
title={isDescriptionHovered ? null : tooltipTitle}
mouseLeaveDelay={0}
overlayStyle={{ pointerEvents: 'none' }}
>
{/* Wrap in a span so the Popover gets a stable DOM ref target;
DateLabel forwards its ref to an inner span used for measuring
text truncation, which would otherwise become the popover's
@@ -390,7 +403,10 @@ export default function DateFilterLabel(props: DateFilterControlProps) {
return (
<>
<ControlHeader {...props} />
<ControlHeader
{...props}
onDescriptionHoverChange={setIsDescriptionHovered}
/>
{popoverContent}
</>
);
@@ -18,16 +18,35 @@
*/
import thunk from 'redux-thunk';
import { Provider } from 'react-redux';
import configureStore from 'redux-mock-store';
import configureMockStore from 'redux-mock-store';
import { render, screen, userEvent } from 'spec/helpers/testing-library';
import {
render,
screen,
userEvent,
waitFor,
fireEvent,
} from 'spec/helpers/testing-library';
import { NO_TIME_RANGE } from '@superset-ui/core';
import { NO_TIME_RANGE, fetchTimeRange } from '@superset-ui/core';
import DateFilterLabel from '..';
import { DateFilterControlProps } from '../types';
import { DateFilterTestKey } from '../utils';
const mockStore = configureStore([thunk]);
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
fetchTimeRange: jest.fn(),
}));
const mockedFetchTimeRange = fetchTimeRange as jest.MockedFunction<
typeof fetchTimeRange
>;
const FIELD_TOOLTIP = '2024-01-01 ≤ col < 2024-01-08';
const DESCRIPTION_TOOLTIP =
'This control filters the whole chart based on the selected time range.';
const mockStore = configureMockStore([thunk]);
const defaultProps = {
onChange: jest.fn(),
@@ -35,6 +54,11 @@ const defaultProps = {
onOpenPopover: jest.fn(),
};
beforeEach(() => {
mockedFetchTimeRange.mockReset();
mockedFetchTimeRange.mockResolvedValue({ value: FIELD_TOOLTIP });
});
function setup(
props: Omit<DateFilterControlProps, 'name'> = defaultProps,
store: any = mockStore({}),
@@ -136,3 +160,61 @@ test('DateFilter should properly handle isOverflowingFilterBar prop changes', ()
expect(popoverAfterRerender?.parentElement).toBe(trigger.parentElement);
expect(popoverAfterRerender?.parentElement).not.toBe(document.body);
});
test('hovering the description icon does not show the date range tooltip', async () => {
const tooltipOnClick = jest.fn();
render(
setup({
...defaultProps,
value: 'Last week',
label: 'Date Range',
description: DESCRIPTION_TOOLTIP,
hovered: true,
tooltipOnClick,
}),
);
await waitFor(() => {
expect(screen.getByText('Last week')).toBeInTheDocument();
});
await userEvent.hover(screen.getByText('Last week'));
expect(await screen.findByRole('tooltip')).toHaveTextContent(FIELD_TOOLTIP);
const descriptionIcon = screen.getByRole('button', {
name: 'Show info tooltip',
});
fireEvent.focus(descriptionIcon);
await waitFor(() => {
expect(screen.getByRole('tooltip')).toHaveTextContent(DESCRIPTION_TOOLTIP);
expect(screen.getByRole('tooltip')).not.toHaveTextContent(FIELD_TOOLTIP);
expect(screen.getAllByRole('tooltip')).toHaveLength(1);
});
fireEvent.blur(descriptionIcon);
await waitFor(() => {
expect(screen.getByRole('tooltip')).toHaveTextContent(FIELD_TOOLTIP);
expect(screen.getAllByRole('tooltip')).toHaveLength(1);
});
await userEvent.unhover(screen.getByText('Last week'));
await waitFor(() => {
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
await userEvent.hover(descriptionIcon);
const tooltip = await screen.findByRole('tooltip');
expect(tooltip).toHaveTextContent(DESCRIPTION_TOOLTIP);
expect(tooltip).not.toHaveTextContent(FIELD_TOOLTIP);
expect(screen.getAllByRole('tooltip')).toHaveLength(1);
await userEvent.unhover(descriptionIcon);
await waitFor(() => {
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
fireEvent.keyDown(descriptionIcon, { key: 'Enter' });
expect(tooltipOnClick).toHaveBeenCalled();
});
@@ -16,6 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import { ReactNode } from 'react';
export type SelectOptionType = {
value: string;
label: string;
@@ -113,4 +115,8 @@ export interface DateFilterControlProps {
onOpenPopover?: () => void;
onClosePopover?: () => void;
isOverflowingFilterBar?: boolean;
hovered?: boolean;
description?: ReactNode;
label?: ReactNode;
tooltipOnClick?: () => void;
}
@@ -16,10 +16,12 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback, useState } from 'react';
import {
AppSection,
Behavior,
ChartProps,
type DataMask,
type FilterState,
} from '@superset-ui/core';
import { supersetTheme } from '@apache-superset/core/theme';
@@ -1393,6 +1395,153 @@ test('preserves dependent filter value restored from URL when it exists in data'
});
});
test('keeps a dependent filter empty after the user clears it', async () => {
// Regression: a dependent filter with "Select first filter value by default"
// used to re-apply the first option as soon as the cleared value round-tripped
// through the filter bar, making it impossible to clear.
jest.useRealTimers();
const setDataMaskMock = jest.fn();
const testProps = {
...selectMultipleProps,
formData: {
...selectMultipleProps.formData,
multiSelect: false,
enableEmptyFilter: false,
defaultToFirstItem: true,
// Non-empty extraFormData is what marks this filter as dependent
extraFormData: {
filters: [{ col: 'region', op: 'IN', val: ['North America'] }],
},
},
};
// The filter bar feeds every dispatched dataMask back into the plugin as the
// controlled `filterState` prop; the harness reproduces that round-trip.
const ControlledSelectFilter = () => {
const [filterState, setFilterState] = useState<FilterState>({
value: ['boy'],
});
const handleDataMask = useCallback((dataMask: DataMask) => {
setDataMaskMock(dataMask);
setFilterState(prev => ({ ...prev, ...dataMask.filterState }));
}, []);
return (
// @ts-expect-error
<SelectFilterPlugin
// @ts-expect-error
{...transformProps({ ...testProps, filterState })}
setDataMask={handleDataMask}
showOverflow={false}
/>
);
};
render(<ControlledSelectFilter />, {
useRedux: true,
initialState: {
nativeFilters: {
filters: {
'test-filter': {
name: 'Test Filter',
},
},
},
dataMask: {
'test-filter': {
extraFormData: {},
filterState: { value: ['boy'] },
},
},
},
});
userEvent.click(
screen.getByRole('img', {
name: /close-circle/i,
hidden: true,
}),
);
await waitFor(() =>
expect(setDataMaskMock).toHaveBeenLastCalledWith(
expect.objectContaining({
extraFormData: {},
filterState: expect.objectContaining({ value: null }),
}),
),
);
// Let the re-validation effects settle: the value must not come back
await act(async () => {
await Promise.resolve();
});
expect(setDataMaskMock).toHaveBeenLastCalledWith(
expect.objectContaining({
filterState: expect.objectContaining({ value: null }),
}),
);
expect(screen.queryByTitle('boy')).not.toBeInTheDocument();
});
test('keeps a dependent filter empty when it mounts with a cleared value', async () => {
// Regression: after a reload the cleared state comes back as `value: null` on
// a fresh component, so the in-memory "user cleared this" ref is gone. The
// first item must still not be re-applied.
const setDataMaskMock = jest.fn();
const testProps = {
...selectMultipleProps,
formData: {
...selectMultipleProps.formData,
multiSelect: false,
enableEmptyFilter: false,
defaultToFirstItem: true,
extraFormData: {
filters: [{ col: 'region', op: 'IN', val: ['North America'] }],
},
},
filterState: { value: null },
};
render(
// @ts-expect-error
<SelectFilterPlugin
// @ts-expect-error
{...transformProps(testProps)}
setDataMask={setDataMaskMock}
showOverflow={false}
/>,
{
useRedux: true,
initialState: {
nativeFilters: {
filters: {
'test-filter': {
name: 'Test Filter',
},
},
},
dataMask: {
'test-filter': {
extraFormData: {},
filterState: { value: null },
},
},
},
},
);
// Let the re-validation effect run before asserting it did nothing
await act(async () => {
await Promise.resolve();
});
expect(setDataMaskMock).toHaveBeenCalled();
expect(setDataMaskMock).not.toHaveBeenCalledWith(
expect.objectContaining({
filterState: expect.objectContaining({ value: ['boy'] }),
}),
);
});
test('resets dependent filter to first item when value does not exist in data', async () => {
const setDataMaskMock = jest.fn();
const testProps = {
@@ -157,7 +157,6 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
const [col] = groupby;
const [initialColtypeMap] = useState(coltypeMap);
const [search, setSearch] = useState('');
const prevDataRef = useRef(data);
const userClearedRef = useRef(false);
const [dataMask, dispatchDataMask] = useImmerReducer(reducer, {
extraFormData: {},
@@ -430,26 +429,6 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
clearAllTrigger,
]);
useEffect(() => {
const prev = prevDataRef.current;
const curr = data;
const hasDataChanged =
prev?.length !== curr?.length ||
prev?.some((row, i) => {
const prevVal = row[col];
const currVal = curr[i][col];
return typeof prevVal === 'bigint' || typeof currVal === 'bigint'
? prevVal?.toString() !== currVal?.toString()
: prevVal !== currVal;
});
// If data actually changed (e.g., due to parent filter), reset flag
if (hasDataChanged) {
prevDataRef.current = data;
}
}, [data, col]);
useEffect(() => {
if (
filterState.value?.every((value?: any) =>
@@ -462,13 +441,17 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
? (groupby.map(col => data[0][col]) as string[])
: null;
// Skip default value update when clearAllTrigger is active
// Skip default value update when clearAllTrigger is active.
// `null` is a persisted "user cleared this" state, as opposed to
// `undefined` for "never set", so it must not be re-defaulted either —
// `userClearedRef` alone would not survive a reload.
if (
!clearAllTrigger &&
defaultToFirstItem &&
!userClearedRef.current &&
Object.keys(formData?.extraFormData || {}).length &&
filterState.value !== undefined &&
filterState.value !== null &&
firstItem !== null &&
filterState.value !== firstItem
) {
+10 -1
View File
@@ -585,8 +585,17 @@ class ChartDataRestApi(ChartRestApi):
query["timing"] = query_result.timing.as_public_dict()
if security_manager.is_guest_user():
# Guests may see the generated SQL only when the role attached to
# their guest token has been granted "can view query on Dashboard",
# mirroring the permission the frontend uses to expose the
# "View query" action. Stacktraces and driver errors stay redacted
# regardless, as those leak details of the deployment itself.
can_view_query = security_manager.can_access(
"can_view_query", "Dashboard"
)
for query in queries:
query.pop("query", None)
if not can_view_query:
query.pop("query", None)
query.pop("stacktrace", None)
if query.get("error"):
query["error"] = sanitize_error_message(query["error"])
+11 -4
View File
@@ -91,8 +91,10 @@ class ExportChartsCommand(ExportModelsCommand):
def enable_tag_export(cls) -> None:
cls._include_tags = True
def run(self) -> Iterator[tuple[str, Callable[[], str]]]:
yield from super().run()
def run(
self, seen: set[str] | None = None
) -> Iterator[tuple[str, Callable[[], str]]]:
yield from super().run(seen=seen)
# Tags are exported once for all requested charts (rather than per
# chart in `_export`) so a multi-chart export doesn't lose tags to
@@ -108,12 +110,17 @@ class ExportChartsCommand(ExportModelsCommand):
@staticmethod
def _export(
model: Slice, export_related: bool = True
model: Slice, export_related: bool = True, seen: set[str] | None = None
) -> Iterator[tuple[str, Callable[[], str]]]:
# Initialize seen set if not provided
if seen is None:
seen = set()
yield (
ExportChartsCommand._file_name(model),
lambda: ExportChartsCommand._file_content(model),
)
if model.table and export_related:
yield from ExportDatasetsCommand([model.table.id]).run()
# Pass the shared seen set to the dataset export command
yield from ExportDatasetsCommand([model.table.id]).run(seen=seen)
+20 -6
View File
@@ -383,8 +383,12 @@ class ExportDashboardsCommand(ExportModelsCommand):
@staticmethod
# ruff: noqa: C901
def _export(
model: Dashboard, export_related: bool = True
model: Dashboard, export_related: bool = True, seen: set[str] | None = None
) -> Iterator[tuple[str, Callable[[], str]]]:
# Initialize seen set if not provided
if seen is None:
seen = set()
yield (
ExportDashboardsCommand._file_name(model),
lambda: ExportDashboardsCommand._file_content(model),
@@ -395,8 +399,11 @@ class ExportDashboardsCommand(ExportModelsCommand):
dashboard_ids = model.id
command = ExportChartsCommand(chart_ids)
command.disable_tag_export()
yield from command.run()
command.enable_tag_export()
try:
# Pass the shared seen set to the chart export command
yield from command.run(seen=seen)
finally:
command.enable_tag_export()
if feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"):
yield from ExportTagsCommand(
dashboard_ids=dashboard_ids, chart_ids=chart_ids
@@ -406,7 +413,8 @@ class ExportDashboardsCommand(ExportModelsCommand):
if model.theme:
from superset.commands.theme.export import ExportThemesCommand
yield from ExportThemesCommand([model.theme.id]).run()
# Pass the shared seen set to the theme export command
yield from ExportThemesCommand([model.theme.id]).run(seen=seen)
payload = model.export_to_dict(
recursive=False,
@@ -435,7 +443,10 @@ class ExportDashboardsCommand(ExportModelsCommand):
if dataset_id is not None:
dataset = DatasetDAO.find_by_id(dataset_id)
if dataset:
yield from ExportDatasetsCommand([dataset_id]).run()
# Pass the shared seen set to the dataset export command
yield from ExportDatasetsCommand([dataset_id]).run(
seen=seen
)
# Export datasets referenced by display controls
for customization in (
@@ -446,4 +457,7 @@ class ExportDashboardsCommand(ExportModelsCommand):
if dataset_id is not None:
dataset = DatasetDAO.find_by_id(dataset_id)
if dataset:
yield from ExportDatasetsCommand([dataset_id]).run()
# Pass the shared seen set to the dataset export command
yield from ExportDatasetsCommand([dataset_id]).run(
seen=seen
)
+5 -1
View File
@@ -113,8 +113,12 @@ class ExportDatabasesCommand(ExportModelsCommand):
@staticmethod
def _export(
model: Database, export_related: bool = True
model: Database, export_related: bool = True, seen: set[str] | None = None
) -> Iterator[tuple[str, Callable[[], str]]]:
# Initialize seen set if not provided
if seen is None:
seen = set()
yield (
ExportDatabasesCommand._file_name(model),
lambda: ExportDatabasesCommand._file_content(model),
@@ -219,6 +219,8 @@ class UploadCommand(BaseCommand):
database_id=self._model_id,
editors=editors,
schema=self._schema,
# Ensure catalog is set
catalog=self._model.get_default_catalog(),
)
db.session.add(sqla_table)
+44 -2
View File
@@ -33,7 +33,18 @@ from superset.commands.dataset.exceptions import (
)
from superset.commands.utils import populate_subjects
from superset.daos.dataset import DatasetDAO
from superset.exceptions import SupersetParseError, SupersetSecurityException
from superset.db_engine_specs.exceptions import (
SupersetDBAPIConnectionError,
SupersetDBAPIDatabaseError,
SupersetDBAPIOperationalError,
)
from superset.exceptions import (
OAuth2RedirectError,
SupersetException,
SupersetParseError,
SupersetSecurityException,
SupersetTimeoutException,
)
from superset.extensions import security_manager
from superset.sql.parse import Table
from superset.utils.decorators import on_error, transaction
@@ -50,7 +61,38 @@ class CreateDatasetCommand(CreateMixin, BaseCommand):
self.validate()
dataset = DatasetDAO.create(attributes=self._properties)
dataset.fetch_metadata()
try:
dataset.fetch_metadata()
except OAuth2RedirectError:
# Must reach the caller unchanged to start the OAuth2 dance.
raise
except (
SupersetTimeoutException,
SupersetDBAPIConnectionError,
SupersetDBAPIOperationalError,
SupersetDBAPIDatabaseError,
):
# Infra-level failures (unreachable database, query timeout), not
# bad user input: let them propagate with their own status
# instead of being coerced into a 422 "invalid table" error.
raise
except SupersetException as ex:
# Not a SQLAlchemyError, so ``on_error`` re-raises it untouched and
# it escapes to FAB's ``@safe`` as an opaque 500 "Fatal error".
# Deliberately covers the 403 ``SupersetSecurityException`` raised
# for mutation/multi-statement SQL too: ``validate()`` already
# reports that class of rejection as a 422 on ``sql`` via
# ``DatasetDataAccessIsNotAllowed``.
raise DatasetInvalidError(
exceptions=[
ValidationError(
# ``lazy_gettext`` messages aren't ``str``, so
# marshmallow won't wrap them into a list on its own.
[str(ex.message)],
field_name="sql" if self._properties.get("sql") else "table",
)
]
) from ex
return dataset
def validate(self) -> None: # noqa: C901
+37 -24
View File
@@ -89,8 +89,12 @@ class ExportDatasetsCommand(ExportModelsCommand):
@staticmethod
def _export(
model: SqlaTable, export_related: bool = True
model: SqlaTable, export_related: bool = True, seen: set[str] | None = None
) -> Iterator[tuple[str, Callable[[], str]]]:
# Initialize seen set if not provided
if seen is None:
seen = set()
yield (
ExportDatasetsCommand._file_name(model),
lambda: ExportDatasetsCommand._file_content(model),
@@ -103,32 +107,41 @@ class ExportDatasetsCommand(ExportModelsCommand):
)
file_path = f"databases/{db_file_name}.yaml"
payload = model.database.export_to_dict(
recursive=False,
include_parent_ref=False,
include_defaults=True,
export_uuids=True,
)
# TODO (betodealmeida): move this logic to export_to_dict once this
# becomes the default export endpoint
if payload.get("extra"):
try:
payload["extra"] = json.loads(payload["extra"])
except json.JSONDecodeError:
logger.info("Unable to decode `extra` field: %s", payload["extra"])
if ssh_tunnel := model.database.ssh_tunnel:
ssh_tunnel_payload = ssh_tunnel.export_to_dict(
# Only yield the database file if not already seen. This is
# critical to fix the issue where databases were being
# duplicated and potentially overwritten when charts from
# different databases were exported.
if file_path not in seen:
payload = model.database.export_to_dict(
recursive=False,
include_parent_ref=False,
include_defaults=True,
export_uuids=False,
export_uuids=True,
)
payload["ssh_tunnel"] = mask_password_info(ssh_tunnel_payload)
# TODO (betodealmeida): move this logic to export_to_dict once this
# becomes the default export endpoint
if payload.get("extra"):
try:
payload["extra"] = json.loads(payload["extra"])
except json.JSONDecodeError:
logger.info(
"Unable to decode `extra` field: %s", payload["extra"]
)
payload["version"] = EXPORT_VERSION
if ssh_tunnel := model.database.ssh_tunnel:
ssh_tunnel_payload = ssh_tunnel.export_to_dict(
recursive=False,
include_parent_ref=False,
include_defaults=True,
export_uuids=False,
)
payload["ssh_tunnel"] = mask_password_info(ssh_tunnel_payload)
yield (
file_path,
lambda: yaml.safe_dump(payload, sort_keys=False, allow_unicode=True),
)
payload["version"] = EXPORT_VERSION
yield (
file_path,
lambda: yaml.safe_dump(
payload, sort_keys=False, allow_unicode=True
),
)
+2 -2
View File
@@ -384,7 +384,7 @@ class UpdateDatasetCommand(UpdateMixin, BaseCommand):
)
exceptions.append(
ValidationError(
message,
[message],
field_name=f"{label}.{idx}.expression",
)
)
@@ -412,7 +412,7 @@ class UpdateDatasetCommand(UpdateMixin, BaseCommand):
)
exceptions.append(
ValidationError(
message,
[message],
field_name="fetch_values_predicate",
)
)
+29 -11
View File
@@ -47,27 +47,45 @@ class ExportModelsCommand(BaseCommand):
@staticmethod
def _file_content(model: Model) -> str:
raise NotImplementedError("Subclasses MUST implement _export")
raise NotImplementedError("Subclasses MUST implement _file_content")
@staticmethod
def _export(
model: Model, export_related: bool = True
model: Model, export_related: bool = True, seen: set[str] | None = None
) -> Iterator[tuple[str, Callable[[], str]]]:
raise NotImplementedError("Subclasses MUST implement _export")
def run(self) -> Iterator[tuple[str, Callable[[], str]]]:
def run(
self, seen: set[str] | None = None
) -> Iterator[tuple[str, Callable[[], str]]]:
self.validate()
metadata = {
"version": EXPORT_VERSION,
"type": self.dao.model_cls.__name__, # type: ignore
"timestamp": datetime.now(tz=timezone.utc).isoformat(),
}
yield METADATA_FILE_NAME, lambda: yaml.safe_dump(metadata, sort_keys=False)
# Use provided seen set or create new one
if seen is None:
seen = set()
should_add_metadata = True
else:
# If seen set is provided, we're being called from another command
should_add_metadata = False
# Only add metadata if this is the root command
if should_add_metadata:
metadata = {
"version": EXPORT_VERSION,
"type": self.dao.model_cls.__name__, # type: ignore
"timestamp": datetime.now(tz=timezone.utc).isoformat(),
}
if METADATA_FILE_NAME not in seen:
yield (
METADATA_FILE_NAME,
lambda: yaml.safe_dump(metadata, sort_keys=False),
)
seen.add(METADATA_FILE_NAME)
seen = {METADATA_FILE_NAME}
for model in self._models:
for file_name, file_content in self._export(model, self.export_related):
for file_name, file_content in self._export(
model, self.export_related, seen
):
if file_name not in seen:
yield file_name, file_content
seen.add(file_name)
+25 -17
View File
@@ -67,8 +67,12 @@ class ExportSavedQueriesCommand(ExportModelsCommand):
@staticmethod
def _export(
model: SavedQuery, export_related: bool = True
model: SavedQuery, export_related: bool = True, seen: set[str] | None = None
) -> Iterator[tuple[str, Callable[[], str]]]:
# Initialize seen set if not provided
if seen is None:
seen = set()
yield (
ExportSavedQueriesCommand._file_name(model),
lambda: ExportSavedQueriesCommand._file_content(model),
@@ -79,21 +83,25 @@ class ExportSavedQueriesCommand(ExportModelsCommand):
database_slug = secure_filename(model.database.database_name)
file_name = f"databases/{database_slug}.yaml"
payload = model.database.export_to_dict(
recursive=False,
include_parent_ref=False,
include_defaults=True,
export_uuids=True,
)
# TODO (betodealmeida): move this logic to export_to_dict once this
# becomes the default export endpoint
if "extra" in payload:
try:
payload["extra"] = json.loads(payload["extra"])
except json.JSONDecodeError:
logger.info("Unable to decode `extra` field: %s", payload["extra"])
# Only yield if not already seen (similar to dataset export)
if file_name not in seen:
payload = model.database.export_to_dict(
recursive=False,
include_parent_ref=False,
include_defaults=True,
export_uuids=True,
)
# TODO (betodealmeida): move this logic to export_to_dict once this
# becomes the default export endpoint
if "extra" in payload:
try:
payload["extra"] = json.loads(payload["extra"])
except (TypeError, json.JSONDecodeError):
logger.info(
"Unable to decode `extra` field: %s", payload["extra"]
)
payload["version"] = EXPORT_VERSION
payload["version"] = EXPORT_VERSION
file_content = yaml.safe_dump(payload, sort_keys=False)
yield file_name, lambda: file_content
file_content = yaml.safe_dump(payload, sort_keys=False)
yield file_name, lambda: file_content
+3 -1
View File
@@ -46,7 +46,9 @@ class ExportTagsCommand(ExportModelsCommand):
self.dashboard_ids = dashboard_ids
self.chart_ids = chart_ids
def run(self) -> Iterator[tuple[str, Callable[[], str]]]:
def run(
self, seen: set[str] | None = None
) -> Iterator[tuple[str, Callable[[], str]]]:
if not feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"):
return
+5 -1
View File
@@ -67,8 +67,12 @@ class ExportThemesCommand(ExportModelsCommand):
@staticmethod
def _export(
model: Theme, export_related: bool = True
model: Theme, export_related: bool = True, seen: set[str] | None = None
) -> Iterator[tuple[str, Callable[[], str]]]:
# Initialize seen set if not provided (for consistency)
if seen is None:
seen = set()
yield (
ExportThemesCommand._file_name(model),
lambda: ExportThemesCommand._file_content(model),
+10 -2
View File
@@ -34,6 +34,7 @@ from superset import db
from superset.constants import LRU_CACHE_MAX_SIZE
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import (
SupersetErrorException,
SupersetGenericDBErrorException,
SupersetParseError,
SupersetSecurityException,
@@ -125,12 +126,14 @@ def get_virtual_table_metadata(dataset: SqlaTable) -> list[ResultSetColumnType]:
# rest (sandbox violations, malformed template syntax, encoding
# errors) indicate a real problem with the template that must
# surface. See #38012.
# str(ex) stringifies the raw SupersetError list (enum reprs and all).
error_message = "; ".join(err.message for err in ex.errors)
if isinstance(ex.__cause__, UndefinedError):
raise SupersetVirtualTableParseException(
message=_("Template processing error: %(error)s", error=str(ex)),
message=_("Template processing error: %(error)s", error=error_message),
) from ex
raise SupersetGenericDBErrorException(
message=_("Template processing error: %(error)s", error=str(ex)),
message=_("Template processing error: %(error)s", error=error_message),
) from ex
try:
parsed_script = SQLScript(sql, engine=db_engine_spec.engine)
@@ -209,6 +212,11 @@ def get_columns_description(
result, cursor.description, db_engine_spec
)
return result_set.columns
except SupersetErrorException:
# Preserve exceptions that already carry a specific SupersetError
# (e.g. OAuth2RedirectError) so callers can act on them instead of
# seeing an opaque generic DB error.
raise
except Exception as ex:
raise SupersetGenericDBErrorException(message=str(ex)) from ex
+15 -1
View File
@@ -87,6 +87,7 @@ from superset.datasets.schemas import (
openapi_spec_methods_override,
)
from superset.exceptions import (
OAuth2RedirectError,
SupersetSyntaxErrorException,
SupersetTemplateException,
)
@@ -440,7 +441,6 @@ class DatasetRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
@expose("/", methods=("POST",))
@protect()
@safe
@statsd_metrics
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.post",
@@ -495,6 +495,12 @@ class DatasetRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
data=new_model.data,
uuid=new_model.uuid,
)
except OAuth2RedirectError:
# Must reach the client unchanged to start the OAuth2 dance;
# ``@safe`` isn't used on this endpoint since it would otherwise
# swallow this into an opaque 500 that drops the ``url``/``tab_id``
# extras the frontend needs.
raise
except DatasetSoftDeletedTwinExistsError as ex:
return self.response_422(message=str(ex))
except DatasetInvalidError as ex:
@@ -507,6 +513,14 @@ class DatasetRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
exc_info=True,
)
return self.response_422(message=str(ex))
except Exception: # pylint: disable=broad-except
# ``@safe`` isn't used on this endpoint (it would swallow the
# ``OAuth2RedirectError`` re-raised above into an opaque 500), so
# replicate its behavior here for any other unexpected exception:
# log the full error server-side, but don't echo internal details
# (ORM/driver error text, connection info) back to the caller.
logger.exception("Unexpected error in DatasetRestApi.post")
return self.response_500(message="Fatal error")
@expose("/<pk>", methods=("PUT",))
@protect()
@@ -220,6 +220,8 @@ async def create_virtual_dataset( # noqa: C901
error=f"Failed to update dataset metadata (creation rolled back): {exc}",
)
except SupersetGenericDBErrorException as exc:
# Defensive backstop for direct raises (see
# test_create_virtual_dataset_sql_error_is_actionable).
logger.warning("Virtual dataset SQL validation failed", exc_info=True)
await ctx.warning(f"Virtual dataset SQL failed validation: {exc}")
return CreateVirtualDatasetResponse(
+11 -3
View File
@@ -162,13 +162,21 @@ def memoized_func(key: str, cache: Cache = cache_manager.cache) -> Callable[...,
def wrapped_f(*args: Any, **kwargs: Any) -> Any:
should_cache = kwargs.pop("cache", True)
force = kwargs.pop("force", False)
cache_timeout = kwargs.pop(
"cache_timeout", app.config["CACHE_DEFAULT_TIMEOUT"]
)
# always popped, even when caching is skipped, so it is never forwarded
# to the decorated function as an unexpected keyword argument.
cache_timeout = kwargs.pop("cache_timeout", None)
if not should_cache:
return f(*args, **kwargs)
# callers may explicitly pass ``cache_timeout=None`` (eg, when a database
# has no custom metadata cache timeout configured), which should fall back
# to the default timeout rather than be forwarded to the cache backend.
# the config lookup happens here so the uncached path stays independent
# of the Flask app config.
if cache_timeout is None:
cache_timeout = app.config["CACHE_DEFAULT_TIMEOUT"]
# format the key using args/kwargs passed to the decorated function
signature = inspect.signature(f)
bound_args = signature.bind(*args, **kwargs)
@@ -34,6 +34,7 @@ import pytest
from flask import g, Response
from flask.ctx import AppContext
from superset import security_manager
from superset.charts.data.api import ChartDataRestApi
from superset.commands.chart.data.get_data_command import ChartDataCommand
from superset.common.chart_data import ChartDataResultFormat, ChartDataResultType
@@ -98,6 +99,25 @@ INCOMPATIBLE_ADHOC_COLUMN_FIXTURE: AdhocColumn = {
}
def _override_view_query_permission(granted: bool) -> Any:
"""
Answer ("can_view_query", "Dashboard") with ``granted`` and let every other
permission check fall through to the real security manager, so the rest of
the request keeps its normal access rules.
"""
real_can_access = security_manager.can_access
def can_access(permission_name: str, view_name: str) -> bool:
if (permission_name, view_name) == ("can_view_query", "Dashboard"):
return granted
return real_can_access(permission_name, view_name)
return mock.patch(
"superset.charts.data.api.security_manager.can_access",
side_effect=can_access,
)
def _query_timing() -> QueryTiming:
return QueryTiming(
query_planning_ns=0,
@@ -1572,19 +1592,40 @@ class TestGetChartDataApi(BaseTestChartDataApi):
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
def test_chart_data_as_guest_user(self, is_guest_user, has_guest_access):
"""
Chart data API: Test response does not inlcude the SQL query for embedded
users.
Chart data API: Test response does not include the SQL query for embedded
users whose role lacks "can view query on Dashboard".
"""
g.user.rls = []
is_guest_user.return_value = True
has_guest_access.return_value = True
rv = self.client.post(CHART_DATA_URI, json=self.query_context_payload)
with _override_view_query_permission(granted=False):
rv = self.client.post(CHART_DATA_URI, json=self.query_context_payload)
data = json.loads(rv.data.decode("utf-8"))
result = data["result"]
excluded_key = "query"
assert all([excluded_key not in query for query in result]) # noqa: C419
@mock.patch("superset.security.manager.SupersetSecurityManager.has_guest_access")
@mock.patch("superset.security.manager.SupersetSecurityManager.is_guest_user")
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
def test_chart_data_as_guest_user_allowed_to_view_query(
self, is_guest_user, has_guest_access
):
"""
Chart data API: Test response includes the SQL query for embedded users
whose role carries "can view query on Dashboard".
"""
g.user.rls = []
is_guest_user.return_value = True
has_guest_access.return_value = True
with _override_view_query_permission(granted=True):
rv = self.client.post(CHART_DATA_URI, json=self.query_context_payload)
data = json.loads(rv.data.decode("utf-8"))
result = data["result"]
assert all("query" in query for query in result)
def test_chart_data_table_chart_with_time_grain_filter(self):
"""
Chart data API: Test that a table chart that's not using a temporal column can
@@ -533,6 +533,110 @@ class TestExportDashboardsCommand(SupersetTestCase):
{"dashboard_title": "World Bank's Data"},
)
@pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
@patch("superset.security.manager.g")
@patch("superset.views.base.g")
def test_export_dashboard_cross_database_charts(self, mock_g1, mock_g2):
"""
Test that dashboards with charts from multiple databases export correctly.
This reproduces issue #37113 where charts from different databases were missing.
"""
mock_g1.user = security_manager.find_user("admin")
mock_g2.user = security_manager.find_user("admin")
# Create a second database for testing
second_db = Database(database_name="test_db_2", sqlalchemy_uri="sqlite://")
db.session.add(second_db)
# Create a dataset in the second database
second_dataset = SqlaTable(
table_name="second_dataset",
database=second_db,
database_id=second_db.id,
columns=[],
)
db.session.add(second_dataset)
# Flush so `second_dataset.id` is populated before it's read below;
# otherwise the chart would be constructed with `datasource_id=None`
# and never actually link back to this dataset.
db.session.flush()
# Create a chart using the second database's dataset
chart_from_second_db = Slice(
slice_name="Chart from Second Database",
datasource_type="table",
datasource_id=second_dataset.id,
datasource_name=second_dataset.table_name,
viz_type="bar",
params=json.dumps({"viz_type": "bar"}),
)
db.session.add(chart_from_second_db)
# Get the example dashboard and add the new chart
example_dashboard = (
db.session.query(Dashboard).filter_by(slug="world_health").one()
)
# Store original charts count
original_charts_count = len(example_dashboard.slices)
# Add the new chart from different database to the dashboard
example_dashboard.slices.append(chart_from_second_db)
db.session.commit()
try:
# Export the dashboard
command = ExportDashboardsCommand([example_dashboard.id])
contents = dict(command.run())
# Verify all databases are exported
db_files = [key for key in contents.keys() if key.startswith("databases/")]
assert len(db_files) >= 2, (
f"Expected at least 2 database files, got {db_files}"
)
# Verify the second database is included
assert "databases/test_db_2.yaml" in contents.keys(), (
f"Second database not found in export. Keys: {list(contents.keys())}"
)
# Verify all charts are exported (original + new one)
chart_files = [key for key in contents.keys() if key.startswith("charts/")]
assert len(chart_files) == original_charts_count + 1, (
f"Expected {original_charts_count + 1} charts, got {len(chart_files)}"
)
# Verify the new chart from second database is included
chart_from_second_db_file = None
for key in chart_files:
if f"Chart_from_Second_Database_{chart_from_second_db.id}" in key:
chart_from_second_db_file = key
break
assert chart_from_second_db_file is not None, (
f"Chart from second database not found in export. "
f"Chart files: {chart_files}"
)
# Verify the dataset from second database is included
dataset_files = [
key for key in contents.keys() if key.startswith("datasets/")
]
second_dataset_file = (
f"datasets/test_db_2/second_dataset_{second_dataset.id}.yaml"
)
assert second_dataset_file in contents.keys(), (
f"Second dataset not found. Dataset files: {dataset_files}"
)
finally:
# Clean up, even if an assertion above failed, so a failing run
# doesn't leave extra Database/Slice/SqlaTable rows for later tests.
example_dashboard.slices.remove(chart_from_second_db)
db.session.delete(chart_from_second_db)
db.session.delete(second_dataset)
db.session.delete(second_db)
db.session.commit()
class TestImportDashboardsCommand(SupersetTestCase):
def test_import_v0_dashboard_cli_export(self):
@@ -146,6 +146,36 @@ def test_csv_upload_dataset():
assert user_is_editor(security_manager.find_user("admin"), dataset)
@only_postgresql
@pytest.mark.usefixtures("setup_csv_upload_with_context_schema")
def test_csv_upload_dataset_catalog():
admin_user = security_manager.find_user(username="admin")
upload_database = get_upload_db()
with override_user(admin_user):
UploadCommand(
upload_database.id,
CSV_UPLOAD_TABLE_W_SCHEMA,
create_csv_file(CSV_FILE_1),
"public",
CSVReader({}),
).run()
dataset = (
db.session.query(SqlaTable)
.filter_by(
database_id=upload_database.id,
table_name=CSV_UPLOAD_TABLE_W_SCHEMA,
)
.one()
)
catalog = upload_database.get_default_catalog()
assert dataset.catalog == catalog
assert dataset.schema_perm == (
f"[{upload_database.database_name}].[{catalog}].[public]"
)
@pytest.mark.usefixtures("setup_csv_upload_with_context")
def test_csv_upload_with_index():
admin_user = security_manager.find_user(username="admin")
@@ -328,6 +328,10 @@ def test_send_chart_response_strips_guest_query_after_timing_projection(
"superset.charts.data.api.security_manager.is_guest_user",
return_value=True,
),
patch(
"superset.charts.data.api.security_manager.can_access",
return_value=False,
),
):
response = api._send_chart_response(result)
finally:
@@ -339,6 +343,35 @@ def test_send_chart_response_strips_guest_query_after_timing_projection(
assert "query" in query_payload
def test_send_chart_response_keeps_guest_query_when_permitted(
app: SupersetApp,
) -> None:
"""
A guest whose role carries "can view query on Dashboard" must receive the
generated SQL, otherwise "View query" is empty on embedded dashboards.
"""
query_payload = {"data": [{"col1": 1}], "query": "SELECT 1"}
result = _json_execution_result(query_payload)
api = ChartDataRestApi()
with (
app.test_request_context("/api/v1/chart/data"),
patch(
"superset.charts.data.api.security_manager.is_guest_user",
return_value=True,
),
patch(
"superset.charts.data.api.security_manager.can_access",
return_value=True,
) as can_access,
):
response = api._send_chart_response(result)
query = json.loads(response.get_data(as_text=True))["result"][0]
assert query["query"] == "SELECT 1"
can_access.assert_called_once_with("can_view_query", "Dashboard")
def test_send_chart_response_redacts_guest_query_error(app: SupersetApp) -> None:
result = _json_execution_result(
{
@@ -356,6 +389,10 @@ def test_send_chart_response_redacts_guest_query_error(app: SupersetApp) -> None
"superset.charts.data.api.security_manager.is_guest_user",
return_value=True,
),
patch(
"superset.charts.data.api.security_manager.can_access",
return_value=False,
),
):
response = api._send_chart_response(result)
@@ -364,6 +401,42 @@ def test_send_chart_response_redacts_guest_query_error(app: SupersetApp) -> None
assert "stacktrace" not in query
def test_send_chart_response_still_redacts_guest_errors_when_query_permitted(
app: SupersetApp,
) -> None:
"""
"can view query on Dashboard" only unlocks the generated SQL; stacktraces
and driver errors describe the deployment and stay redacted for guests.
"""
result = _json_execution_result(
{
"error": "Table mydb.myschema.mytable was not found",
"stacktrace": "Traceback ...",
"query": "SELECT 1",
},
result_type=ChartDataResultType.QUERY,
)
api = ChartDataRestApi()
with (
app.test_request_context("/api/v1/chart/data"),
patch(
"superset.charts.data.api.security_manager.is_guest_user",
return_value=True,
),
patch(
"superset.charts.data.api.security_manager.can_access",
return_value=True,
),
):
response = api._send_chart_response(result)
query = json.loads(response.get_data(as_text=True))["result"][0]
assert query["query"] == "SELECT 1"
assert query["error"] == str(GENERIC_ERROR_MESSAGE)
assert "stacktrace" not in query
def test_get_data_response_redacts_guest_query_failure(app: SupersetApp) -> None:
command = MagicMock()
command.execute.side_effect = ChartDataQueryFailedError(
@@ -18,11 +18,18 @@ from unittest.mock import Mock, patch
import pytest
from marshmallow import ValidationError
from pytest_mock import MockerFixture
from superset.commands.dataset.create import CreateDatasetCommand
from superset.commands.dataset.exceptions import DatasetInvalidError
from superset.db_engine_specs.exceptions import SupersetDBAPIConnectionError
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import SupersetParseError
from superset.exceptions import (
OAuth2RedirectError,
SupersetGenericDBErrorException,
SupersetParseError,
SupersetTimeoutException,
)
from superset.models.core import Database
@@ -250,3 +257,161 @@ def test_create_dataset_generic_exists_error_when_no_twin() -> None:
)
with pytest.raises(DatasetInvalidError):
command.validate()
def test_create_dataset_metadata_fetch_error_is_structured(
mocker: MockerFixture,
) -> None:
"""A metadata-fetch failure must surface the engine's own message.
``run()`` executes the SQL to introspect columns; the resulting
``SupersetGenericDBErrorException`` used to escape as a 500 "Fatal error".
"""
mocker.patch.object(CreateDatasetCommand, "validate")
dataset = Mock()
dataset.fetch_metadata.side_effect = SupersetGenericDBErrorException(
message="Invalid SQL: Unable to parse: SELECT ...",
)
mocker.patch(
"superset.commands.dataset.create.DatasetDAO.create",
return_value=dataset,
)
command = CreateDatasetCommand(
{
"database": 1,
"table_name": "dataset wrong",
"sql": "SELECT ...",
}
)
with pytest.raises(DatasetInvalidError) as exc_info:
command.run()
validation_errors = exc_info.value._exceptions
assert len(validation_errors) == 1
assert validation_errors[0].field_name == "sql"
assert "Invalid SQL: Unable to parse: SELECT ..." in str(
validation_errors[0].messages[0]
)
def test_create_dataset_metadata_fetch_error_physical_table(
mocker: MockerFixture,
) -> None:
"""The same conversion applies to physical datasets, keyed on ``table``."""
mocker.patch.object(CreateDatasetCommand, "validate")
dataset = Mock()
dataset.fetch_metadata.side_effect = SupersetGenericDBErrorException(
message="(psycopg2.OperationalError) could not connect to server",
)
mocker.patch(
"superset.commands.dataset.create.DatasetDAO.create",
return_value=dataset,
)
command = CreateDatasetCommand({"database": 1, "table_name": "physical_table"})
with pytest.raises(DatasetInvalidError) as exc_info:
command.run()
validation_errors = exc_info.value._exceptions
assert validation_errors[0].field_name == "table"
assert "could not connect to server" in str(validation_errors[0].messages[0])
def test_create_dataset_oauth2_redirect_propagates_unchanged(
mocker: MockerFixture,
) -> None:
"""OAuth2 redirects must not be flattened into a DatasetInvalidError."""
mocker.patch.object(CreateDatasetCommand, "validate")
dataset = Mock()
oauth2_error = OAuth2RedirectError(
url="https://example.org/oauth2/authorize",
tab_id="tab-123",
redirect_uri="https://superset.example.org/oauth2/redirect",
)
dataset.fetch_metadata.side_effect = oauth2_error
mocker.patch(
"superset.commands.dataset.create.DatasetDAO.create",
return_value=dataset,
)
command = CreateDatasetCommand(
{"database": 1, "table_name": "good_dataset", "sql": "SELECT 1 AS a"}
)
with pytest.raises(OAuth2RedirectError) as exc_info:
command.run()
assert exc_info.value is oauth2_error
assert exc_info.value.error.extra["url"] == "https://example.org/oauth2/authorize"
assert exc_info.value.error.extra["tab_id"] == "tab-123"
def test_create_dataset_timeout_propagates_unchanged(
mocker: MockerFixture,
) -> None:
"""A query timeout is an infra failure, not bad user input: it must not
be flattened into a 422 DatasetInvalidError on ``table``/``sql``."""
mocker.patch.object(CreateDatasetCommand, "validate")
dataset = Mock()
timeout_error = SupersetTimeoutException(
error_type=SupersetErrorType.CONNECTION_DATABASE_TIMEOUT,
message="Connection timed out",
level=ErrorLevel.ERROR,
)
dataset.fetch_metadata.side_effect = timeout_error
mocker.patch(
"superset.commands.dataset.create.DatasetDAO.create",
return_value=dataset,
)
command = CreateDatasetCommand({"database": 1, "table_name": "physical_table"})
with pytest.raises(SupersetTimeoutException) as exc_info:
command.run()
assert exc_info.value is timeout_error
def test_create_dataset_connection_error_propagates_unchanged(
mocker: MockerFixture,
) -> None:
"""An unreachable database must not be reported as an invalid table name."""
mocker.patch.object(CreateDatasetCommand, "validate")
dataset = Mock()
connection_error = SupersetDBAPIConnectionError(
"could not connect to server: Connection refused"
)
dataset.fetch_metadata.side_effect = connection_error
mocker.patch(
"superset.commands.dataset.create.DatasetDAO.create",
return_value=dataset,
)
command = CreateDatasetCommand({"database": 1, "table_name": "physical_table"})
with pytest.raises(SupersetDBAPIConnectionError) as exc_info:
command.run()
assert exc_info.value is connection_error
def test_create_dataset_run_succeeds_when_metadata_fetch_works(
mocker: MockerFixture,
) -> None:
"""Control: the happy path still returns the created dataset."""
mocker.patch.object(CreateDatasetCommand, "validate")
dataset = Mock()
mocker.patch(
"superset.commands.dataset.create.DatasetDAO.create",
return_value=dataset,
)
command = CreateDatasetCommand(
{"database": 1, "table_name": "good_dataset", "sql": "SELECT 1 AS a"}
)
assert command.run() is dataset
dataset.fetch_metadata.assert_called_once()
@@ -473,6 +473,10 @@ def test_update_dataset_rejects_malicious_expression(
f"Expected a field-level ValidationError on '{field}'. Got: "
f"{[(type(e).__name__, getattr(e, 'field_name', None), str(e)) for e in excinfo.value._exceptions]}" # noqa: E501
)
# `messages` must stay a list even though the underlying message is a
# `LazyString`, which marshmallow does not treat as `str`/`bytes` and so
# would otherwise store bare instead of wrapping it.
assert isinstance(expression_errors[0].messages, list)
def test_update_dataset_accepts_benign_expression(mocker: MockerFixture) -> None:
@@ -191,6 +191,39 @@ def test_get_virtual_table_metadata_template_security_error_is_not_softened():
assert "Template processing error" in str(exc_info.value.message)
def test_get_virtual_table_metadata_template_error_message_is_clean():
"""The message must be the SupersetError's own text, not str(ex)."""
mock_dataset = Mock(spec=SqlaTable)
mock_database = Mock(spec=Database)
mock_dataset.database = mock_database
mock_dataset.sql = "SELECT 1 {% if %}"
ex = SupersetSyntaxErrorException(
[
SupersetError(
message="Malformed template, expected 'endif'",
error_type=SupersetErrorType.GENERIC_COMMAND_ERROR,
level=ErrorLevel.ERROR,
)
]
)
ex.__cause__ = SecurityError("unrelated cause, not UndefinedError")
mock_template_processor = Mock()
mock_template_processor.process_template.side_effect = ex
mock_dataset.get_template_processor.return_value = mock_template_processor
mock_dataset.template_params_dict = {}
with pytest.raises(SupersetGenericDBErrorException) as exc_info:
get_virtual_table_metadata(mock_dataset)
message = str(exc_info.value.message)
assert message == (
"Template processing error: Malformed template, expected 'endif'"
)
assert "SupersetError(" not in message
assert "error_type=<" not in message
def test_get_virtual_table_metadata_multiple_statements_not_allowed():
"""Test that multiple SQL statements raise security error."""
mock_dataset = Mock(spec=SqlaTable)
+23 -1
View File
@@ -26,7 +26,7 @@ from superset.connectors.sqla.utils import (
get_columns_description,
get_virtual_table_metadata,
)
from superset.exceptions import SupersetSecurityException
from superset.exceptions import OAuth2RedirectError, SupersetSecurityException
from superset.models.core import Database
@@ -102,6 +102,28 @@ def test_returns_column_descriptions(mocker: MockerFixture) -> None:
]
def test_get_columns_description_propagates_oauth2_redirect(
mocker: MockerFixture,
) -> None:
"""
``get_columns_description`` wraps every exception raised while executing
the metadata query into ``SupersetGenericDBErrorException`` -- but an
``OAuth2RedirectError`` raised by the driver (e.g. a database requiring
per-user OAuth2 tokens) must reach the caller unchanged so the frontend
can start the OAuth2 dance, instead of being flattened into an opaque
generic DB error.
"""
database = mocker.MagicMock()
cursor = mocker.MagicMock()
oauth2_error = OAuth2RedirectError("https://example.org/oauth2", "tab-id", "uri")
database.get_raw_connection.return_value.__enter__.return_value.cursor.return_value = cursor # noqa: E501
database.db_engine_spec.execute.side_effect = oauth2_error
with pytest.raises(OAuth2RedirectError):
get_columns_description(database, "catalog", "schema", "SELECT * FROM table")
def _create_zero_row_database(tmp_path: Path) -> tuple[Database, str]:
"""
Create a real SQLite-backed ``Database`` and a query that matches zero rows.
+133
View File
@@ -214,3 +214,136 @@ def test_handle_filters_args_returns_request_scoped_filters(
fresh_filters = api.datamodel.get_filters.return_value
assert fresh_filters.rest_add_filters.call_count == 2
assert fresh_filters.get_joined_filters.call_count == 2
def test_post_dataset_with_invalid_sql_returns_actionable_422(
session: Session,
client: Any,
full_api_access: None,
) -> None:
"""Saving a dataset over unrunnable SQL must explain what is wrong.
With blanket database access ``validate()`` never parses the SQL, so
``run()``'s column introspection is the first thing to reject it. That
used to surface as a bare 500 ``{"message": "Fatal error"}``.
"""
from superset.connectors.sqla.models import SqlaTable
from superset.models.core import Database
SqlaTable.metadata.create_all(db.session.get_bind())
database = Database(database_name="invalid_sql_db", sqlalchemy_uri="sqlite://")
db.session.add(database)
db.session.flush()
response = client.post(
"/api/v1/dataset/",
json={
"database": database.id,
"schema": "main",
"table_name": "dataset wrong",
"sql": "SELECT ...",
},
)
assert response.status_code == 422
message = response.json["message"]
assert "Fatal error" not in str(message)
# Not the parser's exact wording -- that would break on a sqlglot bump.
assert message["sql"][0].startswith("Invalid SQL")
# The failed create must not leave a half-built dataset behind.
assert (
db.session.query(SqlaTable).filter_by(table_name="dataset wrong").one_or_none()
is None
)
def test_post_dataset_oauth2_redirect_propagates_unchanged(
session: Session,
client: Any,
full_api_access: None,
) -> None:
"""OAuth2RedirectError must reach the client with its ``url``/``tab_id``
extras intact so the frontend can start the OAuth2 dance.
``DatasetRestApi.post`` doesn't use flask-appbuilder's ``@safe``
decorator for this reason: ``@safe`` catches any uncaught exception and
flattens it into an opaque 500, which would strip those extras.
"""
from superset.connectors.sqla.models import SqlaTable
from superset.exceptions import OAuth2RedirectError
from superset.models.core import Database
SqlaTable.metadata.create_all(db.session.get_bind())
database = Database(database_name="oauth2_db", sqlalchemy_uri="sqlite://")
db.session.add(database)
db.session.flush()
with patch(
"superset.datasets.api.CreateDatasetCommand.run",
side_effect=OAuth2RedirectError(
"http://example.org/auth", "tab-1", "/redirect"
),
):
response = client.post(
"/api/v1/dataset/",
json={
"database": database.id,
"schema": "main",
"table_name": "oauth2_table",
},
)
assert response.status_code == 403
error = response.json["errors"][0]
assert error["error_type"] == "OAUTH2_REDIRECT"
assert error["extra"] == {
"url": "http://example.org/auth",
"tab_id": "tab-1",
"redirect_uri": "/redirect",
}
def test_post_dataset_unexpected_error_returns_sanitized_500(
session: Session,
client: Any,
full_api_access: None,
) -> None:
"""An unexpected, non-``SupersetException`` failure must be flattened
into an opaque 500 -- the same contract ``@safe`` used to provide --
instead of leaking raw exception text (e.g. driver/connection details)
through Flask's catch-all error handler.
``DatasetRestApi.post`` doesn't use ``@safe`` so that ``OAuth2RedirectError``
can reach the client unchanged (see
``test_post_dataset_oauth2_redirect_propagates_unchanged``); it must
replicate ``@safe``'s opaque-500 behavior itself for everything else.
"""
from superset.connectors.sqla.models import SqlaTable
from superset.models.core import Database
SqlaTable.metadata.create_all(db.session.get_bind())
database = Database(database_name="unexpected_db", sqlalchemy_uri="sqlite://")
db.session.add(database)
db.session.flush()
secret = "postgresql://admin:s3cr3t@internal-db.example.com/prod" # noqa: S105
with patch(
"superset.datasets.api.CreateDatasetCommand.run",
side_effect=RuntimeError(secret),
):
response = client.post(
"/api/v1/dataset/",
json={
"database": database.id,
"schema": "main",
"table_name": "unexpected_table",
},
)
assert response.status_code == 500
assert response.json == {"message": "Fatal error"}
assert secret not in response.get_data(as_text=True)
+93
View File
@@ -54,6 +54,99 @@ def test_memoized_func(mocker: MockerFixture) -> None:
assert result == 43
def test_memoized_func_none_cache_timeout(mocker: MockerFixture) -> None:
"""
An explicit ``cache_timeout=None`` falls back to ``CACHE_DEFAULT_TIMEOUT``.
Databases without a custom metadata cache timeout pass ``None`` explicitly, and
forwarding it to the cache backend breaks backends that require an integer.
"""
from superset.utils.cache import memoized_func
_patch_config(mocker)
cache = mocker.MagicMock()
cache.get.return_value = None
decorator = memoized_func("db:{self.id}:schema:{schema}:table_list", cache)
decorated = decorator(lambda self, schema: 42)
self = mocker.MagicMock()
self.id = 1
result = decorated(self, "public", cache_timeout=None)
assert result == 42
cache.set.assert_called_once_with("db:1:schema:public:table_list", 42, timeout=100)
def test_memoized_func_custom_cache_timeout(mocker: MockerFixture) -> None:
"""
An explicit ``cache_timeout`` takes precedence over ``CACHE_DEFAULT_TIMEOUT``.
"""
from superset.utils.cache import memoized_func
_patch_config(mocker)
cache = mocker.MagicMock()
cache.get.return_value = None
decorator = memoized_func("db:{self.id}:schema:{schema}:table_list", cache)
decorated = decorator(lambda self, schema: 42)
self = mocker.MagicMock()
self.id = 1
result = decorated(self, "public", cache_timeout=42)
assert result == 42
cache.set.assert_called_once_with("db:1:schema:public:table_list", 42, timeout=42)
def test_memoized_func_disabled_cache_timeout(mocker: MockerFixture) -> None:
"""
A timeout of -1 (``CACHE_DISABLED_TIMEOUT``) skips the cache set.
"""
from superset.utils.cache import memoized_func
_patch_config(mocker)
cache = mocker.MagicMock()
cache.get.return_value = None
decorator = memoized_func("db:{self.id}:schema:{schema}:table_list", cache)
decorated = decorator(lambda self, schema: 42)
self = mocker.MagicMock()
self.id = 1
result = decorated(self, "public", cache_timeout=-1)
assert result == 42
cache.set.assert_not_called()
def test_memoized_func_skip_cache_pops_cache_timeout(mocker: MockerFixture) -> None:
"""
``cache=False`` skips caching without touching the config or the wrapped function.
``cache_timeout`` must still be popped so it is not forwarded to the decorated
function, which does not accept it. Callers such as
``get_all_table_names_in_schema`` pass ``cache`` and ``cache_timeout`` together.
"""
from superset.utils.cache import memoized_func
mock_config = mocker.patch("superset.utils.cache.app.config", MagicMock())
cache = mocker.MagicMock()
decorator = memoized_func("db:{self.id}:schema:{schema}:table_list", cache)
decorated = decorator(lambda self, schema: 42)
self = mocker.MagicMock()
self.id = 1
result = decorated(self, "public", cache=False, cache_timeout=None)
assert result == 42
cache.get.assert_not_called()
cache.set.assert_not_called()
mock_config.__getitem__.assert_not_called()
def _make_cache_instance(mocker: MockerFixture) -> MagicMock:
"""A cache instance whose ``.cache`` is not a ``NullCache``."""
cache_instance = mocker.MagicMock()