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
71 changed files with 1755 additions and 2461 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

@@ -18,6 +18,7 @@
*/
import {
forwardRef,
Key,
ReactNode,
RefObject,
useCallback,
@@ -42,18 +43,18 @@ import {
useTheme,
} from '@superset-ui/core';
import { RootState } from 'src/dashboard/types';
import { MenuItem } from '@superset-ui/core/components/Menu';
import { Menu } from '@superset-ui/core/components/Menu';
import { usePermissions } from 'src/hooks/usePermissions';
import { Dropdown } from '@superset-ui/core/components';
import { updateDataMask } from 'src/dataMask/actions';
import DrillByModal from 'src/components/Chart/DrillBy/DrillByModal';
import { useDatasetDrillInfo } from 'src/hooks/apiResources/datasets';
import { ResourceStatus } from 'src/hooks/apiResources/apiResources';
import { useDrillDetailMenuItems } from '../useDrillDetailMenuItems';
import { DrillDetailMenuItems } from '../DrillDetail';
import { getMenuAdjustedY } from '../utils';
import { DrillBySubmenu } from '../DrillBy/DrillBySubmenu';
import DrillDetailModal from '../DrillDetail/DrillDetailModal';
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
import { DrillByMenuItems } from '../DrillBy/DrillByMenuItems';
import DrillDetailModal from '../DrillDetail/DrillDetailModal';
export enum ContextMenuItem {
CrossFilter,
@@ -93,8 +94,8 @@ const ChartContextMenu = (
}: ChartContextMenuProps,
ref: RefObject<ChartContextMenuRef>,
) => {
const dispatch = useDispatch();
const theme = useTheme();
const dispatch = useDispatch();
const { canDrillToDetail, canDrillBy, canDownload } = usePermissions();
const crossFiltersEnabled = useSelector<RootState, boolean>(
@@ -103,6 +104,7 @@ const ChartContextMenu = (
const dashboardId = useSelector<RootState, number>(
({ dashboardInfo }) => dashboardInfo.id,
);
const [openKeys, setOpenKeys] = useState<Key[]>([]);
const [modalFilters, setFilters] = useState<BinaryQueryObjectFilterClause[]>(
[],
@@ -158,6 +160,7 @@ const ChartContextMenu = (
const closeContextMenu = useCallback(() => {
setVisible(false);
setOpenKeys([]);
onClose();
}, [onClose]);
@@ -174,7 +177,7 @@ const ChartContextMenu = (
setShowDrillByModal(false);
}, []);
const menuItems: MenuItem[] = [];
const menuItems: React.JSX.Element[] = [];
const showDrillToDetail =
isFeatureEnabled(FeatureFlag.DrillToDetail) &&
@@ -261,20 +264,6 @@ const ChartContextMenu = (
itemsCount = 1; // "No actions" appears if no actions in menu
}
const drillDetailMenuItems = useDrillDetailMenuItems({
formData: drillFormData,
filters: filters?.drillToDetail,
setFilters,
isContextMenu: true,
contextMenuY: clientY,
onSelection,
submenuIndex: showCrossFilters ? 2 : 1,
setShowModal: setDrillModalIsOpen,
dataset: filteredDataset,
isLoadingDataset,
...(additionalConfig?.drillToDetail || {}),
});
if (showCrossFilters) {
const isCrossFilterDisabled =
!isCrossFilteringSupportedByChart ||
@@ -316,65 +305,74 @@ const ChartContextMenu = (
</>
);
}
menuItems.push(
{
key: 'cross-filtering-menu-item',
label: filters?.crossFilter?.isCurrentValueSelected ? (
t('Remove cross-filter')
) : (
<span>
{t('Add cross-filter')}
<MenuItemTooltip
title={crossFilteringTooltipTitle}
color={!isCrossFilterDisabled ? theme.colorIcon : undefined}
/>
</span>
),
disabled: isCrossFilterDisabled,
onClick: () => {
if (filters?.crossFilter) {
dispatch(updateDataMask(id, filters.crossFilter.dataMask));
}
},
},
...(itemsCount > 1
? [{ key: 'divider-1', type: 'divider' as const }]
: []),
<>
<Menu.Item
key="cross-filtering-menu-item"
disabled={isCrossFilterDisabled}
onClick={() => {
if (filters?.crossFilter) {
dispatch(updateDataMask(id, filters.crossFilter.dataMask));
}
}}
>
{filters?.crossFilter?.isCurrentValueSelected ? (
t('Remove cross-filter')
) : (
<div>
{t('Add cross-filter')}
<MenuItemTooltip
title={crossFilteringTooltipTitle}
color={!isCrossFilterDisabled ? theme.colorIcon : undefined}
/>
</div>
)}
</Menu.Item>
{itemsCount > 1 && <Menu.Divider />}
</>,
);
}
if (showDrillToDetail) {
menuItems.push(...drillDetailMenuItems);
menuItems.push(
<DrillDetailMenuItems
formData={drillFormData}
filters={filters?.drillToDetail}
setFilters={setFilters}
isContextMenu
contextMenuY={clientY}
onSelection={onSelection}
submenuIndex={showCrossFilters ? 2 : 1}
setShowModal={setDrillModalIsOpen}
dataset={filteredDataset}
isLoadingDataset={isLoadingDataset}
{...(additionalConfig?.drillToDetail || {})}
/>,
);
}
if (showDrillBy) {
if (menuItems.length > 0) {
menuItems.push({ key: 'divider-drill-by', type: 'divider' as const });
let submenuIndex = 0;
if (showCrossFilters) {
submenuIndex += 1;
}
const hasDrillBy = enhancedFilters?.drillBy?.groupbyFieldName;
const handlesDimensionContextMenu = getChartMetadataRegistry()
.get(formData.viz_type)
?.behaviors.find(behavior => behavior === Behavior.DrillBy);
const isDrillByDisabled = !handlesDimensionContextMenu || !hasDrillBy;
// Add a custom render component for DrillBy submenu to support react-window
menuItems.push({
key: 'drill-by-submenu',
disabled: isDrillByDisabled,
label: (
<DrillBySubmenu
drillByConfig={enhancedFilters?.drillBy}
onSelection={onSelection}
onCloseMenu={closeContextMenu}
formData={formData}
onDrillBy={handleDrillBy}
dataset={filteredDataset}
isLoadingDataset={isLoadingDataset}
{...(additionalConfig?.drillBy || {})}
/>
),
});
if (showDrillToDetail) {
submenuIndex += 2;
}
menuItems.push(
<DrillByMenuItems
drillByConfig={enhancedFilters?.drillBy}
onSelection={onSelection}
onCloseMenu={closeContextMenu}
formData={formData}
contextMenuY={clientY}
submenuIndex={submenuIndex}
open={openKeys.includes('drill-by-submenu')}
key="drill-by-submenu"
onDrillBy={handleDrillBy}
dataset={filteredDataset}
isLoadingDataset={isLoadingDataset}
{...(additionalConfig?.drillBy || {})}
/>,
);
}
const open = useCallback(
@@ -406,22 +404,30 @@ const ChartContextMenu = (
return ReactDOM.createPortal(
<>
<Dropdown
menu={{
items:
menuItems.length > 0
? menuItems
: [{ key: 'no-actions', label: t('No actions'), disabled: true }],
onClick: () => {
setVisible(false);
onClose();
},
}}
dropdownRender={menu => (
<div data-test="chart-context-menu">{menu}</div>
popupRender={() => (
<Menu
className="chart-context-menu"
data-test="chart-context-menu"
onOpenChange={setOpenKeys}
onClick={() => {
setVisible(false);
setOpenKeys([]);
onClose();
}}
>
{menuItems.length ? (
menuItems
) : (
<Menu.Item disabled>{t('No actions')}</Menu.Item>
)}
</Menu>
)}
trigger={['click']}
onOpenChange={value => {
setVisible(value);
if (!value) {
setOpenKeys([]);
}
}}
open={visible}
>

View File

@@ -30,8 +30,9 @@ import {
waitFor,
} from 'spec/helpers/testing-library';
import chartQueries, { sliceId } from 'spec/fixtures/mockChartQueries';
import { Menu } from '@superset-ui/core/components/Menu';
import { supersetGetCache } from 'src/utils/cachedSupersetGet';
import { DrillBySubmenu, DrillBySubmenuProps } from './DrillBySubmenu';
import { DrillByMenuItems, DrillByMenuItemsProps } from './DrillByMenuItems';
/* eslint jest/expect-expect: ["warn", { "assertFunctionNames": ["expect*"] }] */
@@ -78,29 +79,37 @@ const defaultFilters = [
},
];
const renderSubmenu = ({
const renderMenu = ({
formData = defaultFormData,
drillByConfig = { filters: defaultFilters, groupbyFieldName: 'groupby' },
dataset = mockDataset,
...rest
}: Partial<DrillBySubmenuProps>) =>
}: Partial<DrillByMenuItemsProps>) =>
render(
<DrillBySubmenu
formData={formData ?? defaultFormData}
drillByConfig={drillByConfig}
dataset={dataset}
{...rest}
/>,
<Menu forceSubMenuRender>
<DrillByMenuItems
formData={formData ?? defaultFormData}
drillByConfig={drillByConfig}
dataset={dataset}
open
{...rest}
/>
</Menu>,
{ useRouter: true, useRedux: true },
);
const expectDrillByDisabled = async (tooltipContent: string) => {
const drillByButton = screen.getByRole('button', { name: /drill by/i });
expect(drillByButton).toBeInTheDocument();
expect(drillByButton).toBeVisible();
expect(drillByButton).toHaveAttribute('tabindex', '-1');
const drillByMenuItem = screen
.getAllByRole('menuitem')
.find(menuItem => within(menuItem).queryByText('Drill by'));
const tooltipTrigger = within(drillByButton).getByTestId('tooltip-trigger');
expect(drillByMenuItem).toBeDefined();
expect(drillByMenuItem).toBeVisible();
expect(drillByMenuItem).toHaveAttribute('aria-disabled', 'true');
const tooltipTrigger = within(drillByMenuItem!).getByTestId(
'tooltip-trigger',
);
userEvent.hover(tooltipTrigger as HTMLElement);
const tooltip = await screen.findByRole('tooltip', { name: tooltipContent });
@@ -108,17 +117,20 @@ const expectDrillByDisabled = async (tooltipContent: string) => {
};
const expectDrillByEnabled = async () => {
const drillByButton = screen.getByRole('button', { name: /drill by/i });
expect(drillByButton).toBeInTheDocument();
expect(drillByButton).not.toHaveAttribute('tabindex', '-1');
const tooltipTrigger = within(drillByButton).queryByTestId('tooltip-trigger');
const drillByMenuItem = screen.getByRole('menuitem', {
name: 'Drill by',
});
expect(drillByMenuItem).toBeInTheDocument();
await waitFor(() =>
expect(drillByMenuItem).not.toHaveAttribute('aria-disabled'),
);
const tooltipTrigger =
within(drillByMenuItem).queryByTestId('tooltip-trigger');
expect(tooltipTrigger).not.toBeInTheDocument();
userEvent.hover(drillByButton);
const popover = await screen.findByRole('menu');
expect(popover).toBeInTheDocument();
userEvent.hover(within(drillByMenuItem).getByText('Drill by'));
const drillBySubmenus = await screen.findAllByTestId('drill-by-submenu');
expect(drillBySubmenus[0]).toBeInTheDocument();
};
getChartMetadataRegistry().registerValue(
@@ -137,7 +149,7 @@ afterEach(() => {
});
test('render disabled menu item for unsupported chart', async () => {
renderSubmenu({
renderMenu({
formData: { ...defaultFormData, viz_type: 'unsupported_viz' },
});
await expectDrillByDisabled(
@@ -146,75 +158,89 @@ test('render disabled menu item for unsupported chart', async () => {
});
test('render enabled menu item for supported chart, no filters', async () => {
renderSubmenu({
drillByConfig: { filters: [], groupbyFieldName: 'groupby' },
});
renderMenu({ drillByConfig: { filters: [], groupbyFieldName: 'groupby' } });
await expectDrillByEnabled();
});
test('render disabled menu item for supported chart, no columns', async () => {
const emptyDataset = { ...mockDataset, columns: [], drillable_columns: [] };
renderSubmenu({ dataset: emptyDataset });
renderMenu({ dataset: emptyDataset });
await expectDrillByEnabled();
const noColumnsText = await screen.findByText('No columns found');
expect(noColumnsText).toBeInTheDocument();
screen.getByText('No columns found');
});
test('render menu item with submenu without searchbox', async () => {
const slicedColumns = defaultColumns.slice(0, 1); // Use only 1 column to avoid search box
const slicedColumns = defaultColumns.slice(0, 9);
const datasetWithSlicedColumns = {
...mockDataset,
columns: slicedColumns,
drillable_columns: slicedColumns,
};
renderSubmenu({ dataset: datasetWithSlicedColumns });
renderMenu({ dataset: datasetWithSlicedColumns });
await expectDrillByEnabled();
// Check that the column appears in the popover
const col1Element = await screen.findByText('col1');
expect(col1Element).toBeInTheDocument();
// Should not have search box for small number of columns
// Check that each column appears in the drill-by submenu
slicedColumns.forEach(column => {
const submenus = screen.getAllByTestId('drill-by-submenu');
const submenu = submenus[0]; // Use the first submenu
expect(within(submenu).getByText(column.column_name)).toBeInTheDocument();
});
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
});
// Add global timeout for all tests
jest.setTimeout(20000);
test('render menu item with submenu and searchbox', async () => {
renderSubmenu({ dataset: mockDataset });
renderMenu({ dataset: mockDataset });
await expectDrillByEnabled();
// Wait for first column to ensure menu is loaded
await screen.findByText('col1');
// Wait for all columns to be visible
await waitFor(
() => {
const submenus = screen.getAllByTestId('drill-by-submenu');
const submenu = submenus[0];
defaultColumns.forEach(column => {
expect(
within(submenu).getByText(column.column_name),
).toBeInTheDocument();
});
},
{ timeout: 10000 },
);
// Then check all columns are visible
defaultColumns.forEach(column => {
expect(screen.getByText(column.column_name)).toBeInTheDocument();
});
const searchbox = screen.getByPlaceholderText('Search columns');
const searchbox = await waitFor(
() => screen.getAllByPlaceholderText('Search columns')[0],
);
expect(searchbox).toBeInTheDocument();
userEvent.type(searchbox, 'col1');
const expectedFilteredColumnNames = ['col1', 'col10', 'col11'];
// Wait for filtering to take effect by checking for first filtered item
// Wait for filtered results
await waitFor(() => {
// Check that non-matching columns are not visible
expect(screen.queryByText('col2')).not.toBeInTheDocument();
const submenus = screen.getAllByTestId('drill-by-submenu');
const submenu = submenus[0];
expectedFilteredColumnNames.forEach(colName => {
expect(within(submenu).getByText(colName)).toBeInTheDocument();
});
});
// Then verify all expected columns are visible
expectedFilteredColumnNames.forEach(colName => {
expect(screen.getByText(colName)).toBeInTheDocument();
});
const submenus = screen.getAllByTestId('drill-by-submenu');
const submenu = submenus[0];
// Check that non-matching columns are not visible
defaultColumns
.filter(col => !expectedFilteredColumnNames.includes(col.column_name))
.forEach(col => {
expect(screen.queryByText(col.column_name)).not.toBeInTheDocument();
expect(
within(submenu).queryByText(col.column_name),
).not.toBeInTheDocument();
});
expectedFilteredColumnNames.forEach(colName => {
expect(within(submenu).getByText(colName)).toBeInTheDocument();
});
});
test('Do not display excluded column in the menu', async () => {
@@ -226,7 +252,7 @@ test('Do not display excluded column in the menu', async () => {
...mockDataset,
drillable_columns: filteredColumns,
};
renderSubmenu({
renderMenu({
dataset: datasetWithFilteredColumns,
excludedColumns: excludedColNames.map(colName => ({
column_name: colName,
@@ -235,24 +261,32 @@ test('Do not display excluded column in the menu', async () => {
await expectDrillByEnabled();
// Wait for first column to ensure menu is loaded
await screen.findByText('col1');
// Then check all non-excluded columns are visible
defaultColumns
.filter(column => !excludedColNames.includes(column.column_name))
.forEach(column => {
expect(screen.getByText(column.column_name)).toBeInTheDocument();
});
// Wait for menu items to be loaded
await waitFor(
() => {
const submenus = screen.getAllByTestId('drill-by-submenu');
const submenu = submenus[0];
defaultColumns
.filter(column => !excludedColNames.includes(column.column_name))
.forEach(column => {
expect(
within(submenu).getByText(column.column_name),
).toBeInTheDocument();
});
},
{ timeout: 10000 },
);
const submenus = screen.getAllByTestId('drill-by-submenu');
const submenu = submenus[0];
excludedColNames.forEach(colName => {
expect(screen.queryByText(colName)).not.toBeInTheDocument();
expect(within(submenu).queryByText(colName)).not.toBeInTheDocument();
});
});
test('When menu item is clicked, call onSelection with clicked column and drill by filters', async () => {
const onSelectionMock = jest.fn();
renderSubmenu({
renderMenu({
dataset: mockDataset,
onSelection: onSelectionMock,
});
@@ -260,7 +294,11 @@ test('When menu item is clicked, call onSelection with clicked column and drill
await expectDrillByEnabled();
// Wait for col1 to be visible before clicking
const col1Element = await screen.findByText('col1');
const col1Element = await waitFor(() => {
const submenus = screen.getAllByTestId('drill-by-submenu');
const submenu = submenus[0];
return within(submenu).getByText('col1');
});
userEvent.click(col1Element);
expect(onSelectionMock).toHaveBeenCalledWith(
@@ -271,10 +309,3 @@ test('When menu item is clicked, call onSelection with clicked column and drill
{ filters: defaultFilters, groupbyFieldName: 'groupby' },
);
});
test('matrixify_enable_vertical_layout should not render component', () => {
const { container } = renderSubmenu({
formData: { ...defaultFormData, matrixify_enable_vertical_layout: true },
});
expect(container).toBeEmptyDOMElement();
});

View File

@@ -0,0 +1,277 @@
/**
* 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 {
CSSProperties,
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Menu } from '@superset-ui/core/components/Menu';
import {
BaseFormData,
Behavior,
Column,
ContextMenuFilters,
css,
ensureIsArray,
getChartMetadataRegistry,
t,
useTheme,
} from '@superset-ui/core';
import { Constants, Input, Loading } from '@superset-ui/core/components';
import { debounce } from 'lodash';
import { FixedSizeList as List } from 'react-window';
import { Icons } from '@superset-ui/core/components/Icons';
import { InputRef } from 'antd';
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
import { getSubmenuYOffset } from '../utils';
import { VirtualizedMenuItem } from '../MenuItemWithTruncation';
import { Dataset } from '../types';
const SUBMENU_HEIGHT = 200;
const SHOW_COLUMNS_SEARCH_THRESHOLD = 10;
const SEARCH_INPUT_HEIGHT = 48;
export interface DrillByMenuItemsProps {
drillByConfig?: ContextMenuFilters['drillBy'];
formData: BaseFormData & { [key: string]: any };
contextMenuY?: number;
submenuIndex?: number;
onSelection?: (...args: any) => void;
onClick?: (event: MouseEvent) => void;
onCloseMenu?: () => void;
openNewModal?: boolean;
excludedColumns?: Column[];
open: boolean;
onDrillBy?: (column: Column, dataset: Dataset) => void;
dataset?: Dataset;
isLoadingDataset?: boolean;
}
export const DrillByMenuItems = ({
drillByConfig,
formData,
contextMenuY = 0,
submenuIndex = 0,
onSelection = () => {},
onClick = () => {},
onCloseMenu = () => {},
excludedColumns,
openNewModal = true,
open,
onDrillBy,
dataset,
isLoadingDataset = false,
...rest
}: DrillByMenuItemsProps) => {
const theme = useTheme();
const [searchInput, setSearchInput] = useState('');
const [debouncedSearchInput, setDebouncedSearchInput] = useState('');
const ref = useRef<InputRef>(null);
const columns = dataset ? ensureIsArray(dataset.drillable_columns) : [];
const showSearch = columns.length > SHOW_COLUMNS_SEARCH_THRESHOLD;
const handleSelection = useCallback(
(event, column) => {
onClick(event);
onSelection(column, drillByConfig);
if (openNewModal && onDrillBy && dataset) {
onDrillBy(column, dataset);
}
onCloseMenu();
},
[drillByConfig, onClick, onSelection, openNewModal, onDrillBy, dataset],
);
useEffect(() => {
if (open) {
ref.current?.input?.focus({ preventScroll: true });
} else {
// Reset search input when menu is closed
setSearchInput('');
setDebouncedSearchInput('');
}
}, [open]);
const hasDrillBy = drillByConfig?.groupbyFieldName;
const handlesDimensionContextMenu = useMemo(
() =>
getChartMetadataRegistry()
.get(formData.viz_type)
?.behaviors.find(behavior => behavior === Behavior.DrillBy),
[formData.viz_type],
);
const debouncedSetSearchInput = useMemo(
() =>
debounce((value: string) => {
setDebouncedSearchInput(value);
}, Constants.FAST_DEBOUNCE),
[],
);
const handleInput = (value: string) => {
setSearchInput(value);
debouncedSetSearchInput(value);
};
const filteredColumns = useMemo(
() =>
columns.filter(column =>
(column.verbose_name || column.column_name)
.toLowerCase()
.includes(debouncedSearchInput.toLowerCase()),
),
[columns, debouncedSearchInput],
);
const submenuYOffset = useMemo(
() =>
getSubmenuYOffset(
contextMenuY,
filteredColumns.length || 1,
submenuIndex,
SUBMENU_HEIGHT,
showSearch ? SEARCH_INPUT_HEIGHT : 0,
),
[contextMenuY, filteredColumns.length, submenuIndex, showSearch],
);
let tooltip: ReactNode;
if (!handlesDimensionContextMenu) {
tooltip = t('Drill by is not yet supported for this chart type');
} else if (!hasDrillBy) {
tooltip = t('Drill by is not available for this data point');
}
if (!handlesDimensionContextMenu || !hasDrillBy) {
return (
<Menu.Item key="drill-by-disabled" disabled {...rest}>
<div>
{t('Drill by')}
<MenuItemTooltip title={tooltip} />
</div>
</Menu.Item>
);
}
const Row = ({
index,
data,
style,
}: {
index: number;
data: { columns: Column[] };
style: CSSProperties;
}) => {
const { columns, ...rest } = data;
const column = columns[index];
return (
<VirtualizedMenuItem
tooltipText={column.verbose_name || column.column_name}
onClick={e => handleSelection(e, column)}
style={style}
{...rest}
>
{column.verbose_name || column.column_name}
</VirtualizedMenuItem>
);
};
// Don't render drill by menu items when matrixify is enabled
if (
formData.matrixify_enable_vertical_layout === true ||
formData.matrixify_enable_horizontal_layout === true
) {
return null;
}
return (
<>
<Menu.SubMenu
key="drill-by-submenu"
title={t('Drill by')}
popupClassName="chart-context-submenu"
popupOffset={[0, submenuYOffset]}
{...rest}
>
<div data-test="drill-by-submenu">
{showSearch && (
<Input
ref={ref}
prefix={
<Icons.SearchOutlined
iconSize="l"
iconColor={theme.colorIcon}
/>
}
onChange={e => {
e.stopPropagation();
handleInput(e.target.value);
}}
placeholder={t('Search columns')}
onClick={e => {
// prevent closing menu when clicking on input
e.nativeEvent.stopImmediatePropagation();
}}
allowClear
css={css`
width: auto;
max-width: 100%;
margin: ${theme.sizeUnit * 2}px ${theme.sizeUnit * 3}px;
box-shadow: none;
`}
value={searchInput}
/>
)}
{isLoadingDataset ? (
<div
css={css`
padding: ${theme.sizeUnit * 3}px 0;
`}
>
<Loading position="inline-centered" />
</div>
) : filteredColumns.length ? (
<List
width="100%"
height={SUBMENU_HEIGHT}
itemSize={35}
itemCount={filteredColumns.length}
itemData={{ columns: filteredColumns, ...rest }}
overscanCount={20}
>
{Row}
</List>
) : (
<Menu.Item disabled key="no-drill-by-columns-found" {...rest}>
{t('No columns found')}
</Menu.Item>
)}
</div>
</Menu.SubMenu>
</>
);
};

View File

@@ -1,342 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
CSSProperties,
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
BaseFormData,
Behavior,
Column,
ContextMenuFilters,
css,
ensureIsArray,
getChartMetadataRegistry,
t,
useTheme,
} from '@superset-ui/core';
import {
Constants,
Input,
Loading,
Popover,
Icons,
} from '@superset-ui/core/components';
import { debounce } from 'lodash';
import { FixedSizeList as List } from 'react-window';
import { InputRef } from 'antd';
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
import { VirtualizedMenuItem } from '../MenuItemWithTruncation';
import { Dataset } from '../types';
const SUBMENU_HEIGHT = 200;
const SHOW_COLUMNS_SEARCH_THRESHOLD = 10;
export interface DrillBySubmenuProps {
drillByConfig?: ContextMenuFilters['drillBy'];
formData: BaseFormData & { [key: string]: any };
onSelection?: (...args: any) => void;
onClick?: (event: MouseEvent) => void;
onCloseMenu?: () => void;
openNewModal?: boolean;
excludedColumns?: Column[];
onDrillBy?: (column: Column, dataset: Dataset) => void;
dataset?: Dataset;
isLoadingDataset?: boolean;
}
export const DrillBySubmenu = ({
drillByConfig,
formData,
onSelection = () => {},
onClick = () => {},
onCloseMenu = () => {},
openNewModal = true,
excludedColumns,
onDrillBy,
dataset,
isLoadingDataset = false,
...rest
}: DrillBySubmenuProps) => {
const theme = useTheme();
const [searchInput, setSearchInput] = useState('');
const [debouncedSearchInput, setDebouncedSearchInput] = useState('');
const [popoverOpen, setPopoverOpen] = useState(false);
const ref = useRef<InputRef>(null);
const menuItemRef = useRef<HTMLDivElement>(null);
const columns = useMemo(
() => (dataset ? ensureIsArray(dataset.drillable_columns) : []),
[dataset],
);
const showSearch = columns.length > SHOW_COLUMNS_SEARCH_THRESHOLD;
const handleSelection = useCallback(
(event, column) => {
onClick(event as MouseEvent);
onSelection(column, drillByConfig);
if (openNewModal && onDrillBy && dataset) {
onDrillBy(column, dataset);
}
setPopoverOpen(false);
onCloseMenu();
},
[
drillByConfig,
onClick,
onSelection,
openNewModal,
onDrillBy,
dataset,
onCloseMenu,
],
);
useEffect(() => {
let timeoutId: NodeJS.Timeout;
if (popoverOpen) {
// Small delay to ensure popover is rendered
timeoutId = setTimeout(() => {
ref.current?.input?.focus({ preventScroll: true });
}, 100);
} else {
// Reset search input when menu is closed
setSearchInput('');
setDebouncedSearchInput('');
}
return () => {
if (timeoutId) clearTimeout(timeoutId);
};
}, [popoverOpen]);
const hasDrillBy = drillByConfig?.groupbyFieldName;
const handlesDimensionContextMenu = useMemo(
() =>
getChartMetadataRegistry()
.get(formData.viz_type)
?.behaviors.find(behavior => behavior === Behavior.DrillBy),
[formData.viz_type],
);
const debouncedSetSearchInput = useMemo(
() =>
debounce((value: string) => {
setDebouncedSearchInput(value);
}, Constants.FAST_DEBOUNCE),
[],
);
const handleInput = (value: string) => {
setSearchInput(value);
debouncedSetSearchInput(value);
};
const filteredColumns = useMemo(
() =>
columns
.filter(column => {
// Filter out excluded columns
const excludedColumnNames =
excludedColumns?.map(col => col.column_name) || [];
return !excludedColumnNames.includes(column.column_name);
})
.filter(column =>
(column.verbose_name || column.column_name)
.toLowerCase()
.includes(debouncedSearchInput.toLowerCase()),
),
[columns, debouncedSearchInput, excludedColumns],
);
let tooltip: ReactNode;
if (!handlesDimensionContextMenu) {
tooltip = t('Drill by is not yet supported for this chart type');
} else if (!hasDrillBy) {
tooltip = t('Drill by is not available for this data point');
}
if (
formData.matrixify_enable_vertical_layout === true ||
formData.matrixify_enable_horizontal_layout === true
) {
return null;
}
const isDisabled = !handlesDimensionContextMenu || !hasDrillBy;
const Row = ({
index,
data,
style,
}: {
index: number;
data: { columns: Column[] };
style: CSSProperties;
}) => {
const { columns } = data;
const column = columns[index];
return (
<VirtualizedMenuItem
tooltipText={column.verbose_name || column.column_name}
onClick={e => handleSelection(e, column)}
style={style}
>
{column.verbose_name || column.column_name}
</VirtualizedMenuItem>
);
};
const popoverContent = (
<div
role="menu"
tabIndex={0}
data-test="drill-by-submenu"
css={css`
width: 220px;
max-width: 220px;
.ant-input-affix-wrapper {
margin-bottom: ${theme.sizeUnit * 2}px;
}
`}
onClick={e => e.stopPropagation()}
>
{showSearch && (
<Input
ref={ref}
prefix={
<Icons.SearchOutlined iconSize="l" iconColor={theme.colorIcon} />
}
onChange={e => {
e.stopPropagation();
handleInput(e.target.value);
}}
placeholder={t('Search columns')}
onClick={e => {
// prevent closing menu when clicking on input
e.nativeEvent.stopImmediatePropagation();
}}
allowClear
css={css`
width: 100%;
box-shadow: none;
`}
value={searchInput}
/>
)}
{isLoadingDataset ? (
<div
css={css`
padding: ${theme.sizeUnit * 3}px 0;
`}
>
<Loading position="inline-centered" />
</div>
) : filteredColumns.length ? (
<List
width="100%"
height={SUBMENU_HEIGHT}
itemSize={35}
itemCount={filteredColumns.length}
itemData={{ columns: filteredColumns }}
overscanCount={20}
>
{Row}
</List>
) : (
<div
css={css`
padding: ${theme.sizeUnit * 2}px;
color: ${theme.colorTextDisabled};
text-align: center;
`}
>
{t('No columns found')}
</div>
)}
</div>
);
const menuItem = (
<div
ref={menuItemRef}
role="button"
tabIndex={isDisabled ? -1 : 0}
css={css`
display: flex;
align-items: center;
justify-content: space-between;
cursor: ${isDisabled ? 'not-allowed' : 'pointer'};
color: ${isDisabled ? theme.colorTextDisabled : 'inherit'};
&:hover {
background: transparent;
}
`}
onClick={() => !isDisabled && setPopoverOpen(!popoverOpen)}
onKeyDown={e => {
if (!isDisabled && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
setPopoverOpen(!popoverOpen);
}
}}
>
<span>{t('Drill by')}</span>
{isDisabled ? (
<MenuItemTooltip title={tooltip} />
) : (
<Icons.RightOutlined iconSize="s" iconColor={theme.colorTextTertiary} />
)}
</div>
);
if (isDisabled) {
return menuItem;
}
return (
<Popover
content={popoverContent}
placement="rightTop"
open={popoverOpen}
onOpenChange={setPopoverOpen}
trigger={['hover', 'click']}
arrow={false}
styles={{
root: {
paddingLeft: 0,
},
body: {
padding: theme.sizeUnit * 2,
boxShadow: theme.boxShadow,
borderRadius: theme.borderRadius,
},
}}
{...rest}
>
{menuItem}
</Popover>
);
};

View File

@@ -22,7 +22,6 @@ import {
render,
screen,
userEvent,
waitFor,
within,
} from 'spec/helpers/testing-library';
import setupPlugins from 'src/setup/setupPlugins';
@@ -30,13 +29,15 @@ import { getMockStoreWithNativeFilters } from 'spec/fixtures/mockStore';
import chartQueries, { sliceId } from 'spec/fixtures/mockChartQueries';
import { BinaryQueryObjectFilterClause, VizType } from '@superset-ui/core';
import { Menu } from '@superset-ui/core/components/Menu';
import DrillDetailModal from '../DrillDetail/DrillDetailModal';
import { useDrillDetailMenuItems, DrillDetailMenuItemsProps } from './index';
import DrillDetailMenuItems, {
DrillDetailMenuItemsProps,
} from './DrillDetailMenuItems';
import DrillDetailModal from './DrillDetailModal';
/* eslint jest/expect-expect: ["warn", { "assertFunctionNames": ["expect*"] }] */
jest.mock(
'../DrillDetail/DrillDetailPane',
'./DrillDetailPane',
() =>
({
initialFilters,
@@ -86,17 +87,17 @@ const MockRenderChart = ({
BinaryQueryObjectFilterClause[] | undefined
>(filters);
const menuItems = useDrillDetailMenuItems({
setFilters,
formData: formData ?? defaultFormData,
filters: modalFilters,
isContextMenu,
setShowModal: setShowMenu,
});
return (
<>
<Menu forceSubMenuRender items={menuItems} />
<Menu forceSubMenuRender>
<DrillDetailMenuItems
setFilters={setFilters}
formData={formData ?? defaultFormData}
filters={modalFilters}
isContextMenu={isContextMenu}
setShowModal={setShowMenu}
/>
</Menu>
<DrillDetailModal
chartId={chartId ?? defaultChartId}
@@ -129,10 +130,8 @@ const renderMenu = ({
);
};
const setupMenu = async (filters: BinaryQueryObjectFilterClause[]) => {
const setupMenu = (filters: BinaryQueryObjectFilterClause[]) => {
cleanup();
// Small delay to ensure DOM cleanup is complete
await new Promise(resolve => setTimeout(resolve, 10));
renderMenu({
chartId: defaultChartId,
formData: defaultFormData,
@@ -236,11 +235,11 @@ const expectDrillToDetailByEnabled = async () => {
.getAllByRole('menuitem')
.find(menuItem => within(menuItem).queryByText('Drill to detail by'));
await expectMenuItemEnabled(drillToDetailBy!);
userEvent.hover(drillToDetailBy!);
const submenu = await screen.findByRole('menu', {});
expect(submenu).toBeInTheDocument();
const submenus = await screen.findAllByTestId('drill-to-detail-by-submenu');
expect(submenus.length).toEqual(2);
};
/**
@@ -259,37 +258,17 @@ const expectDrillToDetailByDisabled = async (tooltipContent?: string) => {
const expectDrillToDetailByDimension = async (
filter: BinaryQueryObjectFilterClause,
) => {
const formattedVal = filter.formattedVal as string;
const drillByMenuItem = screen.getByRole('menuitem', {
name: 'Drill to detail by',
});
userEvent.hover(drillByMenuItem);
const submenuPopup = (await waitFor(() =>
screen
.getAllByRole('menu', { hidden: true })
.find(menu =>
(menu.textContent ?? '')
.replace(/\s+/g, ' ')
.trim()
.includes(formattedVal),
),
)) as HTMLElement;
const drillToDetailBySubmenuItem = (await waitFor(() => {
const items = within(submenuPopup).getAllByRole('menuitem');
return items.find(item =>
(item.textContent ?? '')
.replace(/\s+/g, ' ')
.trim()
.includes(`Drill to detail by ${filter.formattedVal}`),
);
})) as HTMLElement;
userEvent.hover(screen.getByRole('menuitem', { name: 'Drill to detail by' }));
const drillToDetailBySubMenus = await screen.findAllByTestId(
'drill-to-detail-by-submenu',
);
const menuItemName = `Drill to detail by ${filter.formattedVal}`;
const drillToDetailBySubmenuItems = await within(
drillToDetailBySubMenus[1],
).findAllByRole('menuitem');
await expectMenuItemEnabled(drillToDetailBySubmenuItem);
await expectMenuItemEnabled(drillToDetailBySubmenuItems[0]);
await expectDrillToDetailModal(menuItemName, [filter]);
};
@@ -299,15 +278,14 @@ const expectDrillToDetailByDimension = async (
const expectDrillToDetailByAll = async (
filters: BinaryQueryObjectFilterClause[],
) => {
const drillByMenuItem = screen.getByRole('menuitem', {
name: 'Drill to detail by',
});
userEvent.hover(screen.getByRole('menuitem', { name: 'Drill to detail by' }));
const drillToDetailBySubMenus = await screen.findAllByTestId(
'drill-to-detail-by-submenu',
);
userEvent.hover(drillByMenuItem);
await screen.findByRole('menu');
const drillToDetailBySubmenuItem = await screen.findByText(/all/i, {});
const drillToDetailBySubmenuItem = await within(
drillToDetailBySubMenus[1],
).findByText(/all/i);
await expectMenuItemEnabled(drillToDetailBySubmenuItem);
@@ -408,20 +386,20 @@ test('context menu for supported chart, dimensions, 1 filter', async () => {
test('context menu for supported chart, dimensions, filter A', async () => {
const filters = [filterA, filterB];
await setupMenu(filters);
setupMenu(filters);
await expectDrillToDetailByEnabled();
await expectDrillToDetailByDimension(filterA);
});
test('context menu for supported chart, dimensions, filter B', async () => {
const filters = [filterA, filterB];
await setupMenu(filters);
setupMenu(filters);
await expectDrillToDetailByEnabled();
await expectDrillToDetailByDimension(filterB);
});
test.skip('context menu for supported chart, dimensions, all filters', async () => {
const filters = [filterA, filterB];
await setupMenu(filters);
setupMenu(filters);
await expectDrillToDetailByAll(filters);
});

View File

@@ -0,0 +1,253 @@
/**
* 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 {
Dispatch,
ReactNode,
SetStateAction,
useCallback,
useMemo,
} from 'react';
import { isEmpty } from 'lodash';
import {
Behavior,
BinaryQueryObjectFilterClause,
css,
extractQueryFields,
getChartMetadataRegistry,
QueryFormData,
removeHTMLTags,
styled,
t,
} from '@superset-ui/core';
import { useSelector } from 'react-redux';
import { Menu } from '@superset-ui/core/components/Menu';
import { RootState } from 'src/dashboard/types';
import { getSubmenuYOffset } from '../utils';
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
import { MenuItemWithTruncation } from '../MenuItemWithTruncation';
import { Dataset } from '../types';
const DRILL_TO_DETAIL = t('Drill to detail');
const DRILL_TO_DETAIL_BY = t('Drill to detail by');
const DISABLED_REASONS = {
DATABASE: t(
'Drill to detail is disabled for this database. Change the database settings to enable it.',
),
NO_AGGREGATIONS: t(
'Drill to detail is disabled because this chart does not group data by dimension value.',
),
NO_FILTERS: t(
'Right-click on a dimension value to drill to detail by that value.',
),
NOT_SUPPORTED: t(
'Drill to detail by value is not yet supported for this chart type.',
),
};
const DisabledMenuItem = ({
children,
menuKey,
...rest
}: {
children: ReactNode;
menuKey: string;
}) => (
<Menu.Item disabled key={menuKey} {...rest}>
<div
css={css`
white-space: normal;
max-width: 160px;
`}
>
{children}
</div>
</Menu.Item>
);
const Filter = ({
children,
stripHTML = false,
}: {
children: ReactNode;
stripHTML: boolean;
}) => {
const content =
stripHTML && typeof children === 'string'
? removeHTMLTags(children)
: children;
return <span>{content}</span>;
};
const StyledFilter = styled(Filter)`
${({ theme }) => `
font-weight: ${theme.fontWeightStrong};
color: ${theme.colorPrimary};
`}
`;
export type DrillDetailMenuItemsProps = {
formData: QueryFormData;
filters?: BinaryQueryObjectFilterClause[];
setFilters: Dispatch<SetStateAction<BinaryQueryObjectFilterClause[]>>;
isContextMenu?: boolean;
contextMenuY?: number;
onSelection?: () => void;
onClick?: (event: MouseEvent) => void;
submenuIndex?: number;
setShowModal: (show: boolean) => void;
key?: string;
forceSubmenuRender?: boolean;
dataset?: Dataset;
isLoadingDataset?: boolean;
};
const DrillDetailMenuItems = ({
formData,
filters = [],
isContextMenu = false,
contextMenuY = 0,
onSelection = () => null,
onClick = () => null,
submenuIndex = 0,
setFilters,
setShowModal,
key,
...props
}: DrillDetailMenuItemsProps) => {
const drillToDetailDisabled = useSelector<RootState, boolean | undefined>(
({ datasources }) =>
datasources[formData.datasource]?.database?.disable_drill_to_detail,
);
const openModal = useCallback(
(filters, event) => {
onClick(event);
onSelection();
setFilters(filters);
setShowModal(true);
},
[onClick, onSelection],
);
// Check for Behavior.DRILL_TO_DETAIL to tell if plugin handles the `contextmenu`
// event for dimensions. If it doesn't, tell the user that drill to detail by
// dimension is not supported. If it does, and the `contextmenu` handler didn't
// pass any filters, tell the user that they didn't select a dimension.
const handlesDimensionContextMenu = useMemo(
() =>
getChartMetadataRegistry()
.get(formData.viz_type)
?.behaviors.find(behavior => behavior === Behavior.DrillToDetail),
[formData.viz_type],
);
// Check metrics to see if chart's current configuration lacks
// aggregations, in which case Drill to Detail should be disabled.
const noAggregations = useMemo(() => {
const { metrics } = extractQueryFields(formData);
return isEmpty(metrics);
}, [formData]);
// Ensure submenu doesn't appear offscreen
const submenuYOffset = useMemo(
() =>
getSubmenuYOffset(
contextMenuY,
filters.length > 1 ? filters.length + 1 : filters.length,
submenuIndex,
),
[contextMenuY, filters.length, submenuIndex],
);
let drillDisabled;
let drillByDisabled;
if (drillToDetailDisabled) {
drillDisabled = DISABLED_REASONS.DATABASE;
drillByDisabled = DISABLED_REASONS.DATABASE;
} else if (handlesDimensionContextMenu) {
if (noAggregations) {
drillDisabled = DISABLED_REASONS.NO_AGGREGATIONS;
drillByDisabled = DISABLED_REASONS.NO_AGGREGATIONS;
} else if (!filters?.length) {
drillByDisabled = DISABLED_REASONS.NO_FILTERS;
}
} else {
drillByDisabled = DISABLED_REASONS.NOT_SUPPORTED;
}
const drillToDetailMenuItem = drillDisabled ? (
<DisabledMenuItem menuKey="drill-to-detail-disabled" {...props}>
{DRILL_TO_DETAIL}
<MenuItemTooltip title={drillDisabled} />
</DisabledMenuItem>
) : (
<Menu.Item key="drill-to-detail" onClick={openModal.bind(null, [])}>
{DRILL_TO_DETAIL}
</Menu.Item>
);
const drillToDetailByMenuItem = drillByDisabled ? (
<DisabledMenuItem menuKey="drill-to-detail-by-disabled" {...props}>
{DRILL_TO_DETAIL_BY}
<MenuItemTooltip title={drillByDisabled} />
</DisabledMenuItem>
) : (
<Menu.SubMenu
popupOffset={[0, submenuYOffset]}
popupClassName="chart-context-submenu"
title={DRILL_TO_DETAIL_BY}
key={key}
{...props}
>
<div data-test="drill-to-detail-by-submenu">
{filters.map((filter, i) => (
<MenuItemWithTruncation
tooltipText={`${DRILL_TO_DETAIL_BY} ${filter.formattedVal}`}
menuKey={`drill-detail-filter-${i}`}
onClick={openModal.bind(null, [filter])}
>
{`${DRILL_TO_DETAIL_BY} `}
<StyledFilter stripHTML>{filter.formattedVal}</StyledFilter>
</MenuItemWithTruncation>
))}
{filters.length > 1 && (
<Menu.Item
key="drill-detail-filter-all"
onClick={openModal.bind(null, filters)}
>
<div>
{`${DRILL_TO_DETAIL_BY} `}
<StyledFilter stripHTML={false}>{t('all')}</StyledFilter>
</div>
</Menu.Item>
)}
</div>
</Menu.SubMenu>
);
return (
<>
{drillToDetailMenuItem}
{isContextMenu && drillToDetailByMenuItem}
</>
);
};
export default DrillDetailMenuItems;

View File

@@ -0,0 +1,21 @@
/**
* 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.
*/
export { default as DrillDetailMenuItems } from './DrillDetailMenuItems';
export { useDrillDetailMenuItems } from './useDrillDetailMenuItems';

View File

@@ -37,12 +37,11 @@ import {
t,
} from '@superset-ui/core';
import { useSelector } from 'react-redux';
import { type ItemType } from '@superset-ui/core/components/Menu';
import { MenuItem } from '@superset-ui/core/components/Menu';
import { RootState } from 'src/dashboard/types';
import { getSubmenuYOffset } from '../utils';
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
import { TruncatedMenuLabel } from '../MenuItemWithTruncation';
import { Dataset } from '../types';
import { useMenuItemWithTruncation } from '../MenuItemWithTruncation';
const DRILL_TO_DETAIL = t('Drill to detail');
const DRILL_TO_DETAIL_BY = t('Drill to detail by');
@@ -61,6 +60,28 @@ const DISABLED_REASONS = {
),
};
function getDisabledMenuItem(
children: ReactNode,
menuKey: string,
...rest: unknown[]
): MenuItem {
return {
disabled: true,
key: menuKey,
label: (
<div
css={css`
white-space: normal;
max-width: 160px;
`}
>
{children}
</div>
),
...rest,
};
}
const Filter = ({
children,
stripHTML = false,
@@ -82,7 +103,7 @@ const StyledFilter = styled(Filter)`
`}
`;
export type DrillDetailMenuItemsProps = {
export type DrillDetailMenuItemsArgs = {
formData: QueryFormData;
filters?: BinaryQueryObjectFilterClause[];
setFilters: Dispatch<SetStateAction<BinaryQueryObjectFilterClause[]>>;
@@ -94,8 +115,6 @@ export type DrillDetailMenuItemsProps = {
setShowModal: (show: boolean) => void;
key?: string;
forceSubmenuRender?: boolean;
dataset?: Dataset;
isLoadingDataset?: boolean;
};
export const useDrillDetailMenuItems = ({
@@ -110,7 +129,7 @@ export const useDrillDetailMenuItems = ({
setShowModal,
key,
...props
}: DrillDetailMenuItemsProps): ItemType[] => {
}: DrillDetailMenuItemsArgs) => {
const drillToDetailDisabled = useSelector<RootState, boolean | undefined>(
({ datasources }) =>
datasources[formData.datasource]?.database?.disable_drill_to_detail,
@@ -123,7 +142,7 @@ export const useDrillDetailMenuItems = ({
setFilters(filters);
setShowModal(true);
},
[onClick, onSelection, setFilters, setShowModal],
[onClick, onSelection],
);
// Check for Behavior.DRILL_TO_DETAIL to tell if plugin handles the `contextmenu`
@@ -172,110 +191,79 @@ export const useDrillDetailMenuItems = ({
drillByDisabled = DISABLED_REASONS.NOT_SUPPORTED;
}
const drillToDetailMenuItem: ItemType = drillDisabled
? {
key: 'drill-to-detail-disabled',
disabled: true,
label: (
<div
css={css`
white-space: normal;
max-width: 160px;
`}
>
{DRILL_TO_DETAIL}
<MenuItemTooltip title={drillDisabled} />
</div>
),
...props,
}
const drillToDetailMenuItem: MenuItem = drillDisabled
? getDisabledMenuItem(
<>
{DRILL_TO_DETAIL}
<MenuItemTooltip title={drillDisabled} />
</>,
'drill-to-detail-disabled',
props,
)
: {
key: 'drill-to-detail',
onClick: openModal.bind(null, []),
label: DRILL_TO_DETAIL,
onClick: openModal.bind(null, []),
...props,
};
const drillToDetailByMenuItem: ItemType | null = !isContextMenu
? null
: drillByDisabled
? {
key: 'drill-to-detail-by-disabled',
disabled: true,
label: (
<div
css={css`
white-space: normal;
max-width: 160px;
`}
>
{DRILL_TO_DETAIL_BY}
<MenuItemTooltip title={drillByDisabled} />
</div>
),
...props,
}
: {
key: key || 'drill-to-detail-by',
label: DRILL_TO_DETAIL_BY,
popupOffset: [0, submenuYOffset],
popupClassName: 'chart-context-submenu',
children: [
...filters.map((filter, i) => ({
key: `drill-detail-filter-${i}`,
const getMenuItemWithTruncation = useMenuItemWithTruncation();
const drillToDetailByMenuItem: MenuItem = drillByDisabled
? getDisabledMenuItem(
<>
{DRILL_TO_DETAIL_BY}
<MenuItemTooltip title={drillByDisabled} />
</>,
'drill-to-detail-by-disabled',
props,
)
: {
key: key || 'drill-to-detail-by',
label: DRILL_TO_DETAIL_BY,
children: [
...filters.map((filter, i) => ({
key: `drill-detail-filter-${i}`,
label: getMenuItemWithTruncation({
tooltipText: `${DRILL_TO_DETAIL_BY} ${filter.formattedVal}`,
onClick: openModal.bind(null, [filter]),
label: (
<div
css={css`
max-width: 200px;
`}
>
<TruncatedMenuLabel
tooltipText={`${DRILL_TO_DETAIL_BY} ${filter.formattedVal}`}
aria-label={`${DRILL_TO_DETAIL_BY} ${filter.formattedVal}`}
>
<span
css={css`
display: inline;
`}
>
{DRILL_TO_DETAIL_BY}{' '}
<StyledFilter stripHTML>
{filter.formattedVal}
</StyledFilter>
</span>
</TruncatedMenuLabel>
</div>
key: `drill-detail-filter-${i}`,
children: (
<>
{`${DRILL_TO_DETAIL_BY} `}
<StyledFilter stripHTML>{filter.formattedVal}</StyledFilter>
</>
),
})),
...(filters.length > 1
? [
{
key: 'drill-detail-filter-all',
onClick: openModal.bind(null, filters),
label: (
<div
aria-label={`${DRILL_TO_DETAIL_BY} ${t('all')}`}
css={css`
max-width: 200px;
`}
>
{`${DRILL_TO_DETAIL_BY} `}
<StyledFilter stripHTML={false}>
{t('all')}
</StyledFilter>
</div>
),
},
]
: []),
],
...props,
};
const menuItems: ItemType[] = [drillToDetailMenuItem];
if (drillToDetailByMenuItem) {
menuItems.push(drillToDetailByMenuItem);
}),
})),
filters.length > 1 && {
key: 'drill-detail-filter-all',
label: getMenuItemWithTruncation({
tooltipText: `${DRILL_TO_DETAIL_BY} ${t('all')}`,
onClick: openModal.bind(null, filters),
key: 'drill-detail-filter-all',
children: (
<>
{`${DRILL_TO_DETAIL_BY} `}
<StyledFilter stripHTML={false}>{t('all')}</StyledFilter>
</>
),
}),
},
].filter(Boolean) as MenuItem[],
onClick: openModal.bind(null, filters),
forceSubmenuRender: true,
popupOffset: [0, submenuYOffset],
popupClassName: 'chart-context-submenu',
...props,
};
if (isContextMenu) {
return {
drillToDetailMenuItem,
drillToDetailByMenuItem,
};
}
return menuItems;
return {
drillToDetailMenuItem,
};
};

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: {},
@@ -170,7 +171,6 @@ export const DEFAULT_COMMON_BOOTSTRAP_DATA: CommonBootstrapData = {
},
d3_format: DEFAULT_D3_FORMAT,
d3_time_format: DEFAULT_D3_TIME_FORMAT,
pdf_compression_level: 'MEDIUM',
};
export const DEFAULT_BOOTSTRAP_DATA: BootstrapData = {

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

@@ -19,9 +19,11 @@
/* eslint-env browser */
import tinycolor from 'tinycolor2';
import Tabs from '@superset-ui/core/components/Tabs';
import { t, css, SupersetTheme } from '@superset-ui/core';
import { t, css, SupersetTheme, useTheme } from '@superset-ui/core';
import { useSelector } from 'react-redux';
import SliceAdder from 'src/dashboard/containers/SliceAdder';
import dashboardComponents from 'src/visualizations/presets/dashboardComponents';
import { useMemo } from 'react';
import NewColumn from '../gridComponents/new/NewColumn';
import NewDivider from '../gridComponents/new/NewDivider';
import NewHeader from '../gridComponents/new/NewHeader';
@@ -37,83 +39,98 @@ const TABS_KEYS = {
LAYOUT_ELEMENTS: 'LAYOUT_ELEMENTS',
};
const BuilderComponentPane = ({ topOffset = 0 }) => (
<div
data-test="dashboard-builder-sidepane"
css={css`
position: sticky;
right: 0;
top: ${topOffset}px;
height: calc(100vh - ${topOffset}px);
width: ${BUILDER_PANE_WIDTH}px;
`}
>
const BuilderComponentPane = ({ topOffset = 0 }) => {
const theme = useTheme();
const nativeFiltersBarOpen = useSelector(
(state: any) => state.dashboardState.nativeFiltersBarOpen ?? false,
);
const tabBarStyle = useMemo(
() => ({
paddingLeft: nativeFiltersBarOpen ? 0 : theme.sizeUnit * 4,
}),
[nativeFiltersBarOpen, theme.sizeUnit],
);
return (
<div
css={(theme: SupersetTheme) => css`
position: absolute;
height: 100%;
data-test="dashboard-builder-sidepane"
css={css`
position: sticky;
right: 0;
top: ${topOffset}px;
height: calc(100vh - ${topOffset}px);
width: ${BUILDER_PANE_WIDTH}px;
box-shadow: -${theme.sizeUnit}px 0 ${theme.sizeUnit}px 0
${tinycolor(theme.colorBorder).setAlpha(0.1).toRgbString()};
background-color: ${theme.colorBgBase};
`}
>
<Tabs
data-test="dashboard-builder-component-pane-tabs-navigation"
id="tabs"
<div
css={(theme: SupersetTheme) => css`
line-height: inherit;
margin-top: ${theme.sizeUnit * 2}px;
position: absolute;
height: 100%;
& .ant-tabs-content-holder {
height: 100%;
& .ant-tabs-content {
height: 100%;
}
}
width: ${BUILDER_PANE_WIDTH}px;
box-shadow: -4px 0 4px 0
${tinycolor(theme.colorBorder).setAlpha(0.1).toRgbString()};
background-color: ${theme.colorBgBase};
`}
items={[
{
key: TABS_KEYS.CHARTS,
label: t('Charts'),
children: (
<div
css={css`
height: calc(100vh - ${topOffset * 2}px);
`}
>
<SliceAdder />
</div>
),
},
{
key: TABS_KEYS.LAYOUT_ELEMENTS,
label: t('Layout elements'),
children: (
<>
<NewTabs />
<NewRow />
<NewColumn />
<NewHeader />
<NewMarkdown />
<NewDivider />
{dashboardComponents
.getAll()
.map(({ key: componentKey, metadata }) => (
<NewDynamicComponent
key={componentKey}
metadata={metadata}
componentKey={componentKey}
/>
))}
</>
),
},
]}
/>
>
<Tabs
data-test="dashboard-builder-component-pane-tabs-navigation"
id="tabs"
tabBarStyle={tabBarStyle}
css={(theme: SupersetTheme) => css`
line-height: inherit;
margin-top: ${theme.sizeUnit * 2}px;
height: 100%;
& .ant-tabs-content-holder {
height: 100%;
& .ant-tabs-content {
height: 100%;
}
}
`}
items={[
{
key: TABS_KEYS.CHARTS,
label: t('Charts'),
children: (
<div
css={css`
height: calc(100vh - ${topOffset * 2}px);
`}
>
<SliceAdder />
</div>
),
},
{
key: TABS_KEYS.LAYOUT_ELEMENTS,
label: t('Layout elements'),
children: (
<>
<NewTabs />
<NewRow />
<NewColumn />
<NewHeader />
<NewMarkdown />
<NewDivider />
{dashboardComponents
.getAll()
.map(({ key: componentKey, metadata }) => (
<NewDynamicComponent
key={componentKey}
metadata={metadata}
componentKey={componentKey}
/>
))}
</>
),
},
]}
/>
</div>
</div>
</div>
);
);
};
export default BuilderComponentPane;

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

@@ -86,6 +86,16 @@ const StyledFilterCount = styled.div`
const StyledBadge = styled(Badge)`
${({ theme }) => `
margin-left: ${theme.sizeUnit * 2}px;
&>sup.ant-badge-count {
padding: 0 ${theme.sizeUnit}px;
min-width: ${theme.sizeUnit * 4}px;
height: ${theme.sizeUnit * 4}px;
line-height: 1.5;
font-weight: ${theme.fontWeightStrong};
font-size: ${theme.fontSizeSM - 1}px;
box-shadow: none;
padding: 0 ${theme.sizeUnit}px;
}
`}
`;
@@ -304,7 +314,6 @@ export const FiltersBadge = ({ chartId }: FiltersBadgeProps) => {
data-test="applied-filter-count"
className="applied-count"
count={filterCount}
size="small"
showZero
/>
</StyledFilterCount>

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

@@ -85,7 +85,7 @@ const ChartHeaderStyles = styled.div`
& > .header-title {
overflow: hidden;
text-overflow: ellipsis;
max-width: calc(100% - ${theme.sizeUnit * 4}px);
max-width: 100%;
flex-grow: 1;
display: -webkit-box;
-webkit-line-clamp: 2;

View File

@@ -54,7 +54,7 @@ import { getSliceHeaderTooltip } from 'src/dashboard/util/getSliceHeaderTooltip'
import { Icons } from '@superset-ui/core/components/Icons';
import ViewQueryModal from 'src/explore/components/controls/ViewQueryModal';
import { ResultsPaneOnDashboard } from 'src/explore/components/DataTablesPane';
import { useDrillDetailMenuItems } from 'src/components/Chart/useDrillDetailMenuItems';
import { useDrillDetailMenuItems } from 'src/components/Chart/DrillDetail';
import { LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE } from 'src/logger/LogUtils';
import { MenuKeys, RootState } from 'src/dashboard/types';
import DrillDetailModal from 'src/components/Chart/DrillDetail/DrillDetailModal';
@@ -455,13 +455,14 @@ const SliceHeaderControls = (
});
}
const drillDetailMenuItems = useDrillDetailMenuItems({
formData: props.formData,
filters: modalFilters,
setFilters,
setShowModal: setDrillModalIsOpen,
key: MenuKeys.DrillToDetail,
});
const { drillToDetailMenuItem, drillToDetailByMenuItem } =
useDrillDetailMenuItems({
formData: props.formData,
filters: modalFilters,
setFilters,
setShowModal: setDrillModalIsOpen,
key: MenuKeys.DrillToDetail,
});
const shareMenuItems = useShareMenuItems({
dashboardId,
@@ -476,7 +477,10 @@ const SliceHeaderControls = (
});
if (isFeatureEnabled(FeatureFlag.DrillToDetail) && canDrillToDetail) {
newMenuItems.push(...drillDetailMenuItems);
newMenuItems.push(drillToDetailMenuItem);
if (drillToDetailByMenuItem) {
newMenuItems.push(drillToDetailByMenuItem);
}
}
if (slice.description || canExplore) {

View File

@@ -58,7 +58,7 @@ const HoverStyleOverrides = styled.div`
top: ${({ theme }) => theme.sizeUnit * -6}px;
left: 50%;
transform: translate(-50%);
padding: 0;
padding: 0 ${({ theme }) => theme.sizeUnit * 2}px;
display: flex;
flex-direction: row;
justify-content: center;

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

@@ -33,7 +33,7 @@ const useFilterFocusHighlightStyles = (chartId: number) => {
() => ({
borderColor: theme.colorPrimaryBorder,
opacity: 1,
boxShadow: `0px 0px ${theme.sizeUnit * 3}px ${theme.colorPrimary}`,
boxShadow: `0px 0px ${theme.sizeUnit * 2}px ${theme.colorPrimary}`,
pointerEvents: 'auto',
}),
[theme],

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

@@ -25,7 +25,11 @@ import {
setItem,
LocalStorageKeys,
} from 'src/utils/localStorageHelpers';
import { SamplesPane, useResultsPane } from './components';
import {
SamplesPane,
TableControlsWrapper,
useResultsPane,
} from './components';
import { DataTablesPaneProps, ResultTypes } from './types';
const StyledDiv = styled.div`
@@ -158,7 +162,7 @@ export const DataTablesPane = ({
<Icons.DownOutlined aria-label={t('Expand data panel')} />
);
return (
<div>
<TableControlsWrapper>
{panelOpen ? (
<span
role="button"
@@ -176,7 +180,7 @@ export const DataTablesPane = ({
{caretIcon}
</span>
)}
</div>
</TableControlsWrapper>
);
}, [handleCollapseChange, panelOpen]);

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

@@ -16,43 +16,24 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import { SupersetClient } from '@superset-ui/core';
import userEvent from '@testing-library/user-event';
import { render, screen } from 'spec/helpers/testing-library';
import Login from './index';
const mockGetBootstrapData = jest.fn();
const mockApplicationRoot = jest.fn();
const renderLogin = () => render(<Login />, { useRedux: true });
jest.mock('src/utils/getBootstrapData', () => ({
__esModule: true,
default: () => mockGetBootstrapData(),
}));
jest.mock('src/utils/pathUtils', () => ({
__esModule: true,
ensureAppRoot: (path: string) =>
`${mockApplicationRoot()}${path.startsWith('/') ? path : `/${path}`}`,
}));
const defaultBootstrapData = {
common: {
conf: {
AUTH_TYPE: 1,
AUTH_PROVIDERS: [],
AUTH_USER_REGISTRATION: false,
default: () => ({
common: {
conf: {
AUTH_TYPE: 1,
AUTH_PROVIDERS: [],
AUTH_USER_REGISTRATION: false,
},
},
},
};
beforeEach(() => {
mockGetBootstrapData.mockReturnValue(defaultBootstrapData);
});
}),
}));
test('should render login form elements', () => {
renderLogin();
render(<Login />);
expect(screen.getByTestId('login-form')).toBeInTheDocument();
expect(screen.getByTestId('username-input')).toBeInTheDocument();
expect(screen.getByTestId('password-input')).toBeInTheDocument();
@@ -61,663 +42,14 @@ test('should render login form elements', () => {
});
test('should render username and password labels', () => {
renderLogin();
render(<Login />);
expect(screen.getByText('Username:')).toBeInTheDocument();
expect(screen.getByText('Password:')).toBeInTheDocument();
});
test('should render form instruction text', () => {
renderLogin();
render(<Login />);
expect(
screen.getByText('Enter your login and password below:'),
).toBeInTheDocument();
});
test('should render OAuth providers with correct app root URLs', () => {
mockApplicationRoot.mockReturnValue('/superset');
mockGetBootstrapData.mockReturnValue({
common: {
conf: {
AUTH_TYPE: 4, // AuthType.AuthOauth
AUTH_PROVIDERS: [
{ name: 'google', icon: 'google' },
{ name: 'github', icon: 'github' },
],
AUTH_USER_REGISTRATION: false,
},
},
});
renderLogin();
const googleButton = screen.getByRole('link', {
name: /Sign in with Google/i,
});
const githubButton = screen.getByRole('link', {
name: /Sign in with Github/i,
});
expect(googleButton).toHaveAttribute('href', '/superset/login/google');
expect(githubButton).toHaveAttribute('href', '/superset/login/github');
});
test('should render OAuth providers with default URLs when no app root', () => {
mockApplicationRoot.mockReturnValue('');
mockGetBootstrapData.mockReturnValue({
common: {
conf: {
AUTH_TYPE: 4, // AuthType.AuthOauth
AUTH_PROVIDERS: [{ name: 'google', icon: 'google' }],
AUTH_USER_REGISTRATION: false,
},
},
});
renderLogin();
const googleButton = screen.getByRole('link', {
name: /Sign in with Google/i,
});
expect(googleButton).toHaveAttribute('href', '/login/google');
});
test('should render LDAP/OID providers with correct app root URLs', () => {
mockApplicationRoot.mockReturnValue('/superset');
mockGetBootstrapData.mockReturnValue({
common: {
conf: {
AUTH_TYPE: 0, // AuthType.AuthOID
AUTH_PROVIDERS: [{ name: 'ldap', url: '/login/ldap' }],
AUTH_USER_REGISTRATION: false,
},
},
});
renderLogin();
const ldapButton = screen.getByRole('link', { name: /Sign in with Ldap/i });
expect(ldapButton).toHaveAttribute('href', '/superset/login/ldap');
});
test('should render registration button with correct app root URL', () => {
mockApplicationRoot.mockReturnValue('/superset');
mockGetBootstrapData.mockReturnValue({
common: {
conf: {
AUTH_TYPE: 1, // AuthType.AuthDB
AUTH_PROVIDERS: [],
AUTH_USER_REGISTRATION: true,
},
},
});
renderLogin();
const registerButton = screen.getByTestId('register-button');
expect(registerButton).toHaveAttribute('href', '/superset/register/');
});
test('should render registration button with default URL when no app root', () => {
mockApplicationRoot.mockReturnValue('');
mockGetBootstrapData.mockReturnValue({
common: {
conf: {
AUTH_TYPE: 1, // AuthType.AuthDB
AUTH_PROVIDERS: [],
AUTH_USER_REGISTRATION: true,
},
},
});
renderLogin();
const registerButton = screen.getByTestId('register-button');
expect(registerButton).toHaveAttribute('href', '/register/');
});
test('should call SupersetClient.postForm with correct endpoint (no double-prefix)', async () => {
const postFormSpy = jest
.spyOn(SupersetClient, 'postForm')
.mockResolvedValue();
mockApplicationRoot.mockReturnValue('/superset');
mockGetBootstrapData.mockReturnValue({
common: {
conf: {
AUTH_TYPE: 1, // AuthType.AuthDB
AUTH_PROVIDERS: [],
AUTH_USER_REGISTRATION: false,
},
},
});
renderLogin();
// Fill in the form
const usernameInput = screen.getByTestId('username-input');
const passwordInput = screen.getByTestId('password-input');
const loginButton = screen.getByTestId('login-button');
await userEvent.type(usernameInput, 'testuser');
await userEvent.type(passwordInput, 'testpass');
await userEvent.click(loginButton);
await waitFor(() => {
expect(postFormSpy).toHaveBeenCalledWith(
'/login/', // Should be bare endpoint, not /superset/login/
{ username: 'testuser', password: 'testpass' },
'',
);
});
postFormSpy.mockRestore();
});
// Edge case tests
test('should handle empty providers array gracefully', () => {
mockGetBootstrapData.mockReturnValue({
common: {
conf: {
AUTH_TYPE: 4, // AuthType.AuthOauth
AUTH_PROVIDERS: [],
AUTH_USER_REGISTRATION: false,
},
},
});
renderLogin();
// Should not crash and OAuth section should be empty
expect(
screen.queryByRole('link', { name: /Sign in with/i }),
).not.toBeInTheDocument();
});
test('should handle invalid provider objects', () => {
mockGetBootstrapData.mockReturnValue({
common: {
conf: {
AUTH_TYPE: 4, // AuthType.AuthOauth
AUTH_PROVIDERS: [
{ name: null, icon: 'google' },
{ name: 'github' }, // missing icon
{}, // empty object
],
AUTH_USER_REGISTRATION: false,
},
},
});
renderLogin();
// Should only render valid providers
const githubButton = screen.getByRole('link', {
name: /Sign in with Github/i,
});
expect(githubButton).toBeInTheDocument();
});
test('should handle providers with special characters in names', () => {
mockApplicationRoot.mockReturnValue('/superset');
mockGetBootstrapData.mockReturnValue({
common: {
conf: {
AUTH_TYPE: 4, // AuthType.AuthOauth
AUTH_PROVIDERS: [
{ name: 'test-provider', icon: 'test' },
{ name: 'test_provider', icon: 'test' },
{ name: 'test.provider', icon: 'test' },
],
AUTH_USER_REGISTRATION: false,
},
},
});
renderLogin();
const testDashButton = screen.getByRole('link', {
name: /Sign in with Test-provider/i,
});
expect(testDashButton).toHaveAttribute(
'href',
'/superset/login/test-provider',
);
const testUnderscoreButton = screen.getByRole('link', {
name: /Sign in with Test_provider/i,
});
expect(testUnderscoreButton).toHaveAttribute(
'href',
'/superset/login/test_provider',
);
const testDotButton = screen.getByRole('link', {
name: /Sign in with Test.provider/i,
});
expect(testDotButton).toHaveAttribute(
'href',
'/superset/login/test.provider',
);
});
test('should handle very long provider names', () => {
const longName = 'a'.repeat(100);
mockGetBootstrapData.mockReturnValue({
common: {
conf: {
AUTH_TYPE: 4, // AuthType.AuthOauth
AUTH_PROVIDERS: [{ name: longName, icon: 'test' }],
AUTH_USER_REGISTRATION: false,
},
},
});
renderLogin();
const longButton = screen.getByRole('link', {
name: new RegExp(`Sign in with ${longName.charAt(0).toUpperCase()}`, 'i'),
});
expect(longButton).toBeInTheDocument();
});
test('should handle mixed auth types correctly', () => {
// Test OAuth with registration enabled
mockGetBootstrapData.mockReturnValue({
common: {
conf: {
AUTH_TYPE: 4, // AuthType.AuthOauth
AUTH_PROVIDERS: [{ name: 'google', icon: 'google' }],
AUTH_USER_REGISTRATION: true, // Registration with OAuth
},
},
});
renderLogin();
const googleButton = screen.getByRole('link', {
name: /Sign in with Google/i,
});
expect(googleButton).toBeInTheDocument();
// Registration button should not be shown with OAuth
expect(screen.queryByTestId('register-button')).not.toBeInTheDocument();
});
test('should handle undefined provider configuration', () => {
mockGetBootstrapData.mockReturnValue({
common: {
conf: {
AUTH_TYPE: 4, // AuthType.AuthOauth
AUTH_PROVIDERS: undefined,
AUTH_USER_REGISTRATION: false,
},
},
});
renderLogin();
// Should not crash
expect(screen.getByTestId('login-form')).toBeInTheDocument();
});
test('should handle null provider configuration', () => {
mockGetBootstrapData.mockReturnValue({
common: {
conf: {
AUTH_TYPE: 4, // AuthType.AuthOauth
AUTH_PROVIDERS: null,
AUTH_USER_REGISTRATION: false,
},
},
});
renderLogin();
// Should not crash
expect(screen.getByTestId('login-form')).toBeInTheDocument();
});
// Integration and interaction tests
test('should handle full login flow with session storage', async () => {
const postFormSpy = jest
.spyOn(SupersetClient, 'postForm')
.mockResolvedValue();
mockGetBootstrapData.mockReturnValue({
common: {
conf: {
AUTH_TYPE: 1, // AuthType.AuthDB
AUTH_PROVIDERS: [],
AUTH_USER_REGISTRATION: false,
},
},
});
renderLogin();
const usernameInput = screen.getByTestId('username-input');
const passwordInput = screen.getByTestId('password-input');
const loginButton = screen.getByTestId('login-button');
// Type credentials
await userEvent.type(usernameInput, 'testuser');
await userEvent.type(passwordInput, 'testpass123');
// Check session storage is set before submission
await userEvent.click(loginButton);
expect(sessionStorage.getItem('login_attempted')).toBe('true');
await waitFor(() => {
expect(postFormSpy).toHaveBeenCalledWith(
'/login/',
{ username: 'testuser', password: 'testpass123' },
'',
);
});
postFormSpy.mockRestore();
});
test('should show loading state during form submission', async () => {
const postFormSpy = jest
.spyOn(SupersetClient, 'postForm')
.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
mockGetBootstrapData.mockReturnValue({
common: {
conf: {
AUTH_TYPE: 1, // AuthType.AuthDB
AUTH_PROVIDERS: [],
AUTH_USER_REGISTRATION: false,
},
},
});
renderLogin();
const usernameInput = screen.getByTestId('username-input');
const passwordInput = screen.getByTestId('password-input');
const loginButton = screen.getByTestId('login-button');
await userEvent.type(usernameInput, 'user');
await userEvent.type(passwordInput, 'pass');
await userEvent.click(loginButton);
// Button should show loading state
expect(loginButton).toHaveAttribute('aria-busy', 'true');
await waitFor(() => {
expect(loginButton).not.toHaveAttribute('aria-busy', 'true');
});
postFormSpy.mockRestore();
});
test('should validate password field is required', async () => {
mockGetBootstrapData.mockReturnValue({
common: {
conf: {
AUTH_TYPE: 1, // AuthType.AuthDB
AUTH_PROVIDERS: [],
AUTH_USER_REGISTRATION: false,
},
},
});
renderLogin();
const usernameInput = screen.getByTestId('username-input');
const loginButton = screen.getByTestId('login-button');
// Try to submit with only username
await userEvent.type(usernameInput, 'testuser');
await userEvent.click(loginButton);
// Form should not submit without password
await waitFor(() => {
expect(screen.getByText('Please enter your password')).toBeInTheDocument();
});
});
test('should handle keyboard navigation for accessibility', async () => {
mockGetBootstrapData.mockReturnValue({
common: {
conf: {
AUTH_TYPE: 4, // AuthType.AuthOauth
AUTH_PROVIDERS: [
{ name: 'google', icon: 'google' },
{ name: 'github', icon: 'github' },
],
AUTH_USER_REGISTRATION: false,
},
},
});
renderLogin();
const googleButton = screen.getByRole('link', {
name: /Sign in with Google/i,
});
const githubButton = screen.getByRole('link', {
name: /Sign in with Github/i,
});
// Tab to first OAuth button
googleButton.focus();
expect(googleButton).toHaveFocus();
// Tab to next OAuth button
await userEvent.tab();
expect(githubButton).toHaveFocus();
});
test('should handle password visibility toggle', async () => {
mockGetBootstrapData.mockReturnValue({
common: {
conf: {
AUTH_TYPE: 1, // AuthType.AuthDB
AUTH_PROVIDERS: [],
AUTH_USER_REGISTRATION: false,
},
},
});
renderLogin();
const passwordInput = screen.getByTestId('password-input');
// Initially password should be hidden
expect(passwordInput).toHaveAttribute('type', 'password');
// Find and click the visibility toggle button
const toggleButton = screen.getByRole('button', { name: /eye/i });
await userEvent.click(toggleButton);
// Password should now be visible
expect(passwordInput).toHaveAttribute('type', 'text');
// Click again to hide
await userEvent.click(toggleButton);
expect(passwordInput).toHaveAttribute('type', 'password');
});
test('should handle form reset when navigating away', async () => {
mockGetBootstrapData.mockReturnValue({
common: {
conf: {
AUTH_TYPE: 1, // AuthType.AuthDB
AUTH_PROVIDERS: [],
AUTH_USER_REGISTRATION: true,
},
},
});
const { unmount } = renderLogin();
const usernameInput = screen.getByTestId('username-input');
const passwordInput = screen.getByTestId('password-input');
// Enter some data
await userEvent.type(usernameInput, 'testuser');
await userEvent.type(passwordInput, 'testpass');
// Unmount and remount
unmount();
renderLogin();
// Fields should be empty after remounting
expect(screen.getByTestId('username-input')).toHaveValue('');
expect(screen.getByTestId('password-input')).toHaveValue('');
});
// Error state tests
test('should handle network error during form submission', async () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
const postFormSpy = jest
.spyOn(SupersetClient, 'postForm')
.mockRejectedValue(new Error('Network error'));
mockGetBootstrapData.mockReturnValue({
common: {
conf: {
AUTH_TYPE: 1, // AuthType.AuthDB
AUTH_PROVIDERS: [],
AUTH_USER_REGISTRATION: false,
},
},
});
renderLogin();
const usernameInput = screen.getByTestId('username-input');
const passwordInput = screen.getByTestId('password-input');
const loginButton = screen.getByTestId('login-button');
await userEvent.type(usernameInput, 'testuser');
await userEvent.type(passwordInput, 'testpass');
await userEvent.click(loginButton);
// Should handle error gracefully
await waitFor(() => {
expect(postFormSpy).toHaveBeenCalled();
});
postFormSpy.mockRestore();
consoleSpy.mockRestore();
});
test('should handle malformed bootstrap data', () => {
mockGetBootstrapData.mockReturnValue({
common: {
conf: null, // Malformed config
},
});
// Should not crash
renderLogin();
expect(screen.getByTestId('login-form')).toBeInTheDocument();
});
test('should handle missing auth type', () => {
mockGetBootstrapData.mockReturnValue({
common: {
conf: {
// No AUTH_TYPE
AUTH_PROVIDERS: [],
AUTH_USER_REGISTRATION: false,
},
},
});
renderLogin();
// Should still render form
expect(screen.getByTestId('login-form')).toBeInTheDocument();
});
test('should handle error when sessionStorage is unavailable', async () => {
const originalSessionStorage = global.sessionStorage;
// @ts-ignore
delete global.sessionStorage;
const postFormSpy = jest
.spyOn(SupersetClient, 'postForm')
.mockResolvedValue();
mockGetBootstrapData.mockReturnValue({
common: {
conf: {
AUTH_TYPE: 1, // AuthType.AuthDB
AUTH_PROVIDERS: [],
AUTH_USER_REGISTRATION: false,
},
},
});
renderLogin();
const usernameInput = screen.getByTestId('username-input');
const passwordInput = screen.getByTestId('password-input');
const loginButton = screen.getByTestId('login-button');
await userEvent.type(usernameInput, 'testuser');
await userEvent.type(passwordInput, 'testpass');
// Should not crash even without sessionStorage
await userEvent.click(loginButton);
await waitFor(() => {
expect(postFormSpy).toHaveBeenCalled();
});
global.sessionStorage = originalSessionStorage;
postFormSpy.mockRestore();
});
test('should display error message from session storage on mount', () => {
sessionStorage.setItem('login_attempted', 'true');
mockGetBootstrapData.mockReturnValue({
common: {
conf: {
AUTH_TYPE: 1, // AuthType.AuthDB
AUTH_PROVIDERS: [],
AUTH_USER_REGISTRATION: false,
},
},
});
renderLogin();
// Should show error toast
expect(sessionStorage.getItem('login_attempted')).toBeNull();
});
test('should handle OAuth provider with malformed URL', () => {
mockApplicationRoot.mockReturnValue('/superset');
mockGetBootstrapData.mockReturnValue({
common: {
conf: {
AUTH_TYPE: 0, // AuthType.AuthOID
AUTH_PROVIDERS: [
{ name: 'provider1', url: null }, // null URL
{ name: 'provider2', url: '' }, // empty URL
{ name: 'provider3' }, // missing URL
],
AUTH_USER_REGISTRATION: false,
},
},
});
renderLogin();
// Should still render buttons for all providers
expect(
screen.getByRole('link', { name: /Sign in with Provider1/i }),
).toBeInTheDocument();
expect(
screen.getByRole('link', { name: /Sign in with Provider2/i }),
).toBeInTheDocument();
expect(
screen.getByRole('link', { name: /Sign in with Provider3/i }),
).toBeInTheDocument();
});

View File

@@ -27,12 +27,9 @@ 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';
import { ensureAppRoot } from 'src/utils/pathUtils';
type OAuthProvider = {
name: string;
@@ -80,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();
@@ -89,28 +85,8 @@ 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');
// Note: SupersetClient.postForm already adds appRoot internally via getUrl()
// so we don't use ensureAppRoot() here to avoid double-prefixing
SupersetClient.postForm('/login/', values, '').finally(() => {
setLoading(false);
});
@@ -150,7 +126,7 @@ export default function Login() {
{providers.map((provider: OIDProvider) => (
<Form.Item<LoginForm>>
<Button
href={ensureAppRoot(`/login/${provider.name}`)}
href={`/login/${provider.name}`}
block
iconPosition="start"
icon={getAuthIconElement(provider.name)}
@@ -168,7 +144,7 @@ export default function Login() {
{providers.map((provider: OAuthProvider) => (
<Form.Item<LoginForm>>
<Button
href={ensureAppRoot(`/login/${provider.name}`)}
href={`/login/${provider.name}`}
block
iconPosition="start"
icon={getAuthIconElement(provider.name)}
@@ -236,7 +212,7 @@ export default function Login() {
<Button
block
type="default"
href={ensureAppRoot('/register/')}
href="/register/"
data-test="register-button"
>
{t('Register')}

View File

@@ -20,18 +20,15 @@ import { render, screen } from 'spec/helpers/testing-library';
import { MemoryRouter } from 'react-router-dom';
import Register from './index';
const mockGetBootstrapData = jest.fn();
const mockApplicationRoot = jest.fn();
jest.mock('src/utils/getBootstrapData', () => ({
__esModule: true,
default: () => mockGetBootstrapData(),
}));
jest.mock('src/utils/pathUtils', () => ({
__esModule: true,
ensureAppRoot: (path: string) =>
`${mockApplicationRoot()}${path.startsWith('/') ? path : `/${path}`}`,
default: () => ({
common: {
conf: {
RECAPTCHA_PUBLIC_KEY: '',
},
},
}),
}));
jest.mock('react-google-recaptcha', () => ({
@@ -46,17 +43,6 @@ const renderRegister = () =>
</MemoryRouter>,
);
beforeEach(() => {
mockGetBootstrapData.mockReturnValue({
common: {
conf: {
RECAPTCHA_PUBLIC_KEY: '',
},
},
});
mockApplicationRoot.mockReturnValue('');
});
test('should render register form elements', () => {
renderRegister();
@@ -94,35 +80,3 @@ test('should render input placeholders', () => {
expect(screen.getByPlaceholderText('Password')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Confirm password')).toBeInTheDocument();
});
test('should render login button with correct app root URL', () => {
mockApplicationRoot.mockReturnValue('/superset');
renderRegister();
const loginButton = screen.getByTestId('login-button');
expect(loginButton).toHaveAttribute('href', '/superset/login/');
});
test('should render login button with default URL when no app root', () => {
mockApplicationRoot.mockReturnValue('');
renderRegister();
const loginButton = screen.getByTestId('login-button');
expect(loginButton).toHaveAttribute('href', '/login/');
});
test('should handle empty app root correctly', () => {
mockApplicationRoot.mockReturnValue(null);
renderRegister();
const loginButton = screen.getByTestId('login-button');
expect(loginButton).toHaveAttribute('href', '/login/');
});
test('should handle app root with trailing slash', () => {
mockApplicationRoot.mockReturnValue('/superset/');
renderRegister();
const loginButton = screen.getByTestId('login-button');
expect(loginButton).toHaveAttribute('href', '/superset/login/');
});

View File

@@ -28,7 +28,6 @@ import {
} from '@superset-ui/core/components';
import { useState } from 'react';
import getBootstrapData from 'src/utils/getBootstrapData';
import { ensureAppRoot } from 'src/utils/pathUtils';
import ReactCAPTCHA from 'react-google-recaptcha';
import { useParams } from 'react-router-dom';
@@ -88,11 +87,7 @@ export default function Login() {
title="Registration successful"
subTitle="Your account is activated. You can log in with your credentials."
extra={[
<Button
type="default"
href={ensureAppRoot('/login/')}
data-test="login-button"
>
<Button type="default" href="/login/" data-test="login-button">
{t('Login')}
</Button>,
]}

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;
@@ -163,7 +165,6 @@ export interface CommonBootstrapData {
menu_data: MenuData;
d3_format: Partial<FormatLocaleDefinition>;
d3_time_format: Partial<TimeLocaleDefinition>;
pdf_compression_level: 'NONE' | 'FAST' | 'MEDIUM' | 'SLOW';
}
export interface BootstrapData {

View File

@@ -21,9 +21,6 @@ import domToPdf from 'dom-to-pdf';
import { kebabCase } from 'lodash';
import { logging, t } from '@superset-ui/core';
import { addWarningToast } from 'src/components/MessageToasts/actions';
import getBootstrapData from 'src/utils/getBootstrapData';
const pdfCompressionLevel = getBootstrapData().common.pdf_compression_level;
/**
* generate a consistent file stem from a description and date
@@ -61,7 +58,6 @@ export default function downloadAsPdf(
const options = {
margin: 10,
compression: pdfCompressionLevel,
filename: `${generateFileStem(description)}.pdf`,
image: { type: 'jpeg', quality: 1 },
html2canvas: { scale: 2 },

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

@@ -115,7 +115,6 @@ PACKAGE_JSON_FILE = str(files("superset") / "static/assets/package.json")
# "rel": "icon"
# },
FAVICONS = [{"href": "/static/assets/images/favicon.png"}]
PDF_COMPRESSION_LEVEL: Literal["NONE", "FAST", "MEDIUM", "SLOW"] = "MEDIUM"
def _try_json_readversion(filepath: str) -> str | None:

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,
@@ -488,7 +490,6 @@ def cached_common_bootstrap_data( # pylint: disable=unused-argument
"EXTRA_CATEGORICAL_COLOR_SCHEMES"
],
"menu_data": menu_data(g.user),
"pdf_compression_level": app.config["PDF_COMPRESSION_LEVEL"],
}
bootstrap_data.update(app.config["COMMON_BOOTSTRAP_OVERRIDES_FUNC"](bootstrap_data))
@@ -498,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]:
@@ -593,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 = (
@@ -613,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(
@@ -625,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