Compare commits

...

13 Commits

Author SHA1 Message Date
Beto Dealmeida
48958cc8ce Address comments 2026-06-29 15:59:14 -04:00
Beto Dealmeida
7dac6c1d4f Also disable drill-to-detail 2026-06-29 12:11:14 -04:00
Beto Dealmeida
a1297d10ac feat(semantic layers): don't show samples tab in explore 2026-06-26 17:12:37 -04:00
Brett Smith
35365d639d fix(deckgl): render legend swatch as a coloured box, not an emoji glyph (#40784)
Signed-off-by: Brett Smith <brett@pukekos.co.nz>
Co-authored-by: Joe Li <joe@preset.io>
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
Co-authored-by: Damian Pendrak <dpendrak@gmail.com>
2026-06-26 10:07:29 +02:00
Michael Gerber
7e17c70cba fix: Filter null child names in treeBuilder utility (#31477)
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-25 22:03:45 -07:00
SkinnyPigeon
0d43c2c12c feat(reports): trigger alerts (#41336) 2026-06-25 22:01:39 -07:00
Evan Rusackas
7410ff73c0 ci: schedule a weekly Docker image rebuild against the latest release (#40426)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-25 17:15:31 -07:00
Debabrata Saha
f08f068240 fix(sqllab): replace native prompt with modal for tab rename (#41329)
Signed-off-by: debabsah <debasaha.uw@gmail.com>
2026-06-25 17:15:07 -07:00
Greg Neighbors
2b09b6bc1d feat(mcp): list_charts accepts dashboards filter (#40397)
Co-authored-by: gkneighb <26003+gkneighb@users.noreply.github.com>
Co-authored-by: Greg Neighbors <gregneighbors@Gregs-Air-2.lan>
2026-06-25 17:14:11 -07:00
Özgür YÜKSEL
d763255e15 chore(i18n): update Turkish translations messages.po (#39064)
Co-authored-by: Özgür YÜKSEL <o.yuksel@gardiyan.com>
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-25 17:11:40 -07:00
Evan Rusackas
8fed514e79 fix(dashboard): keep pasted filter values outside the loaded page (#41136)
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-25 15:33:57 -07:00
Evan Rusackas
c94bc7178f fix(world-map): rely on built-in highlightOnHover to reset hover highlight (#41158)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-25 15:33:46 -07:00
Evan Rusackas
95ecdd3753 fix(menu): highlight active nav tab in non-English locales (#41183)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-25 15:33:30 -07:00
51 changed files with 6883 additions and 3782 deletions

View File

@@ -0,0 +1,177 @@
name: Scheduled Docker image refresh
# Re-runs the Docker image build against the latest published release on a
# weekly cadence. The code being built doesn't change — but the base image
# layers (python:*-slim-trixie and its OS packages) DO get upstream
# security patches between Superset releases, and those patches don't
# reach our published images unless we rebuild.
#
# Without this workflow, `apache/superset:<latest>` lags behind upstream
# Debian/Python base patches by whatever interval falls between Superset
# releases (typically 36 weeks). With it, the lag drops to at most one
# week regardless of release cadence.
#
# This is a security-hygiene cron, not a release. It overwrites the
# existing tags for the most recent release (e.g. `apache/superset:5.0.0`
# and `apache/superset:latest`) with bit-for-bit-equivalent contents
# layered on a refreshed base. Image digests change; everything users
# actually pin against (image content, code, deps) does not.
on:
schedule:
# Mondays at 06:00 UTC — gives the weekend for upstream patches to
# settle and surfaces failures at the start of the work week so a
# human can react.
- cron: "0 6 * * 1"
# Manual trigger so operators can force a refresh on demand (e.g.
# immediately after a high-severity base-image CVE drops).
workflow_dispatch: {}
permissions:
contents: read
# Serialize with itself and with the release publisher (tag-release.yml) —
# both push to the same Docker Hub tags, so a race could end with stale
# layers winning. Both workflows must declare this group for the lock to work.
concurrency:
group: docker-publish-latest-release
cancel-in-progress: false
jobs:
config:
runs-on: ubuntu-24.04
outputs:
has-secrets: ${{ steps.check.outputs.has-secrets }}
latest-release: ${{ steps.latest.outputs.tag }}
force-latest: ${{ steps.latest.outputs.force-latest }}
steps:
- name: Check for Docker Hub secrets
id: check
shell: bash
run: |
if [ -n "${DOCKERHUB_USER}" ]; then
echo "has-secrets=1" >> "$GITHUB_OUTPUT"
fi
env:
DOCKERHUB_USER: ${{ (secrets.DOCKERHUB_USER != '' && secrets.DOCKERHUB_TOKEN != '') || '' }}
- name: Look up latest published release
id: latest
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPOSITORY: ${{ github.repository }}
run: |
# `releases/latest` returns the latest non-prerelease, non-draft
# release — which is exactly what `apache/superset:latest`
# should reflect.
TAG=$(gh api "repos/${REPOSITORY}/releases/latest" --jq .tag_name)
if [ -z "$TAG" ] || [ "$TAG" = "null" ]; then
echo "::error::Could not determine latest release tag"
exit 1
fi
echo "Latest release: $TAG"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
# Only move `:latest` when the release flagged "latest" is also the
# highest semver release. This guards against a mis-click leaving an
# older maintenance release (e.g. a 5.x patch shipped after 6.0 GA)
# marked latest, which would otherwise roll `:latest` back a major
# version on the next cron run. If it isn't the newest, we still
# refresh that release's own version tag but leave `:latest` alone.
HIGHEST=$(gh api --paginate "repos/${REPOSITORY}/releases" \
--jq '.[] | select(.draft|not) | select(.prerelease|not) | .tag_name' \
| sed 's/^v//' | sort -V | tail -n1)
if [ "${TAG#v}" = "$HIGHEST" ]; then
echo "force-latest=1" >> "$GITHUB_OUTPUT"
else
echo "::warning::Latest-flagged release $TAG is not the highest semver ($HIGHEST); refreshing its version tag but leaving :latest untouched"
fi
docker-rebuild:
needs: config
if: needs.config.outputs.has-secrets == '1'
name: docker-rebuild
runs-on: ubuntu-24.04
strategy:
# Mirror the same matrix the release publisher uses so every variant
# operators consume from Docker Hub gets the refreshed base.
matrix:
build_preset: ["dev", "lean", "py310", "websocket", "dockerize", "py311", "py312"]
fail-fast: false
steps:
- name: "Checkout release tag: ${{ needs.config.outputs.latest-release }}"
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ needs.config.outputs.latest-release }}
fetch-depth: 0
persist-credentials: false
- name: Setup Docker Environment
uses: ./.github/actions/setup-docker
with:
dockerhub-user: ${{ secrets.DOCKERHUB_USER }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
install-docker-compose: "false"
build: "true"
- name: Use Node.js 20
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 20
- name: Setup supersetbot
uses: ./.github/actions/setup-supersetbot/
- name: Rebuild and push
env:
DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUILD_PRESET: ${{ matrix.build_preset }}
LATEST_RELEASE: ${{ needs.config.outputs.latest-release }}
FORCE_LATEST_FLAG: ${{ needs.config.outputs.force-latest == '1' && '--force-latest' || '' }}
run: |
# Reuses the same supersetbot invocation as the release
# publisher (`tag-release.yml`), so the resulting tags are
# identical to what a manual release dispatch would produce —
# just with a freshly-pulled base image layer underneath.
# `--force-latest` is only passed when the config job confirmed the
# fetched release is the newest one (see FORCE_LATEST_FLAG above).
supersetbot docker \
--push \
--preset "$BUILD_PRESET" \
--context release \
--context-ref "$LATEST_RELEASE" \
$FORCE_LATEST_FLAG \
--platform "linux/arm64" \
--platform "linux/amd64"
# The whole point of this cron is catching base-image CVEs, so a silent
# failure is the expensive case — a red X in the Actions tab nobody is
# watching on a Monday. File a tracked issue when any rebuild leg fails so
# a missed security refresh surfaces instead of sitting unnoticed.
notify-on-failure:
needs: [config, docker-rebuild]
if: failure() && needs.config.outputs.has-secrets == '1'
runs-on: ubuntu-24.04
permissions:
contents: read
issues: write
steps:
- name: Open a tracking issue
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPOSITORY: ${{ github.repository }}
LATEST_RELEASE: ${{ needs.config.outputs.latest-release }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
gh issue create \
--repo "$REPOSITORY" \
--title "Scheduled Docker image refresh failed for ${LATEST_RELEASE}" \
--label "infra:container" \
--label "bug" \
--body "The weekly Docker base-image refresh failed for release \`${LATEST_RELEASE}\`. Published images may be missing upstream base-layer security patches until this is resolved.
Failed run: ${RUN_URL}"

View File

@@ -24,6 +24,12 @@ on:
permissions:
contents: read
# Serialize with the scheduled Docker image refresh — both workflows push
# to the same Docker Hub tags and must not race on apache/superset:latest.
concurrency:
group: docker-publish-latest-release
cancel-in-progress: false
jobs:
config:
runs-on: ubuntu-24.04

View File

@@ -8438,9 +8438,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8458,9 +8455,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8478,9 +8472,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8498,9 +8489,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8518,9 +8506,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8538,9 +8523,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8558,9 +8540,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8578,9 +8557,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [

View File

@@ -1146,6 +1146,127 @@ test('pasting an non-existent option should not add it if allowNewOptions is fal
expect(await findAllSelectOptions()).toHaveLength(0);
});
// Reference for the bug this tests: https://github.com/apache/superset/issues/32645
// Dashboard filters with "Dynamically search all filter values" only load a
// page of options client-side, so a pasted value outside that page used to be
// silently dropped. allowNewOptionsOnPaste keeps such values so the filter can
// still apply them.
test('keeps pasted values outside loaded options when allowNewOptionsOnPaste is true', async () => {
const onChange = jest.fn();
render(
<Select
{...defaultProps}
mode="multiple"
allowNewOptions={false}
allowNewOptionsOnPaste
onChange={onChange}
/>,
);
const input = getElementByClassName('.ant-select-selection-search-input');
const paste = createEvent.paste(input, {
clipboardData: {
// Liam is a loaded option; OutsideValue is not in the loaded page.
getData: () => 'Liam,OutsideValue',
},
});
fireEvent(input, paste);
await waitFor(() => {
const values = [
...getElementsByClassName('.ant-select-selection-item'),
].map(value => value.textContent);
// The paste handler appends, so the loaded option resolves first.
expect(values).toEqual(['Liam', 'OutsideValue']);
});
// Assert the unloaded value actually reaches the change handler (the value
// that gets applied to the filter query), not just the rendered label.
expect(onChange).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ value: 'OutsideValue' }),
]),
expect.anything(),
);
});
test('trims whitespace around pasted comma-separated values', async () => {
const onChange = jest.fn();
render(
<Select
{...defaultProps}
mode="multiple"
allowNewOptions={false}
allowNewOptionsOnPaste
onChange={onChange}
/>,
);
const input = getElementByClassName('.ant-select-selection-search-input');
const paste = createEvent.paste(input, {
clipboardData: {
// Note the space after the comma — it must not leak into the value.
getData: () => 'Liam, OutsideValue',
},
});
fireEvent(input, paste);
await waitFor(() => {
const values = [
...getElementsByClassName('.ant-select-selection-item'),
].map(value => value.textContent);
expect(values).toEqual(['Liam', 'OutsideValue']);
});
expect(onChange).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ value: 'OutsideValue' }),
]),
expect.anything(),
);
});
test('does not create an empty option when pasting blank text', async () => {
const onChange = jest.fn();
render(
<Select
{...defaultProps}
mode="multiple"
allowNewOptions={false}
allowNewOptionsOnPaste
onChange={onChange}
/>,
);
const input = getElementByClassName('.ant-select-selection-search-input');
const paste = createEvent.paste(input, {
clipboardData: {
getData: () => ' ',
},
});
fireEvent(input, paste);
await waitFor(() => {
const values = [
...getElementsByClassName('.ant-select-selection-item'),
].map(value => value.textContent);
expect(values).toEqual([]);
});
// No empty-string value should ever reach the handler.
onChange.mock.calls.forEach(([value]) => {
expect(value).not.toContain('');
});
});
test('drops pasted values outside loaded options when allowNewOptionsOnPaste is false', async () => {
render(<Select {...defaultProps} mode="multiple" allowNewOptions={false} />);
const input = getElementByClassName('.ant-select-selection-search-input');
const paste = createEvent.paste(input, {
clipboardData: {
getData: () => 'Liam,OutsideValue',
},
});
fireEvent(input, paste);
await waitFor(() => {
const values = [
...getElementsByClassName('.ant-select-selection-item'),
].map(value => value.textContent);
expect(values).toEqual(['Liam']);
});
});
test('does not fire onChange if the same value is selected in single mode', async () => {
const onChange = jest.fn();
render(<Select {...defaultProps} onChange={onChange} />);

View File

@@ -91,6 +91,7 @@ const Select = forwardRef(
className,
allowClear,
allowNewOptions = false,
allowNewOptionsOnPaste = false,
allowSelectAll = true,
ariaLabel,
autoClearSearchValue = false,
@@ -692,20 +693,34 @@ const Select = forwardRef(
}
} else {
const token = tokenSeparators.find(token => pastedText.includes(token));
const array = token ? uniq(pastedText.split(token)) : [pastedText];
const array = token
? uniq(
pastedText
.split(token)
.map(item => item.trim())
.filter(Boolean),
)
: [pastedText.trim()].filter(Boolean);
const newOptions: SelectOptionsType = [];
// When `allowNewOptionsOnPaste` is set, accept pasted values that are
// not in the loaded options even if `allowNewOptions` is false. The
// full option set is searched server-side and only partially loaded
// client-side, so a pasted value can legitimately exist in the dataset
// but fall outside the loaded page.
const keepUnknownValues = allowNewOptions || allowNewOptionsOnPaste;
const values = array
.map(item => {
const option = getOption(item, fullSelectOptions, true);
if (!option && allowNewOptions) {
if (!option && keepUnknownValues) {
const newOption = {
label: item,
value: item,
isNewOption: true,
};
newOptions.push(newOption);
return labelInValue ? { label: item, value: item } : item;
}
return getPastedTextValue(item);
})

View File

@@ -88,6 +88,18 @@ export interface BaseSelectProps extends AntdExposedProps {
* False by default.
* */
allowNewOptions?: boolean;
/**
* Accept values pasted into the Select even when they are not part of the
* currently loaded options and `allowNewOptions` is false. Useful for
* selects whose full option set is searched server-side and only partially
* loaded on the client (e.g. dashboard filters with "Dynamically search all
* filter values"), where a pasted value can legitimately exist in the
* dataset but fall outside the loaded page.
* Only applies to multi-select paste; single-select paste resolves through
* `allowNewOptions` and ignores this flag.
* False by default.
* */
allowNewOptionsOnPaste?: boolean;
/**
* It adds the aria-label tag for accessibility standards.
* Must be plain English and localized.

View File

@@ -305,36 +305,16 @@ function WorldMap(element: HTMLElement, props: WorldMapProps): void {
key: JSON.stringify,
},
done: datamap => {
// Hover highlighting and its reset are handled entirely by Datamaps'
// built-in highlightOnHover, which saves each country's original fill on
// mouseover and restores it on mouseout. Adding our own mouseover/mouseout
// fill handlers here creates a second, competing restore path whose
// execution order is browser-timing-dependent, which left the highlight
// stuck on Chrome/Edge (see #37761).
datamap.svg
.selectAll('.datamaps-subunit')
.on('contextmenu', handleContextMenu)
.on('click', handleClick)
// Use namespaced events to avoid overriding Datamaps' default tooltip handlers
.on('mouseover.fillPreserve', function onMouseOver() {
if (inContextMenu) {
return;
}
const element = d3.select(this);
const classes = element.attr('class') || '';
const countryId = classes.split(' ')[1];
const countryData = mapData[countryId];
const originalFill =
(countryData && countryData.fillColor) || theme.colorBorder;
// Store original fill color for restoration
element.attr('data-original-fill', originalFill);
})
.on('mouseout.fillPreserve', function onMouseOut() {
if (inContextMenu) {
return;
}
const element = d3.select(this);
const originalFill = element.attr('data-original-fill');
// Restore the original fill color (data-based or default no-data color)
if (originalFill) {
element.style('fill', originalFill);
element.attr('data-original-fill', null);
}
});
.on('click', handleClick);
},
});

View File

@@ -58,15 +58,6 @@ interface WorldMapProps {
formatter: ValueFormatter;
}
type MouseEventHandler = (this: HTMLElement) => void;
interface MockD3Selection {
attr: jest.Mock;
style: jest.Mock;
classed: jest.Mock;
selectAll: jest.Mock;
}
// Mock Datamap
const mockBubbles = jest.fn();
const mockUpdateChoropleth = jest.fn();
@@ -157,244 +148,36 @@ afterEach(() => {
document.body.removeChild(container);
});
test('sets up mouseover and mouseout handlers on countries', () => {
test('relies on Datamaps highlightOnHover without adding conflicting fill handlers', () => {
// Regression test for #37761. The hover highlight got stuck on Chrome/Edge
// because hand-written mouseover/mouseout handlers competed with Datamaps'
// built-in highlightOnHover restore path, and the winning path was
// browser-timing-dependent. The chart should rely on the single built-in
// path and register no custom fill-restoring hover handlers on the countries.
WorldMap(container, baseProps);
expect(mockSvg.selectAll).toHaveBeenCalledWith('.datamaps-subunit');
const onCalls = mockSvg.on.mock.calls;
const geographyConfig = lastDatamapConfig?.geographyConfig as Record<
string,
unknown
>;
expect(geographyConfig?.highlightOnHover).toBe(true);
// Find mouseover and mouseout handler registrations (namespaced events)
const hasMouseover = onCalls.some(
call => call[0] === 'mouseover.fillPreserve',
const hoverHandlers = mockSvg.on.mock.calls.filter((call: [string]) =>
/^mouse(over|out)/.test(call[0]),
);
const hasMouseout = onCalls.some(call => call[0] === 'mouseout.fillPreserve');
expect(hasMouseover).toBe(true);
expect(hasMouseout).toBe(true);
expect(hoverHandlers).toEqual([]);
});
test('stores original fill color on mouseover', () => {
// Create a mock DOM element with d3 selection capabilities
const mockElement = document.createElement('path');
mockElement.setAttribute('class', 'datamaps-subunit USA');
mockElement.style.fill = 'rgb(100, 150, 200)';
container.appendChild(mockElement);
test('disables Datamaps highlightOnHover while the context menu is open', () => {
// Companion to the regression guard above: when the context menu is open we
// pass highlightOnHover: false so hover highlighting is suppressed at init.
WorldMap(container, { ...baseProps, inContextMenu: true });
let mouseoverHandler: MouseEventHandler | null = null;
// Mock d3.select to return the mock element
const mockD3Selection: MockD3Selection = {
attr: jest.fn((attrName: string, value?: string) => {
if (value !== undefined) {
mockElement.setAttribute(attrName, value);
} else {
return mockElement.getAttribute(attrName);
}
return mockD3Selection;
}),
style: jest.fn((styleName: string, value?: string) => {
if (value !== undefined) {
mockElement.style[styleName as any] = value;
} else {
return mockElement.style[styleName as any];
}
return mockD3Selection;
}),
classed: jest.fn().mockReturnThis(),
selectAll: jest.fn().mockReturnValue({ remove: jest.fn() }),
};
jest.spyOn(d3 as any, 'select').mockReturnValue(mockD3Selection as any);
// Capture the mouseover handler (namespaced event)
mockSvg.on.mockImplementation((event: string, handler: MouseEventHandler) => {
if (event === 'mouseover.fillPreserve') {
mouseoverHandler = handler;
}
return mockSvg;
});
WorldMap(container, baseProps);
// Simulate mouseover
if (mouseoverHandler) {
(mouseoverHandler as MouseEventHandler).call(mockElement);
}
// Verify that data-original-fill attribute was set
expect(mockD3Selection.attr).toHaveBeenCalledWith(
'data-original-fill',
expect.any(String),
);
});
test('restores original fill color on mouseout for country with data', () => {
const mockElement = document.createElement('path');
mockElement.setAttribute('class', 'datamaps-subunit USA');
mockElement.style.fill = 'rgb(100, 150, 200)';
mockElement.setAttribute('data-original-fill', 'rgb(100, 150, 200)');
container.appendChild(mockElement);
let mouseoutHandler: MouseEventHandler | null = null;
const mockD3Selection: MockD3Selection = {
attr: jest.fn((attrName: string, value?: string | null) => {
if (value !== undefined) {
if (value === null) {
mockElement.removeAttribute(attrName);
} else {
mockElement.setAttribute(attrName, value);
}
return mockD3Selection;
}
return mockElement.getAttribute(attrName);
}),
style: jest.fn((styleName: string, value?: string) => {
if (value !== undefined) {
mockElement.style[styleName as any] = value;
}
return mockElement.style[styleName as any] || mockD3Selection;
}),
classed: jest.fn().mockReturnThis(),
selectAll: jest.fn().mockReturnValue({ remove: jest.fn() }),
};
jest.spyOn(d3 as any, 'select').mockReturnValue(mockD3Selection as any);
// Capture the mouseout handler (namespaced event)
mockSvg.on.mockImplementation((event: string, handler: MouseEventHandler) => {
if (event === 'mouseout.fillPreserve') {
mouseoutHandler = handler;
}
return mockSvg;
});
WorldMap(container, baseProps);
// Simulate mouseout
if (mouseoutHandler) {
(mouseoutHandler as MouseEventHandler).call(mockElement);
}
// Verify that original fill was restored
expect(mockD3Selection.style).toHaveBeenCalledWith(
'fill',
'rgb(100, 150, 200)',
);
expect(mockD3Selection.attr).toHaveBeenCalledWith('data-original-fill', null);
});
test('restores default fill color on mouseout for country with no data', () => {
const mockElement = document.createElement('path');
mockElement.setAttribute('class', 'datamaps-subunit XXX');
mockElement.style.fill = '#e0e0e0'; // Default border color
mockElement.setAttribute('data-original-fill', '#e0e0e0');
container.appendChild(mockElement);
let mouseoutHandler: MouseEventHandler | null = null;
const mockD3Selection: MockD3Selection = {
attr: jest.fn((attrName: string, value?: string | null) => {
if (value !== undefined) {
if (value === null) {
mockElement.removeAttribute(attrName);
} else {
mockElement.setAttribute(attrName, value);
}
return mockD3Selection;
}
return mockElement.getAttribute(attrName);
}),
style: jest.fn((styleName: string, value?: string) => {
if (value !== undefined) {
mockElement.style[styleName as any] = value;
}
return mockElement.style[styleName as any] || mockD3Selection;
}),
classed: jest.fn().mockReturnThis(),
selectAll: jest.fn().mockReturnValue({ remove: jest.fn() }),
};
jest.spyOn(d3 as any, 'select').mockReturnValue(mockD3Selection as any);
// Capture the mouseout handler (namespaced event)
mockSvg.on.mockImplementation((event: string, handler: MouseEventHandler) => {
if (event === 'mouseout.fillPreserve') {
mouseoutHandler = handler;
}
return mockSvg;
});
WorldMap(container, baseProps);
// Simulate mouseout
if (mouseoutHandler) {
(mouseoutHandler as MouseEventHandler).call(mockElement);
}
// Verify that default fill was restored (no-data color)
expect(mockD3Selection.style).toHaveBeenCalledWith('fill', '#e0e0e0');
expect(mockD3Selection.attr).toHaveBeenCalledWith('data-original-fill', null);
});
test('does not handle mouse events when inContextMenu is true', () => {
const propsWithContextMenu = {
...baseProps,
inContextMenu: true,
};
const mockElement = document.createElement('path');
mockElement.setAttribute('class', 'datamaps-subunit USA');
mockElement.style.fill = 'rgb(100, 150, 200)';
container.appendChild(mockElement);
let mouseoverHandler: MouseEventHandler | null = null;
let mouseoutHandler: MouseEventHandler | null = null;
const mockD3Selection: MockD3Selection = {
attr: jest.fn(() => mockD3Selection),
style: jest.fn(() => mockD3Selection),
classed: jest.fn().mockReturnThis(),
selectAll: jest.fn().mockReturnValue({ remove: jest.fn() }),
};
jest.spyOn(d3 as any, 'select').mockReturnValue(mockD3Selection as any);
// Capture namespaced event handlers
mockSvg.on.mockImplementation((event: string, handler: MouseEventHandler) => {
if (event === 'mouseover.fillPreserve') {
mouseoverHandler = handler;
}
if (event === 'mouseout.fillPreserve') {
mouseoutHandler = handler;
}
return mockSvg;
});
WorldMap(container, propsWithContextMenu);
// Simulate mouseover and mouseout
if (mouseoverHandler) {
(mouseoverHandler as MouseEventHandler).call(mockElement);
}
if (mouseoutHandler) {
(mouseoutHandler as MouseEventHandler).call(mockElement);
}
// When inContextMenu is true, handlers should exit early without modifying anything
// We verify this by checking that attr and style weren't called to change fill
const attrCalls = mockD3Selection.attr.mock.calls;
const fillChangeCalls = attrCalls.filter(
(call: [string, unknown]) =>
call[0] === 'data-original-fill' && call[1] !== undefined,
);
const styleCalls = mockD3Selection.style.mock.calls;
const fillStyleChangeCalls = styleCalls.filter(
(call: [string, unknown]) => call[0] === 'fill' && call[1] !== undefined,
);
// The handlers should return early, so no state changes
expect(fillChangeCalls.length).toBe(0);
expect(fillStyleChangeCalls.length).toBe(0);
const geographyConfig = lastDatamapConfig?.geographyConfig as Record<
string,
unknown
>;
expect(geographyConfig?.highlightOnHover).toBe(false);
});
test('does not throw error when onContextMenu is undefined', () => {

View File

@@ -92,6 +92,20 @@ const config: ControlPanelConfig = {
},
},
],
[
{
name: 'show_null_values',
config: {
type: 'CheckboxControl',
label: t('Show Null Values'),
default: true,
renderTrigger: true,
description: t(
'Whether to display entries with null values in the hierarchy',
),
},
},
],
[
{
name: 'label_type',

View File

@@ -186,6 +186,7 @@ export default function transformProps(
showLabels,
showLabelsThreshold,
showTotal,
showNullValues,
sliceId,
} = formData;
const {
@@ -251,6 +252,7 @@ export default function transformProps(
columnLabels,
metricLabel,
secondaryMetricLabel,
!showNullValues,
);
const totalValue = treeData.reduce(
(result, treeNode) => result + treeNode.value,

View File

@@ -36,6 +36,7 @@ export function treeBuilder(
groupBy: string[],
metric: string,
secondaryMetric?: string,
filterNullNames?: boolean,
): TreeNode[] {
const [curGroupBy, ...restGroupby] = groupBy;
const curData = _groupBy(data, curGroupBy);
@@ -63,6 +64,7 @@ export function treeBuilder(
restGroupby,
metric,
secondaryMetric,
filterNullNames,
);
const metricValue = children.reduce(
(prev, cur) => prev + (cur.value as number),
@@ -74,9 +76,12 @@ export function treeBuilder(
0,
)
: metricValue;
const validChildren = filterNullNames
? children.filter(child => child.name !== null)
: children;
result.push({
name,
children,
children: validChildren,
value: metricValue,
secondaryValue,
groupBy: curGroupBy,

View File

@@ -271,4 +271,244 @@ describe('test treeBuilder', () => {
},
]);
});
test('include null values', () => {
const tree = treeBuilder(
[
...data,
{
foo: 'a-2',
bar: null,
count: 2,
count2: 3,
},
],
['foo', 'bar'],
'count',
);
expect(tree).toEqual([
{
children: [
{
groupBy: 'bar',
name: 'a',
secondaryValue: 2,
value: 2,
},
],
groupBy: 'foo',
name: 'a-1',
secondaryValue: 2,
value: 2,
},
{
children: [
{
groupBy: 'bar',
name: 'a',
secondaryValue: 2,
value: 2,
},
{
groupBy: 'bar',
name: null,
secondaryValue: 2,
value: 2,
},
],
groupBy: 'foo',
name: 'a-2',
secondaryValue: 4,
value: 4,
},
{
children: [
{
groupBy: 'bar',
name: 'b',
secondaryValue: 2,
value: 2,
},
],
groupBy: 'foo',
name: 'b-1',
secondaryValue: 2,
value: 2,
},
{
children: [
{
groupBy: 'bar',
name: 'b',
secondaryValue: 2,
value: 2,
},
],
groupBy: 'foo',
name: 'b-2',
secondaryValue: 2,
value: 2,
},
{
children: [
{
groupBy: 'bar',
name: 'c',
secondaryValue: 2,
value: 2,
},
],
groupBy: 'foo',
name: 'c-1',
secondaryValue: 2,
value: 2,
},
{
children: [
{
groupBy: 'bar',
name: 'c',
secondaryValue: 2,
value: 2,
},
],
groupBy: 'foo',
name: 'c-2',
secondaryValue: 2,
value: 2,
},
{
children: [
{
groupBy: 'bar',
name: 'd',
secondaryValue: 2,
value: 2,
},
],
groupBy: 'foo',
name: 'd-1',
secondaryValue: 2,
value: 2,
},
]);
});
test('filter null values', () => {
const tree = treeBuilder(
[
...data,
{
foo: 'a-2',
bar: null,
count: 2,
count2: 3,
},
],
['foo', 'bar'],
'count',
undefined,
true,
);
expect(tree).toEqual([
{
children: [
{
groupBy: 'bar',
name: 'a',
secondaryValue: 2,
value: 2,
},
],
groupBy: 'foo',
name: 'a-1',
secondaryValue: 2,
value: 2,
},
{
children: [
{
groupBy: 'bar',
name: 'a',
secondaryValue: 2,
value: 2,
},
],
groupBy: 'foo',
name: 'a-2',
secondaryValue: 4,
value: 4,
},
{
children: [
{
groupBy: 'bar',
name: 'b',
secondaryValue: 2,
value: 2,
},
],
groupBy: 'foo',
name: 'b-1',
secondaryValue: 2,
value: 2,
},
{
children: [
{
groupBy: 'bar',
name: 'b',
secondaryValue: 2,
value: 2,
},
],
groupBy: 'foo',
name: 'b-2',
secondaryValue: 2,
value: 2,
},
{
children: [
{
groupBy: 'bar',
name: 'c',
secondaryValue: 2,
value: 2,
},
],
groupBy: 'foo',
name: 'c-1',
secondaryValue: 2,
value: 2,
},
{
children: [
{
groupBy: 'bar',
name: 'c',
secondaryValue: 2,
value: 2,
},
],
groupBy: 'foo',
name: 'c-2',
secondaryValue: 2,
value: 2,
},
{
children: [
{
groupBy: 'bar',
name: 'd',
secondaryValue: 2,
value: 2,
},
],
groupBy: 'foo',
name: 'd-1',
secondaryValue: 2,
value: 2,
},
]);
});
});

View File

@@ -141,8 +141,22 @@ const Legend = ({
}
const categories = Object.entries(categoriesObject).map(([k, v]) => {
const style = { color: `rgba(${v.color?.join(', ')})` };
const icon = v.enabled ? '\u25FC' : '\u25FB';
const color = `rgba(${v.color?.join(', ')})`;
// Render the swatch as a real coloured box rather than a colour-tinted
// text glyph. U+25FC/U+25FB are in Unicode's Emoji set but lack
// Emoji_Presentation, so Chromium resolves them to a colour-emoji font
// whose glyphs carry baked-in colour and ignore the CSS `color` property,
// producing a black square regardless of the category colour. A bordered
// box has no such dependency: filled when enabled, hollow when disabled.
const swatchStyle = {
display: 'inline-block',
width: '12px',
height: '12px',
border: `1px solid ${color}`,
backgroundColor: v.enabled ? color : 'transparent',
alignSelf: 'center',
flex: '0 0 auto',
};
return (
<li key={k}>
@@ -158,7 +172,7 @@ const Legend = ({
showSingleCategory(k);
}}
>
<span style={style}>{icon}</span> {formatCategoryLabel(k)}
<span aria-hidden style={swatchStyle} /> {formatCategoryLabel(k)}
</a>
</li>
);
@@ -173,7 +187,7 @@ const Legend = ({
};
return (
<StyledLegend className="dupa" style={style}>
<StyledLegend style={style}>
<ul>{categories}</ul>
</StyledLegend>
);

View File

@@ -135,14 +135,17 @@ describe('SqlEditorTabHeader', () => {
test('should dispatch queryEditorSetTitle action', async () => {
await waitFor(() =>
expect(screen.getByTestId('close-tab-menu-option')).toBeInTheDocument(),
expect(
screen.getByTestId('rename-tab-menu-option'),
).toBeInTheDocument(),
);
const expectedTitle = 'typed text';
const mockPrompt = jest
.spyOn(window, 'prompt')
.mockImplementation(() => expectedTitle);
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
const input = await screen.findByTestId('rename-tab-input');
fireEvent.change(input, { target: { value: expectedTitle } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
const actions = store.getActions();
await waitFor(() =>
expect(actions[0]).toEqual({
@@ -153,7 +156,127 @@ describe('SqlEditorTabHeader', () => {
}),
}),
);
mockPrompt.mockClear();
});
test('prefills the rename input with the current tab name', async () => {
await waitFor(() =>
expect(
screen.getByTestId('rename-tab-menu-option'),
).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
const input = await screen.findByTestId('rename-tab-input');
expect(input).toHaveValue(defaultQueryEditor.name);
});
test('focuses the rename input when the modal opens', async () => {
await waitFor(() =>
expect(
screen.getByTestId('rename-tab-menu-option'),
).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
const input = await screen.findByTestId('rename-tab-input');
await waitFor(() => expect(input).toHaveFocus());
});
test('disables Save when the input is empty or whitespace', async () => {
await waitFor(() =>
expect(
screen.getByTestId('rename-tab-menu-option'),
).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
const input = await screen.findByTestId('rename-tab-input');
fireEvent.change(input, { target: { value: ' ' } });
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
});
test('does not dispatch or dismiss on Enter when the input is empty', async () => {
await waitFor(() =>
expect(
screen.getByTestId('rename-tab-menu-option'),
).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
const input = await screen.findByTestId('rename-tab-input');
fireEvent.change(input, { target: { value: ' ' } });
fireEvent.keyDown(input, { key: 'Enter', keyCode: 13, charCode: 13 });
const dispatchedTitleChange = store
.getActions()
.some(action => action.type === QUERY_EDITOR_SET_TITLE);
expect(dispatchedTitleChange).toBe(false);
// the modal must stay open so the user can correct the name,
// mirroring the disabled Save button rather than dismissing like Escape
expect(screen.queryByRole('dialog')).toBeInTheDocument();
});
test('does not dispatch a title change when the modal is cancelled', async () => {
await waitFor(() =>
expect(
screen.getByTestId('rename-tab-menu-option'),
).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
const input = await screen.findByTestId('rename-tab-input');
fireEvent.change(input, { target: { value: 'discarded text' } });
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(store.getActions()).toEqual([]);
});
test('does not dispatch a title change when dismissed with the close button', async () => {
await waitFor(() =>
expect(
screen.getByTestId('rename-tab-menu-option'),
).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
const input = await screen.findByTestId('rename-tab-input');
fireEvent.change(input, { target: { value: 'discarded text' } });
fireEvent.click(screen.getByTestId('close-modal-btn'));
expect(store.getActions()).toEqual([]);
});
test('returns focus to the tab header after the modal is cancelled', async () => {
await waitFor(() =>
expect(
screen.getByTestId('rename-tab-menu-option'),
).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
await screen.findByTestId('rename-tab-input');
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
await waitFor(() =>
expect(screen.getByTestId('sql-editor-tab-header')).toHaveFocus(),
);
});
test('returns focus to the tab header after a successful rename', async () => {
await waitFor(() =>
expect(
screen.getByTestId('rename-tab-menu-option'),
).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
const input = await screen.findByTestId('rename-tab-input');
fireEvent.change(input, { target: { value: 'renamed tab' } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() =>
expect(screen.getByTestId('sql-editor-tab-header')).toHaveFocus(),
);
});
test('should dispatch removeAllOtherQueryEditors action', async () => {
@@ -196,4 +319,42 @@ describe('SqlEditorTabHeader', () => {
);
});
});
test('does not leak tab-editing keystrokes from the rename input to the surrounding tabs', async () => {
const onContainerKeyDown = jest.fn();
const store = mockStore(initialState);
render(
<div onKeyDown={onContainerKeyDown}>
<SqlEditorTabHeader queryEditor={defaultQueryEditor} />
</div>,
{ useRedux: true, store },
);
userEvent.click(screen.getByTestId('dropdown-trigger'));
await waitFor(() =>
expect(screen.getByTestId('rename-tab-menu-option')).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
const input = await screen.findByTestId('rename-tab-input');
// The modal portals over the editable-card tabs, whose keyboard handler would
// otherwise remove, navigate, or activate a tab (and swallow Space). None of
// these keys should escape the modal to the surrounding container.
[
'Delete',
'Backspace',
'ArrowLeft',
'ArrowRight',
'Home',
'End',
' ',
].forEach(key => fireEvent.keyDown(input, { key }));
expect(onContainerKeyDown).not.toHaveBeenCalled();
// Escape (close) and Tab (focus trap) must still reach the Modal.
fireEvent.keyDown(input, { key: 'Tab' });
fireEvent.keyDown(input, { key: 'Escape' });
const reached = onContainerKeyDown.mock.calls.map(call => call[0].key);
expect(reached).toEqual(expect.arrayContaining(['Tab', 'Escape']));
});
});

View File

@@ -16,12 +16,17 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useMemo, FC } from 'react';
import { useEffect, useMemo, useRef, useState, FC } from 'react';
import { bindActionCreators } from 'redux';
import { useSelector, shallowEqual } from 'react-redux';
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
import { MenuDotsDropdown } from '@superset-ui/core/components';
import {
MenuDotsDropdown,
Modal,
Input,
InputRef,
} from '@superset-ui/core/components';
import { Menu, MenuItemType } from '@superset-ui/core/components/Menu';
import { t } from '@apache-superset/core/translation';
import { QueryState } from '@superset-ui/core';
@@ -107,13 +112,35 @@ const SqlEditorTabHeader: FC<Props> = ({ queryEditor }) => {
[dispatch],
);
function renameTab() {
// TODO: Replace native prompt with a proper modal dialog
// eslint-disable-next-line no-alert
const newTitle = prompt(t('Enter a new title for the tab'));
if (newTitle) {
actions.queryEditorSetTitle(qe, newTitle, qe.id);
const [isRenameModalOpen, setIsRenameModalOpen] = useState(false);
const [newTitle, setNewTitle] = useState('');
const renameInputRef = useRef<InputRef>(null);
const tabHeaderRef = useRef<HTMLDivElement>(null);
const trimmedTitle = newTitle.trim();
function openRenameModal() {
setNewTitle(qe.name);
setIsRenameModalOpen(true);
}
// antd's Modal moves focus to the dialog container on open, which overrides
// the Input's autoFocus, so focus and select the field via a ref once the
// modal is open (select lets the prefilled name be overtyped, like prompt()).
useEffect(() => {
if (isRenameModalOpen) {
renameInputRef.current?.focus();
renameInputRef.current?.select();
}
}, [isRenameModalOpen]);
function handleRenameTab() {
if (trimmedTitle) {
actions.queryEditorSetTitle(qe, trimmedTitle, qe.id);
}
setIsRenameModalOpen(false);
// Save closes via the show prop rather than the Modal's onHide, so return
// focus to the tab header here, matching what openerRef does on dismiss.
tabHeaderRef.current?.focus();
}
const getStatusColor = (state: QueryState, theme: SupersetTheme): string => {
const statusColors: Record<QueryState, string> = {
@@ -131,7 +158,11 @@ const SqlEditorTabHeader: FC<Props> = ({ queryEditor }) => {
return statusColors[state] || theme.colorIcon;
};
return (
<TabTitleWrapper>
<TabTitleWrapper
ref={tabHeaderRef}
tabIndex={-1}
data-test="sql-editor-tab-header"
>
<MenuDotsDropdown
trigger={['click']}
overlay={
@@ -158,7 +189,7 @@ const SqlEditorTabHeader: FC<Props> = ({ queryEditor }) => {
} as MenuItemType,
{
key: '2',
onClick: renameTab,
onClick: openRenameModal,
'data-test': 'rename-tab-menu-option',
label: (
<>
@@ -220,6 +251,37 @@ const SqlEditorTabHeader: FC<Props> = ({ queryEditor }) => {
iconSize="m"
iconColor={getStatusColor(queryState, theme)}
/>{' '}
<Modal
show={isRenameModalOpen}
onHide={() => setIsRenameModalOpen(false)}
title={t('Rename tab')}
onHandledPrimaryAction={handleRenameTab}
primaryButtonName={t('Save')}
disablePrimaryButton={!trimmedTitle}
openerRef={tabHeaderRef}
>
<Input
ref={renameInputRef}
data-test="rename-tab-input"
aria-label={t('Tab name')}
value={newTitle}
onChange={e => setNewTitle(e.target.value)}
onPressEnter={() => {
if (trimmedTitle) {
handleRenameTab();
}
}}
onKeyDown={e => {
// The modal portals over the editable-card tabs; without this, keys
// bubble to their handler and remove, navigate, or activate a tab
// (Space included). Escape and Tab are left to bubble so the Modal
// can close and trap focus.
if (e.key !== 'Escape' && e.key !== 'Tab') {
e.stopPropagation();
}
}}
/>
</Modal>
</TabTitleWrapper>
);
};

View File

@@ -221,6 +221,12 @@ test('should render the error', async () => {
.spyOn(SupersetClient, 'post')
.mockRejectedValue(new Error('Something went wrong'));
await waitForRender();
// The error is wrapped in an Alert component with a stable headline and the
// raw error text in the description — no more bare ``<pre>`` elements.
expect(await screen.findByRole('alert')).toBeVisible();
expect(
await screen.findByText('Failed to load drill-to-detail rows'),
).toBeVisible();
expect(screen.getByText('Error: Something went wrong')).toBeInTheDocument();
});

View File

@@ -42,6 +42,7 @@ import BooleanCell from '@superset-ui/core/components/Table/cell-renderers/Boole
import NullCell from '@superset-ui/core/components/Table/cell-renderers/NullCell';
import TimeCell from '@superset-ui/core/components/Table/cell-renderers/TimeCell';
import { EmptyState, Loading } from '@superset-ui/core/components';
import { Alert } from '@apache-superset/core/components';
import { getDatasourceSamples } from 'src/components/Chart/chartAction';
import Table, {
ColumnsType,
@@ -362,13 +363,18 @@ export default function DrillDetailPane({
if (responseError) {
// Render error if page download failed
tableContent = (
<pre
<div
css={css`
margin-top: ${theme.sizeUnit * 4}px;
`}
>
{responseError}
</pre>
<Alert
type="error"
showIcon
message={t('Failed to load drill-to-detail rows')}
description={responseError}
/>
</div>
);
} else if (bootstrapping) {
// Render loading if first page hasn't loaded

View File

@@ -49,6 +49,7 @@ const DISABLED_REASONS = {
DATABASE: t(
'Drill to detail is disabled for this database. Change the database settings to enable it.',
),
DATASOURCE: t('Drill to detail is not available for this datasource type.'),
NO_AGGREGATIONS: t(
'Drill to detail is disabled because this chart does not group data by dimension value.',
),
@@ -115,6 +116,17 @@ export const useDrillDetailMenuItems = ({
datasources[formData.datasource]?.database?.disable_drill_to_detail,
);
// Capability flag on the datasource itself. Datasources that don't model
// raw rows (e.g. semantic views) opt out via ``supports_drill_to_detail``
// in the explore data payload.
const datasourceSupportsDrillToDetail = useSelector<
RootState,
boolean | undefined
>(
({ datasources }) =>
datasources[formData.datasource]?.supports_drill_to_detail,
);
const openModal = useCallback(
(filters: BinaryQueryObjectFilterClause[], event: MouseEvent) => {
onClick(event);
@@ -157,7 +169,10 @@ export const useDrillDetailMenuItems = ({
let drillDisabled;
let drillByDisabled;
if (drillToDetailDisabled) {
if (datasourceSupportsDrillToDetail === false) {
drillDisabled = DISABLED_REASONS.DATASOURCE;
drillByDisabled = DISABLED_REASONS.DATASOURCE;
} else if (drillToDetailDisabled) {
drillDisabled = DISABLED_REASONS.DATABASE;
drillByDisabled = DISABLED_REASONS.DATABASE;
} else if (handlesDimensionContextMenu) {

View File

@@ -426,3 +426,45 @@ test.skip('context menu for supported chart, dimensions, all filters', async ()
await setupMenu(filters);
await expectDrillToDetailByAll(filters);
});
const buildStateWithUnsupportedDatasource = () => {
const baseState = getMockStoreWithNativeFilters().getState();
const datasourceKey = defaultFormData.datasource as string;
return {
...baseState,
datasources: {
...baseState.datasources,
[datasourceKey]: {
...baseState.datasources[datasourceKey],
supports_drill_to_detail: false,
},
},
};
};
test('dropdown menu when datasource opts out via supports_drill_to_detail=false', async () => {
cleanup();
render(<MockRenderChart formData={defaultFormData} />, {
useRouter: true,
useRedux: true,
initialState: buildStateWithUnsupportedDatasource(),
});
await expectDrillToDetailDisabled(
'Drill to detail is not available for this datasource type.',
);
await expectNoDrillToDetailBy();
});
test('context menu when datasource opts out via supports_drill_to_detail=false', async () => {
cleanup();
render(<MockRenderChart formData={defaultFormData} isContextMenu />, {
useRouter: true,
useRedux: true,
initialState: buildStateWithUnsupportedDatasource(),
});
const message = 'Drill to detail is not available for this datasource type.';
await expectDrillToDetailDisabled(message);
await expectDrillToDetailByDisabled(message);
});

View File

@@ -231,6 +231,10 @@ export type Datasource = Dataset & {
column_types: GenericDataType[];
table_name: string;
database?: Database;
/** False when the datasource can't return row samples (e.g. semantic views). */
supports_samples?: boolean;
/** False when the datasource can't answer drill-to-detail requests. */
supports_drill_to_detail?: boolean;
};
export type DatasourcesState = {
[key: string]: Datasource;

View File

@@ -197,25 +197,44 @@ export const DataTablesPane = ({
children: pane,
}));
// Hide the Samples tab for datasources that don't expose raw rows
// (e.g. semantic views). The check is intentionally ``=== false`` so that
// datasources from older backends that don't send the flag still show the
// tab and preserve current behavior.
const showSamplesTab = datasource?.supports_samples !== false;
// If the datasource swaps to one that doesn't support samples while the
// Samples tab is active (e.g. the user picks a semantic view), the tab
// disappears from ``tabItems`` and ``activeTabKey`` is orphaned. Fall back
// to Results so the panel keeps rendering content.
useEffect(() => {
if (!showSamplesTab && activeTabKey === ResultTypes.Samples) {
setActiveTabKey(ResultTypes.Results);
}
}, [showSamplesTab, activeTabKey]);
const tabItems = [
...queryResultsPanes,
{
key: ResultTypes.Samples,
label: t('Samples'),
children: (
<StyledDiv>
<SamplesPane
datasource={datasource}
queryFormData={queryFormData}
queryForce={queryForce}
isRequest={isRequest.samples}
setForceQuery={setForceQuery}
isVisible={ResultTypes.Samples === activeTabKey}
canDownload={canDownload}
/>
</StyledDiv>
),
},
...(showSamplesTab
? [
{
key: ResultTypes.Samples,
label: t('Samples'),
children: (
<StyledDiv>
<SamplesPane
datasource={datasource}
queryFormData={queryFormData}
queryForce={queryForce}
isRequest={isRequest.samples}
setForceQuery={setForceQuery}
isVisible={ResultTypes.Samples === activeTabKey}
canDownload={canDownload}
/>
</StyledDiv>
),
},
]
: []),
];
return (

View File

@@ -21,6 +21,7 @@ import { t } from '@apache-superset/core/translation';
import { ensureIsArray } from '@superset-ui/core';
import { datasetLabelLower } from 'src/features/semanticLayers/label';
import { styled } from '@apache-superset/core/theme';
import { Alert } from '@apache-superset/core/components';
import { EmptyState, Loading } from '@superset-ui/core/components';
import { GenericDataType } from '@apache-superset/core/common';
import { GridTable } from 'src/components/GridTable';
@@ -35,7 +36,7 @@ import {
import { TableControls, ROW_LIMIT_OPTIONS } from './DataTableControls';
import { SamplesPaneProps } from '../types';
const Error = styled.pre`
const ErrorAlertWrapper = styled.div`
margin-top: ${({ theme }) => `${theme.sizeUnit * 4}px`};
`;
@@ -155,7 +156,14 @@ export const SamplesPane = ({
rowLimitOptions={ROW_LIMIT_OPTIONS}
onRowLimitChange={handleRowLimitChange}
/>
<Error>{responseError}</Error>
<ErrorAlertWrapper>
<Alert
type="error"
showIcon
message={t('Failed to load samples')}
description={responseError}
/>
</ErrorAlertWrapper>
</>
);
}

View File

@@ -25,13 +25,14 @@ import {
getClientErrorObject,
} from '@superset-ui/core';
import { styled } from '@apache-superset/core/theme';
import { Alert } from '@apache-superset/core/components';
import { EmptyState, Loading } from '@superset-ui/core/components';
import { getChartDataRequest } from 'src/components/Chart/chartAction';
import { ResultsPaneProps, QueryResultInterface } from '../types';
import { SingleQueryResultPane } from './SingleQueryResultPane';
import { TableControls, ROW_LIMIT_OPTIONS } from './DataTableControls';
const Error = styled.pre`
const ErrorAlertWrapper = styled.div`
margin-top: ${({ theme }) => `${theme.sizeUnit * 4}px`};
`;
@@ -157,7 +158,14 @@ export const useResultsPane = ({
isLoading={false}
canDownload={canDownload}
/>
<Error>{responseError}</Error>
<ErrorAlertWrapper>
<Alert
type="error"
showIcon
message={t('Failed to load results')}
description={responseError}
/>
</ErrorAlertWrapper>
</>
);
return Array(queryCount).fill(err);

View File

@@ -19,7 +19,12 @@
import fetchMock from 'fetch-mock';
import { FeatureFlag } from '@superset-ui/core';
import * as copyUtils from 'src/utils/copy';
import { render, screen, userEvent } from 'spec/helpers/testing-library';
import {
render,
screen,
userEvent,
waitFor,
} from 'spec/helpers/testing-library';
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
import { setItem, LocalStorageKeys } from 'src/utils/localStorageHelpers';
import { DataTablesPane } from '..';
@@ -89,6 +94,48 @@ describe('DataTablesPane', () => {
expect(await screen.findByLabelText('Collapse data panel')).toBeVisible();
});
test('Hides Samples tab when datasource opts out via supports_samples=false', async () => {
const props = createDataTablesPaneProps(0);
const propsWithoutSamples = {
...props,
datasource: { ...props.datasource, supports_samples: false },
};
render(<DataTablesPane {...propsWithoutSamples} />, { useRedux: true });
expect(await screen.findByText('Results')).toBeVisible();
expect(screen.queryByText('Samples')).not.toBeInTheDocument();
});
test('Falls back to Results when active Samples tab disappears mid-session', async () => {
// Regression for codeant Major finding on PR #41509: a datasource swap
// that hides the Samples tab while it was the active tab used to leave
// ``activeTabKey === 'samples'`` orphaned, rendering a blank panel.
const props = createDataTablesPaneProps(0);
const { rerender } = render(<DataTablesPane {...props} />, {
useRedux: true,
});
// Open the panel and pick the Samples tab.
userEvent.click(screen.getByLabelText('Expand data panel'));
userEvent.click(await screen.findByText('Samples'));
expect(await screen.findByLabelText('Collapse data panel')).toBeVisible();
// Swap to a datasource that doesn't support samples (e.g. a semantic
// view). The Samples tab should disappear and the panel should land on
// Results with content still rendered.
rerender(
<DataTablesPane
{...props}
datasource={{ ...props.datasource, supports_samples: false }}
/>,
);
await waitFor(() => {
expect(screen.queryByText('Samples')).not.toBeInTheDocument();
});
expect(screen.getByText('Results')).toBeVisible();
// Panel stays expanded and renders Results content rather than going blank.
expect(screen.getByLabelText('Collapse data panel')).toBeVisible();
});
test('Should copy data table content correctly', async () => {
fetchMock.post(
'glob:*/api/v1/chart/data?form_data=%7B%22slice_id%22%3A456%7D',

View File

@@ -84,10 +84,14 @@ describe('SamplesPane', () => {
const props = createSamplesPaneProps({
datasourceId: 36,
});
const { findByText } = render(<SamplesPane {...props} />, {
const { findByText, findByRole } = render(<SamplesPane {...props} />, {
useRedux: true,
});
// The error is now rendered inside an Alert component, with a clear
// headline message and the raw error text as the description.
expect(await findByRole('alert')).toBeVisible();
expect(await findByText('Failed to load samples')).toBeVisible();
expect(await findByText('Error: Bad request')).toBeVisible();
});

View File

@@ -78,6 +78,18 @@ export type Datasource = Dataset & {
schema?: string;
is_sqllab_view?: boolean;
extra?: string | object;
/**
* False when the datasource (e.g. a semantic view) doesn't model raw rows
* and therefore can't return a row sample. Defaults to true on the server
* side; missing here means the explore UI keeps current behavior.
*/
supports_samples?: boolean;
/**
* False when the datasource doesn't model raw rows and therefore can't
* answer a drill-to-detail query. Tracked separately from
* ``supports_samples`` so the two capabilities can diverge.
*/
supports_drill_to_detail?: boolean;
};
export interface ExplorePageInitialData {

View File

@@ -0,0 +1,125 @@
/**
* 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 fetchMock from 'fetch-mock';
import { SupersetClient } from '@superset-ui/core';
import {
useExecuteReportSchedule,
type ExecuteReportResponse,
} from './useExecuteReportSchedule';
const mockExecuteResponse: ExecuteReportResponse = {
execution_id: 'test-uuid-123',
message: 'Report schedule execution started successfully',
};
beforeAll(() => {
SupersetClient.configure().init();
});
afterEach(() => {
fetchMock.clearHistory().removeRoutes();
});
test('successfully executes a report', async () => {
const reportId = 123;
fetchMock.post(
`glob:*/api/v1/report/${reportId}/execute`,
mockExecuteResponse,
);
const { result } = renderHook(() => useExecuteReportSchedule());
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe(null);
let executeResult: ExecuteReportResponse | undefined;
await act(async () => {
executeResult = await result.current.executeReport(reportId);
});
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe(null);
expect(executeResult).toEqual(mockExecuteResponse);
expect(
fetchMock.callHistory.calls(`glob:*/api/v1/report/${reportId}/execute`),
).toHaveLength(1);
});
test('handles execution errors', async () => {
const reportId = 123;
const errorMessage = 'Report not found';
fetchMock.post(`glob:*/api/v1/report/${reportId}/execute`, {
status: 404,
body: { message: errorMessage },
});
const { result } = renderHook(() => useExecuteReportSchedule());
await act(async () => {
try {
await result.current.executeReport(reportId);
} catch {
// Expected to throw
}
});
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe(errorMessage);
});
test('calls success callback on successful execution', async () => {
const reportId = 123;
const onSuccess = jest.fn();
fetchMock.post(
`glob:*/api/v1/report/${reportId}/execute`,
mockExecuteResponse,
);
const { result } = renderHook(() => useExecuteReportSchedule());
await act(async () => {
await result.current.executeReport(reportId, onSuccess);
});
expect(onSuccess).toHaveBeenCalledWith(mockExecuteResponse);
});
test('calls error callback on failed execution', async () => {
const reportId = 123;
const onError = jest.fn();
const errorMessage = 'Execution failed';
fetchMock.post(`glob:*/api/v1/report/${reportId}/execute`, {
status: 500,
body: { message: errorMessage },
});
const { result } = renderHook(() => useExecuteReportSchedule());
await act(async () => {
try {
await result.current.executeReport(reportId, undefined, onError);
} catch {
// Expected to throw
}
});
expect(onError).toHaveBeenCalledWith(errorMessage);
});

View File

@@ -0,0 +1,96 @@
/**
* 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 { useState, useCallback } from 'react';
import { t } from '@apache-superset/core/translation';
import { SupersetClient } from '@superset-ui/core';
export interface ExecuteReportResponse {
execution_id: string;
message: string;
}
interface ErrorPayload {
message?: string;
}
interface UseExecuteReportScheduleState {
loading: boolean;
error: string | null;
}
export function useExecuteReportSchedule() {
const [state, setState] = useState<UseExecuteReportScheduleState>({
loading: false,
error: null,
});
const executeReport = useCallback(
async (
reportId: number,
onSuccess?: (response: ExecuteReportResponse) => void,
onError?: (error: string) => void,
): Promise<ExecuteReportResponse> => {
setState({ loading: true, error: null });
try {
const response = await SupersetClient.post({
endpoint: `/api/v1/report/${reportId}/execute`,
});
const result = response.json as ExecuteReportResponse;
setState({ loading: false, error: null });
if (onSuccess) {
onSuccess(result);
}
return result;
} catch (error) {
let errorMessage = t('An error occurred while triggering the report');
// SupersetClient rejects non-2xx responses with the raw Response object
if (error instanceof Response) {
try {
const errorJson: ErrorPayload = await error.json();
if (errorJson?.message) {
errorMessage = errorJson.message;
}
} catch {
// JSON parse failed — keep the default message
}
}
setState({ loading: false, error: errorMessage });
if (onError) {
onError(errorMessage);
}
throw error;
}
},
[],
);
return {
executeReport,
loading: state.loading,
error: state.error,
};
}

View File

@@ -796,3 +796,111 @@ test('brand link falls back to brand.path when theme brandLogoUrl is absent', as
// ensureAppRoot must have been applied: /welcome/ → /superset/welcome/
expect(brandLink).toHaveAttribute('href', '/superset/welcome/');
});
// --- Active tab highlighting (regression tests for issue #36403) ---
//
// The active top-level tab is highlighted by matching the current route to a
// menu item. The matching must rely on a stable identifier (the FAB `name`),
// not the displayed label, otherwise highlighting breaks for any non-English
// locale where the label is translated.
// Returns the top-level <li> that contains the given visible text, so we can
// assert whether antd marked it as the selected menu item.
const getMenuItemByText = (text: string): HTMLElement | null =>
screen.getByText(text).closest('li');
// Scoped in a describe so the route-resetting afterEach only applies to these
// tests and does not leak into the rest of the file.
describe('active tab highlighting (regression #36403)', () => {
afterEach(() => {
// Reset the route so a pushed path does not leak into the next test.
window.history.pushState({}, '', '/');
});
test('highlights the active top-level tab on a matching route (English)', async () => {
useSelectorMock.mockReturnValue({ roles: user.roles });
window.history.pushState({}, '', '/dashboard/list/');
render(<Menu {...mockedProps} />, {
useRedux: true,
useQueryParams: true,
useRouter: true,
useTheme: true,
});
await screen.findByText('Dashboards');
expect(getMenuItemByText('Dashboards')).toHaveClass(
'ant-menu-item-selected',
);
});
test('highlights the active top-level tab when the label is localized', async () => {
// Russian locale: the FAB `name` stays the stable English identifier while
// the displayed `label` is translated. Highlighting must still work.
const localizedProps = {
...mockedProps,
data: {
...mockedProps.data,
menu: mockedProps.data.menu.map(item =>
item.name === 'Dashboards' ? { ...item, label: 'Дашборды' } : item,
),
},
};
useSelectorMock.mockReturnValue({ roles: user.roles });
window.history.pushState({}, '', '/dashboard/list/');
render(<Menu {...localizedProps} />, {
useRedux: true,
useQueryParams: true,
useRouter: true,
useTheme: true,
});
await screen.findByText('Дашборды');
expect(getMenuItemByText('Дашборды')).toHaveClass('ant-menu-item-selected');
});
test('highlights the active SQL tab when the label is localized', async () => {
// The SQL Lab top-level entry is a FAB category: its stable `name` is
// "SQL Lab" while its label ("SQL") is localized.
const localizedProps = {
...mockedProps,
data: {
...mockedProps.data,
menu: [
...mockedProps.data.menu,
{
name: 'SQL Lab',
icon: 'fa-flask',
label: 'SQL запросы',
childs: [
{
name: 'SQL Editor',
label: 'SQL Lab',
url: '/sqllab/',
index: 1,
},
],
},
],
},
};
useSelectorMock.mockReturnValue({ roles: user.roles });
window.history.pushState({}, '', '/sqllab/');
render(<Menu {...localizedProps} />, {
useRedux: true,
useQueryParams: true,
useRouter: true,
useTheme: true,
});
await screen.findByText('SQL запросы');
// SQL Lab renders as a submenu, so antd marks it with the submenu variant.
expect(getMenuItemByText('SQL запросы')).toHaveClass(
'ant-menu-submenu-selected',
);
});
});

View File

@@ -211,6 +211,16 @@ export function Menu({
SavedQueries = '/savedqueryview',
}
// Stable Flask-AppBuilder menu identifiers (`name`), used as menu item keys.
// These are locale-independent, unlike the displayed labels, so matching the
// active tab against them keeps highlighting working in every language.
enum MenuKeys {
Dashboards = 'Dashboards',
Charts = 'Charts',
Datasets = 'Datasets',
SqlLab = 'SQL Lab',
}
const defaultTabSelection: string[] = [];
const [activeTabs, setActiveTabs] = useState(defaultTabSelection);
const location = useLocation();
@@ -218,16 +228,16 @@ export function Menu({
const path = location.pathname;
switch (true) {
case path.startsWith(Paths.Dashboard):
setActiveTabs([t('Dashboards')]);
setActiveTabs([MenuKeys.Dashboards]);
break;
case path.startsWith(Paths.Chart) || path.startsWith(Paths.Explore):
setActiveTabs([t('Charts')]);
setActiveTabs([MenuKeys.Charts]);
break;
case path.startsWith(Paths.Datasets):
setActiveTabs([datasetsLabel()]);
setActiveTabs([MenuKeys.Datasets]);
break;
case path.startsWith(Paths.SqlLab) || path.startsWith(Paths.SavedQueries):
setActiveTabs(['SQL']);
setActiveTabs([MenuKeys.SqlLab]);
break;
default:
setActiveTabs(defaultTabSelection);
@@ -242,10 +252,14 @@ export function Menu({
childs,
url,
isFrontendRoute,
name,
}: MenuObjectProps): MenuItem => {
// Key items by the stable FAB `name` so active-tab matching is independent
// of the localized label. Fall back to the label when no name is provided.
const key = name ?? label;
if (url && isFrontendRoute) {
return {
key: label,
key,
label: (
<NavLink role="button" to={url} activeClassName="is-active">
{label}
@@ -256,7 +270,7 @@ export function Menu({
if (url) {
return {
key: label,
key,
label: <Typography.Link href={url}>{label}</Typography.Link>,
};
}
@@ -268,7 +282,11 @@ export function Menu({
} else if (typeof child !== 'string') {
Object.assign(child, { label: t(child.label) });
childItems.push({
key: `${child.label}`,
// Key children by the stable FAB `name` as well, so a child whose
// localized label coincides with a parent key (e.g. the "SQL Editor"
// child labeled "SQL Lab" under the "SQL Lab" category) doesn't
// collide with that parent. Fall back to the label when no name.
key: child.name ?? `${child.label}`,
label: child.isFrontendRoute ? (
<NavLink to={child.url || ''} exact activeClassName="is-active">
{child.label}
@@ -281,7 +299,7 @@ export function Menu({
});
return {
key: label,
key,
label,
...(screens.md && {
icon: <Icons.DownOutlined iconSize="xs" />,

View File

@@ -606,6 +606,7 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
allowClear
autoClearSearchValue
allowNewOptions={!searchAllOptions && creatable !== false}
allowNewOptionsOnPaste={multiSelect && searchAllOptions}
allowSelectAll={!searchAllOptions}
value={multiSelect ? filterState.value || [] : filterState.value}
disabled={isDisabled}

View File

@@ -482,3 +482,70 @@ test('empty API result shows empty state', async () => {
expect(await screen.findByText(/no alerts yet/i)).toBeInTheDocument();
});
test('trigger-now action calls execute API for owned alert', async () => {
fetchMock.post(
'glob:*/api/v1/report/*/execute',
{
execution_id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
message: 'Triggered',
},
{ name: 'execute-report' },
);
renderAlertList();
await screen.findByText('Weekly Sales Alert');
// Alert 1 is owned by mockUser (userId: 1) so allowEdit is true,
// meaning the trigger-now button is rendered.
const triggerButtons = screen.getAllByTestId('trigger-now-action');
expect(triggerButtons.length).toBeGreaterThanOrEqual(1);
fireEvent.click(triggerButtons[0]);
// Execute endpoint is called exactly once for the owned alert.
await waitFor(() => {
expect(fetchMock.callHistory.calls('execute-report')).toHaveLength(1);
});
expect(fetchMock.callHistory.calls('execute-report')[0].url).toMatch(
/\/report\/\d+\/execute/,
);
});
test('trigger-now action does not duplicate in-flight requests', async () => {
// Slow down the response so we can fire two rapid clicks before it resolves.
let resolveExecute: (value: unknown) => void;
const pendingExecute = new Promise(resolve => {
resolveExecute = resolve;
});
fetchMock.post(
'glob:*/api/v1/report/*/execute',
() =>
pendingExecute.then(() => ({
execution_id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
message: 'Triggered',
})),
{ name: 'execute-report-slow' },
);
renderAlertList();
await screen.findByText('Weekly Sales Alert');
const triggerButtons = screen.getAllByTestId('trigger-now-action');
fireEvent.click(triggerButtons[0]);
fireEvent.click(triggerButtons[0]); // rapid double-click
// Wait for the first (and only) request to be issued.
await waitFor(() => {
expect(fetchMock.callHistory.calls('execute-report-slow')).toHaveLength(1);
});
// Second click must not have triggered a second request.
expect(fetchMock.callHistory.calls('execute-report-slow')).toHaveLength(1);
// Resolve so the test cleanup is clean.
resolveExecute!(undefined);
await waitFor(() => {
expect(fetchMock.callHistory.calls('execute-report-slow')).toHaveLength(1);
});
});

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import { useState, useMemo, useEffect, useCallback } from 'react';
import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import { useHistory } from 'react-router-dom';
import { t } from '@apache-superset/core/translation';
import {
@@ -63,6 +63,7 @@ import { isUserAdmin } from 'src/dashboard/util/permissionUtils';
import Owner from 'src/types/Owner';
import AlertReportModal from 'src/features/alerts/AlertReportModal';
import { AlertObject, AlertState } from 'src/features/alerts/types';
import { useExecuteReportSchedule } from 'src/features/alerts/hooks/useExecuteReportSchedule';
import { QueryObjectColumns } from 'src/views/CRUD/types';
import { Icons } from '@superset-ui/core/components/Icons';
import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils';
@@ -168,6 +169,53 @@ function AlertList({
const [currentAlertDeleting, setCurrentAlertDeleting] =
useState<AlertObject | null>(null);
// Track in-flight execute requests with a ref for race-condition-safe double-click prevention
const executingIdsRef = useRef<Set<number>>(new Set());
const { executeReport } = useExecuteReportSchedule();
const handleExecuteReport = useCallback(
(alert: AlertObject) => {
const alertId = alert.id;
if (!alertId) {
return;
}
// Atomically check-and-set before any async work to prevent duplicate requests
// from rapid double-clicks that occur before a re-render can update state.
if (executingIdsRef.current.has(alertId)) {
return;
}
executingIdsRef.current.add(alertId);
executeReport(
alertId,
() => {
addSuccessToast(
t('%(alertType)s "%(alertName)s" triggered successfully', {
alertType: alert.type,
alertName: alert.name,
}),
);
},
error => {
addDangerToast(
t('Failed to trigger %(alertType)s "%(alertName)s": %(error)s', {
alertType: alert.type,
alertName: alert.name,
error,
}),
);
},
)
.catch(() => {
// Error already surfaced to the user via the onError callback above.
})
.finally(() => {
executingIdsRef.current.delete(alertId);
});
},
[executeReport, addSuccessToast, addDangerToast],
);
// Actions
function handleAlertEdit(alert: AlertObject | null) {
setCurrentAlert(alert);
@@ -405,6 +453,15 @@ function AlertList({
onClick: handleEdit,
}
: null,
allowEdit
? {
label: 'trigger-now-action',
tooltip: t('Trigger now'),
placement: 'bottom',
icon: 'ThunderboltOutlined',
onClick: () => handleExecuteReport(original),
}
: null,
allowEdit && canDelete
? {
label: 'delete-action',
@@ -432,7 +489,7 @@ function AlertList({
id: QueryObjectColumns.ChangedBy,
},
],
[canDelete, canEdit, isReportEnabled, toggleActive],
[canDelete, canEdit, isReportEnabled, toggleActive, handleExecuteReport],
);
const subMenuButtons: SubMenuProps['buttons'] = [];

View File

@@ -336,3 +336,21 @@ class ReportScheduleUserEmailNotFoundError(ValidationError):
),
field_name="recipients",
)
class ReportScheduleExecuteNowFailedError(CommandException):
"""Command exception raised when a report schedule fails to execute immediately."""
message = _("Report Schedule execute now failed.")
class ReportScheduleCeleryNotConfiguredError(CommandException):
"""Command exception raised when a report schedule is executed but
Celery is not configured.
"""
status = 503
message = _(
"Report Schedule execution requires a Celery backend to be configured. "
"Please configure a Celery broker (Redis or RabbitMQ) and worker processes."
)

View File

@@ -0,0 +1,134 @@
# 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 logging
from datetime import datetime, timezone
from typing import Any, Optional
from uuid import uuid4
from flask import current_app
from kombu.exceptions import OperationalError as KombuOperationalError
from superset import security_manager
from superset.commands.base import BaseCommand
from superset.commands.exceptions import CommandException
from superset.commands.report.exceptions import (
ReportScheduleCeleryNotConfiguredError,
ReportScheduleExecuteNowFailedError,
ReportScheduleForbiddenError,
ReportScheduleNotFoundError,
)
from superset.daos.report import ReportScheduleDAO
from superset.exceptions import SupersetSecurityException
from superset.reports.models import ReportSchedule
logger = logging.getLogger(__name__)
class ExecuteReportScheduleNowCommand(BaseCommand):
"""
Execute a report schedule immediately (manual trigger).
Validates permissions and triggers immediate execution of a report or alert
via Celery task, similar to scheduled execution but without waiting for the
cron schedule. Sets ``eta`` to the current UTC time so that the downstream
task receives a valid ``scheduled_dttm`` value.
"""
def __init__(self, model_id: int) -> None:
self._model_id = model_id
self._model: Optional[ReportSchedule] = None
def run(self) -> str:
"""
Execute the command and return an execution UUID for tracking.
Returns:
str: Execution UUID that can be used to track the execution status.
Raises:
ReportScheduleNotFoundError: Report schedule not found.
ReportScheduleForbiddenError: User doesn't have permission to execute.
ReportScheduleCeleryNotConfiguredError: Celery broker is not reachable.
ReportScheduleExecuteNowFailedError: Execution failed to start.
"""
try:
self.validate()
if not self._model:
raise ReportScheduleExecuteNowFailedError()
execution_id = str(uuid4())
logger.info(
"Manually executing report schedule %s (id: %d), execution_id: %s",
self._model.name,
self._model.id,
execution_id,
)
# Import here to avoid circular imports
from superset.tasks.scheduler import execute
# Set eta to now so the downstream task receives a valid scheduled_dttm.
async_options: dict[str, Any] = {
"task_id": execution_id,
"eta": datetime.now(tz=timezone.utc),
}
if self._model.working_timeout is not None and current_app.config.get(
"ALERT_REPORTS_WORKING_TIME_OUT_KILL", True
):
async_options["time_limit"] = (
self._model.working_timeout
+ current_app.config.get("ALERT_REPORTS_WORKING_TIME_OUT_LAG", 10)
)
async_options["soft_time_limit"] = (
self._model.working_timeout
+ current_app.config.get(
"ALERT_REPORTS_WORKING_SOFT_TIME_OUT_LAG", 5
)
)
try:
execute.apply_async((self._model.id,), **async_options)
except KombuOperationalError as celery_ex:
logger.error("Celery backend not configured: %s", str(celery_ex))
raise ReportScheduleCeleryNotConfiguredError() from celery_ex
except Exception as celery_ex:
logger.error("Celery task execution failed: %s", str(celery_ex))
raise ReportScheduleExecuteNowFailedError() from celery_ex
return execution_id
except CommandException:
raise
except Exception as ex:
logger.exception(
"Unexpected error executing report schedule %d", self._model_id
)
raise ReportScheduleExecuteNowFailedError() from ex
def validate(self) -> None:
"""Validate the report schedule exists and the user has permission to
execute."""
self._model = ReportScheduleDAO.find_by_id(self._model_id)
if not self._model:
raise ReportScheduleNotFoundError()
try:
security_manager.raise_for_ownership(self._model)
except SupersetSecurityException as ex:
raise ReportScheduleForbiddenError() from ex

View File

@@ -193,6 +193,16 @@ class BaseDatasource(
# Only some datasources support Row Level Security
is_rls_supported: bool = False
# Datasources that can return raw row samples (anything backed by a SQL
# table can; semantic-layer abstractions cannot, since they only expose
# pre-defined metrics and dimensions).
supports_samples: bool = True
# Datasources that can answer "drill to detail" requests — i.e. fetch the
# raw rows underlying a chart cell. Conceptually similar to ``samples``
# but kept as a separate capability so the two can diverge.
supports_drill_to_detail: bool = True
@property
def name(self) -> str:
# can be a Column or a property pointing to one
@@ -500,6 +510,8 @@ class BaseDatasource(
"owners": [owner.id for owner in self.owners],
"verbose_map": self.verbose_map,
"select_star": self.select_star,
"supports_samples": self.supports_samples,
"supports_drill_to_detail": self.supports_drill_to_detail,
}
def data_for_slices( # pylint: disable=too-many-locals # noqa: C901

View File

@@ -191,6 +191,16 @@ class BaseDAO(CoreBaseDAO[T], Generic[T]):
id_column_name: ClassVar[str] = "id"
uuid_column_name: ClassVar[str] = "uuid"
filterable_relationships: ClassVar[frozenset[str]] = frozenset()
"""
Names of collection relationships (m2m / one-to-many) this DAO advertises
as filterable via ``get_filterable_columns_and_operators``. Empty means
no relationships are advertised — important because consumer tools
constrain filter columns via Pydantic ``Literal`` and would silently
reject anything advertised here that isn't in their allowlist.
Child DAOs override with the relationship names they wish to expose.
"""
def __init_subclass__(cls) -> None:
cls.model_cls = get_args(
cls.__orig_bases__[0] # type: ignore[attr-defined] # pylint: disable=no-member
@@ -581,14 +591,83 @@ class BaseDAO(CoreBaseDAO[T], Generic[T]):
)
column = getattr(cls.model_cls, col)
try:
# Always use ColumnOperatorEnum's apply method
operator_enum = ColumnOperatorEnum(opr)
query = query.filter(operator_enum.apply(column, value))
# Relationship attributes (many-to-many or one-to-many)
# can't be compared directly with scalar operators —
# SQLAlchemy needs `.any(...)`. Detect the collection
# case and dispatch to the related model's primary key
# column. This lets callers use the natural shapes
# `{col: "<relationship>", opr: "eq", value: <id>}` or
# `{opr: "in", value: [<id>, ...]}` etc. to find rows
# whose related collection contains those id(s).
is_collection_relationship = (
hasattr(column, "property")
and isinstance(column.property, RelationshipProperty)
and column.property.uselist
)
if is_collection_relationship:
query = cls._apply_relationship_filter(
query, column, col, operator_enum, value
)
else:
query = query.filter(operator_enum.apply(column, value))
except Exception as e:
logging.error("Error applying filter on column '%s': %s", col, e)
raise
return query
@classmethod
def _apply_relationship_filter(
cls,
query: Any,
column: Any,
col_name: str,
operator_enum: "ColumnOperatorEnum",
value: Any,
) -> Any:
"""Apply a filter on a many-to-many or one-to-many relationship column.
Translates the caller's operator into a SQLAlchemy ``.any()``
expression against the related model's primary key. Supports
eq / ne / in / nin / is_null / is_not_null. Other operators
(sw, like, gt, etc.) don't make sense on a collection of related
rows and raise a clear ValueError instead of producing a
cryptic SQLAlchemy error at query time.
"""
pk_cols = inspect(column.property.mapper).primary_key
if len(pk_cols) != 1:
# Composite PKs would need a tuple `.in_()` and per-operator
# tuple handling; no Superset model uses one today, so we
# fail loudly rather than silently drop the trailing columns.
raise ValueError(
f"Relationship filter on '{col_name}' requires a "
f"single-column primary key on the related model; "
f"found {len(pk_cols)} columns."
)
related_pk = pk_cols[0]
if operator_enum == ColumnOperatorEnum.eq:
return query.filter(column.any(related_pk == value))
if operator_enum == ColumnOperatorEnum.ne:
# "no related row has id == value"
return query.filter(~column.any(related_pk == value))
if operator_enum == ColumnOperatorEnum.in_:
values = value if isinstance(value, (list, tuple)) else [value]
return query.filter(column.any(related_pk.in_(values)))
if operator_enum == ColumnOperatorEnum.nin:
values = value if isinstance(value, (list, tuple)) else [value]
return query.filter(~column.any(related_pk.in_(values)))
if operator_enum == ColumnOperatorEnum.is_null:
# "has no related rows at all"
return query.filter(~column.any())
if operator_enum == ColumnOperatorEnum.is_not_null:
# "has at least one related row"
return query.filter(column.any())
raise ValueError(
f"Operator '{operator_enum.value}' is not supported on "
f"relationship column '{col_name}'. Use one of: eq, ne, in, "
f"nin, is_null, is_not_null."
)
@classmethod
def get_filterable_columns_and_operators(cls) -> Dict[str, List[str]]:
"""
@@ -600,6 +679,17 @@ class BaseDAO(CoreBaseDAO[T], Generic[T]):
mapper = inspect(cls.model_cls)
columns = {c.key: c for c in mapper.columns}
# Collection relationships (m2m / one-to-many) are filterable via
# `.any()` against the related model's primary key. Only advertise
# the relationships the DAO has opted into via
# ``filterable_relationships`` so schema discovery stays in sync
# with the consumer tool's input ``Literal`` allowlist (otherwise
# the LLM sees fields it cannot actually pass).
relationship_columns = {
rel.key: rel
for rel in mapper.relationships
if rel.uselist and rel.key in cls.filterable_relationships
}
# Add hybrid properties
hybrids = {
name: attr
@@ -610,6 +700,17 @@ class BaseDAO(CoreBaseDAO[T], Generic[T]):
# custom_fields = {"tags": ["eq", "in_", "like"], ...}
custom_fields: Dict[str, List[str]] = {}
# Operators apply_relationship_filter supports for collection
# relationships. Keep in sync with that method.
relationship_operators = [
ColumnOperatorEnum.eq,
ColumnOperatorEnum.ne,
ColumnOperatorEnum.in_,
ColumnOperatorEnum.nin,
ColumnOperatorEnum.is_null,
ColumnOperatorEnum.is_not_null,
]
filterable: Dict[str, Any] = {}
for name, col in columns.items():
if isinstance(col.type, (sa.String, sa.Text)):
@@ -631,6 +732,9 @@ class BaseDAO(CoreBaseDAO[T], Generic[T]):
# Add hybrid properties as string fields by default
for name in hybrids:
filterable[name] = TYPE_OPERATOR_MAP["string"]
# Add collection relationships
for name in relationship_columns:
filterable[name] = relationship_operators
# Add custom fields
filterable.update(custom_fields)

View File

@@ -44,6 +44,7 @@ CHART_CUSTOM_FIELDS = {
class ChartDAO(BaseDAO[Slice]):
base_filter = ChartFilter
filterable_relationships = frozenset({"dashboards"})
@classmethod
def apply_column_operators(

View File

@@ -606,12 +606,17 @@ class ChartFilter(ColumnOperator):
"datasource_name",
"created_by_fk",
"changed_by_fk",
"dashboards",
] = Field(
...,
description="Column to filter on. Use get_schema(model_type='chart') for "
"available filter columns. To filter by a person, first call find_users "
"to resolve a name to a user ID, then filter by created_by_fk or "
"changed_by_fk with that integer ID.",
"changed_by_fk with that integer ID. To find charts attached to a "
"specific dashboard, filter by 'dashboards' with an integer "
"dashboard ID using opr='eq' (other supported operators on "
"'dashboards': ne, in, nin, is_null, is_not_null — like/sw/gt are "
"rejected because they don't apply to a collection relationship).",
)
opr: ColumnOperatorEnum = Field(
...,

View File

@@ -35,13 +35,16 @@ from superset.charts.filters import ChartFilter
from superset.commands.report.create import CreateReportScheduleCommand
from superset.commands.report.delete import DeleteReportScheduleCommand
from superset.commands.report.exceptions import (
ReportScheduleCeleryNotConfiguredError,
ReportScheduleCreateFailedError,
ReportScheduleDeleteFailedError,
ReportScheduleExecuteNowFailedError,
ReportScheduleForbiddenError,
ReportScheduleInvalidError,
ReportScheduleNotFoundError,
ReportScheduleUpdateFailedError,
)
from superset.commands.report.execute_now import ExecuteReportScheduleNowCommand
from superset.commands.report.update import UpdateReportScheduleCommand
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod
from superset.dashboards.filters import DashboardAccessFilter
@@ -54,6 +57,7 @@ from superset.reports.schemas import (
get_delete_ids_schema,
get_slack_channels_schema,
openapi_spec_methods_override,
ReportScheduleExecuteResponseSchema,
ReportSchedulePostSchema,
ReportSchedulePutSchema,
ReportScheduleSubscribeSchema,
@@ -84,6 +88,7 @@ class ReportScheduleRestApi(BaseSupersetModelRestApi):
"bulk_delete",
"slack_channels", # not using RouteMethod since locally defined
"subscribe",
"execute", # not using RouteMethod since locally defined
}
class_permission_name = "ReportSchedule"
method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
@@ -678,3 +683,79 @@ class ReportScheduleRestApi(BaseSupersetModelRestApi):
except SupersetException as ex:
logger.error("Error fetching slack channels %s", str(ex))
return self.response_422(message=str(ex))
@expose("/<int:pk>/execute", methods=("POST",))
@protect()
@safe
@permission_name("execute")
@statsd_metrics
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.execute",
log_to_statsd=False,
)
def execute(self, pk: int) -> Response:
"""Execute a report schedule immediately.
---
post:
summary: Execute a report schedule immediately
parameters:
- in: path
schema:
type: integer
name: pk
description: The report schedule pk
responses:
200:
description: Report schedule execution started
content:
application/json:
schema:
type: object
properties:
execution_id:
type: string
description: UUID to track the execution status
message:
type: string
description: Success message
403:
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
422:
$ref: '#/components/responses/422'
500:
$ref: '#/components/responses/500'
503:
description: Celery backend not configured
"""
try:
execution_id = ExecuteReportScheduleNowCommand(pk).run()
response_schema = ReportScheduleExecuteResponseSchema()
return self.response(
200,
**response_schema.dump(
{
"execution_id": execution_id,
"message": "Report schedule execution started successfully",
}
),
)
except ReportScheduleNotFoundError:
return self.response_404()
except ReportScheduleForbiddenError:
return self.response_403()
except ReportScheduleCeleryNotConfiguredError as ex:
logger.error(
"Celery backend not configured for report schedule execution: %s",
str(ex),
)
return self.response(503, message=str(ex))
except ReportScheduleExecuteNowFailedError as ex:
logger.error(
"Error executing report schedule %s: %s",
self.__class__.__name__,
str(ex),
exc_info=True,
)
return self.response_422(message=str(ex))

View File

@@ -469,3 +469,15 @@ class SlackChannelSchema(Schema):
name = fields.String()
is_member = fields.Boolean()
is_private = fields.Boolean()
class ReportScheduleExecuteResponseSchema(Schema):
"""Schema for the response when executing a report schedule immediately."""
class Meta:
unknown = EXCLUDE
execution_id = fields.UUID(
metadata={"description": _("UUID to track the execution status")}
)
message = fields.String(metadata={"description": _("Success message")})

View File

@@ -196,6 +196,12 @@ class SemanticView(AuditMixinNullable, Model):
__tablename__ = "semantic_views"
# Semantic views expose pre-defined metrics and dimensions, not raw rows,
# so neither the "Samples" tab in Explore nor the "Drill to detail"
# affordance from the chart 3-dots menu can return anything meaningful.
supports_samples: bool = False
supports_drill_to_detail: bool = False
# Use integer as the primary key for cross-database auto-increment
# compatibility (sa.Identity() is not supported in MySQL or SQLite).
# The uuid column is a secondary unique identifier used in URLs and perms.
@@ -393,6 +399,8 @@ class SemanticView(AuditMixinNullable, Model):
"filter_select_enabled": True,
"sql": None,
"select_star": None,
"supports_samples": self.supports_samples,
"supports_drill_to_detail": self.supports_drill_to_detail,
"owners": [],
"description": self.description,
"table_name": self.name,

View File

@@ -338,6 +338,11 @@ class ExplorableData(TypedDict, total=False):
extra: str | None
always_filter_main_dttm: bool
normalize_columns: bool
# Set by datasources that cannot return raw row samples (e.g. semantic
# views, which only expose pre-defined metrics and dimensions).
supports_samples: bool
# Set by datasources that cannot answer drill-to-detail requests.
supports_drill_to_detail: bool
VizData: TypeAlias = list[Any] | dict[Any, Any] | None

File diff suppressed because it is too large Load Diff

View File

@@ -208,6 +208,23 @@ class Datasource(BaseSupersetView):
payload = SamplesPayloadSchema().load(request.json)
except ValidationError as err:
return json_error_response(err.messages, status=400)
# Refuse early for datasource types that don't model raw rows
# (e.g. semantic views, which only expose pre-defined metrics and
# dimensions). Without this gate the request would still go through
# the standard query pipeline and fail with an opaque 500.
# ``supports_samples`` defaults to True for any datasource class that
# doesn't explicitly opt out, so SqlaTable/Query/SavedQuery continue
# to work without needing the attribute declared on each class.
ds_class = DatasourceDAO.sources.get(
DatasourceType(params["datasource_type"]),
)
if ds_class is not None and not getattr(ds_class, "supports_samples", True):
return json_error_response(
_("Samples are not available for this datasource type."),
status=400,
)
dashboard_id = None
if security_manager.is_guest_user():
if not params["dashboard_id"]:

View File

@@ -21,6 +21,8 @@ from datetime import datetime, timedelta
from typing import Any
from unittest.mock import patch
from kombu.exceptions import OperationalError as KombuOperationalError
import pytz
import pytest
@@ -338,7 +340,8 @@ class TestReportSchedulesApi(SupersetTestCase):
assert "can_read" in data["permissions"]
assert "can_write" in data["permissions"]
assert "can_subscribe" in data["permissions"]
assert len(data["permissions"]) == 3
assert "can_execute" in data["permissions"]
assert len(data["permissions"]) == 4
@pytest.mark.usefixtures("create_report_schedules")
def test_get_report_schedule_not_found(self):
@@ -2885,3 +2888,94 @@ class TestReportSchedulesApi(SupersetTestCase):
db.session.delete(report_b)
db.session.delete(dashboard)
db.session.commit()
@pytest.mark.usefixtures("create_report_schedules")
@patch("superset.tasks.scheduler.execute.apply_async")
def test_execute_report_schedule(self, mock_execute: Any) -> None:
"""
ReportSchedule Api: Test execute report schedule
"""
report_schedule = (
db.session.query(ReportSchedule)
.filter(ReportSchedule.name == "name1")
.one_or_none()
)
assert report_schedule is not None
self.login(ADMIN_USERNAME)
uri = f"api/v1/report/{report_schedule.id}/execute"
rv = self.client.post(uri)
assert rv.status_code == 200
data = json.loads(rv.data.decode("utf-8"))
assert "execution_id" in data
assert "message" in data
assert data["message"] == "Report schedule execution started successfully"
mock_execute.assert_called_once()
call_args = mock_execute.call_args
# First positional arg is the tuple of task args
assert call_args[0][0] == (report_schedule.id,)
# eta must be set so the downstream task receives a valid scheduled_dttm
assert "eta" in call_args[1]
assert call_args[1]["eta"] is not None
@pytest.mark.usefixtures("create_report_schedules")
def test_execute_report_schedule_not_found(self) -> None:
"""
ReportSchedule Api: Test execute report schedule not found
"""
self.login(ADMIN_USERNAME)
uri = "api/v1/report/9999999/execute"
rv = self.client.post(uri)
assert rv.status_code == 404
@pytest.mark.usefixtures("create_report_schedules")
def test_execute_report_schedule_not_owned(self) -> None:
"""
ReportSchedule Api: Test execute report schedule forbidden for non-owner
"""
report_schedule = (
db.session.query(ReportSchedule)
.filter(ReportSchedule.name == "name1")
.one_or_none()
)
assert report_schedule is not None
self.login(GAMMA_USERNAME)
uri = f"api/v1/report/{report_schedule.id}/execute"
rv = self.client.post(uri)
assert rv.status_code == 403
@with_feature_flags(ALERT_REPORTS=False)
def test_execute_report_schedule_feature_disabled(self) -> None:
"""
ReportSchedule Api: Test execute returns 404 when ALERT_REPORTS
feature is disabled
"""
self.login(ADMIN_USERNAME)
uri = "api/v1/report/1/execute"
rv = self.client.post(uri)
assert rv.status_code == 404
@pytest.mark.usefixtures("create_report_schedules")
@patch("superset.tasks.scheduler.execute.apply_async")
def test_execute_report_schedule_celery_error(self, mock_execute: Any) -> None:
"""
ReportSchedule Api: Test execute returns 503 when Celery broker is unreachable
"""
mock_execute.side_effect = KombuOperationalError("broker connection refused")
report_schedule = (
db.session.query(ReportSchedule)
.filter(ReportSchedule.name == "name1")
.one_or_none()
)
assert report_schedule is not None
self.login(ADMIN_USERNAME)
uri = f"api/v1/report/{report_schedule.id}/execute"
rv = self.client.post(uri)
assert rv.status_code == 503
data = json.loads(rv.data.decode("utf-8"))
assert "Celery" in data["message"]
assert "broker" in data["message"].lower()

View File

@@ -0,0 +1,198 @@
# 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 sys
from unittest.mock import MagicMock, patch
from uuid import UUID
import pytest
from kombu.exceptions import OperationalError as KombuOperationalError
from superset.commands.report.exceptions import (
ReportScheduleCeleryNotConfiguredError,
ReportScheduleExecuteNowFailedError,
ReportScheduleForbiddenError,
ReportScheduleNotFoundError,
)
from superset.exceptions import SupersetSecurityException
def _make_mock_schedule(*, working_timeout: int | None = None) -> MagicMock:
"""Return a minimal mock ReportSchedule."""
mock_schedule = MagicMock()
mock_schedule.id = 1
mock_schedule.name = "Test Report"
mock_schedule.working_timeout = working_timeout
return mock_schedule
def test_execute_now_success() -> None:
"""Command returns a valid UUID string and calls apply_async on success."""
mock_task = MagicMock()
mock_scheduler = MagicMock()
mock_scheduler.execute = mock_task
with patch.dict(sys.modules, {"superset.tasks.scheduler": mock_scheduler}):
from superset.commands.report.execute_now import ExecuteReportScheduleNowCommand
with (
patch(
"superset.commands.report.execute_now.ReportScheduleDAO.find_by_id",
return_value=_make_mock_schedule(),
),
patch(
"superset.commands.report.execute_now.security_manager"
".raise_for_ownership"
),
):
command = ExecuteReportScheduleNowCommand(1)
result = command.run()
# Result is a valid UUID string.
UUID(result)
mock_task.apply_async.assert_called_once()
positional_args, keyword_args = mock_task.apply_async.call_args
assert positional_args[0] == (1,)
assert keyword_args["task_id"] == result
assert "eta" in keyword_args
def test_execute_now_not_found() -> None:
"""Command raises ReportScheduleNotFoundError when the schedule is missing."""
mock_scheduler = MagicMock()
with patch.dict(sys.modules, {"superset.tasks.scheduler": mock_scheduler}):
from superset.commands.report.execute_now import ExecuteReportScheduleNowCommand
with patch(
"superset.commands.report.execute_now.ReportScheduleDAO.find_by_id",
return_value=None,
):
command = ExecuteReportScheduleNowCommand(999)
with pytest.raises(ReportScheduleNotFoundError):
command.run()
def test_execute_now_forbidden() -> None:
"""Command raises ReportScheduleForbiddenError when the caller is not an owner."""
mock_scheduler = MagicMock()
with patch.dict(sys.modules, {"superset.tasks.scheduler": mock_scheduler}):
from superset.commands.report.execute_now import ExecuteReportScheduleNowCommand
with (
patch(
"superset.commands.report.execute_now.ReportScheduleDAO.find_by_id",
return_value=_make_mock_schedule(),
),
patch(
"superset.commands.report.execute_now.security_manager"
".raise_for_ownership",
side_effect=SupersetSecurityException(MagicMock(message="Forbidden")),
),
):
command = ExecuteReportScheduleNowCommand(1)
with pytest.raises(ReportScheduleForbiddenError):
command.run()
def test_execute_now_celery_not_configured() -> None:
"""Command raises ReportScheduleCeleryNotConfiguredError on broker error."""
mock_task = MagicMock()
mock_task.apply_async.side_effect = KombuOperationalError(
"broker connection refused"
)
mock_scheduler = MagicMock()
mock_scheduler.execute = mock_task
with patch.dict(sys.modules, {"superset.tasks.scheduler": mock_scheduler}):
from superset.commands.report.execute_now import ExecuteReportScheduleNowCommand
with (
patch(
"superset.commands.report.execute_now.ReportScheduleDAO.find_by_id",
return_value=_make_mock_schedule(),
),
patch(
"superset.commands.report.execute_now.security_manager"
".raise_for_ownership"
),
):
command = ExecuteReportScheduleNowCommand(1)
with pytest.raises(ReportScheduleCeleryNotConfiguredError):
command.run()
def test_execute_now_unexpected_failure() -> None:
"""Command raises ReportScheduleExecuteNowFailedError on unexpected errors."""
mock_task = MagicMock()
mock_task.apply_async.side_effect = RuntimeError("unexpected task error")
mock_scheduler = MagicMock()
mock_scheduler.execute = mock_task
with patch.dict(sys.modules, {"superset.tasks.scheduler": mock_scheduler}):
from superset.commands.report.execute_now import ExecuteReportScheduleNowCommand
with (
patch(
"superset.commands.report.execute_now.ReportScheduleDAO.find_by_id",
return_value=_make_mock_schedule(),
),
patch(
"superset.commands.report.execute_now.security_manager"
".raise_for_ownership"
),
):
command = ExecuteReportScheduleNowCommand(1)
with pytest.raises(ReportScheduleExecuteNowFailedError):
command.run()
def test_execute_now_sets_time_limit_when_working_timeout_configured() -> None:
"""Command includes time_limit in async options when working_timeout is set."""
mock_task = MagicMock()
mock_scheduler = MagicMock()
mock_scheduler.execute = mock_task
with patch.dict(sys.modules, {"superset.tasks.scheduler": mock_scheduler}):
from superset.commands.report.execute_now import ExecuteReportScheduleNowCommand
with (
patch(
"superset.commands.report.execute_now.ReportScheduleDAO.find_by_id",
return_value=_make_mock_schedule(working_timeout=300),
),
patch(
"superset.commands.report.execute_now.security_manager"
".raise_for_ownership"
),
patch("superset.commands.report.execute_now.current_app") as mock_app,
):
mock_app.config = {
"ALERT_REPORTS_WORKING_TIME_OUT_KILL": True,
"ALERT_REPORTS_WORKING_TIME_OUT_LAG": 10,
"ALERT_REPORTS_WORKING_SOFT_TIME_OUT_LAG": 5,
}
command = ExecuteReportScheduleNowCommand(1)
command.run()
_, keyword_args = mock_task.apply_async.call_args
assert "time_limit" in keyword_args
assert keyword_args["time_limit"] == 310 # working_timeout(300) + LAG(10)
assert "soft_time_limit" in keyword_args
assert keyword_args["soft_time_limit"] == 305 # working_timeout(300) + SOFT_LAG(5)

View File

@@ -0,0 +1,203 @@
# 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.
"""Tests for BaseDAO.apply_column_operators with relationship columns.
`apply_column_operators` historically only worked on scalar columns. When a
caller asked for `{col: "<m2m_relationship>", opr: "eq", value: <id>}` the
SQLAlchemy backend raised "Can't compare a collection to an object."
The behavior now: detect the relationship attribute and dispatch to
`column.any(<related_pk> == value)`, so callers can use the natural shape
for both scalar columns and m2m relationships.
"""
from unittest.mock import MagicMock
import pytest
from superset.daos.base import BaseDAO, ColumnOperator, ColumnOperatorEnum
from superset.models.slice import Slice
class _SliceDAO(BaseDAO[Slice]):
"""Tiny concrete DAO so we can exercise the BaseDAO logic against the
real Slice model + its m2m `dashboards` relationship without standing up
a full database."""
model_cls = Slice
filterable_relationships = frozenset({"dashboards"})
class _SliceDAONoRelationships(BaseDAO[Slice]):
"""Same model but with no relationships opted into discovery. Used to
verify the default behavior (empty ``filterable_relationships``)
keeps relationship columns out of the schema-discovery output."""
model_cls = Slice
class TestApplyColumnOperatorsRelationship:
"""`apply_column_operators` handles m2m relationship columns via .any()."""
def test_eq_on_relationship_dispatches_to_any(self):
"""`{col: 'dashboards', opr: 'eq', value: 42}` calls .any() on the
relationship and compares the related model's PK to 42."""
mock_query = MagicMock()
mock_query.filter.return_value = mock_query
result = _SliceDAO.apply_column_operators(
mock_query,
[ColumnOperator(col="dashboards", opr=ColumnOperatorEnum.eq, value=42)],
)
assert result is mock_query
# We applied exactly one filter
assert mock_query.filter.call_count == 1
# And the argument is a SQLAlchemy BinaryExpression that survives
# being passed to .filter() — we can't easily inspect its structure
# without rendering SQL, but we can verify the dispatch happened
# without raising.
def test_eq_on_scalar_column_unchanged(self):
"""Non-relationship columns continue using the old scalar path."""
mock_query = MagicMock()
mock_query.filter.return_value = mock_query
_SliceDAO.apply_column_operators(
mock_query,
[
ColumnOperator(
col="slice_name", opr=ColumnOperatorEnum.eq, value="Revenue"
)
],
)
assert mock_query.filter.call_count == 1
def test_invalid_column_still_raises(self):
"""Columns that don't exist on the model raise ValueError as before."""
mock_query = MagicMock()
with pytest.raises(ValueError, match="does not exist on Slice"):
_SliceDAO.apply_column_operators(
mock_query,
[
ColumnOperator(
col="does_not_exist", opr=ColumnOperatorEnum.eq, value=1
)
],
)
@pytest.mark.parametrize(
"operator",
[
ColumnOperatorEnum.eq,
ColumnOperatorEnum.ne,
ColumnOperatorEnum.in_,
ColumnOperatorEnum.nin,
ColumnOperatorEnum.is_null,
ColumnOperatorEnum.is_not_null,
],
)
def test_supported_relationship_operators_dispatch(self, operator):
"""eq/ne/in/nin/is_null/is_not_null all dispatch to .any() variants."""
mock_query = MagicMock()
mock_query.filter.return_value = mock_query
_SliceDAO.apply_column_operators(
mock_query,
[ColumnOperator(col="dashboards", opr=operator, value=[1, 2])],
)
assert mock_query.filter.call_count == 1
@pytest.mark.parametrize(
"operator",
[
ColumnOperatorEnum.sw,
ColumnOperatorEnum.ew,
ColumnOperatorEnum.like,
ColumnOperatorEnum.ilike,
ColumnOperatorEnum.gt,
ColumnOperatorEnum.gte,
ColumnOperatorEnum.lt,
ColumnOperatorEnum.lte,
],
)
def test_unsupported_relationship_operators_raise(self, operator):
"""sw/ew/like/ilike/gt/gte/lt/lte on a relationship raise a clear
error instead of producing a cryptic SQLAlchemy error at query time."""
mock_query = MagicMock()
with pytest.raises(ValueError, match="not supported on relationship column"):
_SliceDAO.apply_column_operators(
mock_query,
[ColumnOperator(col="dashboards", opr=operator, value="x")],
)
class TestRelationshipFilterDiscovery:
"""`get_filterable_columns_and_operators` advertises relationship columns
so schema discovery (get_schema) stays in sync with runtime behavior."""
def test_dashboards_relationship_appears_in_filter_metadata(self):
"""get_schema(model_type='chart') will now show dashboards as a
valid filter column. Previously the runtime accepted it but
discovery didn't list it — an API contract mismatch."""
filterable = _SliceDAO.get_filterable_columns_and_operators()
assert "dashboards" in filterable, (
"schema discovery should advertise the dashboards relationship"
)
def test_relationship_operators_match_runtime_dispatch(self):
"""The set of operators advertised for a relationship column matches
the set ``_apply_relationship_filter`` actually handles. If these
ever drift, callers following schema discovery will get runtime
errors on operators that look valid."""
filterable = _SliceDAO.get_filterable_columns_and_operators()
ops = set(filterable["dashboards"])
assert ops == {"eq", "ne", "in", "nin", "is_null", "is_not_null"}
def test_relationships_not_advertised_by_default(self):
"""``filterable_relationships`` is empty by default — a DAO that
does not opt-in keeps relationship columns out of discovery, so
the consumer's ``Literal`` allowlist isn't contradicted by a
wider discovery output."""
filterable = _SliceDAONoRelationships.get_filterable_columns_and_operators()
assert "dashboards" not in filterable
assert "owners" not in filterable
assert "tags" not in filterable
def test_only_opted_in_relationships_advertised(self):
"""A DAO that opts into only specific relationships advertises
exactly those, even if the model has additional collection
relationships SQLAlchemy could reflect on."""
filterable = _SliceDAO.get_filterable_columns_and_operators()
# Slice has many collection relationships (owners, tags, dashboards,
# ...). Only the opted-in one appears in discovery.
assert "dashboards" in filterable
assert "owners" not in filterable
assert "tags" not in filterable
def test_chart_dao_opts_into_dashboards(self):
"""ChartDAO is the production DAO behind ``list_charts``;
verify it advertises exactly the same relationship the tool's
``ChartFilter.col`` Literal exposes."""
from superset.daos.chart import ChartDAO
filterable = ChartDAO.get_filterable_columns_and_operators()
assert "dashboards" in filterable
# Other relationships on Slice must not leak into discovery.
assert "owners" not in filterable
assert "tags" not in filterable

View File

@@ -184,6 +184,29 @@ class TestListChartsRequestSchema:
with pytest.raises(ValueError, match="Field required"):
ChartFilter(col="slice_name") # Missing opr and value
def test_dashboards_filter_accepted(self):
"""`dashboards` is a valid filter column for finding charts on a dashboard."""
# The filter is accepted at schema-validation time
f = ChartFilter(col="dashboards", opr="eq", value=42)
assert f.col == "dashboards"
assert f.opr.value == "eq"
assert f.value == 42
# And composes into a request like any other filter
request = ListChartsRequest(filters=[f])
assert request.filters[0].col == "dashboards"
def test_invalid_filter_column_rejected(self):
"""Unknown filter columns are rejected by the literal."""
with pytest.raises(
ValueError,
match=(
"Input should be 'slice_name', 'viz_type', 'datasource_name', "
"'created_by_fk', 'changed_by_fk' or 'dashboards'"
),
):
ChartFilter(col="nonexistent_column", opr="eq", value=1)
def test_search_and_filters_conflict_validation(self):
"""Test that using both search and filters raises validation error."""
with pytest.raises(

View File

@@ -653,6 +653,20 @@ def test_semantic_view_data(
assert data["table_name"] == "Orders View"
assert data["datasource_name"] == "Orders View"
assert data["offset"] == 0
# Semantic views don't model raw rows, so neither samples nor
# drill-to-detail are available.
assert data["supports_samples"] is False
assert data["supports_drill_to_detail"] is False
def test_semantic_view_supports_samples_is_false() -> None:
"""The class-level flag opts SemanticView out of the Samples affordance."""
assert SemanticView.supports_samples is False
def test_semantic_view_supports_drill_to_detail_is_false() -> None:
"""The class-level flag opts SemanticView out of Drill to detail."""
assert SemanticView.supports_drill_to_detail is False
def test_semantic_view_get_query_result(

View File

@@ -312,3 +312,66 @@ def test_save_non_owner_with_owners_field_is_rejected(
raw_save(_view_self())
mock_security_manager.raise_for_ownership.assert_called_once_with(mock_orm)
# ---------------------------------------------------------------------------
# Datasource.samples
# ---------------------------------------------------------------------------
@patch("superset.views.datasource.views._", lambda s: s)
@patch("superset.views.datasource.views.get_samples")
@patch("superset.views.datasource.views.json_error_response")
@patch("superset.views.datasource.views.security_manager", new_callable=MagicMock)
def test_samples_returns_400_for_unsupported_datasource_type(
mock_security_manager: MagicMock,
mock_json_error_response: MagicMock,
mock_get_samples: MagicMock,
) -> None:
"""Semantic views can't return raw samples — endpoint should refuse with 400."""
from flask import Flask
mock_security_manager.is_guest_user.return_value = False
mock_json_error_response.return_value = "error-response"
raw_samples = _get_view_func("samples")
app = Flask(__name__)
with app.test_request_context(
"/datasource/samples?datasource_type=semantic_view&datasource_id=1",
method="POST",
json={},
):
result = raw_samples(_view_self())
assert result == "error-response"
mock_json_error_response.assert_called_once()
_, kwargs = mock_json_error_response.call_args
assert kwargs.get("status") == 400
# The bail-out must happen before any sample fetching is attempted.
mock_get_samples.assert_not_called()
@patch("superset.views.datasource.views.get_samples")
@patch("superset.views.datasource.views.security_manager", new_callable=MagicMock)
def test_samples_proceeds_for_supported_datasource_type(
mock_security_manager: MagicMock,
mock_get_samples: MagicMock,
) -> None:
"""A `query` datasource (supports_samples=True) bypasses the 400 short-circuit."""
from flask import Flask
mock_security_manager.is_guest_user.return_value = False
mock_get_samples.return_value = {"rows": []}
view = _view_self()
raw_samples = _get_view_func("samples")
app = Flask(__name__)
with app.test_request_context(
"/datasource/samples?datasource_type=query&datasource_id=1",
method="POST",
json={},
):
raw_samples(view)
mock_get_samples.assert_called_once()
view.json_response.assert_called_once_with({"result": {"rows": []}})