mirror of
https://github.com/apache/superset.git
synced 2026-07-12 17:55:38 +00:00
Compare commits
2 Commits
msyavuz/te
...
fix-mysql-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c88f2d61d | ||
|
|
66f6a6ce94 |
4
LLMS.md
4
LLMS.md
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
1
superset-frontend/.gitignore
vendored
1
superset-frontend/.gitignore
vendored
@@ -3,4 +3,3 @@ cypress/screenshots
|
||||
cypress/videos
|
||||
src/temp
|
||||
.temp_cache/
|
||||
.tsbuildinfo
|
||||
|
||||
8
superset-frontend/package-lock.json
generated
8
superset-frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,6 @@ const SqlEditorLeftBar = ({
|
||||
'dbId',
|
||||
'catalog',
|
||||
'schema',
|
||||
'tabViewId',
|
||||
]);
|
||||
|
||||
const [_emptyResultsWithSearch, setEmptyResultsWithSearch] = useState(false);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
51
superset-frontend/src/components/FlashProvider/index.tsx
Normal file
51
superset-frontend/src/components/FlashProvider/index.tsx
Normal 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 };
|
||||
20
superset-frontend/src/components/FlashProvider/types.ts
Normal file
20
superset-frontend/src/components/FlashProvider/types.ts
Normal 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];
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -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')),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
export interface QueryExecutePayload {
|
||||
client_id: string;
|
||||
database_id: number;
|
||||
json: boolean;
|
||||
runAsync: boolean;
|
||||
catalog: string | null;
|
||||
schema: string;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, []);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ export interface ExploreResponsePayload {
|
||||
export interface ExplorePageState {
|
||||
user: UserWithPermissionsAndRoles;
|
||||
common: {
|
||||
flash_messages: string[];
|
||||
conf: JsonObject;
|
||||
locale: string;
|
||||
};
|
||||
|
||||
@@ -33,7 +33,7 @@ jest.mock('src/utils/getBootstrapData', () => ({
|
||||
}));
|
||||
|
||||
test('should render login form elements', () => {
|
||||
render(<Login />, { useRedux: true });
|
||||
render(<Login />);
|
||||
expect(screen.getByTestId('login-form')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('username-input')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('password-input')).toBeInTheDocument();
|
||||
@@ -42,13 +42,13 @@ test('should render login form elements', () => {
|
||||
});
|
||||
|
||||
test('should render username and password labels', () => {
|
||||
render(<Login />, { useRedux: true });
|
||||
render(<Login />);
|
||||
expect(screen.getByText('Username:')).toBeInTheDocument();
|
||||
expect(screen.getByText('Password:')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render form instruction text', () => {
|
||||
render(<Login />, { useRedux: true });
|
||||
render(<Login />);
|
||||
expect(
|
||||
screen.getByText('Enter your login and password below:'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
@@ -27,10 +27,8 @@ import {
|
||||
Typography,
|
||||
Icons,
|
||||
} from '@superset-ui/core/components';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { capitalize } from 'lodash/fp';
|
||||
import { addDangerToast } from 'src/components/MessageToasts/actions';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import getBootstrapData from 'src/utils/getBootstrapData';
|
||||
|
||||
type OAuthProvider = {
|
||||
@@ -79,7 +77,6 @@ const StyledLabel = styled(Typography.Text)`
|
||||
export default function Login() {
|
||||
const [form] = Form.useForm<LoginForm>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const bootstrapData = getBootstrapData();
|
||||
|
||||
@@ -88,28 +85,11 @@ export default function Login() {
|
||||
const authRegistration: boolean =
|
||||
bootstrapData.common.conf.AUTH_USER_REGISTRATION;
|
||||
|
||||
// TODO: This is a temporary solution for showing login errors after form submission.
|
||||
// Should be replaced with proper SPA-style authentication (JSON API with error responses)
|
||||
// when Flask-AppBuilder is updated or we implement a custom login endpoint.
|
||||
useEffect(() => {
|
||||
const loginAttempted = sessionStorage.getItem('login_attempted');
|
||||
|
||||
if (loginAttempted === 'true') {
|
||||
sessionStorage.removeItem('login_attempted');
|
||||
dispatch(addDangerToast(t('Invalid username or password')));
|
||||
// Clear password field for security
|
||||
form.setFieldsValue({ password: '' });
|
||||
}
|
||||
}, [dispatch, form]);
|
||||
|
||||
const onFinish = (values: LoginForm) => {
|
||||
setLoading(true);
|
||||
|
||||
// Mark that we're attempting login (for error detection after redirect)
|
||||
sessionStorage.setItem('login_attempted', 'true');
|
||||
|
||||
// Use standard form submission for Flask-AppBuilder compatibility
|
||||
SupersetClient.postForm('/login/', values, '');
|
||||
SupersetClient.postForm('/login/', values, '').finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
const getAuthIconElement = (
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -625,7 +625,7 @@ DEFAULT_FEATURE_FLAGS: dict[str, bool] = {
|
||||
# in addition to relative timeshifts (e.g., "1 day ago")
|
||||
"DATE_RANGE_TIMESHIFTS_ENABLED": False,
|
||||
# Enable Matrixify feature for matrix-style chart layouts
|
||||
"MATRIXIFY": True,
|
||||
"MATRIXIFY": False,
|
||||
# Force garbage collection after every request
|
||||
"FORCE_GARBAGE_COLLECTION_AFTER_EVERY_REQUEST": False,
|
||||
}
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -27,7 +27,9 @@ from babel import Locale
|
||||
from flask import (
|
||||
abort,
|
||||
current_app as app,
|
||||
flash,
|
||||
g,
|
||||
get_flashed_messages,
|
||||
redirect,
|
||||
Response,
|
||||
session,
|
||||
@@ -497,7 +499,10 @@ def cached_common_bootstrap_data( # pylint: disable=unused-argument
|
||||
|
||||
|
||||
def common_bootstrap_payload() -> dict[str, Any]:
|
||||
return cached_common_bootstrap_data(utils.get_user_id(), get_locale())
|
||||
return {
|
||||
**cached_common_bootstrap_data(utils.get_user_id(), get_locale()),
|
||||
"flash_messages": get_flashed_messages(with_categories=True),
|
||||
}
|
||||
|
||||
|
||||
def get_spa_payload(extra_data: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
@@ -592,7 +597,7 @@ class DeleteMixin: # pylint: disable=too-few-public-methods
|
||||
try:
|
||||
self.pre_delete(item)
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
logger.error("Pre-delete error: %s", str(ex))
|
||||
flash(str(ex), "danger")
|
||||
else:
|
||||
view_menu = security_manager.find_view_menu(item.get_perm())
|
||||
pvs = (
|
||||
@@ -612,6 +617,7 @@ class DeleteMixin: # pylint: disable=too-few-public-methods
|
||||
|
||||
db.session.commit() # pylint: disable=consider-using-transaction
|
||||
|
||||
flash(*self.datamodel.message)
|
||||
self.update_redirect()
|
||||
|
||||
@action(
|
||||
@@ -624,7 +630,7 @@ class DeleteMixin: # pylint: disable=too-few-public-methods
|
||||
try:
|
||||
self.pre_delete(item)
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
logger.error("Pre-delete error: %s", str(ex))
|
||||
flash(str(ex), "danger")
|
||||
else:
|
||||
self._delete(item.id)
|
||||
self.update_redirect()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user