mirror of
https://github.com/apache/superset.git
synced 2026-07-29 10:02:32 +00:00
Compare commits
6 Commits
fix-save-a
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
139b21f593 | ||
|
|
dac69f9bcd | ||
|
|
d984fb08a4 | ||
|
|
8be430014e | ||
|
|
00c60bf2de | ||
|
|
1d4c3a2adb |
2
.github/workflows/codeql-analysis.yml
vendored
2
.github/workflows/codeql-analysis.yml
vendored
@@ -64,7 +64,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@e0647621c2984b5ed2f768cb892365bf2a616ad1 # v4.37.2
|
||||
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
|
||||
@@ -325,9 +325,8 @@ const config: ControlPanelConfig = {
|
||||
description: t(
|
||||
'Total angle covered by the chart, in degrees. ' +
|
||||
'360° draws a full circle and 180° draws a half donut. ' +
|
||||
'When the sweep is 180° or less and the start angle is a ' +
|
||||
'multiple of 90°, the chart is automatically re-centered ' +
|
||||
'to make use of the empty space.',
|
||||
'Partial arcs are automatically re-centered and scaled ' +
|
||||
'to make use of the available space.',
|
||||
),
|
||||
renderTrigger: true,
|
||||
default: DEFAULT_FORM_DATA.sweptAngle,
|
||||
|
||||
@@ -39,9 +39,6 @@ import {
|
||||
EchartsPieLabelType,
|
||||
PieChartDataItem,
|
||||
PieChartTransformedProps,
|
||||
TotalValuePaddingProps,
|
||||
PaddingResult,
|
||||
HalfDonut,
|
||||
} from './types';
|
||||
import { DEFAULT_LEGEND_FORM_DATA, OpacityEnum } from '../constants';
|
||||
import {
|
||||
@@ -76,120 +73,153 @@ export function parseParams({
|
||||
return [name, formattedValue, formattedPercent];
|
||||
}
|
||||
|
||||
const HALF_DONUT_SWEEP_LIMIT = 180;
|
||||
/**
|
||||
* Bounding box of the pie arc in unit coordinates: outer radius = 1,
|
||||
* mathematical y-up convention matching ECharts' angle convention
|
||||
* (0° points right, 90° points up, angles sweep clockwise from
|
||||
* `startAngle` to `startAngle - sweptAngle`).
|
||||
*/
|
||||
export interface ArcBoundingBox {
|
||||
minX: number;
|
||||
maxX: number;
|
||||
minY: number;
|
||||
maxY: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Geometric configuration for each type of semi-circular layout.
|
||||
* Computes the bounding box of an annular sector from its angles.
|
||||
*
|
||||
* - `centerOffset` — offset of the chart center from the baseline 50% on the X and Y axes.
|
||||
* Resulting position: `50% + offset`.
|
||||
* - `totalBase` — base position of the "Total" text as a percentage on the X and Y axes.
|
||||
* The box is spanned by the outer arc endpoints, the inner arc endpoints
|
||||
* (which collapse to the pie origin when `innerRatio` is 0), and every axis
|
||||
* extreme (0°/90°/180°/270°) the arc sweeps through.
|
||||
*
|
||||
* The values are empirically tuned so that the "Total" text visually remains
|
||||
* at the geometric center of the arc after the chart is re-centered. The
|
||||
* `left`/`right` totalBase values sit 5% inside the shifted chart center
|
||||
* (60% and 40% respectively) to compensate for the text being positioned by
|
||||
* its left edge rather than its midpoint.
|
||||
* @param startAngle - The start angle of the arc in degrees.
|
||||
* @param sweptAngle - The total angle covered by the arc in degrees.
|
||||
* @param innerRatio - Inner radius as a fraction of the outer radius (0–1).
|
||||
*/
|
||||
const HALF_DONUT_LAYOUT: Record<
|
||||
HalfDonut,
|
||||
{
|
||||
centerOffset: { x: number; y: number };
|
||||
totalBase: { left: number; top: number };
|
||||
}
|
||||
> = {
|
||||
top: { centerOffset: { x: 0, y: 20 }, totalBase: { left: 50, top: 68.5 } },
|
||||
bottom: { centerOffset: { x: 0, y: -20 }, totalBase: { left: 50, top: 30 } },
|
||||
left: { centerOffset: { x: 10, y: 0 }, totalBase: { left: 55, top: 50 } },
|
||||
right: { centerOffset: { x: -10, y: 0 }, totalBase: { left: 35, top: 50 } },
|
||||
none: { centerOffset: { x: 0, y: 0 }, totalBase: { left: 50, top: 50 } },
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines the type of semicircular layout based on the start angle and swept angle.
|
||||
*
|
||||
* All four semicircle orientations are supported:
|
||||
* - `'top'` — the arc is positioned at the top; the chart center shifts downwards.
|
||||
* - `'bottom'` — the arc is positioned at the bottom; the chart center shifts upwards.
|
||||
* - `'left'` — the arc is positioned at the left; the chart center shifts right.
|
||||
* - `'right'` — the arc is positioned at the right; the chart center shifts left.
|
||||
*
|
||||
* @param startAngle - The start angle of the arc in degrees (0–360).
|
||||
* @param sweptAngle - The swept angle of the arc in degrees (10–360).
|
||||
* @returns The type of semicircular layout.
|
||||
*/
|
||||
export const getHalfDonut = (
|
||||
export function getArcBoundingBox(
|
||||
startAngle: number,
|
||||
sweptAngle: number,
|
||||
): HalfDonut => {
|
||||
if (sweptAngle > HALF_DONUT_SWEEP_LIMIT) return 'none';
|
||||
innerRatio: number,
|
||||
): ArcBoundingBox {
|
||||
if (sweptAngle >= 360) {
|
||||
return { minX: -1, maxX: 1, minY: -1, maxY: 1 };
|
||||
}
|
||||
const toRad = (deg: number) => (deg * Math.PI) / 180;
|
||||
const endAngle = startAngle - sweptAngle;
|
||||
const points: [number, number][] = [];
|
||||
[startAngle, endAngle].forEach(angle => {
|
||||
const x = Math.cos(toRad(angle));
|
||||
const y = Math.sin(toRad(angle));
|
||||
points.push([x, y], [innerRatio * x, innerRatio * y]);
|
||||
});
|
||||
for (
|
||||
let axis = Math.ceil(endAngle / 90) * 90;
|
||||
axis <= startAngle;
|
||||
axis += 90
|
||||
) {
|
||||
points.push([Math.cos(toRad(axis)), Math.sin(toRad(axis))]);
|
||||
}
|
||||
const xs = points.map(([x]) => x);
|
||||
const ys = points.map(([, y]) => y);
|
||||
return {
|
||||
minX: Math.min(...xs),
|
||||
maxX: Math.max(...xs),
|
||||
minY: Math.min(...ys),
|
||||
maxY: Math.max(...ys),
|
||||
};
|
||||
}
|
||||
|
||||
const normalized = startAngle % 360;
|
||||
/**
|
||||
* Arcs covering only a sliver of the circle would otherwise scale up without
|
||||
* bound; cap the fit scale at the factor a quarter arc reaches naturally.
|
||||
*/
|
||||
const MAX_RADIUS_SCALE = 2;
|
||||
|
||||
if (normalized === 180) return 'top';
|
||||
if (normalized === 0) return 'bottom';
|
||||
if (normalized === 270) return 'left';
|
||||
if (normalized === 90) return 'right';
|
||||
export interface PieLayout {
|
||||
/** Pie origin in px, relative to the padded series rect. */
|
||||
center: [number, number];
|
||||
/** Inner/outer radius percent strings, scaled to fit the arc's box. */
|
||||
radius: [string, string];
|
||||
/** Pie origin in px, in container coordinates (for the graphic component). */
|
||||
totalAnchor: { x: number; y: number };
|
||||
}
|
||||
|
||||
return 'none';
|
||||
};
|
||||
|
||||
const getHalfDonutLayout = (startAngle: number, sweptAngle: number) =>
|
||||
HALF_DONUT_LAYOUT[getHalfDonut(startAngle, sweptAngle)];
|
||||
|
||||
export function getTotalValuePadding({
|
||||
chartPadding,
|
||||
donut,
|
||||
/**
|
||||
* Lays out the pie geometrically for any start/sweep angle combination:
|
||||
* scales the radius until the arc's bounding box fills the available rect
|
||||
* (so partial arcs reclaim the space a full circle would leave empty) and
|
||||
* shifts the pie origin so that box is centered. A full circle reproduces
|
||||
* ECharts' default layout exactly.
|
||||
*/
|
||||
export function getPieLayout({
|
||||
width,
|
||||
height,
|
||||
padding,
|
||||
startAngle,
|
||||
sweptAngle,
|
||||
}: TotalValuePaddingProps): PaddingResult {
|
||||
const safeHeight = height || 1;
|
||||
const safeWidth = width || 1;
|
||||
donut,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
}: {
|
||||
width: number;
|
||||
height: number;
|
||||
padding: { top: number; bottom: number; left: number; right: number };
|
||||
startAngle: number;
|
||||
sweptAngle: number;
|
||||
donut: boolean;
|
||||
innerRadius: number;
|
||||
outerRadius: number;
|
||||
}): PieLayout {
|
||||
const rectWidth = Math.max(width - padding.left - padding.right, 1);
|
||||
const rectHeight = Math.max(height - padding.top - padding.bottom, 1);
|
||||
const innerRatio = donut ? innerRadius / Math.max(outerRadius, 1) : 0;
|
||||
const box = getArcBoundingBox(startAngle, sweptAngle, innerRatio);
|
||||
const boxWidth = box.maxX - box.minX;
|
||||
const boxHeight = box.maxY - box.minY;
|
||||
|
||||
const halfType = getHalfDonut(startAngle, sweptAngle);
|
||||
const layout = HALF_DONUT_LAYOUT[halfType];
|
||||
const isHalf = halfType !== 'none';
|
||||
// ECharts resolves percentage radii against min(rect width, height) / 2.
|
||||
// Grow that basis until the arc's bounding box hits the rect on one axis.
|
||||
const fullBasis = Math.min(rectWidth, rectHeight) / 2;
|
||||
const fitBasis = Math.min(rectWidth / boxWidth, rectHeight / boxHeight);
|
||||
const scale = Math.min(fitBasis / fullBasis, MAX_RADIUS_SCALE);
|
||||
const outerPx = (outerRadius / 100) * fullBasis * scale;
|
||||
|
||||
const calculateTop = (): string => {
|
||||
if (chartPadding.bottom) {
|
||||
return donut
|
||||
? `${layout.totalBase.top - (chartPadding.bottom / safeHeight) * 50}%`
|
||||
: '0';
|
||||
}
|
||||
// Place the pie origin so the arc's box is centered in the rect. Unit y
|
||||
// points up while screen y points down, hence the sign flip.
|
||||
const round = (value: number) => Math.round(value * 100) / 100;
|
||||
const centerX = rectWidth / 2 - ((box.minX + box.maxX) / 2) * outerPx;
|
||||
const centerY = rectHeight / 2 + ((box.minY + box.maxY) / 2) * outerPx;
|
||||
|
||||
if (chartPadding.top || isHalf) {
|
||||
if (donut) {
|
||||
return `${layout.totalBase.top + (chartPadding.top / safeHeight) * 50}%`;
|
||||
}
|
||||
return `${(chartPadding.top / safeHeight) * 100}%`;
|
||||
}
|
||||
|
||||
return donut ? 'middle' : '0';
|
||||
};
|
||||
|
||||
const calculateLeft = (): string => {
|
||||
if (chartPadding.right) {
|
||||
const rightPercent = (chartPadding.right / safeWidth) * 100;
|
||||
return `${layout.totalBase.left - rightPercent * 0.75}%`;
|
||||
}
|
||||
|
||||
if (chartPadding.left) {
|
||||
const leftPercent = (chartPadding.left / safeWidth) * 100;
|
||||
return `${layout.totalBase.left + leftPercent * 0.25}%`;
|
||||
}
|
||||
|
||||
if (isHalf && (halfType === 'left' || halfType === 'right')) {
|
||||
return `${layout.totalBase.left}%`;
|
||||
}
|
||||
|
||||
return 'center';
|
||||
};
|
||||
// The pie origin is the natural spot for the "Total" text (the middle of
|
||||
// the hole, or the flat edge of a half donut), but for narrow arcs it can
|
||||
// fall far outside the drawn shape, even off-canvas. Clamp it into the
|
||||
// arc's bounding box, which always sits within the rect.
|
||||
const clamp = (value: number, min: number, max: number) =>
|
||||
Math.min(Math.max(value, min), max);
|
||||
const halfBoxWidth = (boxWidth / 2) * outerPx;
|
||||
const halfBoxHeight = (boxHeight / 2) * outerPx;
|
||||
const anchorX = clamp(
|
||||
centerX,
|
||||
rectWidth / 2 - halfBoxWidth,
|
||||
rectWidth / 2 + halfBoxWidth,
|
||||
);
|
||||
const anchorY = clamp(
|
||||
centerY,
|
||||
rectHeight / 2 - halfBoxHeight,
|
||||
rectHeight / 2 + halfBoxHeight,
|
||||
);
|
||||
|
||||
return {
|
||||
top: calculateTop(),
|
||||
left: calculateLeft(),
|
||||
center: [round(centerX), round(centerY)],
|
||||
radius: [
|
||||
`${round(donut ? innerRadius * scale : 0)}%`,
|
||||
`${round(outerRadius * scale)}%`,
|
||||
],
|
||||
totalAnchor: {
|
||||
x: round(padding.left + anchorX),
|
||||
y: round(padding.top + anchorY),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -478,7 +508,16 @@ export default function transformProps(
|
||||
effectiveLegendMargin,
|
||||
);
|
||||
|
||||
const { centerOffset } = getHalfDonutLayout(startAngle, sweptAngle);
|
||||
const pieLayout = getPieLayout({
|
||||
width,
|
||||
height,
|
||||
padding: chartPadding,
|
||||
startAngle,
|
||||
sweptAngle,
|
||||
donut,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
});
|
||||
|
||||
const series: PieSeriesOption[] = [
|
||||
{
|
||||
@@ -486,8 +525,8 @@ export default function transformProps(
|
||||
...chartPadding,
|
||||
animation: false,
|
||||
roseType: roseType || undefined,
|
||||
radius: [`${donut ? innerRadius : 0}%`, `${outerRadius}%`],
|
||||
center: [`${50 + centerOffset.x}%`, `${50 + centerOffset.y}%`],
|
||||
radius: pieLayout.radius,
|
||||
center: pieLayout.center,
|
||||
startAngle,
|
||||
endAngle: startAngle - sweptAngle,
|
||||
avoidLabelOverlap: true,
|
||||
@@ -550,16 +589,18 @@ export default function transformProps(
|
||||
graphic: showTotal
|
||||
? {
|
||||
type: 'text',
|
||||
...getTotalValuePadding({
|
||||
chartPadding,
|
||||
donut,
|
||||
width,
|
||||
height,
|
||||
startAngle,
|
||||
sweptAngle,
|
||||
}),
|
||||
// Donut: center the text on the pie origin (the middle of the
|
||||
// hole, or the flat edge of a partial arc). Pie: park it at the
|
||||
// top center of the padded rect so it doesn't overlap the slices.
|
||||
x: donut
|
||||
? pieLayout.totalAnchor.x
|
||||
: chartPadding.left +
|
||||
(width - chartPadding.left - chartPadding.right) / 2,
|
||||
y: donut ? pieLayout.totalAnchor.y : chartPadding.top,
|
||||
style: {
|
||||
text: t('Total: %s', numberFormatter(totalValue)),
|
||||
align: 'center',
|
||||
verticalAlign: donut ? 'middle' : 'top',
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
fill: theme.colorText,
|
||||
|
||||
@@ -103,36 +103,3 @@ export interface PieChartDataItem {
|
||||
};
|
||||
isOther?: boolean;
|
||||
}
|
||||
|
||||
interface ChartPadding {
|
||||
top: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
right: number;
|
||||
}
|
||||
|
||||
export interface TotalValuePaddingProps {
|
||||
chartPadding: ChartPadding;
|
||||
donut: boolean;
|
||||
width: number;
|
||||
height: number;
|
||||
sweptAngle: number;
|
||||
startAngle: number;
|
||||
}
|
||||
|
||||
export interface PaddingResult {
|
||||
top?: string;
|
||||
left?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Semicircular chart layout type.
|
||||
*
|
||||
* - `'top'` — arc at the top, center shifted downwards.
|
||||
* - `'bottom'` — arc at the bottom, center shifted upwards.
|
||||
* - `'left'` — arc on the left, center shifted to the right.
|
||||
* - `'right'` — arc on the right, center shifted to the left.
|
||||
* - `'none'` — full circle (no recentering).
|
||||
* @see getHalfDonut
|
||||
*/
|
||||
export type HalfDonut = 'top' | 'bottom' | 'left' | 'right' | 'none';
|
||||
|
||||
@@ -29,12 +29,20 @@ import type {
|
||||
} from 'echarts/types/src/util/types';
|
||||
import transformProps, {
|
||||
parseParams,
|
||||
getHalfDonut,
|
||||
getTotalValuePadding,
|
||||
getArcBoundingBox,
|
||||
getPieLayout,
|
||||
} from '../../src/Pie/transformProps';
|
||||
import { EchartsPieChartProps, PieChartDataItem } from '../../src/Pie/types';
|
||||
import { LegendOrientation, LegendType } from '../../src/types';
|
||||
|
||||
const getGraphic = (transformed: ReturnType<typeof transformProps>) =>
|
||||
transformed.echartOptions.graphic as {
|
||||
type: string;
|
||||
x: number;
|
||||
y: number;
|
||||
style: { text: string; align: string; verticalAlign: string };
|
||||
};
|
||||
|
||||
describe('Pie transformProps', () => {
|
||||
const formData: SqlaFormData = {
|
||||
colorScheme: 'bnbColors',
|
||||
@@ -352,109 +360,60 @@ describe('Total value positioning with legends', () => {
|
||||
|
||||
test('should center total text when legend is on the right', () => {
|
||||
const props = getChartPropsWithLegend(true, true, 'right', true);
|
||||
const transformed = transformProps(props);
|
||||
const graphic = getGraphic(transformProps(props));
|
||||
|
||||
expect(transformed.echartOptions.graphic).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
left: expect.stringMatching(/^\d+(\.\d+)?%$/),
|
||||
top: 'middle',
|
||||
style: expect.objectContaining({
|
||||
text: expect.stringContaining('Total:'),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// The left position should be less than 50% (shifted left)
|
||||
const leftValue = parseFloat(
|
||||
(transformed.echartOptions.graphic as any).left.replace('%', ''),
|
||||
);
|
||||
expect(leftValue).toBeLessThan(50);
|
||||
expect(leftValue).toBeGreaterThan(30); // Should be reasonable positioning
|
||||
expect(graphic.type).toBe('text');
|
||||
expect(graphic.style.text).toContain('Total:');
|
||||
expect(graphic.style.align).toBe('center');
|
||||
expect(graphic.style.verticalAlign).toBe('middle');
|
||||
// Anchored on the pie origin, which shifts left of the container center
|
||||
// because the right legend narrows the series rect.
|
||||
expect(graphic.x).toBeLessThan(400);
|
||||
expect(graphic.y).toBe(300);
|
||||
});
|
||||
|
||||
test('should center total text when legend is on the left', () => {
|
||||
const props = getChartPropsWithLegend(true, true, 'left', true);
|
||||
const transformed = transformProps(props);
|
||||
const graphic = getGraphic(transformProps(props));
|
||||
|
||||
expect(transformed.echartOptions.graphic).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
left: expect.stringMatching(/^\d+(\.\d+)?%$/),
|
||||
top: 'middle',
|
||||
}),
|
||||
);
|
||||
|
||||
// The left position should be greater than 50% (shifted right)
|
||||
const leftValue = parseFloat(
|
||||
(transformed.echartOptions.graphic as any).left.replace('%', ''),
|
||||
);
|
||||
expect(leftValue).toBeGreaterThan(50);
|
||||
expect(leftValue).toBeLessThan(70); // Should be reasonable positioning
|
||||
// The left legend pads the rect, pushing the pie origin right.
|
||||
expect(graphic.x).toBeGreaterThan(400);
|
||||
expect(graphic.y).toBe(300);
|
||||
});
|
||||
|
||||
test('should center total text when legend is on top', () => {
|
||||
const props = getChartPropsWithLegend(true, true, 'top', true);
|
||||
const transformed = transformProps(props);
|
||||
const graphic = getGraphic(transformProps(props));
|
||||
|
||||
expect(transformed.echartOptions.graphic).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
left: 'center',
|
||||
top: expect.stringMatching(/^\d+(\.\d+)?%$/),
|
||||
}),
|
||||
);
|
||||
|
||||
// The top position should be adjusted for top legend
|
||||
const topValue = parseFloat(
|
||||
(transformed.echartOptions.graphic as any).top.replace('%', ''),
|
||||
);
|
||||
expect(topValue).toBeGreaterThan(50); // Shifted down for top legend
|
||||
expect(graphic.x).toBe(400);
|
||||
expect(graphic.y).toBeGreaterThan(300);
|
||||
});
|
||||
|
||||
test('should center total text when legend is on bottom', () => {
|
||||
const props = getChartPropsWithLegend(true, true, 'bottom', true);
|
||||
const transformed = transformProps(props);
|
||||
const graphic = getGraphic(transformProps(props));
|
||||
|
||||
expect(transformed.echartOptions.graphic).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
left: 'center',
|
||||
top: expect.stringMatching(/^\d+(\.\d+)?%$/),
|
||||
}),
|
||||
);
|
||||
|
||||
// The top position should be adjusted for bottom legend
|
||||
const topValue = parseFloat(
|
||||
(transformed.echartOptions.graphic as any).top.replace('%', ''),
|
||||
);
|
||||
expect(topValue).toBeLessThan(50); // Shifted up for bottom legend
|
||||
expect(graphic.x).toBe(400);
|
||||
expect(graphic.y).toBeLessThan(300);
|
||||
});
|
||||
|
||||
test('should use default positioning when no legend is shown', () => {
|
||||
test('should center on the pie origin when no legend is shown', () => {
|
||||
const props = getChartPropsWithLegend(true, false, 'right', true);
|
||||
const transformed = transformProps(props);
|
||||
const graphic = getGraphic(transformProps(props));
|
||||
|
||||
expect(transformed.echartOptions.graphic).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
left: 'center',
|
||||
top: 'middle',
|
||||
}),
|
||||
);
|
||||
expect(graphic.x).toBe(400);
|
||||
expect(graphic.y).toBe(300);
|
||||
expect(graphic.style.verticalAlign).toBe('middle');
|
||||
});
|
||||
|
||||
test('should handle regular pie chart (non-donut) positioning', () => {
|
||||
test('should park total at the top of the rect for non-donut charts', () => {
|
||||
const props = getChartPropsWithLegend(true, true, 'right', false);
|
||||
const transformed = transformProps(props);
|
||||
const graphic = getGraphic(transformProps(props));
|
||||
|
||||
expect(transformed.echartOptions.graphic).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
top: '0', // Non-donut charts use '0' as default top position
|
||||
left: expect.stringMatching(/^\d+(\.\d+)?%$/), // Should still adjust left for right legend
|
||||
}),
|
||||
);
|
||||
expect(graphic.y).toBe(0);
|
||||
expect(graphic.style.verticalAlign).toBe('top');
|
||||
// Horizontally centered over the narrowed rect, not the container.
|
||||
expect(graphic.x).toBeLessThan(400);
|
||||
});
|
||||
|
||||
test('should not show total graphic when showTotal is false', () => {
|
||||
@@ -655,6 +614,7 @@ const getAngleChartProps = (
|
||||
startAngle,
|
||||
sweptAngle,
|
||||
show_total: true,
|
||||
show_legend: false,
|
||||
};
|
||||
|
||||
return new ChartProps({
|
||||
@@ -673,217 +633,110 @@ const getAngleChartProps = (
|
||||
}) as EchartsPieChartProps;
|
||||
};
|
||||
|
||||
test('sets center to 70% for half-donut', () => {
|
||||
const props = getAngleChartProps(true, 180);
|
||||
const transformed = transformProps(props);
|
||||
const series = transformed.echartOptions.series as PieSeriesOption[];
|
||||
expect(series[0].center).toEqual(['50%', '70%']);
|
||||
const getSeries = (props: EchartsPieChartProps) =>
|
||||
transformProps(props).echartOptions.series as PieSeriesOption[];
|
||||
|
||||
test('keeps ECharts default layout for a full donut', () => {
|
||||
const series = getSeries(getAngleChartProps(true, 360, 90));
|
||||
expect(series[0].center).toEqual([400, 300]);
|
||||
expect(series[0].radius).toEqual(['30%', '70%']);
|
||||
});
|
||||
|
||||
test('keeps center at 50% for full donut', () => {
|
||||
const props = getAngleChartProps(true, 360);
|
||||
const transformed = transformProps(props);
|
||||
const series = transformed.echartOptions.series as PieSeriesOption[];
|
||||
expect(series[0].center).toEqual(['50%', '50%']);
|
||||
test('recenters and scales up a top half-donut', () => {
|
||||
// Bounding box is 2 wide x 1 tall, so an 800x600 canvas fits a radius
|
||||
// basis of min(800/2, 600/1) = 400 instead of 300: scale 4/3.
|
||||
const series = getSeries(getAngleChartProps(true, 180, 180));
|
||||
expect(series[0].center).toEqual([400, 440]);
|
||||
expect(series[0].radius).toEqual(['40%', '93.33%']);
|
||||
});
|
||||
|
||||
test('calculates endAngle for a quarter donut', () => {
|
||||
const props = getAngleChartProps(true, 90);
|
||||
const transformed = transformProps(props);
|
||||
const series = transformed.echartOptions.series as PieSeriesOption[];
|
||||
expect(series[0].endAngle).toBe(90);
|
||||
test('recenters a bottom half-donut upwards', () => {
|
||||
const series = getSeries(getAngleChartProps(true, 180, 0));
|
||||
expect(series[0].center).toEqual([400, 160]);
|
||||
});
|
||||
|
||||
test('sets center to 30% for bottom half-donut (startAngle=0)', () => {
|
||||
const props = getAngleChartProps(true, 180, 0);
|
||||
const transformed = transformProps(props);
|
||||
const series = transformed.echartOptions.series as PieSeriesOption[];
|
||||
expect(series[0].center).toEqual(['50%', '30%']);
|
||||
test('recenters a right half-donut leftwards without scaling', () => {
|
||||
// A lateral half is 1 wide x 2 tall; height binds at the full-circle
|
||||
// basis, so the radius stays put and only the center shifts.
|
||||
const series = getSeries(getAngleChartProps(true, 180, 90));
|
||||
expect(series[0].center).toEqual([295, 300]);
|
||||
expect(series[0].radius).toEqual(['30%', '70%']);
|
||||
});
|
||||
|
||||
test('sets center to 30% for bottom half-donut (startAngle=360)', () => {
|
||||
const props = getAngleChartProps(true, 180, 360);
|
||||
const transformed = transformProps(props);
|
||||
const series = transformed.echartOptions.series as PieSeriesOption[];
|
||||
expect(series[0].center).toEqual(['50%', '30%']);
|
||||
test('recenters a left half-donut rightwards', () => {
|
||||
const series = getSeries(getAngleChartProps(true, 180, 270));
|
||||
expect(series[0].center).toEqual([505, 300]);
|
||||
});
|
||||
|
||||
test('shifts center left for right half-donut (startAngle=90)', () => {
|
||||
const props = getAngleChartProps(true, 180, 90);
|
||||
const transformed = transformProps(props);
|
||||
const series = transformed.echartOptions.series as PieSeriesOption[];
|
||||
expect(series[0].center).toEqual(['40%', '50%']);
|
||||
test('recenters non-cardinal start angles too', () => {
|
||||
const series = getSeries(getAngleChartProps(true, 180, 170));
|
||||
expect(series[0].center).not.toEqual([400, 300]);
|
||||
});
|
||||
|
||||
test('shifts center right for left half-donut (startAngle=270)', () => {
|
||||
const props = getAngleChartProps(true, 180, 270);
|
||||
const transformed = transformProps(props);
|
||||
const series = transformed.echartOptions.series as PieSeriesOption[];
|
||||
expect(series[0].center).toEqual(['60%', '50%']);
|
||||
test('scales a quarter donut to the fit cap', () => {
|
||||
const series = getSeries(getAngleChartProps(true, 90, 180));
|
||||
expect(series[0].center).toEqual([610, 510]);
|
||||
expect(series[0].radius).toEqual(['60%', '140%']);
|
||||
});
|
||||
|
||||
test('keeps center at 50% for non-cardinal start angle even when sweep ≤ 180', () => {
|
||||
const props = getAngleChartProps(true, 180, 45);
|
||||
const transformed = transformProps(props);
|
||||
const series = transformed.echartOptions.series as PieSeriesOption[];
|
||||
expect(series[0].center).toEqual(['50%', '50%']);
|
||||
});
|
||||
|
||||
test('allows endAngle to go negative for right half-donut', () => {
|
||||
const props = getAngleChartProps(true, 180, 90);
|
||||
const transformed = transformProps(props);
|
||||
const series = transformed.echartOptions.series as PieSeriesOption[];
|
||||
test('passes startAngle through and derives endAngle from the sweep', () => {
|
||||
const series = getSeries(getAngleChartProps(true, 180, 90));
|
||||
expect(series[0].startAngle).toBe(90);
|
||||
expect(series[0].endAngle).toBe(-90);
|
||||
});
|
||||
|
||||
test('anchors the total on the pie origin for a top half-donut', () => {
|
||||
const graphic = getGraphic(
|
||||
transformProps(getAngleChartProps(true, 180, 180)),
|
||||
);
|
||||
expect(graphic.x).toBe(400);
|
||||
expect(graphic.y).toBe(440);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[180, 180, 'top'],
|
||||
[180, 90, 'top'],
|
||||
[180, 45, 'top'],
|
||||
[0, 180, 'bottom'],
|
||||
[360, 180, 'bottom'],
|
||||
[360, 90, 'bottom'],
|
||||
[90, 180, 'right'],
|
||||
[90, 90, 'right'],
|
||||
[270, 180, 'left'],
|
||||
[270, 90, 'left'],
|
||||
[45, 180, 'none'],
|
||||
[170, 180, 'none'],
|
||||
[180, 360, 'none'],
|
||||
[180, 181, 'none'],
|
||||
[0, 360, 'none'],
|
||||
])('startAngle=%i, sweptAngle=%i → %s', (start, swept, expected) => {
|
||||
expect(getHalfDonut(start, swept)).toBe(expected);
|
||||
['full circle', 90, 360, 0.3, { minX: -1, maxX: 1, minY: -1, maxY: 1 }],
|
||||
['top half', 180, 180, 0, { minX: -1, maxX: 1, minY: 0, maxY: 1 }],
|
||||
['bottom half', 0, 180, 0, { minX: -1, maxX: 1, minY: -1, maxY: 0 }],
|
||||
['right half', 90, 180, 0, { minX: 0, maxX: 1, minY: -1, maxY: 1 }],
|
||||
['left half', 270, 180, 0, { minX: -1, maxX: 0, minY: -1, maxY: 1 }],
|
||||
['top-left quarter', 180, 90, 0.5, { minX: -1, maxX: 0, minY: 0, maxY: 1 }],
|
||||
])('getArcBoundingBox: %s', (_label, start, sweep, inner, expected) => {
|
||||
const box = getArcBoundingBox(start, sweep, inner);
|
||||
expect(box.minX).toBeCloseTo(expected.minX, 10);
|
||||
expect(box.maxX).toBeCloseTo(expected.maxX, 10);
|
||||
expect(box.minY).toBeCloseTo(expected.minY, 10);
|
||||
expect(box.maxY).toBeCloseTo(expected.maxY, 10);
|
||||
});
|
||||
|
||||
const baseProps = {
|
||||
donut: true,
|
||||
width: 800,
|
||||
height: 600,
|
||||
startAngle: 180,
|
||||
sweptAngle: 360,
|
||||
};
|
||||
|
||||
test('returns "middle" for donut without padding and not half', () => {
|
||||
const result = getTotalValuePadding({
|
||||
...baseProps,
|
||||
chartPadding: { top: 0, bottom: 0, left: 0, right: 0 },
|
||||
});
|
||||
expect(result.top).toBe('middle');
|
||||
test('getArcBoundingBox includes inner arc endpoints for narrow donuts', () => {
|
||||
// A 20-degree sliver straddling 12 o'clock: the lowest point of the
|
||||
// annular sector is an inner endpoint, not an outer one.
|
||||
const box = getArcBoundingBox(100, 20, 0.5);
|
||||
expect(box.minY).toBeCloseTo(0.5 * Math.sin((80 * Math.PI) / 180), 10);
|
||||
expect(box.maxY).toBeCloseTo(1, 10);
|
||||
});
|
||||
|
||||
test('returns "0" for non-donut without padding and not half', () => {
|
||||
const result = getTotalValuePadding({
|
||||
...baseProps,
|
||||
donut: false,
|
||||
chartPadding: { top: 0, bottom: 0, left: 0, right: 0 },
|
||||
});
|
||||
expect(result.top).toBe('0');
|
||||
});
|
||||
|
||||
test('adjusts top for donut with bottom padding', () => {
|
||||
const result = getTotalValuePadding({
|
||||
...baseProps,
|
||||
chartPadding: { top: 0, bottom: 60, left: 0, right: 0 },
|
||||
});
|
||||
expect(result.top).toBe('45%');
|
||||
});
|
||||
|
||||
test('returns "0" for non-donut with bottom padding', () => {
|
||||
const result = getTotalValuePadding({
|
||||
...baseProps,
|
||||
donut: false,
|
||||
chartPadding: { top: 0, bottom: 60, left: 0, right: 0 },
|
||||
});
|
||||
expect(result.top).toBe('0');
|
||||
});
|
||||
|
||||
test('adjusts top for donut with top padding', () => {
|
||||
const result = getTotalValuePadding({
|
||||
...baseProps,
|
||||
chartPadding: { top: 60, bottom: 0, left: 0, right: 0 },
|
||||
});
|
||||
expect(result.top).toBe('55%');
|
||||
});
|
||||
|
||||
test('adjusts top for non-donut with top padding', () => {
|
||||
const result = getTotalValuePadding({
|
||||
...baseProps,
|
||||
donut: false,
|
||||
chartPadding: { top: 60, bottom: 0, left: 0, right: 0 },
|
||||
});
|
||||
expect(result.top).toBe('10%');
|
||||
});
|
||||
|
||||
test('positions total at 68.5% for top half-donut without padding', () => {
|
||||
const result = getTotalValuePadding({
|
||||
...baseProps,
|
||||
sweptAngle: 180,
|
||||
chartPadding: { top: 0, bottom: 0, left: 0, right: 0 },
|
||||
});
|
||||
expect(result.top).toBe('68.5%');
|
||||
});
|
||||
|
||||
test('adjusts total position from 68.5% base for top half-donut with top padding', () => {
|
||||
const result = getTotalValuePadding({
|
||||
...baseProps,
|
||||
sweptAngle: 180,
|
||||
chartPadding: { top: 60, bottom: 0, left: 0, right: 0 },
|
||||
});
|
||||
expect(result.top).toBe('73.5%');
|
||||
});
|
||||
|
||||
test('returns "center" when no left/right padding', () => {
|
||||
const result = getTotalValuePadding({
|
||||
...baseProps,
|
||||
chartPadding: { top: 0, bottom: 0, left: 0, right: 0 },
|
||||
});
|
||||
expect(result.left).toBe('center');
|
||||
});
|
||||
|
||||
test('adjusts left for left padding', () => {
|
||||
const result = getTotalValuePadding({
|
||||
...baseProps,
|
||||
chartPadding: { top: 0, bottom: 0, left: 80, right: 0 },
|
||||
});
|
||||
expect(result.left).toBe('52.5%');
|
||||
});
|
||||
|
||||
test('adjusts left for right padding', () => {
|
||||
const result = getTotalValuePadding({
|
||||
...baseProps,
|
||||
chartPadding: { top: 0, bottom: 0, left: 0, right: 80 },
|
||||
});
|
||||
expect(result.left).toBe('42.5%');
|
||||
});
|
||||
|
||||
test('prioritizes right padding over left padding', () => {
|
||||
const result = getTotalValuePadding({
|
||||
...baseProps,
|
||||
chartPadding: { top: 0, bottom: 0, left: 80, right: 80 },
|
||||
});
|
||||
expect(result.left).toBe('42.5%');
|
||||
});
|
||||
|
||||
test('positions total inside the shifted center for left half-donut', () => {
|
||||
const result = getTotalValuePadding({
|
||||
...baseProps,
|
||||
startAngle: 270,
|
||||
sweptAngle: 180,
|
||||
chartPadding: { top: 0, bottom: 0, left: 0, right: 0 },
|
||||
});
|
||||
expect(result.left).toBe('55%');
|
||||
expect(result.top).toBe('50%');
|
||||
});
|
||||
|
||||
test('positions total inside the shifted center for right half-donut', () => {
|
||||
const result = getTotalValuePadding({
|
||||
...baseProps,
|
||||
test('getPieLayout centers the bounding box within legend padding', () => {
|
||||
const layout = getPieLayout({
|
||||
width: 800,
|
||||
height: 600,
|
||||
padding: { top: 0, bottom: 0, left: 0, right: 200 },
|
||||
startAngle: 90,
|
||||
sweptAngle: 180,
|
||||
chartPadding: { top: 0, bottom: 0, left: 0, right: 0 },
|
||||
sweptAngle: 360,
|
||||
donut: true,
|
||||
innerRadius: 30,
|
||||
outerRadius: 70,
|
||||
});
|
||||
expect(result.left).toBe('35%');
|
||||
expect(result.top).toBe('50%');
|
||||
// Rect is 600x600; the pie centers within it and the total anchor is
|
||||
// reported in container coordinates.
|
||||
expect(layout.center).toEqual([300, 300]);
|
||||
expect(layout.totalAnchor).toEqual({ x: 300, y: 300 });
|
||||
});
|
||||
|
||||
test('clamps the total anchor into the arc box for narrow arcs', () => {
|
||||
// A 20-degree sliver's pie origin falls far below the drawn wedge; the
|
||||
// anchor must stay within the arc's bounding box so the text is visible.
|
||||
const graphic = getGraphic(transformProps(getAngleChartProps(true, 20, 100)));
|
||||
expect(graphic.x).toBe(400);
|
||||
expect(graphic.y).toBeCloseTo(421.37, 1);
|
||||
});
|
||||
|
||||
@@ -47,6 +47,10 @@ export const URL_PARAMS = {
|
||||
name: 'show_filters',
|
||||
type: 'boolean',
|
||||
},
|
||||
showDownload: {
|
||||
name: 'show_download',
|
||||
type: 'boolean',
|
||||
},
|
||||
expandFilters: {
|
||||
name: 'expand_filters',
|
||||
type: 'boolean',
|
||||
|
||||
@@ -547,7 +547,7 @@ test('Clear All stages filter_select clear without dispatching until Apply', asy
|
||||
});
|
||||
expect(updateDataMaskSpy).toHaveBeenCalledWith(filterId, {
|
||||
id: filterId,
|
||||
filterState: { value: undefined, validateStatus: undefined },
|
||||
filterState: { value: null, validateStatus: undefined },
|
||||
extraFormData: {},
|
||||
});
|
||||
updateDataMaskSpy.mockRestore();
|
||||
@@ -685,12 +685,95 @@ test('Clear All + Apply only dispatches for filters present in dataMask', async
|
||||
expect(updateDataMaskSpy).toHaveBeenCalledTimes(1);
|
||||
expect(updateDataMaskSpy).toHaveBeenCalledWith(idInMask, {
|
||||
id: idInMask,
|
||||
filterState: { value: undefined, validateStatus: undefined },
|
||||
filterState: { value: null, validateStatus: undefined },
|
||||
extraFormData: {},
|
||||
});
|
||||
updateDataMaskSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('Clear All in horizontal bar does not re-apply default values', async () => {
|
||||
fetchMock.post(
|
||||
'glob:*/api/v1/chart/data',
|
||||
{
|
||||
result: [
|
||||
{
|
||||
data: [{ test_column: 'East' }, { test_column: 'West' }],
|
||||
colnames: ['test_column'],
|
||||
coltypes: [1],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ name: 'horizontal-clear-chart-data' },
|
||||
);
|
||||
const filterId = 'NATIVE_FILTER-horizontal-default';
|
||||
const updateDataMaskSpy = jest.spyOn(dataMaskActions, 'updateDataMask');
|
||||
const filterWithDefault = createFilter({
|
||||
id: filterId,
|
||||
name: 'Region',
|
||||
filterType: 'filter_select',
|
||||
targets: [{ datasetId: 7, column: { name: 'test_column' } }],
|
||||
defaultDataMask: {
|
||||
filterState: { value: ['East'] },
|
||||
extraFormData: {
|
||||
filters: [{ col: 'test_column', op: 'IN', val: ['East'] }],
|
||||
},
|
||||
},
|
||||
chartsInScope: [18],
|
||||
});
|
||||
const stateHorizontal = {
|
||||
...stateWithoutNativeFilters,
|
||||
dashboardInfo: {
|
||||
id: 1,
|
||||
dash_edit_perm: true,
|
||||
filterBarOrientation: FilterBarOrientation.Horizontal,
|
||||
metadata: {
|
||||
native_filter_configuration: [filterWithDefault],
|
||||
chart_configuration: {},
|
||||
},
|
||||
},
|
||||
dashboardState: {
|
||||
...stateWithoutNativeFilters.dashboardState,
|
||||
activeTabs: ['ROOT_ID'],
|
||||
},
|
||||
dataMask: {
|
||||
[filterId]: createDataMask(filterId, ['East'], {
|
||||
filters: [{ col: 'test_column', op: 'IN', val: ['East'] }],
|
||||
}),
|
||||
},
|
||||
nativeFilters: {
|
||||
filters: { [filterId]: filterWithDefault },
|
||||
filtersState: {},
|
||||
},
|
||||
};
|
||||
|
||||
render(<FilterBar orientation={FilterBarOrientation.Horizontal} />, {
|
||||
initialState: stateHorizontal,
|
||||
useDnd: true,
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
});
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
const clearBtn = screen.getByTestId(getTestId('clear-button'));
|
||||
expect(clearBtn).not.toBeDisabled();
|
||||
await act(async () => {
|
||||
userEvent.click(clearBtn);
|
||||
});
|
||||
// Let the clear-all trigger round-trip through the filter plugin
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
// The staged clear must survive the trigger completing: the default value
|
||||
// must not be re-applied and Apply must stay enabled
|
||||
expect(updateDataMaskSpy).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTitle('East')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId(getTestId('apply-button'))).not.toBeDisabled();
|
||||
updateDataMaskSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('FilterBar Clear All only clears in-scope filters, not out-of-scope ones', async () => {
|
||||
const inScopeFilterId = 'NATIVE_FILTER-in-scope';
|
||||
const outOfScopeRequiredFilterId = 'NATIVE_FILTER-out-of-scope-required';
|
||||
@@ -831,7 +914,7 @@ test('FilterBar Clear All only clears in-scope filters, not out-of-scope ones',
|
||||
|
||||
expect(updateDataMaskSpy).toHaveBeenCalledWith(inScopeFilterId, {
|
||||
id: inScopeFilterId,
|
||||
filterState: { value: undefined, validateStatus: undefined },
|
||||
filterState: { value: null, validateStatus: undefined },
|
||||
extraFormData: {},
|
||||
});
|
||||
|
||||
|
||||
@@ -517,22 +517,21 @@ const FilterBar: FC<FiltersBarProps> = ({
|
||||
// Only clear in-scope filters
|
||||
if (!inScopeFilterIds.has(id)) return;
|
||||
|
||||
// Range filters use [null, null] as the cleared value; others use undefined
|
||||
const clearedValue =
|
||||
filterType === 'filter_range' ? [null, null] : undefined;
|
||||
// Cleared values stage as explicit null ([null, null] for ranges), never
|
||||
// undefined: the select plugin's init effect treats undefined as
|
||||
// "uninitialized" and would re-apply default values once the clear-all
|
||||
// trigger completes.
|
||||
const clearedValue = filterType === 'filter_range' ? [null, null] : null;
|
||||
const isRequired = !!filter.controlValues?.enableEmptyFilter;
|
||||
if (dataMaskSelected[id]) {
|
||||
// Stage the cleared value locally; do NOT dispatch to Redux here.
|
||||
// Persistence happens when the user clicks Apply.
|
||||
setDataMaskSelected(draft => {
|
||||
if (draft[id].filterState?.value !== undefined) {
|
||||
draft[id].filterState!.value = clearedValue;
|
||||
}
|
||||
draft[id].extraFormData = {};
|
||||
if (draft[id].filterState) {
|
||||
draft[id].filterState!.validateStatus = isRequired
|
||||
? 'error'
|
||||
: undefined;
|
||||
const { filterState } = draft[id];
|
||||
if (filterState) {
|
||||
filterState.value = clearedValue;
|
||||
filterState.validateStatus = isRequired ? 'error' : undefined;
|
||||
}
|
||||
});
|
||||
newClearAllTriggers[id] = true;
|
||||
|
||||
@@ -31,6 +31,15 @@ import {
|
||||
import ChartContainerComponent from 'src/explore/components/ExploreChartPanel';
|
||||
import { setItem, LocalStorageKeys } from 'src/utils/localStorageHelpers';
|
||||
|
||||
jest.mock('./StandaloneDownloadControl', () => ({
|
||||
__esModule: true,
|
||||
default: () => (
|
||||
<button type="button" data-test="standalone-download-button">
|
||||
Download
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
// Cast to accept partial mock props in tests
|
||||
const ChartContainer = ChartContainerComponent as unknown as React.FC<
|
||||
Record<string, any>
|
||||
@@ -75,6 +84,10 @@ const createProps = (overrides = {}) => ({
|
||||
describe('ChartContainer', () => {
|
||||
jest.setTimeout(10000);
|
||||
|
||||
afterEach(() => {
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
});
|
||||
|
||||
test('renders when vizType is line', () => {
|
||||
const props = createProps();
|
||||
expect(isValidElement(<ChartContainer {...props} />)).toBe(true);
|
||||
@@ -188,4 +201,77 @@ describe('ChartContainer', () => {
|
||||
expect(await screen.findByRole('timer')).toBeInTheDocument();
|
||||
expect(gutter).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('does not render standalone download control when show_download is absent', () => {
|
||||
const props = createProps({
|
||||
standalone: true,
|
||||
can_download: true,
|
||||
});
|
||||
|
||||
render(<ChartContainer {...props} />, { useRedux: true });
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('standalone-download-button'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not render standalone download control when show_download is disabled', () => {
|
||||
window.history.replaceState({}, '', '?show_download=0');
|
||||
|
||||
const props = createProps({
|
||||
standalone: true,
|
||||
can_download: true,
|
||||
});
|
||||
|
||||
render(<ChartContainer {...props} />, { useRedux: true });
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('standalone-download-button'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders standalone download control when show_download is enabled and user can download', () => {
|
||||
window.history.replaceState({}, '', '?show_download=1');
|
||||
|
||||
const props = createProps({
|
||||
standalone: true,
|
||||
can_download: true,
|
||||
});
|
||||
|
||||
render(<ChartContainer {...props} />, { useRedux: true });
|
||||
|
||||
expect(
|
||||
screen.getByTestId('standalone-download-button'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not render standalone download control when user cannot download', () => {
|
||||
window.history.replaceState({}, '', '?show_download=1');
|
||||
|
||||
const props = createProps({
|
||||
standalone: true,
|
||||
can_download: false,
|
||||
});
|
||||
|
||||
render(<ChartContainer {...props} />, { useRedux: true });
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('standalone-download-button'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not render standalone download control outside standalone mode', () => {
|
||||
window.history.replaceState({}, '', '?show_download=1');
|
||||
|
||||
const props = createProps({
|
||||
standalone: false,
|
||||
can_download: true,
|
||||
});
|
||||
|
||||
render(<ChartContainer {...props} />, { useRedux: true });
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('standalone-download-button'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { render, screen, userEvent } from 'spec/helpers/testing-library';
|
||||
import { VizType } from '@superset-ui/core';
|
||||
import { ExportStatus } from 'src/components/StreamingExportModal/StreamingExportModal';
|
||||
import { useExploreDataExport } from '../useExploreAdditionalActionsMenu/useExploreDataExport';
|
||||
import StandaloneDownloadControl from './StandaloneDownloadControl';
|
||||
|
||||
jest.mock('../useExploreAdditionalActionsMenu/useExploreDataExport', () => ({
|
||||
useExploreDataExport: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUseExploreDataExport = useExploreDataExport as jest.MockedFunction<
|
||||
typeof useExploreDataExport
|
||||
>;
|
||||
|
||||
const exportCSV = jest.fn();
|
||||
const exportJson = jest.fn();
|
||||
const exportExcel = jest.fn();
|
||||
|
||||
const streamingExportState = {
|
||||
isVisible: false,
|
||||
progress: {
|
||||
rowsProcessed: 0,
|
||||
totalRows: undefined,
|
||||
totalSize: 0,
|
||||
speed: 0,
|
||||
mbPerSecond: 0,
|
||||
elapsedTime: 0,
|
||||
status: ExportStatus.STREAMING,
|
||||
},
|
||||
onCancel: jest.fn(),
|
||||
onRetry: jest.fn(),
|
||||
onDownload: jest.fn(),
|
||||
};
|
||||
|
||||
const latestQueryFormData = {
|
||||
viz_type: VizType.Histogram,
|
||||
datasource: '49__table',
|
||||
};
|
||||
|
||||
describe('StandaloneDownloadControl', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockUseExploreDataExport.mockReturnValue({
|
||||
exportCSV,
|
||||
exportCSVPivoted: jest.fn(),
|
||||
exportJson,
|
||||
exportExcel,
|
||||
handleExportError: jest.fn(),
|
||||
streamingExportState,
|
||||
});
|
||||
});
|
||||
|
||||
test('renders the download button', () => {
|
||||
render(
|
||||
<StandaloneDownloadControl
|
||||
latestQueryFormData={latestQueryFormData}
|
||||
canDownload
|
||||
/>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('standalone-download-button'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders export options when download button is clicked', async () => {
|
||||
render(
|
||||
<StandaloneDownloadControl
|
||||
latestQueryFormData={latestQueryFormData}
|
||||
canDownload
|
||||
/>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
userEvent.click(screen.getByTestId('standalone-download-button'));
|
||||
|
||||
expect(await screen.findByText('Export to .CSV')).toBeInTheDocument();
|
||||
expect(screen.getByText('Export to .JSON')).toBeInTheDocument();
|
||||
expect(screen.getByText('Export to Excel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('exports CSV when CSV option is clicked', async () => {
|
||||
render(
|
||||
<StandaloneDownloadControl
|
||||
latestQueryFormData={latestQueryFormData}
|
||||
canDownload
|
||||
/>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
userEvent.click(screen.getByTestId('standalone-download-button'));
|
||||
userEvent.click(await screen.findByText('Export to .CSV'));
|
||||
|
||||
expect(exportCSV).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('exports JSON when JSON option is clicked', async () => {
|
||||
render(
|
||||
<StandaloneDownloadControl
|
||||
latestQueryFormData={latestQueryFormData}
|
||||
canDownload
|
||||
/>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
userEvent.click(screen.getByTestId('standalone-download-button'));
|
||||
userEvent.click(await screen.findByText('Export to .JSON'));
|
||||
|
||||
expect(exportJson).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('exports Excel when Excel option is clicked', async () => {
|
||||
render(
|
||||
<StandaloneDownloadControl
|
||||
latestQueryFormData={latestQueryFormData}
|
||||
canDownload
|
||||
/>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
userEvent.click(screen.getByTestId('standalone-download-button'));
|
||||
userEvent.click(await screen.findByText('Export to Excel'));
|
||||
|
||||
expect(exportExcel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 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 { useMemo } from 'react';
|
||||
import { JsonObject, LatestQueryFormData } from '@superset-ui/core';
|
||||
import { Button, Dropdown } from '@superset-ui/core/components';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { css, useTheme } from '@apache-superset/core/theme';
|
||||
import { StreamingExportModal } from 'src/components/StreamingExportModal';
|
||||
import { Slice } from 'src/types/Chart';
|
||||
import { useExploreDataExport } from '../useExploreAdditionalActionsMenu/useExploreDataExport';
|
||||
|
||||
interface StandaloneDownloadControlProps {
|
||||
latestQueryFormData: LatestQueryFormData;
|
||||
canDownload: boolean;
|
||||
slice?: Slice;
|
||||
ownState?: JsonObject;
|
||||
}
|
||||
|
||||
const StandaloneDownloadControl = ({
|
||||
latestQueryFormData,
|
||||
canDownload,
|
||||
slice,
|
||||
ownState,
|
||||
}: StandaloneDownloadControlProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const { exportCSV, exportJson, exportExcel, streamingExportState } =
|
||||
useExploreDataExport({
|
||||
latestQueryFormData,
|
||||
canDownloadCSV: canDownload,
|
||||
slice,
|
||||
ownState,
|
||||
});
|
||||
|
||||
const downloadMenuItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'export_csv',
|
||||
label: t('Export to .CSV'),
|
||||
onClick: exportCSV,
|
||||
},
|
||||
{
|
||||
key: 'export_json',
|
||||
label: t('Export to .JSON'),
|
||||
onClick: exportJson,
|
||||
},
|
||||
{
|
||||
key: 'export_excel',
|
||||
label: t('Export to Excel'),
|
||||
onClick: exportExcel,
|
||||
},
|
||||
],
|
||||
[exportCSV, exportExcel, exportJson],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
menu={{
|
||||
selectable: false,
|
||||
items: downloadMenuItems,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
aria-label={t('Download')}
|
||||
data-test="standalone-download-button"
|
||||
css={css`
|
||||
position: absolute;
|
||||
top: ${theme.sizeUnit * 2}px;
|
||||
right: ${theme.sizeUnit * 2}px;
|
||||
z-index: 1;
|
||||
`}
|
||||
>
|
||||
<Icons.DownloadOutlined />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
|
||||
<StreamingExportModal
|
||||
visible={streamingExportState.isVisible}
|
||||
onCancel={streamingExportState.onCancel}
|
||||
onRetry={streamingExportState.onRetry}
|
||||
onDownload={streamingExportState.onDownload}
|
||||
progress={streamingExportState.progress}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default StandaloneDownloadControl;
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
getExtensionsRegistry,
|
||||
} from '@superset-ui/core';
|
||||
import { Alert } from '@apache-superset/core/components';
|
||||
import { URL_PARAMS } from 'src/constants';
|
||||
import { getUrlParam } from 'src/utils/urlUtils';
|
||||
import { css, styled, useTheme } from '@apache-superset/core/theme';
|
||||
import ChartContainer from 'src/components/Chart/ChartContainer';
|
||||
import { updateExploreChartState } from 'src/explore/actions/exploreActions';
|
||||
@@ -56,6 +58,7 @@ import { DataTablesPane } from '../DataTablesPane';
|
||||
import { ChartPills } from '../ChartPills';
|
||||
import { ExploreAlert } from '../ExploreAlert';
|
||||
import useResizeDetectorByObserver from './useResizeDetectorByObserver';
|
||||
import StandaloneDownloadControl from './StandaloneDownloadControl';
|
||||
|
||||
const extensionsRegistry = getExtensionsRegistry();
|
||||
const DefaultHeader: React.FC<{ children?: React.ReactNode }> = ({
|
||||
@@ -177,6 +180,7 @@ const ExploreChartPanel = ({
|
||||
}: ExploreChartPanelProps) => {
|
||||
const theme = useTheme();
|
||||
const dispatch = useDispatch();
|
||||
const showDownload = getUrlParam(URL_PARAMS.showDownload);
|
||||
const gutterMargin = theme.sizeUnit * GUTTER_SIZE_FACTOR;
|
||||
const gutterHeight = theme.sizeUnit * GUTTER_SIZE_FACTOR;
|
||||
|
||||
@@ -552,7 +556,23 @@ const ExploreChartPanel = ({
|
||||
document.body.className += ` ${standaloneClass}`;
|
||||
}
|
||||
return (
|
||||
<div id="app" data-test="standalone-app">
|
||||
<div
|
||||
id="app"
|
||||
data-test="standalone-app"
|
||||
css={css`
|
||||
position: relative;
|
||||
height: 100%;
|
||||
`}
|
||||
>
|
||||
{showDownload && canDownload && (
|
||||
<StandaloneDownloadControl
|
||||
latestQueryFormData={chart.latestQueryFormData}
|
||||
canDownload={canDownload}
|
||||
slice={slice}
|
||||
ownState={mergedOwnState}
|
||||
/>
|
||||
)}
|
||||
|
||||
{standaloneChartBody}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -46,7 +46,6 @@ import {
|
||||
} from '@superset-ui/core/components';
|
||||
import { Menu, MenuProps } from '@superset-ui/core/components/Menu';
|
||||
import { useToasts } from 'src/components/MessageToasts/withToasts';
|
||||
import { DEFAULT_CSV_STREAMING_ROW_THRESHOLD } from 'src/constants';
|
||||
import { exportChart, getChartKey } from 'src/explore/exploreUtils';
|
||||
import downloadAsImage from 'src/utils/downloadAsImage';
|
||||
import downloadAsPdf from 'src/utils/downloadAsPdf';
|
||||
@@ -65,16 +64,14 @@ import {
|
||||
LOG_ACTIONS_CHART_DOWNLOAD_AS_XLS,
|
||||
} from 'src/logger/LogUtils';
|
||||
import exportPivotExcel from 'src/utils/downloadAsPivotExcel';
|
||||
import {
|
||||
useStreamingExport,
|
||||
StreamingProgress,
|
||||
} from 'src/components/StreamingExportModal';
|
||||
import { StreamingProgress } from 'src/components/StreamingExportModal';
|
||||
import { Slice } from 'src/types/Chart';
|
||||
import { ChartState, ExplorePageInitialData } from 'src/explore/types';
|
||||
import { ReportObject } from 'src/features/reports/types';
|
||||
import ViewQueryModal from '../controls/ViewQueryModal';
|
||||
import EmbedCodeContent from '../EmbedCodeContent';
|
||||
import { useDashboardsMenuItems } from './DashboardsSubMenu';
|
||||
import { useExploreDataExport } from './useExploreDataExport';
|
||||
|
||||
export const SEARCH_THRESHOLD = 10;
|
||||
|
||||
@@ -317,11 +314,6 @@ export const useExploreAdditionalActionsMenu = (
|
||||
const chart = useSelector<ExploreState, ChartState | undefined>(state =>
|
||||
state.explore ? state.charts?.[getChartKey(state.explore)] : undefined,
|
||||
);
|
||||
const streamingThreshold = useSelector<ExploreState, number>(
|
||||
state =>
|
||||
state.common?.conf?.CSV_STREAMING_ROW_THRESHOLD ||
|
||||
DEFAULT_CSV_STREAMING_ROW_THRESHOLD,
|
||||
);
|
||||
const exploreChartState = useSelector<ExploreState, JsonObject | undefined>(
|
||||
state => {
|
||||
const chartKey = state.explore ? getChartKey(state.explore) : undefined;
|
||||
@@ -362,32 +354,20 @@ export const useExploreAdditionalActionsMenu = (
|
||||
);
|
||||
|
||||
// Streaming export state and handlers
|
||||
const [isStreamingModalVisible, setIsStreamingModalVisible] = useState(false);
|
||||
const {
|
||||
progress,
|
||||
isExporting: _isExporting,
|
||||
startExport,
|
||||
cancelExport: _cancelExport,
|
||||
resetExport,
|
||||
retryExport,
|
||||
} = useStreamingExport({
|
||||
onComplete: () => {
|
||||
// Don't show toast here - wait for user to click Download button
|
||||
},
|
||||
onError: () => {
|
||||
addDangerToast(t('Export failed - please try again'));
|
||||
},
|
||||
exportCSV,
|
||||
exportCSVPivoted,
|
||||
exportJson,
|
||||
exportExcel,
|
||||
handleExportError,
|
||||
streamingExportState,
|
||||
} = useExploreDataExport({
|
||||
latestQueryFormData,
|
||||
canDownloadCSV,
|
||||
slice,
|
||||
ownState,
|
||||
});
|
||||
|
||||
const handleCloseStreamingModal = useCallback(() => {
|
||||
setIsStreamingModalVisible(false);
|
||||
resetExport();
|
||||
}, [resetExport]);
|
||||
|
||||
const handleDownloadComplete = useCallback(() => {
|
||||
addSuccessToast(t('CSV file downloaded successfully'));
|
||||
}, [addSuccessToast]);
|
||||
|
||||
// Use the updated report menu items hook
|
||||
const reportMenuItem = useHeaderReportMenuItems({
|
||||
chart,
|
||||
@@ -439,159 +419,6 @@ export const useExploreAdditionalActionsMenu = (
|
||||
}
|
||||
}, [addDangerToast, latestQueryFormData, permalinkChartState]);
|
||||
|
||||
const handleExportError = useCallback(
|
||||
(error: unknown) => {
|
||||
const exportError = error as Error & {
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
response?: { status?: number };
|
||||
};
|
||||
const status = exportError.status || exportError.response?.status;
|
||||
if (status === 413) {
|
||||
addDangerToast(
|
||||
t(
|
||||
'The chart data is too large to download. Please try reducing the date range, limiting rows, or using fewer columns.',
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const errorMessage =
|
||||
exportError.message ||
|
||||
exportError.statusText ||
|
||||
t(
|
||||
'Failed to export chart data. Please try again or contact your administrator.',
|
||||
);
|
||||
addDangerToast(errorMessage);
|
||||
}
|
||||
},
|
||||
[addDangerToast],
|
||||
);
|
||||
|
||||
const exportCSV = useCallback(async () => {
|
||||
if (!canDownloadCSV) return null;
|
||||
|
||||
// Determine row count for streaming threshold check
|
||||
let actualRowCount;
|
||||
const isTableViz = latestQueryFormData?.viz_type === 'table';
|
||||
const queriesResponse = chart?.queriesResponse;
|
||||
|
||||
if (
|
||||
isTableViz &&
|
||||
queriesResponse &&
|
||||
queriesResponse.length > 1 &&
|
||||
queriesResponse[1]?.data?.[0]?.rowcount
|
||||
) {
|
||||
actualRowCount = queriesResponse[1].data[0].rowcount;
|
||||
} else if (queriesResponse && queriesResponse[0]?.sql_rowcount != null) {
|
||||
actualRowCount = queriesResponse[0].sql_rowcount;
|
||||
} else if (queriesResponse && queriesResponse[0]?.rowcount != null) {
|
||||
actualRowCount = queriesResponse[0].rowcount;
|
||||
} else {
|
||||
actualRowCount = latestQueryFormData?.row_limit;
|
||||
}
|
||||
|
||||
// Check if streaming should be used
|
||||
const shouldUseStreaming =
|
||||
actualRowCount && actualRowCount >= streamingThreshold;
|
||||
|
||||
let filename: string | undefined;
|
||||
if (shouldUseStreaming) {
|
||||
const now = new Date();
|
||||
const date = now.toISOString().slice(0, 10);
|
||||
const time = now.toISOString().slice(11, 19).replace(/:/g, '');
|
||||
const timestamp = `_${date}_${time}`;
|
||||
const chartName =
|
||||
slice?.slice_name || latestQueryFormData.viz_type || 'chart';
|
||||
const safeChartName = chartName.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
filename = `${safeChartName}${timestamp}.csv`;
|
||||
}
|
||||
|
||||
try {
|
||||
await exportChart({
|
||||
formData: latestQueryFormData as QueryFormData,
|
||||
ownState,
|
||||
resultType: 'full',
|
||||
resultFormat: 'csv',
|
||||
onStartStreamingExport: shouldUseStreaming
|
||||
? exportParams => {
|
||||
if (exportParams.url) {
|
||||
setIsStreamingModalVisible(true);
|
||||
startExport({
|
||||
...exportParams,
|
||||
url: exportParams.url,
|
||||
filename,
|
||||
expectedRows: actualRowCount,
|
||||
exportType: exportParams.exportType as 'csv' | 'xlsx',
|
||||
});
|
||||
}
|
||||
}
|
||||
: null,
|
||||
});
|
||||
} catch (error) {
|
||||
handleExportError(error);
|
||||
}
|
||||
return null;
|
||||
}, [
|
||||
canDownloadCSV,
|
||||
latestQueryFormData,
|
||||
ownState,
|
||||
chart,
|
||||
streamingThreshold,
|
||||
slice,
|
||||
startExport,
|
||||
handleExportError,
|
||||
]);
|
||||
|
||||
const exportCSVPivoted = useCallback(async () => {
|
||||
if (!canDownloadCSV) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await exportChart({
|
||||
formData: latestQueryFormData as QueryFormData,
|
||||
ownState,
|
||||
resultType: 'post_processed',
|
||||
resultFormat: 'csv',
|
||||
});
|
||||
} catch (error) {
|
||||
handleExportError(error);
|
||||
}
|
||||
return null;
|
||||
}, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
|
||||
|
||||
const exportJson = useCallback(async () => {
|
||||
if (!canDownloadCSV) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await exportChart({
|
||||
formData: latestQueryFormData as QueryFormData,
|
||||
ownState,
|
||||
resultType: 'results',
|
||||
resultFormat: 'json',
|
||||
});
|
||||
} catch (error) {
|
||||
handleExportError(error);
|
||||
}
|
||||
return null;
|
||||
}, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
|
||||
|
||||
const exportExcel = useCallback(async () => {
|
||||
if (!canDownloadCSV) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await exportChart({
|
||||
formData: latestQueryFormData as QueryFormData,
|
||||
ownState,
|
||||
resultType: 'results',
|
||||
resultFormat: 'xlsx',
|
||||
});
|
||||
} catch (error) {
|
||||
handleExportError(error);
|
||||
}
|
||||
return null;
|
||||
}, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
|
||||
|
||||
const copyLink = useCallback(async () => {
|
||||
try {
|
||||
if (!latestQueryFormData?.datasource) {
|
||||
@@ -1246,14 +1073,5 @@ export const useExploreAdditionalActionsMenu = (
|
||||
canExportImage,
|
||||
]);
|
||||
|
||||
// Return streaming modal state and handlers for parent to render
|
||||
const streamingExportState = {
|
||||
isVisible: isStreamingModalVisible,
|
||||
progress,
|
||||
onCancel: handleCloseStreamingModal,
|
||||
onRetry: retryExport,
|
||||
onDownload: handleDownloadComplete,
|
||||
};
|
||||
|
||||
return [menu, isDropdownVisible, setIsDropdownVisible, streamingExportState];
|
||||
};
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import * as exploreUtils from 'src/explore/exploreUtils';
|
||||
import { useExploreDataExport } from './useExploreDataExport';
|
||||
|
||||
const mockAddDangerToast = jest.fn();
|
||||
const mockAddSuccessToast = jest.fn();
|
||||
const mockStartExport = jest.fn();
|
||||
const mockResetExport = jest.fn();
|
||||
const mockRetryExport = jest.fn();
|
||||
|
||||
jest.mock('src/components/MessageToasts/withToasts', () => ({
|
||||
useToasts: () => ({
|
||||
addDangerToast: mockAddDangerToast,
|
||||
addSuccessToast: mockAddSuccessToast,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('src/components/StreamingExportModal', () => ({
|
||||
useStreamingExport: jest.fn(() => ({
|
||||
progress: {
|
||||
rowsProcessed: 0,
|
||||
totalRows: undefined,
|
||||
totalSize: 0,
|
||||
speed: 0,
|
||||
mbPerSecond: 0,
|
||||
elapsedTime: 0,
|
||||
status: 'streaming',
|
||||
},
|
||||
startExport: mockStartExport,
|
||||
resetExport: mockResetExport,
|
||||
retryExport: mockRetryExport,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('src/explore/exploreUtils', () => ({
|
||||
__esModule: true,
|
||||
...jest.requireActual('src/explore/exploreUtils'),
|
||||
exportChart: jest.fn(),
|
||||
getChartKey: jest.fn(() => 1),
|
||||
}));
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useSelector: jest.fn(callback =>
|
||||
callback({
|
||||
charts: {
|
||||
1: {
|
||||
queriesResponse: [{ sql_rowcount: 10 }],
|
||||
},
|
||||
},
|
||||
explore: {
|
||||
slice: {
|
||||
slice_id: 1,
|
||||
slice_name: 'Test Chart',
|
||||
},
|
||||
},
|
||||
common: {
|
||||
conf: {
|
||||
CSV_STREAMING_ROW_THRESHOLD: 1000,
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
const mockExportChart = exploreUtils.exportChart as jest.Mock;
|
||||
|
||||
const defaultProps = {
|
||||
latestQueryFormData: {
|
||||
datasource: '1__table',
|
||||
viz_type: 'table',
|
||||
row_limit: 10000,
|
||||
} as any,
|
||||
canDownloadCSV: true,
|
||||
slice: {
|
||||
slice_id: 1,
|
||||
slice_name: 'Test Chart',
|
||||
} as any,
|
||||
ownState: {},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockExportChart.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
test('exports CSV with full result type', async () => {
|
||||
const { result } = renderHook(() => useExploreDataExport(defaultProps));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.exportCSV();
|
||||
});
|
||||
|
||||
expect(mockExportChart).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
formData: defaultProps.latestQueryFormData,
|
||||
ownState: defaultProps.ownState,
|
||||
resultType: 'full',
|
||||
resultFormat: 'csv',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('exports pivoted CSV with post processed result type', async () => {
|
||||
const { result } = renderHook(() => useExploreDataExport(defaultProps));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.exportCSVPivoted();
|
||||
});
|
||||
|
||||
expect(mockExportChart).toHaveBeenCalledWith({
|
||||
formData: defaultProps.latestQueryFormData,
|
||||
ownState: defaultProps.ownState,
|
||||
resultType: 'post_processed',
|
||||
resultFormat: 'csv',
|
||||
});
|
||||
});
|
||||
|
||||
test('exports JSON with results result type', async () => {
|
||||
const { result } = renderHook(() => useExploreDataExport(defaultProps));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.exportJson();
|
||||
});
|
||||
|
||||
expect(mockExportChart).toHaveBeenCalledWith({
|
||||
formData: defaultProps.latestQueryFormData,
|
||||
ownState: defaultProps.ownState,
|
||||
resultType: 'results',
|
||||
resultFormat: 'json',
|
||||
});
|
||||
});
|
||||
|
||||
test('exports Excel with results result type', async () => {
|
||||
const { result } = renderHook(() => useExploreDataExport(defaultProps));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.exportExcel();
|
||||
});
|
||||
|
||||
expect(mockExportChart).toHaveBeenCalledWith({
|
||||
formData: defaultProps.latestQueryFormData,
|
||||
ownState: defaultProps.ownState,
|
||||
resultType: 'results',
|
||||
resultFormat: 'xlsx',
|
||||
});
|
||||
});
|
||||
|
||||
test('does not export when downloads are disabled', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useExploreDataExport({
|
||||
...defaultProps,
|
||||
canDownloadCSV: false,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.exportCSV();
|
||||
await result.current.exportCSVPivoted();
|
||||
await result.current.exportJson();
|
||||
await result.current.exportExcel();
|
||||
});
|
||||
|
||||
expect(mockExportChart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('shows large data error toast when export fails with status 413', async () => {
|
||||
mockExportChart.mockRejectedValue({
|
||||
status: 413,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useExploreDataExport(defaultProps));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.exportCSV();
|
||||
});
|
||||
|
||||
expect(mockAddDangerToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining('too large to download'),
|
||||
);
|
||||
});
|
||||
|
||||
test('shows export error message when export fails', async () => {
|
||||
mockExportChart.mockRejectedValue(new Error('Export exploded'));
|
||||
|
||||
const { result } = renderHook(() => useExploreDataExport(defaultProps));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.exportJson();
|
||||
});
|
||||
|
||||
expect(mockAddDangerToast).toHaveBeenCalledWith('Export exploded');
|
||||
});
|
||||
|
||||
test('exposes streaming export modal state and handlers', () => {
|
||||
const { result } = renderHook(() => useExploreDataExport(defaultProps));
|
||||
|
||||
expect(result.current.streamingExportState).toEqual(
|
||||
expect.objectContaining({
|
||||
isVisible: false,
|
||||
onRetry: mockRetryExport,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* 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 { useCallback, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
JsonObject,
|
||||
LatestQueryFormData,
|
||||
QueryFormData,
|
||||
} from '@superset-ui/core';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { useToasts } from 'src/components/MessageToasts/withToasts';
|
||||
import {
|
||||
StreamingProgress,
|
||||
useStreamingExport,
|
||||
} from 'src/components/StreamingExportModal';
|
||||
import { DEFAULT_CSV_STREAMING_ROW_THRESHOLD } from 'src/constants';
|
||||
import { exportChart, getChartKey } from 'src/explore/exploreUtils';
|
||||
import { ChartState } from 'src/explore/types';
|
||||
import { Slice } from 'src/types/Chart';
|
||||
|
||||
interface ExploreSlice {
|
||||
slice?: Slice | null;
|
||||
}
|
||||
|
||||
interface ExploreState {
|
||||
charts?: Record<number, ChartState>;
|
||||
explore?: ExploreSlice;
|
||||
common?: {
|
||||
conf?: {
|
||||
CSV_STREAMING_ROW_THRESHOLD?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface StreamingExportState {
|
||||
isVisible: boolean;
|
||||
progress: StreamingProgress;
|
||||
onCancel: () => void;
|
||||
onRetry: () => void;
|
||||
onDownload: () => void;
|
||||
}
|
||||
|
||||
interface UseExploreDataExportProps {
|
||||
latestQueryFormData: LatestQueryFormData;
|
||||
canDownloadCSV: boolean;
|
||||
slice?: Slice | null;
|
||||
ownState?: JsonObject;
|
||||
}
|
||||
|
||||
export const useExploreDataExport = ({
|
||||
latestQueryFormData,
|
||||
canDownloadCSV,
|
||||
slice,
|
||||
ownState,
|
||||
}: UseExploreDataExportProps) => {
|
||||
const { addDangerToast, addSuccessToast } = useToasts();
|
||||
|
||||
const chart = useSelector<ExploreState, ChartState | undefined>(state =>
|
||||
state.explore ? state.charts?.[getChartKey(state.explore)] : undefined,
|
||||
);
|
||||
|
||||
const streamingThreshold = useSelector<ExploreState, number>(
|
||||
state =>
|
||||
state.common?.conf?.CSV_STREAMING_ROW_THRESHOLD ||
|
||||
DEFAULT_CSV_STREAMING_ROW_THRESHOLD,
|
||||
);
|
||||
|
||||
const [isStreamingModalVisible, setIsStreamingModalVisible] = useState(false);
|
||||
|
||||
const { progress, startExport, resetExport, retryExport } =
|
||||
useStreamingExport({
|
||||
onComplete: () => {
|
||||
// Wait for the user to click Download before showing a success toast.
|
||||
},
|
||||
onError: () => {
|
||||
addDangerToast(t('Export failed - please try again'));
|
||||
},
|
||||
});
|
||||
|
||||
const handleCloseStreamingModal = useCallback(() => {
|
||||
setIsStreamingModalVisible(false);
|
||||
resetExport();
|
||||
}, [resetExport]);
|
||||
|
||||
const handleDownloadComplete = useCallback(() => {
|
||||
addSuccessToast(t('CSV file downloaded successfully'));
|
||||
}, [addSuccessToast]);
|
||||
|
||||
const handleExportError = useCallback(
|
||||
(error: unknown) => {
|
||||
const exportError = error as Error & {
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
response?: { status?: number };
|
||||
};
|
||||
const status = exportError.status || exportError.response?.status;
|
||||
|
||||
if (status === 413) {
|
||||
addDangerToast(
|
||||
t(
|
||||
'The chart data is too large to download. Please try reducing the date range, limiting rows, or using fewer columns.',
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const errorMessage =
|
||||
exportError.message ||
|
||||
exportError.statusText ||
|
||||
t(
|
||||
'Failed to export chart data. Please try again or contact your administrator.',
|
||||
);
|
||||
addDangerToast(errorMessage);
|
||||
}
|
||||
},
|
||||
[addDangerToast],
|
||||
);
|
||||
|
||||
const exportCSV = useCallback(async () => {
|
||||
if (!canDownloadCSV) return null;
|
||||
|
||||
let actualRowCount;
|
||||
const isTableViz = latestQueryFormData?.viz_type === 'table';
|
||||
const queriesResponse = chart?.queriesResponse;
|
||||
|
||||
if (
|
||||
isTableViz &&
|
||||
queriesResponse &&
|
||||
queriesResponse.length > 1 &&
|
||||
queriesResponse[1]?.data?.[0]?.rowcount
|
||||
) {
|
||||
actualRowCount = queriesResponse[1].data[0].rowcount;
|
||||
} else if (queriesResponse && queriesResponse[0]?.sql_rowcount != null) {
|
||||
actualRowCount = queriesResponse[0].sql_rowcount;
|
||||
} else if (queriesResponse && queriesResponse[0]?.rowcount != null) {
|
||||
actualRowCount = queriesResponse[0].rowcount;
|
||||
} else {
|
||||
actualRowCount = latestQueryFormData?.row_limit;
|
||||
}
|
||||
|
||||
const shouldUseStreaming =
|
||||
actualRowCount && actualRowCount >= streamingThreshold;
|
||||
|
||||
let filename: string | undefined;
|
||||
if (shouldUseStreaming) {
|
||||
const now = new Date();
|
||||
const date = now.toISOString().slice(0, 10);
|
||||
const time = now.toISOString().slice(11, 19).replace(/:/g, '');
|
||||
const timestamp = `_${date}_${time}`;
|
||||
const chartName =
|
||||
slice?.slice_name || latestQueryFormData.viz_type || 'chart';
|
||||
const safeChartName = chartName.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
filename = `${safeChartName}${timestamp}.csv`;
|
||||
}
|
||||
|
||||
try {
|
||||
await exportChart({
|
||||
formData: latestQueryFormData as QueryFormData,
|
||||
ownState,
|
||||
resultType: 'full',
|
||||
resultFormat: 'csv',
|
||||
onStartStreamingExport: shouldUseStreaming
|
||||
? exportParams => {
|
||||
if (exportParams.url) {
|
||||
setIsStreamingModalVisible(true);
|
||||
startExport({
|
||||
...exportParams,
|
||||
url: exportParams.url,
|
||||
filename,
|
||||
expectedRows: actualRowCount,
|
||||
exportType: exportParams.exportType as 'csv' | 'xlsx',
|
||||
});
|
||||
}
|
||||
}
|
||||
: null,
|
||||
});
|
||||
} catch (error) {
|
||||
handleExportError(error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [
|
||||
canDownloadCSV,
|
||||
latestQueryFormData,
|
||||
ownState,
|
||||
chart,
|
||||
streamingThreshold,
|
||||
slice,
|
||||
startExport,
|
||||
handleExportError,
|
||||
]);
|
||||
|
||||
const exportCSVPivoted = useCallback(async () => {
|
||||
if (!canDownloadCSV) return null;
|
||||
|
||||
try {
|
||||
await exportChart({
|
||||
formData: latestQueryFormData as QueryFormData,
|
||||
ownState,
|
||||
resultType: 'post_processed',
|
||||
resultFormat: 'csv',
|
||||
});
|
||||
} catch (error) {
|
||||
handleExportError(error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
|
||||
|
||||
const exportJson = useCallback(async () => {
|
||||
if (!canDownloadCSV) return null;
|
||||
|
||||
try {
|
||||
await exportChart({
|
||||
formData: latestQueryFormData as QueryFormData,
|
||||
ownState,
|
||||
resultType: 'results',
|
||||
resultFormat: 'json',
|
||||
});
|
||||
} catch (error) {
|
||||
handleExportError(error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
|
||||
|
||||
const exportExcel = useCallback(async () => {
|
||||
if (!canDownloadCSV) return null;
|
||||
|
||||
try {
|
||||
await exportChart({
|
||||
formData: latestQueryFormData as QueryFormData,
|
||||
ownState,
|
||||
resultType: 'results',
|
||||
resultFormat: 'xlsx',
|
||||
});
|
||||
} catch (error) {
|
||||
handleExportError(error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
|
||||
|
||||
const streamingExportState: StreamingExportState = {
|
||||
isVisible: isStreamingModalVisible,
|
||||
progress,
|
||||
onCancel: handleCloseStreamingModal,
|
||||
onRetry: retryExport,
|
||||
onDownload: handleDownloadComplete,
|
||||
};
|
||||
|
||||
return {
|
||||
exportCSV,
|
||||
exportCSVPivoted,
|
||||
exportJson,
|
||||
exportExcel,
|
||||
handleExportError,
|
||||
streamingExportState,
|
||||
};
|
||||
};
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { AppSection } from '@superset-ui/core';
|
||||
import { AppSection, type ChartProps } from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
@@ -443,6 +443,35 @@ describe('RangeFilterPlugin', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('clears a filter still sitting at its default value', () => {
|
||||
const renderWith = (value: [number | null, number | null]) => (
|
||||
<RangeFilterPlugin
|
||||
{...(transformProps({
|
||||
...rangeProps,
|
||||
filterState: { value },
|
||||
} as unknown as ChartProps) as PluginFilterRangeProps)}
|
||||
setDataMask={setDataMask}
|
||||
/>
|
||||
);
|
||||
|
||||
// The filter loads at its default and the user never touches it.
|
||||
const { rerender } = render(renderWith([10, 70]));
|
||||
setDataMask.mockClear();
|
||||
|
||||
// Clear All stages [null, null] for range filters.
|
||||
rerender(renderWith([null, null]));
|
||||
|
||||
expect(setDataMask).toHaveBeenCalledWith({
|
||||
extraFormData: {},
|
||||
filterState: {
|
||||
value: [null, null],
|
||||
label: '',
|
||||
validateStatus: undefined,
|
||||
validateMessage: '',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('calculateStep returns ~100 steps for integer ranges', () => {
|
||||
|
||||
@@ -363,8 +363,14 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear all case
|
||||
if (filterState.value === undefined && !filterState.validateStatus) {
|
||||
// Clear all case. The filter bar stages [null, null] for range filters, so
|
||||
// matching only undefined let a filter still sitting at its default fall
|
||||
// through to the default-restoring branch below.
|
||||
if (
|
||||
(filterState.value === undefined ||
|
||||
isEqualArray(filterState.value, [null, null])) &&
|
||||
!filterState.validateStatus
|
||||
) {
|
||||
setInputValue([null, null]);
|
||||
updateDataMaskValue([null, null]);
|
||||
return;
|
||||
|
||||
@@ -114,7 +114,7 @@ msgid " expression which needs to adhere to the "
|
||||
msgstr "準拠すべき式"
|
||||
|
||||
msgid " for details."
|
||||
msgstr "(詳細)"
|
||||
msgstr "をご覧ください。"
|
||||
|
||||
#, python-format
|
||||
msgid " near '%(highlight)s'"
|
||||
@@ -196,7 +196,7 @@ msgstr "合計に対する %"
|
||||
|
||||
#, python-format
|
||||
msgid "%(alertType)s \"%(alertName)s\" triggered successfully"
|
||||
msgstr ""
|
||||
msgstr "%(alertType)s 「%(alertName)s」が正常に実行されました"
|
||||
|
||||
#, python-format
|
||||
msgid "%(dialect)s cannot be used as a data source for security reasons."
|
||||
@@ -208,7 +208,7 @@ msgstr "%(label)s ファイル"
|
||||
|
||||
#, python-format
|
||||
msgid "%(name)s.%(extension)s"
|
||||
msgstr ""
|
||||
msgstr "%(name)s.%(extension)s"
|
||||
|
||||
#, python-format
|
||||
msgid "%(name)s.csv"
|
||||
@@ -258,7 +258,7 @@ msgid ""
|
||||
"Please recheck your query.\n"
|
||||
"Exception: %(ex)s"
|
||||
msgstr ""
|
||||
"%(validator)s はクエリをチェックできませんでした。\n"
|
||||
"%(validator)s はクエリを検証できませんでした。\n"
|
||||
"クエリを再確認してください。\n"
|
||||
"例外: %(ex)s"
|
||||
|
||||
@@ -504,7 +504,7 @@ msgid "+ %s more"
|
||||
msgstr "他 %s 件"
|
||||
|
||||
msgid ", then paste the JSON below. See our"
|
||||
msgstr "、次に以下のJSONを貼り付けてください。詳細は当社の"
|
||||
msgstr "で作成し、以下に JSON を貼り付けてください。詳細については、当社の"
|
||||
|
||||
msgid ""
|
||||
"-- Note: Unless you save your query, these tabs will NOT persist if you "
|
||||
@@ -848,10 +848,10 @@ msgstr "国別の値を表示可能な世界地図です。"
|
||||
msgid ""
|
||||
"A map that takes rendering circles with a variable radius at "
|
||||
"latitude/longitude coordinates"
|
||||
msgstr "緯度・経度座標に、半径が可変の円をレンダリングする地図です"
|
||||
msgstr "緯度・経度座標上に、可変半径の円を描画する地図です。"
|
||||
|
||||
msgid "A metric to use for color"
|
||||
msgstr "色に使用するメトリック"
|
||||
msgstr "色付けに使用するメトリック"
|
||||
|
||||
msgid ""
|
||||
"A multiplier applied to the point radius. Use this to uniformly scale all"
|
||||
@@ -897,7 +897,7 @@ msgid ""
|
||||
"Restore it via POST /api/v1/dataset/%(uuid)s/restore before creating a "
|
||||
"new dataset over this table, or use a different table name."
|
||||
msgstr ""
|
||||
"ソフト削除されたデータセット (uuid %(uuid)s) "
|
||||
"論理削除されたデータセット (uuid %(uuid)s) "
|
||||
"が、すでにこのテーブルを参照しています。このテーブル上に新しいデータセットを作成する前に、POST "
|
||||
"/api/v1/dataset/%(uuid)s/restore を使用して復元するか、別のテーブル名を使用してください。"
|
||||
|
||||
@@ -907,7 +907,7 @@ msgid ""
|
||||
"Restore it via POST /api/v1/dataset/%(uuid)s/restore before uploading, or"
|
||||
" upload to a different table name."
|
||||
msgstr ""
|
||||
"ソフト削除されたデータセット(uuid %(uuid)s)が、すでにこのテーブルを参照しています。アップロードする前に、POST "
|
||||
"論理削除されたデータセット(uuid %(uuid)s)が、すでにこのテーブルを参照しています。アップロードする前に、POST "
|
||||
"/api/v1/dataset/%(uuid)s/restore を使用して復元するか、別のテーブル名にアップロードしてください。"
|
||||
|
||||
msgid "A timeout occurred while executing the query."
|
||||
@@ -920,7 +920,7 @@ msgid "A timeout occurred while generating a dataframe."
|
||||
msgstr "データフレームの生成中にタイムアウトが発生しました。"
|
||||
|
||||
msgid "A timeout occurred while generating an Excel file."
|
||||
msgstr ""
|
||||
msgstr "Excel ファイルの生成中にタイムアウトが発生しました。"
|
||||
|
||||
msgid "A timeout occurred while taking a screenshot."
|
||||
msgstr "スクリーンショットの撮影中にタイムアウトが発生しました。"
|
||||
@@ -975,13 +975,13 @@ msgid "AUG"
|
||||
msgstr "8月"
|
||||
|
||||
msgid "Aborted"
|
||||
msgstr "中止"
|
||||
msgstr "中止済み"
|
||||
|
||||
msgid "Aborting"
|
||||
msgstr "中止中"
|
||||
|
||||
msgid "About"
|
||||
msgstr "詳細"
|
||||
msgstr "概要"
|
||||
|
||||
msgid "Access"
|
||||
msgstr "アクセス"
|
||||
@@ -993,16 +993,16 @@ msgid "Account"
|
||||
msgstr "アカウント"
|
||||
|
||||
msgid "Action"
|
||||
msgstr "アクション"
|
||||
msgstr "操作"
|
||||
|
||||
msgid "Action Log"
|
||||
msgstr "操作履歴"
|
||||
msgstr "操作ログ"
|
||||
|
||||
msgid "Action Logs"
|
||||
msgstr "操作履歴"
|
||||
msgstr "操作ログ"
|
||||
|
||||
msgid "Actions"
|
||||
msgstr "アクション"
|
||||
msgstr "操作"
|
||||
|
||||
msgid "Active"
|
||||
msgstr "有効"
|
||||
@@ -1131,7 +1131,7 @@ msgid "Add dataset columns here to group the pivot table columns."
|
||||
msgstr "ここにデータセットの列を追加して、ピボットテーブルの列をグループ化します。"
|
||||
|
||||
msgid "Add delivery method"
|
||||
msgstr "送信方法を追加"
|
||||
msgstr "配信方法を追加"
|
||||
|
||||
msgid "Add description of your tag"
|
||||
msgstr "タグの説明を追加"
|
||||
@@ -1201,7 +1201,7 @@ msgid "Add required control values to save chart"
|
||||
msgstr "チャートを保存するために必要なコントロール値を追加してください"
|
||||
|
||||
msgid "Add service credentials"
|
||||
msgstr ""
|
||||
msgstr "サービスの認証情報を追加する"
|
||||
|
||||
msgid "Add sheet"
|
||||
msgstr "シートを追加"
|
||||
@@ -1267,10 +1267,10 @@ msgid ""
|
||||
msgstr "比較値からの増減(正負の変化)に基づいて、チャートのシンボルに色を付けます。"
|
||||
|
||||
msgid "Adhoc metric SQL expression is invalid"
|
||||
msgstr ""
|
||||
msgstr "アドホックなメトリックのSQL式が無効です"
|
||||
|
||||
msgid "Adhoc metric aggregate is invalid"
|
||||
msgstr ""
|
||||
msgstr "アドホックなメトリックの集計が無効です"
|
||||
|
||||
msgid "Adjust how this database will interact with SQL Lab."
|
||||
msgstr "このデータベースとSQL Labとの連携設定を調整します。"
|
||||
@@ -1704,7 +1704,7 @@ msgstr "関数名の取得中にエラーが発生しました。"
|
||||
|
||||
#, python-format
|
||||
msgid "An error occurred while fetching row level security subject values: %s"
|
||||
msgstr ""
|
||||
msgstr "行レベルのセキュリティ対象値の取得中にエラーが発生しました: %s"
|
||||
|
||||
#, python-format
|
||||
msgid "An error occurred while fetching schema values: %s"
|
||||
@@ -1805,7 +1805,7 @@ msgid "An error occurred while syncing permissions for %s: %s"
|
||||
msgstr "%s の権限同期中にエラーが発生しました: %s"
|
||||
|
||||
msgid "An error occurred while triggering the report"
|
||||
msgstr ""
|
||||
msgstr "レポートの実行中にエラーが発生しました"
|
||||
|
||||
msgid "An error occurred while updating the extension."
|
||||
msgstr "拡張機能の更新中にエラーが発生しました。"
|
||||
@@ -1832,6 +1832,8 @@ msgid ""
|
||||
"Angle at which the first slice begins, in degrees. 90° starts at the top,"
|
||||
" 0°/360° at the right, 270° at the bottom, and 180° at the left."
|
||||
msgstr ""
|
||||
"最初のスライスを開始する角度(度)。90°は上端から始まり、"
|
||||
"0°/360°は右端から、270°は下端から、180°は左端から始まります。"
|
||||
|
||||
msgid "Angle at which to end progress axis"
|
||||
msgstr "進行軸を終了する角度"
|
||||
@@ -2067,7 +2069,7 @@ msgid "Are you sure you want to delete the selected layers?"
|
||||
msgstr "選択したレイヤーを削除してもよろしいですか?"
|
||||
|
||||
msgid "Are you sure you want to delete the selected queries?"
|
||||
msgstr ""
|
||||
msgstr "選択したクエリを削除してもよろしいですか?"
|
||||
|
||||
msgid "Are you sure you want to delete the selected roles?"
|
||||
msgstr "選択したロールを削除してもよろしいですか?"
|
||||
@@ -2658,7 +2660,7 @@ msgid ""
|
||||
"permanently once a purge capability ships, or remove the underlying rows "
|
||||
"out-of-band, before deleting the database."
|
||||
msgstr ""
|
||||
"残っているデータセットがすべてソフト削除されているデータベースは削除できません。それらを復元(POST "
|
||||
"残っているデータセットがすべて論理削除されているデータベースは削除できません。それらを復元(POST "
|
||||
"/api/v1/dataset/<uuid>/restore)し、パージ機能が提供されたら完全に削除するか、データベースを削除する前に、別の手順で基となる行を削除してください。"
|
||||
|
||||
msgid "Cannot delete system themes"
|
||||
@@ -2733,7 +2735,7 @@ msgid "Category, Value and Percentage"
|
||||
msgstr "カテゴリ、値、割合 (%)"
|
||||
|
||||
msgid "Cell Padding"
|
||||
msgstr "セルのパディング"
|
||||
msgstr "セルの余白"
|
||||
|
||||
msgid "Cell Radius"
|
||||
msgstr "セルの角の丸み"
|
||||
@@ -2994,7 +2996,7 @@ msgid "Check out this dashboard: "
|
||||
msgstr "このダッシュボードを確認: "
|
||||
|
||||
msgid "Check to force date partitions to have the same height"
|
||||
msgstr "日付パーティションを強制的に同じ高さにする場合はオンにしてください"
|
||||
msgstr "日付パーティションを強制的に同じ高さにする場合はチェックを入れます"
|
||||
|
||||
msgid "Child label position"
|
||||
msgstr "子ラベルの位置"
|
||||
@@ -3013,7 +3015,7 @@ msgid "Choose a chart for displaying on the map"
|
||||
msgstr "マップ上に表示するチャートを選択"
|
||||
|
||||
msgid "Choose a chart or dashboard not both"
|
||||
msgstr "チャートまたはダッシュボードのどちらか一方を選択してください"
|
||||
msgstr "チャートまたはダッシュボードのどちらか一方のみを選択してください"
|
||||
|
||||
msgid "Choose a database..."
|
||||
msgstr "データベースを選択..."
|
||||
@@ -3163,7 +3165,7 @@ msgid "Clear local theme"
|
||||
msgstr "ローカルテーマをクリア"
|
||||
|
||||
msgid "Clear search"
|
||||
msgstr ""
|
||||
msgstr "検索をクリア"
|
||||
|
||||
msgid "Clear the selection to revert to the system default theme"
|
||||
msgstr "選択を解除して、システムの既定テーマに戻します"
|
||||
@@ -3643,7 +3645,7 @@ msgid "Connection failed, please check your connection settings."
|
||||
msgstr "接続に失敗しました。接続設定を確認してください。"
|
||||
|
||||
msgid "Connection looks good!"
|
||||
msgstr ""
|
||||
msgstr "正常に接続できました!"
|
||||
|
||||
msgid "Contains"
|
||||
msgstr "含む"
|
||||
@@ -3720,13 +3722,13 @@ msgid "Copy permalink to clipboard"
|
||||
msgstr "固定リンクをクリップボードにコピー"
|
||||
|
||||
msgid "Copy query"
|
||||
msgstr ""
|
||||
msgstr "クエリをコピー"
|
||||
|
||||
msgid "Copy query URL"
|
||||
msgstr ""
|
||||
msgstr "クエリのURLをコピー"
|
||||
|
||||
msgid "Copy query link to your clipboard"
|
||||
msgstr "クエリのリンクをクリップボードにコピー"
|
||||
msgstr "クエリリンクをクリップボードにコピー"
|
||||
|
||||
msgid "Copy the current data"
|
||||
msgstr "現在のデータをコピー"
|
||||
@@ -3844,7 +3846,7 @@ msgstr "新しいチャートを作成"
|
||||
msgid ""
|
||||
"Create a new tag and assign it to existing entities like charts or "
|
||||
"dashboards"
|
||||
msgstr ""
|
||||
msgstr "新しいタグを作成し、チャートやダッシュボードなどの既存のエンティティに割り当てる"
|
||||
|
||||
msgid "Create and explore dataset"
|
||||
msgstr "データセットを作成して探索"
|
||||
@@ -4018,7 +4020,7 @@ msgid "Customize"
|
||||
msgstr "カスタマイズ"
|
||||
|
||||
msgid "Customize Metrics"
|
||||
msgstr "メトリックをカスタマイズ"
|
||||
msgstr "メトリックのカスタマイズ"
|
||||
|
||||
msgid ""
|
||||
"Customize cell titles using Handlebars template syntax. Available "
|
||||
@@ -4026,7 +4028,7 @@ msgid ""
|
||||
msgstr "Handlebarsテンプレート構文を使用して、セルの見出しをカスタマイズします。利用可能な変数:{{rowLabel}}、{{colLabel}}"
|
||||
|
||||
msgid "Customize columns"
|
||||
msgstr "列をカスタマイズ"
|
||||
msgstr "列のカスタマイズ"
|
||||
|
||||
msgid "Customize data source, filters, and layout."
|
||||
msgstr "データソース、フィルター、およびレイアウトのカスタマイズ。"
|
||||
@@ -4047,10 +4049,10 @@ msgid ""
|
||||
msgstr "合計値に対して、ツールチップ、凡例、および軸に表示するラベルをカスタマイズします。"
|
||||
|
||||
msgid "Customize tooltips template"
|
||||
msgstr "ツールチップテンプレートをカスタマイズ"
|
||||
msgstr "ツールチップテンプレートのカスタマイズ"
|
||||
|
||||
msgid "Cyclic dependency detected"
|
||||
msgstr "循環参照(依存関係)が検出されました"
|
||||
msgstr "循環依存(循環参照)が検出されました"
|
||||
|
||||
msgid "D3 format"
|
||||
msgstr "D3形式"
|
||||
@@ -4131,6 +4133,8 @@ msgid ""
|
||||
"Dashboard cannot be restored because its slug is now used by another "
|
||||
"active dashboard. Rename one of the dashboards and retry."
|
||||
msgstr ""
|
||||
"このダッシュボードは、そのスラグが現在別のアクティブなダッシュボードで使用されているため、復元できません。"
|
||||
"いずれかのダッシュボードの名前を変更してから、再度お試しください。"
|
||||
|
||||
msgid "Dashboard cannot be unfavorited."
|
||||
msgstr "ダッシュボードのお気に入りを解除できませんでした。"
|
||||
@@ -4266,7 +4270,7 @@ msgid "Data refreshed"
|
||||
msgstr "データを更新しました"
|
||||
|
||||
msgid "Data type"
|
||||
msgstr "データ種類"
|
||||
msgstr "データ型"
|
||||
|
||||
msgid "DataFrame include at least one series"
|
||||
msgstr "データフレームには少なくとも1つの系列が必要です"
|
||||
@@ -4308,8 +4312,8 @@ msgid ""
|
||||
"Database driver for importing maybe not installed. Visit the Superset "
|
||||
"documentation page for installation instructions: "
|
||||
msgstr ""
|
||||
"インポートに必要なデータベースドライバーがインストールされていない可能性があります。インストール手順についてはSupersetのドキュメントを確認してください:"
|
||||
" "
|
||||
"インポートに必要なデータベースドライバーがインストールされていない可能性があります。"
|
||||
"インストール手順についてはSupersetのドキュメントを確認してください:"
|
||||
|
||||
msgid "Database error"
|
||||
msgstr "データベースエラー"
|
||||
@@ -4397,7 +4401,7 @@ msgid "Dataset does not exist"
|
||||
msgstr "データセットが存在しません"
|
||||
|
||||
msgid "Dataset is required"
|
||||
msgstr "データセットが必要です"
|
||||
msgstr "データセットは必須です"
|
||||
|
||||
msgid "Dataset metric delete failed."
|
||||
msgstr "データセットメトリックの削除に失敗しました。"
|
||||
@@ -4439,7 +4443,7 @@ msgid "Datasource does not exist"
|
||||
msgstr "データソースが存在しません"
|
||||
|
||||
msgid "Datasource is required"
|
||||
msgstr "データソースが必要です"
|
||||
msgstr "データソースは必須です"
|
||||
|
||||
msgid "Datasource is required for validation"
|
||||
msgstr "検証にはデータソースが必要です"
|
||||
@@ -4496,7 +4500,7 @@ msgid "Decides which column or measure to sort the base axis by."
|
||||
msgstr "基準軸の並べ替えに使用する列またはメジャーを選択します。"
|
||||
|
||||
msgid "Decimal character"
|
||||
msgstr "小数点"
|
||||
msgstr "小数点記号"
|
||||
|
||||
msgid "Deck.gl - 3D Grid"
|
||||
msgstr "Deck.gl - 3Dグリッド"
|
||||
@@ -4741,7 +4745,7 @@ msgid "Delete item"
|
||||
msgstr "項目を削除"
|
||||
|
||||
msgid "Delete query"
|
||||
msgstr ""
|
||||
msgstr "クエリを削除"
|
||||
|
||||
msgid "Delete role"
|
||||
msgstr "ロールを削除"
|
||||
@@ -4874,7 +4878,7 @@ msgid "Delimiter"
|
||||
msgstr "区切り文字"
|
||||
|
||||
msgid "Delivery method"
|
||||
msgstr "送信方法"
|
||||
msgstr "配信方法"
|
||||
|
||||
msgid "Density"
|
||||
msgstr "密度"
|
||||
@@ -4898,7 +4902,7 @@ msgid "Deselect all"
|
||||
msgstr "すべての選択を解除"
|
||||
|
||||
msgid "Design with"
|
||||
msgstr "デザイン"
|
||||
msgstr "デザインを"
|
||||
|
||||
msgid "Details"
|
||||
msgstr "詳細"
|
||||
@@ -4934,7 +4938,7 @@ msgid "Difference"
|
||||
msgstr "差分"
|
||||
|
||||
msgid "Dim Gray"
|
||||
msgstr "ディム・グレー"
|
||||
msgstr "薄灰色"
|
||||
|
||||
msgid "Dimension"
|
||||
msgstr "ディメンション"
|
||||
@@ -5018,6 +5022,8 @@ msgid ""
|
||||
"Display charts on a map. For using this plugin, users first have to "
|
||||
"create any other chart that can then be placed on the map."
|
||||
msgstr ""
|
||||
"地図上にグラフを表示します。"
|
||||
"このプラグインを使用するには、まず地図上に配置できる任意のグラフを作成する必要があります。"
|
||||
|
||||
msgid "Display column in the chart"
|
||||
msgstr "チャートに列を表示"
|
||||
@@ -5116,7 +5122,7 @@ msgid ""
|
||||
msgstr "ディメンションの値に基づいて、各カテゴリをさらにサブカテゴリに分割します。共通部分の除外などに利用できます。"
|
||||
|
||||
msgid "Do you want a donut or a pie?"
|
||||
msgstr "ドーナツ型か円型のどちらにしますか?"
|
||||
msgstr "ドーナツ型と円型のどちらにしますか?"
|
||||
|
||||
msgid "Documentation"
|
||||
msgstr "ドキュメント"
|
||||
@@ -5146,7 +5152,7 @@ msgid "Download is on the way"
|
||||
msgstr "ダウンロードを準備しています"
|
||||
|
||||
msgid "Download to CSV"
|
||||
msgstr "CSVとしてダウンロード"
|
||||
msgstr "CSV形式でダウンロード"
|
||||
|
||||
msgid "Download to client"
|
||||
msgstr "クライアントへダウンロード"
|
||||
@@ -5353,7 +5359,7 @@ msgstr "エラー"
|
||||
|
||||
#, python-format
|
||||
msgid "ERROR: %s"
|
||||
msgstr ""
|
||||
msgstr "エラー: %s"
|
||||
|
||||
msgid "Edge length"
|
||||
msgstr "エッジの長さ"
|
||||
@@ -5445,7 +5451,7 @@ msgid "Edit properties"
|
||||
msgstr "プロパティを編集"
|
||||
|
||||
msgid "Edit query"
|
||||
msgstr ""
|
||||
msgstr "クエリを編集"
|
||||
|
||||
msgid "Edit report"
|
||||
msgstr "レポートを編集"
|
||||
@@ -5529,7 +5535,7 @@ msgid "Email link"
|
||||
msgstr "メールリンク"
|
||||
|
||||
msgid "Email recipients"
|
||||
msgstr ""
|
||||
msgstr "メールの受信者"
|
||||
|
||||
msgid "Email reports active"
|
||||
msgstr "メールレポート有効"
|
||||
@@ -5732,7 +5738,7 @@ msgid "Enter the required %(dbModelName)s credentials"
|
||||
msgstr "%(dbModelName)s に必要な認証情報を入力してください"
|
||||
|
||||
msgid "Enter the unique project id for your database."
|
||||
msgstr "データベースのユニークなプロジェクトIDを入力してください。"
|
||||
msgstr "データベースの一意なプロジェクトIDを入力してください。"
|
||||
|
||||
msgid "Enter the user's email"
|
||||
msgstr "ユーザーのメールアドレスを入力"
|
||||
@@ -5759,7 +5765,7 @@ msgid "Entity"
|
||||
msgstr "エンティティ"
|
||||
|
||||
msgid "Entries per page"
|
||||
msgstr ""
|
||||
msgstr "1ページあたりの表示件数"
|
||||
|
||||
msgid "Equal Date Sizes"
|
||||
msgstr "日付サイズを均等にする"
|
||||
@@ -5882,7 +5888,7 @@ msgid "Error: %s"
|
||||
msgstr "エラー: %s"
|
||||
|
||||
msgid "Error: permalink state not found"
|
||||
msgstr "エラー: パーマリンクの状態が見つかりません"
|
||||
msgstr "エラー: 固定リンクの状態が見つかりません"
|
||||
|
||||
msgid "Estimate cost"
|
||||
msgstr "コストを見積もる"
|
||||
@@ -6015,7 +6021,7 @@ msgid "Export as Example"
|
||||
msgstr "構成をエクスポート"
|
||||
|
||||
msgid "Export as PDF"
|
||||
msgstr ""
|
||||
msgstr "PDFとしてエクスポート"
|
||||
|
||||
msgid "Export cancelled"
|
||||
msgstr "エクスポートがキャンセルされました"
|
||||
@@ -6034,13 +6040,13 @@ msgid "Export failed: %s"
|
||||
msgstr "エクスポート失敗: %s"
|
||||
|
||||
msgid "Export query"
|
||||
msgstr ""
|
||||
msgstr "クエリのエクスポート"
|
||||
|
||||
msgid "Export screenshot (jpeg)"
|
||||
msgstr "スクリーンショットをエクスポート (JPEG)"
|
||||
|
||||
msgid "Export screenshot (png)"
|
||||
msgstr ""
|
||||
msgstr "スクリーンショットをエクスポート (PNG)"
|
||||
|
||||
#, python-format
|
||||
msgid "Export successful: %s"
|
||||
@@ -6084,11 +6090,15 @@ msgid ""
|
||||
"Exporting semantic views is not supported yet — %s semantic-view row(s) "
|
||||
"were skipped."
|
||||
msgstr ""
|
||||
"セマンティックビューのエクスポートは現時点ではサポートされていません。"
|
||||
"%s 個のセマンティックビュー行がスキップされました。"
|
||||
|
||||
msgid ""
|
||||
"Exporting semantic views is not supported yet. Deselect the semantic-view"
|
||||
" rows and try again."
|
||||
msgstr ""
|
||||
"セマンティックビューのエクスポートは、現時点ではサポートされていません。"
|
||||
"セマンティックビューの行の選択を解除して、もう一度お試しください。"
|
||||
|
||||
msgid "Expose database in SQL Lab"
|
||||
msgstr "SQL Labでデータベースを公開する"
|
||||
@@ -6192,6 +6202,8 @@ msgid ""
|
||||
"Failed to export chart data. Please try again or contact your "
|
||||
"administrator."
|
||||
msgstr ""
|
||||
"チャートデータのエクスポートに失敗しました。"
|
||||
"もう一度お試しいただくか、管理者にお問い合わせください。"
|
||||
|
||||
msgid "Failed to fetch API keys"
|
||||
msgstr "APIキーの取得に失敗しました"
|
||||
@@ -6267,7 +6279,7 @@ msgstr "項目へのタグ付けに失敗しました"
|
||||
|
||||
#, python-format
|
||||
msgid "Failed to trigger %(alertType)s \"%(alertName)s\": %(error)s"
|
||||
msgstr ""
|
||||
msgstr "%(alertType)s 「%(alertName)s」のトリガーに失敗しました:%(error)s"
|
||||
|
||||
msgid "Failed to update report"
|
||||
msgstr "レポートの更新に失敗しました"
|
||||
@@ -6467,7 +6479,7 @@ msgid "Fit columns dynamically"
|
||||
msgstr "列を動的にフィットさせる"
|
||||
|
||||
msgid "Fit data"
|
||||
msgstr ""
|
||||
msgstr "フィットデータ"
|
||||
|
||||
msgid ""
|
||||
"Fix the trend line to the full time range specified in case filtered "
|
||||
@@ -6579,7 +6591,7 @@ msgid "Forecast periods"
|
||||
msgstr "予測期間"
|
||||
|
||||
msgid "Forecast requires at least 2 data points"
|
||||
msgstr ""
|
||||
msgstr "予測には、少なくとも2つのデータポイントが必要です"
|
||||
|
||||
msgid "Foreign key"
|
||||
msgstr "外部キー"
|
||||
@@ -6625,7 +6637,7 @@ msgid "Formatted CSV attached in email"
|
||||
msgstr "整形済みCSVをメールに添付"
|
||||
|
||||
msgid "Formatted Excel attached in email"
|
||||
msgstr ""
|
||||
msgstr "メールに書式設定済みのExcelファイルを添付しました"
|
||||
|
||||
msgid "Formatted value"
|
||||
msgstr "整形済みの値"
|
||||
@@ -7287,7 +7299,7 @@ msgid "Invalid options for %(rolling_type)s: %(options)s"
|
||||
msgstr "%(rolling_type)s のオプションが無効です: %(options)s"
|
||||
|
||||
msgid "Invalid permalink key"
|
||||
msgstr "パーマリンクキーが無効です"
|
||||
msgstr "固定リンクキーが無効です"
|
||||
|
||||
#, python-format
|
||||
msgid "Invalid reference to column: \"%(column)s\""
|
||||
@@ -7498,7 +7510,7 @@ msgid "Label for your query"
|
||||
msgstr "クエリのラベル"
|
||||
|
||||
msgid "Label must not be empty."
|
||||
msgstr ""
|
||||
msgstr "ラベルを空にしないでください。"
|
||||
|
||||
msgid "Label position"
|
||||
msgstr "ラベルの位置"
|
||||
@@ -7780,7 +7792,7 @@ msgid "Lines encoding"
|
||||
msgstr "線のエンコーディング"
|
||||
|
||||
msgid "Link Copied!"
|
||||
msgstr ""
|
||||
msgstr "リンクがコピーされました!"
|
||||
|
||||
msgid "List"
|
||||
msgstr "一覧"
|
||||
@@ -8641,7 +8653,7 @@ msgid "No data after filtering or data is NULL for the latest time record"
|
||||
msgstr "フィルターの結果、または最新レコードのデータがNULLのため、データがありません"
|
||||
|
||||
msgid "No data found"
|
||||
msgstr ""
|
||||
msgstr "データが見つかりませんでした"
|
||||
|
||||
msgid "No data in file"
|
||||
msgstr "ファイル内にデータがありません"
|
||||
@@ -9023,7 +9035,7 @@ msgstr "1つ以上のメトリックが存在しません"
|
||||
|
||||
#, python-format
|
||||
msgid "One or more parameters are missing: %(missing)s"
|
||||
msgstr ""
|
||||
msgstr "1つ以上のパラメータが不足しています:%(missing)s"
|
||||
|
||||
msgid "One or more parameters needed to configure a database are missing."
|
||||
msgstr "データベース設定に必要な1つ以上のパラメータが不足しています。"
|
||||
@@ -9627,6 +9639,9 @@ msgid ""
|
||||
"period (e.g. today so far) against complete prior periods (e.g. all of "
|
||||
"yesterday)."
|
||||
msgstr ""
|
||||
"各時間シフトされた系列を、主系列に合わせて切り詰めるのではなく、その全時間範囲にわたってプロットします。"
|
||||
"これは、現在の期間の一部(例:今日のこれまでの時間)を、"
|
||||
"過去の期間全体(例:昨日の全期間)と比較する際に役立ちます。"
|
||||
|
||||
msgid "Plot the distance (like flight paths) between origin and destination."
|
||||
msgstr "出発地と目的地間の距離(飛行経路など)をプロットします。"
|
||||
@@ -10063,6 +10078,9 @@ msgid ""
|
||||
"except the subjects defined in the filter, and can be used to define what"
|
||||
" users can see if no RLS filters within a filter group apply to them."
|
||||
msgstr ""
|
||||
"通常のフィルターは、ユーザーがフィルターで参照されている対象と一致する場合、クエリにWHERE句を追加します。"
|
||||
"ベースフィルターは、フィルターで定義された対象を除くすべてのクエリにフィルターを適用し、"
|
||||
"フィルターグループ内のRLSフィルターがユーザーに適用されない場合に、そのユーザーに表示できる内容を定義するために使用できます。"
|
||||
|
||||
msgid "Relational"
|
||||
msgstr "関連した"
|
||||
@@ -10101,7 +10119,7 @@ msgid "Remove customization"
|
||||
msgstr "カスタマイズを削除"
|
||||
|
||||
msgid "Remove dependency"
|
||||
msgstr ""
|
||||
msgstr "依存関係を削除"
|
||||
|
||||
msgid "Remove filter"
|
||||
msgstr "フィルターを削除"
|
||||
@@ -10110,13 +10128,13 @@ msgid "Remove item"
|
||||
msgstr "項目を削除"
|
||||
|
||||
msgid "Remove notification method"
|
||||
msgstr ""
|
||||
msgstr "通知方法を削除"
|
||||
|
||||
msgid "Remove query from log"
|
||||
msgstr "ログからクエリを削除"
|
||||
|
||||
msgid "Remove sheet"
|
||||
msgstr ""
|
||||
msgstr "シートを削除"
|
||||
|
||||
#, python-format
|
||||
msgid "Removed 1 column from the virtual dataset"
|
||||
@@ -10156,7 +10174,7 @@ msgid "Report Schedule delete failed."
|
||||
msgstr "レポートスケジュールの削除に失敗しました。"
|
||||
|
||||
msgid "Report Schedule execute now failed."
|
||||
msgstr ""
|
||||
msgstr "レポートスケジュールの即時実行に失敗しました。"
|
||||
|
||||
msgid "Report Schedule execution failed when generating a csv."
|
||||
msgstr "CSV生成時にレポートスケジュールの実行が失敗しました。"
|
||||
@@ -10171,7 +10189,7 @@ msgid "Report Schedule execution failed when generating a screenshot."
|
||||
msgstr "スクリーンショット生成時にレポートスケジュールの実行が失敗しました。"
|
||||
|
||||
msgid "Report Schedule execution failed when generating an Excel file."
|
||||
msgstr ""
|
||||
msgstr "Excel ファイルの生成中に、レポートスケジュールの実行に失敗しました。"
|
||||
|
||||
msgid "Report Schedule execution got an unexpected error."
|
||||
msgstr "レポートスケジュールの実行中に予期しないエラーが発生しました。"
|
||||
@@ -10181,10 +10199,12 @@ msgid ""
|
||||
"Please configure a Celery broker (Redis or RabbitMQ) and worker "
|
||||
"processes."
|
||||
msgstr ""
|
||||
"レポートスケジュールの実行には、Celeryバックエンドの設定が必要です。"
|
||||
"Celeryブローカー(RedisまたはRabbitMQ)とワーカープロセスを設定してください。"
|
||||
|
||||
#, python-format
|
||||
msgid "Report Schedule executor user %(username)s was not found."
|
||||
msgstr ""
|
||||
msgstr "レポートスケジュールの実行者ユーザー %(username)s が見つかりませんでした。"
|
||||
|
||||
msgid "Report Schedule is still working, refusing to re-compute."
|
||||
msgstr "レポートスケジュールはまだ処理中のため、再計算を拒否しました。"
|
||||
@@ -10833,7 +10853,7 @@ msgid "Search Metrics & Columns"
|
||||
msgstr "メトリックと列を検索"
|
||||
|
||||
msgid "Search a channel by name, or paste a channel ID"
|
||||
msgstr ""
|
||||
msgstr "チャンネル名を検索するか、チャンネルIDを貼り付けてください"
|
||||
|
||||
msgid "Search all charts"
|
||||
msgstr "すべてのチャートを検索"
|
||||
@@ -10875,7 +10895,7 @@ msgid "Search metrics by key or label"
|
||||
msgstr "メトリックをキーまたはラベルで検索"
|
||||
|
||||
msgid "Search records"
|
||||
msgstr ""
|
||||
msgstr "記録を検索"
|
||||
|
||||
msgid "Search tags"
|
||||
msgstr "タグを検索"
|
||||
@@ -10887,7 +10907,7 @@ msgid "Search..."
|
||||
msgstr "検索…"
|
||||
|
||||
msgid "Searches all text fields: Name, Description, Database & Schema"
|
||||
msgstr ""
|
||||
msgstr "すべてのテキストフィールド(「名前」、「説明」、「データベース」、「スキーマ」)を検索します"
|
||||
|
||||
msgid "Second"
|
||||
msgstr "秒"
|
||||
@@ -10928,7 +10948,7 @@ msgid "See all %(tableName)s"
|
||||
msgstr "すべての %(tableName)s を表示"
|
||||
|
||||
msgid "See all dashboards"
|
||||
msgstr ""
|
||||
msgstr "すべてのダッシュボードを表示する"
|
||||
|
||||
msgid "See less"
|
||||
msgstr "表示を減らす"
|
||||
@@ -11190,10 +11210,10 @@ msgid "Select operator"
|
||||
msgstr "演算子を選択"
|
||||
|
||||
msgid "Select or type BCC recipients"
|
||||
msgstr ""
|
||||
msgstr "BCCの受信者を選択するか、入力してください"
|
||||
|
||||
msgid "Select or type CC recipients"
|
||||
msgstr ""
|
||||
msgstr "CCの受信者を選択するか、入力してください"
|
||||
|
||||
msgid "Select or type a custom value..."
|
||||
msgstr "カスタム値を選択または入力..."
|
||||
@@ -11205,7 +11225,7 @@ msgid "Select or type dataset name"
|
||||
msgstr "データセット名を選択または入力"
|
||||
|
||||
msgid "Select or type email recipients"
|
||||
msgstr ""
|
||||
msgstr "メールの受信者を選択するか、入力してください"
|
||||
|
||||
msgid "Select page size"
|
||||
msgstr "ページサイズを選択"
|
||||
@@ -11392,7 +11412,7 @@ msgid "Send as CSV"
|
||||
msgstr "CSVとして送信"
|
||||
|
||||
msgid "Send as Excel"
|
||||
msgstr ""
|
||||
msgstr "Excelとして送信"
|
||||
|
||||
msgid "Send as PDF"
|
||||
msgstr "PDFとして送信"
|
||||
@@ -11599,7 +11619,7 @@ msgid "Show Metric Names"
|
||||
msgstr "メトリック名を表示"
|
||||
|
||||
msgid "Show Null Values"
|
||||
msgstr ""
|
||||
msgstr "NULL値を表示する"
|
||||
|
||||
msgid "Show Range Filter"
|
||||
msgstr "範囲フィルターを表示"
|
||||
@@ -11637,13 +11657,16 @@ msgid ""
|
||||
msgstr "スパークラインにY軸を表示します。手動設定されている場合はその最小/最大値を、そうでない場合はデータの最小/最大値を表示します。"
|
||||
|
||||
msgid "Show a draggable slider to control the visible range of the Y-axis."
|
||||
msgstr ""
|
||||
msgstr "Y軸の表示範囲を制御するための、ドラッグ可能なスライダーを表示します。"
|
||||
|
||||
msgid ""
|
||||
"Show a summary row of total aggregations: the selected metrics in "
|
||||
"aggregate mode, or the sum of numeric columns in raw records mode. Note "
|
||||
"that row limit does not apply to the result."
|
||||
msgstr ""
|
||||
"集計結果の要約行を表示します。"
|
||||
"集計モードでは選択されたメトリクス、未加工レコードモードでは数値列の合計が表示されます。"
|
||||
"なお、結果には行数の制限は適用されません。"
|
||||
|
||||
msgid "Show all columns"
|
||||
msgstr "すべての列を表示"
|
||||
@@ -11682,7 +11705,7 @@ msgid "Show entries per page"
|
||||
msgstr "1ページあたりの表示件数"
|
||||
|
||||
msgid "Show full range for time shift"
|
||||
msgstr ""
|
||||
msgstr "タイムシフトの全範囲を表示する"
|
||||
|
||||
msgid ""
|
||||
"Show hierarchical relationships of data, with the value represented by "
|
||||
@@ -11763,7 +11786,7 @@ msgstr "値を表示"
|
||||
msgid ""
|
||||
"Showcases a metric along with a comparison of value, change, and percent "
|
||||
"change for a selected time period."
|
||||
msgstr ""
|
||||
msgstr "選択した期間における指標と、その値、変化量、および変化率の比較を表示します。"
|
||||
|
||||
msgid ""
|
||||
"Showcases a single metric front-and-center. Big number is best used to "
|
||||
@@ -11888,7 +11911,7 @@ msgid "Solid"
|
||||
msgstr "実線"
|
||||
|
||||
msgid "Solid background"
|
||||
msgstr ""
|
||||
msgstr "単色の背景"
|
||||
|
||||
msgid "Some groups could not be resolved and are shown as IDs."
|
||||
msgstr "一部のグループを解決できなかったため、IDとして表示されています。"
|
||||
@@ -11900,12 +11923,13 @@ msgid "Some required filters on other tabs have values and will not be cleared"
|
||||
msgstr "他のタブの一部の必須フィルターには値が設定されており、クリアされません"
|
||||
|
||||
msgid "Some tables are not shown. Refine your search."
|
||||
msgstr ""
|
||||
msgstr "一部のテーブルは表示されていません。検索条件を絞り込んでください。"
|
||||
|
||||
msgid ""
|
||||
"Something went wrong loading the dashboard. Check the dev console for "
|
||||
"details."
|
||||
msgstr ""
|
||||
"ダッシュボードの読み込み中に問題が発生しました。詳細については開発者コンソールを確認してください。"
|
||||
|
||||
msgid "Something went wrong while saving the user info"
|
||||
msgstr "ユーザー情報の保存中に問題が発生しました"
|
||||
@@ -12283,7 +12307,7 @@ msgid "Success"
|
||||
msgstr "成功"
|
||||
|
||||
msgid "Success message"
|
||||
msgstr ""
|
||||
msgstr "成功メッセージ"
|
||||
|
||||
#, python-format
|
||||
msgid "Successfully changed %s!"
|
||||
@@ -12351,7 +12375,7 @@ msgid "Swap rows and columns"
|
||||
msgstr "行と列を入れ替え"
|
||||
|
||||
msgid "Sweep angle"
|
||||
msgstr ""
|
||||
msgstr "スイープ角度"
|
||||
|
||||
msgid ""
|
||||
"Swiss army knife for visualizing data. Choose between step, line, "
|
||||
@@ -12506,7 +12530,7 @@ msgid "Tag created"
|
||||
msgstr "タグを作成しました"
|
||||
|
||||
msgid "Tag description"
|
||||
msgstr ""
|
||||
msgstr "タグの説明"
|
||||
|
||||
msgid "Tag name"
|
||||
msgstr "タグ名"
|
||||
@@ -12628,7 +12652,7 @@ msgstr "メールに埋め込まれるテキスト"
|
||||
|
||||
#, python-format
|
||||
msgid "The %(key)s in metadata_cache_timeout must be a non-negative integer."
|
||||
msgstr ""
|
||||
msgstr "metadata_cache_timeout 内の %(key)s は、非負の整数でなければなりません。"
|
||||
|
||||
#, python-format
|
||||
msgid "The %s"
|
||||
@@ -12726,6 +12750,8 @@ msgid ""
|
||||
"The chart data is too large to download. Please try reducing the date "
|
||||
"range, limiting rows, or using fewer columns."
|
||||
msgstr ""
|
||||
"チャートのデータ量が大きすぎてダウンロードできません。"
|
||||
"日付範囲を狭めたり、行数を減らしたり、表示する列の数を減らしたりしてみてください。"
|
||||
|
||||
msgid "The chart is still loading. Please wait a moment and try again."
|
||||
msgstr "チャートはまだ読み込み中です。しばらく待ってから再試行してください。"
|
||||
@@ -12736,7 +12762,7 @@ msgid ""
|
||||
msgstr "このレポートの対象であるチャートは削除されました。チャートを復元するか、レポートを更新して有効なチャートを指定してください。"
|
||||
|
||||
msgid "The chat failed to load."
|
||||
msgstr ""
|
||||
msgstr "チャットが読み込めませんでした。"
|
||||
|
||||
msgid ""
|
||||
"The classic. Great for showing how much of a company each investor gets, "
|
||||
@@ -12819,7 +12845,7 @@ msgid ""
|
||||
msgstr "このレポートの対象であるダッシュボードは削除されました。ダッシュボードを復元するか、レポートを更新して有効なダッシュボードを指定してください。"
|
||||
|
||||
msgid "The dashboard you are looking for may have been deleted or moved."
|
||||
msgstr ""
|
||||
msgstr "探しているダッシュボードは、削除されたか、移動された可能性があります。"
|
||||
|
||||
msgid "The data source seems to have been deleted"
|
||||
msgstr "データソースが削除されたようです"
|
||||
@@ -13035,7 +13061,7 @@ msgstr "メトリックの最大値。設定は任意です。"
|
||||
msgid ""
|
||||
"The metadata_cache_timeout must be a mapping from string keys to non-"
|
||||
"negative integer values."
|
||||
msgstr ""
|
||||
msgstr "「metadata_cache_timeout」 は、文字列キーから非負の整数値へのマッピングでなければなりません。"
|
||||
|
||||
#, python-format
|
||||
msgid ""
|
||||
@@ -13212,7 +13238,7 @@ msgid "The query contains one or more malformed template parameters."
|
||||
msgstr "クエリに1つ以上の不正なテンプレートパラメータが含まれています。"
|
||||
|
||||
msgid "The query context datasource does not match the chart datasource"
|
||||
msgstr ""
|
||||
msgstr "クエリのコンテキストのデータソースが、チャートのデータソースと一致しません"
|
||||
|
||||
msgid "The query couldn't be loaded"
|
||||
msgstr "クエリを読み込めませんでした"
|
||||
@@ -13566,7 +13592,7 @@ msgid "There was an error fetching the filtered charts and dashboards:"
|
||||
msgstr "フィルタリングされたチャートおよびダッシュボードの取得中にエラーが発生しました:"
|
||||
|
||||
msgid "There was an error generating the permalink."
|
||||
msgstr ""
|
||||
msgstr "固定リンクの生成中にエラーが発生しました。"
|
||||
|
||||
msgid "There was an error loading groups."
|
||||
msgstr "グループの読み込み中にエラーが発生しました。"
|
||||
@@ -13662,7 +13688,7 @@ msgstr "選択したレイヤーの削除中に問題が発生しました: %s"
|
||||
|
||||
#, python-format
|
||||
msgid "There was an issue deleting the selected queries: %s"
|
||||
msgstr ""
|
||||
msgstr "選択したクエリの削除中に問題が発生しました:%s"
|
||||
|
||||
#, python-format
|
||||
msgid "There was an issue deleting the selected templates: %s"
|
||||
@@ -13699,7 +13725,7 @@ msgid "There was an issue exporting the selected dashboards"
|
||||
msgstr "選択したダッシュボードのエクスポート中に問題が発生しました"
|
||||
|
||||
msgid "There was an issue exporting the selected queries"
|
||||
msgstr ""
|
||||
msgstr "選択したクエリのエクスポート中に問題が発生しました"
|
||||
|
||||
msgid "There was an issue exporting the selected themes"
|
||||
msgstr "選択したテーマのエクスポート中に問題が発生しました"
|
||||
@@ -13840,7 +13866,7 @@ msgstr ""
|
||||
"注釈データを含むチャートに引き継ぐかどうかを制御します。"
|
||||
|
||||
msgid "This dashboard does not exist"
|
||||
msgstr ""
|
||||
msgstr "このダッシュボードは存在しません"
|
||||
|
||||
msgid "This dashboard is managed externally, and can't be edited in Superset"
|
||||
msgstr "このダッシュボードは外部で管理されているため、Superset では編集できません"
|
||||
@@ -14133,7 +14159,7 @@ msgid "Time Grain"
|
||||
msgstr "時間粒度"
|
||||
|
||||
msgid "Time Grain must be specified when using Time Comparison."
|
||||
msgstr ""
|
||||
msgstr "「時間比較」を使用する場合は、「時間粒度」を指定する必要があります。"
|
||||
|
||||
msgid "Time Granularity"
|
||||
msgstr "時間粒度"
|
||||
@@ -14402,6 +14428,9 @@ msgid ""
|
||||
" angle is a multiple of 90°, the chart is automatically re-centered to "
|
||||
"make use of the empty space."
|
||||
msgstr ""
|
||||
"チャートがカバーする総角度(度単位)。360°では完全な円が描かれ、180°では半円が描かれます。"
|
||||
"スイープ角度が180°以下で、開始角度が90°の倍数である場合、空きスペースを活用するために、"
|
||||
"チャートの中心位置が自動的に再設定されます。"
|
||||
|
||||
msgid "Total color"
|
||||
msgstr "合計の色"
|
||||
@@ -14426,7 +14455,7 @@ msgid "Transparent"
|
||||
msgstr "透明"
|
||||
|
||||
msgid "Transparent background"
|
||||
msgstr ""
|
||||
msgstr "透明な背景"
|
||||
|
||||
msgid "Transpose pivot"
|
||||
msgstr "ピボットを転置"
|
||||
@@ -14456,7 +14485,7 @@ msgid "Trigger Alert If..."
|
||||
msgstr "アラートの発生条件..."
|
||||
|
||||
msgid "Trigger now"
|
||||
msgstr ""
|
||||
msgstr "今すぐ実行"
|
||||
|
||||
msgid "True"
|
||||
msgstr "真 (True)"
|
||||
@@ -14561,7 +14590,7 @@ msgid "URL parameters"
|
||||
msgstr "URLパラメータ"
|
||||
|
||||
msgid "UUID to track the execution status"
|
||||
msgstr ""
|
||||
msgstr "実行状況を追跡するためのUUID"
|
||||
|
||||
msgid "Unable to calculate such a date delta"
|
||||
msgstr "指定された期間差を計算できません"
|
||||
@@ -14620,7 +14649,7 @@ msgstr "ダウンロード用ペイロードを生成できません"
|
||||
|
||||
#, python-format
|
||||
msgid "Unable to generate forecast: %(error)s"
|
||||
msgstr ""
|
||||
msgstr "予測を生成できませんでした:%(error)s"
|
||||
|
||||
msgid ""
|
||||
"Unable to identify temporal column for date range time comparison.Please "
|
||||
@@ -14631,7 +14660,7 @@ msgstr "期間比較のための時間列を特定できません。データセ
|
||||
msgid ""
|
||||
"Unable to interpret the time offset: %(offset)s. Use a relative time such"
|
||||
" as \"1 month ago\"."
|
||||
msgstr ""
|
||||
msgstr "時間オフセット %(offset)s を解釈できません。「1ヶ月前」などの相対的な時間を使用してください。"
|
||||
|
||||
msgid ""
|
||||
"Unable to load columns for the selected table. Please select a different "
|
||||
@@ -15501,7 +15530,7 @@ msgid "Whether to display bubbles on top of countries"
|
||||
msgstr "国の上にバブルを表示するかどうか"
|
||||
|
||||
msgid "Whether to display entries with null values in the hierarchy"
|
||||
msgstr ""
|
||||
msgstr "階層内でNULL値を持つエントリを表示するかどうか"
|
||||
|
||||
msgid "Whether to display in the chart"
|
||||
msgstr "チャートに表示するかどうか"
|
||||
@@ -15626,6 +15655,8 @@ msgid ""
|
||||
"Whether to sort tooltip by the selected metric in descending order. On "
|
||||
"stacked charts, values are shown in ascending order."
|
||||
msgstr ""
|
||||
"ツールチップを選択した指標に基づいて降順で並べ替えるかどうか。"
|
||||
"積み上げグラフでは、値は昇順で表示されます。"
|
||||
|
||||
msgid "Whether to truncate metrics"
|
||||
msgstr "メトリックを切り詰めるかどうか"
|
||||
@@ -15785,7 +15816,7 @@ msgid "Y-axis bounds"
|
||||
msgstr "Y軸の境界"
|
||||
|
||||
msgid "Y-axis range slider"
|
||||
msgstr ""
|
||||
msgstr "Y軸の範囲スライダー"
|
||||
|
||||
msgid "Y-scale interval"
|
||||
msgstr "Y軸の間隔"
|
||||
@@ -16021,26 +16052,36 @@ msgid ""
|
||||
"You must be a chart editor in order to delete. Please reach out to a "
|
||||
"chart editor to request modifications or edit access."
|
||||
msgstr ""
|
||||
"削除を行うには、チャート編集者である必要があります。"
|
||||
"変更依頼や編集権限の付与については、チャート編集者にお問い合わせください。"
|
||||
|
||||
msgid ""
|
||||
"You must be a chart editor in order to edit. Please reach out to a chart "
|
||||
"editor to request modifications or edit access."
|
||||
msgstr ""
|
||||
"編集を行うには、チャート編集者である必要があります。"
|
||||
"変更依頼や編集権限の付与については、チャート編集者にお問い合わせください。"
|
||||
|
||||
msgid ""
|
||||
"You must be a dashboard editor in order to delete. Please reach out to a "
|
||||
"dashboard editor to request modifications or edit access."
|
||||
msgstr ""
|
||||
"削除を行うには、ダッシュボード編集者である必要があります。"
|
||||
"変更や編集権限の付与をご希望の場合は、ダッシュボード編集者にお問い合わせください。"
|
||||
|
||||
msgid ""
|
||||
"You must be a dashboard editor in order to edit. Please reach out to a "
|
||||
"dashboard editor to request modifications or edit access."
|
||||
msgstr ""
|
||||
"編集を行うには、ダッシュボード編集者である必要があります。"
|
||||
"変更依頼や編集権限の付与については、ダッシュボード編集者にお問い合わせください。"
|
||||
|
||||
msgid ""
|
||||
"You must be a dataset editor in order to delete. Please reach out to a "
|
||||
"dataset editor to request modifications or edit access."
|
||||
msgstr ""
|
||||
"削除を行うには、データセット編集者である必要があります。"
|
||||
"変更の依頼や編集権限の付与については、データセット編集者にお問い合わせください。"
|
||||
|
||||
msgid ""
|
||||
"You must be a dataset editor in order to edit. Please reach out to a "
|
||||
@@ -16104,6 +16145,9 @@ msgid ""
|
||||
"into multiple dashboards) or raise the "
|
||||
"SUPERSET_DASHBOARD_POSITION_DATA_LIMIT config setting."
|
||||
msgstr ""
|
||||
"ダッシュボードが大きすぎて保存できません。シリアル化されたレイアウトの長さは %s ですが、制限値は %s です。"
|
||||
"ダッシュボードのサイズを縮小するか(たとえば複数のダッシュボードに分割するなど)"
|
||||
"SUPERSET_DASHBOARD_POSITION_DATA_LIMIT 設定値を上げてください。"
|
||||
|
||||
msgid "Your query could not be saved"
|
||||
msgstr "クエリを保存できませんでした"
|
||||
@@ -16877,7 +16921,7 @@ msgid ""
|
||||
msgstr "パーセンタイルは、1つ目が2つ目より小さい2つの数値のリストまたはタプルである必要があります"
|
||||
|
||||
msgid "permalink state not found"
|
||||
msgstr "パーマリンクの状態が見つかりません"
|
||||
msgstr "固定リンクの状態が見つかりません"
|
||||
|
||||
#. do-not-translate
|
||||
msgid "pivoted_xlsx"
|
||||
@@ -16906,7 +16950,7 @@ msgid "quarter"
|
||||
msgstr "四半期"
|
||||
|
||||
msgid "queries"
|
||||
msgstr ""
|
||||
msgstr "クエリ"
|
||||
|
||||
msgid "query"
|
||||
msgstr "クエリ"
|
||||
@@ -16952,7 +16996,7 @@ msgid "seconds"
|
||||
msgstr "秒"
|
||||
|
||||
msgid "semantic layer"
|
||||
msgstr ""
|
||||
msgstr "セマンティックレイヤー"
|
||||
|
||||
msgid "series"
|
||||
msgstr "系列"
|
||||
|
||||
@@ -2017,13 +2017,26 @@ def _process_datetime_column(
|
||||
|
||||
# Parse with or without format (suppress warning if no format)
|
||||
if format_to_use:
|
||||
df[col.col_label] = pd.to_datetime(
|
||||
converted = pd.to_datetime(
|
||||
df[col.col_label],
|
||||
utc=False,
|
||||
format=format_to_use,
|
||||
errors="coerce",
|
||||
exact=False,
|
||||
)
|
||||
# A format that coerces every non-null value to NaT is a mismatch
|
||||
# (e.g. an epoch-millis column that inherited a '%Y' string format
|
||||
# when used as a chart's granularity). Assigning it would silently
|
||||
# blank the whole column, so keep the original values instead.
|
||||
if df[col.col_label].notna().any() and not converted.notna().any():
|
||||
logger.warning(
|
||||
"Datetime format %s coerced every value of column %s to NaT; "
|
||||
"keeping the original values",
|
||||
format_to_use,
|
||||
col.col_label,
|
||||
)
|
||||
else:
|
||||
df[col.col_label] = converted
|
||||
else:
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings("ignore", message=".*Could not infer format.*")
|
||||
|
||||
@@ -273,6 +273,22 @@ def test_normalize_dttm_col() -> None:
|
||||
assert df["__time"].astype(str).tolist() == ["2017-07-01"]
|
||||
|
||||
|
||||
def test_normalize_dttm_col_mismatched_format_keeps_values() -> None:
|
||||
"""A datetime format that coerces every value to NaT is a mismatch (e.g. an
|
||||
epoch-millis column that inherited a ``%Y`` string format when used as a
|
||||
chart's granularity); applying it would silently blank the whole column, so
|
||||
the original values are kept instead of being nulled. Regression for the
|
||||
Samples pane showing N/A for such columns."""
|
||||
df = pd.DataFrame({"year": [1136073600000, 473385600000]}) # epoch ms
|
||||
before = df["year"].tolist()
|
||||
|
||||
normalize_dttm_col(df, (DateColumn(col_label="year", timestamp_format="%Y"),))
|
||||
|
||||
# not blanked to NaT/None
|
||||
assert df["year"].notna().all()
|
||||
assert df["year"].tolist() == before
|
||||
|
||||
|
||||
def test_normalize_dttm_col_epoch_seconds() -> None:
|
||||
"""Test conversion of epoch seconds."""
|
||||
df = pd.DataFrame(
|
||||
|
||||
Reference in New Issue
Block a user