mirror of
https://github.com/apache/superset.git
synced 2026-09-09 00:34:49 +00:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b42b67aea9 | ||
|
|
2362ffc815 | ||
|
|
1b0094565b | ||
|
|
85a5f273b7 | ||
|
|
a6cbd7c3b5 | ||
|
|
5d8ff9181a | ||
|
|
c91c901832 | ||
|
|
232ccf66d6 | ||
|
|
dd1a475df6 | ||
|
|
9828281e7f | ||
|
|
b558f41461 | ||
|
|
28578160ff | ||
|
|
d2d965e009 | ||
|
|
a95c40aa1e | ||
|
|
d9887fd928 | ||
|
|
669a0cefb4 | ||
|
|
0398b320f9 | ||
|
|
9d855a4adf | ||
|
|
3bd0ad5267 | ||
|
|
464adba69b | ||
|
|
c185a2c678 | ||
|
|
bf12a21367 | ||
|
|
18465bad51 |
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
|
||||
Generated
+127
-93
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+112
@@ -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();
|
||||
});
|
||||
|
||||
+94
-1
@@ -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(() =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+60
-215
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
]
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user