mirror of
https://github.com/apache/superset.git
synced 2026-07-03 21:35:32 +00:00
Compare commits
18 Commits
fix-mysql-
...
fix-app-ro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ce3b10b8b | ||
|
|
0e3385b9e7 | ||
|
|
ee5faac08b | ||
|
|
7a5553a6f8 | ||
|
|
77a5969dc1 | ||
|
|
fb9032c05c | ||
|
|
7a9dbfe879 | ||
|
|
0de78d8203 | ||
|
|
abc2d46fed | ||
|
|
927cc1cda1 | ||
|
|
7f3840557a | ||
|
|
0defcb604b | ||
|
|
94686ddfbe | ||
|
|
ec322dfd8d | ||
|
|
cb88d886c7 | ||
|
|
608e3baf43 | ||
|
|
b6f6b75348 | ||
|
|
a5ad1d186c |
4
LLMS.md
4
LLMS.md
@@ -70,6 +70,10 @@ 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,13 +413,6 @@ 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,3 +3,4 @@ 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.3",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
|
||||
"integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -60740,7 +60740,7 @@
|
||||
},
|
||||
"packages/superset-core": {
|
||||
"name": "@apache-superset/core",
|
||||
"version": "0.0.1-rc4",
|
||||
"version": "0.0.1-rc5",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.26.4",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { getColorBreakpointsBuckets } from './utils';
|
||||
import { getColorBreakpointsBuckets, getBreakPoints } from './utils';
|
||||
import { ColorBreakpointType } from './types';
|
||||
|
||||
describe('getColorBreakpointsBuckets', () => {
|
||||
@@ -44,3 +44,447 @@ 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,19 +75,35 @@ 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;
|
||||
|
||||
return new Array(numBuckets + 1 + extraBucket)
|
||||
.fill(0)
|
||||
.map((_, i) => (startValue + i * delta).toFixed(precision));
|
||||
// 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 formDataBreakPoints.sort(
|
||||
@@ -146,7 +162,10 @@ export function getBreakPointColorScaler(
|
||||
scaler = scaleThreshold<number, string>()
|
||||
.domain(points)
|
||||
.range(bucketedColors);
|
||||
maskPoint = value => !!value && (value > points[n] || value < points[0]);
|
||||
// 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]);
|
||||
} else {
|
||||
// interpolate colors linearly
|
||||
const linearScaleDomain = extent(features, accessor);
|
||||
|
||||
@@ -322,12 +322,6 @@ 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,
|
||||
@@ -460,7 +454,11 @@ export default function transformProps(
|
||||
theme,
|
||||
},
|
||||
);
|
||||
if (transformedSeries) series.push(transformedSeries);
|
||||
|
||||
if (transformedSeries) {
|
||||
series.push(transformedSeries);
|
||||
mapSeriesIdToAxis(transformedSeries, yAxisIndex);
|
||||
}
|
||||
});
|
||||
|
||||
rawSeriesB.forEach(entry => {
|
||||
@@ -528,7 +526,11 @@ export default function transformProps(
|
||||
theme,
|
||||
},
|
||||
);
|
||||
if (transformedSeries) series.push(transformedSeries);
|
||||
|
||||
if (transformedSeries) {
|
||||
series.push(transformedSeries);
|
||||
mapSeriesIdToAxis(transformedSeries, yAxisIndexB);
|
||||
}
|
||||
});
|
||||
|
||||
// default to 0-100% range when doing row-level contribution chart
|
||||
|
||||
@@ -49,7 +49,6 @@ export default {
|
||||
dash_edit_perm: true,
|
||||
dash_save_perm: true,
|
||||
common: {
|
||||
flash_messages: [],
|
||||
conf: { SUPERSET_WEBSERVER_TIMEOUT: 60 },
|
||||
},
|
||||
filterBarOrientation: FilterBarOrientation.Vertical,
|
||||
|
||||
@@ -338,7 +338,6 @@ 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,
|
||||
@@ -956,7 +955,7 @@ export function addTable(queryEditor, tableName, catalogName, schemaName) {
|
||||
const { dbId } = getUpToDateQuery(getState(), queryEditor, queryEditor.id);
|
||||
const table = {
|
||||
dbId,
|
||||
queryEditorId: queryEditor.id,
|
||||
queryEditorId: queryEditor.tabViewId ?? queryEditor.id,
|
||||
catalog: catalogName,
|
||||
schema: schemaName,
|
||||
name: tableName,
|
||||
|
||||
@@ -1002,6 +1002,85 @@ 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,6 +65,7 @@ const AceEditorWrapper = ({
|
||||
'catalog',
|
||||
'schema',
|
||||
'templateParams',
|
||||
'tabViewId',
|
||||
]);
|
||||
// Prevent a maximum update depth exceeded error
|
||||
// by skipping access the unsaved query editor state
|
||||
@@ -172,6 +173,7 @@ const AceEditorWrapper = ({
|
||||
dbId: queryEditor.dbId,
|
||||
catalog: queryEditor.catalog,
|
||||
schema: queryEditor.schema,
|
||||
tabViewId: queryEditor.tabViewId,
|
||||
},
|
||||
!autocomplete,
|
||||
);
|
||||
|
||||
@@ -44,6 +44,7 @@ type Params = {
|
||||
dbId?: string | number;
|
||||
catalog?: string | null;
|
||||
schema?: string;
|
||||
tabViewId?: string;
|
||||
};
|
||||
|
||||
const EMPTY_LIST = [] as typeof sqlKeywords;
|
||||
@@ -59,7 +60,7 @@ const getHelperText = (value: string) =>
|
||||
const extensionsRegistry = getExtensionsRegistry();
|
||||
|
||||
export function useKeywords(
|
||||
{ queryEditorId, dbId, catalog, schema }: Params,
|
||||
{ queryEditorId, dbId, catalog, schema, tabViewId }: Params,
|
||||
skip = false,
|
||||
) {
|
||||
const useCustomKeywords = extensionsRegistry.get(
|
||||
@@ -147,7 +148,12 @@ export function useKeywords(
|
||||
const insertMatch = useEffectEvent((editor: Editor, data: any) => {
|
||||
if (data.meta === 'table') {
|
||||
dispatch(
|
||||
addTable({ id: queryEditorId, dbId }, data.value, catalog, schema),
|
||||
addTable(
|
||||
{ id: queryEditorId, dbId, tabViewId },
|
||||
data.value,
|
||||
catalog,
|
||||
schema,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ const SqlEditorLeftBar = ({
|
||||
'dbId',
|
||||
'catalog',
|
||||
'schema',
|
||||
'tabViewId',
|
||||
]);
|
||||
|
||||
const [_emptyResultsWithSearch, setEmptyResultsWithSearch] = useState(false);
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
*/
|
||||
import {
|
||||
forwardRef,
|
||||
Key,
|
||||
ReactNode,
|
||||
RefObject,
|
||||
useCallback,
|
||||
@@ -43,18 +42,18 @@ import {
|
||||
useTheme,
|
||||
} from '@superset-ui/core';
|
||||
import { RootState } from 'src/dashboard/types';
|
||||
import { Menu } from '@superset-ui/core/components/Menu';
|
||||
import { MenuItem } from '@superset-ui/core/components/Menu';
|
||||
import { usePermissions } from 'src/hooks/usePermissions';
|
||||
import { Dropdown } from '@superset-ui/core/components';
|
||||
import { updateDataMask } from 'src/dataMask/actions';
|
||||
import DrillByModal from 'src/components/Chart/DrillBy/DrillByModal';
|
||||
import { useDatasetDrillInfo } from 'src/hooks/apiResources/datasets';
|
||||
import { ResourceStatus } from 'src/hooks/apiResources/apiResources';
|
||||
import { DrillDetailMenuItems } from '../DrillDetail';
|
||||
import { useDrillDetailMenuItems } from '../useDrillDetailMenuItems';
|
||||
import { getMenuAdjustedY } from '../utils';
|
||||
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
|
||||
import { DrillByMenuItems } from '../DrillBy/DrillByMenuItems';
|
||||
import { DrillBySubmenu } from '../DrillBy/DrillBySubmenu';
|
||||
import DrillDetailModal from '../DrillDetail/DrillDetailModal';
|
||||
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
|
||||
|
||||
export enum ContextMenuItem {
|
||||
CrossFilter,
|
||||
@@ -94,8 +93,8 @@ const ChartContextMenu = (
|
||||
}: ChartContextMenuProps,
|
||||
ref: RefObject<ChartContextMenuRef>,
|
||||
) => {
|
||||
const theme = useTheme();
|
||||
const dispatch = useDispatch();
|
||||
const theme = useTheme();
|
||||
const { canDrillToDetail, canDrillBy, canDownload } = usePermissions();
|
||||
|
||||
const crossFiltersEnabled = useSelector<RootState, boolean>(
|
||||
@@ -104,7 +103,6 @@ const ChartContextMenu = (
|
||||
const dashboardId = useSelector<RootState, number>(
|
||||
({ dashboardInfo }) => dashboardInfo.id,
|
||||
);
|
||||
const [openKeys, setOpenKeys] = useState<Key[]>([]);
|
||||
|
||||
const [modalFilters, setFilters] = useState<BinaryQueryObjectFilterClause[]>(
|
||||
[],
|
||||
@@ -160,7 +158,6 @@ const ChartContextMenu = (
|
||||
|
||||
const closeContextMenu = useCallback(() => {
|
||||
setVisible(false);
|
||||
setOpenKeys([]);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
@@ -177,7 +174,7 @@ const ChartContextMenu = (
|
||||
setShowDrillByModal(false);
|
||||
}, []);
|
||||
|
||||
const menuItems: React.JSX.Element[] = [];
|
||||
const menuItems: MenuItem[] = [];
|
||||
|
||||
const showDrillToDetail =
|
||||
isFeatureEnabled(FeatureFlag.DrillToDetail) &&
|
||||
@@ -264,6 +261,20 @@ const ChartContextMenu = (
|
||||
itemsCount = 1; // "No actions" appears if no actions in menu
|
||||
}
|
||||
|
||||
const drillDetailMenuItems = useDrillDetailMenuItems({
|
||||
formData: drillFormData,
|
||||
filters: filters?.drillToDetail,
|
||||
setFilters,
|
||||
isContextMenu: true,
|
||||
contextMenuY: clientY,
|
||||
onSelection,
|
||||
submenuIndex: showCrossFilters ? 2 : 1,
|
||||
setShowModal: setDrillModalIsOpen,
|
||||
dataset: filteredDataset,
|
||||
isLoadingDataset,
|
||||
...(additionalConfig?.drillToDetail || {}),
|
||||
});
|
||||
|
||||
if (showCrossFilters) {
|
||||
const isCrossFilterDisabled =
|
||||
!isCrossFilteringSupportedByChart ||
|
||||
@@ -305,74 +316,65 @@ const ChartContextMenu = (
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
menuItems.push(
|
||||
<>
|
||||
<Menu.Item
|
||||
key="cross-filtering-menu-item"
|
||||
disabled={isCrossFilterDisabled}
|
||||
onClick={() => {
|
||||
if (filters?.crossFilter) {
|
||||
dispatch(updateDataMask(id, filters.crossFilter.dataMask));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{filters?.crossFilter?.isCurrentValueSelected ? (
|
||||
t('Remove cross-filter')
|
||||
) : (
|
||||
<div>
|
||||
{t('Add cross-filter')}
|
||||
<MenuItemTooltip
|
||||
title={crossFilteringTooltipTitle}
|
||||
color={!isCrossFilterDisabled ? theme.colorIcon : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Menu.Item>
|
||||
{itemsCount > 1 && <Menu.Divider />}
|
||||
</>,
|
||||
{
|
||||
key: 'cross-filtering-menu-item',
|
||||
label: filters?.crossFilter?.isCurrentValueSelected ? (
|
||||
t('Remove cross-filter')
|
||||
) : (
|
||||
<span>
|
||||
{t('Add cross-filter')}
|
||||
<MenuItemTooltip
|
||||
title={crossFilteringTooltipTitle}
|
||||
color={!isCrossFilterDisabled ? theme.colorIcon : undefined}
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
disabled: isCrossFilterDisabled,
|
||||
onClick: () => {
|
||||
if (filters?.crossFilter) {
|
||||
dispatch(updateDataMask(id, filters.crossFilter.dataMask));
|
||||
}
|
||||
},
|
||||
},
|
||||
...(itemsCount > 1
|
||||
? [{ key: 'divider-1', type: 'divider' as const }]
|
||||
: []),
|
||||
);
|
||||
}
|
||||
if (showDrillToDetail) {
|
||||
menuItems.push(
|
||||
<DrillDetailMenuItems
|
||||
formData={drillFormData}
|
||||
filters={filters?.drillToDetail}
|
||||
setFilters={setFilters}
|
||||
isContextMenu
|
||||
contextMenuY={clientY}
|
||||
onSelection={onSelection}
|
||||
submenuIndex={showCrossFilters ? 2 : 1}
|
||||
setShowModal={setDrillModalIsOpen}
|
||||
dataset={filteredDataset}
|
||||
isLoadingDataset={isLoadingDataset}
|
||||
{...(additionalConfig?.drillToDetail || {})}
|
||||
/>,
|
||||
);
|
||||
menuItems.push(...drillDetailMenuItems);
|
||||
}
|
||||
|
||||
if (showDrillBy) {
|
||||
let submenuIndex = 0;
|
||||
if (showCrossFilters) {
|
||||
submenuIndex += 1;
|
||||
if (menuItems.length > 0) {
|
||||
menuItems.push({ key: 'divider-drill-by', type: 'divider' as const });
|
||||
}
|
||||
if (showDrillToDetail) {
|
||||
submenuIndex += 2;
|
||||
}
|
||||
menuItems.push(
|
||||
<DrillByMenuItems
|
||||
drillByConfig={enhancedFilters?.drillBy}
|
||||
onSelection={onSelection}
|
||||
onCloseMenu={closeContextMenu}
|
||||
formData={formData}
|
||||
contextMenuY={clientY}
|
||||
submenuIndex={submenuIndex}
|
||||
open={openKeys.includes('drill-by-submenu')}
|
||||
key="drill-by-submenu"
|
||||
onDrillBy={handleDrillBy}
|
||||
dataset={filteredDataset}
|
||||
isLoadingDataset={isLoadingDataset}
|
||||
{...(additionalConfig?.drillBy || {})}
|
||||
/>,
|
||||
);
|
||||
|
||||
const hasDrillBy = enhancedFilters?.drillBy?.groupbyFieldName;
|
||||
const handlesDimensionContextMenu = getChartMetadataRegistry()
|
||||
.get(formData.viz_type)
|
||||
?.behaviors.find(behavior => behavior === Behavior.DrillBy);
|
||||
const isDrillByDisabled = !handlesDimensionContextMenu || !hasDrillBy;
|
||||
|
||||
// Add a custom render component for DrillBy submenu to support react-window
|
||||
menuItems.push({
|
||||
key: 'drill-by-submenu',
|
||||
disabled: isDrillByDisabled,
|
||||
label: (
|
||||
<DrillBySubmenu
|
||||
drillByConfig={enhancedFilters?.drillBy}
|
||||
onSelection={onSelection}
|
||||
onCloseMenu={closeContextMenu}
|
||||
formData={formData}
|
||||
onDrillBy={handleDrillBy}
|
||||
dataset={filteredDataset}
|
||||
isLoadingDataset={isLoadingDataset}
|
||||
{...(additionalConfig?.drillBy || {})}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const open = useCallback(
|
||||
@@ -404,30 +406,22 @@ const ChartContextMenu = (
|
||||
return ReactDOM.createPortal(
|
||||
<>
|
||||
<Dropdown
|
||||
popupRender={() => (
|
||||
<Menu
|
||||
className="chart-context-menu"
|
||||
data-test="chart-context-menu"
|
||||
onOpenChange={setOpenKeys}
|
||||
onClick={() => {
|
||||
setVisible(false);
|
||||
setOpenKeys([]);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{menuItems.length ? (
|
||||
menuItems
|
||||
) : (
|
||||
<Menu.Item disabled>{t('No actions')}</Menu.Item>
|
||||
)}
|
||||
</Menu>
|
||||
menu={{
|
||||
items:
|
||||
menuItems.length > 0
|
||||
? menuItems
|
||||
: [{ key: 'no-actions', label: t('No actions'), disabled: true }],
|
||||
onClick: () => {
|
||||
setVisible(false);
|
||||
onClose();
|
||||
},
|
||||
}}
|
||||
dropdownRender={menu => (
|
||||
<div data-test="chart-context-menu">{menu}</div>
|
||||
)}
|
||||
trigger={['click']}
|
||||
onOpenChange={value => {
|
||||
setVisible(value);
|
||||
if (!value) {
|
||||
setOpenKeys([]);
|
||||
}
|
||||
}}
|
||||
open={visible}
|
||||
>
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
CSSProperties,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Menu } from '@superset-ui/core/components/Menu';
|
||||
import {
|
||||
BaseFormData,
|
||||
Behavior,
|
||||
Column,
|
||||
ContextMenuFilters,
|
||||
css,
|
||||
ensureIsArray,
|
||||
getChartMetadataRegistry,
|
||||
t,
|
||||
useTheme,
|
||||
} from '@superset-ui/core';
|
||||
import { Constants, Input, Loading } from '@superset-ui/core/components';
|
||||
import { debounce } from 'lodash';
|
||||
import { FixedSizeList as List } from 'react-window';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { InputRef } from 'antd';
|
||||
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
|
||||
import { getSubmenuYOffset } from '../utils';
|
||||
import { VirtualizedMenuItem } from '../MenuItemWithTruncation';
|
||||
import { Dataset } from '../types';
|
||||
|
||||
const SUBMENU_HEIGHT = 200;
|
||||
const SHOW_COLUMNS_SEARCH_THRESHOLD = 10;
|
||||
const SEARCH_INPUT_HEIGHT = 48;
|
||||
|
||||
export interface DrillByMenuItemsProps {
|
||||
drillByConfig?: ContextMenuFilters['drillBy'];
|
||||
formData: BaseFormData & { [key: string]: any };
|
||||
contextMenuY?: number;
|
||||
submenuIndex?: number;
|
||||
onSelection?: (...args: any) => void;
|
||||
onClick?: (event: MouseEvent) => void;
|
||||
onCloseMenu?: () => void;
|
||||
openNewModal?: boolean;
|
||||
excludedColumns?: Column[];
|
||||
open: boolean;
|
||||
onDrillBy?: (column: Column, dataset: Dataset) => void;
|
||||
dataset?: Dataset;
|
||||
isLoadingDataset?: boolean;
|
||||
}
|
||||
|
||||
export const DrillByMenuItems = ({
|
||||
drillByConfig,
|
||||
formData,
|
||||
contextMenuY = 0,
|
||||
submenuIndex = 0,
|
||||
onSelection = () => {},
|
||||
onClick = () => {},
|
||||
onCloseMenu = () => {},
|
||||
excludedColumns,
|
||||
openNewModal = true,
|
||||
open,
|
||||
onDrillBy,
|
||||
dataset,
|
||||
isLoadingDataset = false,
|
||||
...rest
|
||||
}: DrillByMenuItemsProps) => {
|
||||
const theme = useTheme();
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [debouncedSearchInput, setDebouncedSearchInput] = useState('');
|
||||
const ref = useRef<InputRef>(null);
|
||||
const columns = dataset ? ensureIsArray(dataset.drillable_columns) : [];
|
||||
const showSearch = columns.length > SHOW_COLUMNS_SEARCH_THRESHOLD;
|
||||
|
||||
const handleSelection = useCallback(
|
||||
(event, column) => {
|
||||
onClick(event);
|
||||
onSelection(column, drillByConfig);
|
||||
if (openNewModal && onDrillBy && dataset) {
|
||||
onDrillBy(column, dataset);
|
||||
}
|
||||
onCloseMenu();
|
||||
},
|
||||
[drillByConfig, onClick, onSelection, openNewModal, onDrillBy, dataset],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
ref.current?.input?.focus({ preventScroll: true });
|
||||
} else {
|
||||
// Reset search input when menu is closed
|
||||
setSearchInput('');
|
||||
setDebouncedSearchInput('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const hasDrillBy = drillByConfig?.groupbyFieldName;
|
||||
|
||||
const handlesDimensionContextMenu = useMemo(
|
||||
() =>
|
||||
getChartMetadataRegistry()
|
||||
.get(formData.viz_type)
|
||||
?.behaviors.find(behavior => behavior === Behavior.DrillBy),
|
||||
[formData.viz_type],
|
||||
);
|
||||
|
||||
const debouncedSetSearchInput = useMemo(
|
||||
() =>
|
||||
debounce((value: string) => {
|
||||
setDebouncedSearchInput(value);
|
||||
}, Constants.FAST_DEBOUNCE),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleInput = (value: string) => {
|
||||
setSearchInput(value);
|
||||
debouncedSetSearchInput(value);
|
||||
};
|
||||
|
||||
const filteredColumns = useMemo(
|
||||
() =>
|
||||
columns.filter(column =>
|
||||
(column.verbose_name || column.column_name)
|
||||
.toLowerCase()
|
||||
.includes(debouncedSearchInput.toLowerCase()),
|
||||
),
|
||||
[columns, debouncedSearchInput],
|
||||
);
|
||||
|
||||
const submenuYOffset = useMemo(
|
||||
() =>
|
||||
getSubmenuYOffset(
|
||||
contextMenuY,
|
||||
filteredColumns.length || 1,
|
||||
submenuIndex,
|
||||
SUBMENU_HEIGHT,
|
||||
showSearch ? SEARCH_INPUT_HEIGHT : 0,
|
||||
),
|
||||
[contextMenuY, filteredColumns.length, submenuIndex, showSearch],
|
||||
);
|
||||
|
||||
let tooltip: ReactNode;
|
||||
|
||||
if (!handlesDimensionContextMenu) {
|
||||
tooltip = t('Drill by is not yet supported for this chart type');
|
||||
} else if (!hasDrillBy) {
|
||||
tooltip = t('Drill by is not available for this data point');
|
||||
}
|
||||
|
||||
if (!handlesDimensionContextMenu || !hasDrillBy) {
|
||||
return (
|
||||
<Menu.Item key="drill-by-disabled" disabled {...rest}>
|
||||
<div>
|
||||
{t('Drill by')}
|
||||
<MenuItemTooltip title={tooltip} />
|
||||
</div>
|
||||
</Menu.Item>
|
||||
);
|
||||
}
|
||||
|
||||
const Row = ({
|
||||
index,
|
||||
data,
|
||||
style,
|
||||
}: {
|
||||
index: number;
|
||||
data: { columns: Column[] };
|
||||
style: CSSProperties;
|
||||
}) => {
|
||||
const { columns, ...rest } = data;
|
||||
const column = columns[index];
|
||||
return (
|
||||
<VirtualizedMenuItem
|
||||
tooltipText={column.verbose_name || column.column_name}
|
||||
onClick={e => handleSelection(e, column)}
|
||||
style={style}
|
||||
{...rest}
|
||||
>
|
||||
{column.verbose_name || column.column_name}
|
||||
</VirtualizedMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
// Don't render drill by menu items when matrixify is enabled
|
||||
if (
|
||||
formData.matrixify_enable_vertical_layout === true ||
|
||||
formData.matrixify_enable_horizontal_layout === true
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu.SubMenu
|
||||
key="drill-by-submenu"
|
||||
title={t('Drill by')}
|
||||
popupClassName="chart-context-submenu"
|
||||
popupOffset={[0, submenuYOffset]}
|
||||
{...rest}
|
||||
>
|
||||
<div data-test="drill-by-submenu">
|
||||
{showSearch && (
|
||||
<Input
|
||||
ref={ref}
|
||||
prefix={
|
||||
<Icons.SearchOutlined
|
||||
iconSize="l"
|
||||
iconColor={theme.colorIcon}
|
||||
/>
|
||||
}
|
||||
onChange={e => {
|
||||
e.stopPropagation();
|
||||
handleInput(e.target.value);
|
||||
}}
|
||||
placeholder={t('Search columns')}
|
||||
onClick={e => {
|
||||
// prevent closing menu when clicking on input
|
||||
e.nativeEvent.stopImmediatePropagation();
|
||||
}}
|
||||
allowClear
|
||||
css={css`
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
margin: ${theme.sizeUnit * 2}px ${theme.sizeUnit * 3}px;
|
||||
box-shadow: none;
|
||||
`}
|
||||
value={searchInput}
|
||||
/>
|
||||
)}
|
||||
{isLoadingDataset ? (
|
||||
<div
|
||||
css={css`
|
||||
padding: ${theme.sizeUnit * 3}px 0;
|
||||
`}
|
||||
>
|
||||
<Loading position="inline-centered" />
|
||||
</div>
|
||||
) : filteredColumns.length ? (
|
||||
<List
|
||||
width="100%"
|
||||
height={SUBMENU_HEIGHT}
|
||||
itemSize={35}
|
||||
itemCount={filteredColumns.length}
|
||||
itemData={{ columns: filteredColumns, ...rest }}
|
||||
overscanCount={20}
|
||||
>
|
||||
{Row}
|
||||
</List>
|
||||
) : (
|
||||
<Menu.Item disabled key="no-drill-by-columns-found" {...rest}>
|
||||
{t('No columns found')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</div>
|
||||
</Menu.SubMenu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -30,9 +30,8 @@ import {
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import chartQueries, { sliceId } from 'spec/fixtures/mockChartQueries';
|
||||
import { Menu } from '@superset-ui/core/components/Menu';
|
||||
import { supersetGetCache } from 'src/utils/cachedSupersetGet';
|
||||
import { DrillByMenuItems, DrillByMenuItemsProps } from './DrillByMenuItems';
|
||||
import { DrillBySubmenu, DrillBySubmenuProps } from './DrillBySubmenu';
|
||||
|
||||
/* eslint jest/expect-expect: ["warn", { "assertFunctionNames": ["expect*"] }] */
|
||||
|
||||
@@ -79,37 +78,29 @@ const defaultFilters = [
|
||||
},
|
||||
];
|
||||
|
||||
const renderMenu = ({
|
||||
const renderSubmenu = ({
|
||||
formData = defaultFormData,
|
||||
drillByConfig = { filters: defaultFilters, groupbyFieldName: 'groupby' },
|
||||
dataset = mockDataset,
|
||||
...rest
|
||||
}: Partial<DrillByMenuItemsProps>) =>
|
||||
}: Partial<DrillBySubmenuProps>) =>
|
||||
render(
|
||||
<Menu forceSubMenuRender>
|
||||
<DrillByMenuItems
|
||||
formData={formData ?? defaultFormData}
|
||||
drillByConfig={drillByConfig}
|
||||
dataset={dataset}
|
||||
open
|
||||
{...rest}
|
||||
/>
|
||||
</Menu>,
|
||||
<DrillBySubmenu
|
||||
formData={formData ?? defaultFormData}
|
||||
drillByConfig={drillByConfig}
|
||||
dataset={dataset}
|
||||
{...rest}
|
||||
/>,
|
||||
{ useRouter: true, useRedux: true },
|
||||
);
|
||||
|
||||
const expectDrillByDisabled = async (tooltipContent: string) => {
|
||||
const drillByMenuItem = screen
|
||||
.getAllByRole('menuitem')
|
||||
.find(menuItem => within(menuItem).queryByText('Drill by'));
|
||||
const drillByButton = screen.getByRole('button', { name: /drill by/i });
|
||||
expect(drillByButton).toBeInTheDocument();
|
||||
expect(drillByButton).toBeVisible();
|
||||
expect(drillByButton).toHaveAttribute('tabindex', '-1');
|
||||
|
||||
expect(drillByMenuItem).toBeDefined();
|
||||
expect(drillByMenuItem).toBeVisible();
|
||||
expect(drillByMenuItem).toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
const tooltipTrigger = within(drillByMenuItem!).getByTestId(
|
||||
'tooltip-trigger',
|
||||
);
|
||||
const tooltipTrigger = within(drillByButton).getByTestId('tooltip-trigger');
|
||||
userEvent.hover(tooltipTrigger as HTMLElement);
|
||||
|
||||
const tooltip = await screen.findByRole('tooltip', { name: tooltipContent });
|
||||
@@ -117,20 +108,17 @@ const expectDrillByDisabled = async (tooltipContent: string) => {
|
||||
};
|
||||
|
||||
const expectDrillByEnabled = async () => {
|
||||
const drillByMenuItem = screen.getByRole('menuitem', {
|
||||
name: 'Drill by',
|
||||
});
|
||||
expect(drillByMenuItem).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(drillByMenuItem).not.toHaveAttribute('aria-disabled'),
|
||||
);
|
||||
const tooltipTrigger =
|
||||
within(drillByMenuItem).queryByTestId('tooltip-trigger');
|
||||
const drillByButton = screen.getByRole('button', { name: /drill by/i });
|
||||
expect(drillByButton).toBeInTheDocument();
|
||||
expect(drillByButton).not.toHaveAttribute('tabindex', '-1');
|
||||
|
||||
const tooltipTrigger = within(drillByButton).queryByTestId('tooltip-trigger');
|
||||
expect(tooltipTrigger).not.toBeInTheDocument();
|
||||
|
||||
userEvent.hover(within(drillByMenuItem).getByText('Drill by'));
|
||||
const drillBySubmenus = await screen.findAllByTestId('drill-by-submenu');
|
||||
expect(drillBySubmenus[0]).toBeInTheDocument();
|
||||
userEvent.hover(drillByButton);
|
||||
|
||||
const popover = await screen.findByRole('menu');
|
||||
expect(popover).toBeInTheDocument();
|
||||
};
|
||||
|
||||
getChartMetadataRegistry().registerValue(
|
||||
@@ -149,7 +137,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
test('render disabled menu item for unsupported chart', async () => {
|
||||
renderMenu({
|
||||
renderSubmenu({
|
||||
formData: { ...defaultFormData, viz_type: 'unsupported_viz' },
|
||||
});
|
||||
await expectDrillByDisabled(
|
||||
@@ -158,89 +146,75 @@ test('render disabled menu item for unsupported chart', async () => {
|
||||
});
|
||||
|
||||
test('render enabled menu item for supported chart, no filters', async () => {
|
||||
renderMenu({ drillByConfig: { filters: [], groupbyFieldName: 'groupby' } });
|
||||
renderSubmenu({
|
||||
drillByConfig: { filters: [], groupbyFieldName: 'groupby' },
|
||||
});
|
||||
await expectDrillByEnabled();
|
||||
});
|
||||
|
||||
test('render disabled menu item for supported chart, no columns', async () => {
|
||||
const emptyDataset = { ...mockDataset, columns: [], drillable_columns: [] };
|
||||
renderMenu({ dataset: emptyDataset });
|
||||
renderSubmenu({ dataset: emptyDataset });
|
||||
await expectDrillByEnabled();
|
||||
screen.getByText('No columns found');
|
||||
|
||||
const noColumnsText = await screen.findByText('No columns found');
|
||||
expect(noColumnsText).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('render menu item with submenu without searchbox', async () => {
|
||||
const slicedColumns = defaultColumns.slice(0, 9);
|
||||
const slicedColumns = defaultColumns.slice(0, 1); // Use only 1 column to avoid search box
|
||||
const datasetWithSlicedColumns = {
|
||||
...mockDataset,
|
||||
columns: slicedColumns,
|
||||
drillable_columns: slicedColumns,
|
||||
};
|
||||
renderMenu({ dataset: datasetWithSlicedColumns });
|
||||
renderSubmenu({ dataset: datasetWithSlicedColumns });
|
||||
await expectDrillByEnabled();
|
||||
|
||||
// Check that each column appears in the drill-by submenu
|
||||
slicedColumns.forEach(column => {
|
||||
const submenus = screen.getAllByTestId('drill-by-submenu');
|
||||
const submenu = submenus[0]; // Use the first submenu
|
||||
expect(within(submenu).getByText(column.column_name)).toBeInTheDocument();
|
||||
});
|
||||
// Check that the column appears in the popover
|
||||
const col1Element = await screen.findByText('col1');
|
||||
expect(col1Element).toBeInTheDocument();
|
||||
|
||||
// Should not have search box for small number of columns
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Add global timeout for all tests
|
||||
jest.setTimeout(20000);
|
||||
|
||||
test('render menu item with submenu and searchbox', async () => {
|
||||
renderMenu({ dataset: mockDataset });
|
||||
renderSubmenu({ dataset: mockDataset });
|
||||
await expectDrillByEnabled();
|
||||
|
||||
// Wait for all columns to be visible
|
||||
await waitFor(
|
||||
() => {
|
||||
const submenus = screen.getAllByTestId('drill-by-submenu');
|
||||
const submenu = submenus[0];
|
||||
defaultColumns.forEach(column => {
|
||||
expect(
|
||||
within(submenu).getByText(column.column_name),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
// Wait for first column to ensure menu is loaded
|
||||
await screen.findByText('col1');
|
||||
|
||||
const searchbox = await waitFor(
|
||||
() => screen.getAllByPlaceholderText('Search columns')[0],
|
||||
);
|
||||
// Then check all columns are visible
|
||||
defaultColumns.forEach(column => {
|
||||
expect(screen.getByText(column.column_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const searchbox = screen.getByPlaceholderText('Search columns');
|
||||
expect(searchbox).toBeInTheDocument();
|
||||
|
||||
userEvent.type(searchbox, 'col1');
|
||||
|
||||
const expectedFilteredColumnNames = ['col1', 'col10', 'col11'];
|
||||
|
||||
// Wait for filtered results
|
||||
// Wait for filtering to take effect by checking for first filtered item
|
||||
await waitFor(() => {
|
||||
const submenus = screen.getAllByTestId('drill-by-submenu');
|
||||
const submenu = submenus[0];
|
||||
expectedFilteredColumnNames.forEach(colName => {
|
||||
expect(within(submenu).getByText(colName)).toBeInTheDocument();
|
||||
});
|
||||
// Check that non-matching columns are not visible
|
||||
expect(screen.queryByText('col2')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const submenus = screen.getAllByTestId('drill-by-submenu');
|
||||
const submenu = submenus[0];
|
||||
// Then verify all expected columns are visible
|
||||
expectedFilteredColumnNames.forEach(colName => {
|
||||
expect(screen.getByText(colName)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check that non-matching columns are not visible
|
||||
defaultColumns
|
||||
.filter(col => !expectedFilteredColumnNames.includes(col.column_name))
|
||||
.forEach(col => {
|
||||
expect(
|
||||
within(submenu).queryByText(col.column_name),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(col.column_name)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
expectedFilteredColumnNames.forEach(colName => {
|
||||
expect(within(submenu).getByText(colName)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('Do not display excluded column in the menu', async () => {
|
||||
@@ -252,7 +226,7 @@ test('Do not display excluded column in the menu', async () => {
|
||||
...mockDataset,
|
||||
drillable_columns: filteredColumns,
|
||||
};
|
||||
renderMenu({
|
||||
renderSubmenu({
|
||||
dataset: datasetWithFilteredColumns,
|
||||
excludedColumns: excludedColNames.map(colName => ({
|
||||
column_name: colName,
|
||||
@@ -261,32 +235,24 @@ test('Do not display excluded column in the menu', async () => {
|
||||
|
||||
await expectDrillByEnabled();
|
||||
|
||||
// Wait for menu items to be loaded
|
||||
await waitFor(
|
||||
() => {
|
||||
const submenus = screen.getAllByTestId('drill-by-submenu');
|
||||
const submenu = submenus[0];
|
||||
defaultColumns
|
||||
.filter(column => !excludedColNames.includes(column.column_name))
|
||||
.forEach(column => {
|
||||
expect(
|
||||
within(submenu).getByText(column.column_name),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
// Wait for first column to ensure menu is loaded
|
||||
await screen.findByText('col1');
|
||||
|
||||
// Then check all non-excluded columns are visible
|
||||
defaultColumns
|
||||
.filter(column => !excludedColNames.includes(column.column_name))
|
||||
.forEach(column => {
|
||||
expect(screen.getByText(column.column_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const submenus = screen.getAllByTestId('drill-by-submenu');
|
||||
const submenu = submenus[0];
|
||||
excludedColNames.forEach(colName => {
|
||||
expect(within(submenu).queryByText(colName)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(colName)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('When menu item is clicked, call onSelection with clicked column and drill by filters', async () => {
|
||||
const onSelectionMock = jest.fn();
|
||||
renderMenu({
|
||||
renderSubmenu({
|
||||
dataset: mockDataset,
|
||||
onSelection: onSelectionMock,
|
||||
});
|
||||
@@ -294,11 +260,7 @@ test('When menu item is clicked, call onSelection with clicked column and drill
|
||||
await expectDrillByEnabled();
|
||||
|
||||
// Wait for col1 to be visible before clicking
|
||||
const col1Element = await waitFor(() => {
|
||||
const submenus = screen.getAllByTestId('drill-by-submenu');
|
||||
const submenu = submenus[0];
|
||||
return within(submenu).getByText('col1');
|
||||
});
|
||||
const col1Element = await screen.findByText('col1');
|
||||
userEvent.click(col1Element);
|
||||
|
||||
expect(onSelectionMock).toHaveBeenCalledWith(
|
||||
@@ -309,3 +271,10 @@ test('When menu item is clicked, call onSelection with clicked column and drill
|
||||
{ filters: defaultFilters, groupbyFieldName: 'groupby' },
|
||||
);
|
||||
});
|
||||
|
||||
test('matrixify_enable_vertical_layout should not render component', () => {
|
||||
const { container } = renderSubmenu({
|
||||
formData: { ...defaultFormData, matrixify_enable_vertical_layout: true },
|
||||
});
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
CSSProperties,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
BaseFormData,
|
||||
Behavior,
|
||||
Column,
|
||||
ContextMenuFilters,
|
||||
css,
|
||||
ensureIsArray,
|
||||
getChartMetadataRegistry,
|
||||
t,
|
||||
useTheme,
|
||||
} from '@superset-ui/core';
|
||||
import {
|
||||
Constants,
|
||||
Input,
|
||||
Loading,
|
||||
Popover,
|
||||
Icons,
|
||||
} from '@superset-ui/core/components';
|
||||
import { debounce } from 'lodash';
|
||||
import { FixedSizeList as List } from 'react-window';
|
||||
import { InputRef } from 'antd';
|
||||
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
|
||||
import { VirtualizedMenuItem } from '../MenuItemWithTruncation';
|
||||
import { Dataset } from '../types';
|
||||
|
||||
const SUBMENU_HEIGHT = 200;
|
||||
const SHOW_COLUMNS_SEARCH_THRESHOLD = 10;
|
||||
|
||||
export interface DrillBySubmenuProps {
|
||||
drillByConfig?: ContextMenuFilters['drillBy'];
|
||||
formData: BaseFormData & { [key: string]: any };
|
||||
onSelection?: (...args: any) => void;
|
||||
onClick?: (event: MouseEvent) => void;
|
||||
onCloseMenu?: () => void;
|
||||
openNewModal?: boolean;
|
||||
excludedColumns?: Column[];
|
||||
onDrillBy?: (column: Column, dataset: Dataset) => void;
|
||||
dataset?: Dataset;
|
||||
isLoadingDataset?: boolean;
|
||||
}
|
||||
|
||||
export const DrillBySubmenu = ({
|
||||
drillByConfig,
|
||||
formData,
|
||||
onSelection = () => {},
|
||||
onClick = () => {},
|
||||
onCloseMenu = () => {},
|
||||
openNewModal = true,
|
||||
excludedColumns,
|
||||
onDrillBy,
|
||||
dataset,
|
||||
isLoadingDataset = false,
|
||||
...rest
|
||||
}: DrillBySubmenuProps) => {
|
||||
const theme = useTheme();
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [debouncedSearchInput, setDebouncedSearchInput] = useState('');
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const ref = useRef<InputRef>(null);
|
||||
const menuItemRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const columns = useMemo(
|
||||
() => (dataset ? ensureIsArray(dataset.drillable_columns) : []),
|
||||
[dataset],
|
||||
);
|
||||
const showSearch = columns.length > SHOW_COLUMNS_SEARCH_THRESHOLD;
|
||||
|
||||
const handleSelection = useCallback(
|
||||
(event, column) => {
|
||||
onClick(event as MouseEvent);
|
||||
onSelection(column, drillByConfig);
|
||||
if (openNewModal && onDrillBy && dataset) {
|
||||
onDrillBy(column, dataset);
|
||||
}
|
||||
setPopoverOpen(false);
|
||||
onCloseMenu();
|
||||
},
|
||||
[
|
||||
drillByConfig,
|
||||
onClick,
|
||||
onSelection,
|
||||
openNewModal,
|
||||
onDrillBy,
|
||||
dataset,
|
||||
onCloseMenu,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
if (popoverOpen) {
|
||||
// Small delay to ensure popover is rendered
|
||||
timeoutId = setTimeout(() => {
|
||||
ref.current?.input?.focus({ preventScroll: true });
|
||||
}, 100);
|
||||
} else {
|
||||
// Reset search input when menu is closed
|
||||
setSearchInput('');
|
||||
setDebouncedSearchInput('');
|
||||
}
|
||||
return () => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
};
|
||||
}, [popoverOpen]);
|
||||
|
||||
const hasDrillBy = drillByConfig?.groupbyFieldName;
|
||||
|
||||
const handlesDimensionContextMenu = useMemo(
|
||||
() =>
|
||||
getChartMetadataRegistry()
|
||||
.get(formData.viz_type)
|
||||
?.behaviors.find(behavior => behavior === Behavior.DrillBy),
|
||||
[formData.viz_type],
|
||||
);
|
||||
|
||||
const debouncedSetSearchInput = useMemo(
|
||||
() =>
|
||||
debounce((value: string) => {
|
||||
setDebouncedSearchInput(value);
|
||||
}, Constants.FAST_DEBOUNCE),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleInput = (value: string) => {
|
||||
setSearchInput(value);
|
||||
debouncedSetSearchInput(value);
|
||||
};
|
||||
|
||||
const filteredColumns = useMemo(
|
||||
() =>
|
||||
columns
|
||||
.filter(column => {
|
||||
// Filter out excluded columns
|
||||
const excludedColumnNames =
|
||||
excludedColumns?.map(col => col.column_name) || [];
|
||||
return !excludedColumnNames.includes(column.column_name);
|
||||
})
|
||||
.filter(column =>
|
||||
(column.verbose_name || column.column_name)
|
||||
.toLowerCase()
|
||||
.includes(debouncedSearchInput.toLowerCase()),
|
||||
),
|
||||
[columns, debouncedSearchInput, excludedColumns],
|
||||
);
|
||||
|
||||
let tooltip: ReactNode;
|
||||
|
||||
if (!handlesDimensionContextMenu) {
|
||||
tooltip = t('Drill by is not yet supported for this chart type');
|
||||
} else if (!hasDrillBy) {
|
||||
tooltip = t('Drill by is not available for this data point');
|
||||
}
|
||||
|
||||
if (
|
||||
formData.matrixify_enable_vertical_layout === true ||
|
||||
formData.matrixify_enable_horizontal_layout === true
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isDisabled = !handlesDimensionContextMenu || !hasDrillBy;
|
||||
|
||||
const Row = ({
|
||||
index,
|
||||
data,
|
||||
style,
|
||||
}: {
|
||||
index: number;
|
||||
data: { columns: Column[] };
|
||||
style: CSSProperties;
|
||||
}) => {
|
||||
const { columns } = data;
|
||||
const column = columns[index];
|
||||
return (
|
||||
<VirtualizedMenuItem
|
||||
tooltipText={column.verbose_name || column.column_name}
|
||||
onClick={e => handleSelection(e, column)}
|
||||
style={style}
|
||||
>
|
||||
{column.verbose_name || column.column_name}
|
||||
</VirtualizedMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
const popoverContent = (
|
||||
<div
|
||||
role="menu"
|
||||
tabIndex={0}
|
||||
data-test="drill-by-submenu"
|
||||
css={css`
|
||||
width: 220px;
|
||||
max-width: 220px;
|
||||
.ant-input-affix-wrapper {
|
||||
margin-bottom: ${theme.sizeUnit * 2}px;
|
||||
}
|
||||
`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{showSearch && (
|
||||
<Input
|
||||
ref={ref}
|
||||
prefix={
|
||||
<Icons.SearchOutlined iconSize="l" iconColor={theme.colorIcon} />
|
||||
}
|
||||
onChange={e => {
|
||||
e.stopPropagation();
|
||||
handleInput(e.target.value);
|
||||
}}
|
||||
placeholder={t('Search columns')}
|
||||
onClick={e => {
|
||||
// prevent closing menu when clicking on input
|
||||
e.nativeEvent.stopImmediatePropagation();
|
||||
}}
|
||||
allowClear
|
||||
css={css`
|
||||
width: 100%;
|
||||
box-shadow: none;
|
||||
`}
|
||||
value={searchInput}
|
||||
/>
|
||||
)}
|
||||
{isLoadingDataset ? (
|
||||
<div
|
||||
css={css`
|
||||
padding: ${theme.sizeUnit * 3}px 0;
|
||||
`}
|
||||
>
|
||||
<Loading position="inline-centered" />
|
||||
</div>
|
||||
) : filteredColumns.length ? (
|
||||
<List
|
||||
width="100%"
|
||||
height={SUBMENU_HEIGHT}
|
||||
itemSize={35}
|
||||
itemCount={filteredColumns.length}
|
||||
itemData={{ columns: filteredColumns }}
|
||||
overscanCount={20}
|
||||
>
|
||||
{Row}
|
||||
</List>
|
||||
) : (
|
||||
<div
|
||||
css={css`
|
||||
padding: ${theme.sizeUnit * 2}px;
|
||||
color: ${theme.colorTextDisabled};
|
||||
text-align: center;
|
||||
`}
|
||||
>
|
||||
{t('No columns found')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const menuItem = (
|
||||
<div
|
||||
ref={menuItemRef}
|
||||
role="button"
|
||||
tabIndex={isDisabled ? -1 : 0}
|
||||
css={css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: ${isDisabled ? 'not-allowed' : 'pointer'};
|
||||
color: ${isDisabled ? theme.colorTextDisabled : 'inherit'};
|
||||
&:hover {
|
||||
background: transparent;
|
||||
}
|
||||
`}
|
||||
onClick={() => !isDisabled && setPopoverOpen(!popoverOpen)}
|
||||
onKeyDown={e => {
|
||||
if (!isDisabled && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
setPopoverOpen(!popoverOpen);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span>{t('Drill by')}</span>
|
||||
{isDisabled ? (
|
||||
<MenuItemTooltip title={tooltip} />
|
||||
) : (
|
||||
<Icons.RightOutlined iconSize="s" iconColor={theme.colorTextTertiary} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isDisabled) {
|
||||
return menuItem;
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
content={popoverContent}
|
||||
placement="rightTop"
|
||||
open={popoverOpen}
|
||||
onOpenChange={setPopoverOpen}
|
||||
trigger={['hover', 'click']}
|
||||
arrow={false}
|
||||
styles={{
|
||||
root: {
|
||||
paddingLeft: 0,
|
||||
},
|
||||
body: {
|
||||
padding: theme.sizeUnit * 2,
|
||||
boxShadow: theme.boxShadow,
|
||||
borderRadius: theme.borderRadius,
|
||||
},
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{menuItem}
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -1,253 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
Dispatch,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { isEmpty } from 'lodash';
|
||||
import {
|
||||
Behavior,
|
||||
BinaryQueryObjectFilterClause,
|
||||
css,
|
||||
extractQueryFields,
|
||||
getChartMetadataRegistry,
|
||||
QueryFormData,
|
||||
removeHTMLTags,
|
||||
styled,
|
||||
t,
|
||||
} from '@superset-ui/core';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Menu } from '@superset-ui/core/components/Menu';
|
||||
import { RootState } from 'src/dashboard/types';
|
||||
import { getSubmenuYOffset } from '../utils';
|
||||
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
|
||||
import { MenuItemWithTruncation } from '../MenuItemWithTruncation';
|
||||
import { Dataset } from '../types';
|
||||
|
||||
const DRILL_TO_DETAIL = t('Drill to detail');
|
||||
const DRILL_TO_DETAIL_BY = t('Drill to detail by');
|
||||
const DISABLED_REASONS = {
|
||||
DATABASE: t(
|
||||
'Drill to detail is disabled for this database. Change the database settings to enable it.',
|
||||
),
|
||||
NO_AGGREGATIONS: t(
|
||||
'Drill to detail is disabled because this chart does not group data by dimension value.',
|
||||
),
|
||||
NO_FILTERS: t(
|
||||
'Right-click on a dimension value to drill to detail by that value.',
|
||||
),
|
||||
NOT_SUPPORTED: t(
|
||||
'Drill to detail by value is not yet supported for this chart type.',
|
||||
),
|
||||
};
|
||||
|
||||
const DisabledMenuItem = ({
|
||||
children,
|
||||
menuKey,
|
||||
...rest
|
||||
}: {
|
||||
children: ReactNode;
|
||||
menuKey: string;
|
||||
}) => (
|
||||
<Menu.Item disabled key={menuKey} {...rest}>
|
||||
<div
|
||||
css={css`
|
||||
white-space: normal;
|
||||
max-width: 160px;
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</Menu.Item>
|
||||
);
|
||||
|
||||
const Filter = ({
|
||||
children,
|
||||
stripHTML = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
stripHTML: boolean;
|
||||
}) => {
|
||||
const content =
|
||||
stripHTML && typeof children === 'string'
|
||||
? removeHTMLTags(children)
|
||||
: children;
|
||||
return <span>{content}</span>;
|
||||
};
|
||||
|
||||
const StyledFilter = styled(Filter)`
|
||||
${({ theme }) => `
|
||||
font-weight: ${theme.fontWeightStrong};
|
||||
color: ${theme.colorPrimary};
|
||||
`}
|
||||
`;
|
||||
|
||||
export type DrillDetailMenuItemsProps = {
|
||||
formData: QueryFormData;
|
||||
filters?: BinaryQueryObjectFilterClause[];
|
||||
setFilters: Dispatch<SetStateAction<BinaryQueryObjectFilterClause[]>>;
|
||||
isContextMenu?: boolean;
|
||||
contextMenuY?: number;
|
||||
onSelection?: () => void;
|
||||
onClick?: (event: MouseEvent) => void;
|
||||
submenuIndex?: number;
|
||||
setShowModal: (show: boolean) => void;
|
||||
key?: string;
|
||||
forceSubmenuRender?: boolean;
|
||||
dataset?: Dataset;
|
||||
isLoadingDataset?: boolean;
|
||||
};
|
||||
|
||||
const DrillDetailMenuItems = ({
|
||||
formData,
|
||||
filters = [],
|
||||
isContextMenu = false,
|
||||
contextMenuY = 0,
|
||||
onSelection = () => null,
|
||||
onClick = () => null,
|
||||
submenuIndex = 0,
|
||||
setFilters,
|
||||
setShowModal,
|
||||
key,
|
||||
...props
|
||||
}: DrillDetailMenuItemsProps) => {
|
||||
const drillToDetailDisabled = useSelector<RootState, boolean | undefined>(
|
||||
({ datasources }) =>
|
||||
datasources[formData.datasource]?.database?.disable_drill_to_detail,
|
||||
);
|
||||
|
||||
const openModal = useCallback(
|
||||
(filters, event) => {
|
||||
onClick(event);
|
||||
onSelection();
|
||||
setFilters(filters);
|
||||
setShowModal(true);
|
||||
},
|
||||
[onClick, onSelection],
|
||||
);
|
||||
|
||||
// Check for Behavior.DRILL_TO_DETAIL to tell if plugin handles the `contextmenu`
|
||||
// event for dimensions. If it doesn't, tell the user that drill to detail by
|
||||
// dimension is not supported. If it does, and the `contextmenu` handler didn't
|
||||
// pass any filters, tell the user that they didn't select a dimension.
|
||||
const handlesDimensionContextMenu = useMemo(
|
||||
() =>
|
||||
getChartMetadataRegistry()
|
||||
.get(formData.viz_type)
|
||||
?.behaviors.find(behavior => behavior === Behavior.DrillToDetail),
|
||||
[formData.viz_type],
|
||||
);
|
||||
|
||||
// Check metrics to see if chart's current configuration lacks
|
||||
// aggregations, in which case Drill to Detail should be disabled.
|
||||
const noAggregations = useMemo(() => {
|
||||
const { metrics } = extractQueryFields(formData);
|
||||
return isEmpty(metrics);
|
||||
}, [formData]);
|
||||
|
||||
// Ensure submenu doesn't appear offscreen
|
||||
const submenuYOffset = useMemo(
|
||||
() =>
|
||||
getSubmenuYOffset(
|
||||
contextMenuY,
|
||||
filters.length > 1 ? filters.length + 1 : filters.length,
|
||||
submenuIndex,
|
||||
),
|
||||
[contextMenuY, filters.length, submenuIndex],
|
||||
);
|
||||
|
||||
let drillDisabled;
|
||||
let drillByDisabled;
|
||||
if (drillToDetailDisabled) {
|
||||
drillDisabled = DISABLED_REASONS.DATABASE;
|
||||
drillByDisabled = DISABLED_REASONS.DATABASE;
|
||||
} else if (handlesDimensionContextMenu) {
|
||||
if (noAggregations) {
|
||||
drillDisabled = DISABLED_REASONS.NO_AGGREGATIONS;
|
||||
drillByDisabled = DISABLED_REASONS.NO_AGGREGATIONS;
|
||||
} else if (!filters?.length) {
|
||||
drillByDisabled = DISABLED_REASONS.NO_FILTERS;
|
||||
}
|
||||
} else {
|
||||
drillByDisabled = DISABLED_REASONS.NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
const drillToDetailMenuItem = drillDisabled ? (
|
||||
<DisabledMenuItem menuKey="drill-to-detail-disabled" {...props}>
|
||||
{DRILL_TO_DETAIL}
|
||||
<MenuItemTooltip title={drillDisabled} />
|
||||
</DisabledMenuItem>
|
||||
) : (
|
||||
<Menu.Item key="drill-to-detail" onClick={openModal.bind(null, [])}>
|
||||
{DRILL_TO_DETAIL}
|
||||
</Menu.Item>
|
||||
);
|
||||
|
||||
const drillToDetailByMenuItem = drillByDisabled ? (
|
||||
<DisabledMenuItem menuKey="drill-to-detail-by-disabled" {...props}>
|
||||
{DRILL_TO_DETAIL_BY}
|
||||
<MenuItemTooltip title={drillByDisabled} />
|
||||
</DisabledMenuItem>
|
||||
) : (
|
||||
<Menu.SubMenu
|
||||
popupOffset={[0, submenuYOffset]}
|
||||
popupClassName="chart-context-submenu"
|
||||
title={DRILL_TO_DETAIL_BY}
|
||||
key={key}
|
||||
{...props}
|
||||
>
|
||||
<div data-test="drill-to-detail-by-submenu">
|
||||
{filters.map((filter, i) => (
|
||||
<MenuItemWithTruncation
|
||||
tooltipText={`${DRILL_TO_DETAIL_BY} ${filter.formattedVal}`}
|
||||
menuKey={`drill-detail-filter-${i}`}
|
||||
onClick={openModal.bind(null, [filter])}
|
||||
>
|
||||
{`${DRILL_TO_DETAIL_BY} `}
|
||||
<StyledFilter stripHTML>{filter.formattedVal}</StyledFilter>
|
||||
</MenuItemWithTruncation>
|
||||
))}
|
||||
{filters.length > 1 && (
|
||||
<Menu.Item
|
||||
key="drill-detail-filter-all"
|
||||
onClick={openModal.bind(null, filters)}
|
||||
>
|
||||
<div>
|
||||
{`${DRILL_TO_DETAIL_BY} `}
|
||||
<StyledFilter stripHTML={false}>{t('all')}</StyledFilter>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</div>
|
||||
</Menu.SubMenu>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{drillToDetailMenuItem}
|
||||
{isContextMenu && drillToDetailByMenuItem}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DrillDetailMenuItems;
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
export { default as DrillDetailMenuItems } from './DrillDetailMenuItems';
|
||||
export { useDrillDetailMenuItems } from './useDrillDetailMenuItems';
|
||||
@@ -37,11 +37,12 @@ import {
|
||||
t,
|
||||
} from '@superset-ui/core';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { MenuItem } from '@superset-ui/core/components/Menu';
|
||||
import { type ItemType } from '@superset-ui/core/components/Menu';
|
||||
import { RootState } from 'src/dashboard/types';
|
||||
import { getSubmenuYOffset } from '../utils';
|
||||
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
|
||||
import { useMenuItemWithTruncation } from '../MenuItemWithTruncation';
|
||||
import { TruncatedMenuLabel } from '../MenuItemWithTruncation';
|
||||
import { Dataset } from '../types';
|
||||
|
||||
const DRILL_TO_DETAIL = t('Drill to detail');
|
||||
const DRILL_TO_DETAIL_BY = t('Drill to detail by');
|
||||
@@ -60,28 +61,6 @@ const DISABLED_REASONS = {
|
||||
),
|
||||
};
|
||||
|
||||
function getDisabledMenuItem(
|
||||
children: ReactNode,
|
||||
menuKey: string,
|
||||
...rest: unknown[]
|
||||
): MenuItem {
|
||||
return {
|
||||
disabled: true,
|
||||
key: menuKey,
|
||||
label: (
|
||||
<div
|
||||
css={css`
|
||||
white-space: normal;
|
||||
max-width: 160px;
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
const Filter = ({
|
||||
children,
|
||||
stripHTML = false,
|
||||
@@ -103,7 +82,7 @@ const StyledFilter = styled(Filter)`
|
||||
`}
|
||||
`;
|
||||
|
||||
export type DrillDetailMenuItemsArgs = {
|
||||
export type DrillDetailMenuItemsProps = {
|
||||
formData: QueryFormData;
|
||||
filters?: BinaryQueryObjectFilterClause[];
|
||||
setFilters: Dispatch<SetStateAction<BinaryQueryObjectFilterClause[]>>;
|
||||
@@ -115,6 +94,8 @@ export type DrillDetailMenuItemsArgs = {
|
||||
setShowModal: (show: boolean) => void;
|
||||
key?: string;
|
||||
forceSubmenuRender?: boolean;
|
||||
dataset?: Dataset;
|
||||
isLoadingDataset?: boolean;
|
||||
};
|
||||
|
||||
export const useDrillDetailMenuItems = ({
|
||||
@@ -129,7 +110,7 @@ export const useDrillDetailMenuItems = ({
|
||||
setShowModal,
|
||||
key,
|
||||
...props
|
||||
}: DrillDetailMenuItemsArgs) => {
|
||||
}: DrillDetailMenuItemsProps): ItemType[] => {
|
||||
const drillToDetailDisabled = useSelector<RootState, boolean | undefined>(
|
||||
({ datasources }) =>
|
||||
datasources[formData.datasource]?.database?.disable_drill_to_detail,
|
||||
@@ -142,7 +123,7 @@ export const useDrillDetailMenuItems = ({
|
||||
setFilters(filters);
|
||||
setShowModal(true);
|
||||
},
|
||||
[onClick, onSelection],
|
||||
[onClick, onSelection, setFilters, setShowModal],
|
||||
);
|
||||
|
||||
// Check for Behavior.DRILL_TO_DETAIL to tell if plugin handles the `contextmenu`
|
||||
@@ -191,79 +172,110 @@ export const useDrillDetailMenuItems = ({
|
||||
drillByDisabled = DISABLED_REASONS.NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
const drillToDetailMenuItem: MenuItem = drillDisabled
|
||||
? getDisabledMenuItem(
|
||||
<>
|
||||
{DRILL_TO_DETAIL}
|
||||
<MenuItemTooltip title={drillDisabled} />
|
||||
</>,
|
||||
'drill-to-detail-disabled',
|
||||
props,
|
||||
)
|
||||
const drillToDetailMenuItem: ItemType = drillDisabled
|
||||
? {
|
||||
key: 'drill-to-detail-disabled',
|
||||
disabled: true,
|
||||
label: (
|
||||
<div
|
||||
css={css`
|
||||
white-space: normal;
|
||||
max-width: 160px;
|
||||
`}
|
||||
>
|
||||
{DRILL_TO_DETAIL}
|
||||
<MenuItemTooltip title={drillDisabled} />
|
||||
</div>
|
||||
),
|
||||
...props,
|
||||
}
|
||||
: {
|
||||
key: 'drill-to-detail',
|
||||
label: DRILL_TO_DETAIL,
|
||||
onClick: openModal.bind(null, []),
|
||||
...props,
|
||||
label: DRILL_TO_DETAIL,
|
||||
};
|
||||
|
||||
const getMenuItemWithTruncation = useMenuItemWithTruncation();
|
||||
|
||||
const drillToDetailByMenuItem: MenuItem = drillByDisabled
|
||||
? getDisabledMenuItem(
|
||||
<>
|
||||
{DRILL_TO_DETAIL_BY}
|
||||
<MenuItemTooltip title={drillByDisabled} />
|
||||
</>,
|
||||
'drill-to-detail-by-disabled',
|
||||
props,
|
||||
)
|
||||
: {
|
||||
key: key || 'drill-to-detail-by',
|
||||
label: DRILL_TO_DETAIL_BY,
|
||||
children: [
|
||||
...filters.map((filter, i) => ({
|
||||
key: `drill-detail-filter-${i}`,
|
||||
label: getMenuItemWithTruncation({
|
||||
tooltipText: `${DRILL_TO_DETAIL_BY} ${filter.formattedVal}`,
|
||||
onClick: openModal.bind(null, [filter]),
|
||||
const drillToDetailByMenuItem: ItemType | null = !isContextMenu
|
||||
? null
|
||||
: drillByDisabled
|
||||
? {
|
||||
key: 'drill-to-detail-by-disabled',
|
||||
disabled: true,
|
||||
label: (
|
||||
<div
|
||||
css={css`
|
||||
white-space: normal;
|
||||
max-width: 160px;
|
||||
`}
|
||||
>
|
||||
{DRILL_TO_DETAIL_BY}
|
||||
<MenuItemTooltip title={drillByDisabled} />
|
||||
</div>
|
||||
),
|
||||
...props,
|
||||
}
|
||||
: {
|
||||
key: key || 'drill-to-detail-by',
|
||||
label: DRILL_TO_DETAIL_BY,
|
||||
popupOffset: [0, submenuYOffset],
|
||||
popupClassName: 'chart-context-submenu',
|
||||
children: [
|
||||
...filters.map((filter, i) => ({
|
||||
key: `drill-detail-filter-${i}`,
|
||||
children: (
|
||||
<>
|
||||
{`${DRILL_TO_DETAIL_BY} `}
|
||||
<StyledFilter stripHTML>{filter.formattedVal}</StyledFilter>
|
||||
</>
|
||||
onClick: openModal.bind(null, [filter]),
|
||||
label: (
|
||||
<div
|
||||
css={css`
|
||||
max-width: 200px;
|
||||
`}
|
||||
>
|
||||
<TruncatedMenuLabel
|
||||
tooltipText={`${DRILL_TO_DETAIL_BY} ${filter.formattedVal}`}
|
||||
aria-label={`${DRILL_TO_DETAIL_BY} ${filter.formattedVal}`}
|
||||
>
|
||||
<span
|
||||
css={css`
|
||||
display: inline;
|
||||
`}
|
||||
>
|
||||
{DRILL_TO_DETAIL_BY}{' '}
|
||||
<StyledFilter stripHTML>
|
||||
{filter.formattedVal}
|
||||
</StyledFilter>
|
||||
</span>
|
||||
</TruncatedMenuLabel>
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
})),
|
||||
filters.length > 1 && {
|
||||
key: 'drill-detail-filter-all',
|
||||
label: getMenuItemWithTruncation({
|
||||
tooltipText: `${DRILL_TO_DETAIL_BY} ${t('all')}`,
|
||||
onClick: openModal.bind(null, filters),
|
||||
key: 'drill-detail-filter-all',
|
||||
children: (
|
||||
<>
|
||||
{`${DRILL_TO_DETAIL_BY} `}
|
||||
<StyledFilter stripHTML={false}>{t('all')}</StyledFilter>
|
||||
</>
|
||||
),
|
||||
}),
|
||||
},
|
||||
].filter(Boolean) as MenuItem[],
|
||||
onClick: openModal.bind(null, filters),
|
||||
forceSubmenuRender: true,
|
||||
popupOffset: [0, submenuYOffset],
|
||||
popupClassName: 'chart-context-submenu',
|
||||
...props,
|
||||
};
|
||||
if (isContextMenu) {
|
||||
return {
|
||||
drillToDetailMenuItem,
|
||||
drillToDetailByMenuItem,
|
||||
};
|
||||
})),
|
||||
...(filters.length > 1
|
||||
? [
|
||||
{
|
||||
key: 'drill-detail-filter-all',
|
||||
onClick: openModal.bind(null, filters),
|
||||
label: (
|
||||
<div
|
||||
aria-label={`${DRILL_TO_DETAIL_BY} ${t('all')}`}
|
||||
css={css`
|
||||
max-width: 200px;
|
||||
`}
|
||||
>
|
||||
{`${DRILL_TO_DETAIL_BY} `}
|
||||
<StyledFilter stripHTML={false}>
|
||||
{t('all')}
|
||||
</StyledFilter>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
...props,
|
||||
};
|
||||
|
||||
const menuItems: ItemType[] = [drillToDetailMenuItem];
|
||||
if (drillToDetailByMenuItem) {
|
||||
menuItems.push(drillToDetailByMenuItem);
|
||||
}
|
||||
return {
|
||||
drillToDetailMenuItem,
|
||||
};
|
||||
|
||||
return menuItems;
|
||||
};
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
within,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import setupPlugins from 'src/setup/setupPlugins';
|
||||
@@ -29,15 +30,13 @@ import { getMockStoreWithNativeFilters } from 'spec/fixtures/mockStore';
|
||||
import chartQueries, { sliceId } from 'spec/fixtures/mockChartQueries';
|
||||
import { BinaryQueryObjectFilterClause, VizType } from '@superset-ui/core';
|
||||
import { Menu } from '@superset-ui/core/components/Menu';
|
||||
import DrillDetailMenuItems, {
|
||||
DrillDetailMenuItemsProps,
|
||||
} from './DrillDetailMenuItems';
|
||||
import DrillDetailModal from './DrillDetailModal';
|
||||
import DrillDetailModal from '../DrillDetail/DrillDetailModal';
|
||||
import { useDrillDetailMenuItems, DrillDetailMenuItemsProps } from './index';
|
||||
|
||||
/* eslint jest/expect-expect: ["warn", { "assertFunctionNames": ["expect*"] }] */
|
||||
|
||||
jest.mock(
|
||||
'./DrillDetailPane',
|
||||
'../DrillDetail/DrillDetailPane',
|
||||
() =>
|
||||
({
|
||||
initialFilters,
|
||||
@@ -87,17 +86,17 @@ const MockRenderChart = ({
|
||||
BinaryQueryObjectFilterClause[] | undefined
|
||||
>(filters);
|
||||
|
||||
const menuItems = useDrillDetailMenuItems({
|
||||
setFilters,
|
||||
formData: formData ?? defaultFormData,
|
||||
filters: modalFilters,
|
||||
isContextMenu,
|
||||
setShowModal: setShowMenu,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu forceSubMenuRender>
|
||||
<DrillDetailMenuItems
|
||||
setFilters={setFilters}
|
||||
formData={formData ?? defaultFormData}
|
||||
filters={modalFilters}
|
||||
isContextMenu={isContextMenu}
|
||||
setShowModal={setShowMenu}
|
||||
/>
|
||||
</Menu>
|
||||
<Menu forceSubMenuRender items={menuItems} />
|
||||
|
||||
<DrillDetailModal
|
||||
chartId={chartId ?? defaultChartId}
|
||||
@@ -130,8 +129,10 @@ const renderMenu = ({
|
||||
);
|
||||
};
|
||||
|
||||
const setupMenu = (filters: BinaryQueryObjectFilterClause[]) => {
|
||||
const setupMenu = async (filters: BinaryQueryObjectFilterClause[]) => {
|
||||
cleanup();
|
||||
// Small delay to ensure DOM cleanup is complete
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
renderMenu({
|
||||
chartId: defaultChartId,
|
||||
formData: defaultFormData,
|
||||
@@ -235,11 +236,11 @@ const expectDrillToDetailByEnabled = async () => {
|
||||
.getAllByRole('menuitem')
|
||||
.find(menuItem => within(menuItem).queryByText('Drill to detail by'));
|
||||
await expectMenuItemEnabled(drillToDetailBy!);
|
||||
|
||||
userEvent.hover(drillToDetailBy!);
|
||||
|
||||
const submenus = await screen.findAllByTestId('drill-to-detail-by-submenu');
|
||||
|
||||
expect(submenus.length).toEqual(2);
|
||||
const submenu = await screen.findByRole('menu', {});
|
||||
expect(submenu).toBeInTheDocument();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -258,17 +259,37 @@ const expectDrillToDetailByDisabled = async (tooltipContent?: string) => {
|
||||
const expectDrillToDetailByDimension = async (
|
||||
filter: BinaryQueryObjectFilterClause,
|
||||
) => {
|
||||
userEvent.hover(screen.getByRole('menuitem', { name: 'Drill to detail by' }));
|
||||
const drillToDetailBySubMenus = await screen.findAllByTestId(
|
||||
'drill-to-detail-by-submenu',
|
||||
);
|
||||
const formattedVal = filter.formattedVal as string;
|
||||
const drillByMenuItem = screen.getByRole('menuitem', {
|
||||
name: 'Drill to detail by',
|
||||
});
|
||||
|
||||
userEvent.hover(drillByMenuItem);
|
||||
|
||||
const submenuPopup = (await waitFor(() =>
|
||||
screen
|
||||
.getAllByRole('menu', { hidden: true })
|
||||
.find(menu =>
|
||||
(menu.textContent ?? '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.includes(formattedVal),
|
||||
),
|
||||
)) as HTMLElement;
|
||||
|
||||
const drillToDetailBySubmenuItem = (await waitFor(() => {
|
||||
const items = within(submenuPopup).getAllByRole('menuitem');
|
||||
return items.find(item =>
|
||||
(item.textContent ?? '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.includes(`Drill to detail by ${filter.formattedVal}`),
|
||||
);
|
||||
})) as HTMLElement;
|
||||
|
||||
const menuItemName = `Drill to detail by ${filter.formattedVal}`;
|
||||
const drillToDetailBySubmenuItems = await within(
|
||||
drillToDetailBySubMenus[1],
|
||||
).findAllByRole('menuitem');
|
||||
|
||||
await expectMenuItemEnabled(drillToDetailBySubmenuItems[0]);
|
||||
await expectMenuItemEnabled(drillToDetailBySubmenuItem);
|
||||
await expectDrillToDetailModal(menuItemName, [filter]);
|
||||
};
|
||||
|
||||
@@ -278,14 +299,15 @@ const expectDrillToDetailByDimension = async (
|
||||
const expectDrillToDetailByAll = async (
|
||||
filters: BinaryQueryObjectFilterClause[],
|
||||
) => {
|
||||
userEvent.hover(screen.getByRole('menuitem', { name: 'Drill to detail by' }));
|
||||
const drillToDetailBySubMenus = await screen.findAllByTestId(
|
||||
'drill-to-detail-by-submenu',
|
||||
);
|
||||
const drillByMenuItem = screen.getByRole('menuitem', {
|
||||
name: 'Drill to detail by',
|
||||
});
|
||||
|
||||
const drillToDetailBySubmenuItem = await within(
|
||||
drillToDetailBySubMenus[1],
|
||||
).findByText(/all/i);
|
||||
userEvent.hover(drillByMenuItem);
|
||||
|
||||
await screen.findByRole('menu');
|
||||
|
||||
const drillToDetailBySubmenuItem = await screen.findByText(/all/i, {});
|
||||
|
||||
await expectMenuItemEnabled(drillToDetailBySubmenuItem);
|
||||
|
||||
@@ -386,20 +408,20 @@ test('context menu for supported chart, dimensions, 1 filter', async () => {
|
||||
|
||||
test('context menu for supported chart, dimensions, filter A', async () => {
|
||||
const filters = [filterA, filterB];
|
||||
setupMenu(filters);
|
||||
await setupMenu(filters);
|
||||
await expectDrillToDetailByEnabled();
|
||||
await expectDrillToDetailByDimension(filterA);
|
||||
});
|
||||
|
||||
test('context menu for supported chart, dimensions, filter B', async () => {
|
||||
const filters = [filterA, filterB];
|
||||
setupMenu(filters);
|
||||
await setupMenu(filters);
|
||||
await expectDrillToDetailByEnabled();
|
||||
await expectDrillToDetailByDimension(filterB);
|
||||
});
|
||||
|
||||
test.skip('context menu for supported chart, dimensions, all filters', async () => {
|
||||
const filters = [filterA, filterB];
|
||||
setupMenu(filters);
|
||||
await setupMenu(filters);
|
||||
await expectDrillToDetailByAll(filters);
|
||||
});
|
||||
@@ -104,10 +104,10 @@ export default class CRUDCollection extends PureComponent<
|
||||
this.toggleExpand = this.toggleExpand.bind(this);
|
||||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps(nextProps: CRUDCollectionProps) {
|
||||
if (nextProps.collection !== this.props.collection) {
|
||||
componentDidUpdate(prevProps: CRUDCollectionProps) {
|
||||
if (this.props.collection !== prevProps.collection) {
|
||||
const { collection, collectionArray } = createKeyedCollection(
|
||||
nextProps.collection,
|
||||
this.props.collection,
|
||||
);
|
||||
this.setState(prevState => ({
|
||||
collection,
|
||||
|
||||
@@ -740,7 +740,6 @@ 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,
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { 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);
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { 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 };
|
||||
@@ -1,20 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
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 } from '@superset-ui/core/components';
|
||||
import { Modal, Loading, Flex } from '@superset-ui/core/components';
|
||||
import { ModalTitleWithIcon } from 'src/components/ModalTitleWithIcon';
|
||||
|
||||
interface StandardModalProps {
|
||||
@@ -39,6 +39,7 @@ interface StandardModalProps {
|
||||
destroyOnClose?: boolean;
|
||||
maskClosable?: boolean;
|
||||
wrapProps?: object;
|
||||
contentLoading?: boolean;
|
||||
}
|
||||
|
||||
// Standard modal widths
|
||||
@@ -113,12 +114,13 @@ export function StandardModal({
|
||||
destroyOnClose = true,
|
||||
maskClosable = false,
|
||||
wrapProps,
|
||||
contentLoading = false,
|
||||
}: StandardModalProps) {
|
||||
const primaryButtonName = saveText || (isEditMode ? t('Save') : t('Add'));
|
||||
|
||||
return (
|
||||
<StyledModal
|
||||
disablePrimaryButton={saveDisabled || saveLoading}
|
||||
disablePrimaryButton={saveDisabled || saveLoading || contentLoading}
|
||||
primaryButtonLoading={saveLoading}
|
||||
primaryTooltipMessage={errorTooltip}
|
||||
onHandledPrimaryAction={onSave}
|
||||
@@ -139,7 +141,13 @@ export function StandardModal({
|
||||
)
|
||||
}
|
||||
>
|
||||
{children}
|
||||
{contentLoading ? (
|
||||
<Flex justify="center" align="center" style={{ minHeight: 200 }}>
|
||||
<Loading />
|
||||
</Flex>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</StyledModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ 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,7 +122,6 @@ 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: {},
|
||||
@@ -171,6 +170,7 @@ export const DEFAULT_COMMON_BOOTSTRAP_DATA: CommonBootstrapData = {
|
||||
},
|
||||
d3_format: DEFAULT_D3_FORMAT,
|
||||
d3_time_format: DEFAULT_D3_TIME_FORMAT,
|
||||
pdf_compression_level: 'MEDIUM',
|
||||
};
|
||||
|
||||
export const DEFAULT_BOOTSTRAP_DATA: BootstrapData = {
|
||||
|
||||
@@ -274,7 +274,6 @@ 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:
|
||||
|
||||
@@ -19,11 +19,9 @@
|
||||
/* eslint-env browser */
|
||||
import tinycolor from 'tinycolor2';
|
||||
import Tabs from '@superset-ui/core/components/Tabs';
|
||||
import { t, css, SupersetTheme, useTheme } from '@superset-ui/core';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { t, css, SupersetTheme } from '@superset-ui/core';
|
||||
import SliceAdder from 'src/dashboard/containers/SliceAdder';
|
||||
import dashboardComponents from 'src/visualizations/presets/dashboardComponents';
|
||||
import { useMemo } from 'react';
|
||||
import NewColumn from '../gridComponents/new/NewColumn';
|
||||
import NewDivider from '../gridComponents/new/NewDivider';
|
||||
import NewHeader from '../gridComponents/new/NewHeader';
|
||||
@@ -39,98 +37,83 @@ const TABS_KEYS = {
|
||||
LAYOUT_ELEMENTS: 'LAYOUT_ELEMENTS',
|
||||
};
|
||||
|
||||
const BuilderComponentPane = ({ topOffset = 0 }) => {
|
||||
const theme = useTheme();
|
||||
const nativeFiltersBarOpen = useSelector(
|
||||
(state: any) => state.dashboardState.nativeFiltersBarOpen ?? false,
|
||||
);
|
||||
|
||||
const tabBarStyle = useMemo(
|
||||
() => ({
|
||||
paddingLeft: nativeFiltersBarOpen ? 0 : theme.sizeUnit * 4,
|
||||
}),
|
||||
[nativeFiltersBarOpen, theme.sizeUnit],
|
||||
);
|
||||
|
||||
return (
|
||||
const BuilderComponentPane = ({ topOffset = 0 }) => (
|
||||
<div
|
||||
data-test="dashboard-builder-sidepane"
|
||||
css={css`
|
||||
position: sticky;
|
||||
right: 0;
|
||||
top: ${topOffset}px;
|
||||
height: calc(100vh - ${topOffset}px);
|
||||
width: ${BUILDER_PANE_WIDTH}px;
|
||||
`}
|
||||
>
|
||||
<div
|
||||
data-test="dashboard-builder-sidepane"
|
||||
css={css`
|
||||
position: sticky;
|
||||
right: 0;
|
||||
top: ${topOffset}px;
|
||||
height: calc(100vh - ${topOffset}px);
|
||||
css={(theme: SupersetTheme) => css`
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: ${BUILDER_PANE_WIDTH}px;
|
||||
box-shadow: -${theme.sizeUnit}px 0 ${theme.sizeUnit}px 0
|
||||
${tinycolor(theme.colorBorder).setAlpha(0.1).toRgbString()};
|
||||
background-color: ${theme.colorBgBase};
|
||||
`}
|
||||
>
|
||||
<div
|
||||
<Tabs
|
||||
data-test="dashboard-builder-component-pane-tabs-navigation"
|
||||
id="tabs"
|
||||
css={(theme: SupersetTheme) => css`
|
||||
position: absolute;
|
||||
line-height: inherit;
|
||||
margin-top: ${theme.sizeUnit * 2}px;
|
||||
height: 100%;
|
||||
width: ${BUILDER_PANE_WIDTH}px;
|
||||
box-shadow: -4px 0 4px 0
|
||||
${tinycolor(theme.colorBorder).setAlpha(0.1).toRgbString()};
|
||||
background-color: ${theme.colorBgBase};
|
||||
`}
|
||||
>
|
||||
<Tabs
|
||||
data-test="dashboard-builder-component-pane-tabs-navigation"
|
||||
id="tabs"
|
||||
tabBarStyle={tabBarStyle}
|
||||
css={(theme: SupersetTheme) => css`
|
||||
line-height: inherit;
|
||||
margin-top: ${theme.sizeUnit * 2}px;
|
||||
height: 100%;
|
||||
|
||||
& .ant-tabs-content-holder {
|
||||
& .ant-tabs-content-holder {
|
||||
height: 100%;
|
||||
& .ant-tabs-content {
|
||||
height: 100%;
|
||||
& .ant-tabs-content {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
`}
|
||||
items={[
|
||||
{
|
||||
key: TABS_KEYS.CHARTS,
|
||||
label: t('Charts'),
|
||||
children: (
|
||||
<div
|
||||
css={css`
|
||||
height: calc(100vh - ${topOffset * 2}px);
|
||||
`}
|
||||
>
|
||||
<SliceAdder />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: TABS_KEYS.LAYOUT_ELEMENTS,
|
||||
label: t('Layout elements'),
|
||||
children: (
|
||||
<>
|
||||
<NewTabs />
|
||||
<NewRow />
|
||||
<NewColumn />
|
||||
<NewHeader />
|
||||
<NewMarkdown />
|
||||
<NewDivider />
|
||||
{dashboardComponents
|
||||
.getAll()
|
||||
.map(({ key: componentKey, metadata }) => (
|
||||
<NewDynamicComponent
|
||||
key={componentKey}
|
||||
metadata={metadata}
|
||||
componentKey={componentKey}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
`}
|
||||
items={[
|
||||
{
|
||||
key: TABS_KEYS.CHARTS,
|
||||
label: t('Charts'),
|
||||
children: (
|
||||
<div
|
||||
css={css`
|
||||
height: calc(100vh - ${topOffset * 2}px);
|
||||
`}
|
||||
>
|
||||
<SliceAdder />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: TABS_KEYS.LAYOUT_ELEMENTS,
|
||||
label: t('Layout elements'),
|
||||
children: (
|
||||
<>
|
||||
<NewTabs />
|
||||
<NewRow />
|
||||
<NewColumn />
|
||||
<NewHeader />
|
||||
<NewMarkdown />
|
||||
<NewDivider />
|
||||
{dashboardComponents
|
||||
.getAll()
|
||||
.map(({ key: componentKey, metadata }) => (
|
||||
<NewDynamicComponent
|
||||
key={componentKey}
|
||||
metadata={metadata}
|
||||
componentKey={componentKey}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
</div>
|
||||
);
|
||||
|
||||
export default BuilderComponentPane;
|
||||
|
||||
@@ -120,15 +120,12 @@ class Dashboard extends PureComponent {
|
||||
this.applyCharts();
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
componentDidUpdate(prevProps) {
|
||||
this.applyCharts();
|
||||
}
|
||||
const currentChartIds = getChartIdsFromLayout(prevProps.layout);
|
||||
const nextChartIds = getChartIdsFromLayout(this.props.layout);
|
||||
|
||||
UNSAFE_componentWillReceiveProps(nextProps) {
|
||||
const currentChartIds = getChartIdsFromLayout(this.props.layout);
|
||||
const nextChartIds = getChartIdsFromLayout(nextProps.layout);
|
||||
|
||||
if (this.props.dashboardId !== nextProps.dashboardId) {
|
||||
if (prevProps.dashboardId !== this.props.dashboardId) {
|
||||
// single-page-app navigation check
|
||||
return;
|
||||
}
|
||||
@@ -140,7 +137,7 @@ class Dashboard extends PureComponent {
|
||||
newChartIds.forEach(newChartId =>
|
||||
this.props.actions.addSliceToDashboard(
|
||||
newChartId,
|
||||
getLayoutComponentFromChartId(nextProps.layout, newChartId),
|
||||
getLayoutComponentFromChartId(this.props.layout, newChartId),
|
||||
),
|
||||
);
|
||||
} else if (currentChartIds.length > nextChartIds.length) {
|
||||
|
||||
@@ -86,16 +86,6 @@ const StyledFilterCount = styled.div`
|
||||
const StyledBadge = styled(Badge)`
|
||||
${({ theme }) => `
|
||||
margin-left: ${theme.sizeUnit * 2}px;
|
||||
&>sup.ant-badge-count {
|
||||
padding: 0 ${theme.sizeUnit}px;
|
||||
min-width: ${theme.sizeUnit * 4}px;
|
||||
height: ${theme.sizeUnit * 4}px;
|
||||
line-height: 1.5;
|
||||
font-weight: ${theme.fontWeightStrong};
|
||||
font-size: ${theme.fontSizeSM - 1}px;
|
||||
box-shadow: none;
|
||||
padding: 0 ${theme.sizeUnit}px;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
@@ -314,6 +304,7 @@ export const FiltersBadge = ({ chartId }: FiltersBadgeProps) => {
|
||||
data-test="applied-filter-count"
|
||||
className="applied-count"
|
||||
count={filterCount}
|
||||
size="small"
|
||||
showZero
|
||||
/>
|
||||
</StyledFilterCount>
|
||||
|
||||
@@ -100,7 +100,7 @@ import { useHeaderActionsMenu } from './useHeaderActionsDropdownMenu';
|
||||
const extensionsRegistry = getExtensionsRegistry();
|
||||
|
||||
const headerContainerStyle = theme => css`
|
||||
border-bottom: 1px solid ${theme.colorSplit};
|
||||
border-bottom: 1px solid ${theme.colorBorder};
|
||||
`;
|
||||
|
||||
const editButtonStyle = theme => css`
|
||||
|
||||
@@ -354,9 +354,19 @@ describe('PropertiesModal', () => {
|
||||
mockedIsFeatureEnabled.mockReturnValue(false);
|
||||
const props = createProps();
|
||||
props.onlyApply = false;
|
||||
render(<PropertiesModal {...props} />, {
|
||||
// Pass dashboardInfo to avoid loading state
|
||||
const propsWithDashboardInfo = {
|
||||
...props,
|
||||
dashboardInfo: {
|
||||
...dashboardInfo,
|
||||
json_metadata: mockedJsonMetadata,
|
||||
},
|
||||
};
|
||||
render(<PropertiesModal {...propsWithDashboardInfo} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
// Wait for the form to be visible
|
||||
expect(
|
||||
await screen.findByTestId('dashboard-edit-properties-form'),
|
||||
).toBeInTheDocument();
|
||||
@@ -379,9 +389,19 @@ describe('PropertiesModal', () => {
|
||||
mockedIsFeatureEnabled.mockReturnValue(false);
|
||||
const props = createProps();
|
||||
props.onlyApply = true;
|
||||
render(<PropertiesModal {...props} />, {
|
||||
// Pass dashboardInfo to avoid loading state
|
||||
const propsWithDashboardInfo = {
|
||||
...props,
|
||||
dashboardInfo: {
|
||||
...dashboardInfo,
|
||||
json_metadata: mockedJsonMetadata,
|
||||
},
|
||||
};
|
||||
render(<PropertiesModal {...propsWithDashboardInfo} />, {
|
||||
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(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isApplying, setIsApplying] = useState(false);
|
||||
const [colorScheme, setCurrentColorScheme] = useState(currentColorScheme);
|
||||
const [jsonMetadata, setJsonMetadata] = useState('');
|
||||
@@ -207,7 +207,6 @@ 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
|
||||
@@ -382,10 +381,6 @@ 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'));
|
||||
@@ -422,10 +417,15 @@ 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,10 +621,9 @@ const PropertiesModal = ({
|
||||
}}
|
||||
title={t('Dashboard properties')}
|
||||
isEditMode
|
||||
saveDisabled={
|
||||
isLoading || dashboardInfo?.isManagedExternally || hasErrors
|
||||
}
|
||||
saveDisabled={dashboardInfo?.isManagedExternally || hasErrors}
|
||||
saveLoading={isApplying}
|
||||
contentLoading={isLoading}
|
||||
errorTooltip={
|
||||
dashboardInfo?.isManagedExternally
|
||||
? t(
|
||||
@@ -665,7 +664,6 @@ const PropertiesModal = ({
|
||||
children: (
|
||||
<BasicInfoSection
|
||||
form={form}
|
||||
isLoading={isLoading}
|
||||
validationStatus={validationStatus}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -21,8 +21,9 @@ import { Form } from '@superset-ui/core/components';
|
||||
import BasicInfoSection from './BasicInfoSection';
|
||||
|
||||
const defaultProps = {
|
||||
form: {} as any,
|
||||
isLoading: false,
|
||||
form: {
|
||||
getFieldValue: jest.fn(() => 'Test Dashboard'),
|
||||
} as any,
|
||||
validationStatus: {
|
||||
basic: { hasErrors: false, errors: [], name: 'Basic' },
|
||||
},
|
||||
@@ -50,16 +51,6 @@ 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,62 +23,59 @@ import { ValidationObject } from 'src/components/Modal/useModalValidation';
|
||||
|
||||
interface BasicInfoSectionProps {
|
||||
form: FormInstance;
|
||||
isLoading: boolean;
|
||||
validationStatus: ValidationObject;
|
||||
}
|
||||
|
||||
const BasicInfoSection = ({
|
||||
form,
|
||||
isLoading,
|
||||
validationStatus,
|
||||
}: 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,
|
||||
},
|
||||
]}
|
||||
}: 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}
|
||||
>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BasicInfoSection;
|
||||
|
||||
@@ -155,6 +155,23 @@ 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>;
|
||||
|
||||
@@ -195,19 +212,20 @@ class SliceAdder extends Component<SliceAdderProps, SliceAdderState> {
|
||||
);
|
||||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps(nextProps: SliceAdderProps) {
|
||||
componentDidUpdate(prevProps: SliceAdderProps) {
|
||||
const nextState: SliceAdderState = {} as SliceAdderState;
|
||||
if (nextProps.lastUpdated !== this.props.lastUpdated) {
|
||||
nextState.filteredSlices = this.getFilteredSortedSlices(
|
||||
nextProps.slices,
|
||||
if (this.props.lastUpdated !== prevProps.lastUpdated) {
|
||||
nextState.filteredSlices = getFilteredSortedSlices(
|
||||
this.props.slices,
|
||||
this.state.searchTerm,
|
||||
this.state.sortBy,
|
||||
this.state.showOnlyMyCharts,
|
||||
this.props.userId,
|
||||
);
|
||||
}
|
||||
|
||||
if (nextProps.selectedSliceIds !== this.props.selectedSliceIds) {
|
||||
nextState.selectedSliceIdsSet = new Set(nextProps.selectedSliceIds);
|
||||
if (prevProps.selectedSliceIds !== this.props.selectedSliceIds) {
|
||||
nextState.selectedSliceIdsSet = new Set(this.props.selectedSliceIds);
|
||||
}
|
||||
|
||||
if (Object.keys(nextState).length) {
|
||||
@@ -227,23 +245,6 @@ 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(
|
||||
@@ -256,11 +257,12 @@ class SliceAdder extends Component<SliceAdderProps, SliceAdderState> {
|
||||
searchUpdated(searchTerm: string) {
|
||||
this.setState(prevState => ({
|
||||
searchTerm,
|
||||
filteredSlices: this.getFilteredSortedSlices(
|
||||
filteredSlices: getFilteredSortedSlices(
|
||||
this.props.slices,
|
||||
searchTerm,
|
||||
prevState.sortBy,
|
||||
prevState.showOnlyMyCharts,
|
||||
this.props.userId,
|
||||
),
|
||||
}));
|
||||
}
|
||||
@@ -268,11 +270,12 @@ class SliceAdder extends Component<SliceAdderProps, SliceAdderState> {
|
||||
handleSelect(sortBy: keyof Slice) {
|
||||
this.setState(prevState => ({
|
||||
sortBy,
|
||||
filteredSlices: this.getFilteredSortedSlices(
|
||||
filteredSlices: getFilteredSortedSlices(
|
||||
this.props.slices,
|
||||
prevState.searchTerm,
|
||||
sortBy,
|
||||
prevState.showOnlyMyCharts,
|
||||
this.props.userId,
|
||||
),
|
||||
}));
|
||||
this.slicesRequest = this.props.fetchSlices(
|
||||
@@ -340,11 +343,12 @@ class SliceAdder extends Component<SliceAdderProps, SliceAdderState> {
|
||||
}
|
||||
this.setState(prevState => ({
|
||||
showOnlyMyCharts,
|
||||
filteredSlices: this.getFilteredSortedSlices(
|
||||
filteredSlices: getFilteredSortedSlices(
|
||||
this.props.slices,
|
||||
prevState.searchTerm,
|
||||
prevState.sortBy,
|
||||
showOnlyMyCharts,
|
||||
this.props.userId,
|
||||
),
|
||||
}));
|
||||
setItem(LocalStorageKeys.DashboardEditorShowOnlyMyCharts, showOnlyMyCharts);
|
||||
|
||||
@@ -85,7 +85,7 @@ const ChartHeaderStyles = styled.div`
|
||||
& > .header-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
max-width: calc(100% - ${theme.sizeUnit * 4}px);
|
||||
flex-grow: 1;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
|
||||
@@ -54,7 +54,7 @@ import { getSliceHeaderTooltip } from 'src/dashboard/util/getSliceHeaderTooltip'
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import ViewQueryModal from 'src/explore/components/controls/ViewQueryModal';
|
||||
import { ResultsPaneOnDashboard } from 'src/explore/components/DataTablesPane';
|
||||
import { useDrillDetailMenuItems } from 'src/components/Chart/DrillDetail';
|
||||
import { useDrillDetailMenuItems } from 'src/components/Chart/useDrillDetailMenuItems';
|
||||
import { LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE } from 'src/logger/LogUtils';
|
||||
import { MenuKeys, RootState } from 'src/dashboard/types';
|
||||
import DrillDetailModal from 'src/components/Chart/DrillDetail/DrillDetailModal';
|
||||
@@ -455,14 +455,13 @@ const SliceHeaderControls = (
|
||||
});
|
||||
}
|
||||
|
||||
const { drillToDetailMenuItem, drillToDetailByMenuItem } =
|
||||
useDrillDetailMenuItems({
|
||||
formData: props.formData,
|
||||
filters: modalFilters,
|
||||
setFilters,
|
||||
setShowModal: setDrillModalIsOpen,
|
||||
key: MenuKeys.DrillToDetail,
|
||||
});
|
||||
const drillDetailMenuItems = useDrillDetailMenuItems({
|
||||
formData: props.formData,
|
||||
filters: modalFilters,
|
||||
setFilters,
|
||||
setShowModal: setDrillModalIsOpen,
|
||||
key: MenuKeys.DrillToDetail,
|
||||
});
|
||||
|
||||
const shareMenuItems = useShareMenuItems({
|
||||
dashboardId,
|
||||
@@ -477,10 +476,7 @@ const SliceHeaderControls = (
|
||||
});
|
||||
|
||||
if (isFeatureEnabled(FeatureFlag.DrillToDetail) && canDrillToDetail) {
|
||||
newMenuItems.push(drillToDetailMenuItem);
|
||||
if (drillToDetailByMenuItem) {
|
||||
newMenuItems.push(drillToDetailByMenuItem);
|
||||
}
|
||||
newMenuItems.push(...drillDetailMenuItems);
|
||||
}
|
||||
|
||||
if (slice.description || canExplore) {
|
||||
|
||||
@@ -58,7 +58,7 @@ const HoverStyleOverrides = styled.div`
|
||||
top: ${({ theme }) => theme.sizeUnit * -6}px;
|
||||
left: 50%;
|
||||
transform: translate(-50%);
|
||||
padding: 0 ${({ theme }) => theme.sizeUnit * 2}px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
|
||||
@@ -129,12 +129,12 @@ export default class WithPopoverMenu extends PureComponent<
|
||||
this.handleClick = this.handleClick.bind(this);
|
||||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps(nextProps: WithPopoverMenuProps) {
|
||||
if (nextProps.editMode && nextProps.isFocused && !this.state.isFocused) {
|
||||
componentDidUpdate(prevProps: WithPopoverMenuProps) {
|
||||
if (this.props.editMode && this.props.isFocused && !this.state.isFocused) {
|
||||
document.addEventListener('click', this.handleClick);
|
||||
document.addEventListener('drag', this.handleClick);
|
||||
this.setState({ isFocused: true });
|
||||
} else if (this.state.isFocused && !nextProps.editMode) {
|
||||
} else if (this.state.isFocused && !this.props.editMode) {
|
||||
document.removeEventListener('click', this.handleClick);
|
||||
document.removeEventListener('drag', this.handleClick);
|
||||
this.setState({ isFocused: false });
|
||||
|
||||
@@ -33,7 +33,7 @@ const useFilterFocusHighlightStyles = (chartId: number) => {
|
||||
() => ({
|
||||
borderColor: theme.colorPrimaryBorder,
|
||||
opacity: 1,
|
||||
boxShadow: `0px 0px ${theme.sizeUnit * 2}px ${theme.colorPrimary}`,
|
||||
boxShadow: `0px 0px ${theme.sizeUnit * 3}px ${theme.colorPrimary}`,
|
||||
pointerEvents: 'auto',
|
||||
}),
|
||||
[theme],
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
export interface QueryExecutePayload {
|
||||
client_id: string;
|
||||
database_id: number;
|
||||
json: boolean;
|
||||
runAsync: boolean;
|
||||
catalog: string | null;
|
||||
schema: string;
|
||||
|
||||
@@ -22,13 +22,12 @@ 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 { FlashProvider, DynamicPluginProvider } from 'src/components';
|
||||
import { 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.
|
||||
@@ -56,7 +55,6 @@ const themeController = new ThemeController({
|
||||
|
||||
export const getThemeController = (): ThemeController => themeController;
|
||||
|
||||
const { common } = getBootstrapData();
|
||||
const extensionsRegistry = getExtensionsRegistry();
|
||||
|
||||
export const EmbeddedContextProviders: React.FC = ({ children }) => {
|
||||
@@ -68,24 +66,22 @@ export const EmbeddedContextProviders: React.FC = ({ children }) => {
|
||||
<SupersetThemeProvider themeController={themeController}>
|
||||
<ReduxProvider store={store}>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<FlashProvider messages={common.flash_messages}>
|
||||
<EmbeddedUiConfigProvider>
|
||||
<DynamicPluginProvider>
|
||||
<QueryParamProvider
|
||||
ReactRouterRoute={Route}
|
||||
stringifyOptions={{ encode: false }}
|
||||
>
|
||||
{RootContextProviderExtension ? (
|
||||
<RootContextProviderExtension>
|
||||
{children}
|
||||
</RootContextProviderExtension>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</QueryParamProvider>
|
||||
</DynamicPluginProvider>
|
||||
</EmbeddedUiConfigProvider>
|
||||
</FlashProvider>
|
||||
<EmbeddedUiConfigProvider>
|
||||
<DynamicPluginProvider>
|
||||
<QueryParamProvider
|
||||
ReactRouterRoute={Route}
|
||||
stringifyOptions={{ encode: false }}
|
||||
>
|
||||
{RootContextProviderExtension ? (
|
||||
<RootContextProviderExtension>
|
||||
{children}
|
||||
</RootContextProviderExtension>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</QueryParamProvider>
|
||||
</DynamicPluginProvider>
|
||||
</EmbeddedUiConfigProvider>
|
||||
</DndProvider>
|
||||
</ReduxProvider>
|
||||
</SupersetThemeProvider>
|
||||
|
||||
@@ -25,11 +25,7 @@ import {
|
||||
setItem,
|
||||
LocalStorageKeys,
|
||||
} from 'src/utils/localStorageHelpers';
|
||||
import {
|
||||
SamplesPane,
|
||||
TableControlsWrapper,
|
||||
useResultsPane,
|
||||
} from './components';
|
||||
import { SamplesPane, useResultsPane } from './components';
|
||||
import { DataTablesPaneProps, ResultTypes } from './types';
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
@@ -162,7 +158,7 @@ export const DataTablesPane = ({
|
||||
<Icons.DownOutlined aria-label={t('Expand data panel')} />
|
||||
);
|
||||
return (
|
||||
<TableControlsWrapper>
|
||||
<div>
|
||||
{panelOpen ? (
|
||||
<span
|
||||
role="button"
|
||||
@@ -180,7 +176,7 @@ export const DataTablesPane = ({
|
||||
{caretIcon}
|
||||
</span>
|
||||
)}
|
||||
</TableControlsWrapper>
|
||||
</div>
|
||||
);
|
||||
}, [handleCollapseChange, panelOpen]);
|
||||
|
||||
|
||||
@@ -107,17 +107,23 @@ class AnnotationLayerControl extends PureComponent<Props, PopoverState> {
|
||||
AnnotationLayer.preload();
|
||||
}
|
||||
|
||||
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, []);
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,10 +87,48 @@ 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);
|
||||
@@ -126,7 +164,7 @@ class AdhocFilterControl extends Component {
|
||||
);
|
||||
this.state = {
|
||||
values: filters,
|
||||
options: this.optionsForSelect(this.props),
|
||||
options: optionsForSelect(this.props),
|
||||
partitionColumn: null,
|
||||
};
|
||||
}
|
||||
@@ -173,13 +211,13 @@ class AdhocFilterControl extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps(nextProps) {
|
||||
if (this.props.columns !== nextProps.columns) {
|
||||
this.setState({ options: this.optionsForSelect(nextProps) });
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.columns !== prevProps.columns) {
|
||||
this.setState({ options: optionsForSelect(this.props) });
|
||||
}
|
||||
if (this.props.value !== nextProps.value) {
|
||||
if (this.props.value !== prevProps.value) {
|
||||
this.setState({
|
||||
values: (nextProps.value || []).map(filter =>
|
||||
values: (this.props.value || []).map(filter =>
|
||||
isDictionaryForAdhocFilter(filter) ? new AdhocFilter(filter) : filter,
|
||||
),
|
||||
});
|
||||
@@ -298,45 +336,6 @@ 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,7 +16,16 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { savedMetricType } from './types';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
// For backward compatibility with PropTypes usage
|
||||
export { savedMetricType as default } 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;
|
||||
|
||||
@@ -167,12 +167,12 @@ export default class SelectControl extends PureComponent {
|
||||
this.handleFilterOptions = this.handleFilterOptions.bind(this);
|
||||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps(nextProps) {
|
||||
componentDidUpdate(prevProps) {
|
||||
if (
|
||||
!isEqualArray(nextProps.choices, this.props.choices) ||
|
||||
!isEqualArray(nextProps.options, this.props.options)
|
||||
!isEqualArray(this.props.choices, prevProps.choices) ||
|
||||
!isEqualArray(this.props.options, prevProps.options)
|
||||
) {
|
||||
const options = this.getOptions(nextProps);
|
||||
const options = this.getOptions(this.props);
|
||||
this.setState({ options });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +98,6 @@ export interface ExploreResponsePayload {
|
||||
export interface ExplorePageState {
|
||||
user: UserWithPermissionsAndRoles;
|
||||
common: {
|
||||
flash_messages: string[];
|
||||
conf: JsonObject;
|
||||
locale: string;
|
||||
};
|
||||
|
||||
@@ -16,24 +16,43 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import { render, screen, waitFor } from 'spec/helpers/testing-library';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import Login from './index';
|
||||
|
||||
const mockGetBootstrapData = jest.fn();
|
||||
const mockApplicationRoot = jest.fn();
|
||||
|
||||
const renderLogin = () => render(<Login />, { useRedux: true });
|
||||
|
||||
jest.mock('src/utils/getBootstrapData', () => ({
|
||||
__esModule: true,
|
||||
default: () => ({
|
||||
common: {
|
||||
conf: {
|
||||
AUTH_TYPE: 1,
|
||||
AUTH_PROVIDERS: [],
|
||||
AUTH_USER_REGISTRATION: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
default: () => mockGetBootstrapData(),
|
||||
}));
|
||||
|
||||
jest.mock('src/utils/pathUtils', () => ({
|
||||
__esModule: true,
|
||||
ensureAppRoot: (path: string) =>
|
||||
`${mockApplicationRoot()}${path.startsWith('/') ? path : `/${path}`}`,
|
||||
}));
|
||||
|
||||
const defaultBootstrapData = {
|
||||
common: {
|
||||
conf: {
|
||||
AUTH_TYPE: 1,
|
||||
AUTH_PROVIDERS: [],
|
||||
AUTH_USER_REGISTRATION: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetBootstrapData.mockReturnValue(defaultBootstrapData);
|
||||
});
|
||||
|
||||
test('should render login form elements', () => {
|
||||
render(<Login />);
|
||||
renderLogin();
|
||||
expect(screen.getByTestId('login-form')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('username-input')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('password-input')).toBeInTheDocument();
|
||||
@@ -42,14 +61,663 @@ test('should render login form elements', () => {
|
||||
});
|
||||
|
||||
test('should render username and password labels', () => {
|
||||
render(<Login />);
|
||||
renderLogin();
|
||||
expect(screen.getByText('Username:')).toBeInTheDocument();
|
||||
expect(screen.getByText('Password:')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render form instruction text', () => {
|
||||
render(<Login />);
|
||||
renderLogin();
|
||||
expect(
|
||||
screen.getByText('Enter your login and password below:'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render OAuth providers with correct app root URLs', () => {
|
||||
mockApplicationRoot.mockReturnValue('/superset');
|
||||
mockGetBootstrapData.mockReturnValue({
|
||||
common: {
|
||||
conf: {
|
||||
AUTH_TYPE: 4, // AuthType.AuthOauth
|
||||
AUTH_PROVIDERS: [
|
||||
{ name: 'google', icon: 'google' },
|
||||
{ name: 'github', icon: 'github' },
|
||||
],
|
||||
AUTH_USER_REGISTRATION: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderLogin();
|
||||
|
||||
const googleButton = screen.getByRole('link', {
|
||||
name: /Sign in with Google/i,
|
||||
});
|
||||
const githubButton = screen.getByRole('link', {
|
||||
name: /Sign in with Github/i,
|
||||
});
|
||||
|
||||
expect(googleButton).toHaveAttribute('href', '/superset/login/google');
|
||||
expect(githubButton).toHaveAttribute('href', '/superset/login/github');
|
||||
});
|
||||
|
||||
test('should render OAuth providers with default URLs when no app root', () => {
|
||||
mockApplicationRoot.mockReturnValue('');
|
||||
mockGetBootstrapData.mockReturnValue({
|
||||
common: {
|
||||
conf: {
|
||||
AUTH_TYPE: 4, // AuthType.AuthOauth
|
||||
AUTH_PROVIDERS: [{ name: 'google', icon: 'google' }],
|
||||
AUTH_USER_REGISTRATION: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderLogin();
|
||||
|
||||
const googleButton = screen.getByRole('link', {
|
||||
name: /Sign in with Google/i,
|
||||
});
|
||||
expect(googleButton).toHaveAttribute('href', '/login/google');
|
||||
});
|
||||
|
||||
test('should render LDAP/OID providers with correct app root URLs', () => {
|
||||
mockApplicationRoot.mockReturnValue('/superset');
|
||||
mockGetBootstrapData.mockReturnValue({
|
||||
common: {
|
||||
conf: {
|
||||
AUTH_TYPE: 0, // AuthType.AuthOID
|
||||
AUTH_PROVIDERS: [{ name: 'ldap', url: '/login/ldap' }],
|
||||
AUTH_USER_REGISTRATION: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderLogin();
|
||||
|
||||
const ldapButton = screen.getByRole('link', { name: /Sign in with Ldap/i });
|
||||
expect(ldapButton).toHaveAttribute('href', '/superset/login/ldap');
|
||||
});
|
||||
|
||||
test('should render registration button with correct app root URL', () => {
|
||||
mockApplicationRoot.mockReturnValue('/superset');
|
||||
mockGetBootstrapData.mockReturnValue({
|
||||
common: {
|
||||
conf: {
|
||||
AUTH_TYPE: 1, // AuthType.AuthDB
|
||||
AUTH_PROVIDERS: [],
|
||||
AUTH_USER_REGISTRATION: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderLogin();
|
||||
|
||||
const registerButton = screen.getByTestId('register-button');
|
||||
expect(registerButton).toHaveAttribute('href', '/superset/register/');
|
||||
});
|
||||
|
||||
test('should render registration button with default URL when no app root', () => {
|
||||
mockApplicationRoot.mockReturnValue('');
|
||||
mockGetBootstrapData.mockReturnValue({
|
||||
common: {
|
||||
conf: {
|
||||
AUTH_TYPE: 1, // AuthType.AuthDB
|
||||
AUTH_PROVIDERS: [],
|
||||
AUTH_USER_REGISTRATION: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderLogin();
|
||||
|
||||
const registerButton = screen.getByTestId('register-button');
|
||||
expect(registerButton).toHaveAttribute('href', '/register/');
|
||||
});
|
||||
|
||||
test('should call SupersetClient.postForm with correct endpoint (no double-prefix)', async () => {
|
||||
const postFormSpy = jest
|
||||
.spyOn(SupersetClient, 'postForm')
|
||||
.mockResolvedValue();
|
||||
|
||||
mockApplicationRoot.mockReturnValue('/superset');
|
||||
mockGetBootstrapData.mockReturnValue({
|
||||
common: {
|
||||
conf: {
|
||||
AUTH_TYPE: 1, // AuthType.AuthDB
|
||||
AUTH_PROVIDERS: [],
|
||||
AUTH_USER_REGISTRATION: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderLogin();
|
||||
|
||||
// Fill in the form
|
||||
const usernameInput = screen.getByTestId('username-input');
|
||||
const passwordInput = screen.getByTestId('password-input');
|
||||
const loginButton = screen.getByTestId('login-button');
|
||||
|
||||
await userEvent.type(usernameInput, 'testuser');
|
||||
await userEvent.type(passwordInput, 'testpass');
|
||||
await userEvent.click(loginButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(postFormSpy).toHaveBeenCalledWith(
|
||||
'/login/', // Should be bare endpoint, not /superset/login/
|
||||
{ username: 'testuser', password: 'testpass' },
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
postFormSpy.mockRestore();
|
||||
});
|
||||
|
||||
// Edge case tests
|
||||
test('should handle empty providers array gracefully', () => {
|
||||
mockGetBootstrapData.mockReturnValue({
|
||||
common: {
|
||||
conf: {
|
||||
AUTH_TYPE: 4, // AuthType.AuthOauth
|
||||
AUTH_PROVIDERS: [],
|
||||
AUTH_USER_REGISTRATION: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderLogin();
|
||||
|
||||
// Should not crash and OAuth section should be empty
|
||||
expect(
|
||||
screen.queryByRole('link', { name: /Sign in with/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should handle invalid provider objects', () => {
|
||||
mockGetBootstrapData.mockReturnValue({
|
||||
common: {
|
||||
conf: {
|
||||
AUTH_TYPE: 4, // AuthType.AuthOauth
|
||||
AUTH_PROVIDERS: [
|
||||
{ name: null, icon: 'google' },
|
||||
{ name: 'github' }, // missing icon
|
||||
{}, // empty object
|
||||
],
|
||||
AUTH_USER_REGISTRATION: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderLogin();
|
||||
|
||||
// Should only render valid providers
|
||||
const githubButton = screen.getByRole('link', {
|
||||
name: /Sign in with Github/i,
|
||||
});
|
||||
expect(githubButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should handle providers with special characters in names', () => {
|
||||
mockApplicationRoot.mockReturnValue('/superset');
|
||||
mockGetBootstrapData.mockReturnValue({
|
||||
common: {
|
||||
conf: {
|
||||
AUTH_TYPE: 4, // AuthType.AuthOauth
|
||||
AUTH_PROVIDERS: [
|
||||
{ name: 'test-provider', icon: 'test' },
|
||||
{ name: 'test_provider', icon: 'test' },
|
||||
{ name: 'test.provider', icon: 'test' },
|
||||
],
|
||||
AUTH_USER_REGISTRATION: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderLogin();
|
||||
|
||||
const testDashButton = screen.getByRole('link', {
|
||||
name: /Sign in with Test-provider/i,
|
||||
});
|
||||
expect(testDashButton).toHaveAttribute(
|
||||
'href',
|
||||
'/superset/login/test-provider',
|
||||
);
|
||||
|
||||
const testUnderscoreButton = screen.getByRole('link', {
|
||||
name: /Sign in with Test_provider/i,
|
||||
});
|
||||
expect(testUnderscoreButton).toHaveAttribute(
|
||||
'href',
|
||||
'/superset/login/test_provider',
|
||||
);
|
||||
|
||||
const testDotButton = screen.getByRole('link', {
|
||||
name: /Sign in with Test.provider/i,
|
||||
});
|
||||
expect(testDotButton).toHaveAttribute(
|
||||
'href',
|
||||
'/superset/login/test.provider',
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle very long provider names', () => {
|
||||
const longName = 'a'.repeat(100);
|
||||
mockGetBootstrapData.mockReturnValue({
|
||||
common: {
|
||||
conf: {
|
||||
AUTH_TYPE: 4, // AuthType.AuthOauth
|
||||
AUTH_PROVIDERS: [{ name: longName, icon: 'test' }],
|
||||
AUTH_USER_REGISTRATION: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderLogin();
|
||||
|
||||
const longButton = screen.getByRole('link', {
|
||||
name: new RegExp(`Sign in with ${longName.charAt(0).toUpperCase()}`, 'i'),
|
||||
});
|
||||
expect(longButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should handle mixed auth types correctly', () => {
|
||||
// Test OAuth with registration enabled
|
||||
mockGetBootstrapData.mockReturnValue({
|
||||
common: {
|
||||
conf: {
|
||||
AUTH_TYPE: 4, // AuthType.AuthOauth
|
||||
AUTH_PROVIDERS: [{ name: 'google', icon: 'google' }],
|
||||
AUTH_USER_REGISTRATION: true, // Registration with OAuth
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderLogin();
|
||||
|
||||
const googleButton = screen.getByRole('link', {
|
||||
name: /Sign in with Google/i,
|
||||
});
|
||||
expect(googleButton).toBeInTheDocument();
|
||||
// Registration button should not be shown with OAuth
|
||||
expect(screen.queryByTestId('register-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should handle undefined provider configuration', () => {
|
||||
mockGetBootstrapData.mockReturnValue({
|
||||
common: {
|
||||
conf: {
|
||||
AUTH_TYPE: 4, // AuthType.AuthOauth
|
||||
AUTH_PROVIDERS: undefined,
|
||||
AUTH_USER_REGISTRATION: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderLogin();
|
||||
|
||||
// Should not crash
|
||||
expect(screen.getByTestId('login-form')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should handle null provider configuration', () => {
|
||||
mockGetBootstrapData.mockReturnValue({
|
||||
common: {
|
||||
conf: {
|
||||
AUTH_TYPE: 4, // AuthType.AuthOauth
|
||||
AUTH_PROVIDERS: null,
|
||||
AUTH_USER_REGISTRATION: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderLogin();
|
||||
|
||||
// Should not crash
|
||||
expect(screen.getByTestId('login-form')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Integration and interaction tests
|
||||
test('should handle full login flow with session storage', async () => {
|
||||
const postFormSpy = jest
|
||||
.spyOn(SupersetClient, 'postForm')
|
||||
.mockResolvedValue();
|
||||
|
||||
mockGetBootstrapData.mockReturnValue({
|
||||
common: {
|
||||
conf: {
|
||||
AUTH_TYPE: 1, // AuthType.AuthDB
|
||||
AUTH_PROVIDERS: [],
|
||||
AUTH_USER_REGISTRATION: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderLogin();
|
||||
|
||||
const usernameInput = screen.getByTestId('username-input');
|
||||
const passwordInput = screen.getByTestId('password-input');
|
||||
const loginButton = screen.getByTestId('login-button');
|
||||
|
||||
// Type credentials
|
||||
await userEvent.type(usernameInput, 'testuser');
|
||||
await userEvent.type(passwordInput, 'testpass123');
|
||||
|
||||
// Check session storage is set before submission
|
||||
await userEvent.click(loginButton);
|
||||
|
||||
expect(sessionStorage.getItem('login_attempted')).toBe('true');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(postFormSpy).toHaveBeenCalledWith(
|
||||
'/login/',
|
||||
{ username: 'testuser', password: 'testpass123' },
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
postFormSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('should show loading state during form submission', async () => {
|
||||
const postFormSpy = jest
|
||||
.spyOn(SupersetClient, 'postForm')
|
||||
.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
|
||||
|
||||
mockGetBootstrapData.mockReturnValue({
|
||||
common: {
|
||||
conf: {
|
||||
AUTH_TYPE: 1, // AuthType.AuthDB
|
||||
AUTH_PROVIDERS: [],
|
||||
AUTH_USER_REGISTRATION: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderLogin();
|
||||
|
||||
const usernameInput = screen.getByTestId('username-input');
|
||||
const passwordInput = screen.getByTestId('password-input');
|
||||
const loginButton = screen.getByTestId('login-button');
|
||||
|
||||
await userEvent.type(usernameInput, 'user');
|
||||
await userEvent.type(passwordInput, 'pass');
|
||||
await userEvent.click(loginButton);
|
||||
|
||||
// Button should show loading state
|
||||
expect(loginButton).toHaveAttribute('aria-busy', 'true');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(loginButton).not.toHaveAttribute('aria-busy', 'true');
|
||||
});
|
||||
|
||||
postFormSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('should validate password field is required', async () => {
|
||||
mockGetBootstrapData.mockReturnValue({
|
||||
common: {
|
||||
conf: {
|
||||
AUTH_TYPE: 1, // AuthType.AuthDB
|
||||
AUTH_PROVIDERS: [],
|
||||
AUTH_USER_REGISTRATION: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderLogin();
|
||||
|
||||
const usernameInput = screen.getByTestId('username-input');
|
||||
const loginButton = screen.getByTestId('login-button');
|
||||
|
||||
// Try to submit with only username
|
||||
await userEvent.type(usernameInput, 'testuser');
|
||||
await userEvent.click(loginButton);
|
||||
|
||||
// Form should not submit without password
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Please enter your password')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle keyboard navigation for accessibility', async () => {
|
||||
mockGetBootstrapData.mockReturnValue({
|
||||
common: {
|
||||
conf: {
|
||||
AUTH_TYPE: 4, // AuthType.AuthOauth
|
||||
AUTH_PROVIDERS: [
|
||||
{ name: 'google', icon: 'google' },
|
||||
{ name: 'github', icon: 'github' },
|
||||
],
|
||||
AUTH_USER_REGISTRATION: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderLogin();
|
||||
|
||||
const googleButton = screen.getByRole('link', {
|
||||
name: /Sign in with Google/i,
|
||||
});
|
||||
const githubButton = screen.getByRole('link', {
|
||||
name: /Sign in with Github/i,
|
||||
});
|
||||
|
||||
// Tab to first OAuth button
|
||||
googleButton.focus();
|
||||
expect(googleButton).toHaveFocus();
|
||||
|
||||
// Tab to next OAuth button
|
||||
await userEvent.tab();
|
||||
expect(githubButton).toHaveFocus();
|
||||
});
|
||||
|
||||
test('should handle password visibility toggle', async () => {
|
||||
mockGetBootstrapData.mockReturnValue({
|
||||
common: {
|
||||
conf: {
|
||||
AUTH_TYPE: 1, // AuthType.AuthDB
|
||||
AUTH_PROVIDERS: [],
|
||||
AUTH_USER_REGISTRATION: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderLogin();
|
||||
|
||||
const passwordInput = screen.getByTestId('password-input');
|
||||
|
||||
// Initially password should be hidden
|
||||
expect(passwordInput).toHaveAttribute('type', 'password');
|
||||
|
||||
// Find and click the visibility toggle button
|
||||
const toggleButton = screen.getByRole('button', { name: /eye/i });
|
||||
await userEvent.click(toggleButton);
|
||||
|
||||
// Password should now be visible
|
||||
expect(passwordInput).toHaveAttribute('type', 'text');
|
||||
|
||||
// Click again to hide
|
||||
await userEvent.click(toggleButton);
|
||||
expect(passwordInput).toHaveAttribute('type', 'password');
|
||||
});
|
||||
|
||||
test('should handle form reset when navigating away', async () => {
|
||||
mockGetBootstrapData.mockReturnValue({
|
||||
common: {
|
||||
conf: {
|
||||
AUTH_TYPE: 1, // AuthType.AuthDB
|
||||
AUTH_PROVIDERS: [],
|
||||
AUTH_USER_REGISTRATION: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { unmount } = renderLogin();
|
||||
|
||||
const usernameInput = screen.getByTestId('username-input');
|
||||
const passwordInput = screen.getByTestId('password-input');
|
||||
|
||||
// Enter some data
|
||||
await userEvent.type(usernameInput, 'testuser');
|
||||
await userEvent.type(passwordInput, 'testpass');
|
||||
|
||||
// Unmount and remount
|
||||
unmount();
|
||||
renderLogin();
|
||||
|
||||
// Fields should be empty after remounting
|
||||
expect(screen.getByTestId('username-input')).toHaveValue('');
|
||||
expect(screen.getByTestId('password-input')).toHaveValue('');
|
||||
});
|
||||
|
||||
// Error state tests
|
||||
test('should handle network error during form submission', async () => {
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
const postFormSpy = jest
|
||||
.spyOn(SupersetClient, 'postForm')
|
||||
.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
mockGetBootstrapData.mockReturnValue({
|
||||
common: {
|
||||
conf: {
|
||||
AUTH_TYPE: 1, // AuthType.AuthDB
|
||||
AUTH_PROVIDERS: [],
|
||||
AUTH_USER_REGISTRATION: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderLogin();
|
||||
|
||||
const usernameInput = screen.getByTestId('username-input');
|
||||
const passwordInput = screen.getByTestId('password-input');
|
||||
const loginButton = screen.getByTestId('login-button');
|
||||
|
||||
await userEvent.type(usernameInput, 'testuser');
|
||||
await userEvent.type(passwordInput, 'testpass');
|
||||
await userEvent.click(loginButton);
|
||||
|
||||
// Should handle error gracefully
|
||||
await waitFor(() => {
|
||||
expect(postFormSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
postFormSpy.mockRestore();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('should handle malformed bootstrap data', () => {
|
||||
mockGetBootstrapData.mockReturnValue({
|
||||
common: {
|
||||
conf: null, // Malformed config
|
||||
},
|
||||
});
|
||||
|
||||
// Should not crash
|
||||
renderLogin();
|
||||
expect(screen.getByTestId('login-form')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should handle missing auth type', () => {
|
||||
mockGetBootstrapData.mockReturnValue({
|
||||
common: {
|
||||
conf: {
|
||||
// No AUTH_TYPE
|
||||
AUTH_PROVIDERS: [],
|
||||
AUTH_USER_REGISTRATION: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderLogin();
|
||||
// Should still render form
|
||||
expect(screen.getByTestId('login-form')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should handle error when sessionStorage is unavailable', async () => {
|
||||
const originalSessionStorage = global.sessionStorage;
|
||||
// @ts-ignore
|
||||
delete global.sessionStorage;
|
||||
|
||||
const postFormSpy = jest
|
||||
.spyOn(SupersetClient, 'postForm')
|
||||
.mockResolvedValue();
|
||||
|
||||
mockGetBootstrapData.mockReturnValue({
|
||||
common: {
|
||||
conf: {
|
||||
AUTH_TYPE: 1, // AuthType.AuthDB
|
||||
AUTH_PROVIDERS: [],
|
||||
AUTH_USER_REGISTRATION: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderLogin();
|
||||
|
||||
const usernameInput = screen.getByTestId('username-input');
|
||||
const passwordInput = screen.getByTestId('password-input');
|
||||
const loginButton = screen.getByTestId('login-button');
|
||||
|
||||
await userEvent.type(usernameInput, 'testuser');
|
||||
await userEvent.type(passwordInput, 'testpass');
|
||||
|
||||
// Should not crash even without sessionStorage
|
||||
await userEvent.click(loginButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(postFormSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
global.sessionStorage = originalSessionStorage;
|
||||
postFormSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('should display error message from session storage on mount', () => {
|
||||
sessionStorage.setItem('login_attempted', 'true');
|
||||
|
||||
mockGetBootstrapData.mockReturnValue({
|
||||
common: {
|
||||
conf: {
|
||||
AUTH_TYPE: 1, // AuthType.AuthDB
|
||||
AUTH_PROVIDERS: [],
|
||||
AUTH_USER_REGISTRATION: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderLogin();
|
||||
|
||||
// Should show error toast
|
||||
expect(sessionStorage.getItem('login_attempted')).toBeNull();
|
||||
});
|
||||
|
||||
test('should handle OAuth provider with malformed URL', () => {
|
||||
mockApplicationRoot.mockReturnValue('/superset');
|
||||
mockGetBootstrapData.mockReturnValue({
|
||||
common: {
|
||||
conf: {
|
||||
AUTH_TYPE: 0, // AuthType.AuthOID
|
||||
AUTH_PROVIDERS: [
|
||||
{ name: 'provider1', url: null }, // null URL
|
||||
{ name: 'provider2', url: '' }, // empty URL
|
||||
{ name: 'provider3' }, // missing URL
|
||||
],
|
||||
AUTH_USER_REGISTRATION: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderLogin();
|
||||
|
||||
// Should still render buttons for all providers
|
||||
expect(
|
||||
screen.getByRole('link', { name: /Sign in with Provider1/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('link', { name: /Sign in with Provider2/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('link', { name: /Sign in with Provider3/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -27,9 +27,12 @@ import {
|
||||
Typography,
|
||||
Icons,
|
||||
} from '@superset-ui/core/components';
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { capitalize } from 'lodash/fp';
|
||||
import { addDangerToast } from 'src/components/MessageToasts/actions';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import getBootstrapData from 'src/utils/getBootstrapData';
|
||||
import { ensureAppRoot } from 'src/utils/pathUtils';
|
||||
|
||||
type OAuthProvider = {
|
||||
name: string;
|
||||
@@ -77,6 +80,7 @@ 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();
|
||||
|
||||
@@ -85,8 +89,28 @@ export default function Login() {
|
||||
const authRegistration: boolean =
|
||||
bootstrapData.common.conf.AUTH_USER_REGISTRATION;
|
||||
|
||||
// TODO: This is a temporary solution for showing login errors after form submission.
|
||||
// Should be replaced with proper SPA-style authentication (JSON API with error responses)
|
||||
// when Flask-AppBuilder is updated or we implement a custom login endpoint.
|
||||
useEffect(() => {
|
||||
const loginAttempted = sessionStorage.getItem('login_attempted');
|
||||
|
||||
if (loginAttempted === 'true') {
|
||||
sessionStorage.removeItem('login_attempted');
|
||||
dispatch(addDangerToast(t('Invalid username or password')));
|
||||
// Clear password field for security
|
||||
form.setFieldsValue({ password: '' });
|
||||
}
|
||||
}, [dispatch, form]);
|
||||
|
||||
const onFinish = (values: LoginForm) => {
|
||||
setLoading(true);
|
||||
|
||||
// Mark that we're attempting login (for error detection after redirect)
|
||||
sessionStorage.setItem('login_attempted', 'true');
|
||||
|
||||
// Note: SupersetClient.postForm already adds appRoot internally via getUrl()
|
||||
// so we don't use ensureAppRoot() here to avoid double-prefixing
|
||||
SupersetClient.postForm('/login/', values, '').finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
@@ -126,7 +150,7 @@ export default function Login() {
|
||||
{providers.map((provider: OIDProvider) => (
|
||||
<Form.Item<LoginForm>>
|
||||
<Button
|
||||
href={`/login/${provider.name}`}
|
||||
href={ensureAppRoot(`/login/${provider.name}`)}
|
||||
block
|
||||
iconPosition="start"
|
||||
icon={getAuthIconElement(provider.name)}
|
||||
@@ -144,7 +168,7 @@ export default function Login() {
|
||||
{providers.map((provider: OAuthProvider) => (
|
||||
<Form.Item<LoginForm>>
|
||||
<Button
|
||||
href={`/login/${provider.name}`}
|
||||
href={ensureAppRoot(`/login/${provider.name}`)}
|
||||
block
|
||||
iconPosition="start"
|
||||
icon={getAuthIconElement(provider.name)}
|
||||
@@ -212,7 +236,7 @@ export default function Login() {
|
||||
<Button
|
||||
block
|
||||
type="default"
|
||||
href="/register/"
|
||||
href={ensureAppRoot('/register/')}
|
||||
data-test="register-button"
|
||||
>
|
||||
{t('Register')}
|
||||
|
||||
@@ -20,15 +20,18 @@ import { render, screen } from 'spec/helpers/testing-library';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import Register from './index';
|
||||
|
||||
const mockGetBootstrapData = jest.fn();
|
||||
const mockApplicationRoot = jest.fn();
|
||||
|
||||
jest.mock('src/utils/getBootstrapData', () => ({
|
||||
__esModule: true,
|
||||
default: () => ({
|
||||
common: {
|
||||
conf: {
|
||||
RECAPTCHA_PUBLIC_KEY: '',
|
||||
},
|
||||
},
|
||||
}),
|
||||
default: () => mockGetBootstrapData(),
|
||||
}));
|
||||
|
||||
jest.mock('src/utils/pathUtils', () => ({
|
||||
__esModule: true,
|
||||
ensureAppRoot: (path: string) =>
|
||||
`${mockApplicationRoot()}${path.startsWith('/') ? path : `/${path}`}`,
|
||||
}));
|
||||
|
||||
jest.mock('react-google-recaptcha', () => ({
|
||||
@@ -43,6 +46,17 @@ const renderRegister = () =>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetBootstrapData.mockReturnValue({
|
||||
common: {
|
||||
conf: {
|
||||
RECAPTCHA_PUBLIC_KEY: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
mockApplicationRoot.mockReturnValue('');
|
||||
});
|
||||
|
||||
test('should render register form elements', () => {
|
||||
renderRegister();
|
||||
|
||||
@@ -80,3 +94,35 @@ test('should render input placeholders', () => {
|
||||
expect(screen.getByPlaceholderText('Password')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Confirm password')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render login button with correct app root URL', () => {
|
||||
mockApplicationRoot.mockReturnValue('/superset');
|
||||
renderRegister();
|
||||
|
||||
const loginButton = screen.getByTestId('login-button');
|
||||
expect(loginButton).toHaveAttribute('href', '/superset/login/');
|
||||
});
|
||||
|
||||
test('should render login button with default URL when no app root', () => {
|
||||
mockApplicationRoot.mockReturnValue('');
|
||||
renderRegister();
|
||||
|
||||
const loginButton = screen.getByTestId('login-button');
|
||||
expect(loginButton).toHaveAttribute('href', '/login/');
|
||||
});
|
||||
|
||||
test('should handle empty app root correctly', () => {
|
||||
mockApplicationRoot.mockReturnValue(null);
|
||||
renderRegister();
|
||||
|
||||
const loginButton = screen.getByTestId('login-button');
|
||||
expect(loginButton).toHaveAttribute('href', '/login/');
|
||||
});
|
||||
|
||||
test('should handle app root with trailing slash', () => {
|
||||
mockApplicationRoot.mockReturnValue('/superset/');
|
||||
renderRegister();
|
||||
|
||||
const loginButton = screen.getByTestId('login-button');
|
||||
expect(loginButton).toHaveAttribute('href', '/superset/login/');
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from '@superset-ui/core/components';
|
||||
import { useState } from 'react';
|
||||
import getBootstrapData from 'src/utils/getBootstrapData';
|
||||
import { ensureAppRoot } from 'src/utils/pathUtils';
|
||||
import ReactCAPTCHA from 'react-google-recaptcha';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
@@ -87,7 +88,11 @@ export default function Login() {
|
||||
title="Registration successful"
|
||||
subTitle="Your account is activated. You can log in with your credentials."
|
||||
extra={[
|
||||
<Button type="default" href="/login/" data-test="login-button">
|
||||
<Button
|
||||
type="default"
|
||||
href={ensureAppRoot('/login/')}
|
||||
data-test="login-button"
|
||||
>
|
||||
{t('Login')}
|
||||
</Button>,
|
||||
]}
|
||||
|
||||
@@ -83,7 +83,6 @@ const createMockBootstrapData = (
|
||||
common: {
|
||||
application_root: '/',
|
||||
static_assets_prefix: '/static/assets/',
|
||||
flash_messages: [],
|
||||
conf: {},
|
||||
locale: 'en',
|
||||
feature_flags: {},
|
||||
@@ -391,7 +390,6 @@ describe('ThemeController', () => {
|
||||
common: {
|
||||
application_root: '/',
|
||||
static_assets_prefix: '/static/assets/',
|
||||
flash_messages: [],
|
||||
conf: {},
|
||||
locale: 'en',
|
||||
feature_flags: {},
|
||||
|
||||
@@ -20,7 +20,6 @@ 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,
|
||||
@@ -154,7 +153,6 @@ export interface BootstrapThemeDataConfig {
|
||||
export interface CommonBootstrapData {
|
||||
application_root: string;
|
||||
static_assets_prefix: string;
|
||||
flash_messages: FlashMessage[];
|
||||
conf: JsonObject;
|
||||
locale: Locale;
|
||||
feature_flags: FeatureFlagMap;
|
||||
@@ -165,6 +163,7 @@ export interface CommonBootstrapData {
|
||||
menu_data: MenuData;
|
||||
d3_format: Partial<FormatLocaleDefinition>;
|
||||
d3_time_format: Partial<TimeLocaleDefinition>;
|
||||
pdf_compression_level: 'NONE' | 'FAST' | 'MEDIUM' | 'SLOW';
|
||||
}
|
||||
|
||||
export interface BootstrapData {
|
||||
|
||||
@@ -21,6 +21,9 @@ import domToPdf from 'dom-to-pdf';
|
||||
import { kebabCase } from 'lodash';
|
||||
import { logging, t } from '@superset-ui/core';
|
||||
import { addWarningToast } from 'src/components/MessageToasts/actions';
|
||||
import getBootstrapData from 'src/utils/getBootstrapData';
|
||||
|
||||
const pdfCompressionLevel = getBootstrapData().common.pdf_compression_level;
|
||||
|
||||
/**
|
||||
* generate a consistent file stem from a description and date
|
||||
@@ -58,6 +61,7 @@ export default function downloadAsPdf(
|
||||
|
||||
const options = {
|
||||
margin: 10,
|
||||
compression: pdfCompressionLevel,
|
||||
filename: `${generateFileStem(description)}.pdf`,
|
||||
image: { type: 'jpeg', quality: 1 },
|
||||
html2canvas: { scale: 2 },
|
||||
|
||||
@@ -23,8 +23,7 @@ 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 getBootstrapData from 'src/utils/getBootstrapData';
|
||||
import { FlashProvider, DynamicPluginProvider } from 'src/components';
|
||||
import { DynamicPluginProvider } from 'src/components';
|
||||
import { EmbeddedUiConfigProvider } from 'src/components/UiConfigContext';
|
||||
import { SupersetThemeProvider } from 'src/theme/ThemeProvider';
|
||||
import { ThemeController } from 'src/theme/ThemeController';
|
||||
@@ -32,7 +31,6 @@ import { ExtensionsProvider } from 'src/extensions/ExtensionsContext';
|
||||
import { store } from './store';
|
||||
import '../preamble';
|
||||
|
||||
const { common } = getBootstrapData();
|
||||
const themeController = new ThemeController();
|
||||
const extensionsRegistry = getExtensionsRegistry();
|
||||
|
||||
@@ -45,26 +43,24 @@ export const RootContextProviders: React.FC = ({ children }) => {
|
||||
<SupersetThemeProvider themeController={themeController}>
|
||||
<ReduxProvider store={store}>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<FlashProvider messages={common.flash_messages}>
|
||||
<EmbeddedUiConfigProvider>
|
||||
<DynamicPluginProvider>
|
||||
<QueryParamProvider
|
||||
ReactRouterRoute={Route}
|
||||
stringifyOptions={{ encode: false }}
|
||||
>
|
||||
<ExtensionsProvider>
|
||||
{RootContextProviderExtension ? (
|
||||
<RootContextProviderExtension>
|
||||
{children}
|
||||
</RootContextProviderExtension>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</ExtensionsProvider>
|
||||
</QueryParamProvider>
|
||||
</DynamicPluginProvider>
|
||||
</EmbeddedUiConfigProvider>
|
||||
</FlashProvider>
|
||||
<EmbeddedUiConfigProvider>
|
||||
<DynamicPluginProvider>
|
||||
<QueryParamProvider
|
||||
ReactRouterRoute={Route}
|
||||
stringifyOptions={{ encode: false }}
|
||||
>
|
||||
<ExtensionsProvider>
|
||||
{RootContextProviderExtension ? (
|
||||
<RootContextProviderExtension>
|
||||
{children}
|
||||
</RootContextProviderExtension>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</ExtensionsProvider>
|
||||
</QueryParamProvider>
|
||||
</DynamicPluginProvider>
|
||||
</EmbeddedUiConfigProvider>
|
||||
</DndProvider>
|
||||
</ReduxProvider>
|
||||
</SupersetThemeProvider>
|
||||
|
||||
@@ -65,6 +65,9 @@ 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/',
|
||||
@@ -184,21 +187,64 @@ if (!isDevMode) {
|
||||
chunkFilename: '[name].[chunkhash].chunk.css',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Runs type checking on a separate process to speed up the build
|
||||
// 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
|
||||
plugins.push(
|
||||
new ForkTsCheckerWebpackPlugin({
|
||||
typescript: {
|
||||
memoryLimit: 4096,
|
||||
memoryLimit: TYPESCRIPT_MEMORY_LIMIT,
|
||||
build: true,
|
||||
exclude: [
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/coverage/**',
|
||||
'**/storybook/**',
|
||||
'**/*.stories.{ts,tsx,js,jsx}',
|
||||
'**/*.{test,spec}.{ts,tsx,js,jsx}',
|
||||
],
|
||||
mode: 'write-references',
|
||||
},
|
||||
// Logger configuration
|
||||
logger: 'webpack-infrastructure',
|
||||
issue: {
|
||||
exclude: [{ file: '**/node_modules/**' }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -344,7 +390,12 @@ const config = {
|
||||
},
|
||||
resolve: {
|
||||
// resolve modules from `/superset_frontend/node_modules` and `/superset_frontend`
|
||||
modules: ['node_modules', APP_DIR],
|
||||
modules: [
|
||||
'node_modules',
|
||||
APP_DIR,
|
||||
path.resolve(APP_DIR, 'packages'),
|
||||
path.resolve(APP_DIR, 'plugins'),
|
||||
],
|
||||
alias: {
|
||||
react: path.resolve(path.join(APP_DIR, './node_modules/react')),
|
||||
// TODO: remove Handlebars alias once Handlebars NPM package has been updated to
|
||||
@@ -538,6 +589,16 @@ 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
|
||||
|
||||
@@ -115,6 +115,7 @@ PACKAGE_JSON_FILE = str(files("superset") / "static/assets/package.json")
|
||||
# "rel": "icon"
|
||||
# },
|
||||
FAVICONS = [{"href": "/static/assets/images/favicon.png"}]
|
||||
PDF_COMPRESSION_LEVEL: Literal["NONE", "FAST", "MEDIUM", "SLOW"] = "MEDIUM"
|
||||
|
||||
|
||||
def _try_json_readversion(filepath: str) -> str | None:
|
||||
|
||||
@@ -63,7 +63,6 @@ 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,9 +18,8 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from flask import flash, g, redirect
|
||||
from flask import 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
|
||||
@@ -66,7 +65,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)
|
||||
flash(as_unicode(self.false_error_message), "danger")
|
||||
logger.error("Registration activation failed: %s", self.false_error_message)
|
||||
return redirect(self.appbuilder.get_url_for_index)
|
||||
if not self.appbuilder.sm.add_user(
|
||||
username=reg.username,
|
||||
@@ -78,7 +77,7 @@ class SupersetRegisterUserView(BaseSupersetView):
|
||||
),
|
||||
hashed_password=reg.password,
|
||||
):
|
||||
flash(as_unicode(self.error_message), "danger")
|
||||
logger.error("User registration failed: %s", self.error_message)
|
||||
return redirect(self.appbuilder.get_url_for_index)
|
||||
else:
|
||||
self.appbuilder.sm.del_register_user(reg)
|
||||
|
||||
@@ -27,9 +27,7 @@ from babel import Locale
|
||||
from flask import (
|
||||
abort,
|
||||
current_app as app,
|
||||
flash,
|
||||
g,
|
||||
get_flashed_messages,
|
||||
redirect,
|
||||
Response,
|
||||
session,
|
||||
@@ -490,6 +488,7 @@ def cached_common_bootstrap_data( # pylint: disable=unused-argument
|
||||
"EXTRA_CATEGORICAL_COLOR_SCHEMES"
|
||||
],
|
||||
"menu_data": menu_data(g.user),
|
||||
"pdf_compression_level": app.config["PDF_COMPRESSION_LEVEL"],
|
||||
}
|
||||
|
||||
bootstrap_data.update(app.config["COMMON_BOOTSTRAP_OVERRIDES_FUNC"](bootstrap_data))
|
||||
@@ -499,10 +498,7 @@ 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()),
|
||||
"flash_messages": get_flashed_messages(with_categories=True),
|
||||
}
|
||||
return cached_common_bootstrap_data(utils.get_user_id(), get_locale())
|
||||
|
||||
|
||||
def get_spa_payload(extra_data: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
@@ -597,7 +593,7 @@ class DeleteMixin: # pylint: disable=too-few-public-methods
|
||||
try:
|
||||
self.pre_delete(item)
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
flash(str(ex), "danger")
|
||||
logger.error("Pre-delete error: %s", str(ex))
|
||||
else:
|
||||
view_menu = security_manager.find_view_menu(item.get_perm())
|
||||
pvs = (
|
||||
@@ -617,7 +613,6 @@ class DeleteMixin: # pylint: disable=too-few-public-methods
|
||||
|
||||
db.session.commit() # pylint: disable=consider-using-transaction
|
||||
|
||||
flash(*self.datamodel.message)
|
||||
self.update_redirect()
|
||||
|
||||
@action(
|
||||
@@ -630,7 +625,7 @@ class DeleteMixin: # pylint: disable=too-few-public-methods
|
||||
try:
|
||||
self.pre_delete(item)
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
flash(str(ex), "danger")
|
||||
logger.error("Pre-delete error: %s", str(ex))
|
||||
else:
|
||||
self._delete(item.id)
|
||||
self.update_redirect()
|
||||
|
||||
@@ -28,7 +28,6 @@ from urllib import parse
|
||||
from flask import (
|
||||
abort,
|
||||
current_app as app,
|
||||
flash,
|
||||
g,
|
||||
redirect,
|
||||
request,
|
||||
@@ -109,7 +108,6 @@ from superset.views.utils import (
|
||||
get_form_data,
|
||||
get_viz,
|
||||
loads_request_json,
|
||||
redirect_with_flash,
|
||||
sanitize_datasource_data,
|
||||
)
|
||||
from superset.viz import BaseViz
|
||||
@@ -415,7 +413,6 @@ class Superset(BaseSupersetView):
|
||||
|
||||
initial_form_data = {}
|
||||
|
||||
form_data_key = request.args.get("form_data_key")
|
||||
if key is not None:
|
||||
command = GetExplorePermalinkCommand(key)
|
||||
try:
|
||||
@@ -430,9 +427,10 @@ class Superset(BaseSupersetView):
|
||||
_("Error: permalink state not found"), status=404
|
||||
)
|
||||
except (ChartNotFoundError, ExplorePermalinkGetFailedError) as ex:
|
||||
flash(__("Error: %(msg)s", msg=ex.message), "danger")
|
||||
return redirect(url_for("SliceModelView.list"))
|
||||
elif form_data_key:
|
||||
return json_error_response(
|
||||
__("Error: %(msg)s", msg=ex.message), status=404
|
||||
)
|
||||
elif form_data_key := request.args.get("form_data_key"):
|
||||
parameters = CommandParameters(key=form_data_key)
|
||||
value = GetFormDataCommand(parameters).run()
|
||||
initial_form_data = json.loads(value) if value else {}
|
||||
@@ -442,18 +440,8 @@ 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
|
||||
@@ -626,13 +614,9 @@ 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
|
||||
@@ -654,13 +638,6 @@ 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
|
||||
@@ -675,12 +652,6 @@ 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)
|
||||
@@ -798,19 +769,9 @@ class Superset(BaseSupersetView):
|
||||
|
||||
try:
|
||||
dashboard.raise_for_access()
|
||||
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",
|
||||
)
|
||||
except SupersetSecurityException:
|
||||
# Return 404 to avoid revealing dashboard existence
|
||||
return Response(status=404)
|
||||
add_extra_log_payload(
|
||||
dashboard_id=dashboard.id,
|
||||
dashboard_version="v2",
|
||||
@@ -841,12 +802,8 @@ class Superset(BaseSupersetView):
|
||||
) -> FlaskResponse:
|
||||
try:
|
||||
value = GetDashboardPermalinkCommand(key).run()
|
||||
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"))
|
||||
except (DashboardPermalinkGetFailedError, DashboardAccessDeniedError) as ex:
|
||||
return json_error_response(__("Error: %(msg)s", msg=ex.message), status=404)
|
||||
if not value:
|
||||
return json_error_response(_("permalink state not found"), status=404)
|
||||
|
||||
|
||||
@@ -30,6 +30,5 @@ 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,12 +22,11 @@ from typing import Any, Callable, DefaultDict, Optional, Union
|
||||
|
||||
import msgpack
|
||||
import pyarrow as pa
|
||||
from flask import current_app as app, flash, g, has_request_context, redirect, request
|
||||
from flask import current_app as app, g, has_request_context, 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
|
||||
@@ -551,8 +550,3 @@ 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 == 302
|
||||
assert response.status_code == 404
|
||||
|
||||
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 == 302
|
||||
assert response.status_code == 404
|
||||
|
||||
# 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 redirect on regular rbac access denied
|
||||
# assert 404 on regular rbac access denied (prevents information leakage)
|
||||
response = self.get_dashboard_view_response(dashboard)
|
||||
assert response.status_code == 302
|
||||
assert response.status_code == 404
|
||||
|
||||
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 == 302
|
||||
assert response.status_code == 404
|
||||
|
||||
@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 == 302
|
||||
assert response.status_code == 404
|
||||
|
||||
# post
|
||||
revoke_access_to_dashboard(dashboard_to_access, "Public") # noqa: F405
|
||||
|
||||
Reference in New Issue
Block a user