Compare commits

...
Author SHA1 Message Date
sadpandajoeandClaude Sonnet 5 b42b67aea9 fix(explore): clear time_grain_sqla for unaggregated ag-grid table charts
The AG Grid Table chart plugin (behind the opt-in AG_GRID_TABLE_ENABLED
feature flag) has the exact same time_grain_sqla visibility bug as the
classic Table chart: visibility only checks the groupby control's
(possibly stale) value for a temporal column, never the query mode, so
a chart built first as aggregated kept a stale time grain visible and
applied after switching to Raw Records.

Apply the same fix as the classic Table plugin: gate the visibility on
isAggMode, which is already defined locally in this file. Add a
regression test mirroring the one added for plugin-chart-table,
confirmed RED before this change and GREEN after.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 20:42:36 +00:00
sadpandajoeandClaude Sonnet 5 2362ffc815 docs(rca): add ASF license header to RCA.md
Root-level Markdown docs in this repo (README.md, CONTRIBUTING.md,
UPDATING.md, CHANGELOG.md, SECURITY.md) all carry the standard ASF
license header, and RCA.md has no matching entry in .rat-excludes, so
it needs the same header to pass the Apache RAT license check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 20:42:12 +00:00
sadpandajoeandClaude Sonnet 5 1b0094565b fix(explore): clear time_grain_sqla for unaggregated table charts
The Table chart's time_grain_sqla visibility only checked whether the
groupby control held a temporal column, never the query mode. Since
groupby's own value survives a switch to Raw Records mode (it is
intentionally not reset so users can switch back), a chart built first
as an aggregated view kept time_grain_sqla visible and populated after
switching to Raw Records, so the stale time grain was still sent with
the query and used to format the column's raw values.

Gate the visibility on aggregate query mode, matching every other
query-mode-dependent control in this file (groupby, metrics,
percent_metrics, timeseries_limit_metric, show_totals,
totals_aggregate). Once hidden, the existing stash mechanism removes
time_grain_sqla from the outgoing form data.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 20:26:31 +00:00
sadpandajoeandClaude Sonnet 5 85a5f273b7 test(table): pin RCA and RED regression guard for stale time_grain_sqla in raw records mode
Adds RCA.md documenting the root cause: the Table chart's time_grain_sqla
control visibility only checks the (possibly stale) groupby value and
never checks query_mode, unlike every sibling query-mode-dependent
control in the same file. Adds a failing unit test pinning the exact
mechanism before the fix lands.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 20:23:41 +00:00
dependabot[bot] a6cbd7c3b5 chore(deps-dev): bump typescript-eslint from 8.67.0 to 8.68.0 in /superset-websocket (#43742)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-03 12:14:31 -07:00
dependabot[bot] 5d8ff9181a chore(deps-dev): bump the typescript-eslint group across 1 directory with 2 updates (#43744)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-03 12:14:28 -07:00
dependabot[bot] c91c901832 chore(deps): bump baseline-browser-mapping from 2.11.18 to 2.11.19 in /docs (#43747)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-03 12:14:22 -07:00
dependabot[bot] 232ccf66d6 chore(deps-dev): bump eslint from 10.9.0 to 10.9.1 in /superset-frontend (#43749)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-03 12:14:18 -07:00
dependabot[bot] dd1a475df6 chore(deps-dev): bump baseline-browser-mapping from 2.11.18 to 2.11.19 in /superset-frontend (#43750)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-03 12:14:14 -07:00
9828281e7f fix(charts): exclude string helpers from post_processing operation allowlist (#43345)
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 11:48:29 -07:00
b558f41461 feat(dashboard): convert @import to inline CSS in the dashboard CSS editor (#43667)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-09-03 11:46:26 -07:00
Evan RusackasandClaude Sonnet 5 28578160ff docs(pivot-table): document DB-computed totals/subtotals behavior (#43636)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 11:44:19 -07:00
d2d965e009 fix(navbar): use down-chevron icon consistently on all navbar dropdowns (#43531)
Co-authored-by: Juliann Forbes <juliann@preset.io>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 10:00:18 -07:00
dependabot[bot] a95c40aa1e chore(deps): bump qs from 6.15.2 to 6.16.0 in /superset-websocket/utils/client-ws-app in the security group across 1 directory (#43824)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-03 21:42:10 +07:00
dependabot[bot] d9887fd928 chore(deps): bump js-yaml from 5.4.0 to 5.4.1 in /docs (#43816)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-03 21:06:19 +07:00
dependabot[bot]andJoe Li 669a0cefb4 chore(deps): bump the security group across 1 directory with 2 updates (#43797)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-09-03 21:05:59 +07:00
dependabot[bot] 0398b320f9 chore(deps): bump fast-uri from 3.1.5 to 3.1.7 in /docs in the security group across 1 directory (#43798)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-03 21:05:24 +07:00
dependabot[bot] 9d855a4adf chore(deps-dev): bump @types/node from 26.2.0 to 26.4.0 in /superset-websocket (#43815)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-03 21:05:00 +07:00
dependabot[bot] 3bd0ad5267 chore(deps): bump react-intersection-observer from 11.0.0 to 11.0.1 in /superset-frontend (#43817)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-03 21:04:40 +07:00
dependabot[bot] 464adba69b chore(deps-dev): bump minimizer-webpack-plugin from 5.6.1 to 5.7.0 in /superset-frontend (#43818)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-03 21:04:18 +07:00
dependabot[bot] c185a2c678 chore(deps-dev): bump @types/node from 26.3.0 to 26.4.0 in /superset-frontend (#43819)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-03 21:03:47 +07:00
bf12a21367 fix(charts): sort chart types by display name (#43634)
Co-authored-by: Matt Fitzgerald <matt.fitzgerald@preset.io>
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
2026-09-03 12:32:01 +02:00
Jyun-An ChenandEnzo Martellucci 18465bad51 perf(dashboard): batch dataset lookups during dashboard export (#43017)
Co-authored-by: Enzo Martellucci <52219496+EnxDev@users.noreply.github.com>
2026-09-03 11:07:47 +02:00
39 changed files with 2205 additions and 414 deletions
+117
View File
@@ -0,0 +1,117 @@
<!--
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.
-->
## What Happened
Reproduction steps:
1. Create a Line chart on a dataset with a temporal column (e.g. `order_date`), set that column as the X-axis with **Month** as Time Grain, add a metric, and create the chart. This sets `groupby` to the temporal column and `time_grain_sqla` to `P1M`.
2. Switch the visualization type to **Table**.
3. Switch **Query Mode** to **Raw Records**.
4. Add the same temporal column as a column, then update the chart.
Expected: the temporal column renders raw (unaggregated) values, and the outgoing query for the chart data does not carry a time grain.
Actual: the outgoing request body's query object still contains `time_grain_sqla: "P1M"`, and the temporal column is rendered with monthly-grain formatting even though the SQL result rows are unaggregated raw records.
## Root Cause
`verified``superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx`, the `time_grain_sqla` control override (previously lines 267-295): its `visibility` function only checks whether the current `groupby` control value contains a temporal column. It never checks `query_mode`, unlike every sibling query-mode-dependent control in the same file (`groupby`, `metrics`, `percent_metrics`, `timeseries_limit_metric`, `show_totals`, `totals_aggregate`, all of which gate on `isAggMode`/`isRawMode`).
`groupby`'s own visibility is `isAggMode` with `resetOnHide: false` (line 241-242), so when the query mode is switched to Raw Records, `groupby`'s control row is hidden from the UI but its underlying value in `form_data` is deliberately *not* cleared (this lets a user switch back to Aggregate mode without losing their prior groupby selection). Because `time_grain_sqla`'s visibility only inspects `groupby`'s stale value, it keeps evaluating to `true` in Raw Records mode whenever that stale value happens to be a temporal column — which is exactly the case in this repro, since the user built the chart as an aggregated Line chart first.
Because the control is (incorrectly) still "visible", `StashFormDataContainer` (`superset-frontend/src/explore/components/StashFormDataContainer/index.tsx`) never stashes `time_grain_sqla` out of `form_data`, so the stale `P1M` value survives in the redux `form_data` used to build the outgoing query. The Table plugin's `buildQuery` (`superset-frontend/plugins/plugin-chart-table/src/buildQuery.ts:70-71`) then unconditionally reads `formData.time_grain_sqla` and passes it into `buildQueryContext`, which places it on the query object's `extras.time_grain_sqla` (`superset-frontend/packages/superset-ui-core/src/query/extractExtras.ts:76`) regardless of query mode — the core query builder has no concept of "query mode" and relies entirely on each chart plugin's `buildQuery` to omit fields that don't apply. The Table plugin only strips time-grain-derived behavior from the AGGREGATE branch (`buildQuery.ts:138`, `queryMode === QueryMode.Aggregate`); it never clears `time_grain_sqla` itself for the RAW branch.
On the render side, `transformProps.ts` already has a `queryMode !== QueryMode.Raw` guard (line 282) around applying granularity-based smart-date formatting, but that guard only prevents the *smart-date* auto-format path — it does not stop `time_grain_sqla` from being sent to (and, depending on backend/DB codec behavior, applied by) the query itself, which is what the ticket's network trace observed.
## Why It Wasn't Caught
Test gap: `plugins/plugin-chart-table/test/controlPanel.test.ts` only tested the case-insensitivity of the `groupby`-to-`is_dttm` lookup (added in PR #37893); no test exercised `query_mode` at all for this control. `git log -p --follow` on `controlPanel.tsx` shows this `visibility` function has never taken `query_mode`/`isAggMode` into account since it was first introduced in PR #21547 (2022) — the row containing the control was originally gated by `isFeatureEnabled(FeatureFlag.GENERIC_CHART_AXES) && isAggMode`, but `isAggMode` there is a function reference used in a boolean expression at module-eval time, not a call — so it was always truthy and never actually restricted anything to aggregate mode, even at introduction. That flag-gating wrapper (and the identical no-op `isAggMode` reference) was removed entirely in PR #26372 once `GENERIC_CHART_AXES` was made the default, leaving only the feature-flag-free `visibility` function seen today, which still never calls `isAggMode`/`isRawMode`. In other words, this was a latent gap from the control's original implementation, not a regression from an intentional design change.
Assumption gap: the author of the visibility function assumed "the `groupby` control holds a temporal column" was a sufficient proxy for "this chart is doing time-grain aggregation on that column," which is true only in AGGREGATE mode; the RAW_RECORDS query mode (where `groupby` is hidden and stale) was not considered.
## The Fix
The identical fix is applied in both `superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx` and `superset-frontend/plugins/plugin-chart-ag-grid-table/src/controlPanel.tsx` (see the sibling-plugin note above): the `time_grain_sqla` control override's `visibility` function.
Before:
```tsx
visibility: ({ controls }) => {
const dttmLookup = Object.fromEntries(
ensureIsArray(controls?.groupby?.options).map(option => [
(option.column_name || '').toLowerCase(),
option.is_dttm,
]),
);
return ensureIsArray(controls?.groupby.value)
.map(selection => {
if (isAdhocColumn(selection)) {
return true;
}
if (isPhysicalColumn(selection)) {
return !!dttmLookup[(selection || '').toLowerCase()];
}
return false;
})
.some(Boolean);
},
```
After: short-circuit to `false` unless the chart is in Aggregate query mode, using the same `isAggMode` helper every sibling control in this file already uses:
```tsx
visibility: ({ controls }) => {
if (!isAggMode({ controls })) {
return false;
}
const dttmLookup = Object.fromEntries(
ensureIsArray(controls?.groupby?.options).map(option => [
(option.column_name || '').toLowerCase(),
option.is_dttm,
]),
);
return ensureIsArray(controls?.groupby.value)
.map(selection => {
if (isAdhocColumn(selection)) {
return true;
}
if (isPhysicalColumn(selection)) {
return !!dttmLookup[(selection || '').toLowerCase()];
}
return false;
})
.some(Boolean);
},
```
With this change, switching to Raw Records mode makes `time_grain_sqla`'s visibility evaluate to `false` regardless of `groupby`'s stale value. `StashFormDataContainer` then stashes `time_grain_sqla` out of `form_data` (its default `disableStash` is unset and it does not opt out), so the outgoing query for a Raw Records table no longer carries a time grain, and the temporal column renders as raw, unformatted-by-grain data. Switching back to Aggregate mode (with a temporal `groupby` column) restores `time_grain_sqla`'s visibility and its previously stashed value, so the existing aggregated-table behavior is unchanged.
This fix does not touch the per-column `CUSTOMIZE` → D3 time format override path (`column_config` / `d3TimeFormat` in `transformProps.ts`), which continues to take precedence over any grain-derived formatting exactly as before.
`verified` — the sibling `plugin-chart-ag-grid-table` plugin (`superset-frontend/plugins/plugin-chart-ag-grid-table/src/controlPanel.tsx`) has the exact same `time_grain_sqla` visibility function, byte-for-byte the same logic as the pre-fix `plugin-chart-table` version, including the same `groupby` `resetOnHide: false` setup and a locally-defined `isAggMode` helper already in scope. It is registered in `superset-frontend/src/visualizations/presets/MainPreset.ts` as the `VizType.TableAgGrid` chart type behind `FeatureFlag.AgGridTableEnabled` (`AG_GRID_TABLE_ENABLED`, default `False` in `superset/config.py`). This flag is opt-in rather than dead/unshipped code — any deployment that enables it exposes a second, user-selectable "Table" style viz type through the same viz-picker/Explore flow, so it reproduces this bug identically once enabled. It is fixed with the identical change described above, rather than treated as an unreachable latent bug.
## Latent Bugs Found
- The same stale-`groupby`-value pattern (`resetOnHide: false`) is intentional and shared by several other controls in both `controlPanel.tsx` files (`metrics`, `percent_metrics`, `timeseries_limit_metric`, `order_by_cols`, `show_totals`, `totals_aggregate`); all of those already gate their own visibility on `isAggMode`/`isRawMode` directly, so they were not affected by this bug. Not fixed because out of scope: none found affected.
- `buildQuery.ts` (both `plugin-chart-table` and `plugin-chart-ag-grid-table`) reads `time_grain_sqla` via `extra_form_data?.time_grain_sqla || formData.time_grain_sqla` without any query-mode gate of its own; it happens to only be dangerous when `form_data.time_grain_sqla` is stale, which the controlPanel fixes now prevent. A defense-in-depth guard here (e.g. only reading `time_grain_sqla` when `queryMode === QueryMode.Aggregate`) was considered but not added, to keep this fix minimal and scoped to the one root cause.
## Prevention
Add a jest test asserting `query_mode`-dependent visibility for any control whose `visibility` function is customized per chart plugin, whenever the control also has a shared/base default visibility being overridden — a lint or contributor-doc note reminding authors that stale unrelated control values are the norm (`resetOnHide: false` is common) and any visibility override must re-derive its own query-mode gate rather than relying on another control's own gating. This regression is covered going forward by `plugins/plugin-chart-table/test/controlPanel.test.ts` and `plugins/plugin-chart-ag-grid-table/test/controlPanel.test.tsx`.
@@ -151,6 +151,20 @@ see some data!
You should see months in the rows and Department and Travel Class in the columns. Publish this chart
to your existing Tutorial Dashboard you created earlier.
:::note
Row and column totals/subtotals for the Pivot Table are correct even for non-additive metrics,
such as ratios (`SUM(a)/SUM(b)`), `COUNT_DISTINCT`, `AVG`, and percentiles, not just additive ones
like `SUM` or `COUNT`. Totals derive client-side, by reducing the same full-detail query results
used to build the table, only when every selected metric is additive; if any selected metric is
non-additive, Superset instead issues a database query at each total's own granularity for all
metrics, so the total reflects each metric's own definition evaluated at that level rather than an
incorrect combination of the displayed cells. Because of this, there's no separate "Aggregation
function" control for totals in the Pivot Table: a total always reflects the metric's own
definition. The Table chart's **Show summary** row is different: its **Summary aggregation**
control can override a simple metric's own aggregation (to Sum or Average) for the summary row
only — metrics built from custom SQL keep their own aggregation regardless.
:::
### Line Chart
In this section, we are going to create a line chart to understand the average price of a ticket by
+2 -2
View File
@@ -62,11 +62,11 @@
"@superset-ui/core": "^0.20.4",
"@swc/core": "^1.16.1",
"antd": "^6.6.1",
"baseline-browser-mapping": "^2.11.18",
"baseline-browser-mapping": "^2.11.19",
"caniuse-lite": "^1.0.30001809",
"docusaurus-plugin-openapi-docs": "^5.2.0",
"docusaurus-theme-openapi-docs": "^5.2.0",
"js-yaml": "^5.4.0",
"js-yaml": "^5.4.1",
"json-bigint": "^1.0.0",
"prism-react-renderer": "^2.4.1",
"react": "^18.3.1",
+11 -11
View File
@@ -6471,10 +6471,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
baseline-browser-mapping@^2.11.12, baseline-browser-mapping@^2.11.18, baseline-browser-mapping@^2.9.19:
version "2.11.18"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.18.tgz#49701f6ab6c58ccafb6d8e1ab2767574d9f0ba73"
integrity sha512-1iEmLEYSiE1SeBoAfPo/Mnx3PzfzHUkDK61ASkCpuk3YXugYLH5DYK1SzqV55F8FMI6s0F+/tCP7Polz1QRjxw==
baseline-browser-mapping@^2.11.12, baseline-browser-mapping@^2.11.19, baseline-browser-mapping@^2.9.19:
version "2.11.19"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.19.tgz#4711abac48b88ccb56b5817e86f1b3a9a0764276"
integrity sha512-Grytf1xOxOEMTGRwx6rLGKkTabd4vMg3VrKdj/7joCmV0qgh4QwMMO6xh34YEXQqirAuUdgQGa5orJQQ+69RBw==
batch@0.6.1:
version "0.6.1"
@@ -8509,9 +8509,9 @@ fast-safe-stringify@^2.0.7:
integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==
fast-uri@^3.0.1:
version "3.1.5"
resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.5.tgz#610f37419a030270430cecd68d74e3d4d96725d0"
integrity sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==
version "3.1.7"
resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.7.tgz#743157d957f3cbb4c65310e033dc2ad4ad7dc60a"
integrity sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==
fastq@^1.6.0:
version "1.20.1"
@@ -9703,10 +9703,10 @@ js-yaml@4.1.0, js-yaml@=4.3.1, js-yaml@^4.1.0, js-yaml@^4.2.0, js-yaml@^4.3.0:
dependencies:
argparse "^2.0.1"
js-yaml@^5.4.0:
version "5.4.0"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-5.4.0.tgz#246dae8988029a6e9b19963e3ff5a1ff6e41f344"
integrity sha512-jE7vUJIebKzYQI5xu4co5CRBDlDEYnHrdzsxs4O2giCz4v2SbVMYKpmt1D9L38OKQAeCWmrOTRiCV93u0UkaJA==
js-yaml@^5.4.1:
version "5.4.1"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-5.4.1.tgz#4629d91d4b4551f300435f0e4127899710f816fb"
integrity sha512-28R/k+NAjeuf7+CKlTxWZVExJGwVVLwY06DgEnOMz2gEpfNkDcD7QvyiVPT0xy0XXhU8vHsd4Ot42OOPdJG7dQ==
dependencies:
argparse "^2.0.1"
+127 -93
View File
@@ -115,6 +115,7 @@
"mustache": "^4.2.0",
"nanoid": "^6.0.1",
"ol": "^10.10.0",
"postcss": "^8.5.15",
"query-string": "9.5.0",
"re-resizable": "^6.11.2",
"react": "^18.3.0",
@@ -125,7 +126,7 @@
"react-dnd-html5-backend": "^11.1.3",
"react-dom": "^18.3.0",
"react-google-recaptcha": "^3.1.0",
"react-intersection-observer": "^11.0.0",
"react-intersection-observer": "^11.0.1",
"react-json-tree": "^0.20.0",
"react-lines-ellipsis": "^0.16.1",
"react-loadable": "^5.5.0",
@@ -202,7 +203,7 @@
"@types/json-bigint": "^1.0.4",
"@types/lodash-es": "^4.17.12",
"@types/mousetrap": "^1.6.15",
"@types/node": "^26.3.0",
"@types/node": "^26.4.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@types/react-loadable": "^5.5.11",
@@ -214,19 +215,19 @@
"@types/rison": "0.1.0",
"@types/tinycolor2": "^1.4.3",
"@types/unzipper": "^0.10.11",
"@typescript-eslint/eslint-plugin": "^8.67.0",
"@typescript-eslint/eslint-plugin": "^8.68.0",
"@typescript-eslint/parser": "^8.63.0",
"babel-jest": "^30.4.1",
"babel-loader": "^10.1.1",
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"baseline-browser-mapping": "^2.11.18",
"baseline-browser-mapping": "^2.11.19",
"cheerio": "1.2.0",
"concurrently": "^10.0.5",
"copy-webpack-plugin": "^14.0.0",
"cross-env": "^10.1.0",
"css-loader": "^7.1.4",
"eslint": "^10.9.0",
"eslint": "^10.9.1",
"eslint-import-resolver-alias": "^1.1.2",
"eslint-import-resolver-typescript": "^4.4.5",
"eslint-plugin-import": "^2.32.0",
@@ -251,7 +252,7 @@
"lerna": "^10.0.1",
"lightningcss": "^1.33.0",
"mini-css-extract-plugin": "^2.10.2",
"minimizer-webpack-plugin": "^5.6.1",
"minimizer-webpack-plugin": "^5.7.0",
"open-cli": "^9.0.0",
"oxfmt": "^0.65.0",
"oxlint": "^1.80.0",
@@ -12807,9 +12808,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.3.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.3.0.tgz",
"integrity": "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw==",
"version": "26.4.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz",
"integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==",
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
@@ -13250,17 +13251,17 @@
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz",
"integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==",
"version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz",
"integrity": "sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.67.0",
"@typescript-eslint/type-utils": "8.67.0",
"@typescript-eslint/utils": "8.67.0",
"@typescript-eslint/visitor-keys": "8.67.0",
"@typescript-eslint/scope-manager": "8.68.0",
"@typescript-eslint/type-utils": "8.68.0",
"@typescript-eslint/utils": "8.68.0",
"@typescript-eslint/visitor-keys": "8.68.0",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
@@ -13273,7 +13274,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.67.0",
"@typescript-eslint/parser": "^8.68.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
@@ -13289,16 +13290,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz",
"integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==",
"version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.68.0.tgz",
"integrity": "sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.67.0",
"@typescript-eslint/types": "8.67.0",
"@typescript-eslint/typescript-estree": "8.67.0",
"@typescript-eslint/visitor-keys": "8.67.0",
"@typescript-eslint/scope-manager": "8.68.0",
"@typescript-eslint/types": "8.68.0",
"@typescript-eslint/typescript-estree": "8.68.0",
"@typescript-eslint/visitor-keys": "8.68.0",
"debug": "^4.4.3"
},
"engines": {
@@ -13314,14 +13315,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz",
"integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==",
"version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.68.0.tgz",
"integrity": "sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.67.0",
"@typescript-eslint/types": "^8.67.0",
"@typescript-eslint/tsconfig-utils": "^8.68.0",
"@typescript-eslint/types": "^8.68.0",
"debug": "^4.4.3"
},
"engines": {
@@ -13336,14 +13337,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz",
"integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==",
"version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.68.0.tgz",
"integrity": "sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.67.0",
"@typescript-eslint/visitor-keys": "8.67.0"
"@typescript-eslint/types": "8.68.0",
"@typescript-eslint/visitor-keys": "8.68.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -13354,9 +13355,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz",
"integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==",
"version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.68.0.tgz",
"integrity": "sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -13371,15 +13372,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz",
"integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==",
"version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.68.0.tgz",
"integrity": "sha512-X77zqoY1EjeWGs/0JNxeaMfp5C5lIz4Tw8y66F1Ne8Faq6g424sBNYM6xBAqElfGZPLpWS+CZAp0DXyKDzWiHg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.67.0",
"@typescript-eslint/typescript-estree": "8.67.0",
"@typescript-eslint/utils": "8.67.0",
"@typescript-eslint/types": "8.68.0",
"@typescript-eslint/typescript-estree": "8.68.0",
"@typescript-eslint/utils": "8.68.0",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
@@ -13396,9 +13397,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz",
"integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==",
"version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.68.0.tgz",
"integrity": "sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -13410,16 +13411,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz",
"integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==",
"version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.68.0.tgz",
"integrity": "sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.67.0",
"@typescript-eslint/tsconfig-utils": "8.67.0",
"@typescript-eslint/types": "8.67.0",
"@typescript-eslint/visitor-keys": "8.67.0",
"@typescript-eslint/project-service": "8.68.0",
"@typescript-eslint/tsconfig-utils": "8.68.0",
"@typescript-eslint/types": "8.68.0",
"@typescript-eslint/visitor-keys": "8.68.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -13477,16 +13478,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz",
"integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==",
"version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.68.0.tgz",
"integrity": "sha512-PB5gJMMOg0Q5P1tsgWtEAqQacJXq0qEqRHDX/YJ4FaTMLfZPpHB3gjl2EJuiZyPABxmj4ZQYiY9m1bdAJ5y7tQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.67.0",
"@typescript-eslint/types": "8.67.0",
"@typescript-eslint/typescript-estree": "8.67.0"
"@typescript-eslint/scope-manager": "8.68.0",
"@typescript-eslint/types": "8.68.0",
"@typescript-eslint/typescript-estree": "8.68.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -13501,13 +13502,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz",
"integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==",
"version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.68.0.tgz",
"integrity": "sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.67.0",
"@typescript-eslint/types": "8.68.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -15782,9 +15783,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.11.18",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.18.tgz",
"integrity": "sha512-1iEmLEYSiE1SeBoAfPo/Mnx3PzfzHUkDK61ASkCpuk3YXugYLH5DYK1SzqV55F8FMI6s0F+/tCP7Polz1QRjxw==",
"version": "2.11.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.19.tgz",
"integrity": "sha512-Grytf1xOxOEMTGRwx6rLGKkTabd4vMg3VrKdj/7joCmV0qgh4QwMMO6xh34YEXQqirAuUdgQGa5orJQQ+69RBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -19844,9 +19845,9 @@
}
},
"node_modules/eslint": {
"version": "10.9.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.9.0.tgz",
"integrity": "sha512-5KeEOJZBfEVA47boFiBsf+6MmmJpffM7qEBg4pLla2e4nlKgdKlqCW0oSLOGsT8Wl5uCGJptLV1bkaiShj90Gw==",
"version": "10.9.1",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.9.1.tgz",
"integrity": "sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A==",
"dev": true,
"license": "MIT",
"workspaces": [
@@ -20814,9 +20815,9 @@
"license": "(MIT AND Zlib)"
},
"node_modules/fast-uri": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
"funding": [
{
"type": "github",
@@ -29707,16 +29708,16 @@
}
},
"node_modules/minimizer-webpack-plugin": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.6.1.tgz",
"integrity": "sha512-DoeAZz8Q1C1znwsUzej1fdoi4jCf7/+Em27ouLqfK/+3m8G+D7yDhUwrc3CNhjSzGUN1kn7Iv4sWmjflQHenpw==",
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.7.0.tgz",
"integrity": "sha512-0ReIvHAVVojdDOn+kmRzrT62A6Btc9KeAK6ANTpd8+S5mnm5RDdu+EN6924xLFFzb36Rkoj+xOXkDA0jTHId/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.25",
"@jridgewell/trace-mapping": "^0.3.31",
"jest-worker": "^27.4.5",
"schema-utils": "^4.3.0",
"terser": "^5.31.1"
"schema-utils": "^4.3.3",
"terser": "^5.51.0"
},
"engines": {
"node": ">= 10.13.0"
@@ -32716,7 +32717,6 @@
"version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -32886,7 +32886,6 @@
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"dev": true,
"funding": [
{
"type": "github",
@@ -33290,12 +33289,13 @@
"license": "MIT"
},
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
@@ -33304,6 +33304,41 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/qs/node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/qs/node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/query-string": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/query-string/-/query-string-9.5.0.tgz",
@@ -34791,9 +34826,9 @@
}
},
"node_modules/react-intersection-observer": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-11.0.0.tgz",
"integrity": "sha512-tF2PjXa//GcUmkCIcZR2qGsj6HwnuunFQdgeJ89BwFE6epB7E9yIzdr018H1khxG7DiMpIDvAne1GKQMFwn+fw==",
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-11.0.1.tgz",
"integrity": "sha512-BIZ1M40GPSKDIT1MJbXK7SB0BwBaTKr84QfZCs5asKer7YMOYLzMi+RI1F4VStbZLGHoosJmd+aOQDDipUV1gA==",
"license": "MIT",
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
@@ -37058,7 +37093,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -38735,14 +38769,14 @@
}
},
"node_modules/terser": {
"version": "5.37.0",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.37.0.tgz",
"integrity": "sha512-B8wRRkmre4ERucLM/uXx4MOV5cbnOlVAqUst+1+iLKPI0dOgFO28f84ptoQt9HEI537PMzfYa/d+GEPKTRXmYA==",
"version": "5.51.1",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.51.1.tgz",
"integrity": "sha512-jxJxk3OtMbMeoDTtrSezoFRPDFleMDzBl+mFj4gB04HGQUfKnIMllFV1TVhWdsj9o54AlaTvLuNP9yWHzGD1BA==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.8.2",
"acorn": "^8.15.0",
"commander": "^2.20.0",
"source-map-support": "~0.5.20"
},
@@ -42948,7 +42982,7 @@
"@types/d3-time-format": "^4.0.3",
"@types/jquery": "^4.0.1",
"@types/lodash": "^4.17.25",
"@types/node": "^26.3.0",
"@types/node": "^26.4.0",
"@types/prop-types": "^15.7.15",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/react-table": "^7.7.20",
+8 -7
View File
@@ -192,6 +192,7 @@
"mustache": "^4.2.0",
"nanoid": "^6.0.1",
"ol": "^10.10.0",
"postcss": "^8.5.15",
"query-string": "9.5.0",
"re-resizable": "^6.11.2",
"react": "^18.3.0",
@@ -202,7 +203,7 @@
"react-dnd-html5-backend": "^11.1.3",
"react-dom": "^18.3.0",
"react-google-recaptcha": "^3.1.0",
"react-intersection-observer": "^11.0.0",
"react-intersection-observer": "^11.0.1",
"react-json-tree": "^0.20.0",
"react-lines-ellipsis": "^0.16.1",
"react-loadable": "^5.5.0",
@@ -279,7 +280,7 @@
"@types/json-bigint": "^1.0.4",
"@types/lodash-es": "^4.17.12",
"@types/mousetrap": "^1.6.15",
"@types/node": "^26.3.0",
"@types/node": "^26.4.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@types/react-loadable": "^5.5.11",
@@ -291,19 +292,19 @@
"@types/rison": "0.1.0",
"@types/tinycolor2": "^1.4.3",
"@types/unzipper": "^0.10.11",
"@typescript-eslint/eslint-plugin": "^8.67.0",
"@typescript-eslint/eslint-plugin": "^8.68.0",
"@typescript-eslint/parser": "^8.63.0",
"babel-jest": "^30.4.1",
"babel-loader": "^10.1.1",
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"baseline-browser-mapping": "^2.11.18",
"baseline-browser-mapping": "^2.11.19",
"cheerio": "1.2.0",
"concurrently": "^10.0.5",
"copy-webpack-plugin": "^14.0.0",
"cross-env": "^10.1.0",
"css-loader": "^7.1.4",
"eslint": "^10.9.0",
"eslint": "^10.9.1",
"eslint-import-resolver-alias": "^1.1.2",
"eslint-import-resolver-typescript": "^4.4.5",
"eslint-plugin-import": "^2.32.0",
@@ -328,7 +329,7 @@
"lerna": "^10.0.1",
"lightningcss": "^1.33.0",
"mini-css-extract-plugin": "^2.10.2",
"minimizer-webpack-plugin": "^5.6.1",
"minimizer-webpack-plugin": "^5.7.0",
"open-cli": "^9.0.0",
"oxfmt": "^0.65.0",
"oxlint": "^1.80.0",
@@ -405,7 +406,7 @@
"eslint-plugin-jest-dom": {
"eslint": "$eslint"
},
"fast-uri": "^3.1.5",
"fast-uri": "^3.1.7",
"fast-xml-parser": "^5.8.0",
"http-proxy-middleware": "^2.0.10",
"jest-circus": "^30.4.0",
@@ -103,7 +103,7 @@
"@types/d3-time-format": "^4.0.3",
"@types/jquery": "^4.0.1",
"@types/lodash": "^4.17.25",
"@types/node": "^26.3.0",
"@types/node": "^26.4.0",
"@types/prop-types": "^15.7.15",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/react-table": "^7.7.20",
@@ -247,6 +247,9 @@ const config: ControlPanelConfig = {
config: {
...sharedControls.time_grain_sqla,
visibility: ({ controls }) => {
if (!isAggMode({ controls })) {
return false;
}
const dttmLookup = Object.fromEntries(
ensureIsArray(controls?.groupby?.options).map(option => [
(option.column_name || '').toLowerCase(),
@@ -17,18 +17,58 @@
* under the License.
*/
import { GenericDataType } from '@apache-superset/core/common';
import { QueryFormData } from '@superset-ui/core';
import { QueryFormData, QueryMode } from '@superset-ui/core';
import {
ColumnMeta,
Dataset,
isCustomControlItem,
ControlConfig,
ControlPanelsContainerProps,
ControlPanelState,
ControlState,
ColorSchemeEnum,
} from '@superset-ui/chart-controls';
import config from '../src/controlPanel';
type VisibilityFn = (
props: ControlPanelsContainerProps,
control?: ControlState,
) => boolean;
const findTimeGrainSqlaVisibility = (): VisibilityFn | null => {
for (const section of config.controlPanelSections) {
if (!section) continue;
for (const row of section.controlSetRows) {
for (const control of row) {
if (
isCustomControlItem(control) &&
control.name === 'time_grain_sqla' &&
typeof control.config.visibility === 'function'
) {
return control.config.visibility as VisibilityFn;
}
}
}
}
return null;
};
function mkTimeGrainProps(
groupbyValue: string[],
options = [
{ column_name: 'ORDERDATE', is_dttm: true },
{ column_name: 'some_other_col', is_dttm: false },
],
queryMode?: QueryMode,
): ControlPanelsContainerProps {
return {
controls: {
groupby: { value: groupbyValue, options },
...(queryMode ? { query_mode: { value: queryMode } } : {}),
},
} as unknown as ControlPanelsContainerProps;
}
const findConditionalFormattingControl = (): ControlConfig | null => {
for (const section of config.controlPanelSections) {
if (!section) continue;
@@ -273,3 +313,39 @@ test('metrics control includes non-filterable columns', () => {
]),
);
});
test('time_grain_sqla visibility is false in raw records mode even with a stale temporal groupby value', () => {
const vis = findTimeGrainSqlaVisibility();
expect(vis).toBeTruthy();
const controlState = {} as ControlState;
// Simulates switching from an aggregated chart (groupby set to a temporal
// column) to this plugin's Raw Records mode: groupby's value survives the
// switch (its own visibility uses resetOnHide: false), but the query is no
// longer aggregated, so time_grain_sqla must not stay visible/applied.
expect(
vis!(
mkTimeGrainProps(['orderdate'], undefined, QueryMode.Raw),
controlState,
),
).toBe(false);
});
test('time_grain_sqla visibility still requires aggregate mode plus a temporal groupby column', () => {
const vis = findTimeGrainSqlaVisibility();
expect(vis).toBeTruthy();
const controlState = {} as ControlState;
expect(
vis!(
mkTimeGrainProps(['orderdate'], undefined, QueryMode.Aggregate),
controlState,
),
).toBe(true);
expect(
vis!(
mkTimeGrainProps(['some_other_col'], undefined, QueryMode.Aggregate),
controlState,
),
).toBe(false);
});
@@ -270,6 +270,9 @@ const config: ControlPanelConfig = {
config: {
...sharedControls.time_grain_sqla,
visibility: ({ controls }) => {
if (!isAggMode({ controls })) {
return false;
}
const dttmLookup = Object.fromEntries(
ensureIsArray(controls?.groupby?.options).map(option => [
(option.column_name || '').toLowerCase(),
@@ -17,6 +17,7 @@ import {
ControlState,
CustomControlItem,
} from '@superset-ui/chart-controls';
import { QueryMode } from '@superset-ui/core';
import config from '../src/controlPanel';
type VisibilityFn = (
@@ -59,10 +60,12 @@ function mkProps(
{ column_name: 'ORDERDATE', is_dttm: true },
{ column_name: 'some_other_col', is_dttm: false },
],
queryMode?: QueryMode,
): ControlPanelsContainerProps {
return {
controls: {
groupby: { value: groupbyValue, options },
...(queryMode ? { query_mode: { value: queryMode } } : {}),
},
} as unknown as ControlPanelsContainerProps;
}
@@ -75,3 +78,31 @@ test('time_grain_sqla visibility should be case-insensitive', () => {
expect(vis(mkProps(['ORDERDATE']), controlState)).toBe(true);
expect(vis(mkProps(['some_other_col']), controlState)).toBe(false);
});
test('time_grain_sqla visibility is false in raw records mode even with a stale temporal groupby value', () => {
const vis = getVisibility(config, 'time_grain_sqla');
const controlState = {} as ControlState;
// Simulates switching from an aggregated Line chart (groupby set to a
// temporal column) to Table's Raw Records mode: groupby's value survives
// the switch (its own visibility uses resetOnHide: false), but the query
// is no longer aggregated, so time_grain_sqla must not stay visible/applied.
expect(
vis(mkProps(['orderdate'], undefined, QueryMode.Raw), controlState),
).toBe(false);
});
test('time_grain_sqla visibility still requires aggregate mode plus a temporal groupby column', () => {
const vis = getVisibility(config, 'time_grain_sqla');
const controlState = {} as ControlState;
expect(
vis(mkProps(['orderdate'], undefined, QueryMode.Aggregate), controlState),
).toBe(true);
expect(
vis(
mkProps(['some_other_col'], undefined, QueryMode.Aggregate),
controlState,
),
).toBe(false);
});
@@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { type JsonObject } from '@superset-ui/core';
import { type ReactNode } from 'react';
export interface SortColumn {
@@ -102,6 +103,7 @@ export interface ListViewFetchDataConfig {
pageSize: number;
sortBy: SortColumn[];
filters: ListViewFilterValue[];
extraQueryParams?: JsonObject;
}
export interface InternalFilter extends ListViewFilterValue {
@@ -23,8 +23,19 @@ import {
waitFor,
} from 'spec/helpers/testing-library';
import { SupersetClient, isFeatureEnabled } from '@superset-ui/core';
import * as resolveCssImportsModule from 'src/dashboard/util/resolveCssImports';
import StylingSection from './StylingSection';
jest.mock('src/dashboard/util/resolveCssImports', () => ({
...jest.requireActual('src/dashboard/util/resolveCssImports'),
resolveCssImports: jest.fn(),
}));
const mockResolveCssImports =
resolveCssImportsModule.resolveCssImports as jest.MockedFunction<
typeof resolveCssImportsModule.resolveCssImports
>;
// Mock SupersetClient
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
@@ -80,6 +91,11 @@ const defaultProps = {
beforeEach(() => {
jest.clearAllMocks();
// clearAllMocks() only clears call history, not implementations set via
// mockResolvedValue/mockRejectedValue -- reset this one explicitly so a
// value set by an earlier test can't leak into a test that forgets to
// set its own.
mockResolveCssImports.mockReset();
// Reset mocks
mockIsFeatureEnabled.mockReturnValue(false);
mockSupersetClient.get.mockResolvedValue({
@@ -274,3 +290,99 @@ describe('CSS Template functionality', () => {
).not.toBeInTheDocument();
});
});
test('does not show the @import warning for ordinary CSS', () => {
render(
<StylingSection {...defaultProps} customCss=".header { color: red; }" />,
);
expect(screen.queryByTestId('css-import-warning')).not.toBeInTheDocument();
});
test('shows a convert button when the CSS contains @import', () => {
render(
<StylingSection
{...defaultProps}
customCss="@import url('https://fonts.googleapis.com/css2?family=Inter');"
/>,
);
expect(screen.getByTestId('css-import-warning')).toBeInTheDocument();
expect(screen.getByTestId('convert-css-import-button')).toBeInTheDocument();
});
test('converting replaces the CSS and reports success', async () => {
const onCustomCssChange = jest.fn();
mockResolveCssImports.mockResolvedValue({
css: "@font-face { font-family: 'Inter'; src: url('x.woff2'); }",
resolvedCount: 1,
unresolvedUrls: [],
});
render(
<StylingSection
{...defaultProps}
customCss="@import url('https://fonts.googleapis.com/css2?family=Inter');"
onCustomCssChange={onCustomCssChange}
/>,
);
await userEvent.click(screen.getByTestId('convert-css-import-button'));
await waitFor(() => {
expect(onCustomCssChange).toHaveBeenCalledWith(
"@font-face { font-family: 'Inter'; src: url('x.woff2'); }",
);
});
expect(screen.getByTestId('css-import-conversion-result')).toHaveTextContent(
'Converted 1 @import',
);
});
test('converting reports an unresolved import instead of silently dropping it', async () => {
const onCustomCssChange = jest.fn();
mockResolveCssImports.mockResolvedValue({
css: "@import url('https://no-cors.example.com/x.css');",
resolvedCount: 0,
unresolvedUrls: ['https://no-cors.example.com/x.css'],
});
render(
<StylingSection
{...defaultProps}
customCss="@import url('https://no-cors.example.com/x.css');"
onCustomCssChange={onCustomCssChange}
/>,
);
await userEvent.click(screen.getByTestId('convert-css-import-button'));
await waitFor(() => {
expect(
screen.getByTestId('css-import-conversion-result'),
).toHaveTextContent('no-cors.example.com');
});
expect(onCustomCssChange).not.toHaveBeenCalled();
});
test('converting surfaces a warning instead of an unhandled rejection on parse failure', async () => {
const onCustomCssChange = jest.fn();
mockResolveCssImports.mockRejectedValue(new Error('CssSyntaxError'));
render(
<StylingSection
{...defaultProps}
customCss="@import url('https://fonts.googleapis.com/css2?family=Inter');"
onCustomCssChange={onCustomCssChange}
/>,
);
await userEvent.click(screen.getByTestId('convert-css-import-button'));
await waitFor(() => {
expect(
screen.getByTestId('css-import-conversion-result'),
).toHaveTextContent('Could not parse the CSS');
});
expect(onCustomCssChange).not.toHaveBeenCalled();
});
@@ -25,11 +25,15 @@ import {
} from '@superset-ui/core';
import { Alert } from '@apache-superset/core/components';
import { styled } from '@apache-superset/core/theme';
import { Select, Switch } from '@superset-ui/core/components';
import { Button, Select, Switch } from '@superset-ui/core/components';
import { EditorHost } from 'src/core/editors';
import rison from 'rison';
import ColorSchemeSelect from 'src/dashboard/components/ColorSchemeSelect';
import { ModalFormField } from 'src/components/Modal';
import {
hasCssImport,
resolveCssImports,
} from 'src/dashboard/util/resolveCssImports';
const StyledEditorHost = styled(EditorHost)`
border-radius: ${({ theme }) => theme.borderRadius}px;
@@ -112,6 +116,11 @@ const StylingSection = ({
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
const [originalTemplateContent, setOriginalTemplateContent] =
useState<string>('');
const [isConvertingCssImports, setIsConvertingCssImports] = useState(false);
const [cssImportConversionMessage, setCssImportConversionMessage] = useState<{
type: 'success' | 'warning';
text: string;
} | null>(null);
// Fetch CSS templates
const fetchCssTemplates = useCallback(async () => {
@@ -162,6 +171,52 @@ const StylingSection = ({
const hasTemplateModification =
selectedTemplate && customCss !== originalTemplateContent;
// Convert any @import in the CSS to the imported stylesheet's own
// contents, fetched from the browser (not the Superset backend, so this
// carries none of the SSRF risk a server-side fetch of an editor-supplied
// URL would). @import is rejected on save regardless of where it came
// from, so this is the migration path for CSS written (or imported) before
// that check existed.
const handleConvertCssImports = useCallback(async () => {
setIsConvertingCssImports(true);
setCssImportConversionMessage(null);
try {
const result = await resolveCssImports(customCss);
if (result.resolvedCount > 0) {
onCustomCssChange(result.css);
}
if (result.unresolvedUrls.length > 0) {
setCssImportConversionMessage({
type: 'warning',
text: t(
'Could not automatically fetch: %s. This is often blocked by the remote server (CORS); copy its contents in manually instead.',
result.unresolvedUrls.join(', '),
),
});
} else if (result.resolvedCount > 0) {
setCssImportConversionMessage({
type: 'success',
text: t(
'Converted %s @import rule(s) to inline CSS. Review the result before saving.',
result.resolvedCount,
),
});
}
} catch {
// Most commonly the editor contains CSS that postcss can't parse
// (a mid-edit syntax error); surface it rather than leaving the user
// with no feedback and an unhandled rejection.
setCssImportConversionMessage({
type: 'warning',
text: t(
'Could not parse the CSS to convert @import rules. Check for syntax errors and try again.',
),
});
} finally {
setIsConvertingCssImports(false);
}
}, [customCss, onCustomCssChange]);
return (
<>
{themes.length > 0 && (
@@ -265,8 +320,46 @@ const StylingSection = ({
language="css"
width="100%"
height="160px"
readOnly={isConvertingCssImports}
/>
</ModalFormField>
{hasCssImport(customCss) && (
<StyledAlert
type="warning"
showIcon
closable={false}
data-test="css-import-warning"
message={t('This CSS uses @import, which cannot be saved')}
description={
<>
<p>
{t(
'@import is blocked to prevent a dashboard from loading arbitrary remote CSS. Convert it to inline CSS to keep using it.',
)}
</p>
<Button
buttonSize="small"
buttonStyle="secondary"
loading={isConvertingCssImports}
onClick={handleConvertCssImports}
data-test="convert-css-import-button"
>
{t('Convert @import to inline CSS')}
</Button>
</>
}
/>
)}
{cssImportConversionMessage && (
<StyledAlert
type={cssImportConversionMessage.type}
showIcon
closable
onClose={() => setCssImportConversionMessage(null)}
data-test="css-import-conversion-result"
message={cssImportConversionMessage.text}
/>
)}
</>
);
};
@@ -0,0 +1,210 @@
/**
* 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 fetchMock from 'fetch-mock';
import { hasCssImport, resolveCssImports } from './resolveCssImports';
afterEach(() => {
fetchMock.removeRoutes();
fetchMock.clearHistory();
});
test('hasCssImport detects @import case-insensitively', () => {
expect(hasCssImport('')).toBe(false);
expect(hasCssImport('.header { color: red; }')).toBe(false);
expect(hasCssImport("@import url('https://fonts.example.com/x.css');")).toBe(
true,
);
expect(hasCssImport("@IMPORT url('https://fonts.example.com/x.css');")).toBe(
true,
);
});
test('leaves css without @import untouched and fetches nothing', async () => {
const css = '.header { color: red; }';
const result = await resolveCssImports(css);
expect(result).toEqual({ css, resolvedCount: 0, unresolvedUrls: [] });
});
test('replaces a Google-Fonts-style @import with the fetched @font-face rules', async () => {
const fontUrl = 'https://fonts.googleapis.com/css2?family=Inter';
const fontCss =
"@font-face { font-family: 'Inter'; src: url('https://fonts.gstatic.com/s/inter/x.woff2') format('woff2'); }";
fetchMock.get(fontUrl, {
status: 200,
body: fontCss,
headers: { 'content-type': 'text/css; charset=utf-8' },
});
const result = await resolveCssImports(
`.header { color: red; }\n@import url('${fontUrl}');`,
);
expect(result.resolvedCount).toBe(1);
expect(result.unresolvedUrls).toEqual([]);
expect(result.css).not.toMatch(/@import/i);
expect(result.css).toContain('font-family');
expect(result.css).toContain('fonts.gstatic.com');
});
test('rebases a relative url() in the fetched stylesheet against the import URL', async () => {
const fontUrl = 'https://fonts.example.com/css2?family=Inter';
const fontCss =
"@font-face { font-family: 'Inter'; src: url('../fonts/font.woff2') format('woff2'); }";
fetchMock.get(fontUrl, {
status: 200,
body: fontCss,
headers: { 'content-type': 'text/css; charset=utf-8' },
});
const result = await resolveCssImports(`@import url('${fontUrl}');`);
expect(result.resolvedCount).toBe(1);
expect(result.css).toContain(
"url('https://fonts.example.com/fonts/font.woff2')",
);
});
test('leaves absolute, protocol-relative, and data urls in the fetched stylesheet untouched', async () => {
const cssUrl = 'https://cdn.example.com/theme/base.css';
const fetchedCss = [
".a { background: url('https://other.example.com/img/a.png'); }",
".b { background: url('//other.example.com/img/b.png'); }",
".c { background: url('data:image/png;base64,AAAA'); }",
].join('\n');
fetchMock.get(cssUrl, {
status: 200,
body: fetchedCss,
headers: { 'content-type': 'text/css; charset=utf-8' },
});
const result = await resolveCssImports(`@import url('${cssUrl}');`);
expect(result.css).toContain("url('https://other.example.com/img/a.png')");
expect(result.css).toContain("url('//other.example.com/img/b.png')");
expect(result.css).toContain("url('data:image/png;base64,AAAA')");
});
test('leaves an @import unresolved and reports it when the fetch fails', async () => {
const brokenUrl = 'https://no-cors.example.com/branding.css';
fetchMock.get(brokenUrl, { throws: new TypeError('Failed to fetch') });
const result = await resolveCssImports(`@import url('${brokenUrl}');`);
expect(result.resolvedCount).toBe(0);
expect(result.unresolvedUrls).toEqual([brokenUrl]);
expect(result.css).toContain('@import');
});
test('leaves an @import unresolved when the response is not CSS', async () => {
const url = 'https://example.com/not-css.html';
fetchMock.get(url, {
status: 200,
body: '<html></html>',
headers: { 'content-type': 'text/html' },
});
const result = await resolveCssImports(`@import url('${url}');`);
expect(result.resolvedCount).toBe(0);
expect(result.unresolvedUrls).toEqual([url]);
});
test('resolves one @import and reports another it could not fetch', async () => {
const goodUrl = 'https://fonts.googleapis.com/css2?family=Inter';
const badUrl = 'https://no-cors.example.com/branding.css';
fetchMock.get(goodUrl, {
status: 200,
body: "@font-face { font-family: 'Inter'; src: url('x.woff2'); }",
headers: { 'content-type': 'text/css' },
});
fetchMock.get(badUrl, { throws: new TypeError('Failed to fetch') });
const result = await resolveCssImports(
`@import url('${goodUrl}');\n@import url('${badUrl}');`,
);
expect(result.resolvedCount).toBe(1);
expect(result.unresolvedUrls).toEqual([badUrl]);
expect(result.css).toContain('font-family');
expect(result.css).toContain('@import');
expect(result.css).toContain(badUrl);
});
test('preserves a media condition by wrapping the inlined rules in @media', async () => {
const printUrl = 'https://cdn.example.com/print.css';
fetchMock.get(printUrl, {
status: 200,
body: '.report { font-size: 10pt; }',
headers: { 'content-type': 'text/css' },
});
const result = await resolveCssImports(`@import url('${printUrl}') print;`);
expect(result.resolvedCount).toBe(1);
expect(result.css).not.toMatch(/@import/i);
expect(result.css).toContain('@media print');
expect(result.css).toContain('.report');
});
test('preserves layer, supports, and media conditions with spec-order nesting', async () => {
const url = 'https://cdn.example.com/grid.css';
fetchMock.get(url, {
status: 200,
body: '.grid { display: grid; }',
headers: { 'content-type': 'text/css' },
});
const result = await resolveCssImports(
`@import url('${url}') layer(base) supports(display: grid) screen;`,
);
expect(result.resolvedCount).toBe(1);
expect(result.css).not.toMatch(/@import/i);
// Nesting mirrors the @import's semantics: media outermost, then
// supports, then layer around the imported rules.
const mediaIdx = result.css.indexOf('@media screen');
const supportsIdx = result.css.indexOf('@supports (display: grid)');
const layerIdx = result.css.indexOf('@layer base');
const ruleIdx = result.css.indexOf('.grid');
expect(mediaIdx).toBeGreaterThanOrEqual(0);
expect(supportsIdx).toBeGreaterThan(mediaIdx);
expect(layerIdx).toBeGreaterThan(supportsIdx);
expect(ruleIdx).toBeGreaterThan(layerIdx);
});
test('does not resolve an @import nested inside a fetched stylesheet', async () => {
const outerUrl = 'https://fonts.googleapis.com/css2?family=Inter';
const innerCss = "@import url('https://example.com/nested.css');";
fetchMock.get(outerUrl, {
status: 200,
body: innerCss,
headers: { 'content-type': 'text/css' },
});
const result = await resolveCssImports(`@import url('${outerUrl}');`);
// The outer @import was successfully fetched and replaced, but its
// content is inlined as-is, including the @import it itself contains --
// that one is left for the backend validator to reject on save, same as
// any hand-typed @import.
expect(result.resolvedCount).toBe(1);
expect(result.css).toContain('@import');
expect(result.css).toContain('nested.css');
});
@@ -0,0 +1,280 @@
/**
* 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 type { AtRule } from 'postcss';
// Mirrors the "@import" entry of _DANGEROUS_CSS_PATTERNS in
// superset/dashboards/schemas.py's validate_css. This only decides whether
// to offer the "convert @import" action below -- the backend validator is
// the actual gate on save either way, so keeping this exactly in sync is a
// UX nicety, not a security requirement.
const CSS_IMPORT_PATTERN = /@import\b/i;
export function hasCssImport(css: string): boolean {
return CSS_IMPORT_PATTERN.test(css);
}
export interface ResolveCssImportsResult {
css: string;
resolvedCount: number;
unresolvedUrls: string[];
}
/**
* Splits an `@import` at-rule's raw params into the URL and whatever
* trails it (layer/supports/media conditions), e.g.
* `url('https://fonts.googleapis.com/css2?family=Inter') screen` or
* `"https://example.com/x.css"`. Returns null for a params string this
* can't confidently pull a URL out of.
*/
function parseImportParams(
params: string,
): { url: string; conditionsRaw: string } | null {
const trimmed = params.trim();
const match = trimmed.match(
/^url\(\s*['"]?([^'")]+)['"]?\s*\)|^['"]([^'"]+)['"]/i,
);
const url = match?.[1] ?? match?.[2];
if (!match || !url) {
return null;
}
return { url, conditionsRaw: trimmed.slice(match[0].length).trim() };
}
/**
* Reads a balanced `(...)` group starting at `openIdx` (which must point at
* the opening paren). Returns the content between the parens and the index
* of the closing paren, or null if the parens never balance.
*/
function readBalanced(
str: string,
openIdx: number,
): { content: string; end: number } | null {
let depth = 0;
for (let i = openIdx; i < str.length; i += 1) {
if (str[i] === '(') {
depth += 1;
} else if (str[i] === ')') {
depth -= 1;
if (depth === 0) {
return { content: str.slice(openIdx + 1, i), end: i };
}
}
}
return null;
}
interface ImportConditions {
/** Layer name; empty string for the anonymous `layer` keyword. */
layer: string | null;
supports: string | null;
media: string | null;
}
/**
* Parses the conditions that may trail an `@import` URL, in their
* spec-defined order: an optional `layer`/`layer(name)`, an optional
* `supports(...)`, then a media query list. Returns null when the string
* can't be parsed confidently (e.g. unbalanced parens), so the caller can
* leave the rule alone rather than guess at its meaning.
*/
function parseImportConditions(raw: string): ImportConditions | null {
let rest = raw.trim().replace(/;\s*$/, '');
const conditions: ImportConditions = {
layer: null,
supports: null,
media: null,
};
if (/^layer\(/i.test(rest)) {
const group = readBalanced(rest, 'layer'.length);
if (!group) {
return null;
}
conditions.layer = group.content.trim();
rest = rest.slice(group.end + 1).trim();
} else if (/^layer(\s|$)/i.test(rest)) {
conditions.layer = '';
rest = rest.slice('layer'.length).trim();
}
if (/^supports\(/i.test(rest)) {
const group = readBalanced(rest, 'supports'.length);
if (!group) {
return null;
}
conditions.supports = group.content.trim();
rest = rest.slice(group.end + 1).trim();
}
conditions.media = rest || null;
return conditions;
}
// Matches CSS `url(...)` function calls, optionally quoted, e.g.
// `url(../fonts/font.woff2)`, `url('img/bg.png')`, `url("./x.svg")`.
const CSS_URL_PATTERN = /url\(\s*(['"]?)([^'")]+)\1\s*\)/gi;
/**
* Rewrites relative `url(...)` references in a fetched stylesheet (font,
* image, and other asset paths) so they resolve against the stylesheet's
* own URL instead of the dashboard document. Without this, an imported
* stylesheet like `@import url('https://fonts.example.com/css2')` whose
* body contains `url(../fonts/font.woff2)` would, once inlined verbatim,
* resolve that relative path against the dashboard's own origin and fail
* to load. Absolute URLs, protocol-relative URLs, and data URIs are left
* untouched.
*/
function rebaseCssUrls(css: string, baseUrl: string): string {
return css.replace(CSS_URL_PATTERN, (match, quote, rawUrl) => {
const url = rawUrl.trim();
if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|data:)/i.test(url)) {
return match;
}
try {
const rebased = new URL(url, baseUrl).toString();
return `url(${quote}${rebased}${quote})`;
} catch {
return match;
}
});
}
/**
* Replaces every top-level `@import url(...)` in `css` with the fetched
* target stylesheet's own contents, so the result can be saved without
* tripping the backend's `@import` rejection. The fetch happens in the
* caller's own browser, not on the Superset backend, so this carries none
* of the SSRF risk a server-side fetch of an editor-supplied URL would --
* and every dashboard viewer's browser already fetches the same URL today
* whenever `@import`-ing CSS renders, so this isn't new exposure, only a
* one-time version of exposure that already happens on every view.
*
* An `@import` whose target can't be fetched (CORS, network error, a
* non-2xx response, or a response that isn't CSS) is left untouched in the
* output and reported in `unresolvedUrls`, rather than silently dropped, so
* a save attempt still fails with a clear reason and nothing is lost.
*
* An `@import`'s layer/supports/media conditions (e.g.
* `@import url('print.css') print`) are preserved by wrapping the inlined
* stylesheet in the equivalent `@layer`/`@supports`/`@media` blocks, so
* conditional imports keep applying under the same conditions.
*
* Only one level of `@import` is resolved: an `@import` found inside a
* fetched stylesheet is not itself fetched, and is carried through to the
* merged output as-is aside from having its own `url(...)` rebased against
* the parent stylesheet (the same rebasing every other relative URL in that
* stylesheet gets). A save with a remaining `@import` still fails backend
* validation exactly as before -- there is no security reliance on this
* function fully resolving anything, only a UX convenience for the common
* case (a single Google-Fonts-style `@import` resolving to a handful of
* `@font-face` rules).
*/
export async function resolveCssImports(
css: string,
): Promise<ResolveCssImportsResult> {
if (!hasCssImport(css)) {
return { css, resolvedCount: 0, unresolvedUrls: [] };
}
const postcss = (await import('postcss')).default;
const root = postcss.parse(css);
const importRules = root.nodes.filter(
(node): node is AtRule =>
node.type === 'atrule' && node.name.toLowerCase() === 'import',
);
let resolvedCount = 0;
const unresolvedUrls: string[] = [];
await Promise.all(
importRules.map(async rule => {
const parsed = parseImportParams(rule.params);
if (!parsed) {
unresolvedUrls.push(rule.params);
return;
}
const { url } = parsed;
// An @import's layer/supports/media conditions must survive the
// conversion (e.g. `@import url('print.css') print` must not start
// applying on screen), so the fetched rules get wrapped in the
// equivalent block form below. Conditions we can't parse mean we
// leave the rule alone rather than change what it applies to.
const conditions = parseImportConditions(parsed.conditionsRaw);
if (!conditions) {
unresolvedUrls.push(url);
return;
}
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Fetching ${url} returned HTTP ${response.status}`);
}
const contentType = response.headers.get('content-type') ?? '';
if (contentType && !contentType.includes('css')) {
throw new Error(`${url} did not return a CSS response`);
}
const importedCss = rebaseCssUrls(await response.text(), url);
const importedRoot = postcss.parse(importedCss);
// Wrap innermost-first (layer, then supports, then media) so the
// block nesting mirrors the @import's own semantics:
// @media { @supports { @layer { ...imported rules... } } }.
let replacement = importedRoot.nodes;
if (conditions.layer !== null) {
const layerRule = postcss.atRule({
name: 'layer',
params: conditions.layer,
});
layerRule.append(replacement);
replacement = [layerRule];
}
if (conditions.supports !== null) {
const supportsRule = postcss.atRule({
name: 'supports',
// A bare declaration like `display: grid` needs wrapping
// parens to be a valid @supports condition; a condition that
// already starts with `(` or `not` is valid as-is.
params: /^\(|^not\s/i.test(conditions.supports)
? conditions.supports
: `(${conditions.supports})`,
});
supportsRule.append(replacement);
replacement = [supportsRule];
}
if (conditions.media !== null) {
const mediaRule = postcss.atRule({
name: 'media',
params: conditions.media,
});
mediaRule.append(replacement);
replacement = [mediaRule];
}
rule.replaceWith(replacement);
resolvedCount += 1;
} catch {
// Most commonly a CORS rejection: fetch() can't read the response
// body from a server that doesn't opt in with CORS headers, even
// though the browser's own CSS engine can load that same URL
// natively via @import. Left unresolved rather than guessed at.
unresolvedUrls.push(url);
}
}),
);
return { css: root.toString(), resolvedCount, unresolvedUrls };
}
@@ -67,3 +67,13 @@ test('should render the items', async () => {
expect(await screen.findByText('English')).toBeInTheDocument();
expect(await screen.findByText('Italian')).toBeInTheDocument();
});
test('renders the down-chevron caret icon, not the caret glyph (regression #43531)', async () => {
render(<TestLanguagePicker {...mockedProps} />, {
useRouter: true,
});
const menuItem = await screen.findByRole('menuitem');
const caret = menuItem.querySelector('.ant-menu-item-icon');
expect(caret).toHaveClass('anticon-down');
expect(caret?.querySelector('svg')).toHaveAttribute('data-icon', 'down');
});
@@ -78,7 +78,7 @@ export const useLanguageMenuItems = ({
<i className={`flag ${languages[locale]?.flag ?? 'us'}`} />
</span>
),
icon: <Icons.CaretDownOutlined iconSize="xs" />,
icon: <Icons.DownOutlined iconSize="xs" />,
children: items,
className: 'submenu-with-caret',
popupClassName: 'language-picker-popup',
@@ -387,6 +387,20 @@ test('should render all the top navbar menu items', async () => {
});
});
test('renders the down-chevron caret icon on top-level category dropdowns, not the caret glyph (regression #43531)', async () => {
useSelectorMock.mockReturnValue({ roles: user.roles });
render(<Menu {...mockedProps} />, {
useRedux: true,
useQueryParams: true,
useRouter: true,
useTheme: true,
});
const sources = await screen.findByText('Sources');
const caret = sources.closest('li')?.querySelector('.ant-menu-item-icon');
expect(caret).toHaveClass('anticon-down');
expect(caret?.querySelector('svg')).toHaveAttribute('data-icon', 'down');
});
test('should render the top navbar child menu items', async () => {
useSelectorMock.mockReturnValue({ roles: user.roles });
const {
@@ -113,6 +113,16 @@ const StyledMainNav = styled(MainNav)`
padding: 0 ${theme.sizeUnit * 4}px;
}
[data-icon='down'] {
color: ${theme.colorIcon};
/* sizeXS (an antd token, always computed) rather than fontSizeXS
(a Superset custom token seeded only via THEME_DEFAULT in
config.py) so this stays small in contexts that construct a
theme without that seed, e.g. Storybook and Jest. Both resolve
to the same 8px in the app's default theme. */
font-size: ${theme.sizeXS}px;
}
&:hover,
&.ant-menu-submenu-active {
.ant-menu-title-content {
@@ -384,6 +384,34 @@ test('If there is NOT a DB with allow_file_upload set as True the option should
);
});
test('renders the down-chevron caret icon on the "+" and Settings dropdowns, not the caret glyph (regression #43531)', async () => {
const mockedProps = createProps();
resetUseSelectorMock();
render(<RightMenu {...mockedProps} />, {
useRedux: true,
useQueryParams: true,
useRouter: true,
useTheme: true,
});
const newDropdownIcon = screen.getByTestId('new-dropdown-icon');
const newCaret = newDropdownIcon
.closest('li')
?.querySelector('.ant-menu-item-icon');
expect(newCaret).toHaveClass('anticon-down');
expect(newCaret?.querySelector('svg')).toHaveAttribute('data-icon', 'down');
const settings = await screen.findByText(/Settings/i);
const settingsCaret = settings
.closest('li')
?.querySelector('.ant-menu-item-icon');
expect(settingsCaret).toHaveClass('anticon-down');
expect(settingsCaret?.querySelector('svg')).toHaveAttribute(
'data-icon',
'down',
);
});
test('Logs out and clears local storage item redux', async () => {
const mockedProps = createProps();
resetUseSelectorMock();
@@ -846,6 +846,16 @@ const RightMenu = ({
flex-direction: row-reverse;
height: 100%;
}
[data-icon='down'] {
color: ${theme.colorIcon};
/* sizeXS (an antd token, always computed) rather than
fontSizeXS (a Superset custom token seeded only via
THEME_DEFAULT in config.py) so this stays small in
contexts that construct a theme without that seed, e.g.
Storybook and Jest. Both resolve to the same 8px in the
app's default theme. */
font-size: ${theme.sizeXS}px;
}
&.ant-menu-submenu::after {
inset-inline: ${theme.sizeUnit}px;
}
@@ -271,4 +271,14 @@ describe('useThemeMenuItems', () => {
expect(divider).toBeNull();
});
test('renders the down-chevron caret icon, not the caret glyph (regression #43531)', async () => {
renderThemeMenu();
const menuItem = await screen.findByRole('menuitem');
const caret = menuItem.querySelector('.ant-menu-item-icon');
expect(caret).toHaveClass('anticon-down');
expect(caret?.querySelector('svg')).toHaveAttribute('data-icon', 'down');
});
});
@@ -20,7 +20,12 @@ import fetchMock from 'fetch-mock';
import { mockUserSubjectsBootstrapData } from 'spec/helpers/mockBootstrapData';
import { screen, waitFor, within } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import { isFeatureEnabled } from '@superset-ui/core';
import rison from 'rison';
import {
ChartMetadata,
getChartMetadataRegistry,
isFeatureEnabled,
} from '@superset-ui/core';
import {
mockCharts,
mockHandleResourceExport,
@@ -279,6 +284,75 @@ test('sorts table when clicking column headers', async () => {
});
});
test('sends display-ordered chart type slugs only for Type sorting', async () => {
const registry = getChartMetadataRegistry();
const customVizTypes = Array.from(
{ length: 260 },
(_, index) => `custom_display_order_${index}`,
);
registry
.registerValue(
'slug_a',
new ChartMetadata({ name: '001 Zulu', thumbnail: '', behaviors: [] }),
)
.registerValue(
'slug_z',
new ChartMetadata({ name: '000 Alpha', thumbnail: '', behaviors: [] }),
);
customVizTypes.forEach((vizType, index) =>
registry.registerValue(
vizType,
new ChartMetadata({
name: `Plugin ${index}`,
thumbnail: '',
behaviors: [],
}),
),
);
try {
renderChartList(mockUser);
const table = await screen.findByTestId('listview-table');
const initialCall = fetchMock.callHistory
.calls(/chart\/\?q/)
.find(call => !call.url.includes('order_column:viz_type'));
expect(initialCall).toBeDefined();
const initialQuery = new URL(
initialCall!.url,
'http://localhost',
).searchParams.get('q');
expect(rison.decode(initialQuery!)).not.toHaveProperty('viz_type_order');
await userEvent.click(within(table).getByTitle('Type'));
await waitFor(() => {
const typeSortCall = fetchMock.callHistory
.calls(/chart\/\?q/)
.find(call => call.url.includes('order_column:viz_type'));
expect(typeSortCall).toBeDefined();
const query = new URL(
typeSortCall!.url,
'http://localhost',
).searchParams.get('q');
const decoded = rison.decode(query!) as {
order_column: string;
viz_type_order: string[];
};
expect(decoded.order_column).toBe('viz_type');
expect(decoded.viz_type_order).toHaveLength(256);
expect(decoded.viz_type_order.indexOf('slug_z')).toBeLessThan(
decoded.viz_type_order.indexOf('slug_a'),
);
});
} finally {
registry.remove('slug_a');
registry.remove('slug_z');
customVizTypes.forEach(vizType => registry.remove(vizType));
}
});
test('displays chart data correctly in table rows', async () => {
/**
* @todo Implement test logic for tagging.
@@ -71,6 +71,7 @@ import {
ListViewFilterOperator as FilterOperator,
DashboardCrossLinks,
type ListViewProps,
type ListViewFetchDataConfig,
type ListViewFilters,
type ListViewFilter,
} from 'src/components';
@@ -198,6 +199,7 @@ const CONFIRM_OVERWRITE_MESSAGE = t(
);
const registry = getChartMetadataRegistry();
const MAX_VIZ_TYPE_ORDER_LENGTH = 256;
const createFetchDatasets = async (
filterValue = '',
@@ -260,11 +262,35 @@ function ChartList(props: ChartListProps) {
},
setResourceCollection: setCharts,
hasPerm,
fetchData,
fetchData: fetchChartData,
toggleBulkSelect,
refreshData,
} = useListViewResource<Chart>('chart', t('chart'), addDangerToast);
const fetchData = useCallback(
(config: ListViewFetchDataConfig) =>
fetchChartData({
...config,
...(config.sortBy[0]?.id === 'viz_type'
? {
extraQueryParams: {
...config.extraQueryParams,
viz_type_order: registry
.keys()
.sort((left, right) => {
const nameComparison = (
registry.get(left)?.name || left
).localeCompare(registry.get(right)?.name || right);
return nameComparison || left.localeCompare(right);
})
.slice(0, MAX_VIZ_TYPE_ORDER_LENGTH),
},
}
: {}),
}),
[fetchChartData],
);
const chartIds = useMemo(() => charts.map(c => c.id), [charts]);
const { roles } = useSelector<any, UserWithPermissionsAndRoles>(
state => state.user,
@@ -17,6 +17,7 @@
* under the License.
*/
import { renderHook, act } from '@testing-library/react';
import rison from 'rison';
import { waitFor } from 'spec/helpers/testing-library';
import { JsonResponse, SupersetClient } from '@superset-ui/core';
@@ -551,6 +552,107 @@ test('useListViewResource: uses desc sort direction when desc is true', async ()
expect(endpoint).toContain('order_direction:desc');
});
test('useListViewResource: includes extra list query parameters', async () => {
const getSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: { result: [], count: 0 },
} as unknown as JsonResponse);
const { result } = renderHook(() =>
useListViewResource('chart', 'Charts', jest.fn()),
);
await act(async () => {
await result.current.fetchData({
pageIndex: 0,
pageSize: 25,
sortBy: [{ id: 'viz_type' }],
filters: [],
extraQueryParams: {
viz_type_order: ['slug_z', 'slug_a'],
},
});
});
const endpoint = findEndpoint(getSpy, '/api/v1/chart/?q=');
const query = new URL(endpoint, 'http://localhost').searchParams.get('q');
expect(rison.decode(query!)).toMatchObject({
order_column: 'viz_type',
viz_type_order: ['slug_z', 'slug_a'],
});
});
test('useListViewResource: refresh reuses extra list query parameters', async () => {
const getSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: { result: [], count: 0 },
} as unknown as JsonResponse);
const { result } = renderHook(() =>
useListViewResource('chart', 'Charts', jest.fn()),
);
await act(async () => {
await result.current.fetchData({
pageIndex: 0,
pageSize: 25,
sortBy: [{ id: 'viz_type' }],
filters: [],
extraQueryParams: { viz_type_order: ['slug_z', 'slug_a'] },
});
await result.current.refreshData();
});
const listQueries = getSpy.mock.calls
.map(call => (call[0] as { endpoint: string }).endpoint)
.filter(endpoint => endpoint.includes('/api/v1/chart/?q='))
.map(endpoint => {
const query = new URL(endpoint, 'http://localhost').searchParams.get('q');
return rison.decode(query!);
});
expect(listQueries).toHaveLength(2);
expect(listQueries).toEqual([
expect.objectContaining({ viz_type_order: ['slug_z', 'slug_a'] }),
expect.objectContaining({ viz_type_order: ['slug_z', 'slug_a'] }),
]);
});
test('useListViewResource: extra parameters cannot replace list controls', async () => {
const getSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: { result: [], count: 0 },
} as unknown as JsonResponse);
const { result } = renderHook(() =>
useListViewResource('chart', 'Charts', jest.fn()),
);
await act(async () => {
await result.current.fetchData({
pageIndex: 0,
pageSize: 25,
sortBy: [{ id: 'viz_type' }],
filters: [],
extraQueryParams: {
custom_param: 'preserved',
filters: [{ col: 'slice_name', opr: 'eq', value: 'injected' }],
order_column: 'slice_name',
order_direction: 'desc',
page: 99,
page_size: 1,
select_columns: ['slice_name'],
},
});
});
const endpoint = findEndpoint(getSpy, '/api/v1/chart/?q=');
const query = new URL(endpoint, 'http://localhost').searchParams.get('q');
expect(rison.decode(query!)).toEqual({
custom_param: 'preserved',
order_column: 'viz_type',
order_direction: 'asc',
page: 0,
page_size: 25,
});
});
// useSingleViewResource
test('useSingleViewResource: initial state has loading false and null resource', () => {
const { result } = renderHook(() =>
+17
View File
@@ -61,6 +61,15 @@ interface ListViewResourceState<D extends object = any> {
lastFetched?: string;
}
const reservedListQueryParams = new Set([
'filters',
'order_column',
'order_direction',
'page',
'page_size',
'select_columns',
]);
const parsedErrorMessage = (
errorMessage: Record<string, string[] | string> | string,
) => {
@@ -156,6 +165,7 @@ export function useListViewResource<D extends object = any>(
pageSize,
sortBy,
filters: filterValues,
extraQueryParams,
}: FetchDataConfig) => {
const requestId = latestRequestIdRef.current + 1;
latestRequestIdRef.current = requestId;
@@ -165,6 +175,7 @@ export function useListViewResource<D extends object = any>(
pageIndex,
pageSize,
sortBy,
extraQueryParams,
};
lastFetchDataConfigRef.current = config;
// set loading state, cache the last config for refreshing data.
@@ -186,7 +197,13 @@ export function useListViewResource<D extends object = any>(
: value,
}));
const safeExtraQueryParams = Object.fromEntries(
Object.entries(extraQueryParams ?? {}).filter(
([key]) => !reservedListQueryParams.has(key),
),
);
const queryParams = rison.encode_uri({
...safeExtraQueryParams,
order_column: sortBy[0].id,
order_direction: sortBy[0].desc ? 'desc' : 'asc',
page: pageIndex,
+60 -215
View File
@@ -22,7 +22,7 @@
"@types/eslint__js": "^8.42.3",
"@types/jsonwebtoken": "^9.0.10",
"@types/lodash-es": "^4.17.12",
"@types/node": "^26.2.0",
"@types/node": "^26.4.0",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.67.0",
"@typescript-eslint/parser": "^8.68.0",
@@ -32,7 +32,7 @@
"oxfmt": "^0.65.0",
"tscw-config": "^1.1.2",
"typescript": "^6.0.3",
"typescript-eslint": "^8.67.0",
"typescript-eslint": "^8.68.0",
"vitest": "^4.1.11"
},
"engines": {
@@ -979,9 +979,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.2.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
"version": "26.4.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz",
"integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1005,17 +1005,17 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz",
"integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==",
"version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz",
"integrity": "sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.67.0",
"@typescript-eslint/type-utils": "8.67.0",
"@typescript-eslint/utils": "8.67.0",
"@typescript-eslint/visitor-keys": "8.67.0",
"@typescript-eslint/scope-manager": "8.68.0",
"@typescript-eslint/type-utils": "8.68.0",
"@typescript-eslint/utils": "8.68.0",
"@typescript-eslint/visitor-keys": "8.68.0",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
@@ -1028,7 +1028,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.67.0",
"@typescript-eslint/parser": "^8.68.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
@@ -1058,7 +1058,7 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/project-service": {
"node_modules/@typescript-eslint/project-service": {
"version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.68.0.tgz",
"integrity": "sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==",
@@ -1080,7 +1080,7 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": {
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.68.0.tgz",
"integrity": "sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==",
@@ -1098,7 +1098,7 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/tsconfig-utils": {
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.68.0.tgz",
"integrity": "sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==",
@@ -1115,7 +1115,32 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": {
"node_modules/@typescript-eslint/type-utils": {
"version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.68.0.tgz",
"integrity": "sha512-X77zqoY1EjeWGs/0JNxeaMfp5C5lIz4Tw8y66F1Ne8Faq6g424sBNYM6xBAqElfGZPLpWS+CZAp0DXyKDzWiHg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.68.0",
"@typescript-eslint/typescript-estree": "8.68.0",
"@typescript-eslint/utils": "8.68.0",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.68.0.tgz",
"integrity": "sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==",
@@ -1129,7 +1154,7 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": {
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.68.0.tgz",
"integrity": "sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==",
@@ -1157,172 +1182,17 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": {
"version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.68.0.tgz",
"integrity": "sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.68.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/parser/node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz",
"integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.67.0",
"@typescript-eslint/types": "^8.67.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz",
"integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.67.0",
"@typescript-eslint/visitor-keys": "8.67.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz",
"integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz",
"integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.67.0",
"@typescript-eslint/typescript-estree": "8.67.0",
"@typescript-eslint/utils": "8.67.0",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz",
"integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz",
"integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.67.0",
"@typescript-eslint/tsconfig-utils": "8.67.0",
"@typescript-eslint/types": "8.67.0",
"@typescript-eslint/visitor-keys": "8.67.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz",
"integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==",
"version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.68.0.tgz",
"integrity": "sha512-PB5gJMMOg0Q5P1tsgWtEAqQacJXq0qEqRHDX/YJ4FaTMLfZPpHB3gjl2EJuiZyPABxmj4ZQYiY9m1bdAJ5y7tQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.67.0",
"@typescript-eslint/types": "8.67.0",
"@typescript-eslint/typescript-estree": "8.67.0"
"@typescript-eslint/scope-manager": "8.68.0",
"@typescript-eslint/types": "8.68.0",
"@typescript-eslint/typescript-estree": "8.68.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1337,13 +1207,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz",
"integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==",
"version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.68.0.tgz",
"integrity": "sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.67.0",
"@typescript-eslint/types": "8.68.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -3279,41 +3149,16 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz",
"integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==",
"version": "8.68.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.68.0.tgz",
"integrity": "sha512-MHy0Y0ynqeEbx/S45+i/bBssdy3X6KNBfmJAP35GrgtNxu2TQ5K5xsFDhAnmsq1jvpdoZOPG1LGtJo0HWqYCrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.67.0",
"@typescript-eslint/parser": "8.67.0",
"@typescript-eslint/typescript-estree": "8.67.0",
"@typescript-eslint/utils": "8.67.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz",
"integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.67.0",
"@typescript-eslint/types": "8.67.0",
"@typescript-eslint/typescript-estree": "8.67.0",
"@typescript-eslint/visitor-keys": "8.67.0",
"debug": "^4.4.3"
"@typescript-eslint/eslint-plugin": "8.68.0",
"@typescript-eslint/parser": "8.68.0",
"@typescript-eslint/typescript-estree": "8.68.0",
"@typescript-eslint/utils": "8.68.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+2 -2
View File
@@ -30,7 +30,7 @@
"@types/eslint__js": "^8.42.3",
"@types/jsonwebtoken": "^9.0.10",
"@types/lodash-es": "^4.17.12",
"@types/node": "^26.2.0",
"@types/node": "^26.4.0",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.67.0",
"@typescript-eslint/parser": "^8.68.0",
@@ -40,7 +40,7 @@
"oxfmt": "^0.65.0",
"tscw-config": "^1.1.2",
"typescript": "^6.0.3",
"typescript-eslint": "^8.67.0",
"typescript-eslint": "^8.68.0",
"vitest": "^4.1.11"
},
"engines": {
+35 -26
View File
@@ -187,6 +187,7 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
@@ -902,6 +903,7 @@
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
@@ -1093,11 +1095,13 @@
"integrity": "sha512-yYELe9Q5q9IQhuvqsZNwA5hfPkMJ8u92bQLIMcsMxf/VADjNtEYptU+inlufAFYcWdHlwNfZOEnOOQrZrcyJCQ=="
},
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
@@ -1246,13 +1250,14 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
@@ -1264,12 +1269,13 @@
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
@@ -1282,6 +1288,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
@@ -1299,6 +1306,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
@@ -2217,11 +2225,12 @@
"integrity": "sha512-yYELe9Q5q9IQhuvqsZNwA5hfPkMJ8u92bQLIMcsMxf/VADjNtEYptU+inlufAFYcWdHlwNfZOEnOOQrZrcyJCQ=="
},
"qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
"requires": {
"side-channel": "^1.1.0"
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
}
},
"range-parser": {
@@ -2334,24 +2343,24 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
},
"side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"requires": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
}
},
"side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"requires": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
"object-inspect": "^1.13.4"
}
},
"side-channel-map": {
+144 -2
View File
@@ -16,17 +16,35 @@
# under the License.
# pylint: disable=too-many-lines
import logging
from contextvars import ContextVar
from datetime import datetime
from io import BytesIO
from typing import Any, cast, Optional
from zipfile import is_zipfile, ZipFile
from flask import current_app, redirect, request, Response, url_for
from flask_appbuilder.api import expose, protect, rison as parse_rison, safe
from flask_appbuilder import permission_name
from flask_appbuilder.api import (
expose,
merge_response_func,
protect,
rison as parse_rison,
safe,
)
from flask_appbuilder.const import (
API_DESCRIPTION_COLUMNS_RIS_KEY,
API_LABEL_COLUMNS_RIS_KEY,
API_LIST_COLUMNS_RIS_KEY,
API_LIST_TITLE_RIS_KEY,
API_ORDER_COLUMNS_RIS_KEY,
)
from flask_appbuilder.hooks import before_request
from flask_appbuilder.models.sqla.interface import SQLAInterface
from flask_babel import ngettext
from marshmallow import ValidationError
from sqlalchemy import asc, case, desc
from sqlalchemy.orm import Query
from sqlalchemy.orm.util import AliasedClass
from werkzeug.wrappers import Response as WerkzeugResponse
from werkzeug.wsgi import FileWrapper
@@ -46,6 +64,7 @@ from superset.charts.filters import (
ChartTagNameFilter,
)
from superset.charts.schemas import (
chart_get_list_schema,
CHART_SCHEMAS,
ChartCacheWarmUpRequestSchema,
ChartGetResponseSchema,
@@ -142,9 +161,49 @@ _CHART_PURGE_BINDING = SoftDeleteBinding(
delete_failed=ChartDeleteFailedError,
)
_viz_type_order: ContextVar[dict[str, int] | None] = ContextVar(
"chart_viz_type_order", default=None
)
class ChartSQLAInterface(SQLAInterface):
"""Chart model interface with request-scoped display viz type ordering."""
def apply_order_by(
self,
query: Query,
order_column: str,
order_direction: str,
aliases_mapping: dict[str, AliasedClass] | None = None,
bypass_many_to_many: bool = False,
add_pk: bool = False,
) -> Query:
viz_type_order = _viz_type_order.get()
if order_column != "viz_type" or not viz_type_order:
return super().apply_order_by(
query,
order_column,
order_direction,
aliases_mapping=aliases_mapping,
bypass_many_to_many=bypass_many_to_many,
add_pk=add_pk,
)
order_expression = case(
viz_type_order,
value=Slice.viz_type,
else_=len(viz_type_order),
)
direction = asc if order_direction == "asc" else desc
order_by_columns = [direction(order_expression), direction(Slice.viz_type)]
primary_key = self.get_pk()
if add_pk and primary_key is not None:
order_by_columns.append(direction(primary_key))
return query.order_by(*order_by_columns)
class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
datamodel = SQLAInterface(Slice)
datamodel = ChartSQLAInterface(Slice)
resource_name = "chart"
allow_browser_login = True
@@ -312,6 +371,7 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
openapi_spec_component_schemas = CHART_SCHEMAS + (VersionListItemSchema,)
apispec_parameter_schemas = {
"chart_get_list_schema": chart_get_list_schema,
"screenshot_query_schema": screenshot_query_schema,
"get_delete_ids_schema": get_delete_ids_schema,
"get_export_ids_schema": get_export_ids_schema,
@@ -422,6 +482,88 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
if row_id in extra_editors_by_id:
row["extra_editors"] = extra_editors_by_id[row_id]
@expose("/", methods=("GET",))
@protect()
@safe
@permission_name("get")
@parse_rison(chart_get_list_schema)
@merge_response_func(
BaseSupersetModelRestApi.merge_order_columns, API_ORDER_COLUMNS_RIS_KEY
)
@merge_response_func(
BaseSupersetModelRestApi.merge_list_label_columns, API_LABEL_COLUMNS_RIS_KEY
)
@merge_response_func(
BaseSupersetModelRestApi.merge_description_columns,
API_DESCRIPTION_COLUMNS_RIS_KEY,
)
@merge_response_func(
BaseSupersetModelRestApi.merge_list_columns, API_LIST_COLUMNS_RIS_KEY
)
@merge_response_func(
BaseSupersetModelRestApi.merge_list_title, API_LIST_TITLE_RIS_KEY
)
def get_list(self, **kwargs: Any) -> Response:
"""Get a list of charts.
---
get:
summary: Get a list of charts
parameters:
- in: query
name: q
description: >-
Rison-encoded list query. viz_type_order may contain up to 256
unique visualization type slugs, each at most 250 characters,
in the display-name order to use when sorting by viz_type.
content:
application/json:
schema:
$ref: '#/components/schemas/chart_get_list_schema'
responses:
200:
description: Charts
content:
application/json:
schema:
type: object
properties:
ids:
type: array
items:
type: integer
count:
type: integer
result:
type: array
items:
$ref: >-
#/components/schemas/{{self.__class__.__name__}}.get_list
400:
$ref: '#/components/responses/400'
401:
$ref: '#/components/responses/401'
422:
$ref: '#/components/responses/422'
500:
$ref: '#/components/responses/500'
"""
return self.get_list_headless(**kwargs)
def get_list_headless(self, **kwargs: Any) -> Response:
"""Apply client display ordering before list pagination."""
args = kwargs.get("rison", {})
viz_types = args.get("viz_type_order")
if args.get("order_column") != "viz_type" or not viz_types:
return super().get_list_headless(**kwargs)
token = _viz_type_order.set(
{viz_type: index for index, viz_type in enumerate(viz_types)}
)
try:
return super().get_list_headless(**kwargs)
finally:
_viz_type_order.reset(token)
@expose("/<pk>/deck_layers/", methods=("GET",))
@protect()
@safe
+30 -1
View File
@@ -20,6 +20,7 @@ from __future__ import annotations
from typing import Any, TYPE_CHECKING
from flask import current_app
from flask_appbuilder.api.schemas import get_list_schema
from flask_babel import gettext as _
from marshmallow import (
EXCLUDE,
@@ -113,6 +114,26 @@ def validate_prophet_periods(value: int) -> None:
#
# RISON/JSON schemas for query parameters
#
MAX_VIZ_TYPE_ORDER_LENGTH = 256
MAX_VIZ_TYPE_LENGTH = 250
chart_get_list_schema = {
**get_list_schema,
"properties": {
**get_list_schema["properties"],
"viz_type_order": {
"type": "array",
"items": {"type": "string", "maxLength": MAX_VIZ_TYPE_LENGTH},
"maxItems": MAX_VIZ_TYPE_ORDER_LENGTH,
"uniqueItems": True,
"description": (
"Visualization type slugs in display-name order. Used only when "
"order_column is viz_type."
),
},
},
}
get_delete_ids_schema = {
"type": "array",
"items": {"type": "integer"},
@@ -1012,7 +1033,15 @@ class ChartDataGeodeticParseOptionsSchema(
class ChartDataPostProcessingOperationSchema(Schema):
_builtin_ops = pandas_postprocessing.__all__
# OPERATIONS excludes escape_separator/unescape_separator: those are
# internal str -> str helpers used by flatten, not DataFrame
# post-processing operations, so dispatching one against a DataFrame
# raises a confusing TypeError instead of the intended clean validation
# error. No field-level `validate=` here: it would run before, and thus
# reject, any EXTRA_PANDAS_POSTPROCESSING_OPS-registered custom
# operation, which `validate_operation` below is responsible for
# allowing.
_builtin_ops = pandas_postprocessing.OPERATIONS
operation = fields.String(
metadata={
+62 -36
View File
@@ -51,6 +51,19 @@ DEFAULT_CHART_HEIGHT = 50
DEFAULT_CHART_WIDTH = 4
def _coerce_dataset_id(raw_dataset_id: Any) -> Optional[int]:
if isinstance(raw_dataset_id, bool):
return None
if isinstance(raw_dataset_id, int):
return raw_dataset_id
if isinstance(raw_dataset_id, str) and raw_dataset_id.isdigit():
try:
return int(raw_dataset_id)
except ValueError:
return None
return None
def get_default_position(title: str) -> dict[str, Any]:
return {
"DASHBOARD_VERSION_KEY": "v2",
@@ -317,37 +330,53 @@ class ExportDashboardsCommand(ExportModelsCommand):
logger.info("Unable to decode `%s` field: %s", key, value)
payload[new_name] = {}
metadata = payload.get("metadata") or {}
referenced_dataset_ids = {
dataset_id
for native_filter in metadata.get("native_filter_configuration", [])
for target in native_filter.get("targets", [])
if (dataset_id := _coerce_dataset_id(target.get("datasetId"))) is not None
} | {
dataset_id
for customization in metadata.get("chart_customization_config") or []
for target in customization.get("targets") or []
if (dataset_id := _coerce_dataset_id(target.get("datasetId"))) is not None
}
datasets_by_id = {
dataset.id: dataset
for dataset in DatasetDAO.find_by_ids(list(referenced_dataset_ids))
}
# Extract all native filter datasets and replace native
# filter dataset references with uuid
for native_filter in payload.get("metadata", {}).get(
"native_filter_configuration", []
):
for native_filter in metadata.get("native_filter_configuration", []):
for target in native_filter.get("targets", []):
dataset_id = target.pop("datasetId", None)
if dataset_id is not None:
dataset = DatasetDAO.find_by_id(dataset_id)
if dataset:
target["datasetUuid"] = str(dataset.uuid)
dataset_id = _coerce_dataset_id(target.pop("datasetId", None))
if dataset_id is not None and (
dataset := datasets_by_id.get(dataset_id)
):
target["datasetUuid"] = str(dataset.uuid)
# Replace display control dataset references with uuid.
# datasetId is intentionally preserved alongside datasetUuid so that
# bundles remain importable by older versions that do not yet understand
# datasetUuid for display-control targets.
for customization in (
payload.get("metadata", {}).get("chart_customization_config") or []
):
for customization in metadata.get("chart_customization_config") or []:
for target in customization.get("targets") or []:
dataset_id = target.get("datasetId")
if dataset_id is not None:
dataset = DatasetDAO.find_by_id(dataset_id)
if dataset:
raw_dataset_id = target.get("datasetId")
if raw_dataset_id is not None:
dataset_id = _coerce_dataset_id(raw_dataset_id)
if dataset_id is not None and (
dataset := datasets_by_id.get(dataset_id)
):
target["datasetUuid"] = str(dataset.uuid)
else:
logger.warning(
"Dashboard '%s': display control target references "
"missing dataset %s; datasetUuid will not be set",
model.dashboard_title,
dataset_id,
raw_dataset_id,
)
# the mapping between dashboard -> charts is inferred from the position
@@ -440,30 +469,27 @@ class ExportDashboardsCommand(ExportModelsCommand):
payload[new_name] = {}
if export_related:
metadata = payload.get("metadata") or {}
# Extract all native filter datasets and export referenced datasets
for native_filter in payload.get("metadata", {}).get(
"native_filter_configuration", []
):
referenced_dataset_ids: set[int] = set()
for native_filter in metadata.get("native_filter_configuration", []):
for target in native_filter.get("targets", []):
dataset_id = target.pop("datasetId", None)
dataset_id = _coerce_dataset_id(target.pop("datasetId", None))
if dataset_id is not None:
dataset = DatasetDAO.find_by_id(dataset_id)
if dataset:
# Pass the shared seen set to the dataset export command
yield from ExportDatasetsCommand([dataset_id]).run(
seen=seen
)
referenced_dataset_ids.add(dataset_id)
# Export datasets referenced by display controls
for customization in (
payload.get("metadata", {}).get("chart_customization_config") or []
):
for customization in metadata.get("chart_customization_config") or []:
for target in customization.get("targets") or []:
dataset_id = target.get("datasetId")
dataset_id = _coerce_dataset_id(target.get("datasetId"))
if dataset_id is not None:
dataset = DatasetDAO.find_by_id(dataset_id)
if dataset:
# Pass the shared seen set to the dataset export command
yield from ExportDatasetsCommand([dataset_id]).run(
seen=seen
)
referenced_dataset_ids.add(dataset_id)
found_dataset_ids = [
dataset.id
for dataset in DatasetDAO.find_by_ids(list(referenced_dataset_ids))
]
if found_dataset_ids:
# Pass the shared seen set to the dataset export command
yield from ExportDatasetsCommand(found_dataset_ids).run(seen=seen)
+4 -4
View File
@@ -644,10 +644,10 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
raise InvalidPostProcessingError(
_("`operation` property of post processing object undefined")
)
# ``__all__`` is the authoritative list of built-in operations.
# ``hasattr`` would also match module internals (helpers, imported
# submodules, typing aliases), shadowing a like-named custom op.
if operation in pandas_postprocessing.__all__:
# ``OPERATIONS`` is the authoritative list of built-in operations;
# excludes escape_separator/unescape_separator (str -> str helpers
# used by flatten, not DataFrame post-processing operations).
if operation in pandas_postprocessing.OPERATIONS:
func = getattr(pandas_postprocessing, operation)
else:
extra_ops = pandas_postprocessing.build_extra_ops_map(
@@ -80,3 +80,13 @@ def build_extra_ops_map(
spec.
"""
return {name: fn for fn in extra if (name := getattr(fn, "__name__", None))}
# Operations that can be requested via a chart's `post_processing` spec.
# Excludes `escape_separator`/`unescape_separator`: those are internal
# str -> str helpers used by `flatten`, not DataFrame post-processing
# operations, so dispatching one against a DataFrame raises a confusing
# TypeError instead of the intended clean validation error.
OPERATIONS = [
name for name in __all__ if name not in ("escape_separator", "unescape_separator")
]
+114
View File
@@ -28,6 +28,7 @@ from parameterized import parameterized
from sqlalchemy import and_
from sqlalchemy.sql import func
from superset.charts.schemas import chart_get_list_schema
from superset.commands.chart.data.get_data_command import ChartDataCommand
from superset.commands.chart.exceptions import ChartDataQueryFailedError
from superset.connectors.sqla.models import SqlaTable
@@ -1545,6 +1546,119 @@ class TestChartApi(ApiEditorsTestCaseMixin, InsertChartMixin, SupersetTestCase):
data = json.loads(rv.data.decode("utf-8"))
assert data["count"] == 5
def test_chart_list_openapi_documents_viz_type_order(self):
"""Chart API: display ordering is part of the documented list contract."""
self.login(ADMIN_USERNAME)
rv = self.client.get("api/v1/_openapi")
assert rv.status_code == 200
spec = json.loads(rv.data.decode("utf-8"))
query_parameter = spec["paths"]["/api/v1/chart/"]["get"]["parameters"][0]
assert query_parameter["content"]["application/json"]["schema"] == {
"$ref": "#/components/schemas/chart_get_list_schema"
}
viz_type_order = spec["components"]["schemas"]["chart_get_list_schema"][
"properties"
]["viz_type_order"]
assert viz_type_order == chart_get_list_schema["properties"]["viz_type_order"]
@pytest.mark.usefixtures("load_energy_table_with_slice")
def test_get_charts_orders_display_viz_types_before_pagination(self):
"""Chart API: display chart type ordering happens before pagination."""
admin = self.get_user("admin")
charts = [
self.insert_chart("display_type_sort_a", [admin.id], 1, viz_type="slug_a"),
self.insert_chart(
"display_type_sort_middle", [admin.id], 1, viz_type="middle"
),
self.insert_chart("display_type_sort_z", [admin.id], 1, viz_type="slug_z"),
self.insert_chart(
"display_type_sort_unknown", [admin.id], 1, viz_type="unknown"
),
]
self.login(ADMIN_USERNAME)
arguments = {
"filters": [
{
"col": "slice_name",
"opr": "sw",
"value": "display_type_sort_",
}
],
"order_column": "viz_type",
"order_direction": "asc",
"page_size": 2,
"viz_type_order": ["slug_z", "middle", "slug_a"],
}
try:
for direction, expected in (
("asc", ["slug_z", "middle", "slug_a", "unknown"]),
("desc", ["unknown", "slug_a", "middle", "slug_z"]),
):
arguments["order_direction"] = direction
pages = []
for page in (0, 1):
arguments["page"] = page
uri = f"api/v1/chart/?q={rison.dumps(arguments)}"
rv = self.get_assert_metric(uri, "get_list")
assert rv.status_code == 200
data = json.loads(rv.data.decode("utf-8"))
assert data["count"] == 4
pages.extend(item["viz_type"] for item in data["result"])
assert pages == expected
arguments.update(
{
"order_column": "slice_name",
"order_direction": "asc",
"page": 0,
"page_size": 4,
}
)
uri = f"api/v1/chart/?q={rison.dumps(arguments)}"
rv = self.get_assert_metric(uri, "get_list")
assert rv.status_code == 200
data = json.loads(rv.data.decode("utf-8"))
assert [item["slice_name"] for item in data["result"]] == sorted(
chart.slice_name for chart in charts
)
arguments.update(
{
"order_column": "viz_type",
"viz_type_order": [],
}
)
uri = f"api/v1/chart/?q={rison.dumps(arguments)}"
rv = self.get_assert_metric(uri, "get_list")
assert rv.status_code == 200
data = json.loads(rv.data.decode("utf-8"))
assert [item["viz_type"] for item in data["result"]] == [
"middle",
"slug_a",
"slug_z",
"unknown",
]
arguments.pop("viz_type_order")
uri = f"api/v1/chart/?q={rison.dumps(arguments)}"
rv = self.get_assert_metric(uri, "get_list")
assert rv.status_code == 200
data = json.loads(rv.data.decode("utf-8"))
assert [item["viz_type"] for item in data["result"]] == [
"middle",
"slug_a",
"slug_z",
"unknown",
]
finally:
for chart in charts:
db.session.delete(chart)
db.session.commit()
@pytest.fixture
def load_energy_charts(self):
with app.app_context():
+53
View File
@@ -18,10 +18,13 @@
import pandas as pd
import pytest
from flask import current_app
from jsonschema import validate as validate_json_schema
from jsonschema.exceptions import ValidationError as JSONSchemaValidationError
from marshmallow import ValidationError
from pytest_mock import MockerFixture
from superset.charts.schemas import (
chart_get_list_schema,
ChartDataAdhocMetricSchema,
ChartDataExtrasSchema,
ChartDataPostProcessingOperationSchema,
@@ -35,9 +38,48 @@ from superset.charts.schemas import (
DEFAULT_MAX_PROPHET_PERIODS,
get_max_prophet_periods,
get_time_grain_choices,
MAX_VIZ_TYPE_LENGTH,
MAX_VIZ_TYPE_ORDER_LENGTH,
)
def test_chart_get_list_schema_accepts_viz_type_display_order() -> None:
validate_json_schema(
instance={
"order_column": "viz_type",
"viz_type_order": ["slug_z", "slug_a"],
},
schema=chart_get_list_schema,
)
validate_json_schema(
instance={"order_column": "viz_type", "viz_type_order": []},
schema=chart_get_list_schema,
)
@pytest.mark.parametrize(
"viz_type_order",
[
"slug_a",
[1],
["slug_a", "slug_a"],
["a" * (MAX_VIZ_TYPE_LENGTH + 1)],
[f"slug_{index}" for index in range(MAX_VIZ_TYPE_ORDER_LENGTH + 1)],
],
)
def test_chart_get_list_schema_rejects_invalid_viz_type_display_order(
viz_type_order: object,
) -> None:
with pytest.raises(JSONSchemaValidationError):
validate_json_schema(
instance={
"order_column": "viz_type",
"viz_type_order": viz_type_order,
},
schema=chart_get_list_schema,
)
def test_get_time_grain_choices(app_context: None) -> None:
"""Test that get_time_grain_choices returns values with config addons"""
# Save original config
@@ -559,3 +601,14 @@ def test_chart_data_adhoc_metric_schema_accepts_extended_aggregates(
}
)
assert result["aggregate"] == aggregate
@pytest.mark.parametrize("operation", ["escape_separator", "unescape_separator"])
def test_post_processing_operation_schema_rejects_string_helpers(
app_context: None, operation: str
) -> None:
"""`escape_separator`/`unescape_separator` are internal str -> str helpers,
not DataFrame post-processing operations, and shouldn't validate as one."""
schema = ChartDataPostProcessingOperationSchema()
with pytest.raises(ValidationError):
schema.load({"operation": operation, "options": {}})
@@ -82,12 +82,13 @@ def test_file_content_replaces_dataset_id_with_uuid_in_display_controls():
)
mock_dataset = MagicMock()
mock_dataset.id = 99
mock_dataset.uuid = dataset_uuid
with (
patch(
"superset.commands.dashboard.export.DatasetDAO.find_by_id",
return_value=mock_dataset,
"superset.commands.dashboard.export.DatasetDAO.find_by_ids",
return_value=[mock_dataset],
),
patch(
"superset.commands.dashboard.export.feature_flag_manager.is_feature_enabled",
@@ -108,6 +109,272 @@ def test_file_content_replaces_dataset_id_with_uuid_in_display_controls():
assert customizations[1]["targets"] == []
def test_file_content_batches_dataset_lookup_across_targets():
"""
Regression test: dataset lookups must go through a single batched
DatasetDAO.find_by_ids call, not one DatasetDAO.find_by_id call per
target. Multiple filters/customizations referencing the same dataset
must not trigger redundant DB round-trips.
"""
from superset.commands.dashboard.export import ExportDashboardsCommand
dataset_uuid_1 = str(uuid.uuid4())
dataset_uuid_2 = str(uuid.uuid4())
mock_dashboard = _make_mock_dashboard(
{
"native_filter_configuration": [
{
"id": "FILTER-1",
"targets": [{"datasetId": 1}, {"datasetId": 2}],
},
{
"id": "FILTER-2",
"targets": [{"datasetId": 1}],
},
],
"chart_customization_config": [
{
"id": "CUSTOMIZATION-1",
"type": "CHART_CUSTOMIZATION",
"targets": [{"datasetId": 1}],
},
],
}
)
mock_dataset_1 = MagicMock()
mock_dataset_1.id = 1
mock_dataset_1.uuid = dataset_uuid_1
mock_dataset_2 = MagicMock()
mock_dataset_2.id = 2
mock_dataset_2.uuid = dataset_uuid_2
with (
patch(
"superset.commands.dashboard.export.DatasetDAO.find_by_ids",
return_value=[mock_dataset_1, mock_dataset_2],
) as mock_find_by_ids,
patch(
"superset.commands.dashboard.export.DatasetDAO.find_by_id"
) as mock_find_by_id,
patch(
"superset.commands.dashboard.export.feature_flag_manager.is_feature_enabled",
return_value=False,
),
):
content = ExportDashboardsCommand._file_content(mock_dashboard)
mock_find_by_id.assert_not_called()
mock_find_by_ids.assert_called_once()
(called_ids,), _ = mock_find_by_ids.call_args
assert set(called_ids) == {1, 2}
result = yaml.safe_load(content)
native_filters = result["metadata"]["native_filter_configuration"]
assert native_filters[0]["targets"][0]["datasetUuid"] == dataset_uuid_1
assert native_filters[0]["targets"][1]["datasetUuid"] == dataset_uuid_2
assert native_filters[1]["targets"][0]["datasetUuid"] == dataset_uuid_1
customization_target = result["metadata"]["chart_customization_config"][0][
"targets"
][0]
assert customization_target["datasetUuid"] == dataset_uuid_1
def test_export_batches_dataset_export_across_targets():
"""
Regression test: _export must batch dataset exports into a single
ExportDatasetsCommand call, not one call per target. Multiple
filters/customizations referencing the same dataset must only trigger
a single find_by_ids lookup and a single export command.
"""
from superset.commands.dashboard.export import ExportDashboardsCommand
mock_dashboard = _make_mock_dashboard(
{
"native_filter_configuration": [
{
"id": "FILTER-1",
"targets": [{"datasetId": 1}, {"datasetId": 2}],
},
],
"chart_customization_config": [
{
"id": "CUSTOMIZATION-1",
"type": "CHART_CUSTOMIZATION",
"targets": [{"datasetId": 1}],
},
],
}
)
mock_dataset_1 = MagicMock()
mock_dataset_1.id = 1
mock_dataset_2 = MagicMock()
mock_dataset_2.id = 2
mock_datasets_cmd = MagicMock()
mock_datasets_cmd.run.return_value = iter([])
with (
patch(
"superset.commands.dashboard.export.DatasetDAO.find_by_ids",
return_value=[mock_dataset_1, mock_dataset_2],
) as mock_find_by_ids,
patch(
"superset.commands.dashboard.export.DatasetDAO.find_by_id"
) as mock_find_by_id,
patch(
"superset.commands.dashboard.export.ExportDatasetsCommand",
return_value=mock_datasets_cmd,
) as mock_datasets_cls,
patch(
"superset.commands.dashboard.export.ExportChartsCommand"
) as mock_charts_cls,
patch(
"superset.commands.dashboard.export.feature_flag_manager.is_feature_enabled",
return_value=False,
),
):
mock_charts_cls.return_value.run.return_value = iter([])
list(ExportDashboardsCommand._export(mock_dashboard))
mock_find_by_id.assert_not_called()
mock_find_by_ids.assert_called_once()
mock_datasets_cls.assert_called_once()
mock_datasets_cmd.run.assert_called_once()
(called_ids,), _ = mock_datasets_cls.call_args
assert set(called_ids) == {1, 2}
def test_file_content_resolves_string_and_int_dataset_ids_to_same_dataset():
"""
Regression test: datasetId may be stored as either an int or a numeric
string (native_filter_cache.py types it int | str). A target with a
string datasetId must still resolve against the (int-keyed) dataset
lookup instead of silently missing datasetUuid.
"""
from superset.commands.dashboard.export import ExportDashboardsCommand
dataset_uuid = str(uuid.uuid4())
mock_dashboard = _make_mock_dashboard(
{
"native_filter_configuration": [
{
"id": "FILTER-1",
"targets": [{"datasetId": "5"}, {"datasetId": 5}],
},
],
"chart_customization_config": [],
}
)
mock_dataset = MagicMock()
mock_dataset.id = 5
mock_dataset.uuid = dataset_uuid
with (
patch(
"superset.commands.dashboard.export.DatasetDAO.find_by_ids",
return_value=[mock_dataset],
) as mock_find_by_ids,
patch(
"superset.commands.dashboard.export.feature_flag_manager.is_feature_enabled",
return_value=False,
),
):
content = ExportDashboardsCommand._file_content(mock_dashboard)
# both the string and int forms of the same id are batched together
(called_ids,), _ = mock_find_by_ids.call_args
assert set(called_ids) == {5}
native_filters = yaml.safe_load(content)["metadata"]["native_filter_configuration"]
assert native_filters[0]["targets"][0]["datasetUuid"] == dataset_uuid
assert native_filters[0]["targets"][1]["datasetUuid"] == dataset_uuid
def test_coerce_dataset_id_rejects_non_integral_values():
"""Regression test: bare int() silently truncates 1.9 to 1, parses "1_0" as 10."""
from superset.commands.dashboard.export import _coerce_dataset_id
assert _coerce_dataset_id(5) == 5
assert _coerce_dataset_id("5") == 5
assert _coerce_dataset_id(1.9) is None
assert _coerce_dataset_id("1.9") is None
assert _coerce_dataset_id("1_0") is None
assert _coerce_dataset_id("abc") is None
assert _coerce_dataset_id(None) is None
assert _coerce_dataset_id(True) is None
assert _coerce_dataset_id(-5) == -5
assert _coerce_dataset_id("-5") is None
def test_export_skips_dangling_dataset_references_without_raising():
"""
Regression test: the find_by_ids pre-filter in _export must only pass
ids that actually resolved to ExportDatasetsCommand. Passing every
referenced id straight through including one for a dataset that no
longer exists would make ExportModelsCommand.validate() raise
DatasetNotFoundError and abort the entire dashboard export over a
single dangling filter/customization reference.
"""
from superset.commands.dashboard.export import ExportDashboardsCommand
mock_dashboard = _make_mock_dashboard(
{
"native_filter_configuration": [
{
"id": "FILTER-1",
"targets": [{"datasetId": 1}, {"datasetId": 2}],
},
],
"chart_customization_config": [
{
"id": "CUSTOMIZATION-1",
"type": "CHART_CUSTOMIZATION",
# dataset 3 no longer exists (deleted dataset)
"targets": [{"datasetId": 3}],
},
],
}
)
mock_dataset_1 = MagicMock()
mock_dataset_1.id = 1
mock_dataset_2 = MagicMock()
mock_dataset_2.id = 2
mock_datasets_cmd = MagicMock()
mock_datasets_cmd.run.return_value = iter([])
with (
# dataset 3 is deliberately absent from the resolved list
patch(
"superset.commands.dashboard.export.DatasetDAO.find_by_ids",
return_value=[mock_dataset_1, mock_dataset_2],
),
patch(
"superset.commands.dashboard.export.ExportDatasetsCommand",
return_value=mock_datasets_cmd,
) as mock_datasets_cls,
patch(
"superset.commands.dashboard.export.ExportChartsCommand"
) as mock_charts_cls,
patch(
"superset.commands.dashboard.export.feature_flag_manager.is_feature_enabled",
return_value=False,
),
):
mock_charts_cls.return_value.run.return_value = iter([])
# must not raise DatasetNotFoundError
list(ExportDashboardsCommand._export(mock_dashboard))
mock_datasets_cls.assert_called_once()
(called_ids,), _ = mock_datasets_cls.call_args
assert set(called_ids) == {1, 2}
def test_export_yields_dataset_files_for_display_controls():
"""
_export must yield dataset files for datasets referenced by display controls.
@@ -134,14 +401,15 @@ def test_export_yields_dataset_files_for_display_controls():
)
mock_dataset = MagicMock()
mock_dataset.id = dataset_id
sentinel_file = ("datasets/my_dataset.yaml", lambda: "dataset_content")
mock_datasets_cmd = MagicMock()
mock_datasets_cmd.run.return_value = iter([sentinel_file])
with (
patch(
"superset.commands.dashboard.export.DatasetDAO.find_by_id",
return_value=mock_dataset,
"superset.commands.dashboard.export.DatasetDAO.find_by_ids",
return_value=[mock_dataset],
),
patch(
"superset.commands.dashboard.export.ExportDatasetsCommand",
@@ -686,9 +954,10 @@ def test_stabilize_chart_ids_remaps_expanded_slices() -> None:
def test_file_content_missing_dataset_preserves_dataset_id() -> None:
"""
When DatasetDAO.find_by_id returns None for a display control target,
datasetId is preserved (dual-write: it was never popped) and no
datasetUuid is added the target is not silently emptied.
When DatasetDAO.find_by_ids does not return a match for a display
control target's dataset, datasetId is preserved (dual-write: it was
never popped) and no datasetUuid is added the target is not silently
emptied.
"""
from superset.commands.dashboard.export import ExportDashboardsCommand
@@ -706,8 +975,8 @@ def test_file_content_missing_dataset_preserves_dataset_id() -> None:
with (
patch(
"superset.commands.dashboard.export.DatasetDAO.find_by_id",
return_value=None,
"superset.commands.dashboard.export.DatasetDAO.find_by_ids",
return_value=[],
),
patch(
"superset.commands.dashboard.export.feature_flag_manager.is_feature_enabled",
@@ -718,3 +718,20 @@ def test_post_processing_keeps_an_entry_without_an_operation():
query_object = QueryObject(row_limit=1, post_processing=post_processing)
assert query_object.post_processing == post_processing
@pytest.mark.parametrize("operation", ["escape_separator", "unescape_separator"])
def test_exec_post_processing_rejects_string_helpers(
app_context: None, operation: str
) -> None:
"""`escape_separator`/`unescape_separator` are str -> str helpers used by
`flatten`, not DataFrame post-processing operations, and must not be
reachable as a `post_processing` operation name."""
df = pd.DataFrame({"value": [1, 2, 3]})
query_object = QueryObject(
row_limit=10,
post_processing=[{"operation": operation, "options": {}}],
)
with pytest.raises(InvalidPostProcessingError):
query_object.exec_post_processing(df)