Compare commits

...

6 Commits

Author SHA1 Message Date
dependabot[bot]
139b21f593 chore(deps): bump github/codeql-action/init from 4.37.2 to 4.37.3
Bumps [github/codeql-action/init](https://github.com/github/codeql-action) from 4.37.2 to 4.37.3.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](e0647621c2...e4fba868fa)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.37.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-29 07:50:54 +00:00
Evan Rusackas
dac69f9bcd feat(pie): geometric recentering and scaling for partial arcs (#42151)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 22:27:11 -07:00
Rehan Islam
d984fb08a4 feat(explore): add download control to standalone charts (#42238) 2026-07-28 22:06:45 -07:00
jesperct
8be430014e fix(dashboard): make Clear All clear filters that have default values (#42111) 2026-07-28 22:05:51 -07:00
aikawa-ohno
00c60bf2de fix(i18n): Update Japanese translations (#42444) 2026-07-28 17:04:47 -07:00
Evan Rusackas
1d4c3a2adb fix(core): don't blank a datetime column when its format coerces every value to NaT (#42405)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-07-28 15:18:53 -07:00
20 changed files with 1469 additions and 738 deletions

View File

@@ -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.

View 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,

View File

@@ -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 (01).
*/
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 (0360).
* @param sweptAngle - The swept angle of the arc in degrees (10360).
* @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,

View File

@@ -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';

View File

@@ -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);
});

View File

@@ -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',

View File

@@ -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: {},
});

View File

@@ -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;

View File

@@ -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();
});
});

View File

@@ -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);
});
});

View File

@@ -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;

View File

@@ -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>
);

View File

@@ -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];
};

View File

@@ -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,
}),
);
});

View File

@@ -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,
};
};

View File

@@ -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', () => {

View File

@@ -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;

View File

@@ -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 "系列"

View File

@@ -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.*")

View File

@@ -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(