mirror of
https://github.com/apache/superset.git
synced 2026-07-03 05:15:35 +00:00
Compare commits
13 Commits
dashboard-
...
chart-samp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48958cc8ce | ||
|
|
7dac6c1d4f | ||
|
|
a1297d10ac | ||
|
|
35365d639d | ||
|
|
7e17c70cba | ||
|
|
0d43c2c12c | ||
|
|
7410ff73c0 | ||
|
|
f08f068240 | ||
|
|
2b09b6bc1d | ||
|
|
d763255e15 | ||
|
|
8fed514e79 | ||
|
|
c94bc7178f | ||
|
|
95ecdd3753 |
177
.github/workflows/scheduled-docker-image-refresh.yml
vendored
Normal file
177
.github/workflows/scheduled-docker-image-refresh.yml
vendored
Normal 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 3–6 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}"
|
||||
6
.github/workflows/tag-release.yml
vendored
6
.github/workflows/tag-release.yml
vendored
@@ -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
|
||||
|
||||
24
superset-frontend/package-lock.json
generated
24
superset-frontend/package-lock.json
generated
@@ -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": [
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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']));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" />,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'] = [];
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
134
superset/commands/report/execute_now.py
Normal file
134
superset/commands/report/execute_now.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ CHART_CUSTOM_FIELDS = {
|
||||
|
||||
class ChartDAO(BaseDAO[Slice]):
|
||||
base_filter = ChartFilter
|
||||
filterable_relationships = frozenset({"dashboards"})
|
||||
|
||||
@classmethod
|
||||
def apply_column_operators(
|
||||
|
||||
@@ -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(
|
||||
...,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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")})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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"]:
|
||||
|
||||
@@ -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()
|
||||
|
||||
198
tests/unit_tests/commands/report/test_execute_now.py
Normal file
198
tests/unit_tests/commands/report/test_execute_now.py
Normal 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)
|
||||
203
tests/unit_tests/daos/test_base_relationship_filters.py
Normal file
203
tests/unit_tests/daos/test_base_relationship_filters.py
Normal 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
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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": []}})
|
||||
|
||||
Reference in New Issue
Block a user