Compare commits

..

2 Commits

Author SHA1 Message Date
Joe Li
7c88f2d61d fix(mysql): normalize string/bytes/Decimal values before boolean conversion
- Fix critical bug where bool('0') and bool(b'0') returned True instead of False
- Add proper type normalization for strings, bytes, and Decimal values
- Convert string/bytes/Decimal to int before applying bool() for accurate MySQL boolean conversion
- Maintains existing behavior for integer values while fixing edge cases
- Addresses feedback on conversion logic in mysql.py:323-330

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-25 11:42:26 -07:00
Joe Li
66f6a6ce94 fix(mysql): render TINYINT(1) columns as boolean in SQL Lab and charts
Fixes MySQL TINYINT(1)/BOOLEAN columns displaying as numeric icons with 0/1 values instead of boolean representation with True/False in SQL Lab and Explore views.

## Changes

### Schema Mapping (mysql.py)
- Add precise column_type_mappings for TINYINT(1), BOOLEAN, and BOOL patterns
- Map to GenericDataType.BOOLEAN for proper metadata inspection
- Ensures dataset schemas show boolean icons for actual boolean types only

### Runtime Conversion (mysql.py)
- Implement fetch_data override with ultra-precise boolean detection
- Convert 0/1 integers to True/False for TINYINT(1) columns
- Use multiple reliable markers: FIELD_TYPE.TINY + display_size=1 OR SQLAlchemy type string
- Extract _is_boolean_column helper method for clean detection logic
- Enables pandas boolean dtype inference via extract_dataframe_dtypes

### Testing (test_mysql.py)
- Add boolean type test cases to existing parametrized tests
- Test TINYINT(1), BOOLEAN, BOOL → boolean mapping
- Test TINYINT, TINYINT(2+) → numeric mapping (preserved behavior)

## Technical Details

MySQL stores BOOLEAN as TINYINT(1) but returns 0/1 integers instead of Python booleans. This two-layer solution:
1. Maps TINYINT(1) metadata to GenericDataType.BOOLEAN for schema inspection
2. Converts query result values 0/1 → True/False for proper pandas inference

The detection uses explicit width 1 or positive SQLAlchemy type string markers to avoid mis-converting broader TINYINT columns.

Fixes: https://github.com/apache/superset/issues/35166

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-24 15:20:06 -07:00
53 changed files with 739 additions and 960 deletions

View File

@@ -70,10 +70,6 @@ superset/
- **New files require ASF license headers** - When creating new code files, include the standard Apache Software Foundation license header
- **LLM instruction files are excluded** - Files like LLMS.md, CLAUDE.md, etc. are in `.rat-excludes` to avoid header token overhead
### Code Comments
- **Avoid time-specific language** - Don't use words like "now", "currently", "today" in code comments as they become outdated
- **Write timeless comments** - Comments should remain accurate regardless of when they're read
## Documentation Requirements
- **docs/**: Update for any user-facing changes

View File

@@ -413,6 +413,13 @@ module.exports = {
'icons/no-fa-icons-usage': 'error',
'i18n-strings/no-template-vars': ['error', true],
'i18n-strings/sentence-case-buttons': 'error',
camelcase: [
'error',
{
allow: ['^UNSAFE_'],
properties: 'never',
},
],
'class-methods-use-this': 0,
curly: 2,
'func-names': 0,

View File

@@ -3,4 +3,3 @@ cypress/screenshots
cypress/videos
src/temp
.temp_cache/
.tsbuildinfo

View File

@@ -8886,9 +8886,9 @@
"license": "ISC"
},
"node_modules/@ndelangen/get-tarball/node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
"integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -60740,7 +60740,7 @@
},
"packages/superset-core": {
"name": "@apache-superset/core",
"version": "0.0.1-rc5",
"version": "0.0.1-rc4",
"license": "ISC",
"devDependencies": {
"@babel/cli": "^7.26.4",

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { getColorBreakpointsBuckets, getBreakPoints } from './utils';
import { getColorBreakpointsBuckets } from './utils';
import { ColorBreakpointType } from './types';
describe('getColorBreakpointsBuckets', () => {
@@ -44,447 +44,3 @@ describe('getColorBreakpointsBuckets', () => {
expect(result).toEqual({});
});
});
describe('getBreakPoints', () => {
const accessor = (d: any) => d.value;
describe('automatic breakpoint generation', () => {
it('generates correct number of breakpoints for given buckets', () => {
const features = [{ value: 0 }, { value: 50 }, { value: 100 }];
const breakPoints = getBreakPoints(
{ break_points: [], num_buckets: '5' },
features,
accessor,
);
expect(breakPoints).toHaveLength(6); // n buckets = n+1 breakpoints
expect(breakPoints.every(bp => typeof bp === 'string')).toBe(true);
});
it('ensures data range is fully covered', () => {
// Test various data ranges to ensure min/max are always included
const testCases = [
{ data: [0, 100], buckets: 5 },
{ data: [0.1, 99.9], buckets: 4 },
{ data: [-50, 50], buckets: 10 },
{ data: [3.2, 38.7], buckets: 5 }, // Original max bug case
{ data: [3.14, 100], buckets: 5 }, // Min rounding bug case (3.14 -> 3)
{ data: [2.345, 10], buckets: 4 }, // Min rounding bug case (2.345 -> 2.35)
{ data: [0.0001, 0.0009], buckets: 3 }, // Very small numbers
{ data: [1000000, 9000000], buckets: 8 }, // Large numbers
];
testCases.forEach(({ data, buckets }) => {
const [min, max] = data;
const features = [{ value: min }, { value: max }];
const breakPoints = getBreakPoints(
{ break_points: [], num_buckets: String(buckets) },
features,
accessor,
);
const firstBp = parseFloat(breakPoints[0]);
const lastBp = parseFloat(breakPoints[breakPoints.length - 1]);
// Critical: min and max must be within the breakpoint range
expect(firstBp).toBeLessThanOrEqual(min);
expect(lastBp).toBeGreaterThanOrEqual(max);
expect(breakPoints).toHaveLength(buckets + 1);
});
});
it('handles uniform distribution correctly', () => {
const features = [
{ value: 0 },
{ value: 25 },
{ value: 50 },
{ value: 75 },
{ value: 100 },
];
const breakPoints = getBreakPoints(
{ break_points: [], num_buckets: '4' },
features,
accessor,
);
// Check that breakpoints are evenly spaced
const numericBreakPoints = breakPoints.map(parseFloat);
const deltas = [];
for (let i = 1; i < numericBreakPoints.length; i += 1) {
deltas.push(numericBreakPoints[i] - numericBreakPoints[i - 1]);
}
// All deltas should be approximately equal
const avgDelta = deltas.reduce((a, b) => a + b, 0) / deltas.length;
deltas.forEach(delta => {
expect(delta).toBeCloseTo(avgDelta, 1);
});
});
it('handles single value datasets', () => {
const features = [{ value: 42 }, { value: 42 }, { value: 42 }];
const breakPoints = getBreakPoints(
{ break_points: [], num_buckets: '5' },
features,
accessor,
);
const firstBp = parseFloat(breakPoints[0]);
const lastBp = parseFloat(breakPoints[breakPoints.length - 1]);
expect(firstBp).toBeLessThanOrEqual(42);
expect(lastBp).toBeGreaterThanOrEqual(42);
});
it('preserves appropriate precision for different scales', () => {
const testCases = [
{ data: [0, 1], expectedMaxPrecision: 1 }, // 0.0, 0.2, 0.4...
{ data: [0, 0.1], expectedMaxPrecision: 2 }, // 0.00, 0.02...
{ data: [0, 0.01], expectedMaxPrecision: 3 }, // 0.000, 0.002...
{ data: [0, 1000], expectedMaxPrecision: 0 }, // 0, 200, 400...
];
testCases.forEach(({ data, expectedMaxPrecision }) => {
const [min, max] = data;
const features = [{ value: min }, { value: max }];
const breakPoints = getBreakPoints(
{ break_points: [], num_buckets: '5' },
features,
accessor,
);
breakPoints.forEach(bp => {
const decimalPlaces = (bp.split('.')[1] || '').length;
expect(decimalPlaces).toBeLessThanOrEqual(expectedMaxPrecision);
});
});
});
it('handles negative values correctly', () => {
const features = [
{ value: -100 },
{ value: -50 },
{ value: 0 },
{ value: 50 },
{ value: 100 },
];
const breakPoints = getBreakPoints(
{ break_points: [], num_buckets: '5' },
features,
accessor,
);
const numericBreakPoints = breakPoints.map(parseFloat);
expect(numericBreakPoints[0]).toBeLessThanOrEqual(-100);
expect(
numericBreakPoints[numericBreakPoints.length - 1],
).toBeGreaterThanOrEqual(100);
// Verify ascending order
for (let i = 1; i < numericBreakPoints.length; i += 1) {
expect(numericBreakPoints[i]).toBeGreaterThan(
numericBreakPoints[i - 1],
);
}
});
it('handles mixed integer and decimal values', () => {
const features = [
{ value: 1 },
{ value: 2.5 },
{ value: 3.7 },
{ value: 5 },
{ value: 8.2 },
];
const breakPoints = getBreakPoints(
{ break_points: [], num_buckets: '4' },
features,
accessor,
);
const firstBp = parseFloat(breakPoints[0]);
const lastBp = parseFloat(breakPoints[breakPoints.length - 1]);
expect(firstBp).toBeLessThanOrEqual(1);
expect(lastBp).toBeGreaterThanOrEqual(8.2);
});
it('uses floor/ceil for boundary breakpoints to ensure inclusion', () => {
// Test that Math.floor and Math.ceil are used for boundaries
// This ensures all data points fall within the breakpoint range
const testCases = [
{ minValue: 3.14, maxValue: 100, buckets: 5 },
{ minValue: 2.345, maxValue: 10.678, buckets: 4 },
{ minValue: 1.67, maxValue: 5.33, buckets: 3 },
{ minValue: 0.123, maxValue: 0.987, buckets: 5 },
];
testCases.forEach(({ minValue, maxValue, buckets }) => {
const features = [{ value: minValue }, { value: maxValue }];
const breakPoints = getBreakPoints(
{ break_points: [], num_buckets: String(buckets) },
features,
accessor,
);
const firstBp = parseFloat(breakPoints[0]);
const lastBp = parseFloat(breakPoints[breakPoints.length - 1]);
// First breakpoint should be floored (always <= minValue)
expect(firstBp).toBeLessThanOrEqual(minValue);
// Last breakpoint should be ceiled (always >= maxValue)
expect(lastBp).toBeGreaterThanOrEqual(maxValue);
// All values should be within range
expect(minValue).toBeGreaterThanOrEqual(firstBp);
expect(maxValue).toBeLessThanOrEqual(lastBp);
});
});
it('prevents minimum value exclusion edge case', () => {
// Specific edge case test for minimum value exclusion
// Tests the exact scenario where rounding would exclude the min value
const features = [
{ value: 3.14 }, // This would round to 3 at precision 0
{ value: 50 },
{ value: 100 },
];
const breakPoints = getBreakPoints(
{ break_points: [], num_buckets: '5' },
features,
accessor,
);
const firstBp = parseFloat(breakPoints[0]);
// The first breakpoint must be <= 3.14 (floor behavior)
expect(firstBp).toBeLessThanOrEqual(3.14);
// Verify that 3.14 is not excluded
expect(3.14).toBeGreaterThanOrEqual(firstBp);
// The first breakpoint should be a clean floor value
expect(breakPoints[0]).toMatch(/^3(\.0*)?$/);
});
it('prevents maximum value exclusion edge case', () => {
// Specific edge case test for maximum value exclusion
// Tests the exact scenario where rounding would exclude the max value
const features = [
{ value: 0 },
{ value: 20 },
{ value: 38.7 }, // Original bug case
];
const breakPoints = getBreakPoints(
{ break_points: [], num_buckets: '5' },
features,
accessor,
);
const lastBp = parseFloat(breakPoints[breakPoints.length - 1]);
// The last breakpoint must be >= 38.7 (ceil behavior)
expect(lastBp).toBeGreaterThanOrEqual(38.7);
// Verify that 38.7 is not excluded
expect(38.7).toBeLessThanOrEqual(lastBp);
// The last breakpoint should be a clean ceil value
expect(breakPoints[breakPoints.length - 1]).toMatch(/^39(\.0*)?$/);
});
});
describe('custom breakpoints', () => {
it('uses custom breakpoints when provided', () => {
const features = [{ value: 5 }, { value: 15 }, { value: 25 }];
const customBreakPoints = ['0', '10', '20', '30', '40'];
const breakPoints = getBreakPoints(
{ break_points: customBreakPoints, num_buckets: '' },
features,
accessor,
);
expect(breakPoints).toEqual(['0', '10', '20', '30', '40']);
});
it('sorts custom breakpoints in ascending order', () => {
const features = [{ value: 5 }];
const customBreakPoints = ['30', '10', '0', '20'];
const breakPoints = getBreakPoints(
{ break_points: customBreakPoints, num_buckets: '' },
features,
accessor,
);
expect(breakPoints).toEqual(['0', '10', '20', '30']);
});
it('ignores num_buckets when custom breakpoints are provided', () => {
const features = [{ value: 5 }];
const customBreakPoints = ['0', '50', '100'];
const breakPoints = getBreakPoints(
{ break_points: customBreakPoints, num_buckets: '10' }, // num_buckets should be ignored
features,
accessor,
);
expect(breakPoints).toEqual(['0', '50', '100']);
expect(breakPoints).toHaveLength(3); // not 11
});
});
describe('edge cases and error handling', () => {
it('returns empty array when features are undefined', () => {
const breakPoints = getBreakPoints(
{ break_points: [], num_buckets: '5' },
undefined as any,
accessor,
);
expect(breakPoints).toEqual([]);
});
it('returns empty array when features is null', () => {
const breakPoints = getBreakPoints(
{ break_points: [], num_buckets: '5' },
null as any,
accessor,
);
expect(breakPoints).toEqual([]);
});
it('returns empty array when all values are undefined', () => {
const features = [
{ value: undefined },
{ value: undefined },
{ value: undefined },
];
const breakPoints = getBreakPoints(
{ break_points: [], num_buckets: '5' },
features,
accessor,
);
expect(breakPoints).toEqual([]);
});
it('handles empty features array', () => {
const breakPoints = getBreakPoints(
{ break_points: [], num_buckets: '5' },
[],
accessor,
);
expect(breakPoints).toEqual([]);
});
it('handles string values that can be parsed as numbers', () => {
const features = [
{ value: '10.5' },
{ value: '20.3' },
{ value: '30.7' },
];
const breakPoints = getBreakPoints(
{ break_points: [], num_buckets: '3' },
features,
(d: any) =>
typeof d.value === 'string' ? parseFloat(d.value) : d.value,
);
const firstBp = parseFloat(breakPoints[0]);
const lastBp = parseFloat(breakPoints[breakPoints.length - 1]);
expect(firstBp).toBeLessThanOrEqual(10.5);
expect(lastBp).toBeGreaterThanOrEqual(30.7);
});
it('uses default number of buckets when not specified', () => {
const features = [{ value: 0 }, { value: 100 }];
const breakPoints = getBreakPoints(
{ break_points: [], num_buckets: '' },
features,
accessor,
);
// Should use DEFAULT_NUM_BUCKETS (10)
expect(breakPoints).toHaveLength(11); // 10 buckets = 11 breakpoints
});
it('handles Infinity and -Infinity values', () => {
const features = [
{ value: -Infinity },
{ value: 0 },
{ value: Infinity },
];
const breakPoints = getBreakPoints(
{ break_points: [], num_buckets: '5' },
features,
accessor,
);
// Should return empty array when Infinity values are present
expect(breakPoints).toEqual([]);
});
});
describe('breakpoint boundaries validation', () => {
it('ensures no data points fall outside breakpoint range', () => {
// Generate random test data
const generateRandomData = (count: number, min: number, max: number) => {
const data = [];
for (let i = 0; i < count; i += 1) {
data.push({ value: Math.random() * (max - min) + min });
}
return data;
};
// Test with various random datasets
for (let i = 0; i < 10; i += 1) {
const features = generateRandomData(20, -1000, 1000);
const minValue = Math.min(...features.map(f => f.value));
const maxValue = Math.max(...features.map(f => f.value));
const breakPoints = getBreakPoints(
{ break_points: [], num_buckets: '5' },
features,
accessor,
);
const firstBp = parseFloat(breakPoints[0]);
const lastBp = parseFloat(breakPoints[breakPoints.length - 1]);
// Every data point should fall within the breakpoint range
features.forEach(feature => {
expect(feature.value).toBeGreaterThanOrEqual(firstBp);
expect(feature.value).toBeLessThanOrEqual(lastBp);
});
// The range should be as tight as possible while including all data
expect(firstBp).toBeLessThanOrEqual(minValue);
expect(lastBp).toBeGreaterThanOrEqual(maxValue);
}
});
});
});

View File

@@ -75,35 +75,19 @@ export function getBreakPoints(
if (minValue === undefined || maxValue === undefined) {
return [];
}
// Handle Infinity values
if (!Number.isFinite(minValue) || !Number.isFinite(maxValue)) {
return [];
}
const delta = (maxValue - minValue) / numBuckets;
const precision =
delta === 0 ? 0 : Math.max(0, Math.ceil(Math.log10(1 / delta)));
const extraBucket =
maxValue > parseFloat(maxValue.toFixed(precision)) ? 1 : 0;
const startValue =
minValue < parseFloat(minValue.toFixed(precision))
? minValue - 1
: minValue;
// Generate breakpoints
const breakPoints = new Array(numBuckets + 1).fill(0).map((_, i) => {
const value = minValue + i * delta;
// For the first breakpoint, floor to ensure minimum is included
if (i === 0) {
const scale = Math.pow(10, precision);
return (Math.floor(minValue * scale) / scale).toFixed(precision);
}
// For the last breakpoint, ceil to ensure maximum is included
if (i === numBuckets) {
const scale = Math.pow(10, precision);
return (Math.ceil(maxValue * scale) / scale).toFixed(precision);
}
// For middle breakpoints, use standard rounding
return value.toFixed(precision);
});
return breakPoints;
return new Array(numBuckets + 1 + extraBucket)
.fill(0)
.map((_, i) => (startValue + i * delta).toFixed(precision));
}
return formDataBreakPoints.sort(
@@ -162,10 +146,7 @@ export function getBreakPointColorScaler(
scaler = scaleThreshold<number, string>()
.domain(points)
.range(bucketedColors);
// Only mask values that are strictly outside the min/max bounds
// Include values equal to the max breakpoint
maskPoint = value =>
!!value && (value > points[points.length - 1] || value < points[0]);
maskPoint = value => !!value && (value > points[n] || value < points[0]);
} else {
// interpolate colors linearly
const linearScaleDomain = extent(features, accessor);

View File

@@ -322,6 +322,12 @@ export default function transformProps(
primarySeries.add(seriesOption.id as string);
}
};
rawSeriesA.forEach(seriesOption =>
mapSeriesIdToAxis(seriesOption, yAxisIndex),
);
rawSeriesB.forEach(seriesOption =>
mapSeriesIdToAxis(seriesOption, yAxisIndexB),
);
const showValueIndexesA = extractShowValueIndexes(rawSeriesA, {
stack,
onlyTotal,
@@ -454,11 +460,7 @@ export default function transformProps(
theme,
},
);
if (transformedSeries) {
series.push(transformedSeries);
mapSeriesIdToAxis(transformedSeries, yAxisIndex);
}
if (transformedSeries) series.push(transformedSeries);
});
rawSeriesB.forEach(entry => {
@@ -526,11 +528,7 @@ export default function transformProps(
theme,
},
);
if (transformedSeries) {
series.push(transformedSeries);
mapSeriesIdToAxis(transformedSeries, yAxisIndexB);
}
if (transformedSeries) series.push(transformedSeries);
});
// default to 0-100% range when doing row-level contribution chart

View File

@@ -49,6 +49,7 @@ export default {
dash_edit_perm: true,
dash_save_perm: true,
common: {
flash_messages: [],
conf: { SUPERSET_WEBSERVER_TIMEOUT: 60 },
},
filterBarOrientation: FilterBarOrientation.Vertical,

View File

@@ -338,6 +338,7 @@ export function runQuery(query, runPreviewOnly) {
const postPayload = {
client_id: query.id,
database_id: query.dbId,
json: true,
runAsync: query.runAsync,
catalog: query.catalog,
schema: query.schema,
@@ -955,7 +956,7 @@ export function addTable(queryEditor, tableName, catalogName, schemaName) {
const { dbId } = getUpToDateQuery(getState(), queryEditor, queryEditor.id);
const table = {
dbId,
queryEditorId: queryEditor.tabViewId ?? queryEditor.id,
queryEditorId: queryEditor.id,
catalog: catalogName,
schema: schemaName,
name: tableName,

View File

@@ -1002,85 +1002,6 @@ describe('async actions', () => {
}),
);
});
it('uses tabViewId when available', () => {
const tableName = 'table';
const catalogName = null;
const schemaName = 'schema';
const expectedDbId = 473892;
const tabViewId = '123';
const queryWithTabViewId = { ...query, tabViewId };
const store = mockStore({
...initialState,
sqlLab: {
...initialState.sqlLab,
unsavedQueryEditor: {
id: query.id,
dbId: expectedDbId,
},
},
});
const request = actions.addTable(
queryWithTabViewId,
tableName,
catalogName,
schemaName,
);
request(store.dispatch, store.getState);
expect(store.getActions()[0]).toEqual(
expect.objectContaining({
table: expect.objectContaining({
name: tableName,
catalog: catalogName,
schema: schemaName,
dbId: expectedDbId,
queryEditorId: tabViewId, // Should use tabViewId, not id
}),
}),
);
});
it('falls back to id when tabViewId is not available', () => {
const tableName = 'table';
const catalogName = null;
const schemaName = 'schema';
const expectedDbId = 473892;
const queryWithoutTabViewId = { ...query, tabViewId: undefined };
const store = mockStore({
...initialState,
sqlLab: {
...initialState.sqlLab,
unsavedQueryEditor: {
id: query.id,
dbId: expectedDbId,
},
},
});
const request = actions.addTable(
queryWithoutTabViewId,
tableName,
catalogName,
schemaName,
);
request(store.dispatch, store.getState);
expect(store.getActions()[0]).toEqual(
expect.objectContaining({
table: expect.objectContaining({
name: tableName,
catalog: catalogName,
schema: schemaName,
dbId: expectedDbId,
queryEditorId: query.id, // Should use id when tabViewId is not available
}),
}),
);
});
});
describe('syncTable', () => {

View File

@@ -65,7 +65,6 @@ const AceEditorWrapper = ({
'catalog',
'schema',
'templateParams',
'tabViewId',
]);
// Prevent a maximum update depth exceeded error
// by skipping access the unsaved query editor state
@@ -173,7 +172,6 @@ const AceEditorWrapper = ({
dbId: queryEditor.dbId,
catalog: queryEditor.catalog,
schema: queryEditor.schema,
tabViewId: queryEditor.tabViewId,
},
!autocomplete,
);

View File

@@ -44,7 +44,6 @@ type Params = {
dbId?: string | number;
catalog?: string | null;
schema?: string;
tabViewId?: string;
};
const EMPTY_LIST = [] as typeof sqlKeywords;
@@ -60,7 +59,7 @@ const getHelperText = (value: string) =>
const extensionsRegistry = getExtensionsRegistry();
export function useKeywords(
{ queryEditorId, dbId, catalog, schema, tabViewId }: Params,
{ queryEditorId, dbId, catalog, schema }: Params,
skip = false,
) {
const useCustomKeywords = extensionsRegistry.get(
@@ -148,12 +147,7 @@ export function useKeywords(
const insertMatch = useEffectEvent((editor: Editor, data: any) => {
if (data.meta === 'table') {
dispatch(
addTable(
{ id: queryEditorId, dbId, tabViewId },
data.value,
catalog,
schema,
),
addTable({ id: queryEditorId, dbId }, data.value, catalog, schema),
);
}

View File

@@ -84,7 +84,6 @@ const SqlEditorLeftBar = ({
'dbId',
'catalog',
'schema',
'tabViewId',
]);
const [_emptyResultsWithSearch, setEmptyResultsWithSearch] = useState(false);

View File

@@ -104,10 +104,10 @@ export default class CRUDCollection extends PureComponent<
this.toggleExpand = this.toggleExpand.bind(this);
}
componentDidUpdate(prevProps: CRUDCollectionProps) {
if (this.props.collection !== prevProps.collection) {
UNSAFE_componentWillReceiveProps(nextProps: CRUDCollectionProps) {
if (nextProps.collection !== this.props.collection) {
const { collection, collectionArray } = createKeyedCollection(
this.props.collection,
nextProps.collection,
);
this.setState(prevState => ({
collection,

View File

@@ -740,6 +740,7 @@ class DatasourceEditor extends PureComponent {
this.props.runQuery({
client_id: this.props.clientId,
database_id: this.state.datasource.database.id,
json: true,
runAsync: false,
catalog: this.state.datasource.catalog,
schema: this.state.datasource.schema,

View File

@@ -0,0 +1,65 @@
/**
* 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 } from 'spec/helpers/testing-library';
import { Provider } from 'react-redux';
import { store } from 'src/views/store';
import type { FlashMessage } from './types';
import { FlashProvider } from '.';
test('Rerendering correctly with default props', () => {
const messages: FlashMessage[] = [];
render(
<FlashProvider messages={messages}>
<div data-test="my-component">My Component</div>
</FlashProvider>,
{ store },
);
expect(screen.getByTestId('my-component')).toBeInTheDocument();
});
test('messages should only be inserted in the State when the component is mounted', () => {
const messages: FlashMessage[] = [
['info', 'teste message 01'],
['info', 'teste message 02'],
];
expect(store.getState().messageToasts).toEqual([]);
const { rerender } = render(
<Provider store={store}>
<FlashProvider messages={messages}>
<div data-teste="my-component">My Component</div>
</FlashProvider>
</Provider>,
);
const fistRender = store.getState().messageToasts;
expect(fistRender).toHaveLength(2);
expect(fistRender[1].text).toBe(messages[0][1]);
expect(fistRender[0].text).toBe(messages[1][1]);
rerender(
<Provider store={store}>
<FlashProvider messages={[...messages, ['info', 'teste message 03']]}>
<div data-teste="my-component">My Component</div>
</FlashProvider>
</Provider>,
);
const secondRender = store.getState().messageToasts;
expect(secondRender).toEqual(fistRender);
});

View File

@@ -0,0 +1,51 @@
/**
* 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 { useToasts } from 'src/components/MessageToasts/withToasts';
import { useComponentDidMount } from '@superset-ui/core';
import type { FlashMessage } from './types';
interface Props {
children: JSX.Element;
messages: FlashMessage[];
}
const flashObj = {
info: 'addInfoToast',
alert: 'addDangerToast',
danger: 'addDangerToast',
warning: 'addWarningToast',
success: 'addSuccessToast',
};
export function FlashProvider({ children, messages }: Props) {
const toasts = useToasts();
useComponentDidMount(() => {
messages.forEach(message => {
const [type, text] = message;
const flash = flashObj[type];
const toast = toasts[flash as keyof typeof toasts];
if (toast) {
toast(text);
}
});
});
return children;
}
export type { FlashMessage };

View File

@@ -0,0 +1,20 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
type FlashMessageType = 'info' | 'alert' | 'danger' | 'warning' | 'success';
export type FlashMessage = [FlashMessageType, string];

View File

@@ -18,7 +18,7 @@
*/
import { ReactNode } from 'react';
import { styled, t } from '@superset-ui/core';
import { Modal, Loading, Flex } from '@superset-ui/core/components';
import { Modal } from '@superset-ui/core/components';
import { ModalTitleWithIcon } from 'src/components/ModalTitleWithIcon';
interface StandardModalProps {
@@ -39,7 +39,6 @@ interface StandardModalProps {
destroyOnClose?: boolean;
maskClosable?: boolean;
wrapProps?: object;
contentLoading?: boolean;
}
// Standard modal widths
@@ -114,13 +113,12 @@ export function StandardModal({
destroyOnClose = true,
maskClosable = false,
wrapProps,
contentLoading = false,
}: StandardModalProps) {
const primaryButtonName = saveText || (isEditMode ? t('Save') : t('Add'));
return (
<StyledModal
disablePrimaryButton={saveDisabled || saveLoading || contentLoading}
disablePrimaryButton={saveDisabled || saveLoading}
primaryButtonLoading={saveLoading}
primaryTooltipMessage={errorTooltip}
onHandledPrimaryAction={onSave}
@@ -141,13 +139,7 @@ export function StandardModal({
)
}
>
{contentLoading ? (
<Flex justify="center" align="center" style={{ minHeight: 200 }}>
<Loading />
</Flex>
) : (
children
)}
{children}
</StyledModal>
);
}

View File

@@ -38,6 +38,7 @@ export * from './ErrorMessage';
export { ImportModal, type ImportModelsModalProps } from './ImportModal';
export { ErrorBoundary, type ErrorBoundaryProps } from './ErrorBoundary';
export * from './GenericLink';
export { FlashProvider, type FlashMessage } from './FlashProvider';
export { GridTable, type TableProps } from './GridTable';
export * from './Tag';
export * from './TagsList';

View File

@@ -122,6 +122,7 @@ export const RESERVED_DASHBOARD_URL_PARAMS: string[] = [
export const DEFAULT_COMMON_BOOTSTRAP_DATA: CommonBootstrapData = {
application_root: '/',
static_assets_prefix: '',
flash_messages: [],
conf: {},
locale: 'en',
feature_flags: {},

View File

@@ -274,6 +274,7 @@ export const hydrateDashboard =
superset_can_csv: findPermission('can_csv', 'Superset', roles),
common: {
// legacy, please use state.common instead
flash_messages: common?.flash_messages,
conf: common?.conf,
},
filterBarOrientation:

View File

@@ -120,12 +120,15 @@ class Dashboard extends PureComponent {
this.applyCharts();
}
componentDidUpdate(prevProps) {
componentDidUpdate() {
this.applyCharts();
const currentChartIds = getChartIdsFromLayout(prevProps.layout);
const nextChartIds = getChartIdsFromLayout(this.props.layout);
}
if (prevProps.dashboardId !== this.props.dashboardId) {
UNSAFE_componentWillReceiveProps(nextProps) {
const currentChartIds = getChartIdsFromLayout(this.props.layout);
const nextChartIds = getChartIdsFromLayout(nextProps.layout);
if (this.props.dashboardId !== nextProps.dashboardId) {
// single-page-app navigation check
return;
}
@@ -137,7 +140,7 @@ class Dashboard extends PureComponent {
newChartIds.forEach(newChartId =>
this.props.actions.addSliceToDashboard(
newChartId,
getLayoutComponentFromChartId(this.props.layout, newChartId),
getLayoutComponentFromChartId(nextProps.layout, newChartId),
),
);
} else if (currentChartIds.length > nextChartIds.length) {

View File

@@ -100,7 +100,7 @@ import { useHeaderActionsMenu } from './useHeaderActionsDropdownMenu';
const extensionsRegistry = getExtensionsRegistry();
const headerContainerStyle = theme => css`
border-bottom: 1px solid ${theme.colorBorder};
border-bottom: 1px solid ${theme.colorSplit};
`;
const editButtonStyle = theme => css`

View File

@@ -354,19 +354,9 @@ describe('PropertiesModal', () => {
mockedIsFeatureEnabled.mockReturnValue(false);
const props = createProps();
props.onlyApply = false;
// Pass dashboardInfo to avoid loading state
const propsWithDashboardInfo = {
...props,
dashboardInfo: {
...dashboardInfo,
json_metadata: mockedJsonMetadata,
},
};
render(<PropertiesModal {...propsWithDashboardInfo} />, {
render(<PropertiesModal {...props} />, {
useRedux: true,
});
// Wait for the form to be visible
expect(
await screen.findByTestId('dashboard-edit-properties-form'),
).toBeInTheDocument();
@@ -389,19 +379,9 @@ describe('PropertiesModal', () => {
mockedIsFeatureEnabled.mockReturnValue(false);
const props = createProps();
props.onlyApply = true;
// Pass dashboardInfo to avoid loading state
const propsWithDashboardInfo = {
...props,
dashboardInfo: {
...dashboardInfo,
json_metadata: mockedJsonMetadata,
},
};
render(<PropertiesModal {...propsWithDashboardInfo} />, {
render(<PropertiesModal {...props} />, {
useRedux: true,
});
// Wait for the form to be visible
expect(
await screen.findByTestId('dashboard-edit-properties-form'),
).toBeInTheDocument();

View File

@@ -112,7 +112,7 @@ const PropertiesModal = ({
const dispatch = useDispatch();
const [form] = Form.useForm();
const [isLoading, setIsLoading] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const [isApplying, setIsApplying] = useState(false);
const [colorScheme, setCurrentColorScheme] = useState(currentColorScheme);
const [jsonMetadata, setJsonMetadata] = useState('');
@@ -207,6 +207,7 @@ const PropertiesModal = ({
);
const fetchDashboardDetails = useCallback(() => {
setIsLoading(true);
// We fetch the dashboard details because not all code
// that renders this component have all the values we need.
// At some point when we have a more consistent frontend
@@ -381,6 +382,10 @@ const PropertiesModal = ({
if (onlyApply) {
setIsApplying(true);
try {
console.log('Apply CSS debug:', {
css_being_sent: customCss,
onSubmitProps_css: onSubmitProps.css,
});
onSubmit(onSubmitProps);
onHide();
addSuccessToast(t('Dashboard properties updated'));
@@ -417,15 +422,10 @@ const PropertiesModal = ({
useEffect(() => {
if (show) {
// Reset loading state when modal opens
setIsLoading(true);
if (!currentDashboardInfo) {
fetchDashboardDetails();
} else {
handleDashboardData(currentDashboardInfo);
// Data is immediately available, so we can stop loading
setIsLoading(false);
}
// Fetch themes (excluding system themes)
@@ -621,9 +621,10 @@ const PropertiesModal = ({
}}
title={t('Dashboard properties')}
isEditMode
saveDisabled={dashboardInfo?.isManagedExternally || hasErrors}
saveDisabled={
isLoading || dashboardInfo?.isManagedExternally || hasErrors
}
saveLoading={isApplying}
contentLoading={isLoading}
errorTooltip={
dashboardInfo?.isManagedExternally
? t(
@@ -664,6 +665,7 @@ const PropertiesModal = ({
children: (
<BasicInfoSection
form={form}
isLoading={isLoading}
validationStatus={validationStatus}
/>
),

View File

@@ -21,9 +21,8 @@ import { Form } from '@superset-ui/core/components';
import BasicInfoSection from './BasicInfoSection';
const defaultProps = {
form: {
getFieldValue: jest.fn(() => 'Test Dashboard'),
} as any,
form: {} as any,
isLoading: false,
validationStatus: {
basic: { hasErrors: false, errors: [], name: 'Basic' },
},
@@ -51,6 +50,16 @@ test('shows required asterisk for name field', () => {
expect(screen.getByText('*')).toBeInTheDocument();
});
test('disables inputs when loading', () => {
render(
<Form>
<BasicInfoSection {...defaultProps} isLoading />
</Form>,
);
expect(screen.getByTestId('dashboard-title-input')).toBeDisabled();
});
test('shows error message when name is empty and has validation errors', () => {
const mockForm = {
getFieldValue: jest.fn(field => (field === 'title' ? '' : 'test')),

View File

@@ -23,59 +23,62 @@ import { ValidationObject } from 'src/components/Modal/useModalValidation';
interface BasicInfoSectionProps {
form: FormInstance;
isLoading: boolean;
validationStatus: ValidationObject;
}
const BasicInfoSection = ({
form,
isLoading,
validationStatus,
}: BasicInfoSectionProps) => {
const titleValue = form.getFieldValue('title');
const hasError =
validationStatus.basic?.hasErrors &&
(!titleValue || titleValue.trim().length === 0);
return (
<>
<ModalFormField
label={t('Name')}
required
testId="dashboard-name-field"
error={hasError ? t('Dashboard name is required') : undefined}
}: BasicInfoSectionProps) => (
<>
<ModalFormField
label={t('Name')}
required
testId="dashboard-name-field"
error={
validationStatus.basic?.hasErrors &&
(!form.getFieldValue('title') ||
form.getFieldValue('title').trim().length === 0)
? t('Dashboard name is required')
: undefined
}
>
<FormItem
name="title"
noStyle
rules={[
{
required: true,
message: t('Dashboard name is required'),
whitespace: true,
},
]}
>
<FormItem
name="title"
noStyle
rules={[
{
required: true,
message: t('Dashboard name is required'),
whitespace: true,
},
]}
>
<Input
placeholder={t('The display name of your dashboard')}
data-test="dashboard-title-input"
type="text"
/>
</FormItem>
</ModalFormField>
<ModalFormField
label={t('URL Slug')}
testId="dashboard-slug-field"
bottomSpacing={false}
>
<FormItem name="slug" noStyle>
<Input
placeholder={t('A readable URL for your dashboard')}
data-test="dashboard-slug-input"
type="text"
/>
</FormItem>
</ModalFormField>
</>
);
};
<Input
placeholder={t('The display name of your dashboard')}
data-test="dashboard-title-input"
type="text"
disabled={isLoading}
/>
</FormItem>
</ModalFormField>
<ModalFormField
label={t('URL Slug')}
testId="dashboard-slug-field"
bottomSpacing={false}
>
<FormItem name="slug" noStyle>
<Input
placeholder={t('A readable URL for your dashboard')}
data-test="dashboard-slug-input"
type="text"
disabled={isLoading}
/>
</FormItem>
</ModalFormField>
</>
);
export default BasicInfoSection;

View File

@@ -155,23 +155,6 @@ export function sortByComparator(attr: keyof Slice) {
};
}
function getFilteredSortedSlices(
slices: SliceAdderProps['slices'],
searchTerm: string,
sortBy: keyof Slice,
showOnlyMyCharts: boolean,
userId: number,
) {
return Object.values(slices)
.filter(slice =>
showOnlyMyCharts
? slice?.owners?.find(owner => owner.id === userId) ||
slice?.created_by?.id === userId
: true,
)
.filter(createFilter(searchTerm, KEYS_TO_FILTERS))
.sort(sortByComparator(sortBy));
}
class SliceAdder extends Component<SliceAdderProps, SliceAdderState> {
private slicesRequest?: AbortController | Promise<void>;
@@ -212,20 +195,19 @@ class SliceAdder extends Component<SliceAdderProps, SliceAdderState> {
);
}
componentDidUpdate(prevProps: SliceAdderProps) {
UNSAFE_componentWillReceiveProps(nextProps: SliceAdderProps) {
const nextState: SliceAdderState = {} as SliceAdderState;
if (this.props.lastUpdated !== prevProps.lastUpdated) {
nextState.filteredSlices = getFilteredSortedSlices(
this.props.slices,
if (nextProps.lastUpdated !== this.props.lastUpdated) {
nextState.filteredSlices = this.getFilteredSortedSlices(
nextProps.slices,
this.state.searchTerm,
this.state.sortBy,
this.state.showOnlyMyCharts,
this.props.userId,
);
}
if (prevProps.selectedSliceIds !== this.props.selectedSliceIds) {
nextState.selectedSliceIdsSet = new Set(this.props.selectedSliceIds);
if (nextProps.selectedSliceIds !== this.props.selectedSliceIds) {
nextState.selectedSliceIdsSet = new Set(nextProps.selectedSliceIds);
}
if (Object.keys(nextState).length) {
@@ -245,6 +227,23 @@ class SliceAdder extends Component<SliceAdderProps, SliceAdderState> {
}
}
getFilteredSortedSlices(
slices: SliceAdderProps['slices'],
searchTerm: string,
sortBy: keyof Slice,
showOnlyMyCharts: boolean,
) {
return Object.values(slices)
.filter(slice =>
showOnlyMyCharts
? slice?.owners?.find(owner => owner.id === this.props.userId) ||
slice?.created_by?.id === this.props.userId
: true,
)
.filter(createFilter(searchTerm, KEYS_TO_FILTERS))
.sort(sortByComparator(sortBy));
}
handleChange = debounce(value => {
this.searchUpdated(value);
this.slicesRequest = this.props.fetchSlices(
@@ -257,12 +256,11 @@ class SliceAdder extends Component<SliceAdderProps, SliceAdderState> {
searchUpdated(searchTerm: string) {
this.setState(prevState => ({
searchTerm,
filteredSlices: getFilteredSortedSlices(
filteredSlices: this.getFilteredSortedSlices(
this.props.slices,
searchTerm,
prevState.sortBy,
prevState.showOnlyMyCharts,
this.props.userId,
),
}));
}
@@ -270,12 +268,11 @@ class SliceAdder extends Component<SliceAdderProps, SliceAdderState> {
handleSelect(sortBy: keyof Slice) {
this.setState(prevState => ({
sortBy,
filteredSlices: getFilteredSortedSlices(
filteredSlices: this.getFilteredSortedSlices(
this.props.slices,
prevState.searchTerm,
sortBy,
prevState.showOnlyMyCharts,
this.props.userId,
),
}));
this.slicesRequest = this.props.fetchSlices(
@@ -343,12 +340,11 @@ class SliceAdder extends Component<SliceAdderProps, SliceAdderState> {
}
this.setState(prevState => ({
showOnlyMyCharts,
filteredSlices: getFilteredSortedSlices(
filteredSlices: this.getFilteredSortedSlices(
this.props.slices,
prevState.searchTerm,
prevState.sortBy,
showOnlyMyCharts,
this.props.userId,
),
}));
setItem(LocalStorageKeys.DashboardEditorShowOnlyMyCharts, showOnlyMyCharts);

View File

@@ -129,12 +129,12 @@ export default class WithPopoverMenu extends PureComponent<
this.handleClick = this.handleClick.bind(this);
}
componentDidUpdate(prevProps: WithPopoverMenuProps) {
if (this.props.editMode && this.props.isFocused && !this.state.isFocused) {
UNSAFE_componentWillReceiveProps(nextProps: WithPopoverMenuProps) {
if (nextProps.editMode && nextProps.isFocused && !this.state.isFocused) {
document.addEventListener('click', this.handleClick);
document.addEventListener('drag', this.handleClick);
this.setState({ isFocused: true });
} else if (this.state.isFocused && !this.props.editMode) {
} else if (this.state.isFocused && !nextProps.editMode) {
document.removeEventListener('click', this.handleClick);
document.removeEventListener('drag', this.handleClick);
this.setState({ isFocused: false });

View File

@@ -20,6 +20,7 @@
export interface QueryExecutePayload {
client_id: string;
database_id: number;
json: boolean;
runAsync: boolean;
catalog: string | null;
schema: string;

View File

@@ -22,12 +22,13 @@ import { Provider as ReduxProvider } from 'react-redux';
import { QueryParamProvider } from 'use-query-params';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { DynamicPluginProvider } from 'src/components';
import { FlashProvider, DynamicPluginProvider } from 'src/components';
import { EmbeddedUiConfigProvider } from 'src/components/UiConfigContext';
import { SupersetThemeProvider } from 'src/theme/ThemeProvider';
import { ThemeController } from 'src/theme/ThemeController';
import type { ThemeStorage } from '@superset-ui/core';
import { store } from 'src/views/store';
import getBootstrapData from 'src/utils/getBootstrapData';
/**
* In-memory implementation of ThemeStorage interface for embedded contexts.
@@ -55,6 +56,7 @@ const themeController = new ThemeController({
export const getThemeController = (): ThemeController => themeController;
const { common } = getBootstrapData();
const extensionsRegistry = getExtensionsRegistry();
export const EmbeddedContextProviders: React.FC = ({ children }) => {
@@ -66,22 +68,24 @@ export const EmbeddedContextProviders: React.FC = ({ children }) => {
<SupersetThemeProvider themeController={themeController}>
<ReduxProvider store={store}>
<DndProvider backend={HTML5Backend}>
<EmbeddedUiConfigProvider>
<DynamicPluginProvider>
<QueryParamProvider
ReactRouterRoute={Route}
stringifyOptions={{ encode: false }}
>
{RootContextProviderExtension ? (
<RootContextProviderExtension>
{children}
</RootContextProviderExtension>
) : (
children
)}
</QueryParamProvider>
</DynamicPluginProvider>
</EmbeddedUiConfigProvider>
<FlashProvider messages={common.flash_messages}>
<EmbeddedUiConfigProvider>
<DynamicPluginProvider>
<QueryParamProvider
ReactRouterRoute={Route}
stringifyOptions={{ encode: false }}
>
{RootContextProviderExtension ? (
<RootContextProviderExtension>
{children}
</RootContextProviderExtension>
) : (
children
)}
</QueryParamProvider>
</DynamicPluginProvider>
</EmbeddedUiConfigProvider>
</FlashProvider>
</DndProvider>
</ReduxProvider>
</SupersetThemeProvider>

View File

@@ -107,23 +107,17 @@ class AnnotationLayerControl extends PureComponent<Props, PopoverState> {
AnnotationLayer.preload();
}
componentDidUpdate(prevProps: Props) {
const { name, annotationError, validationErrors, value } = this.props;
if (
(Object.keys(annotationError).length && !validationErrors.length) ||
(!Object.keys(annotationError).length && validationErrors.length)
) {
if (
annotationError !== prevProps.annotationError ||
validationErrors !== prevProps.validationErrors ||
value !== prevProps.value
) {
this.props.actions.setControlValue(
name,
value,
Object.keys(annotationError),
);
}
UNSAFE_componentWillReceiveProps(nextProps: Props) {
const { name, annotationError, validationErrors, value } = nextProps;
if (Object.keys(annotationError).length && !validationErrors.length) {
this.props.actions.setControlValue(
name,
value,
Object.keys(annotationError),
);
}
if (!Object.keys(annotationError).length && validationErrors.length) {
this.props.actions.setControlValue(name, value, []);
}
}

View File

@@ -87,48 +87,10 @@ function isDictionaryForAdhocFilter(value) {
return value && !(value instanceof AdhocFilter) && value.expressionType;
}
function optionsForSelect(props) {
const options = [
...props.columns,
...ensureIsArray(props.selectedMetrics).map(
metric =>
metric &&
(typeof metric === 'string'
? { saved_metric_name: metric }
: new AdhocMetric(metric)),
),
].filter(option => option);
return options
.reduce((results, option) => {
if (option.saved_metric_name) {
results.push({
...option,
filterOptionName: option.saved_metric_name,
});
} else if (option.column_name) {
results.push({
...option,
filterOptionName: `_col_${option.column_name}`,
});
} else if (option instanceof AdhocMetric) {
results.push({
...option,
filterOptionName: `_adhocmetric_${option.label}`,
});
}
return results;
}, [])
.sort((a, b) =>
(a.saved_metric_name || a.column_name || a.label).localeCompare(
b.saved_metric_name || b.column_name || b.label,
),
);
}
class AdhocFilterControl extends Component {
constructor(props) {
super(props);
this.optionsForSelect = this.optionsForSelect.bind(this);
this.onRemoveFilter = this.onRemoveFilter.bind(this);
this.onNewFilter = this.onNewFilter.bind(this);
this.onFilterEdit = this.onFilterEdit.bind(this);
@@ -164,7 +126,7 @@ class AdhocFilterControl extends Component {
);
this.state = {
values: filters,
options: optionsForSelect(this.props),
options: this.optionsForSelect(this.props),
partitionColumn: null,
};
}
@@ -211,13 +173,13 @@ class AdhocFilterControl extends Component {
}
}
componentDidUpdate(prevProps) {
if (this.props.columns !== prevProps.columns) {
this.setState({ options: optionsForSelect(this.props) });
UNSAFE_componentWillReceiveProps(nextProps) {
if (this.props.columns !== nextProps.columns) {
this.setState({ options: this.optionsForSelect(nextProps) });
}
if (this.props.value !== prevProps.value) {
if (this.props.value !== nextProps.value) {
this.setState({
values: (this.props.value || []).map(filter =>
values: (nextProps.value || []).map(filter =>
isDictionaryForAdhocFilter(filter) ? new AdhocFilter(filter) : filter,
),
});
@@ -336,6 +298,45 @@ class AdhocFilterControl extends Component {
return null;
}
optionsForSelect(props) {
const options = [
...props.columns,
...ensureIsArray(props.selectedMetrics).map(
metric =>
metric &&
(typeof metric === 'string'
? { saved_metric_name: metric }
: new AdhocMetric(metric)),
),
].filter(option => option);
return options
.reduce((results, option) => {
if (option.saved_metric_name) {
results.push({
...option,
filterOptionName: option.saved_metric_name,
});
} else if (option.column_name) {
results.push({
...option,
filterOptionName: `_col_${option.column_name}`,
});
} else if (option instanceof AdhocMetric) {
results.push({
...option,
filterOptionName: `_adhocmetric_${option.label}`,
});
}
return results;
}, [])
.sort((a, b) =>
(a.saved_metric_name || a.column_name || a.label).localeCompare(
b.saved_metric_name || b.column_name || b.label,
),
);
}
addNewFilterPopoverTrigger(trigger) {
return (
<AdhocFilterPopoverTrigger

View File

@@ -16,16 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import PropTypes from 'prop-types';
export { savedMetricType } from './types';
export type { savedMetricType } from './types';
// PropTypes definition for JavaScript files
const savedMetricTypePropTypes = PropTypes.shape({
metric_name: PropTypes.string.isRequired,
verbose_name: PropTypes.string,
expression: PropTypes.string,
});
// Export as default for backward compatibility with JavaScript files
export default savedMetricTypePropTypes;
// For backward compatibility with PropTypes usage
export { savedMetricType as default } from './types';

View File

@@ -167,12 +167,12 @@ export default class SelectControl extends PureComponent {
this.handleFilterOptions = this.handleFilterOptions.bind(this);
}
componentDidUpdate(prevProps) {
UNSAFE_componentWillReceiveProps(nextProps) {
if (
!isEqualArray(this.props.choices, prevProps.choices) ||
!isEqualArray(this.props.options, prevProps.options)
!isEqualArray(nextProps.choices, this.props.choices) ||
!isEqualArray(nextProps.options, this.props.options)
) {
const options = this.getOptions(this.props);
const options = this.getOptions(nextProps);
this.setState({ options });
}
}

View File

@@ -98,6 +98,7 @@ export interface ExploreResponsePayload {
export interface ExplorePageState {
user: UserWithPermissionsAndRoles;
common: {
flash_messages: string[];
conf: JsonObject;
locale: string;
};

View File

@@ -33,7 +33,7 @@ jest.mock('src/utils/getBootstrapData', () => ({
}));
test('should render login form elements', () => {
render(<Login />, { useRedux: true });
render(<Login />);
expect(screen.getByTestId('login-form')).toBeInTheDocument();
expect(screen.getByTestId('username-input')).toBeInTheDocument();
expect(screen.getByTestId('password-input')).toBeInTheDocument();
@@ -42,13 +42,13 @@ test('should render login form elements', () => {
});
test('should render username and password labels', () => {
render(<Login />, { useRedux: true });
render(<Login />);
expect(screen.getByText('Username:')).toBeInTheDocument();
expect(screen.getByText('Password:')).toBeInTheDocument();
});
test('should render form instruction text', () => {
render(<Login />, { useRedux: true });
render(<Login />);
expect(
screen.getByText('Enter your login and password below:'),
).toBeInTheDocument();

View File

@@ -27,10 +27,8 @@ import {
Typography,
Icons,
} from '@superset-ui/core/components';
import { useState, useEffect } from 'react';
import { useState } from 'react';
import { capitalize } from 'lodash/fp';
import { addDangerToast } from 'src/components/MessageToasts/actions';
import { useDispatch } from 'react-redux';
import getBootstrapData from 'src/utils/getBootstrapData';
type OAuthProvider = {
@@ -79,7 +77,6 @@ const StyledLabel = styled(Typography.Text)`
export default function Login() {
const [form] = Form.useForm<LoginForm>();
const [loading, setLoading] = useState(false);
const dispatch = useDispatch();
const bootstrapData = getBootstrapData();
@@ -88,28 +85,11 @@ export default function Login() {
const authRegistration: boolean =
bootstrapData.common.conf.AUTH_USER_REGISTRATION;
// TODO: This is a temporary solution for showing login errors after form submission.
// Should be replaced with proper SPA-style authentication (JSON API with error responses)
// when Flask-AppBuilder is updated or we implement a custom login endpoint.
useEffect(() => {
const loginAttempted = sessionStorage.getItem('login_attempted');
if (loginAttempted === 'true') {
sessionStorage.removeItem('login_attempted');
dispatch(addDangerToast(t('Invalid username or password')));
// Clear password field for security
form.setFieldsValue({ password: '' });
}
}, [dispatch, form]);
const onFinish = (values: LoginForm) => {
setLoading(true);
// Mark that we're attempting login (for error detection after redirect)
sessionStorage.setItem('login_attempted', 'true');
// Use standard form submission for Flask-AppBuilder compatibility
SupersetClient.postForm('/login/', values, '');
SupersetClient.postForm('/login/', values, '').finally(() => {
setLoading(false);
});
};
const getAuthIconElement = (

View File

@@ -83,6 +83,7 @@ const createMockBootstrapData = (
common: {
application_root: '/',
static_assets_prefix: '/static/assets/',
flash_messages: [],
conf: {},
locale: 'en',
feature_flags: {},
@@ -390,6 +391,7 @@ describe('ThemeController', () => {
common: {
application_root: '/',
static_assets_prefix: '/static/assets/',
flash_messages: [],
conf: {},
locale: 'en',
feature_flags: {},

View File

@@ -20,6 +20,7 @@ import { FormatLocaleDefinition } from 'd3-format';
import { TimeLocaleDefinition } from 'd3-time-format';
import { isPlainObject } from 'lodash';
import { Languages } from 'src/features/home/LanguagePicker';
import type { FlashMessage } from 'src/components';
import type {
AnyThemeConfig,
ColorSchemeConfig,
@@ -153,6 +154,7 @@ export interface BootstrapThemeDataConfig {
export interface CommonBootstrapData {
application_root: string;
static_assets_prefix: string;
flash_messages: FlashMessage[];
conf: JsonObject;
locale: Locale;
feature_flags: FeatureFlagMap;

View File

@@ -23,7 +23,8 @@ import { Provider as ReduxProvider } from 'react-redux';
import { QueryParamProvider } from 'use-query-params';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { DynamicPluginProvider } from 'src/components';
import getBootstrapData from 'src/utils/getBootstrapData';
import { FlashProvider, DynamicPluginProvider } from 'src/components';
import { EmbeddedUiConfigProvider } from 'src/components/UiConfigContext';
import { SupersetThemeProvider } from 'src/theme/ThemeProvider';
import { ThemeController } from 'src/theme/ThemeController';
@@ -31,6 +32,7 @@ import { ExtensionsProvider } from 'src/extensions/ExtensionsContext';
import { store } from './store';
import '../preamble';
const { common } = getBootstrapData();
const themeController = new ThemeController();
const extensionsRegistry = getExtensionsRegistry();
@@ -43,24 +45,26 @@ export const RootContextProviders: React.FC = ({ children }) => {
<SupersetThemeProvider themeController={themeController}>
<ReduxProvider store={store}>
<DndProvider backend={HTML5Backend}>
<EmbeddedUiConfigProvider>
<DynamicPluginProvider>
<QueryParamProvider
ReactRouterRoute={Route}
stringifyOptions={{ encode: false }}
>
<ExtensionsProvider>
{RootContextProviderExtension ? (
<RootContextProviderExtension>
{children}
</RootContextProviderExtension>
) : (
children
)}
</ExtensionsProvider>
</QueryParamProvider>
</DynamicPluginProvider>
</EmbeddedUiConfigProvider>
<FlashProvider messages={common.flash_messages}>
<EmbeddedUiConfigProvider>
<DynamicPluginProvider>
<QueryParamProvider
ReactRouterRoute={Route}
stringifyOptions={{ encode: false }}
>
<ExtensionsProvider>
{RootContextProviderExtension ? (
<RootContextProviderExtension>
{children}
</RootContextProviderExtension>
) : (
children
)}
</ExtensionsProvider>
</QueryParamProvider>
</DynamicPluginProvider>
</EmbeddedUiConfigProvider>
</FlashProvider>
</DndProvider>
</ReduxProvider>
</SupersetThemeProvider>

View File

@@ -65,9 +65,6 @@ const devserverHost =
const isDevMode = mode !== 'production';
const isDevServer = process.argv[1].includes('webpack-dev-server');
// TypeScript checker memory limit (in MB)
const TYPESCRIPT_MEMORY_LIMIT = 4096;
const output = {
path: BUILD_DIR,
publicPath: '/static/assets/',
@@ -187,64 +184,21 @@ if (!isDevMode) {
chunkFilename: '[name].[chunkhash].chunk.css',
}),
);
}
// Type checking for both dev and production
// In dev mode, this provides real-time type checking and builds .d.ts files for plugins
// Can be disabled with DISABLE_TYPE_CHECK=true npm run dev
if (isDevMode) {
if (process.env.DISABLE_TYPE_CHECK) {
console.log('⚡ Type checking disabled (DISABLE_TYPE_CHECK=true)');
} else {
console.log(
'✅ Type checking enabled (disable with DISABLE_TYPE_CHECK=true npm run dev)',
);
// Optimized configuration for development - much faster type checking
plugins.push(
new ForkTsCheckerWebpackPlugin({
typescript: {
memoryLimit: TYPESCRIPT_MEMORY_LIMIT,
build: true, // Generate .d.ts files
mode: 'write-references', // Handle project references properly
// Use main tsconfig but with safe performance optimizations
configOverwrite: {
compilerOptions: {
// Only safe optimizations that won't cause errors
skipLibCheck: true, // Skip checking .d.ts files - safe and huge perf boost
incremental: true, // Enable incremental compilation
},
},
},
// Logger configuration
logger: 'webpack-infrastructure',
async: true, // Non-blocking type checking
// Only check files that webpack is actually processing
// This dramatically reduces the scope of type checking
issue: {
scope: 'webpack', // Only check files in webpack's module graph, not entire project
include: [
{ file: 'src/**/*.{ts,tsx}' },
{ file: 'packages/*/src/**/*.{ts,tsx}' },
{ file: 'plugins/*/src/**/*.{ts,tsx}' },
],
exclude: [{ file: '**/node_modules/**' }],
},
}),
);
}
} else {
// Production mode - full type checking
// Runs type checking on a separate process to speed up the build
plugins.push(
new ForkTsCheckerWebpackPlugin({
typescript: {
memoryLimit: TYPESCRIPT_MEMORY_LIMIT,
memoryLimit: 4096,
build: true,
mode: 'write-references',
},
// Logger configuration
logger: 'webpack-infrastructure',
issue: {
exclude: [{ file: '**/node_modules/**' }],
exclude: [
'**/node_modules/**',
'**/dist/**',
'**/coverage/**',
'**/storybook/**',
'**/*.stories.{ts,tsx,js,jsx}',
'**/*.{test,spec}.{ts,tsx,js,jsx}',
],
},
}),
);
@@ -390,12 +344,7 @@ const config = {
},
resolve: {
// resolve modules from `/superset_frontend/node_modules` and `/superset_frontend`
modules: [
'node_modules',
APP_DIR,
path.resolve(APP_DIR, 'packages'),
path.resolve(APP_DIR, 'plugins'),
],
modules: ['node_modules', APP_DIR],
alias: {
react: path.resolve(path.join(APP_DIR, './node_modules/react')),
// TODO: remove Handlebars alias once Handlebars NPM package has been updated to
@@ -589,16 +538,6 @@ const config = {
},
plugins,
devtool: isDevMode ? 'eval-cheap-module-source-map' : false,
watchOptions: isDevMode
? {
// Watch all plugin and package source directories
ignored: ['**/node_modules', '**/.git', '**/lib', '**/esm', '**/dist'],
// Poll less frequently to reduce file handles
poll: 2000,
// Aggregate changes for 500ms before rebuilding
aggregateTimeout: 500,
}
: undefined,
};
// find all the symlinked plugins and use their source code for imports

View File

@@ -625,7 +625,7 @@ DEFAULT_FEATURE_FLAGS: dict[str, bool] = {
# in addition to relative timeshifts (e.g., "1 day ago")
"DATE_RANGE_TIMESHIFTS_ENABLED": False,
# Enable Matrixify feature for matrix-style chart layouts
"MATRIXIFY": True,
"MATRIXIFY": False,
# Force garbage collection after every request
"FORCE_GARBAGE_COLLECTION_AFTER_EVERY_REQUEST": False,
}

View File

@@ -77,6 +77,18 @@ class MySQLEngineSpec(BasicParametersMixin, BaseEngineSpec):
supports_multivalues_insert = True
column_type_mappings = (
# Boolean types - MySQL uses TINYINT(1) for BOOLEAN
(
re.compile(r"^tinyint\(1\)", re.IGNORECASE),
TINYINT(),
GenericDataType.BOOLEAN,
),
(
re.compile(r"^bool(ean)?", re.IGNORECASE),
TINYINT(),
GenericDataType.BOOLEAN,
),
# Numeric types
(
re.compile(r"^int.*", re.IGNORECASE),
INTEGER(),
@@ -260,6 +272,67 @@ class MySQLEngineSpec(BasicParametersMixin, BaseEngineSpec):
def epoch_to_dttm(cls) -> str:
return "from_unixtime({col})"
@classmethod
def _is_boolean_column(cls, col_desc: tuple[Any, ...]) -> bool:
"""Check if a cursor column description represents a boolean column."""
type_code = col_desc[1] if len(col_desc) > 1 else None
display_size = col_desc[2] if len(col_desc) > 2 else None
# Only process FIELD_TYPE.TINY (type_code 1)
if type_code != 1:
return False
# Explicit width 1 indicates TINYINT(1)/BOOLEAN
if display_size == 1:
return True
# Check SQLAlchemy type string (some drivers provide it at index 4)
if len(col_desc) > 4 and isinstance(col_desc[4], str):
sqla_type_str = col_desc[4].lower()
return any(marker in sqla_type_str for marker in ["bool", "tinyint(1)"])
return False
@classmethod
def fetch_data(cls, cursor: Any, limit: int | None = None) -> list[tuple[Any, ...]]:
"""
Fetch data from cursor, converting MySQL TINYINT(1) values to Python booleans.
MySQL stores BOOLEAN as TINYINT(1), but returns 0/1 integers instead of
True/False. This method detects TINYINT(1) columns using multiple reliable
markers and converts their values to proper Python booleans.
"""
data = super().fetch_data(cursor, limit)
if not cursor.description:
return data
# Find TINYINT(1) columns
bool_column_indices = [
i
for i, col_desc in enumerate(cursor.description)
if cls._is_boolean_column(col_desc)
]
# Convert 0/1 to True/False for boolean columns
if bool_column_indices:
converted_data = []
for row in data:
new_row = list(row)
for col_idx in bool_column_indices:
if new_row[col_idx] is not None:
# Normalize different value types before boolean conversion
# bool("0") returns True, but we need False for MySQL boolean
value = new_row[col_idx]
if isinstance(value, (str, bytes)):
value = int(value)
elif isinstance(value, Decimal):
value = int(value)
new_row[col_idx] = bool(value)
converted_data.append(tuple(new_row))
return converted_data
return data
@classmethod
def _extract_error_message(cls, ex: Exception) -> str:
"""Extract error message for queries"""

View File

@@ -63,6 +63,7 @@ class ExecutePayloadSchema(Schema):
templateParams = fields.String(allow_none=True) # noqa: N815
tmp_table_name = fields.String(allow_none=True)
select_as_cta = fields.Boolean(allow_none=True)
json = fields.Boolean(allow_none=True)
runAsync = fields.Boolean(allow_none=True) # noqa: N815
expand_data = fields.Boolean(allow_none=True)

View File

@@ -18,8 +18,9 @@
import logging
from typing import Optional
from flask import g, redirect
from flask import flash, g, redirect
from flask_appbuilder import expose
from flask_appbuilder._compat import as_unicode
from flask_appbuilder.const import LOGMSG_ERR_SEC_NO_REGISTER_HASH
from flask_appbuilder.security.decorators import no_cache
from flask_appbuilder.security.views import AuthView, WerkzeugResponse
@@ -65,7 +66,7 @@ class SupersetRegisterUserView(BaseSupersetView):
reg = self.appbuilder.sm.find_register_user(activation_hash)
if not reg:
logger.error(LOGMSG_ERR_SEC_NO_REGISTER_HASH, activation_hash)
logger.error("Registration activation failed: %s", self.false_error_message)
flash(as_unicode(self.false_error_message), "danger")
return redirect(self.appbuilder.get_url_for_index)
if not self.appbuilder.sm.add_user(
username=reg.username,
@@ -77,7 +78,7 @@ class SupersetRegisterUserView(BaseSupersetView):
),
hashed_password=reg.password,
):
logger.error("User registration failed: %s", self.error_message)
flash(as_unicode(self.error_message), "danger")
return redirect(self.appbuilder.get_url_for_index)
else:
self.appbuilder.sm.del_register_user(reg)

View File

@@ -27,7 +27,9 @@ from babel import Locale
from flask import (
abort,
current_app as app,
flash,
g,
get_flashed_messages,
redirect,
Response,
session,
@@ -497,7 +499,10 @@ def cached_common_bootstrap_data( # pylint: disable=unused-argument
def common_bootstrap_payload() -> dict[str, Any]:
return cached_common_bootstrap_data(utils.get_user_id(), get_locale())
return {
**cached_common_bootstrap_data(utils.get_user_id(), get_locale()),
"flash_messages": get_flashed_messages(with_categories=True),
}
def get_spa_payload(extra_data: dict[str, Any] | None = None) -> dict[str, Any]:
@@ -592,7 +597,7 @@ class DeleteMixin: # pylint: disable=too-few-public-methods
try:
self.pre_delete(item)
except Exception as ex: # pylint: disable=broad-except
logger.error("Pre-delete error: %s", str(ex))
flash(str(ex), "danger")
else:
view_menu = security_manager.find_view_menu(item.get_perm())
pvs = (
@@ -612,6 +617,7 @@ class DeleteMixin: # pylint: disable=too-few-public-methods
db.session.commit() # pylint: disable=consider-using-transaction
flash(*self.datamodel.message)
self.update_redirect()
@action(
@@ -624,7 +630,7 @@ class DeleteMixin: # pylint: disable=too-few-public-methods
try:
self.pre_delete(item)
except Exception as ex: # pylint: disable=broad-except
logger.error("Pre-delete error: %s", str(ex))
flash(str(ex), "danger")
else:
self._delete(item.id)
self.update_redirect()

View File

@@ -28,6 +28,7 @@ from urllib import parse
from flask import (
abort,
current_app as app,
flash,
g,
redirect,
request,
@@ -108,6 +109,7 @@ from superset.views.utils import (
get_form_data,
get_viz,
loads_request_json,
redirect_with_flash,
sanitize_datasource_data,
)
from superset.viz import BaseViz
@@ -413,6 +415,7 @@ class Superset(BaseSupersetView):
initial_form_data = {}
form_data_key = request.args.get("form_data_key")
if key is not None:
command = GetExplorePermalinkCommand(key)
try:
@@ -427,10 +430,9 @@ class Superset(BaseSupersetView):
_("Error: permalink state not found"), status=404
)
except (ChartNotFoundError, ExplorePermalinkGetFailedError) as ex:
return json_error_response(
__("Error: %(msg)s", msg=ex.message), status=404
)
elif form_data_key := request.args.get("form_data_key"):
flash(__("Error: %(msg)s", msg=ex.message), "danger")
return redirect(url_for("SliceModelView.list"))
elif form_data_key:
parameters = CommandParameters(key=form_data_key)
value = GetFormDataCommand(parameters).run()
initial_form_data = json.loads(value) if value else {}
@@ -440,8 +442,18 @@ class Superset(BaseSupersetView):
dataset_id = request.args.get("dataset_id")
if slice_id:
initial_form_data["slice_id"] = slice_id
if form_data_key:
flash(
_("Form data not found in cache, reverting to chart metadata.")
)
elif dataset_id:
initial_form_data["datasource"] = f"{dataset_id}__table"
if form_data_key:
flash(
_(
"Form data not found in cache, reverting to dataset metadata." # noqa: E501
)
)
form_data, slc = get_form_data(
use_slice_data=True, initial_form_data=initial_form_data
@@ -614,9 +626,13 @@ class Superset(BaseSupersetView):
if action == "saveas" and slice_add_perm:
ChartDAO.create(slc)
db.session.commit() # pylint: disable=consider-using-transaction
msg = _("Chart [{}] has been saved").format(slc.slice_name)
flash(msg, "success")
elif action == "overwrite" and slice_overwrite_perm:
ChartDAO.update(slc)
db.session.commit() # pylint: disable=consider-using-transaction
msg = _("Chart [{}] has been overwritten").format(slc.slice_name)
flash(msg, "success")
# Adding slice to a dashboard if requested
dash: Dashboard | None = None
@@ -638,6 +654,13 @@ class Superset(BaseSupersetView):
_("You don't have the rights to alter this dashboard"),
status=403,
)
flash(
_("Chart [{}] was added to dashboard [{}]").format(
slc.slice_name, dash.dashboard_title
),
"success",
)
elif new_dashboard_name:
# Creating and adding to a new dashboard
# check create dashboard permissions
@@ -652,6 +675,12 @@ class Superset(BaseSupersetView):
dashboard_title=request.args.get("new_dashboard_name"),
owners=[g.user] if g.user else [],
)
flash(
_(
"Dashboard [{}] just got created and chart [{}] was added to it"
).format(dash.dashboard_title, slc.slice_name),
"success",
)
if dash and slc not in dash.slices:
dash.slices.append(slc)
@@ -769,9 +798,19 @@ class Superset(BaseSupersetView):
try:
dashboard.raise_for_access()
except SupersetSecurityException:
# Return 404 to avoid revealing dashboard existence
return Response(status=404)
except SupersetSecurityException as ex:
# anonymous users should get the login screen, others should go to dashboard list # noqa: E501
if g.user is None or g.user.is_anonymous:
redirect_url = f"{appbuilder.get_url_for_login}?next={request.url}"
warn_msg = "Users must be logged in to view this dashboard."
else:
redirect_url = url_for("DashboardModelView.list")
warn_msg = utils.error_msg_from_exception(ex)
return redirect_with_flash(
url=redirect_url,
message=warn_msg,
category="danger",
)
add_extra_log_payload(
dashboard_id=dashboard.id,
dashboard_version="v2",
@@ -802,8 +841,12 @@ class Superset(BaseSupersetView):
) -> FlaskResponse:
try:
value = GetDashboardPermalinkCommand(key).run()
except (DashboardPermalinkGetFailedError, DashboardAccessDeniedError) as ex:
return json_error_response(__("Error: %(msg)s", msg=ex.message), status=404)
except DashboardPermalinkGetFailedError as ex:
flash(__("Error: %(msg)s", msg=ex.message), "danger")
return redirect(url_for("DashboardModelView.list"))
except DashboardAccessDeniedError as ex:
flash(__("Error: %(msg)s", msg=ex.message), "danger")
return redirect(url_for("DashboardModelView.list"))
if not value:
return json_error_response(_("permalink state not found"), status=404)

View File

@@ -30,5 +30,6 @@ class SqlJsonPayloadSchema(Schema):
templateParams = fields.String(allow_none=True) # noqa: N815
tmp_table_name = fields.String(allow_none=True)
select_as_cta = fields.Boolean(allow_none=True)
json = fields.Boolean(allow_none=True)
runAsync = fields.Boolean(allow_none=True) # noqa: N815
expand_data = fields.Boolean(allow_none=True)

View File

@@ -22,11 +22,12 @@ from typing import Any, Callable, DefaultDict, Optional, Union
import msgpack
import pyarrow as pa
from flask import current_app as app, g, has_request_context, request
from flask import current_app as app, flash, g, has_request_context, redirect, request
from flask_appbuilder.security.sqla import models as ab_models
from flask_appbuilder.security.sqla.models import User
from flask_babel import _
from sqlalchemy.exc import NoResultFound
from werkzeug.wrappers.response import Response
from superset import dataframe, db, result_set, viz
from superset.common.db_query_status import QueryStatus
@@ -550,3 +551,8 @@ def get_cta_schema_name(
if not func:
return None
return func(database, user, schema, sql)
def redirect_with_flash(url: str, message: str, category: str) -> Response:
flash(message=message, category=category)
return redirect(url)

View File

@@ -108,7 +108,7 @@ class TestDashboardRoleBasedSecurity(BaseTestDashboardSecurity):
# act
response = self.get_dashboard_view_response(dashboard_to_access)
assert response.status_code == 404
assert response.status_code == 302
request_payload = get_query_context("birth_names")
rv = self.post_assert_metric(CHART_DATA_URI, request_payload, "data")
@@ -129,7 +129,7 @@ class TestDashboardRoleBasedSecurity(BaseTestDashboardSecurity):
response = self.get_dashboard_view_response(dashboard_to_access)
# assert
assert response.status_code == 404
assert response.status_code == 302
# post
revoke_access_to_dashboard(dashboard_to_access, new_role) # noqa: F405
@@ -147,9 +147,9 @@ class TestDashboardRoleBasedSecurity(BaseTestDashboardSecurity):
dashboard = create_dashboard_to_db(published=True, slices=[slice])
self.login(GAMMA_USERNAME)
# assert 404 on regular rbac access denied (prevents information leakage)
# assert redirect on regular rbac access denied
response = self.get_dashboard_view_response(dashboard)
assert response.status_code == 404
assert response.status_code == 302
request_payload = get_query_context("birth_names")
rv = self.post_assert_metric(CHART_DATA_URI, request_payload, "data")
@@ -221,7 +221,7 @@ class TestDashboardRoleBasedSecurity(BaseTestDashboardSecurity):
response = self.get_dashboard_view_response(dashboard_to_access)
# assert
assert response.status_code == 404
assert response.status_code == 302
@pytest.mark.usefixtures("public_role_like_gamma")
def test_get_dashboard_view__public_user_with_dashboard_permission_can_not_access_draft( # noqa: E501
@@ -234,7 +234,7 @@ class TestDashboardRoleBasedSecurity(BaseTestDashboardSecurity):
response = self.get_dashboard_view_response(dashboard_to_access)
# assert
assert response.status_code == 404
assert response.status_code == 302
# post
revoke_access_to_dashboard(dashboard_to_access, "Public") # noqa: F405

View File

@@ -47,8 +47,16 @@ from tests.unit_tests.fixtures.common import dttm # noqa: F401
@pytest.mark.parametrize(
"native_type,sqla_type,attrs,generic_type,is_dttm",
[
# Numeric
# Boolean types - MySQL uses TINYINT(1) for BOOLEAN
("TINYINT(1)", TINYINT, None, GenericDataType.BOOLEAN, False),
("tinyint(1)", TINYINT, None, GenericDataType.BOOLEAN, False),
("BOOLEAN", TINYINT, None, GenericDataType.BOOLEAN, False),
("BOOL", TINYINT, None, GenericDataType.BOOLEAN, False),
# Numeric (ensure TINYINT without (1) remains numeric)
("TINYINT", TINYINT, None, GenericDataType.NUMERIC, False),
("TINYINT(2)", TINYINT, None, GenericDataType.NUMERIC, False),
("TINYINT(4)", TINYINT, None, GenericDataType.NUMERIC, False),
("TINYINT UNSIGNED", TINYINT, None, GenericDataType.NUMERIC, False),
("SMALLINT", types.SmallInteger, None, GenericDataType.NUMERIC, False),
("MEDIUMINT", MEDIUMINT, None, GenericDataType.NUMERIC, False),
("INT", INTEGER, None, GenericDataType.NUMERIC, False),
@@ -77,9 +85,11 @@ def test_get_column_spec(
generic_type: GenericDataType,
is_dttm: bool,
) -> None:
from superset.db_engine_specs.mysql import MySQLEngineSpec as spec # noqa: N813
from superset.db_engine_specs.mysql import MySQLEngineSpec
assert_column_spec(spec, native_type, sqla_type, attrs, generic_type, is_dttm)
assert_column_spec(
MySQLEngineSpec, native_type, sqla_type, attrs, generic_type, is_dttm
)
@pytest.mark.parametrize(
@@ -98,9 +108,9 @@ def test_convert_dttm(
expected_result: Optional[str],
dttm: datetime, # noqa: F811
) -> None:
from superset.db_engine_specs.mysql import MySQLEngineSpec as spec # noqa: N813
from superset.db_engine_specs.mysql import MySQLEngineSpec
assert_convert_dttm(spec, target_type, expected_result, dttm)
assert_convert_dttm(MySQLEngineSpec, target_type, expected_result, dttm)
@pytest.mark.parametrize(
@@ -255,10 +265,153 @@ def test_column_type_mutator(
description: list[Any],
expected_result: list[tuple[Any, ...]],
):
from superset.db_engine_specs.mysql import MySQLEngineSpec as spec # noqa: N813
from superset.db_engine_specs.mysql import MySQLEngineSpec
mock_cursor = Mock()
mock_cursor.fetchall.return_value = data
mock_cursor.description = description
assert spec.fetch_data(mock_cursor) == expected_result
assert MySQLEngineSpec.fetch_data(mock_cursor) == expected_result
def test_fetch_data_boolean_integers() -> None:
"""Test fetch_data converts integer 0/1 to boolean False/True."""
from superset.db_engine_specs.mysql import MySQLEngineSpec
mock_cursor = Mock()
mock_cursor.fetchall.return_value = [(1, "admin"), (0, "user")]
# TINYINT(1) column: type_code=1 (FIELD_TYPE.TINY), display_size=1
mock_cursor.description = [
("is_active", 1, 1, 4, 3, 0, False), # TINYINT(1) - should convert
("role", 254, 255, 0, 0, 0, False), # VARCHAR - should not convert
]
result = MySQLEngineSpec.fetch_data(mock_cursor)
expected = [(True, "admin"), (False, "user")]
assert result == expected
def test_fetch_data_boolean_strings() -> None:
"""Test fetch_data converts string "0"/"1" to boolean False/True."""
from superset.db_engine_specs.mysql import MySQLEngineSpec
mock_cursor = Mock()
mock_cursor.fetchall.return_value = [("1", "admin"), ("0", "user")]
mock_cursor.description = [
("is_active", 1, 1, 4, 3, 0, False), # TINYINT(1) - should convert
("role", 254, 255, 0, 0, 0, False), # VARCHAR - should not convert
]
result = MySQLEngineSpec.fetch_data(mock_cursor)
expected = [(True, "admin"), (False, "user")]
assert result == expected
def test_fetch_data_boolean_bytes() -> None:
"""Test fetch_data converts bytes b"0"/b"1" to boolean False/True."""
from superset.db_engine_specs.mysql import MySQLEngineSpec
mock_cursor = Mock()
mock_cursor.fetchall.return_value = [(b"1", "admin"), (b"0", "user")]
mock_cursor.description = [
("is_active", 1, 1, 4, 3, 0, False), # TINYINT(1) - should convert
("role", 254, 255, 0, 0, 0, False), # VARCHAR - should not convert
]
result = MySQLEngineSpec.fetch_data(mock_cursor)
expected = [(True, "admin"), (False, "user")]
assert result == expected
def test_fetch_data_boolean_decimals() -> None:
"""Test fetch_data converts Decimal 0/1 to boolean False/True."""
from superset.db_engine_specs.mysql import MySQLEngineSpec
mock_cursor = Mock()
mock_cursor.fetchall.return_value = [
(Decimal("1"), "admin"),
(Decimal("0"), "user"),
]
mock_cursor.description = [
("is_active", 1, 1, 4, 3, 0, False), # TINYINT(1) - should convert
("role", 254, 255, 0, 0, 0, False), # VARCHAR - should not convert
]
result = MySQLEngineSpec.fetch_data(mock_cursor)
expected = [(True, "admin"), (False, "user")]
assert result == expected
def test_fetch_data_boolean_with_nulls() -> None:
"""Test fetch_data handles NULL values correctly in boolean columns."""
from superset.db_engine_specs.mysql import MySQLEngineSpec
mock_cursor = Mock()
mock_cursor.fetchall.return_value = [(1, "admin"), (None, "user"), (0, "guest")]
mock_cursor.description = [
("is_active", 1, 1, 4, 3, 0, True), # TINYINT(1) with nulls - should convert
("role", 254, 255, 0, 0, 0, False), # VARCHAR - should not convert
]
result = MySQLEngineSpec.fetch_data(mock_cursor)
expected = [(True, "admin"), (None, "user"), (False, "guest")]
assert result == expected
def test_fetch_data_boolean_mixed_columns() -> None:
"""Test fetch_data with boolean and non-boolean columns together."""
from superset.db_engine_specs.mysql import MySQLEngineSpec
mock_cursor = Mock()
mock_cursor.fetchall.return_value = [(1, 50, 0), (0, 100, 1)]
mock_cursor.description = [
("is_admin", 1, 1, 4, 3, 0, False), # TINYINT(1) - should convert
("count", 3, 11, 4, 10, 0, False), # INT - should not convert
("is_active", 1, 1, 4, 3, 0, False), # TINYINT(1) - should convert
]
result = MySQLEngineSpec.fetch_data(mock_cursor)
expected = [(True, 50, False), (False, 100, True)]
assert result == expected
def test_fetch_data_no_boolean_columns() -> None:
"""Test fetch_data passes through data when no boolean columns present."""
from superset.db_engine_specs.mysql import MySQLEngineSpec
mock_cursor = Mock()
mock_cursor.fetchall.return_value = [(100, "test"), (200, "data")]
mock_cursor.description = [
("count", 3, 11, 4, 10, 0, False), # INT - should not convert
("name", 254, 255, 0, 0, 0, False), # VARCHAR - should not convert
]
result = MySQLEngineSpec.fetch_data(mock_cursor)
expected = [(100, "test"), (200, "data")]
assert result == expected
def test_fetch_data_boolean_mixed_driver_types() -> None:
"""Test fetch_data with different driver return types in same dataset."""
from superset.db_engine_specs.mysql import MySQLEngineSpec
mock_cursor = Mock()
# Mix of integers, strings, bytes, decimals for boolean values
mock_cursor.fetchall.return_value = [
(1, "0", b"1"), # True, False, True
(0, "1", b"0"), # False, True, False
(Decimal(1), None, 0), # True, None, False
]
mock_cursor.description = [
("bool_int", 1, 1, 4, 3, 0, False), # TINYINT(1) - integers
("bool_str", 1, 1, 4, 3, 0, False), # TINYINT(1) - strings
("bool_bytes", 1, 1, 4, 3, 0, False), # TINYINT(1) - bytes
]
result = MySQLEngineSpec.fetch_data(mock_cursor)
expected = [
(True, False, True),
(False, True, False),
(True, None, False),
]
assert result == expected