mirror of
https://github.com/apache/superset.git
synced 2026-08-21 15:41:16 +00:00
Compare commits
90
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a05a099987 | ||
|
|
05842a6350 | ||
|
|
e45dd2d3f1 | ||
|
|
65910abf21 | ||
|
|
6e22050b50 | ||
|
|
132340b652 | ||
|
|
14408eb3db | ||
|
|
87743ef3f8 | ||
|
|
fb53761ec8 | ||
|
|
f7d505e1fd | ||
|
|
22396d504a | ||
|
|
27ea5de44a | ||
|
|
18fc2c6228 | ||
|
|
18a36d04c7 | ||
|
|
01b1d58ac9 | ||
|
|
7441ce90ae | ||
|
|
42ba2a4433 | ||
|
|
271564cb0d | ||
|
|
bcfb4346f6 | ||
|
|
b5fe00b407 | ||
|
|
148ffaff50 | ||
|
|
83c6ea4e03 | ||
|
|
1ca5e61f54 | ||
|
|
1ef12580a7 | ||
|
|
c0884c0f0c | ||
|
|
2ff79bd495 | ||
|
|
e7dccd44a7 | ||
|
|
fdd3945dde | ||
|
|
8b67fb4d30 | ||
|
|
b288db91f9 | ||
|
|
80bba12d0f | ||
|
|
5d4358a149 | ||
|
|
f9cedf84e2 | ||
|
|
c2d653b4b8 | ||
|
|
5a96c3f538 | ||
|
|
faf7c34c0a | ||
|
|
b8fca2145d | ||
|
|
c10054f521 | ||
|
|
8c500ccee1 | ||
|
|
6d77efad29 | ||
|
|
8222db3340 | ||
|
|
01ce8358a6 | ||
|
|
53a8a0e140 | ||
|
|
eafbff9f8d | ||
|
|
1339bcd9da | ||
|
|
334e280489 | ||
|
|
c07f3ebf2d | ||
|
|
1569915096 | ||
|
|
fde0ba26d1 | ||
|
|
097c99b19c | ||
|
|
5ce52e531d | ||
|
|
34cd50cc48 | ||
|
|
c0ab5f3385 | ||
|
|
7d4f30574f | ||
|
|
e4ea6e23d8 | ||
|
|
a13a5f1af4 | ||
|
|
f994602096 | ||
|
|
086b4af65d | ||
|
|
fd063d17bf | ||
|
|
60e1802c52 | ||
|
|
2d1daac11a | ||
|
|
3c90bdc6f0 | ||
|
|
ebab31adc2 | ||
|
|
936f073b9a | ||
|
|
d12320239a | ||
|
|
8ef7be5788 | ||
|
|
72458ab26f | ||
|
|
5105f13726 | ||
|
|
bdafb6c330 | ||
|
|
b4d79462ec | ||
|
|
49374f1fe5 | ||
|
|
f766de6d0d | ||
|
|
ed20e729d0 | ||
|
|
13eb47a1da | ||
|
|
98136d547c | ||
|
|
e2070d79dc | ||
|
|
2807f1b0e8 | ||
|
|
c9c230142b | ||
|
|
2f8875aaef | ||
|
|
6e270df4a2 | ||
|
|
97eafd6140 | ||
|
|
3ed97f9691 | ||
|
|
5105899810 | ||
|
|
d917071708 | ||
|
|
afde126d9a | ||
|
|
b3a9b9beb4 | ||
|
|
70ba9c9552 | ||
|
|
98276cd1f3 | ||
|
|
cdeca0c179 | ||
|
|
aaf9eba161 |
@@ -1,6 +1,6 @@
|
||||
name: Bug report
|
||||
description: Report a bug to improve Superset's stability
|
||||
labels: ["bug"]
|
||||
labels: ["#bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: Cosmetic Issue
|
||||
about: Describe a cosmetic issue with CSS, positioning, layout, labeling, or similar
|
||||
labels: "cosmetic-issue"
|
||||
labels: "#bug:cosmetic"
|
||||
---
|
||||
|
||||
## Screenshot
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@
|
||||
"dependencies:python":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- 'superset/requirements/**'
|
||||
- 'requirements/**'
|
||||
- 'superset/translations/requirements.txt'
|
||||
- 'RELEASING/requirements.txt'
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
python-version: "3.11"
|
||||
enable-cache: true
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -78,6 +78,6 @@ jobs:
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
@@ -78,14 +78,14 @@ jobs:
|
||||
USE_DASHBOARD: ${{ github.event.inputs.use_dashboard == 'true' || 'false' }}
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
image: ghcr.io/apache/superset/ci/postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: superset
|
||||
POSTGRES_PASSWORD: superset
|
||||
ports:
|
||||
- 15432:5432
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
image: ghcr.io/apache/superset/ci/redis:7-alpine
|
||||
ports:
|
||||
- 16379:6379
|
||||
steps:
|
||||
@@ -186,14 +186,14 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
image: ghcr.io/apache/superset/ci/postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: superset
|
||||
POSTGRES_PASSWORD: superset
|
||||
ports:
|
||||
- 15432:5432
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
image: ghcr.io/apache/superset/ci/redis:7-alpine
|
||||
ports:
|
||||
- 16379:6379
|
||||
steps:
|
||||
|
||||
@@ -53,9 +53,7 @@ jobs:
|
||||
mysql+mysqldb://superset:superset@127.0.0.1:13306/superset?charset=utf8mb4&binary_prefix=true
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
# Authenticated pulls use our higher Docker Hub rate limit. Empty on
|
||||
# fork PRs (secrets unavailable) -> runner falls back to anonymous.
|
||||
image: ghcr.io/apache/superset/ci/mysql:8.0
|
||||
env:
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
ports:
|
||||
@@ -66,7 +64,7 @@ jobs:
|
||||
--health-timeout=5s
|
||||
--health-retries=5
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
image: ghcr.io/apache/superset/ci/redis:7-alpine
|
||||
options: --entrypoint redis-server
|
||||
ports:
|
||||
- 16379:6379
|
||||
@@ -143,7 +141,7 @@ jobs:
|
||||
SUPERSET__SQLALCHEMY_DATABASE_URI: postgresql+psycopg2://superset:superset@127.0.0.1:15432/superset
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
image: ghcr.io/apache/superset/ci/postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: superset
|
||||
POSTGRES_PASSWORD: superset
|
||||
@@ -152,7 +150,7 @@ jobs:
|
||||
# GitHub action runner's default installations
|
||||
- 15432:5432
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
image: ghcr.io/apache/superset/ci/redis:7-alpine
|
||||
ports:
|
||||
- 16379:6379
|
||||
steps:
|
||||
@@ -202,7 +200,7 @@ jobs:
|
||||
sqlite:///${{ github.workspace }}/.temp/examples.db?check_same_thread=true
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
image: ghcr.io/apache/superset/ci/redis:7-alpine
|
||||
ports:
|
||||
- 16379:6379
|
||||
steps:
|
||||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
SUPERSET__SQLALCHEMY_EXAMPLES_URI: presto://localhost:15433/memory/default
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
image: ghcr.io/apache/superset/ci/postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: superset
|
||||
POSTGRES_PASSWORD: superset
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
# GitHub action runner's default installations
|
||||
- 15432:5432
|
||||
presto:
|
||||
image: starburstdata/presto:350-e.6
|
||||
image: ghcr.io/apache/superset/ci/presto:350-e.6
|
||||
env:
|
||||
POSTGRES_USER: superset
|
||||
POSTGRES_PASSWORD: superset
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
# GitHub action runner's default installations
|
||||
- 15433:8080
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
image: ghcr.io/apache/superset/ci/redis:7-alpine
|
||||
ports:
|
||||
- 16379:6379
|
||||
steps:
|
||||
@@ -116,7 +116,7 @@ jobs:
|
||||
UPLOAD_FOLDER: /tmp/.superset/uploads/
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
image: ghcr.io/apache/superset/ci/postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: superset
|
||||
POSTGRES_PASSWORD: superset
|
||||
@@ -125,7 +125,7 @@ jobs:
|
||||
# GitHub action runner's default installations
|
||||
- 15432:5432
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
image: ghcr.io/apache/superset/ci/redis:7-alpine
|
||||
ports:
|
||||
- 16379:6379
|
||||
steps:
|
||||
|
||||
+43
@@ -24,6 +24,31 @@ assists people when migrating to a new version.
|
||||
|
||||
## Next
|
||||
|
||||
- `SAMPLES_ROW_LIMIT` is now the default for `/datasource/samples` requests without a valid explicit `per_page`, rather than a hard per-request ceiling; explicit limits are honored up to the existing global row-limit ceiling, matching `/chart/data` SAMPLES requests.
|
||||
|
||||
### MCP tool results preserve stored string values
|
||||
|
||||
Structured MCP tool results no longer add `<UNTRUSTED-CONTENT>` wrappers or
|
||||
rewrite delimiter-looking text inside string fields. Tool-result content remains
|
||||
user-controlled data, but clients must convey that trust boundary outside domain
|
||||
values instead of recognizing or removing marker strings.
|
||||
|
||||
Clients that handled the former delimiter convention should stop stripping marker
|
||||
text: the same text can be legitimate stored content. Response models and content
|
||||
types are unchanged, and no metadata-database migration is required. Automated
|
||||
read-modify-write workflows should be paused or pinned away from older instances
|
||||
until every serving instance is upgraded; a mixed-version response has no reliable
|
||||
signal that tells a client whether its text is decorated. Redis-backed MCP response
|
||||
caches use a new internal namespace after the upgrade, so upgraded instances do not
|
||||
reuse older cached results.
|
||||
|
||||
Values that a client already wrote back with presentation wrappers cannot be
|
||||
distinguished safely from intentional content. Operators should review possible
|
||||
`<UNTRUSTED-CONTENT>` / `</UNTRUSTED-CONTENT>` wrappers and
|
||||
`[ESCAPED-UNTRUSTED-CONTENT-OPEN]` /
|
||||
`[ESCAPED-UNTRUSTED-CONTENT-CLOSE]` substitutions rather than applying an automatic
|
||||
marker-removal migration.
|
||||
|
||||
### OAuth2 database callback metrics include their outcome
|
||||
|
||||
The unqualified `DatabaseRestApi.oauth2` StatsD counter has been replaced with
|
||||
@@ -31,6 +56,7 @@ The unqualified `DatabaseRestApi.oauth2` StatsD counter has been replaced with
|
||||
`DatabaseRestApi.oauth2.error`. Update monitoring rules and dashboards that consume
|
||||
the old counter to use the outcome-specific replacements.
|
||||
|
||||
- [42930](https://github.com/apache/superset/pull/42930): Dataset import data-URI fetches no longer honor an HTTP(S) proxy when `DATASET_IMPORT_ALLOW_INTERNAL_DATA_URLS` is `False` (the default): the connection is now made directly to the destination so the peer-address check validates the real target instead of a proxy's. Deployments that require an egress proxy to reach legitimate external data URLs for dataset import should set `DATASET_IMPORT_ALLOW_INTERNAL_DATA_URLS = True` or otherwise ensure those URLs resolve without one.
|
||||
- [42935](https://github.com/apache/superset/pull/42935): The MCP service now refuses to start (`MCPAuthConfigError`) when `MCP_JWT_ISSUER` trusts more than one issuer and no `MCP_USER_RESOLVER` is configured, instead of only logging a warning. This was already a documented misconfiguration (the default resolver isn't issuer-scoped, so distinct trusted issuers minting the same username/email would resolve to the same Superset user); deployments trusting multiple issuers must configure an `MCP_USER_RESOLVER` that derives its identity from the token's `iss` claim before upgrading. Single-issuer deployments are unaffected.
|
||||
- [42393](https://github.com/apache/superset/pull/42393): Exported dataset YAML now carries a `uuid` for each metric and column so that custom folder assignments (which reference metrics/columns by UUID) survive an import into another workspace. This affects any export bundle that contains datasets, not just a dataset export: chart, dashboard, database and full-asset exports all embed the same dataset YAML, so a dashboard exported from this release also fails to import into an older one even though no dataset was exported directly. As with `folders` and `currency_code_column`, the affected `datasets/` files fail schema validation (`Unknown field: uuid`) when imported into Superset releases that predate this change; regenerate or hand-edit exports for older targets in mixed-version fleets.
|
||||
- [42300](https://github.com/apache/superset/pull/42300): Timeseries charts (line/area/bar) with a Y-axis bound in effect — either an explicit `yAxisBounds` or one derived from `truncateYAxis` — now clamp out-of-range data points to that bound instead of letting ECharts drop the point (and the line segments around it) entirely. Any existing chart with a configured Y-axis bound and data outside it will look different after upgrading: a gap becomes a point pinned to the boundary. The clamp also rewrites the value ECharts reads for that point's tooltip and data label, so the displayed value is the bound rather than the true observation.
|
||||
@@ -99,6 +125,23 @@ dialect; each package's constraint in `pyproject.toml` documents why.
|
||||
No application-level configuration changes are required for deployments
|
||||
that don't touch SQLAlchemy directly.
|
||||
|
||||
### New metric aggregates: MEDIAN, Sample Standard Deviation, Sample Variance
|
||||
|
||||
`MEDIAN`, `STDDEV_SAMP`, and `VAR_SAMP` are now available anywhere a metric
|
||||
aggregate is chosen (every chart type, SQL Lab, MCP), not only in Pivot
|
||||
Table's controls. Support is opt-in per database engine *spec class*,
|
||||
verified against a live instance before being enabled: Postgres, MySQL
|
||||
(`STDDEV_SAMP`/`VAR_SAMP` only, no `MEDIAN`), DuckDB, and Redshift (inherits
|
||||
Postgres's support, not yet separately verified) ship enabled in this
|
||||
release. Engine specs that subclass one of those (e.g. MariaDB, Aurora
|
||||
MySQL/Postgres, TimescaleDB) inherit the same support, on the same
|
||||
not-yet-independently-verified basis. Picking one of these aggregates on a
|
||||
database that has not opted in returns a clear "not supported on this
|
||||
database" error rather than a failed query. See
|
||||
`docs/sip/median-stddev-variance-aggregates.md` for the full design
|
||||
rationale, including why this is safe to add without reintroducing the
|
||||
totals/subtotals correctness bug fixed by #41184 (SIP-216).
|
||||
|
||||
### Soft delete is on by default, and purging is live
|
||||
|
||||
`SOFT_DELETE` now ships **on** (`DEFAULT_FEATURE_FLAGS`), so deleting a
|
||||
|
||||
@@ -576,7 +576,7 @@ MCP_CACHE_CONFIG = {
|
||||
| Key | Default | Description |
|
||||
| -------------------- | --------- | ----------------------------------------------------------- |
|
||||
| `enabled` | `False` | Enable response caching |
|
||||
| `CACHE_KEY_PREFIX` | `None` | Optional prefix for cache keys (useful for shared Redis) |
|
||||
| `CACHE_KEY_PREFIX` | `None` | Base prefix for shared Redis; Superset appends an internal response-contract namespace |
|
||||
| `list_tools_ttl` | `300` | Cache TTL in seconds for `tools/list` |
|
||||
| `list_resources_ttl` | `300` | Cache TTL for `resources/list` |
|
||||
| `list_prompts_ttl` | `300` | Cache TTL for `prompts/list` |
|
||||
@@ -718,6 +718,34 @@ Every MCP request passes through a middleware stack before reaching the tool fun
|
||||
|
||||
Additional middleware classes (`RateLimitMiddleware`, `FieldPermissionsMiddleware`, `PrivateToolMiddleware`) are implemented in `superset/mcp_service/middleware.py` but are not added to the default pipeline. They are available for operators who want to layer them in via a custom startup path.
|
||||
|
||||
### Tool Result Value Contract
|
||||
|
||||
Structured tool results preserve Superset domain values exactly. In particular,
|
||||
string fields are not wrapped in trust delimiters, and text that resembles a
|
||||
delimiter is returned as literal application data. This lets clients safely use a
|
||||
read result as the basis for an update without persisting presentation markup.
|
||||
|
||||
All tool-result content should still be treated as user-controlled data with no
|
||||
instruction authority. MCP clients should communicate that trust boundary through
|
||||
their model instructions or presentation layer, outside the returned field values;
|
||||
fixed or generated marker strings inside a value are ambiguous and must not be used
|
||||
as a trust signal.
|
||||
|
||||
For compatibility, clients that supported the former
|
||||
`<UNTRUSTED-CONTENT>` convention should stop recognizing or stripping those strings.
|
||||
The response schemas and content types have not changed. Because marker-looking text
|
||||
can be legitimate application data, a client cannot reliably distinguish a legacy
|
||||
decorated response from a clean one. Pause automated read-modify-write workflows, or
|
||||
route them only to upgraded instances, until every serving instance is upgraded.
|
||||
|
||||
Redis-backed MCP response caches include an internal response-contract namespace, so
|
||||
an upgraded instance does not reuse responses cached by an older release. Older
|
||||
instances can still return legacy values while they remain in service. After the
|
||||
upgrade, review previously written values for wrapper text and both
|
||||
`[ESCAPED-UNTRUSTED-CONTENT-OPEN]` and
|
||||
`[ESCAPED-UNTRUSTED-CONTENT-CLOSE]`; do not remove these strings automatically,
|
||||
because they may be intentional content.
|
||||
|
||||
### Error Sanitization
|
||||
|
||||
The `GlobalErrorHandlerMiddleware` automatically redacts sensitive information from all error messages before they reach the LLM client. The following are replaced with generic messages:
|
||||
@@ -752,7 +780,11 @@ For a 3-pod Kubernetes deployment with the defaults above, expect up to 3 × (5
|
||||
Enable response caching for read-heavy workloads (dashboards/datasets that don't change frequently). With the in-memory backend (default when `MCP_STORE_CONFIG` is disabled), caching is per-process. Use Redis-backed caching for consistent cache hits across multiple pods:
|
||||
|
||||
```python
|
||||
MCP_CACHE_CONFIG = {"enabled": True, "call_tool_ttl": 3600}
|
||||
MCP_CACHE_CONFIG = {
|
||||
"enabled": True,
|
||||
"CACHE_KEY_PREFIX": "mcp_cache_",
|
||||
"call_tool_ttl": 3600,
|
||||
}
|
||||
MCP_STORE_CONFIG = {"enabled": True, "CACHE_REDIS_URL": "redis://redis:6379/0"}
|
||||
```
|
||||
|
||||
|
||||
@@ -400,7 +400,7 @@ Once enabled, each user manages their own keys from their profile page:
|
||||
1. Open the user menu (top-right) and click **Info** to navigate to the User Info page
|
||||
2. Expand the **API Keys** section
|
||||
3. Click **+ API Key**
|
||||
4. Enter a name and (optionally) an expiration date
|
||||
4. Enter a name and optionally select resource scopes
|
||||
5. Copy the generated token — it is shown only once
|
||||
|
||||
Only users with the `can_read` and `can_write` permissions on `ApiKey` (granted by default to Admins) can manage API keys.
|
||||
@@ -415,6 +415,18 @@ Authorization: Bearer <your-api-key>
|
||||
|
||||
This works for all REST API endpoints and the MCP server. The request is executed with the permissions of the user who created the key.
|
||||
|
||||
#### API Key Scopes
|
||||
|
||||
The creation dialog can restrict an API key to MCP resource actions such as
|
||||
`superset:dashboard:read` or `superset:chart:write`. A scope is an additional
|
||||
restriction: it never grants a permission that the creating user does not
|
||||
already have through Superset RBAC. Write scopes also cover update and delete
|
||||
operations for that resource; `superset:sqllab:write` covers SQL execution.
|
||||
|
||||
Keys created without scopes retain legacy RBAC-only behavior. The scoped-key
|
||||
restrictions described here are enforced by the MCP server; regular REST API
|
||||
routes continue to apply their existing Superset RBAC checks.
|
||||
|
||||
#### Use Cases
|
||||
|
||||
- **CI/CD pipelines** — automated chart/dashboard exports and imports
|
||||
|
||||
+13
-13
@@ -58,14 +58,14 @@
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"@saucelabs/theme-github-codeblock": "^0.3.0",
|
||||
"@storybook/addon-docs": "^10.5.7",
|
||||
"@storybook/addon-docs": "^10.5.8",
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.15.47",
|
||||
"antd": "^6.5.4",
|
||||
"baseline-browser-mapping": "^2.11.12",
|
||||
"caniuse-lite": "^1.0.30001807",
|
||||
"docusaurus-plugin-openapi-docs": "^5.1.3",
|
||||
"docusaurus-theme-openapi-docs": "^5.1.3",
|
||||
"antd": "^6.6.0",
|
||||
"baseline-browser-mapping": "^2.11.13",
|
||||
"caniuse-lite": "^1.0.30001809",
|
||||
"docusaurus-plugin-openapi-docs": "^5.2.0",
|
||||
"docusaurus-theme-openapi-docs": "^5.2.0",
|
||||
"js-yaml": "^5.2.3",
|
||||
"json-bigint": "^1.0.0",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
@@ -77,8 +77,8 @@
|
||||
"react-table": "^7.8.0",
|
||||
"remark-import-partial": "^0.0.2",
|
||||
"reselect": "^5.2.0",
|
||||
"storybook": "^10.5.7",
|
||||
"swagger-ui-react": "^5.32.12",
|
||||
"storybook": "^10.5.8",
|
||||
"swagger-ui-react": "^5.32.13",
|
||||
"swc-loader": "^0.2.7",
|
||||
"tinycolor2": "^1.4.2",
|
||||
"unist-util-visit": "^5.1.0"
|
||||
@@ -89,14 +89,14 @@
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/react": "^19.1.8",
|
||||
"@typescript-eslint/eslint-plugin": "^8.66.0",
|
||||
"@typescript-eslint/parser": "^8.66.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.67.0",
|
||||
"@typescript-eslint/parser": "^8.67.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"globals": "^17.9.0",
|
||||
"oxfmt": "^0.62.0",
|
||||
"globals": "^17.11.0",
|
||||
"oxfmt": "^0.63.0",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.66.0",
|
||||
"typescript-eslint": "^8.67.0",
|
||||
"webpack": "^5.109.2"
|
||||
},
|
||||
"browserslist": {
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
|
||||
# SIP: System-wide MEDIAN, Sample Standard Deviation, and Sample Variance metric aggregates
|
||||
|
||||
## [DRAFT — proposal for discussion]
|
||||
|
||||
This document now has an accompanying implementation in this PR, for the
|
||||
proposed mechanism plus a first, empirically-verified engine set (Postgres,
|
||||
MySQL, DuckDB, Redshift by inheritance). It is intended to seed discussion on
|
||||
whether this is the right shape and scope before it goes up for a formal SIP
|
||||
vote, not to pre-empt that discussion, the code exists so reviewers have a
|
||||
concrete design to react to rather than a description of one.
|
||||
|
||||
## Motivation
|
||||
|
||||
Before #41184 (SIP-216, the non-additive-totals fix), the Pivot Table chart
|
||||
exposed an "Aggregation function" control with 18 choices, including
|
||||
`Median`, `Sample Standard Deviation`, `Sample Variance`, `First`, `Last`,
|
||||
`Count Unique Values`, and `List Unique Values`. #41184 deleted that control
|
||||
wholesale, and deliberately so: it re-aggregated already-aggregated cell
|
||||
values to compute totals/subtotals, which is exactly the class of bug
|
||||
SIP-216 fixed (summing per-group averages, averaging per-group medians, etc.
|
||||
produces silently wrong totals). #42761 subsequently restored the one piece
|
||||
of that control's functionality that was cleanly separable from the
|
||||
correctness bug, the "show as % of row/column/total" display option,
|
||||
redesigned as a decoupled, post-hoc-only `showValuesAs` control.
|
||||
|
||||
A user has since noticed that several of the other pre-#41184 options never
|
||||
came back. Checking today's metric aggregate list (`AVG, COUNT,
|
||||
COUNT_DISTINCT, MAX, MIN, SUM`, see
|
||||
`superset-frontend/packages/superset-ui-core/src/query/types/Metric.ts`),
|
||||
most of these have a reasonable equivalent already: `Count Unique Values`
|
||||
maps to `COUNT_DISTINCT`; `Count`/`Average`/`Max`/`Min` are already standard
|
||||
aggregates; the two "fraction of" variants are already covered by
|
||||
`showValuesAs`. But `Median`, `Sample Standard Deviation`, and `Sample
|
||||
Variance` have no equivalent today anywhere in Superset, not just in Pivot
|
||||
Table, in any chart type, since the aggregate list is shared across the
|
||||
whole app.
|
||||
|
||||
This is a real, currently-live gap, not a hypothetical one:
|
||||
`superset/mcp_service/chart/chart_utils.py`, `schemas.py`, and
|
||||
`prompts/create_chart_guided.py` already treat `STDDEV`, `VAR`, `MEDIAN`,
|
||||
and `PERCENTILE` as valid aggregate values in their own validation and
|
||||
documentation, but those values are never recognized by
|
||||
`superset/connectors/sqla/models.py`'s `sqla_aggregations` dict (the actual
|
||||
mapping from aggregate name to SQL), so an AI agent using the MCP tool to
|
||||
build a chart with `"aggregate": "STDDEV"` today creates a chart that
|
||||
**errors at query time** with "Adhoc metric aggregate is invalid." This SIP
|
||||
proposes closing that gap for real, at the source, rather than patching
|
||||
around it in MCP.
|
||||
|
||||
## Proposed change
|
||||
|
||||
Add `MEDIAN`, `STDDEV_SAMP`, and `VAR_SAMP` as first-class, system-wide
|
||||
metric aggregates, available anywhere a metric aggregate is chosen (every
|
||||
chart type, SQL Lab metric picker, MCP), not as a Pivot-Table-specific
|
||||
control.
|
||||
|
||||
**Why this is safe with respect to SIP-216, and needs no Pivot-Table-specific
|
||||
code at all:** Pivot Table's non-additive-totals machinery
|
||||
(`superset-frontend/plugins/plugin-chart-pivot-table/src/plugin/utilities.ts`)
|
||||
already classifies any metric aggregate not in `ADDITIVE_AGGREGATES = {SUM,
|
||||
COUNT, MIN, MAX}` as non-additive, which routes totals/subtotals through the
|
||||
correct DB-`GROUPING SETS`-rollup path rather than client-side
|
||||
re-aggregation (`AVG` and `COUNT_DISTINCT` already go through this path
|
||||
today). `MEDIAN`/`STDDEV_SAMP`/`VAR_SAMP` fall into that bucket
|
||||
automatically, with zero changes needed to the additivity logic. So once
|
||||
these are valid, buildable SQL aggregates, Pivot Table (and every other
|
||||
chart) gets correct behavior for free. This is the version of "restore the
|
||||
control" that does not reopen the bug that was just fixed.
|
||||
|
||||
**Where the actual change needs to land, and what this PR does:**
|
||||
|
||||
1. **Done.** `superset-frontend/packages/superset-ui-core/src/query/types/Metric.ts`,
|
||||
extended the `Aggregate` type.
|
||||
2. **Done.** `superset-frontend/src/explore/constants.ts`, added to `AGGREGATES`
|
||||
(drives `AGGREGATES_OPTIONS`, the dropdown in `AdhocMetricEditPopover`).
|
||||
3. **Done**, but not consolidated. `superset/connectors/sqla/models.py`
|
||||
(`sqla_aggregations`) and `superset/models/helpers.py`
|
||||
(`ExploreMixin.sqla_aggregations`) are both wired to consult the new
|
||||
`BaseEngineSpec.get_extended_aggregation_func`, in addition to their
|
||||
existing 6-aggregate dict, so neither's original, already-tested behavior
|
||||
changed. They remain two separate dicts, consolidating them into one
|
||||
source of truth is left as a follow-up (see Open questions).
|
||||
4. **Done**, and it surfaced a second, smaller bug on top of the one this SIP
|
||||
opened with: MCP's own aggregate names (`STDDEV`, `VAR`) never matched any
|
||||
real Superset aggregate, before or after this PR, they were always going
|
||||
to error regardless of what this SIP does. `superset/mcp_service/chart/*`
|
||||
now accepts the old shorthand as an alias, normalized to the real,
|
||||
unambiguous names (`STDDEV_SAMP`, `VAR_SAMP`) this PR ships, and the guided
|
||||
prompt text points at the correct names going forward. `MEDIAN`/
|
||||
`PERCENTILE` were already spelled correctly in MCP; `PERCENTILE` remains
|
||||
unimplemented (it needs a parameter this schema has no field for) and is
|
||||
unchanged by this PR, out of scope here.
|
||||
|
||||
**The part that needs real engineering care, this must not be a blind
|
||||
`sa.func.MEDIAN` / `sa.func.STDDEV_SAMP` / `sa.func.VAR_SAMP`:**
|
||||
|
||||
`sqla_aggregations` today is a flat, engine-unaware dict (`sa.func.AVG`,
|
||||
etc., SQLAlchemy emits whatever function name it is given, with zero
|
||||
validation that the target dialect actually has it). Superset already has
|
||||
precedent for exactly this class of per-engine capability difference:
|
||||
`BaseEngineSpec.supports_grouping_sets` and `_time_grain_expressions`, both
|
||||
introduced by #41184 itself. This SIP proposes the same shape, a new
|
||||
per-engine-overridable mechanism (for example
|
||||
`BaseEngineSpec.get_aggregate_sql(aggregate, column)` with a sensible
|
||||
default, overridden per engine spec where the default does not hold),
|
||||
rather than a single hardcoded dict.
|
||||
|
||||
Verified findings so far (via `sqlglot.transpile`, cross-checked against
|
||||
known engine docs; **not** exhaustively tested against live databases, that
|
||||
is necessary follow-up work this SIP alone cannot complete):
|
||||
|
||||
| Engine | `MEDIAN(x)` | `STDDEV_SAMP(x)` | `VAR_SAMP(x)` |
|
||||
|---|---|---|---|
|
||||
| Postgres | `PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY x)` | native | native |
|
||||
| MySQL | no native equivalent, needs explicit "unsupported" handling, not a blind emit | native | MySQL's `VARIANCE()` is an alias for `VAR_POP` (population), not `VAR_SAMP` (sample); a naive dialect-name substitution would silently compute the wrong statistic and needs an explicit, verified expression instead |
|
||||
| SQLite | only if the specific build was compiled with the (non-default) `SQLITE_ENABLE_PERCENTILE` extension (added in SQLite 3.43, 2023), cannot be assumed available | not available in core SQLite | not available in core SQLite |
|
||||
| BigQuery / Snowflake / DuckDB / Redshift / Oracle / T-SQL / Databricks / Spark | native `MEDIAN(x)` | native | native on BigQuery/Snowflake/Databricks/Spark, where `VARIANCE` is correctly sample variance; T-SQL has no function named `VARIANCE` at all and needs `VAR(x)` instead |
|
||||
| Trino / Presto / Hive | `PERCENTILE_CONT` / `approx_percentile` (dialect- and exactness-dependent) | native | `variance` is correctly sample variance per Trino/Presto docs |
|
||||
|
||||
This table is deliberately not exhaustive, Superset has roughly 75
|
||||
`db_engine_specs` files. The proposed default (`BaseEngineSpec`) should be
|
||||
the safe choice (mark unsupported, surface a clear user-facing error) rather
|
||||
than an optimistic one, with individual engine specs opting in once
|
||||
verified. Ship for the handful of engines above first, extend
|
||||
opportunistically.
|
||||
|
||||
**`Count Unique Values`, `First`, `Last`, `List Unique Values`, explicitly
|
||||
out of scope for this SIP:**
|
||||
|
||||
- `Count Unique Values` needs no work, it is already `COUNT_DISTINCT`.
|
||||
- `First`/`Last` have no well-defined, unambiguous meaning as a plain
|
||||
`GROUP BY` aggregate without an explicit ordering; most engines only
|
||||
support this via window functions (`FIRST_VALUE`/`LAST_VALUE` `OVER
|
||||
(ORDER BY ...)`) or do not support it as a simple aggregate at all
|
||||
(Postgres has neither built in). Restoring this properly would mean
|
||||
designing an "order by" sub-control on the metric, a real, separate
|
||||
feature, not a one-line aggregate addition. Proposed as a follow-up SIP if
|
||||
there is demand.
|
||||
- `List Unique Values` maps to the `STRING_AGG`/`GROUP_CONCAT`/`LISTAGG`/
|
||||
`ARRAY_AGG(DISTINCT ...)` family, real dialect differences, plus an open
|
||||
UX question (unbounded cell content for high-cardinality columns).
|
||||
Proposed as a follow-up SIP.
|
||||
|
||||
## New or changed public interfaces
|
||||
|
||||
- New `Aggregate` values (`MEDIAN`, `STDDEV_SAMP`, `VAR_SAMP`) selectable
|
||||
anywhere the standard metric control appears, every chart type, not just
|
||||
Pivot Table.
|
||||
- New `BaseEngineSpec` extensibility point for per-engine aggregate SQL
|
||||
generation (exact shape TBD in implementation, likely mirrors
|
||||
`_time_grain_expressions`).
|
||||
- No REST API surface changes beyond the existing metric aggregate field
|
||||
accepting new values.
|
||||
|
||||
## Migration plan and compatibility
|
||||
|
||||
No new tables/columns needed for the aggregate addition itself.
|
||||
|
||||
Restoring prior chart settings, the way #42761 restored `show_values_as` for
|
||||
charts that had it before #41184, is murkier here than it was for that PR
|
||||
and needs its own design pass: the old `aggregate_function` was a single
|
||||
Pivot-Table-level setting applied uniformly to every metric on the chart,
|
||||
not a per-metric property. A chart that had `aggregate_function: Median`
|
||||
before #41184, with a metric of `SUM(sales)`, was already silently wrong
|
||||
under the old architecture (that is the bug that was fixed); mechanically
|
||||
rewriting its metric to `MEDIAN(sales)` on upgrade would change what the
|
||||
chart's leaf cells display, not just its totals, which may not match user
|
||||
intent. This SIP proposes a best-effort, flagged-for-review migration
|
||||
(surface affected charts to admins rather than silently rewriting them)
|
||||
rather than a fully automatic one-to-one restoration.
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
- **Restoring the old `aggregateFunction` Pivot-Table control as-is.**
|
||||
Rejected: this is the literal mechanism SIP-216 removed because it
|
||||
reintroduces incorrect totals for non-additive metrics. Any fix has to go
|
||||
through the metric's own aggregate, not a separate pivot-level override.
|
||||
- **Routing all metric SQL generation through `sqlglot` expression-building
|
||||
instead of SQLAlchemy's `sa.func`.** More architecturally thorough (would
|
||||
give correct dialect syntax for free across more of the roughly 75 engine
|
||||
specs), but a much larger, more invasive change to a hot path used by
|
||||
every chart query. Noted as a possible future direction, not this SIP's
|
||||
scope; this SIP proposes the smaller, `supports_grouping_sets`-shaped
|
||||
extensibility point instead.
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Resolved for this PR, worth confirming as the community's preferred
|
||||
shape:** implemented as `BaseEngineSpec._extended_aggregations` (a
|
||||
`{aggregate_name: sqla_column -> sqla_column}` dict) plus a
|
||||
`get_extended_aggregation_func` accessor, set on the concrete or shared
|
||||
base engine spec class per engine (e.g. on `PostgresBaseEngineSpec` so
|
||||
Redshift inherits it, but *not* on `PrestoBaseEngineSpec` so Hive/Spark/
|
||||
Databricks don't silently inherit unverified behavior, mirroring how
|
||||
`supports_grouping_sets` is opted into per-concrete-engine there today).
|
||||
Did not route through the `superset/sql/dialects/` sqlglot-based layer;
|
||||
that layer is for SQL Lab parsing, wiring it into chart-metric query
|
||||
building felt like a separate, larger change from this SIP's scope.
|
||||
- **Still open, not addressed in this PR:** how aggressively should
|
||||
`MEDIAN` degrade on engines without a native or exact equivalent?
|
||||
Trino/Presto/Hive were left unimplemented (unsupported) specifically to
|
||||
avoid silently answering this with an approximate function
|
||||
(`approx_percentile`) that changes the semantics of what a user asked
|
||||
for. If someone wants `MEDIAN` on those engines, this needs a real
|
||||
decision: require explicit opt-in, show a UI warning, or keep it
|
||||
disallowed.
|
||||
- **Resolved for this PR:** left the two `sqla_aggregations` dicts
|
||||
(`connectors/sqla/models.py` and `models/helpers.py`) unconsolidated,
|
||||
both now separately wired to the same new `get_extended_aggregation_func`
|
||||
hook. Consolidating them into one source of truth is real but unrelated
|
||||
cleanup, not bundled here to keep the diff reviewable.
|
||||
- **New, from implementation:** only Postgres, MySQL (partial), DuckDB, and
|
||||
Redshift (by inheritance, unverified) ship enabled. BigQuery, Snowflake,
|
||||
Trino, Presto, Hive, Spark, Databricks, Oracle, and T-SQL all have
|
||||
documented (not live-verified) support per the table above but are not
|
||||
yet wired up, each needs the same live-instance verification treatment
|
||||
before being enabled, this PR intentionally didn't guess.
|
||||
Vendored
+44
-32
@@ -3407,22 +3407,26 @@
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"editors": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DashboardRestApi.get_list.Subject"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"is_managed_externally": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"owners": {
|
||||
"$ref": "#/components/schemas/DashboardRestApi.get_list.User2"
|
||||
},
|
||||
"published": {
|
||||
"nullable": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"roles": {
|
||||
"$ref": "#/components/schemas/DashboardRestApi.get_list.Role"
|
||||
},
|
||||
"slug": {
|
||||
"maxLength": 255,
|
||||
"nullable": true,
|
||||
@@ -3432,10 +3436,10 @@
|
||||
"readOnly": true
|
||||
},
|
||||
"tags": {
|
||||
"$ref": "#/components/schemas/DashboardRestApi.get_list.Tag"
|
||||
},
|
||||
"thumbnail_url": {
|
||||
"readOnly": true
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DashboardRestApi.get_list.Tag"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"url": {
|
||||
"readOnly": true
|
||||
@@ -3444,21 +3448,46 @@
|
||||
"format": "uuid",
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"viewers": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DashboardRestApi.get_list.Subject1"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"DashboardRestApi.get_list.Role": {
|
||||
"DashboardRestApi.get_list.Subject": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"maxLength": 64,
|
||||
"label": {
|
||||
"maxLength": 255,
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": ["name"],
|
||||
"required": ["label", "type"],
|
||||
"type": "object"
|
||||
},
|
||||
"DashboardRestApi.get_list.Subject1": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"label": {
|
||||
"maxLength": 255,
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": ["label", "type"],
|
||||
"type": "object"
|
||||
},
|
||||
"DashboardRestApi.get_list.Tag": {
|
||||
@@ -3511,23 +3540,6 @@
|
||||
"required": ["first_name", "last_name"],
|
||||
"type": "object"
|
||||
},
|
||||
"DashboardRestApi.get_list.User2": {
|
||||
"properties": {
|
||||
"first_name": {
|
||||
"maxLength": 64,
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"last_name": {
|
||||
"maxLength": 64,
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["first_name", "last_name"],
|
||||
"type": "object"
|
||||
},
|
||||
"DashboardRestApi.post": {
|
||||
"properties": {
|
||||
"certification_details": {
|
||||
@@ -16506,7 +16518,7 @@
|
||||
},
|
||||
"result": {
|
||||
"items": {
|
||||
"type": "object"
|
||||
"$ref": "#/components/schemas/DashboardRestApi.get_list"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
|
||||
+303
-285
@@ -1142,6 +1142,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.29.7.tgz#12022450c45a4da6d8d8287b18a4ff2ddb23f768"
|
||||
integrity sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==
|
||||
|
||||
"@babel/runtime@^8.0.0":
|
||||
version "8.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-8.0.0.tgz#d7bd513e6843662346552c2798ab895716cf97f2"
|
||||
integrity sha512-sL6cvO2IfkSu/iU+zs2S/w01B7A8V7suXSIKEN4hPFFdZoiPGxrj5pAG0lCaqLWiEIrjKzdznIWuaLcxPR53qw==
|
||||
|
||||
"@babel/template@^7.29.7":
|
||||
version "7.29.7"
|
||||
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.29.7.tgz#4d9d4004f645cdd304de958c725162784ecac700"
|
||||
@@ -3170,100 +3175,100 @@
|
||||
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.23.0.tgz#8b66dbfa7b796139e719063fc0e44084e80a1c15"
|
||||
integrity sha512-gUGJpr+Rn6zMxm5juApV0K3U845i8t47o8k+rbO0BHbi4PoJIfSPeQmrE2dgohQm2g5k6iviNFyXCGqvmaYUpw==
|
||||
|
||||
"@oxfmt/binding-android-arm-eabi@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.62.0.tgz#3f5b9d3ba944f42ad3fa2697b9fef88a8c9d4ce0"
|
||||
integrity sha512-pdsv0C4gPjJ8H1+sd8u0BDx+yLACTL+rgeMIOL1ln4ihSnhw8CWXtYWgvcSkyTfgGBIzFKab+d8rx9Xl4en/Kw==
|
||||
"@oxfmt/binding-android-arm-eabi@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.63.0.tgz#136176dc94fdc41e21415cc770d86f5066282e0f"
|
||||
integrity sha512-YmRth4ZPGgEXcgmkhvANbC9uD67dxmSobW7DQuyt5tOBOKvPnIpk5SVHBj88E+7wMNRI2FhqaDbOhQFBix+b8A==
|
||||
|
||||
"@oxfmt/binding-android-arm64@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.62.0.tgz#4c7e2c567f645ed051be100318e9e3f716630c1b"
|
||||
integrity sha512-WC3YQ7uS/KtDrjmqwBviwFKe9qeoi+eXx8aX1z/ffG23Md75myjrJaQqTuJvdOLPoa4EYTjDWH0dHXfwulCVog==
|
||||
"@oxfmt/binding-android-arm64@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.63.0.tgz#10bc42457179210061c801122a64304619e3bdab"
|
||||
integrity sha512-icbahX8X2X3sRamOMecvdYeZXWjPDazRDIfvWfy7Ca1nc/ZDT2Y9k5Nt7s46EqFd7NQPdgk+CM3/SgIT5LPCaQ==
|
||||
|
||||
"@oxfmt/binding-darwin-arm64@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.62.0.tgz#6c8007ae65ed17f9d1ecc6c680da19ec19276c67"
|
||||
integrity sha512-GM8Yf3LjjaR1I8PD0SfeoIlwhsh9GvSF+cQ8sf624Yxnjsyumn95aFzYfKJVefblfDIiOAnZ7QVm2sa21Er/0Q==
|
||||
"@oxfmt/binding-darwin-arm64@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.63.0.tgz#5f9084d9a760a1836387f8970a7f9d614ec3d909"
|
||||
integrity sha512-WV+Ze5v5gI2qoj8jpAovt8KBTW8pjEz/AiMXXjeTQS+Bmf/MmZXTS40S8xNPDszX+W8WDv2Bbk6qKrMTtUGu1A==
|
||||
|
||||
"@oxfmt/binding-darwin-x64@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.62.0.tgz#0661a0274e8625921c5a054aeb21a36251946e6b"
|
||||
integrity sha512-d5THp7F8bCxLqNogEXDORRsQD6dosf3EyFtnXfBer6v+8tGdcWIjoDX9WaXrrF/26zOmL8qHpPTKCEvpBDmZkQ==
|
||||
"@oxfmt/binding-darwin-x64@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.63.0.tgz#badd4a02218a9a62319817d5c337b30159a54a21"
|
||||
integrity sha512-CJGSBdDxXOWIpoFXHpverimCvz084KA7L483rqJ44c3jDtzv6d4qOSoR/V9ywSHfV+Ks1lwIj2P49BFhunLNAA==
|
||||
|
||||
"@oxfmt/binding-freebsd-x64@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.62.0.tgz#f3345001102ac3e6c2947920d6d1676e9cf97e75"
|
||||
integrity sha512-1DnrtXGZooOZ0fHgAXZUaDQzBVh1CM2MNW4oBXyQ2aWKvCHjyljvT9fgBkOM0fEOb96X5eqtcfJ0YUVt9jj66g==
|
||||
"@oxfmt/binding-freebsd-x64@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.63.0.tgz#a17261e95c8ebef1f76d8aaac746a64fdb6ba51e"
|
||||
integrity sha512-BDfKY+KhL2078cgswBBFQPAYuxCy93bS/iC5frdSeSbTLcGrR6VC2hsuPTanoJmg84+wSyWl0wWC1eR+uTnkRg==
|
||||
|
||||
"@oxfmt/binding-linux-arm-gnueabihf@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.62.0.tgz#ddc03bc2a899f2071d6706c06dfdec3a7f3e8b5a"
|
||||
integrity sha512-4pQDHOYRH+Huqe0StIaWyvk2CVl/aTaqSrbZpA3/pLS2xH24ME7lBgYprhQF2fRkHBzhGGGKliwxFsDdHwx59g==
|
||||
"@oxfmt/binding-linux-arm-gnueabihf@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.63.0.tgz#baeee34bb08e0769af878623f442e83bc0aacd7a"
|
||||
integrity sha512-Ov1cQEXT4mj7cojAokWSS1eoxkoyvbDfAbxNsGIKY2o36kvdAaFzPxRN6NxFRk9fD72B8oCoTTX/NuYTUWlpsg==
|
||||
|
||||
"@oxfmt/binding-linux-arm-musleabihf@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.62.0.tgz#5e82208d612c4caf64ada75e129e34d1a9eefb2c"
|
||||
integrity sha512-X0jAaZJFMCVKhB6YyWVTQ/wN2DLsBcZKSMqTS76bF6riT+XZdtg2FPEdjDvdVbunO9cG+tWiVaEs4Zs38lxYog==
|
||||
"@oxfmt/binding-linux-arm-musleabihf@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.63.0.tgz#e70d5697ec4b6bb5f87a3f019e01b3f956b8e44b"
|
||||
integrity sha512-0LE7ro3+6L79jcMANycAZfRaC7zxr9YZ2+vEL5uMD9QlEep+rS/r1kSJsnuLl991NXJZD60euh0PC1GHrR20vw==
|
||||
|
||||
"@oxfmt/binding-linux-arm64-gnu@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.62.0.tgz#eb379bc58aa962e753d58b4cc68ff4081bc19a5d"
|
||||
integrity sha512-682Z8T5s8T5ATArYtsejKvbIfd8LEAXyyDkKkoZVq8HND7Vx8TYLlrDjDSeYfodMeVwHOgkj13lJYR8cj6vUSg==
|
||||
"@oxfmt/binding-linux-arm64-gnu@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.63.0.tgz#638a8ed4f3d256c50aeb6d2c19cfc65792c902e1"
|
||||
integrity sha512-izPk+2Z4gjuZK32Fqh5qXoMpT/2NXzLh++ob57HiEiVSQZ1iYXu8EKMzb+K5AvWyIEXhdDIt7ADjGGtFhkT9Bw==
|
||||
|
||||
"@oxfmt/binding-linux-arm64-musl@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.62.0.tgz#dc1c62510405e874bf6a53a34f548032eb6dfed7"
|
||||
integrity sha512-lk25fAl7KWaLWVJcW0CHEXB7QlQZtx5eDkjpaGMK0hzXTjUe0Wmlu8IKuFHoviSOcEJedRTs4VE/506VqGxGew==
|
||||
"@oxfmt/binding-linux-arm64-musl@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.63.0.tgz#af5a9b787f5233f27a3360ad56235fc1b011f760"
|
||||
integrity sha512-alPmbOuWXFXiSo+lOtv6X71C7SYMEDW2WVvywOvf9BwKgEhSNGhMTLeFVSjKUMCamcjbbgVdsWF8GN1uy8xshg==
|
||||
|
||||
"@oxfmt/binding-linux-ppc64-gnu@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.62.0.tgz#9f9afee327090024db86b70ec81a57ad06bb2f00"
|
||||
integrity sha512-SFyNqHQLwySceWNLhiSldx7wPXRAzP0L0WcW9GegP3uWrpZGJiZlQO85NbHAFPEfxR9PhZ9qSnZryEh7+v+4Gw==
|
||||
"@oxfmt/binding-linux-ppc64-gnu@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.63.0.tgz#c1a211206134a5577e355a495989e0d733218d60"
|
||||
integrity sha512-BdzCPvolJc4AWZ+YMzgUDJcDzbQWrFjYuqBHoNHNqP1aCaluQRJNs4k3vNU5IG7vTpjf9zeD73D7MFM1TecZpg==
|
||||
|
||||
"@oxfmt/binding-linux-riscv64-gnu@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.62.0.tgz#4427d42ee3bad0e55b38dc76fe14a7e5318c360c"
|
||||
integrity sha512-KYj55C1ywJfHo6+aKDuEmUtVEdJALsC5GwayDGsI6FGz2GxFqNr/mA8nxVsNbJzm7sE5MRqTQ9ziImSzhYXysA==
|
||||
"@oxfmt/binding-linux-riscv64-gnu@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.63.0.tgz#4863f0311e5c1b88f75ef822959b3ca4fd938937"
|
||||
integrity sha512-7sIgfLzqtNKSkMGsGVyRpHwpjNezRg2XONvUOheFZs95TSZpM0JAuPpA8KrQFsWc4wPU95roX2O69JgH8igOgw==
|
||||
|
||||
"@oxfmt/binding-linux-riscv64-musl@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.62.0.tgz#a117f82909f075cf07c333842d89a5638429e21d"
|
||||
integrity sha512-BhZDNo5GOU5nC378RhD0/XpvaEBHsH3HLgJp8YZX3A0InC7oivzA63HsRmiXFLtLSHAstEVrDf6fbC7Rs8Jh/A==
|
||||
"@oxfmt/binding-linux-riscv64-musl@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.63.0.tgz#ad05a017d12553e2f544743c4940adb552aa1d1c"
|
||||
integrity sha512-9Tcg0y0WcVa6Mm9AgcgFMseDS+VkFJZpKZ8We9SpDY4gg5jewSwln+0sO04QLcTS1BtfDl9MwR+NfID8L7PUTg==
|
||||
|
||||
"@oxfmt/binding-linux-s390x-gnu@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.62.0.tgz#3fac79fefe7ffc3f0a9393678ebd782aac918fcd"
|
||||
integrity sha512-UyAFmyHkgSgUJ/wOM4p3U8AC2yAFvRH5PNBs7TnK0fObTT/XSWcdr/lAzPSWaekHaZFaMeFZyk9n93Joq3J93A==
|
||||
"@oxfmt/binding-linux-s390x-gnu@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.63.0.tgz#2803f539db15bc66db115888fa8f84d6531ed2b9"
|
||||
integrity sha512-qWKC1pEOpx1qYhXaugPhHUeXwSfqEOk2wJH2LqVXGPV5iQYfdAZdt+d2XDiX4DTSWA2QDMUcFB+wEORh3Xn/sA==
|
||||
|
||||
"@oxfmt/binding-linux-x64-gnu@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.62.0.tgz#8da207bef27941f0265c129d1c7c82c7cf91d1ce"
|
||||
integrity sha512-1iYMP0leytWazFubD/WnINJuIrzRPuoL1aWEJdlGezEzDbTxcd29R4r8IUzP2oWeKst5V02uMJgR2NILlPlG6w==
|
||||
"@oxfmt/binding-linux-x64-gnu@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.63.0.tgz#c22a06a60ae2d6b3de522095e0c50a816040a033"
|
||||
integrity sha512-S9wXYOiGSqYGS4Fx/TFsY+xDd/7dE5s+rUgbA4TsHiVF9e8J3ZcKmP7dsP/7iqLI9Wz7Ic7TzEr3mdthRCTdrA==
|
||||
|
||||
"@oxfmt/binding-linux-x64-musl@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.62.0.tgz#e55cf9b7c8c2204fdbb5d4818f8c5ba02aa49360"
|
||||
integrity sha512-4rA/URtJSTVNVAQz6Q8wf7SaRvOXVy+TizriT9hs/Y1XhLR/R+92uWKRQG8yFWRAIEBbFHJ6WevQcl/G9SXEfw==
|
||||
"@oxfmt/binding-linux-x64-musl@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.63.0.tgz#48d3eeaf8e3757f638cf92de5ee4858befc9c0a3"
|
||||
integrity sha512-5eGyTJuMZNwBSHCivXt8Yuta6GeTYksOPXRk2MIhajiyFGQx7bjaHIwY+ZusAoFHhT157A9x6sktLjYo9D5oMQ==
|
||||
|
||||
"@oxfmt/binding-openharmony-arm64@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.62.0.tgz#4998769ee1b5894efcd6cb99729d5a75f4c09dd1"
|
||||
integrity sha512-mSZuFHU2ar1KLUjXpI2QBQcJ1VsOB3mOCgQXuXCpKs19dgh4u+OaovNfrWDfiJb+ihJ2+f7YFcaO9bS2dlTCXA==
|
||||
"@oxfmt/binding-openharmony-arm64@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.63.0.tgz#02be9e140ae35ba30f52bdce27612fece4a01ab3"
|
||||
integrity sha512-Rz7hx+Dv3DoW/S6pwVAyjfFXp7/trdQ1zg+vNmsdsdDNlUccugp4XNqambSuEAeP0DaG9k72AtNyfDXCEg0AGw==
|
||||
|
||||
"@oxfmt/binding-win32-arm64-msvc@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.62.0.tgz#e09eaabdde76c885c4f8a190518c2eb2de08548a"
|
||||
integrity sha512-OfwuhkcjDlqC4EgDojtiV9mzpLqeB9KqTOWPOjLEYBVdDCVSxqW3qzp/xcIxsbtI0UgGCnKvAqYKyY25kf5JZw==
|
||||
"@oxfmt/binding-win32-arm64-msvc@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.63.0.tgz#2226eaf52b6345a2cb926499216b2486cf0dbec2"
|
||||
integrity sha512-T/IuizKN9mr4Xw6YYnptkXRNdLkyIlUZ7c8zfTOBpoytZyJ1BAsMUvsMDEx0X4YvSMpaivm+DR8112rQfzC25g==
|
||||
|
||||
"@oxfmt/binding-win32-ia32-msvc@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.62.0.tgz#f36e306308923977365270d8b26290f4ca2fcfa5"
|
||||
integrity sha512-P9uDDNFRzghO3X8QAzhkjKhK7JvtABsVn8UYtFX7uor12IAnwNt8nNIctvfWj1JkQU/kE+fmLRPiw7XlrIHsZw==
|
||||
"@oxfmt/binding-win32-ia32-msvc@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.63.0.tgz#58d263bb5ecd7330c02f9dcd8cda10f66e42e74b"
|
||||
integrity sha512-XjrO5FJ5Wl9vsAxtCP1G/eaeT6y1K2s9CICUHGE42cEjou32/J6S+B1KnrOAboj6E7uhJnwPbRSvznWcxNdA0g==
|
||||
|
||||
"@oxfmt/binding-win32-x64-msvc@0.62.0":
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.62.0.tgz#bb6545e581d5ee7111084dbabeec7fe548bae418"
|
||||
integrity sha512-dlI5SY7XYQCiCBafntWagCR6HcAJB/NpsLtdlPx8x08+Osz8Ok1HHz1GZuusegCe/VoJ6pAnF5a4pd5OZAq7qQ==
|
||||
"@oxfmt/binding-win32-x64-msvc@0.63.0":
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.63.0.tgz#02a166c8a8049c55d0096d1ba9d8e73f3a4d26a7"
|
||||
integrity sha512-sgsHCQy432OTQH4Ikk3tZptp3GqwnhwUDuY0loBH41zyHWfMZY9v8Dy78wsnSofHejvFozZGgJgBB1A0LQRwMQ==
|
||||
|
||||
"@parcel/watcher-android-arm64@2.5.6":
|
||||
version "2.5.6"
|
||||
@@ -3532,13 +3537,13 @@
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.24.4"
|
||||
|
||||
"@rc-component/cascader@~1.17.0":
|
||||
version "1.17.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/cascader/-/cascader-1.17.0.tgz#52c0eceada2c7b4b37ebe822c19a6544b9562edf"
|
||||
integrity sha512-3cVNG0zrQF1PoXq262L3wGCU+/YLEC1mGSVHDl577dQmA0ZKkXFbY6nwyXo+beCcM7buo49t24jkr+QZdL7O8w==
|
||||
"@rc-component/cascader@~1.22.0":
|
||||
version "1.22.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/cascader/-/cascader-1.22.0.tgz#eec0b6f4d2df5903aa12cfed321a578705926937"
|
||||
integrity sha512-SffrA57aS9oub3VuI7ajPhJTPtaNxngSvtRhD40Rd8dwJ5vfWPSrVanWgeepdWFGBt7EHftIK5RUU0u3rCTwWw==
|
||||
dependencies:
|
||||
"@rc-component/select" "~1.8.0"
|
||||
"@rc-component/tree" "~1.3.2"
|
||||
"@rc-component/select" "~1.10.0"
|
||||
"@rc-component/tree" "~1.4.0"
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
@@ -3614,14 +3619,14 @@
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/image@~1.9.0":
|
||||
version "1.9.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/image/-/image-1.9.0.tgz#110785d735d20336afcdbac84e8fbfd059a7a44e"
|
||||
integrity sha512-khF7w7xkBH5B1bsBcI1FSUZdkyd1aqpl2eYyILCqCzzQH3XdfehGUaZTnptyaJJfs09/R5hv9jXWyazOMFIClQ==
|
||||
"@rc-component/image@~1.10.0":
|
||||
version "1.10.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/image/-/image-1.10.0.tgz#5d7a82d20e4c91f75875ea64eb1dadd7af676b1d"
|
||||
integrity sha512-BjeZCRQ+hw+4WAhvrw8rJvy5fckA2xpf/X2XQEOABUHvLTNB9inB98X3Mp54jYQ7g10DfWERQWHXeC4ylxp1Uw==
|
||||
dependencies:
|
||||
"@rc-component/motion" "^1.0.0"
|
||||
"@rc-component/portal" "^2.1.2"
|
||||
"@rc-component/util" "^1.10.1"
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/input-number@~1.6.2":
|
||||
@@ -3633,7 +3638,7 @@
|
||||
"@rc-component/util" "^1.4.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/input@~1.3.0", "@rc-component/input@~1.3.1":
|
||||
"@rc-component/input@~1.3.1":
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/input/-/input-1.3.1.tgz#230b8b59cdde8521d50f0eede63ddacb61cc0cd3"
|
||||
integrity sha512-iFvTUT9W+JC/MSin2aGAk8NqsVlTzcExNC9DZariON1IWirju9NoNeEk47an4Q8iHazkoVI/y1LnDi88+CPcig==
|
||||
@@ -3642,15 +3647,27 @@
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/mentions@~1.10.0":
|
||||
version "1.10.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/mentions/-/mentions-1.10.0.tgz#46b1117cfb0c716b476e97f342555eccc2f41c97"
|
||||
integrity sha512-CI1njYUVY0NjHtLhNoVmXlJyy568Sfep9Wsak6vmGjtT6uazx98djGYlCXz2xkHhEm73g91Y3MTvzUyE5avI7w==
|
||||
"@rc-component/listy@~1.2.3":
|
||||
version "1.2.3"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/listy/-/listy-1.2.3.tgz#e9c8ef4f409c231b44dded37e63ae2875bbe0334"
|
||||
integrity sha512-IXiMjV5s0rczLBlfh7G5nB4M3365mrEeedjwKtf5I+Ns3PqRUsebR2h5u8CeFarsVfLUPC2I5p0h09TNoOWyvQ==
|
||||
dependencies:
|
||||
"@rc-component/input" "~1.3.0"
|
||||
"@rc-component/motion" "^1.1.4"
|
||||
"@rc-component/portal" "^2.0.0"
|
||||
"@rc-component/resize-observer" "^1.0.0"
|
||||
"@rc-component/util" "^1.3.1"
|
||||
"@rc-component/virtual-list" "^1.4.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/mentions@~1.11.0":
|
||||
version "1.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/mentions/-/mentions-1.11.0.tgz#cee0c4710f26766ad8550d386cfec5ff86fd58d9"
|
||||
integrity sha512-IC2qXuEBMFHxPIXEFfYWj6Sr7UiDZnOqJHCYQBbwPzopBJOPZIR6mV9U4QH1bYQRlKYlYnIsajWDMgVGgWQyWQ==
|
||||
dependencies:
|
||||
"@rc-component/input" "~1.3.1"
|
||||
"@rc-component/menu" "~1.4.0"
|
||||
"@rc-component/trigger" "^3.0.0"
|
||||
"@rc-component/util" "^1.3.0"
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/menu@~1.4.0", "@rc-component/menu@~1.4.1":
|
||||
@@ -3724,7 +3741,7 @@
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/portal@^2.1.0", "@rc-component/portal@^2.1.2", "@rc-component/portal@^2.1.3", "@rc-component/portal@^2.2.0", "@rc-component/portal@^2.2.1":
|
||||
"@rc-component/portal@^2.0.0", "@rc-component/portal@^2.1.0", "@rc-component/portal@^2.1.2", "@rc-component/portal@^2.1.3", "@rc-component/portal@^2.2.0", "@rc-component/portal@^2.2.1":
|
||||
version "2.2.1"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/portal/-/portal-2.2.1.tgz#37c34b4c8cd73f53cc7072c96dd0e9ac332669ec"
|
||||
integrity sha512-ck+r1kW/JSv0wxPji3KN2ss9K6Z0qqwusw/mf/0JobXhZ8hC2ejZwCJObW/SvDi0uhA0VzmCnx0CaCci95tcmA==
|
||||
@@ -3772,10 +3789,10 @@
|
||||
"@rc-component/util" "^1.3.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/select@~1.8.0", "@rc-component/select@~1.8.2":
|
||||
version "1.8.2"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/select/-/select-1.8.2.tgz#f016992dae5c57186535512d73783e2fc7e4c59e"
|
||||
integrity sha512-HQ9zuYqjfZTlcEMWlU1GAPBajd2OHIMVHyjZSGVTCVARwkfCgvXZMTEn0cduy3L+ejAKkaZluOQvxovZoaJaQw==
|
||||
"@rc-component/select@~1.10.0":
|
||||
version "1.10.1"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/select/-/select-1.10.1.tgz#323b2f458a637e8e752f8341094783741c613c34"
|
||||
integrity sha512-H+yQsl+qED9NilQ3g6zdpsMwUgwVjrcMTkNHAWRVU/MoNCYgTbDgU+MIMgZDK+rVdd2JUfI/MkysMcZZ0cyQKw==
|
||||
dependencies:
|
||||
"@rc-component/overflow" "^1.0.0"
|
||||
"@rc-component/trigger" "^3.0.0"
|
||||
@@ -3807,10 +3824,10 @@
|
||||
"@rc-component/util" "^1.3.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/table@~1.10.4":
|
||||
version "1.10.4"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/table/-/table-1.10.4.tgz#8c4e33bc150aa39f579c15426421348a789de326"
|
||||
integrity sha512-HwoTnrwc29zeoXkXGhWqzJh8FIibGUxi1jM4LtoSzmR9d5Vv5osUQpZxnXKBP8iOCvyD6BQzZm1nXJRcnrxpAg==
|
||||
"@rc-component/table@~1.11.0":
|
||||
version "1.11.1"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/table/-/table-1.11.1.tgz#7b5c2a7c26fd37b6a403082029b5a72fcb330a4d"
|
||||
integrity sha512-OWdS6DMmeWb7bJBGqPxYZpQbzBlBiXZUu2sqo6Ii7Sjs9GeK1IsrXrWk26SL2c6KEseabswdxrRj7WUm9LdECw==
|
||||
dependencies:
|
||||
"@rc-component/context" "^2.0.1"
|
||||
"@rc-component/resize-observer" "^1.0.0"
|
||||
@@ -3818,10 +3835,10 @@
|
||||
"@rc-component/virtual-list" "^1.0.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/tabs@~1.11.0":
|
||||
version "1.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/tabs/-/tabs-1.11.0.tgz#c157b2fadcdc2f3ab6c69d0098f73e03c6aa0c12"
|
||||
integrity sha512-hA/drZYOVa/MMIb4M2fWf3yaTyTG4qVuIABmghvEhyfw2nBob5VTH69lMCDjSVKmgODjO6nWlCV+gVn3xBrj5Q==
|
||||
"@rc-component/tabs@~1.12.0":
|
||||
version "1.12.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/tabs/-/tabs-1.12.0.tgz#41a1a77ed1afc4f1b8b727003a058c631aceea1b"
|
||||
integrity sha512-XL7Kqy5fnUE2WTlO1/fCGrrfNlGFebdr7JseGkEIjzcVMAtIFQJ8sqCSOmxcXstjU6fonD/4rnhZHxj7sDTajQ==
|
||||
dependencies:
|
||||
"@rc-component/dropdown" "~1.0.0"
|
||||
"@rc-component/menu" "~1.4.0"
|
||||
@@ -3830,13 +3847,13 @@
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/tooltip@~1.4.0":
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/tooltip/-/tooltip-1.4.0.tgz#c8cf15c6773218a5a36271467f06e663f99c28e7"
|
||||
integrity sha512-8Rx5DCctIlLI4raR0I0xHjVTf1aF48+gKCNeAAo5bmF5VoR5YED+A/XEqzXv9KKqrJDRcd3Wndpxh2hyzrTtSg==
|
||||
"@rc-component/tooltip@~1.5.0":
|
||||
version "1.5.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/tooltip/-/tooltip-1.5.0.tgz#422aa0760b310e0a1d0f9f7223e7f0d455de57a2"
|
||||
integrity sha512-agQ/+mBqrEQfTX4D3KhQ7j+ZbX4/VHjoJ7Noa2wIdZ1/FbQTOd7Sn92rp+jtCoqAVTLUgSOydePIgZ204gi2EQ==
|
||||
dependencies:
|
||||
"@rc-component/trigger" "^3.7.1"
|
||||
"@rc-component/util" "^1.3.0"
|
||||
"@rc-component/trigger" "^3.10.0"
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/tour@~2.4.0":
|
||||
@@ -3849,27 +3866,27 @@
|
||||
"@rc-component/util" "^1.7.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/tree-select@~1.11.0":
|
||||
version "1.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/tree-select/-/tree-select-1.11.0.tgz#9080cdf1d28f2ddd6d8a4879b7aa90d3170f7db9"
|
||||
integrity sha512-EhS0X0wtUhBfK4S5TlpSY3MR9ndPMGgujtt1PJW3Ej+ToAlnS/6ohYURtCoXBYGqazUwHmgQGVUDsfpVwhWPkg==
|
||||
"@rc-component/tree-select@~1.16.0":
|
||||
version "1.16.1"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/tree-select/-/tree-select-1.16.1.tgz#dcaea96e396e98108cb29cc051840d4fbdda38cc"
|
||||
integrity sha512-a1Oi6EJhqAhdOxxupdJi6fP0RPHMKn5TcfkX2+llaQ4lF4nwfH7b6SCHcnsybaa2s+pk1yZYwVyeOYkDnEBRdg==
|
||||
dependencies:
|
||||
"@rc-component/select" "~1.8.0"
|
||||
"@rc-component/tree" "~1.3.2"
|
||||
"@rc-component/select" "~1.10.0"
|
||||
"@rc-component/tree" "~1.4.0"
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/tree@~1.3.2":
|
||||
version "1.3.2"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/tree/-/tree-1.3.2.tgz#4b0c13564314eff61ca948c18ef923b87c9d7e44"
|
||||
integrity sha512-bJFj46wEkpBPnWyTm18XmgAgNQ/4YvprxMOPPY2a6rmhGJYxLuNKEFiL5Qej4Qctu9wHJm8WW+v2SYskafE0kA==
|
||||
"@rc-component/tree@~1.4.0":
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/tree/-/tree-1.4.0.tgz#c0031180e681389bf0bdcb867a0087525b45c8a9"
|
||||
integrity sha512-dGsJGDJQedA0BqqVgj3F8BvHXTSZijyhTXdbAdkcx8lynzZkty/CV3Z3LOm/fxz+BCfl3dfGiAQpb7Q5XNvl0Q==
|
||||
dependencies:
|
||||
"@rc-component/motion" "^1.0.0"
|
||||
"@rc-component/util" "^1.11.1"
|
||||
"@rc-component/virtual-list" "^1.2.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/trigger@^3.0.0", "@rc-component/trigger@^3.10.1", "@rc-component/trigger@^3.6.15", "@rc-component/trigger@^3.7.1":
|
||||
"@rc-component/trigger@^3.0.0", "@rc-component/trigger@^3.10.0", "@rc-component/trigger@^3.10.1", "@rc-component/trigger@^3.6.15":
|
||||
version "3.10.1"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/trigger/-/trigger-3.10.1.tgz#cb28e1bc0745a2af6897dd7ec774f9b56dc88f86"
|
||||
integrity sha512-mXlDN0IXdtV8Yqqm8195ECCyrbmfvvfKvwVvSlH0+qvKD6BUF8gRhEjSy0FOcD1+CcDRHgTiX99LoxfQrmh3Cw==
|
||||
@@ -3888,7 +3905,7 @@
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/util@^1.10.1", "@rc-component/util@^1.11.0", "@rc-component/util@^1.11.1", "@rc-component/util@^1.12.0", "@rc-component/util@^1.2.0", "@rc-component/util@^1.2.1", "@rc-component/util@^1.3.0", "@rc-component/util@^1.4.0", "@rc-component/util@^1.7.0", "@rc-component/util@^1.9.0":
|
||||
"@rc-component/util@^1.11.0", "@rc-component/util@^1.11.1", "@rc-component/util@^1.12.0", "@rc-component/util@^1.2.0", "@rc-component/util@^1.2.1", "@rc-component/util@^1.3.0", "@rc-component/util@^1.3.1", "@rc-component/util@^1.4.0", "@rc-component/util@^1.7.0", "@rc-component/util@^1.9.0":
|
||||
version "1.12.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/util/-/util-1.12.0.tgz#58e453585810bcb8a35ff1aafd5e01187457b86f"
|
||||
integrity sha512-AEjPL8JVdohIITaiXokyjL9WQ6tKWWjAYK9QU16tGNE9JaQABBQy+hA4H2Lup5MgXy9yY3iLrbZJheuU13hTdQ==
|
||||
@@ -3906,6 +3923,16 @@
|
||||
"@rc-component/util" "^1.4.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/virtual-list@^1.4.0":
|
||||
version "1.5.1"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/virtual-list/-/virtual-list-1.5.1.tgz#71c5844a8d6bd5b3501dfb66419d3a4612b2bb18"
|
||||
integrity sha512-boqHxdtyWC88u8quYgEO49bcBy5fzRiOcnBge+N4nLzs2k8hUQ/yw7JE9dM6yCBE4jSm5YSHVCVMS+suBuJGKA==
|
||||
dependencies:
|
||||
"@babel/runtime" "^8.0.0"
|
||||
"@rc-component/resize-observer" "^1.0.1"
|
||||
"@rc-component/util" "^1.4.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@redocly/ajv@^8.18.1":
|
||||
version "8.18.3"
|
||||
resolved "https://registry.yarnpkg.com/@redocly/ajv/-/ajv-8.18.3.tgz#a925753d9a33375219f1b2ba91aef320f9929577"
|
||||
@@ -4095,23 +4122,23 @@
|
||||
resolved "https://registry.yarnpkg.com/@standard-schema/utils/-/utils-0.3.0.tgz#3d5e608f16c2390c10528e98e59aef6bf73cae7b"
|
||||
integrity sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==
|
||||
|
||||
"@storybook/addon-docs@^10.5.7":
|
||||
version "10.5.7"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-10.5.7.tgz#6d599c94fc871c248ce06a5c081f57655c83f40a"
|
||||
integrity sha512-KNARJfjICaizinsR3INMEiipZm1ObYo+xw+E26gteu50Bcy2dIZUtk5uHY5XdtardU3AXX6yRXoBZ2HCY3lbHA==
|
||||
"@storybook/addon-docs@^10.5.8":
|
||||
version "10.5.8"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-10.5.8.tgz#767c10c7a4cc1b625b93f869b2a2b09fc8514f2e"
|
||||
integrity sha512-NlHiMKW/UvW/uL8HXFDCEVwoH3qZeGYZ/qlWax4d7H471b/T54MBq2KcB4ZrdA785FfIH3numAJdBb5jwn00Mg==
|
||||
dependencies:
|
||||
"@mdx-js/react" "^3.0.0"
|
||||
"@storybook/csf-plugin" "10.5.7"
|
||||
"@storybook/csf-plugin" "10.5.8"
|
||||
"@storybook/icons" "^2.0.2"
|
||||
"@storybook/react-dom-shim" "10.5.7"
|
||||
"@storybook/react-dom-shim" "10.5.8"
|
||||
react "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
react-dom "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
ts-dedent "^2.0.0"
|
||||
|
||||
"@storybook/csf-plugin@10.5.7":
|
||||
version "10.5.7"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-10.5.7.tgz#bc73f164d1b5f8e2931b2774f4b389a06453cf6e"
|
||||
integrity sha512-IaX8FlM0H36HNFhJ2+4L9bCldqfvHGqcLg841SJNyK/DhfMlM7JsvY/GDH2ZFuWrUf8FSOx96GRRnHq6XfRKag==
|
||||
"@storybook/csf-plugin@10.5.8":
|
||||
version "10.5.8"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-10.5.8.tgz#c626c5bfe55d0e279b2457e5cf150a788d1fc637"
|
||||
integrity sha512-/FHiMyOWWEXfwK/lM0WxmkP9GLzbSJJuzGtfeuNWSOVDnvAMbjavitxfHb5wSbWKIQo0XYC1EJ2Y7x91XNYP4w==
|
||||
dependencies:
|
||||
unplugin "^2.3.5"
|
||||
|
||||
@@ -4125,10 +4152,10 @@
|
||||
resolved "https://registry.yarnpkg.com/@storybook/icons/-/icons-2.1.0.tgz#edfc2450a39c5e780f28c6cbc49acd7bff59b41a"
|
||||
integrity sha512-Fxh9vYpX9bQqFeHRiY8h2ApeRGDzRSMLwJwNZ/AIRqnyOKHxRKL+yFe+ctEkVJmuptRE9u1Hrn8ZZNHyfDKKNg==
|
||||
|
||||
"@storybook/react-dom-shim@10.5.7":
|
||||
version "10.5.7"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-10.5.7.tgz#9a5aa0e0f89c09e71c6cbfc6bb1abeb537e5aabf"
|
||||
integrity sha512-lxOkyh+wu/MiBXvYQHjZfD+DRKOa4bHBzbuGuiHXnHXmdOcTRdcrQTsoeN2FPtfugmmOG66cZUEgDwNX+k5eRA==
|
||||
"@storybook/react-dom-shim@10.5.8":
|
||||
version "10.5.8"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-10.5.8.tgz#40cc3e32af424baa2e4109a325dae2ede29999e2"
|
||||
integrity sha512-N8D13/Xny+V3kfe1KBgsAHS0nKWXLLdgOOXS9poKdYzVwVCN+CGEGBxWX0zMMtdCptqa6/57em9coPlZMoO+bg==
|
||||
|
||||
"@superset-ui/core@^0.20.4":
|
||||
version "0.20.4"
|
||||
@@ -5658,110 +5685,100 @@
|
||||
dependencies:
|
||||
"@types/yargs-parser" "*"
|
||||
|
||||
"@typescript-eslint/eslint-plugin@8.66.0", "@typescript-eslint/eslint-plugin@^8.66.0":
|
||||
version "8.66.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz#76e86aa5a2459fbf5bbd7a839c0dc0cce1d56224"
|
||||
integrity sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==
|
||||
"@typescript-eslint/eslint-plugin@8.67.0", "@typescript-eslint/eslint-plugin@^8.67.0":
|
||||
version "8.67.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz#52f9f0e47d5a7571c4336e69bfeea581509ef2cf"
|
||||
integrity sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==
|
||||
dependencies:
|
||||
"@eslint-community/regexpp" "^4.12.2"
|
||||
"@typescript-eslint/scope-manager" "8.66.0"
|
||||
"@typescript-eslint/type-utils" "8.66.0"
|
||||
"@typescript-eslint/utils" "8.66.0"
|
||||
"@typescript-eslint/visitor-keys" "8.66.0"
|
||||
"@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"
|
||||
ignore "^7.0.5"
|
||||
natural-compare "^1.4.0"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/parser@8.66.0", "@typescript-eslint/parser@^8.66.0":
|
||||
version "8.66.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.66.0.tgz#88e3865ecf73b0118134e7cb831da87a961a57a1"
|
||||
integrity sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==
|
||||
"@typescript-eslint/parser@8.67.0", "@typescript-eslint/parser@^8.67.0":
|
||||
version "8.67.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.67.0.tgz#0158022ec9927e0afcd58a8cc2ad57e01d892f5c"
|
||||
integrity sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==
|
||||
dependencies:
|
||||
"@typescript-eslint/scope-manager" "8.66.0"
|
||||
"@typescript-eslint/types" "8.66.0"
|
||||
"@typescript-eslint/typescript-estree" "8.66.0"
|
||||
"@typescript-eslint/visitor-keys" "8.66.0"
|
||||
"@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/project-service@8.66.0":
|
||||
version "8.66.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.66.0.tgz#828f788895df52d9eb2b543445a3a5a13e35ab4e"
|
||||
integrity sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==
|
||||
"@typescript-eslint/project-service@8.67.0":
|
||||
version "8.67.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.67.0.tgz#1552db007ca9206a1c6c7acf49e210bd17a8c56f"
|
||||
integrity sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==
|
||||
dependencies:
|
||||
"@typescript-eslint/tsconfig-utils" "^8.66.0"
|
||||
"@typescript-eslint/types" "^8.66.0"
|
||||
"@typescript-eslint/tsconfig-utils" "^8.67.0"
|
||||
"@typescript-eslint/types" "^8.67.0"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/scope-manager@8.66.0":
|
||||
version "8.66.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.66.0.tgz#4fffcc6ebd0df9fe7983c0256967567ea6f5ac63"
|
||||
integrity sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==
|
||||
"@typescript-eslint/scope-manager@8.67.0":
|
||||
version "8.67.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz#4d4c2da09560d10dd7d947cba2d29d14d25af16d"
|
||||
integrity sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.66.0"
|
||||
"@typescript-eslint/visitor-keys" "8.66.0"
|
||||
"@typescript-eslint/types" "8.67.0"
|
||||
"@typescript-eslint/visitor-keys" "8.67.0"
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@8.66.0":
|
||||
version "8.66.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.66.0.tgz#3a89066c507aa30541dc176804685b4b444e1e52"
|
||||
integrity sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@^8.66.0":
|
||||
"@typescript-eslint/tsconfig-utils@8.67.0", "@typescript-eslint/tsconfig-utils@^8.67.0":
|
||||
version "8.67.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz#f45a3eba6b9132fb47141ec03ce2f275f1ea991d"
|
||||
integrity sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==
|
||||
|
||||
"@typescript-eslint/type-utils@8.66.0":
|
||||
version "8.66.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.66.0.tgz#b2315303eca72fad9afa7be4f58f053c8f2a0479"
|
||||
integrity sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==
|
||||
"@typescript-eslint/type-utils@8.67.0":
|
||||
version "8.67.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz#96bed105275559df3bcf0449b73a6414d35c59ce"
|
||||
integrity sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.66.0"
|
||||
"@typescript-eslint/typescript-estree" "8.66.0"
|
||||
"@typescript-eslint/utils" "8.66.0"
|
||||
"@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"
|
||||
|
||||
"@typescript-eslint/types@8.66.0":
|
||||
version "8.66.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.66.0.tgz#3cacab94d3b564c1d48c56eb37b89f89a6d48479"
|
||||
integrity sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==
|
||||
|
||||
"@typescript-eslint/types@^8.66.0":
|
||||
"@typescript-eslint/types@8.67.0", "@typescript-eslint/types@^8.67.0":
|
||||
version "8.67.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.67.0.tgz#4a8d00cc1faba5c14feabc60f85b7a32652f34b6"
|
||||
integrity sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==
|
||||
|
||||
"@typescript-eslint/typescript-estree@8.66.0":
|
||||
version "8.66.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.66.0.tgz#1a38c3a97dc6c669b66d585d7f90ebc4fbb32a50"
|
||||
integrity sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==
|
||||
"@typescript-eslint/typescript-estree@8.67.0":
|
||||
version "8.67.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz#116c3a47c06119c5a050e8851861d6497dd64bc2"
|
||||
integrity sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==
|
||||
dependencies:
|
||||
"@typescript-eslint/project-service" "8.66.0"
|
||||
"@typescript-eslint/tsconfig-utils" "8.66.0"
|
||||
"@typescript-eslint/types" "8.66.0"
|
||||
"@typescript-eslint/visitor-keys" "8.66.0"
|
||||
"@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"
|
||||
|
||||
"@typescript-eslint/utils@8.66.0":
|
||||
version "8.66.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.66.0.tgz#e277d67427043cdca2580ee91aa62921e4689969"
|
||||
integrity sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==
|
||||
"@typescript-eslint/utils@8.67.0":
|
||||
version "8.67.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.67.0.tgz#3e478a3d69d330a1fc50c12746cc2ee0732ccfcd"
|
||||
integrity sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils" "^4.9.1"
|
||||
"@typescript-eslint/scope-manager" "8.66.0"
|
||||
"@typescript-eslint/types" "8.66.0"
|
||||
"@typescript-eslint/typescript-estree" "8.66.0"
|
||||
"@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.66.0":
|
||||
version "8.66.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.66.0.tgz#4c494e94745fb2724a4f37a310091e56b644d18a"
|
||||
integrity sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==
|
||||
"@typescript-eslint/visitor-keys@8.67.0":
|
||||
version "8.67.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz#601d40af9acf82a28da2286f3edafc69bba9017f"
|
||||
integrity sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.66.0"
|
||||
"@typescript-eslint/types" "8.67.0"
|
||||
eslint-visitor-keys "^5.0.0"
|
||||
|
||||
"@ungap/structured-clone@^1.0.0":
|
||||
@@ -6164,10 +6181,10 @@ ansis@^3.2.0:
|
||||
resolved "https://registry.yarnpkg.com/ansis/-/ansis-3.17.0.tgz#fa8d9c2a93fe7d1177e0c17f9eeb562a58a832d7"
|
||||
integrity sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==
|
||||
|
||||
antd@^6.5.4:
|
||||
version "6.5.4"
|
||||
resolved "https://registry.yarnpkg.com/antd/-/antd-6.5.4.tgz#b41665e86a5f46ca761abd3b0abef7460116ca0d"
|
||||
integrity sha512-jchA6i0rEwHjLpgC+l6HeLHP0gL4Q4yjs6Mxqt6PlhGD5ArxCj3ZH+fKFbNquCtd6Rlzzi+emfNFpP2dGLwZzg==
|
||||
antd@^6.6.0:
|
||||
version "6.6.0"
|
||||
resolved "https://registry.yarnpkg.com/antd/-/antd-6.6.0.tgz#8acb84c54b36594b5c1a9084c8acb6a03b79961b"
|
||||
integrity sha512-UDwWIbpmrCHB9ZQ+bPh4vQfB6DTI2ulIyoQ0Tc9xxalFblttiNGHl3ySBD9SyV/8+gUjFzfSx1+iU1Fog2i46w==
|
||||
dependencies:
|
||||
"@ant-design/colors" "^8.0.1"
|
||||
"@ant-design/cssinjs" "^2.1.2"
|
||||
@@ -6176,7 +6193,7 @@ antd@^6.5.4:
|
||||
"@ant-design/icons" "^6.3.2"
|
||||
"@ant-design/react-slick" "~2.0.0"
|
||||
"@babel/runtime" "^7.29.2"
|
||||
"@rc-component/cascader" "~1.17.0"
|
||||
"@rc-component/cascader" "~1.22.0"
|
||||
"@rc-component/checkbox" "~2.0.0"
|
||||
"@rc-component/collapse" "~1.2.0"
|
||||
"@rc-component/color-picker" "~3.1.1"
|
||||
@@ -6184,10 +6201,11 @@ antd@^6.5.4:
|
||||
"@rc-component/drawer" "~1.4.2"
|
||||
"@rc-component/dropdown" "~1.0.3"
|
||||
"@rc-component/form" "~1.8.6"
|
||||
"@rc-component/image" "~1.9.0"
|
||||
"@rc-component/image" "~1.10.0"
|
||||
"@rc-component/input" "~1.3.1"
|
||||
"@rc-component/input-number" "~1.6.2"
|
||||
"@rc-component/mentions" "~1.10.0"
|
||||
"@rc-component/listy" "~1.2.3"
|
||||
"@rc-component/mentions" "~1.11.0"
|
||||
"@rc-component/menu" "~1.4.1"
|
||||
"@rc-component/motion" "^1.3.3"
|
||||
"@rc-component/mutate-observer" "^2.0.1"
|
||||
@@ -6199,16 +6217,16 @@ antd@^6.5.4:
|
||||
"@rc-component/rate" "~1.0.1"
|
||||
"@rc-component/resize-observer" "^1.1.2"
|
||||
"@rc-component/segmented" "~1.3.0"
|
||||
"@rc-component/select" "~1.8.2"
|
||||
"@rc-component/select" "~1.10.0"
|
||||
"@rc-component/slider" "~1.1.1"
|
||||
"@rc-component/steps" "~1.2.2"
|
||||
"@rc-component/switch" "~1.0.3"
|
||||
"@rc-component/table" "~1.10.4"
|
||||
"@rc-component/tabs" "~1.11.0"
|
||||
"@rc-component/tooltip" "~1.4.0"
|
||||
"@rc-component/table" "~1.11.0"
|
||||
"@rc-component/tabs" "~1.12.0"
|
||||
"@rc-component/tooltip" "~1.5.0"
|
||||
"@rc-component/tour" "~2.4.0"
|
||||
"@rc-component/tree" "~1.3.2"
|
||||
"@rc-component/tree-select" "~1.11.0"
|
||||
"@rc-component/tree" "~1.4.0"
|
||||
"@rc-component/tree-select" "~1.16.0"
|
||||
"@rc-component/trigger" "^3.10.1"
|
||||
"@rc-component/upload" "~1.1.1"
|
||||
"@rc-component/util" "^1.12.0"
|
||||
@@ -6504,10 +6522,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.10.38, baseline-browser-mapping@^2.11.12, baseline-browser-mapping@^2.9.19:
|
||||
version "2.11.12"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz#42ac48770bf73d292f60ce8ba4dc5e7ebb242ec3"
|
||||
integrity sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==
|
||||
baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.11.13, baseline-browser-mapping@^2.9.19:
|
||||
version "2.11.13"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz#660073103c1bee93e54df55f117b7528adf6af19"
|
||||
integrity sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==
|
||||
|
||||
batch@0.6.1:
|
||||
version "0.6.1"
|
||||
@@ -6745,10 +6763,10 @@ caniuse-api@^3.0.0:
|
||||
lodash.memoize "^4.1.2"
|
||||
lodash.uniq "^4.5.0"
|
||||
|
||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001799, caniuse-lite@^1.0.30001807:
|
||||
version "1.0.30001807"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001807.tgz#a113854941fb45b4c1f51793f4636920489079b4"
|
||||
integrity sha512-daRXJ9EB/rdRgu7kV+TTl1YUKtlsMWblPl2sLnpg9DZae16QCegol6A1SmCE31Lm9mXC1sRWGt/krouH+/dl7Q==
|
||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001799, caniuse-lite@^1.0.30001809:
|
||||
version "1.0.30001809"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz#e6cf71f14ddfe008f114dd2a846923be3c03a07b"
|
||||
integrity sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==
|
||||
|
||||
ccount@^2.0.0:
|
||||
version "2.0.1"
|
||||
@@ -7996,10 +8014,10 @@ doctrine@^2.1.0:
|
||||
dependencies:
|
||||
esutils "^2.0.2"
|
||||
|
||||
docusaurus-plugin-openapi-docs@^5.1.3:
|
||||
version "5.1.3"
|
||||
resolved "https://registry.yarnpkg.com/docusaurus-plugin-openapi-docs/-/docusaurus-plugin-openapi-docs-5.1.3.tgz#b8cd5f8451aaf881deb1a744a8295685f1681865"
|
||||
integrity sha512-HnpblSBdXoR39VNTIW9zWERUsMJxXOpvdQoBKyaTkUBPwCM48Z76+ndo2yO2vADq+EhWjJlfxL1DUzCrgNjThQ==
|
||||
docusaurus-plugin-openapi-docs@^5.2.0:
|
||||
version "5.2.0"
|
||||
resolved "https://registry.yarnpkg.com/docusaurus-plugin-openapi-docs/-/docusaurus-plugin-openapi-docs-5.2.0.tgz#8318ec90cd21fed023be57696211af7d72fd81db"
|
||||
integrity sha512-MjrfRAMB64uvdxRVz6L9AXWe4QFjCdoBAzYs306yyI3nnXHsFj2lv2FnLA90JV9CAUZaGiYMvvkzBo2Nrkq/9w==
|
||||
dependencies:
|
||||
"@apidevtools/json-schema-ref-parser" "^15.3.3"
|
||||
"@redocly/openapi-core" "^2.25.2"
|
||||
@@ -8017,10 +8035,10 @@ docusaurus-plugin-openapi-docs@^5.1.3:
|
||||
swagger2openapi "^7.0.8"
|
||||
xml-formatter "^3.6.6"
|
||||
|
||||
docusaurus-theme-openapi-docs@^5.1.3:
|
||||
version "5.1.3"
|
||||
resolved "https://registry.yarnpkg.com/docusaurus-theme-openapi-docs/-/docusaurus-theme-openapi-docs-5.1.3.tgz#e23644a63785352abbc76e42760c0dfdff3669e1"
|
||||
integrity sha512-npbD1QahtjAEmrOet/86i5fTmcJX4/rPhVT+c0qKjm7StUNbyqjwchSVBQuU1rB69T51JOA9TpT/y6QcB9Xjvw==
|
||||
docusaurus-theme-openapi-docs@^5.2.0:
|
||||
version "5.2.0"
|
||||
resolved "https://registry.yarnpkg.com/docusaurus-theme-openapi-docs/-/docusaurus-theme-openapi-docs-5.2.0.tgz#6d93a74e2e3cf0ae77d24e1c4144bd2e74a52115"
|
||||
integrity sha512-L0b80LzaMUfr76a9EQXRPCf8nxkEz8Xo6Aknnke1UeE2oXsgoiVki6U+RTE7GmJRjO8zSNKXyckGmGmqqWuHeA==
|
||||
dependencies:
|
||||
"@hookform/error-message" "^2.0.1"
|
||||
"@reduxjs/toolkit" "^2.8.2"
|
||||
@@ -8105,7 +8123,7 @@ domhandler@^5.0.2, domhandler@^5.0.3:
|
||||
dependencies:
|
||||
domelementtype "^2.3.0"
|
||||
|
||||
dompurify@^3.3.3, dompurify@^3.4.12:
|
||||
dompurify@^3.3.3, dompurify@^3.4.13:
|
||||
version "3.4.13"
|
||||
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.4.13.tgz#fc28949d59f92d62e28a3a764bcbeee35897a1be"
|
||||
integrity sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==
|
||||
@@ -9156,10 +9174,10 @@ globals@^14.0.0:
|
||||
resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e"
|
||||
integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==
|
||||
|
||||
globals@^17.9.0:
|
||||
version "17.9.0"
|
||||
resolved "https://registry.yarnpkg.com/globals/-/globals-17.9.0.tgz#e43f252d6bbe71508da43902a1709c8895a59f70"
|
||||
integrity sha512-m/MvAW61QVU5VDNF1Vj8axt016h8w7L5TU1e9zlab7XIttAT2YAlCwl75K1fOqvMM9apmD7lbCIRhpfkhmxhCg==
|
||||
globals@^17.11.0:
|
||||
version "17.11.0"
|
||||
resolved "https://registry.yarnpkg.com/globals/-/globals-17.11.0.tgz#d643485bb30220d7751e511cf4f68c73d3870d87"
|
||||
integrity sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==
|
||||
|
||||
globalthis@^1.0.4:
|
||||
version "1.0.4"
|
||||
@@ -10266,10 +10284,10 @@ js-levenshtein@^1.1.6:
|
||||
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
|
||||
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
|
||||
|
||||
js-yaml@4.1.0, js-yaml@=4.3.0, js-yaml@^4.1.0, js-yaml@^4.1.1, js-yaml@^4.2.0, js-yaml@^4.3.0:
|
||||
version "4.3.0"
|
||||
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.3.0.tgz#d1900572a7f7cf0b5f540c83673e60bad3436592"
|
||||
integrity sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==
|
||||
js-yaml@4.1.0, js-yaml@=4.3.1, js-yaml@^4.1.0, js-yaml@^4.1.1, js-yaml@^4.2.0, js-yaml@^4.3.0:
|
||||
version "4.3.1"
|
||||
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.3.1.tgz#01216c001d67f48e2cd560d708c7af21090a3848"
|
||||
integrity sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==
|
||||
dependencies:
|
||||
argparse "^2.0.1"
|
||||
|
||||
@@ -12244,32 +12262,32 @@ oxc-resolver@^11.19.1:
|
||||
"@oxc-resolver/binding-win32-arm64-msvc" "11.23.0"
|
||||
"@oxc-resolver/binding-win32-x64-msvc" "11.23.0"
|
||||
|
||||
oxfmt@^0.62.0:
|
||||
version "0.62.0"
|
||||
resolved "https://registry.yarnpkg.com/oxfmt/-/oxfmt-0.62.0.tgz#9945728022d26dc0a1d5bc486db112e7e340507a"
|
||||
integrity sha512-vxgGHTmnDU9j4CX7dDBLzxgmHxfda/yPcgJkGCMUSCwRmz+euo/V08xXLNgXTeqAB9Fhf3Pe2nO1RNKLCVgphQ==
|
||||
oxfmt@^0.63.0:
|
||||
version "0.63.0"
|
||||
resolved "https://registry.yarnpkg.com/oxfmt/-/oxfmt-0.63.0.tgz#c7338e6c43a68d5cf8dc61c08b617d77cb54e323"
|
||||
integrity sha512-kgdDwv35wvVf6554U2Ab8Jnd0zTM+TsEQWwaB70RAjK3gICFAFGO+2Hd3Be27GMoXj3XRL9IKSNRVl7KBQL6iw==
|
||||
dependencies:
|
||||
tinypool "2.1.0"
|
||||
optionalDependencies:
|
||||
"@oxfmt/binding-android-arm-eabi" "0.62.0"
|
||||
"@oxfmt/binding-android-arm64" "0.62.0"
|
||||
"@oxfmt/binding-darwin-arm64" "0.62.0"
|
||||
"@oxfmt/binding-darwin-x64" "0.62.0"
|
||||
"@oxfmt/binding-freebsd-x64" "0.62.0"
|
||||
"@oxfmt/binding-linux-arm-gnueabihf" "0.62.0"
|
||||
"@oxfmt/binding-linux-arm-musleabihf" "0.62.0"
|
||||
"@oxfmt/binding-linux-arm64-gnu" "0.62.0"
|
||||
"@oxfmt/binding-linux-arm64-musl" "0.62.0"
|
||||
"@oxfmt/binding-linux-ppc64-gnu" "0.62.0"
|
||||
"@oxfmt/binding-linux-riscv64-gnu" "0.62.0"
|
||||
"@oxfmt/binding-linux-riscv64-musl" "0.62.0"
|
||||
"@oxfmt/binding-linux-s390x-gnu" "0.62.0"
|
||||
"@oxfmt/binding-linux-x64-gnu" "0.62.0"
|
||||
"@oxfmt/binding-linux-x64-musl" "0.62.0"
|
||||
"@oxfmt/binding-openharmony-arm64" "0.62.0"
|
||||
"@oxfmt/binding-win32-arm64-msvc" "0.62.0"
|
||||
"@oxfmt/binding-win32-ia32-msvc" "0.62.0"
|
||||
"@oxfmt/binding-win32-x64-msvc" "0.62.0"
|
||||
"@oxfmt/binding-android-arm-eabi" "0.63.0"
|
||||
"@oxfmt/binding-android-arm64" "0.63.0"
|
||||
"@oxfmt/binding-darwin-arm64" "0.63.0"
|
||||
"@oxfmt/binding-darwin-x64" "0.63.0"
|
||||
"@oxfmt/binding-freebsd-x64" "0.63.0"
|
||||
"@oxfmt/binding-linux-arm-gnueabihf" "0.63.0"
|
||||
"@oxfmt/binding-linux-arm-musleabihf" "0.63.0"
|
||||
"@oxfmt/binding-linux-arm64-gnu" "0.63.0"
|
||||
"@oxfmt/binding-linux-arm64-musl" "0.63.0"
|
||||
"@oxfmt/binding-linux-ppc64-gnu" "0.63.0"
|
||||
"@oxfmt/binding-linux-riscv64-gnu" "0.63.0"
|
||||
"@oxfmt/binding-linux-riscv64-musl" "0.63.0"
|
||||
"@oxfmt/binding-linux-s390x-gnu" "0.63.0"
|
||||
"@oxfmt/binding-linux-x64-gnu" "0.63.0"
|
||||
"@oxfmt/binding-linux-x64-musl" "0.63.0"
|
||||
"@oxfmt/binding-openharmony-arm64" "0.63.0"
|
||||
"@oxfmt/binding-win32-arm64-msvc" "0.63.0"
|
||||
"@oxfmt/binding-win32-ia32-msvc" "0.63.0"
|
||||
"@oxfmt/binding-win32-x64-msvc" "0.63.0"
|
||||
|
||||
p-cancelable@^3.0.0:
|
||||
version "3.0.0"
|
||||
@@ -14765,10 +14783,10 @@ stop-iteration-iterator@^1.1.0:
|
||||
es-errors "^1.3.0"
|
||||
internal-slot "^1.1.0"
|
||||
|
||||
storybook@^10.5.7:
|
||||
version "10.5.7"
|
||||
resolved "https://registry.yarnpkg.com/storybook/-/storybook-10.5.7.tgz#adfc465e51f337291c095278c23f1b8024ef2da7"
|
||||
integrity sha512-oiKvWIwIoOhFP1i6dASYyMXwPHKEtVZMshqSB7EvIVYjWRh0l9H7gHEt1z4Gh2rLGFMekWdsm4s94rvwpR7gkg==
|
||||
storybook@^10.5.8:
|
||||
version "10.5.8"
|
||||
resolved "https://registry.yarnpkg.com/storybook/-/storybook-10.5.8.tgz#d5f051983e6232c0a73ea02149a72c7bafb43275"
|
||||
integrity sha512-rR4oFMSiWBSqI0lvsJPtcQUPj8+hzj3TkLu+Mw61Wo6YxPSb5FsLSHai0jZnuaIdKIlmu25KCfwlSQl4e1uvnA==
|
||||
dependencies:
|
||||
"@storybook/global" "^5.0.0"
|
||||
"@storybook/icons" "^2.0.2"
|
||||
@@ -15085,10 +15103,10 @@ swagger-client@^3.37.8:
|
||||
"@swagger-api/apidom-parser-adapter-openapi-yaml-3-2" "^1.12.0"
|
||||
"@swagger-api/apidom-parser-adapter-yaml-1-2" "^1.12.0"
|
||||
|
||||
swagger-ui-react@^5.32.12:
|
||||
version "5.32.12"
|
||||
resolved "https://registry.yarnpkg.com/swagger-ui-react/-/swagger-ui-react-5.32.12.tgz#47525a26774eb02db0e6203af72f5b32fa6205cc"
|
||||
integrity sha512-WCdkNOQyMTZDu+z356FpwVWHf1dwZgQPUjdQPh1L4r7jULaJTKKlIItXq6WsZdYeXvsHndMdxxccEQXOAroUHQ==
|
||||
swagger-ui-react@^5.32.13:
|
||||
version "5.32.13"
|
||||
resolved "https://registry.yarnpkg.com/swagger-ui-react/-/swagger-ui-react-5.32.13.tgz#04c96140b0a2d4ea01ebec4d4cfc655d5ed9a500"
|
||||
integrity sha512-XIDl+Ny6kE1N8wpSPiOFrjPfAevs4GR4XmV6BT6NLMikkMFIbIVocWbA8pnKYyYXQe8Rccfli5o2zDfySw0FnQ==
|
||||
dependencies:
|
||||
"@babel/runtime-corejs3" "^7.27.1"
|
||||
"@scarf/scarf" "=1.4.0"
|
||||
@@ -15097,11 +15115,11 @@ swagger-ui-react@^5.32.12:
|
||||
classnames "^2.5.1"
|
||||
css.escape "1.5.1"
|
||||
deep-extend "0.6.0"
|
||||
dompurify "^3.4.12"
|
||||
dompurify "^3.4.13"
|
||||
ieee754 "^1.2.1"
|
||||
immutable "^4.3.9"
|
||||
js-file-download "^0.4.12"
|
||||
js-yaml "=4.3.0"
|
||||
js-yaml "=4.3.1"
|
||||
lodash "^4.18.1"
|
||||
prop-types "^15.8.1"
|
||||
randexp "^0.5.3"
|
||||
@@ -15467,15 +15485,15 @@ types-ramda@^0.30.1:
|
||||
dependencies:
|
||||
ts-toolbelt "^9.6.0"
|
||||
|
||||
typescript-eslint@^8.66.0:
|
||||
version "8.66.0"
|
||||
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.66.0.tgz#0809b6d25c8a0924690ba30dc1f05607093c11fb"
|
||||
integrity sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==
|
||||
typescript-eslint@^8.67.0:
|
||||
version "8.67.0"
|
||||
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.67.0.tgz#1e92de09ee0ff2d96cc0848f5e9f345ea930d963"
|
||||
integrity sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==
|
||||
dependencies:
|
||||
"@typescript-eslint/eslint-plugin" "8.66.0"
|
||||
"@typescript-eslint/parser" "8.66.0"
|
||||
"@typescript-eslint/typescript-estree" "8.66.0"
|
||||
"@typescript-eslint/utils" "8.66.0"
|
||||
"@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"
|
||||
|
||||
typescript@~6.0.3:
|
||||
version "6.0.3"
|
||||
|
||||
@@ -93,7 +93,7 @@ def find_models(module: ModuleType) -> list[type[Model]]: # noqa: C901
|
||||
# where the current model is out-of-sync with the existing table after a
|
||||
# downgrade
|
||||
sqlalchemy_uri = current_app.config["SQLALCHEMY_DATABASE_URI"]
|
||||
engine = create_engine(sqlalchemy_uri, future=True)
|
||||
engine = create_engine(sqlalchemy_uri)
|
||||
Base = automap_base() # noqa: N806
|
||||
Base.prepare(engine, reflect=True)
|
||||
seen = set()
|
||||
|
||||
@@ -18,8 +18,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from typing import Any
|
||||
|
||||
import isodate
|
||||
import pyarrow as pa
|
||||
@@ -90,6 +91,8 @@ class Dimension:
|
||||
definition: str | None = None
|
||||
description: str | None = None
|
||||
grain: Grain | None = None
|
||||
verbose_name: str | None = field(default=None, compare=False)
|
||||
metadata: dict[str, Any] = field(default_factory=dict, compare=False)
|
||||
|
||||
|
||||
class AggregationType(str, enum.Enum):
|
||||
@@ -121,6 +124,9 @@ class Metric:
|
||||
definition: str
|
||||
description: str | None = None
|
||||
aggregation: AggregationType | None = None
|
||||
verbose_name: str | None = field(default=None, compare=False)
|
||||
d3format: str | None = field(default=None, compare=False)
|
||||
metadata: dict[str, Any] = field(default_factory=dict, compare=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# 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 pyarrow as pa
|
||||
from superset_core.semantic_layers.types import Dimension, Metric
|
||||
|
||||
|
||||
def test_dimension_metadata_is_not_part_of_identity() -> None:
|
||||
first = Dimension(
|
||||
"sales.region",
|
||||
"region",
|
||||
pa.utf8(),
|
||||
verbose_name="Region",
|
||||
metadata={"display_name": "Region"},
|
||||
)
|
||||
second = Dimension(
|
||||
"sales.region",
|
||||
"region",
|
||||
pa.utf8(),
|
||||
verbose_name="Sales region",
|
||||
metadata={"display_name": "Sales region"},
|
||||
)
|
||||
|
||||
assert first == second
|
||||
assert {first, second} == {first}
|
||||
|
||||
|
||||
def test_metric_metadata_is_not_part_of_identity() -> None:
|
||||
first = Metric(
|
||||
"sales.total_revenue",
|
||||
"total_revenue",
|
||||
pa.float64(),
|
||||
"SUM(revenue)",
|
||||
verbose_name="Total revenue",
|
||||
d3format="$,.2f",
|
||||
metadata={"unit": {"kind": "currency", "code": "USD"}},
|
||||
)
|
||||
second = Metric(
|
||||
"sales.total_revenue",
|
||||
"total_revenue",
|
||||
pa.float64(),
|
||||
"SUM(revenue)",
|
||||
verbose_name="Revenue",
|
||||
d3format=",.0f",
|
||||
metadata={"unit": {"kind": "currency", "code": "EUR"}},
|
||||
)
|
||||
|
||||
assert first == second
|
||||
assert {first, second} == {first}
|
||||
|
||||
|
||||
def test_metric_accepts_superset_presentation_fields() -> None:
|
||||
metric = Metric(
|
||||
"sales.total_revenue",
|
||||
"total_revenue",
|
||||
pa.float64(),
|
||||
"SUM(revenue)",
|
||||
verbose_name="Total revenue",
|
||||
d3format="$,.2f",
|
||||
)
|
||||
|
||||
assert metric.verbose_name == "Total revenue"
|
||||
assert metric.d3format == "$,.2f"
|
||||
|
||||
|
||||
def test_dimension_accepts_superset_presentation_fields() -> None:
|
||||
dimension = Dimension(
|
||||
"sales.region",
|
||||
"region",
|
||||
pa.utf8(),
|
||||
verbose_name="Region",
|
||||
)
|
||||
|
||||
assert dimension.verbose_name == "Region"
|
||||
|
||||
|
||||
def test_metadata_defaults_are_not_shared() -> None:
|
||||
first = Metric("first", "first", pa.int64(), "COUNT(*)")
|
||||
second = Metric("second", "second", pa.int64(), "COUNT(*)")
|
||||
|
||||
first.metadata["display_name"] = "First"
|
||||
|
||||
assert second.metadata == {}
|
||||
Generated
+413
-693
File diff suppressed because it is too large
Load Diff
@@ -158,7 +158,7 @@
|
||||
"@visx/xychart": "^4.0.0",
|
||||
"ag-grid-community": "36.1.0",
|
||||
"ag-grid-react": "36.1.0",
|
||||
"antd": "^6.5.4",
|
||||
"antd": "^6.6.0",
|
||||
"chrono-node": "^2.10.1",
|
||||
"classnames": "^2.2.5",
|
||||
"content-disposition": "^2.0.1",
|
||||
@@ -176,7 +176,7 @@
|
||||
"geostyler-openlayers-parser": "^5.7.1",
|
||||
"geostyler-style": "11.0.2",
|
||||
"geostyler-wfs-parser": "^3.0.1",
|
||||
"google-auth-library": "^11.0.0",
|
||||
"google-auth-library": "^11.0.2",
|
||||
"immer": "^11.1.16",
|
||||
"interweave": "^13.1.1",
|
||||
"jquery": "^4.0.0",
|
||||
@@ -257,16 +257,16 @@
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
|
||||
"@storybook/addon-docs": "10.5.7",
|
||||
"@storybook/addon-links": "10.5.7",
|
||||
"@storybook/react-webpack5": "10.5.7",
|
||||
"@storybook/addon-docs": "10.5.8",
|
||||
"@storybook/addon-links": "10.5.8",
|
||||
"@storybook/react-webpack5": "10.5.8",
|
||||
"@storybook/test-runner": "0.24.4",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.47",
|
||||
"@swc/plugin-emotion": "^14.15.0",
|
||||
"@swc/plugin-emotion": "^14.19.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^7.0.0",
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/react": "^15.0.0",
|
||||
"@testing-library/user-event": "^12.8.3",
|
||||
"@types/content-disposition": "^0.5.9",
|
||||
@@ -277,7 +277,7 @@
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-loadable": "^5.5.11",
|
||||
@@ -289,19 +289,19 @@
|
||||
"@types/rison": "0.1.0",
|
||||
"@types/tinycolor2": "^1.4.3",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@typescript-eslint/eslint-plugin": "^8.66.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.67.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.12",
|
||||
"baseline-browser-mapping": "^2.11.14",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.4",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"css-loader": "^7.1.4",
|
||||
"eslint": "^10.8.0",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint-import-resolver-alias": "^1.1.2",
|
||||
"eslint-import-resolver-typescript": "^4.4.5",
|
||||
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
|
||||
@@ -312,7 +312,7 @@
|
||||
"eslint-plugin-no-only-tests": "^3.4.0",
|
||||
"eslint-plugin-react-prefer-function-component": "^5.0.0",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.1",
|
||||
"eslint-plugin-storybook": "10.5.7",
|
||||
"eslint-plugin-storybook": "10.5.8",
|
||||
"eslint-plugin-testing-library": "^7.16.2",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
"fetch-mock": "^12.6.0",
|
||||
@@ -331,8 +331,8 @@
|
||||
"mini-css-extract-plugin": "^2.10.2",
|
||||
"minimizer-webpack-plugin": "^5.6.1",
|
||||
"open-cli": "^9.0.0",
|
||||
"oxfmt": "^0.62.0",
|
||||
"oxlint": "^1.77.0",
|
||||
"oxfmt": "^0.63.0",
|
||||
"oxlint": "^1.78.0",
|
||||
"po2json": "^0.4.5",
|
||||
"postcss-styled-syntax": "^0.7.2",
|
||||
"process": "^0.11.10",
|
||||
@@ -343,13 +343,13 @@
|
||||
"source-map": "^0.8.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"speed-measure-webpack-plugin": "^1.6.0",
|
||||
"storybook": "10.5.7",
|
||||
"storybook": "10.5.8",
|
||||
"style-loader": "^4.0.0",
|
||||
"stylelint": "^17.14.1",
|
||||
"swc-loader": "^0.2.7",
|
||||
"ts-jest": "^29.4.12",
|
||||
"tscw-config": "^1.1.2",
|
||||
"tsx": "^4.23.10",
|
||||
"tsx": "^4.23.12",
|
||||
"typescript": "5.4.5",
|
||||
"unzipper": "^0.12.5",
|
||||
"wait-on": "^9.1.0",
|
||||
|
||||
@@ -130,6 +130,7 @@ export enum GenericDataType {
|
||||
String = 1,
|
||||
Temporal = 2,
|
||||
Boolean = 3,
|
||||
MultiValue = 4,
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+5
@@ -28,6 +28,7 @@ import {
|
||||
FieldBinaryOutlined,
|
||||
FieldStringOutlined,
|
||||
NumberOutlined,
|
||||
UnorderedListOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Icons } from '@superset-ui/core/components';
|
||||
|
||||
@@ -72,6 +73,10 @@ export function ColumnTypeLabel({ type }: ColumnTypeLabelProps) {
|
||||
typeIcon = <FieldBinaryOutlined aria-label={t('boolean type icon')} />;
|
||||
} else if (type === GenericDataType.Temporal) {
|
||||
typeIcon = <ClockCircleOutlined aria-label={t('temporal type icon')} />;
|
||||
} else if (type === GenericDataType.MultiValue) {
|
||||
typeIcon = (
|
||||
<UnorderedListOutlined aria-label={t('multi-value type icon')} />
|
||||
);
|
||||
}
|
||||
|
||||
return <TypeIconWrapper>{typeIcon}</TypeIconWrapper>;
|
||||
|
||||
+17
@@ -64,4 +64,21 @@ describe('ColumnOption', () => {
|
||||
renderColumnTypeLabel({ type: GenericDataType.Temporal });
|
||||
expect(screen.getByLabelText('temporal type icon')).toBeVisible();
|
||||
});
|
||||
test('multi-value (array) type shows list icon', () => {
|
||||
renderColumnTypeLabel({ type: GenericDataType.MultiValue });
|
||||
expect(screen.getByLabelText('multi-value type icon')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GenericDataType enum parity', () => {
|
||||
// These numeric values are shared with the backend enum in
|
||||
// superset/utils/core.py (GenericDataType). They must stay in sync because
|
||||
// the backend serializes columns using these integers.
|
||||
test('values match the backend contract', () => {
|
||||
expect(GenericDataType.Numeric).toBe(0);
|
||||
expect(GenericDataType.String).toBe(1);
|
||||
expect(GenericDataType.Temporal).toBe(2);
|
||||
expect(GenericDataType.Boolean).toBe(3);
|
||||
expect(GenericDataType.MultiValue).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
"@types/d3-time-format": "^4.0.3",
|
||||
"@types/jquery": "^4.0.1",
|
||||
"@types/lodash": "^4.17.25",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/prop-types": "^15.7.15",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/react-table": "^7.7.20",
|
||||
|
||||
+6
-4
@@ -107,12 +107,14 @@ const getAllSelectOptions = () =>
|
||||
|
||||
const findSelectOption = (text: string) =>
|
||||
waitFor(() =>
|
||||
within(getElementByClassName('.rc-virtual-list')).getByText(text),
|
||||
within(getElementByClassName('.ant-select-dropdown-list')).getByText(text),
|
||||
);
|
||||
|
||||
const querySelectOption = (text: string) =>
|
||||
waitFor(() =>
|
||||
within(getElementByClassName('.rc-virtual-list')).queryByText(text),
|
||||
within(getElementByClassName('.ant-select-dropdown-list')).queryByText(
|
||||
text,
|
||||
),
|
||||
);
|
||||
|
||||
const findAllSelectOptions = () =>
|
||||
@@ -644,7 +646,7 @@ test('does not add a new option if the option already exists', async () => {
|
||||
await type(option);
|
||||
await waitFor(() => {
|
||||
const array = within(
|
||||
getElementByClassName('.rc-virtual-list'),
|
||||
getElementByClassName('.ant-select-dropdown-list'),
|
||||
).getAllByText(option);
|
||||
expect(array.length).toBe(1);
|
||||
});
|
||||
@@ -1398,7 +1400,7 @@ test('appends page>1 results during an active search and discards them when sear
|
||||
// scrollTop via e.currentTarget in its onFallbackScroll handler, which
|
||||
// then forwards to onPopupScroll (handlePagination here).
|
||||
const holder = document.querySelector(
|
||||
'.rc-virtual-list-holder',
|
||||
'.ant-select-dropdown-list-holder',
|
||||
) as HTMLElement | null;
|
||||
if (!holder) throw new Error('virtual-list holder not rendered');
|
||||
Object.defineProperty(holder, 'scrollHeight', {
|
||||
|
||||
@@ -93,12 +93,14 @@ const deselectAllButtonText = (length: number) =>
|
||||
|
||||
const findSelectOption = (text: string) =>
|
||||
waitFor(() =>
|
||||
within(getElementByClassName('.rc-virtual-list')).getByText(text),
|
||||
within(getElementByClassName('.ant-select-dropdown-list')).getByText(text),
|
||||
);
|
||||
|
||||
const querySelectOption = (text: string) =>
|
||||
waitFor(() =>
|
||||
within(getElementByClassName('.rc-virtual-list')).queryByText(text),
|
||||
within(getElementByClassName('.ant-select-dropdown-list')).queryByText(
|
||||
text,
|
||||
),
|
||||
);
|
||||
|
||||
const getAllSelectOptions = () =>
|
||||
|
||||
@@ -25,8 +25,11 @@ export type Aggregate =
|
||||
| 'COUNT'
|
||||
| 'COUNT_DISTINCT'
|
||||
| 'MAX'
|
||||
| 'MEDIAN'
|
||||
| 'MIN'
|
||||
| 'SUM';
|
||||
| 'STDDEV_SAMP'
|
||||
| 'SUM'
|
||||
| 'VAR_SAMP';
|
||||
|
||||
export interface AdhocMetricBase {
|
||||
hasCustomLabel?: boolean;
|
||||
|
||||
@@ -19,19 +19,71 @@
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { sanitizeHtml } from './html';
|
||||
|
||||
export type TooltipTruncationMode = 'off' | 'end' | 'start' | 'middle';
|
||||
|
||||
export const TRUNCATION_MAX_CHARS = 40;
|
||||
|
||||
const TRUNCATION_STYLE = `
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const NOWRAP_STYLE = `
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
/**
|
||||
* Shortens plain text so a tooltip label stays readable, placing the ellipsis
|
||||
* where the caller asked for it.
|
||||
*
|
||||
* Only 'start' and 'middle' slice. 'end' is handled by CSS in tooltipHtml, and
|
||||
* 'off' means no truncation at all, so both return the input untouched.
|
||||
*
|
||||
* The input must be plain text. Callers are responsible for truncating before
|
||||
* any markup (such as the ECharts series marker) is prepended, and before
|
||||
* sanitization — slicing a string that already contains markup would cut into
|
||||
* a tag.
|
||||
*/
|
||||
export function truncateLabel(
|
||||
text: string,
|
||||
mode: TooltipTruncationMode = 'end',
|
||||
): string {
|
||||
if (
|
||||
(mode !== 'start' && mode !== 'middle') ||
|
||||
text.length <= TRUNCATION_MAX_CHARS
|
||||
) {
|
||||
return text;
|
||||
}
|
||||
const budget = TRUNCATION_MAX_CHARS - 1;
|
||||
if (mode === 'start') {
|
||||
return `…${text.slice(-budget)}`;
|
||||
}
|
||||
const head = Math.ceil(budget / 2);
|
||||
const tail = Math.floor(budget / 2);
|
||||
return `${text.slice(0, head)}…${text.slice(-tail)}`;
|
||||
}
|
||||
|
||||
function getTruncationStyle(mode: TooltipTruncationMode): string {
|
||||
if (mode === 'end') {
|
||||
return TRUNCATION_STYLE;
|
||||
}
|
||||
if (mode === 'off') {
|
||||
return '';
|
||||
}
|
||||
// 'start' and 'middle' are already sliced upstream; keep them on one line.
|
||||
return NOWRAP_STYLE;
|
||||
}
|
||||
|
||||
export function tooltipHtml(
|
||||
data: string[][],
|
||||
title?: string,
|
||||
focusedRow?: number,
|
||||
truncation: TooltipTruncationMode = 'end',
|
||||
) {
|
||||
const truncationStyle = getTruncationStyle(truncation);
|
||||
const titleRow = title
|
||||
? `<span style="font-weight: 700;${TRUNCATION_STYLE}">${title}</span>`
|
||||
? `<span style="font-weight: 700;${truncationStyle}">${title}</span>`
|
||||
: '';
|
||||
return sanitizeHtml(`
|
||||
<div>
|
||||
@@ -46,7 +98,7 @@ export function tooltipHtml(
|
||||
const cellStyle = `
|
||||
text-align: ${j > 0 ? 'right' : 'left'};
|
||||
padding-left: ${j === 0 ? 0 : 16}px;
|
||||
${TRUNCATION_STYLE}
|
||||
${truncationStyle}
|
||||
`;
|
||||
return `<td style="${cellStyle}">${cell}</td>`;
|
||||
});
|
||||
|
||||
@@ -16,7 +16,12 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { sanitizeHtml, tooltipHtml } from '@superset-ui/core';
|
||||
import {
|
||||
sanitizeHtml,
|
||||
tooltipHtml,
|
||||
truncateLabel,
|
||||
TRUNCATION_MAX_CHARS,
|
||||
} from '@superset-ui/core';
|
||||
|
||||
const TITLE_STYLE =
|
||||
'style="font-weight: 700;max-width:300px;overflow:hidden;text-overflow:ellipsis;"';
|
||||
@@ -182,3 +187,88 @@ test('should preserve table styling after sanitization (fixes ECharts tooltip fo
|
||||
expect(html).toContain('padding-left:16px');
|
||||
expect(html).toContain('max-width:300px');
|
||||
});
|
||||
|
||||
describe('truncateLabel', () => {
|
||||
const long = 'prod-us-east-1-service-checkout-latency-p99'; // 43 chars
|
||||
|
||||
test('returns text unchanged for off and end', () => {
|
||||
expect(truncateLabel(long, 'off')).toBe(long);
|
||||
expect(truncateLabel(long, 'end')).toBe(long);
|
||||
});
|
||||
|
||||
test('defaults to end, which does not slice', () => {
|
||||
expect(truncateLabel(long)).toBe(long);
|
||||
});
|
||||
|
||||
test('truncates the start, keeping the distinguishing suffix', () => {
|
||||
expect(truncateLabel(long, 'start')).toBe(
|
||||
'…-us-east-1-service-checkout-latency-p99',
|
||||
);
|
||||
expect(truncateLabel(long, 'start')).toHaveLength(TRUNCATION_MAX_CHARS);
|
||||
});
|
||||
|
||||
test('truncates the middle, keeping both ends', () => {
|
||||
expect(truncateLabel(long, 'middle')).toBe(
|
||||
'prod-us-east-1-servi…heckout-latency-p99',
|
||||
);
|
||||
expect(truncateLabel(long, 'middle')).toHaveLength(TRUNCATION_MAX_CHARS);
|
||||
});
|
||||
|
||||
test('leaves text at or under the limit untouched', () => {
|
||||
const atLimit = 'x'.repeat(TRUNCATION_MAX_CHARS);
|
||||
expect(truncateLabel(atLimit, 'start')).toBe(atLimit);
|
||||
expect(truncateLabel(atLimit, 'middle')).toBe(atLimit);
|
||||
expect(truncateLabel('short', 'start')).toBe('short');
|
||||
expect(truncateLabel('', 'middle')).toBe('');
|
||||
});
|
||||
|
||||
test('truncates text one character over the limit', () => {
|
||||
const overLimit = 'x'.repeat(TRUNCATION_MAX_CHARS + 1);
|
||||
expect(truncateLabel(overLimit, 'start')).toBe(
|
||||
`…${'x'.repeat(TRUNCATION_MAX_CHARS - 1)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tooltipHtml truncation modes', () => {
|
||||
const rows = [['label', 'value']];
|
||||
|
||||
// sanitizeHtml normalizes spacing inside style attributes, and it does so
|
||||
// differently across versions, so compare with whitespace stripped.
|
||||
const styles = (
|
||||
title: string | undefined,
|
||||
truncation?: 'off' | 'end' | 'start' | 'middle',
|
||||
) => removeWhitespaces(tooltipHtml(rows, title, undefined, truncation));
|
||||
|
||||
test('emits the 300px cap for end and for the default', () => {
|
||||
expect(styles('Title', 'end')).toContain('max-width:300px');
|
||||
expect(tooltipHtml(rows, 'Title')).toBe(
|
||||
tooltipHtml(rows, 'Title', undefined, 'end'),
|
||||
);
|
||||
});
|
||||
|
||||
test('emits no truncation style for off', () => {
|
||||
const html = styles('Title', 'off');
|
||||
expect(html).not.toContain('max-width');
|
||||
expect(html).not.toContain('text-overflow');
|
||||
expect(html).not.toContain('white-space');
|
||||
});
|
||||
|
||||
test.each(['start', 'middle'] as const)(
|
||||
'emits nowrap instead of a cap for %s',
|
||||
mode => {
|
||||
const html = styles('Title', mode);
|
||||
expect(html).toContain('white-space:nowrap');
|
||||
expect(html).not.toContain('max-width');
|
||||
},
|
||||
);
|
||||
|
||||
test('never slices cell text itself, whatever the mode', () => {
|
||||
const longCell = 'y'.repeat(TRUNCATION_MAX_CHARS + 20);
|
||||
(['off', 'end', 'start', 'middle'] as const).forEach(mode => {
|
||||
expect(tooltipHtml([[longCell]], undefined, undefined, mode)).toContain(
|
||||
longCell,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,6 +47,10 @@ export default defineConfig({
|
||||
// Retry logic - 2 retries in CI, 0 locally
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
|
||||
// Disable capturing Git commit info as the project's history is increasingly dense
|
||||
// and breach Playwright's default 3-seconds `git` command timeout limit
|
||||
captureGitInfo: { commit: false, diff: false },
|
||||
|
||||
// Reporter configuration - multiple reporters for better visibility
|
||||
reporter: process.env.CI
|
||||
? [
|
||||
|
||||
@@ -22,7 +22,7 @@ import { Modal } from '../core/Modal';
|
||||
|
||||
/**
|
||||
* Confirm Dialog component for Ant Design Modal.confirm dialogs.
|
||||
* These are the "OK" / "Cancel" confirmation dialogs used throughout Superset.
|
||||
* These are the "Confirm" / "Cancel" confirmation dialogs used throughout Superset.
|
||||
* Uses getByRole with name to target specific confirm dialogs when multiple are open.
|
||||
*/
|
||||
export class ConfirmDialog extends Modal {
|
||||
@@ -43,7 +43,7 @@ export class ConfirmDialog extends Modal {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks the OK button to confirm.
|
||||
* Clicks the Confirm button to confirm.
|
||||
* @param options.timeout - If provided, silently returns if dialog doesn't appear
|
||||
* within timeout. If not provided, waits indefinitely (strict mode).
|
||||
*/
|
||||
@@ -53,7 +53,7 @@ export class ConfirmDialog extends Modal {
|
||||
state: 'visible',
|
||||
timeout: options?.timeout,
|
||||
});
|
||||
await this.clickFooterButton('OK');
|
||||
await this.clickFooterButton('Confirm');
|
||||
await this.waitForHidden();
|
||||
} catch (error) {
|
||||
// Only swallow TimeoutError when timeout was explicitly provided
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* With SOFT_DELETE enabled the delete-confirmation modal becomes recoverable:
|
||||
* it explains the object is moved to the archive (and for how long), and drops
|
||||
* the "type DELETE to confirm" friction. Non-destructive — the modal is opened
|
||||
* and dismissed without deleting anything.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { skipUnlessFeatureEnabled } from '../../helpers/featureFlags';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await skipUnlessFeatureEnabled(page, 'SOFT_DELETE');
|
||||
});
|
||||
|
||||
test('chart delete confirmation reflects soft-delete (archive) semantics', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('chart/list/');
|
||||
await page.locator('[data-test="chart-row-delete"]').first().waitFor();
|
||||
await page.locator('[data-test="chart-row-delete"]').first().click();
|
||||
|
||||
// The action reads as "Archive", not "Delete". Scope to the dialog: with
|
||||
// the flag on, every list row's delete action is also named "Archive", so
|
||||
// an unscoped button query is a strict-mode violation (25 rows + modal).
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog.getByText(/^Archive .+\?$/)).toBeVisible();
|
||||
await expect(dialog.getByRole('button', { name: 'Archive' })).toBeVisible();
|
||||
|
||||
// Recoverable copy instead of "Are you sure … permanently".
|
||||
await expect(page.getByText(/moved to Recently Archived/i)).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(/recover it there within \d+ days/i),
|
||||
).toBeVisible();
|
||||
|
||||
// No "type DELETE to confirm" input in recoverable mode.
|
||||
await expect(page.getByTestId('delete-modal-input')).toHaveCount(0);
|
||||
|
||||
// Dismiss without deleting.
|
||||
await page.getByTestId('close-modal-btn').click();
|
||||
});
|
||||
@@ -29,7 +29,7 @@
|
||||
* restore it and asserts — via the API — that it is live again.
|
||||
*/
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import { apiGet, apiPost } from '../../helpers/api/requests';
|
||||
import { apiGet } from '../../helpers/api/requests';
|
||||
import { extractIdFromResponse } from '../../helpers/api/assertions';
|
||||
import {
|
||||
apiPostChart,
|
||||
@@ -188,58 +188,3 @@ test('permanently deletes an archived item from the view', async ({ page }) => {
|
||||
await TYPES[0].softDelete(page, id).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
test('shows an empty message and no rows when the search matches nothing', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('archived/');
|
||||
await expect(page.getByTestId('archived-list-view')).toBeVisible();
|
||||
|
||||
const search = page.getByPlaceholder(/type a value/i);
|
||||
await search.click();
|
||||
await search.fill(`e2e_nonexistent_${Date.now()}`);
|
||||
await search.press('Enter');
|
||||
|
||||
await expect(
|
||||
page.getByText('No results match your filter criteria'),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId('archived-row-restore')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('restoring an already-restored row surfaces an error without crashing', async ({
|
||||
page,
|
||||
}) => {
|
||||
const name = `e2e_stale_${Date.now()}`;
|
||||
const id = await TYPES[0].create(page, name);
|
||||
// Capture the uuid before soft-delete (a soft-deleted GET returns 404).
|
||||
const { uuid } = (await (await apiGetDashboard(page, id)).json()).result;
|
||||
try {
|
||||
expect((await apiDeleteDashboard(page, id)).ok()).toBeTruthy();
|
||||
|
||||
await openArchive(page, 'Dashboard', name);
|
||||
await expect(page.getByText(name, { exact: false })).toBeVisible();
|
||||
|
||||
// Simulate another actor restoring the object out from under this view.
|
||||
const restored = await apiPost(
|
||||
page,
|
||||
`api/v1/dashboard/${uuid}/restore`,
|
||||
{},
|
||||
);
|
||||
expect(restored.ok()).toBeTruthy();
|
||||
|
||||
// Clicking the now-stale row's Restore yields a 404 → danger toast, no crash.
|
||||
await page
|
||||
.getByRole('row')
|
||||
.filter({ hasText: name })
|
||||
.getByTestId('archived-row-restore')
|
||||
.click();
|
||||
await expect(
|
||||
page.getByText(`Failed to restore ${name}`, { exact: false }),
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
// The page is still functional (the list view did not crash).
|
||||
await expect(page.getByTestId('archived-list-view')).toBeVisible();
|
||||
} finally {
|
||||
// Re-archive the (possibly) restored dashboard, whatever happened above.
|
||||
await apiDeleteDashboard(page, id).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -30,12 +30,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"d3": "^3.5.17",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^19.2.7"
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*"
|
||||
"@superset-ui/core": "*",
|
||||
"react": "^18.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { getNumberFormatter } from '@superset-ui/core';
|
||||
import { render, fireEvent } from '../../../../spec/helpers/testing-library';
|
||||
import BigNumberVis from './BigNumberViz';
|
||||
|
||||
/**
|
||||
* Tests for the color threshold formatter logic in BigNumberViz.
|
||||
*
|
||||
@@ -83,3 +87,33 @@ describe('BigNumberViz color formatters', () => {
|
||||
expect(getColorFromValue).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('BigNumberViz context menu', () => {
|
||||
test('invokes onContextMenu and stops the event bubbling to ancestor handlers', () => {
|
||||
const onContextMenu = jest.fn();
|
||||
const ancestorHandler = jest.fn();
|
||||
|
||||
const { container } = render(
|
||||
<div onContextMenu={ancestorHandler}>
|
||||
<BigNumberVis
|
||||
width={200}
|
||||
height={100}
|
||||
bigNumber={42}
|
||||
headerFormatter={getNumberFormatter()}
|
||||
headerFontSize={0.3}
|
||||
subheaderFontSize={0.125}
|
||||
subtitleFontSize={0.125}
|
||||
subtitle=""
|
||||
refs={{}}
|
||||
onContextMenu={onContextMenu}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
|
||||
const headerLine = container.querySelector('.header-line');
|
||||
fireEvent.contextMenu(headerLine!, { clientX: 10, clientY: 20 });
|
||||
|
||||
expect(onContextMenu).toHaveBeenCalledWith(10, 20);
|
||||
expect(ancestorHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -224,6 +224,7 @@ function BigNumberVis({
|
||||
const handleContextMenu = (e: MouseEvent<HTMLDivElement>) => {
|
||||
if (onContextMenu) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenu(e.nativeEvent.clientX, e.nativeEvent.clientY);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -390,6 +390,7 @@ export default function transformProps(chartProps: EchartsGanttChartProps) {
|
||||
[GenericDataType.String]: undefined,
|
||||
[GenericDataType.Temporal]: tooltipTimeFormatter,
|
||||
[GenericDataType.Boolean]: undefined,
|
||||
[GenericDataType.MultiValue]: undefined,
|
||||
};
|
||||
|
||||
const echartOptions: EChartsCoreOption = {
|
||||
|
||||
+18
-7
@@ -40,6 +40,7 @@ import {
|
||||
TimeseriesChartDataResponseResult,
|
||||
TimeseriesDataRecord,
|
||||
tooltipHtml,
|
||||
truncateLabel,
|
||||
ValueFormatter,
|
||||
} from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
@@ -79,6 +80,7 @@ import {
|
||||
getAnnotationData,
|
||||
} from '../utils/annotation';
|
||||
import {
|
||||
collapseForecastKeys,
|
||||
extractForecastSeriesContext,
|
||||
extractForecastValuesFromTooltipParams,
|
||||
formatForecastTooltipSeries,
|
||||
@@ -207,6 +209,7 @@ export default function transformProps(
|
||||
zoomable,
|
||||
richTooltip,
|
||||
tooltipSortByMetric,
|
||||
tooltipTruncation,
|
||||
xAxisBounds,
|
||||
xAxisLabelRotation,
|
||||
xAxisLabelInterval,
|
||||
@@ -859,12 +862,14 @@ export default function transformProps(
|
||||
: params.value[0];
|
||||
const forecastValue: any[] = richTooltip ? params : [params];
|
||||
|
||||
const sortedKeys = extractTooltipKeys(
|
||||
forecastValue,
|
||||
// horizontal mode is not supported in mixed series chart
|
||||
1,
|
||||
richTooltip,
|
||||
tooltipSortByMetric,
|
||||
const sortedKeys = collapseForecastKeys(
|
||||
extractTooltipKeys(
|
||||
forecastValue,
|
||||
// horizontal mode is not supported in mixed series chart
|
||||
1,
|
||||
richTooltip,
|
||||
tooltipSortByMetric,
|
||||
),
|
||||
);
|
||||
|
||||
const rows: string[][] = [];
|
||||
@@ -907,13 +912,19 @@ export default function transformProps(
|
||||
formatter: primarySeries.has(key)
|
||||
? tooltipFormatter
|
||||
: tooltipFormatterSecondary,
|
||||
truncation: tooltipTruncation,
|
||||
});
|
||||
rows.push(row);
|
||||
if (key === focusedSeries) {
|
||||
focusedRow = rows.length - 1;
|
||||
}
|
||||
});
|
||||
return tooltipHtml(rows, tooltipFormatter(xValue), focusedRow);
|
||||
return tooltipHtml(
|
||||
rows,
|
||||
truncateLabel(tooltipFormatter(xValue), tooltipTruncation),
|
||||
focusedRow,
|
||||
tooltipTruncation,
|
||||
);
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
ContributionType,
|
||||
TimeFormatter,
|
||||
AxisType,
|
||||
TooltipTruncationMode,
|
||||
} from '@superset-ui/core';
|
||||
import {
|
||||
BaseChartProps,
|
||||
@@ -59,6 +60,7 @@ export type EchartsMixedTimeseriesFormData = QueryFormData & {
|
||||
timeGrainSqla?: TimeGranularity;
|
||||
forceMaxInterval?: boolean;
|
||||
tooltipTimeFormat?: string;
|
||||
tooltipTruncation?: TooltipTruncationMode;
|
||||
zoomable: boolean;
|
||||
richTooltip: boolean;
|
||||
showQueryIdentifiers?: boolean;
|
||||
@@ -108,6 +110,7 @@ export const DEFAULT_FORM_DATA: EchartsMixedTimeseriesFormData = {
|
||||
yAxisFormatSecondary: TIMESERIES_DEFAULTS.yAxisFormat,
|
||||
yAxisTitleSecondary: DEFAULT_TITLE_FORM_DATA.yAxisTitle,
|
||||
tooltipTimeFormat: TIMESERIES_DEFAULTS.tooltipTimeFormat,
|
||||
tooltipTruncation: TIMESERIES_DEFAULTS.tooltipTruncation,
|
||||
xAxisBounds: TIMESERIES_DEFAULTS.xAxisBounds,
|
||||
xAxisForceCategorical: TIMESERIES_DEFAULTS.xAxisForceCategorical,
|
||||
xAxisTimeFormat: TIMESERIES_DEFAULTS.xAxisTimeFormat,
|
||||
|
||||
@@ -73,6 +73,7 @@ export const DEFAULT_FORM_DATA: EchartsTimeseriesFormData = {
|
||||
seriesType: EchartsTimeseriesSeriesType.Line,
|
||||
stack: false,
|
||||
tooltipTimeFormat: 'smart_date',
|
||||
tooltipTruncation: 'end',
|
||||
xAxisTimeFormat: 'smart_date',
|
||||
xAxisNumberFormat: 'SMART_NUMBER',
|
||||
truncateXAxis: true,
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
DTTM_ALIAS,
|
||||
ensureIsArray,
|
||||
tooltipHtml,
|
||||
truncateLabel,
|
||||
getCustomFormatter,
|
||||
getMetricLabel,
|
||||
getNumberFormatter,
|
||||
@@ -94,6 +95,7 @@ import {
|
||||
getAnnotationData,
|
||||
} from '../utils/annotation';
|
||||
import {
|
||||
collapseForecastKeys,
|
||||
extractForecastSeriesContext,
|
||||
extractForecastSeriesContexts,
|
||||
extractForecastValuesFromTooltipParams,
|
||||
@@ -303,6 +305,7 @@ export default function transformProps(
|
||||
tooltipSortByMetric,
|
||||
showTooltipTotal,
|
||||
showTooltipPercentage,
|
||||
tooltipTruncation,
|
||||
truncateXAxis,
|
||||
truncateYAxis,
|
||||
xAxis: xAxisOrig,
|
||||
@@ -1390,11 +1393,13 @@ export default function transformProps(
|
||||
const forecastValue: CallbackDataParams[] = richTooltip
|
||||
? params
|
||||
: [params];
|
||||
const sortedKeys = extractTooltipKeys(
|
||||
forecastValue,
|
||||
yIndex,
|
||||
richTooltip,
|
||||
tooltipSortByMetric,
|
||||
const sortedKeys = collapseForecastKeys(
|
||||
extractTooltipKeys(
|
||||
forecastValue,
|
||||
yIndex,
|
||||
richTooltip,
|
||||
tooltipSortByMetric,
|
||||
),
|
||||
);
|
||||
const filteredForecastValue = forecastValue.filter(
|
||||
(item: CallbackDataParams) =>
|
||||
@@ -1449,6 +1454,7 @@ export default function transformProps(
|
||||
seriesName: key,
|
||||
formatter,
|
||||
marker,
|
||||
truncation: tooltipTruncation,
|
||||
});
|
||||
|
||||
const annotationRow = annotationLayers.some(
|
||||
@@ -1482,7 +1488,12 @@ export default function transformProps(
|
||||
}
|
||||
rows.push(totalRow);
|
||||
}
|
||||
return tooltipHtml(rows, tooltipFormatter(xValue), focusedRow);
|
||||
return tooltipHtml(
|
||||
rows,
|
||||
truncateLabel(tooltipFormatter(xValue), tooltipTruncation),
|
||||
focusedRow,
|
||||
tooltipTruncation,
|
||||
);
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
|
||||
@@ -467,6 +467,14 @@ export function transformSeries(
|
||||
return formatter(numericValue);
|
||||
}
|
||||
if (!onlyTotal) {
|
||||
// A stacked segment with no height begins and ends at the same
|
||||
// coordinate as the top of the segment beneath it, so its label is
|
||||
// drawn over that segment's label. Zero and null have no height, so
|
||||
// they carry no label. The rich tooltip omits zero observations from
|
||||
// a stacked series for the same reason.
|
||||
if (stack && !numericValue) {
|
||||
return '';
|
||||
}
|
||||
if (
|
||||
numericValue >=
|
||||
(thresholdValues[dataIndex] || Number.MIN_SAFE_INTEGER)
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
QueryFormMetric,
|
||||
TimeFormatter,
|
||||
TimeGranularity,
|
||||
TooltipTruncationMode,
|
||||
} from '@superset-ui/core';
|
||||
import {
|
||||
BaseChartProps,
|
||||
@@ -82,6 +83,7 @@ export type EchartsTimeseriesFormData = QueryFormData & {
|
||||
tooltipTimeFormat?: string;
|
||||
showTooltipTotal?: boolean;
|
||||
showTooltipPercentage?: boolean;
|
||||
tooltipTruncation?: TooltipTruncationMode;
|
||||
truncateXAxis: boolean;
|
||||
truncateYAxis: boolean;
|
||||
yAxisFormat?: string;
|
||||
|
||||
@@ -16,9 +16,13 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, waitFor } from '../../../../spec/helpers/testing-library';
|
||||
import type { EChartsCoreOption } from 'echarts/core';
|
||||
import Echart, { isReportScreenshotMode } from './Echart';
|
||||
import { render, waitFor } from '../../../../spec/helpers/testing-library';
|
||||
import Echart, {
|
||||
ECHARTS_HOST_CLASS,
|
||||
ECHARTS_RENDER_FINISHED_CLASS,
|
||||
isReportScreenshotMode,
|
||||
} from './Echart';
|
||||
import type { EchartsProps } from '../types';
|
||||
|
||||
type Handler = (params: unknown) => void;
|
||||
@@ -272,3 +276,31 @@ test('keeps animation enabled when not in report screenshot mode', async () => {
|
||||
const lastOptions = mockChart.setOption.mock.calls.at(-1)?.[0];
|
||||
expect(lastOptions.animation).not.toBe(false);
|
||||
});
|
||||
|
||||
test('tags the ECharts canvas host with the readiness-gate class', async () => {
|
||||
const { container } = render(renderEchart(), {
|
||||
initialState,
|
||||
useRedux: true,
|
||||
});
|
||||
await waitFor(() => expect(mockChart.setOption).toHaveBeenCalled());
|
||||
expect(container.querySelector(`.${ECHARTS_HOST_CLASS}`)).not.toBeNull();
|
||||
});
|
||||
|
||||
test('marks the host painted only on the ECharts `finished` event', async () => {
|
||||
const { container } = render(renderEchart(), {
|
||||
initialState,
|
||||
useRedux: true,
|
||||
});
|
||||
await waitFor(() => expect(mockChart.setOption).toHaveBeenCalled());
|
||||
|
||||
const host = container.querySelector(`.${ECHARTS_HOST_CLASS}`) as HTMLElement;
|
||||
expect(host).not.toBeNull();
|
||||
|
||||
// `setOption` ran during mount, which clears the marker; `finished` has not
|
||||
// fired yet, so the host must NOT be flagged as painted.
|
||||
expect(host).not.toHaveClass(ECHARTS_RENDER_FINISHED_CLASS);
|
||||
|
||||
// Simulate ECharts completing its draw -> the host is flagged painted.
|
||||
trigger('finished');
|
||||
expect(host).toHaveClass(ECHARTS_RENDER_FINISHED_CLASS);
|
||||
});
|
||||
|
||||
@@ -138,6 +138,15 @@ export function isReportScreenshotMode(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// Report-screenshot readiness contract (see superset/utils/screenshot_utils.py).
|
||||
// `echarts-host` marks the canvas host element; `echarts-render-finished` is
|
||||
// toggled OFF before each setOption and ON in the ECharts `finished` event --
|
||||
// the only signal that the canvas is fully painted (chartStatus/onRenderSuccess
|
||||
// both fire pre-paint). The readiness gate treats a host that lacks
|
||||
// `echarts-render-finished` as not-yet-painted so it never captures a blank chart.
|
||||
export const ECHARTS_HOST_CLASS = 'echarts-host';
|
||||
export const ECHARTS_RENDER_FINISHED_CLASS = 'echarts-render-finished';
|
||||
|
||||
function Echart(
|
||||
{
|
||||
width,
|
||||
@@ -201,6 +210,11 @@ function Echart(
|
||||
width,
|
||||
height,
|
||||
});
|
||||
// Paint marker for the report-screenshot readiness gate. `finished`
|
||||
// is the only event that guarantees the canvas is fully drawn.
|
||||
chartRef.current.on('finished', () => {
|
||||
divRef.current?.classList.add(ECHARTS_RENDER_FINISHED_CLASS);
|
||||
});
|
||||
}
|
||||
// did mount
|
||||
handleSizeChange({ width, height });
|
||||
@@ -321,6 +335,9 @@ function Echart(
|
||||
}
|
||||
)?.dataZoom
|
||||
: undefined;
|
||||
// Clear the paint marker before (re)drawing; the `finished` handler
|
||||
// re-adds it once the new frame is fully rendered.
|
||||
divRef.current?.classList.remove(ECHARTS_RENDER_FINISHED_CLASS);
|
||||
chartRef.current?.setOption(themedEchartOptions, {
|
||||
notMerge,
|
||||
replaceMerge: notMerge ? undefined : ['series'],
|
||||
@@ -412,7 +429,14 @@ function Echart(
|
||||
handleSizeChange({ width, height });
|
||||
}, [width, height, handleSizeChange]);
|
||||
|
||||
return <Styles ref={divRef} height={height} width={width} />;
|
||||
return (
|
||||
<Styles
|
||||
ref={divRef}
|
||||
className={ECHARTS_HOST_CLASS}
|
||||
height={height}
|
||||
width={width}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(Echart);
|
||||
|
||||
@@ -315,6 +315,27 @@ const tooltipPercentageControl: ControlSetItem = {
|
||||
},
|
||||
};
|
||||
|
||||
const tooltipTruncationControl: ControlSetItem = {
|
||||
name: 'tooltipTruncation',
|
||||
config: {
|
||||
type: 'SelectControl',
|
||||
freeForm: false,
|
||||
label: t('Truncate labels'),
|
||||
renderTrigger: true,
|
||||
default: 'end',
|
||||
clearable: false,
|
||||
choices: [
|
||||
['off', t('Off')],
|
||||
['end', t('End')],
|
||||
['start', t('Start')],
|
||||
['middle', t('Middle')],
|
||||
],
|
||||
description: t(
|
||||
'Where to place the ellipsis when a tooltip label is too long. Choose Off to always show the full label, or Start when labels share a common prefix.',
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const richTooltipSection: ControlSetRow[] = [
|
||||
[<ControlSubSectionHeader>{t('Tooltip')}</ControlSubSectionHeader>],
|
||||
[richTooltipControl],
|
||||
@@ -322,6 +343,7 @@ export const richTooltipSection: ControlSetRow[] = [
|
||||
[tooltipPercentageControl],
|
||||
[tooltipSortByMetricControl],
|
||||
[tooltipTimeFormatControl],
|
||||
[tooltipTruncationControl],
|
||||
];
|
||||
|
||||
const sortSeriesType: ControlSetItem = {
|
||||
|
||||
@@ -16,7 +16,13 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { DataRecord, DTTM_ALIAS, ValueFormatter } from '@superset-ui/core';
|
||||
import {
|
||||
DataRecord,
|
||||
DTTM_ALIAS,
|
||||
truncateLabel,
|
||||
TooltipTruncationMode,
|
||||
ValueFormatter,
|
||||
} from '@superset-ui/core';
|
||||
import type { OptionName, SeriesOption } from 'echarts/types/src/util/types';
|
||||
import type { TooltipMarker } from 'echarts/types/src/util/format';
|
||||
import {
|
||||
@@ -54,6 +60,21 @@ export const extractForecastSeriesContexts = (
|
||||
{} as { [key: string]: ForecastSeriesEnum[] },
|
||||
);
|
||||
|
||||
/**
|
||||
* Collapses raw ECharts series ids onto the names used to key tooltip rows.
|
||||
*
|
||||
* Tooltip values are grouped by forecast-stripped name, so any ordering derived
|
||||
* from the raw series ids has to be expressed in the same terms before it can be
|
||||
* matched against them. This matters beyond real Prophet output: a metric simply
|
||||
* labelled `ci__yhat_lower` collapses to `ci` exactly like a forecast bound
|
||||
* does, and a chart whose every series carries such a suffix has no id that
|
||||
* survives the comparison untouched.
|
||||
*/
|
||||
export const collapseForecastKeys = (seriesIds: string[]): string[] =>
|
||||
Array.from(
|
||||
new Set(seriesIds.map(id => extractForecastSeriesContext(id).name)),
|
||||
);
|
||||
|
||||
export const extractForecastValuesFromTooltipParams = (
|
||||
params: any[],
|
||||
isHorizontal = false,
|
||||
@@ -91,12 +112,16 @@ export const formatForecastTooltipSeries = ({
|
||||
forecastUpper,
|
||||
marker,
|
||||
formatter,
|
||||
truncation = 'end',
|
||||
}: ForecastValue & {
|
||||
seriesName: string;
|
||||
marker: TooltipMarker;
|
||||
formatter: ValueFormatter;
|
||||
truncation?: TooltipTruncationMode;
|
||||
}): string[] => {
|
||||
const name = `${marker}${sanitizeHtml(seriesName)}`;
|
||||
// Truncate before sanitizing and before the marker is prepended: slicing a
|
||||
// string that already contains markup would cut into the marker's tag.
|
||||
const name = `${marker}${sanitizeHtml(truncateLabel(seriesName, truncation))}`;
|
||||
let value = typeof observation === 'number' ? formatter(observation) : '';
|
||||
// Use finite-number checks rather than truthiness so that legitimate
|
||||
// zero values (e.g. a forecast that crosses zero, or a confidence bound of
|
||||
|
||||
+57
@@ -27,6 +27,7 @@ import {
|
||||
VizType,
|
||||
ChartDataResponseResult,
|
||||
TimeGranularity,
|
||||
TooltipTruncationMode,
|
||||
} from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import {
|
||||
@@ -1295,3 +1296,59 @@ test('y-axis title position: non-Left sets nameLocation to end', () => {
|
||||
expect(yAxis[1].nameGap).toEqual(30);
|
||||
expect(yAxis[1].nameLocation).toEqual('end');
|
||||
});
|
||||
describe('EchartsMixedTimeseries tooltip truncation', () => {
|
||||
const longSeriesName = 'prod-us-east-1-service-checkout-latency-p99';
|
||||
const marker = '<span style="background-color:#1f77b4;"></span>';
|
||||
|
||||
const buildTooltip = (tooltipTruncation?: TooltipTruncationMode) => {
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsMixedTimeseriesFormData,
|
||||
EchartsMixedTimeseriesProps
|
||||
>({
|
||||
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
|
||||
defaultQueriesData: queriesData,
|
||||
formData: {
|
||||
...formData,
|
||||
...(tooltipTruncation ? { tooltipTruncation } : {}),
|
||||
},
|
||||
queriesData,
|
||||
});
|
||||
const { echartOptions } = transformProps(chartProps);
|
||||
const { formatter } = echartOptions.tooltip as {
|
||||
formatter: (params: unknown) => string;
|
||||
};
|
||||
// richTooltip is false in this fixture, so the trigger is 'item' and the
|
||||
// formatter receives a single param object rather than an array.
|
||||
return formatter({
|
||||
seriesId: longSeriesName,
|
||||
seriesName: longSeriesName,
|
||||
value: [599616000000, 1],
|
||||
marker,
|
||||
});
|
||||
};
|
||||
|
||||
test('keeps full text with the CSS cap by default', () => {
|
||||
const html = buildTooltip();
|
||||
expect(html.replace(/\s/g, '')).toContain('max-width:300px');
|
||||
expect(html).toContain(longSeriesName);
|
||||
});
|
||||
|
||||
test('removes the cap and keeps full text when off', () => {
|
||||
const html = buildTooltip('off');
|
||||
expect(html).not.toContain('max-width');
|
||||
expect(html).toContain(longSeriesName);
|
||||
});
|
||||
|
||||
test('drops the shared prefix when truncating from the start', () => {
|
||||
const html = buildTooltip('start');
|
||||
expect(html).not.toContain('prod-us-east');
|
||||
expect(html).toContain('latency-p99');
|
||||
expect(html).toContain('background-color:#1f77b4');
|
||||
});
|
||||
|
||||
test('keeps both ends when truncating the middle', () => {
|
||||
const html = buildTooltip('middle');
|
||||
expect(html).toContain('prod-us-east-1-servi…heckout-latency-p99');
|
||||
expect(html).not.toContain(longSeriesName);
|
||||
});
|
||||
});
|
||||
|
||||
+153
@@ -32,6 +32,7 @@ import {
|
||||
TimeseriesAnnotationLayer,
|
||||
ChartDataResponseResult,
|
||||
TimeGranularity,
|
||||
TooltipTruncationMode,
|
||||
} from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { supersetTheme } from '@apache-superset/core/theme';
|
||||
@@ -2437,3 +2438,155 @@ test('honors the snake_case flag the compare-chart migration stores in params',
|
||||
[BASE_TIMESTAMP + 300000000, 2],
|
||||
]);
|
||||
});
|
||||
describe('EchartsTimeseries tooltip truncation', () => {
|
||||
const longSeriesName = 'prod-us-east-1-service-checkout-latency-p99';
|
||||
const marker = '<span style="background-color:#1f77b4;"></span>';
|
||||
|
||||
const buildTooltip = (
|
||||
tooltipTruncation?: TooltipTruncationMode,
|
||||
xValue: string | number = 599616000000,
|
||||
) => {
|
||||
const chartProps = new ChartProps({
|
||||
formData: {
|
||||
colorScheme: 'bnbColors',
|
||||
datasource: '3__table',
|
||||
granularity_sqla: 'ds',
|
||||
metric: 'sum__num',
|
||||
groupby: ['foo'],
|
||||
viz_type: 'my_viz',
|
||||
...(tooltipTruncation ? { tooltipTruncation } : {}),
|
||||
} as SqlaFormData,
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData: [
|
||||
{
|
||||
data: [
|
||||
{ [longSeriesName]: 1, __timestamp: 599616000000 },
|
||||
{ [longSeriesName]: 3, __timestamp: 599916000000 },
|
||||
],
|
||||
},
|
||||
],
|
||||
theme: supersetTheme,
|
||||
});
|
||||
const { echartOptions } = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
const { formatter } = echartOptions.tooltip as {
|
||||
formatter: (params: unknown) => string;
|
||||
};
|
||||
return formatter([
|
||||
{
|
||||
seriesId: longSeriesName,
|
||||
seriesName: longSeriesName,
|
||||
value: [xValue, 1],
|
||||
marker,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
test('applies the CSS cap and keeps full text by default', () => {
|
||||
const html = buildTooltip();
|
||||
expect(html).toContain(longSeriesName);
|
||||
// sanitizeHtml normalizes spacing inside style attributes, so compare with
|
||||
// whitespace stripped rather than hard-coding one version's formatting.
|
||||
expect(html.replace(/\s/g, '')).toContain('max-width:300px');
|
||||
});
|
||||
|
||||
test('removes the cap and keeps full text when off', () => {
|
||||
const html = buildTooltip('off');
|
||||
expect(html).not.toContain('max-width');
|
||||
expect(html).toContain(longSeriesName);
|
||||
});
|
||||
|
||||
test('drops the shared prefix when truncating from the start', () => {
|
||||
const html = buildTooltip('start');
|
||||
expect(html).not.toContain('prod-us-east');
|
||||
expect(html).toContain('latency-p99');
|
||||
expect(html.replace(/\s/g, '')).toContain('white-space:nowrap');
|
||||
});
|
||||
|
||||
test('keeps both ends when truncating the middle', () => {
|
||||
const html = buildTooltip('middle');
|
||||
expect(html).toContain('prod-us-east-1-servi…heckout-latency-p99');
|
||||
expect(html).not.toContain(longSeriesName);
|
||||
});
|
||||
|
||||
test('preserves the echarts marker in every mode', () => {
|
||||
(['off', 'end', 'start', 'middle'] as const).forEach(mode => {
|
||||
expect(buildTooltip(mode)).toContain('background-color:#1f77b4');
|
||||
});
|
||||
});
|
||||
|
||||
test('truncates a long non-temporal x-axis title', () => {
|
||||
const longCategory = 'prod-us-east-1-service-checkout-cohort-2026';
|
||||
const html = buildTooltip('start', longCategory);
|
||||
expect(html).not.toContain(longCategory);
|
||||
expect(html).toContain('cohort-2026');
|
||||
});
|
||||
|
||||
test('leaves a long title alone in the default mode', () => {
|
||||
const longCategory = 'prod-us-east-1-service-checkout-cohort-2026';
|
||||
expect(buildTooltip(undefined, longCategory)).toContain(longCategory);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tooltip for metrics whose labels end in forecast suffixes', () => {
|
||||
const marker = '<span style="background-color:#1f77b4;"></span>';
|
||||
const seriesIds = ['ci__yhat', 'ci__yhat_lower', 'ci__yhat_upper'];
|
||||
const values = [1.5, 0.5, 2.0];
|
||||
|
||||
// Metrics can be labelled `ci__yhat*` with no forecast enabled and no plain
|
||||
// observation series. Every series then collapses onto the same
|
||||
// forecast-stripped tooltip key, so no raw series id matches itself.
|
||||
const buildTooltip = (tooltipSortByMetric = false) => {
|
||||
const chartProps = createTestChartProps({
|
||||
formData: {
|
||||
x_axis: 'dt',
|
||||
metrics: seriesIds,
|
||||
groupby: [],
|
||||
richTooltip: true,
|
||||
tooltipSortByMetric,
|
||||
} as Partial<EchartsTimeseriesFormData>,
|
||||
queriesData: [
|
||||
createTestQueryData([
|
||||
{
|
||||
dt: 599616000000,
|
||||
ci__yhat: 1.5,
|
||||
ci__yhat_lower: 0.5,
|
||||
ci__yhat_upper: 2.5,
|
||||
},
|
||||
]),
|
||||
],
|
||||
});
|
||||
const tooltipFormatter = (transformProps(chartProps).echartOptions as any)
|
||||
.tooltip.formatter;
|
||||
return tooltipFormatter(
|
||||
seriesIds.map((id, i) => ({
|
||||
seriesId: id,
|
||||
seriesName: id,
|
||||
value: [599616000000, values[i]],
|
||||
data: [599616000000, values[i]],
|
||||
marker,
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
test('renders the collapsed series rather than falling back to "No data"', () => {
|
||||
const html = buildTooltip();
|
||||
expect(html).not.toContain('No data');
|
||||
expect(html).toContain('>ci<');
|
||||
expect(html).toContain('ŷ = 1.5 (0.5, 2.5)');
|
||||
});
|
||||
|
||||
test('renders a single row rather than one per forecast suffix', () => {
|
||||
const html = buildTooltip();
|
||||
expect(html.match(/<tr/g)).toHaveLength(1);
|
||||
expect(html).toContain('>ci<');
|
||||
});
|
||||
|
||||
test('still renders the row when the tooltip is sorted by metric', () => {
|
||||
const html = buildTooltip(true);
|
||||
expect(html).not.toContain('No data');
|
||||
expect(html).toContain('>ci<');
|
||||
});
|
||||
});
|
||||
|
||||
+69
-1
@@ -20,13 +20,14 @@ import {
|
||||
CategoricalColorScale,
|
||||
ChartProps,
|
||||
TimeGranularity,
|
||||
getNumberFormatter,
|
||||
} from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { supersetTheme } from '@apache-superset/core/theme';
|
||||
import type { SeriesOption } from 'echarts';
|
||||
import type { ScatterSeriesOption } from 'echarts/charts';
|
||||
import { EchartsTimeseriesSeriesType } from '../../src';
|
||||
import { TIMESERIES_CONSTANTS } from '../../src/constants';
|
||||
import { StackControlsValue, TIMESERIES_CONSTANTS } from '../../src/constants';
|
||||
import {
|
||||
LegendOrientation,
|
||||
EchartsTimeseriesChartProps,
|
||||
@@ -566,3 +567,70 @@ test('getPadding should handle Left position with zero margin correctly', () =>
|
||||
getChartPaddingSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* #42702: a stacked segment with no height starts and ends at the same
|
||||
* coordinate as the top of the segment beneath it, so a value label on it is
|
||||
* drawn over that segment's label. `percentage_threshold` does not filter these
|
||||
* out: it defaults to 0, and `thresholdValues[dataIndex] || MIN_SAFE_INTEGER`
|
||||
* turns a 0 threshold into "no filtering", which is intentional.
|
||||
*/
|
||||
const stackedLabel = (
|
||||
numericValue: number | null,
|
||||
opts: Record<string, unknown> = {},
|
||||
) => {
|
||||
const series = transformSeries(
|
||||
{ id: 'B', name: 'B', data: [[1, numericValue]] } as SeriesOption,
|
||||
mockColorScale,
|
||||
'B',
|
||||
{
|
||||
seriesType: EchartsTimeseriesSeriesType.Bar,
|
||||
stack: StackControlsValue.Stack,
|
||||
showValue: true,
|
||||
onlyTotal: false,
|
||||
formatter: getNumberFormatter(),
|
||||
thresholdValues: [0],
|
||||
...opts,
|
||||
},
|
||||
) as SeriesOption & {
|
||||
label: { formatter: (params: unknown) => string };
|
||||
};
|
||||
return series.label.formatter({
|
||||
value: [1, numericValue],
|
||||
dataIndex: 0,
|
||||
seriesIndex: 1,
|
||||
seriesName: 'B',
|
||||
});
|
||||
};
|
||||
|
||||
test('stacked value labels are omitted for a zero-height segment', () => {
|
||||
expect(stackedLabel(0)).toBe('');
|
||||
expect(stackedLabel(null)).toBe('');
|
||||
});
|
||||
|
||||
test('stacked value labels are kept for segments that have height', () => {
|
||||
expect(stackedLabel(32)).toBe('32');
|
||||
expect(stackedLabel(-5)).toBe('-5');
|
||||
});
|
||||
|
||||
test('a zero value keeps its label when the series is not stacked', () => {
|
||||
// Without a stack the label sits on the bar itself, so there is nothing for
|
||||
// it to collide with.
|
||||
expect(stackedLabel(0, { stack: undefined })).toBe('0');
|
||||
});
|
||||
|
||||
test('percentage_threshold still filters values below the threshold', () => {
|
||||
// 10% of a 100 total. The zero-height guard must not swallow this rule.
|
||||
expect(stackedLabel(5, { thresholdValues: [10] })).toBe('');
|
||||
expect(stackedLabel(50, { thresholdValues: [10] })).toBe('50');
|
||||
});
|
||||
|
||||
test('only-total labels are unaffected by the zero-height guard', () => {
|
||||
expect(
|
||||
stackedLabel(0, {
|
||||
onlyTotal: true,
|
||||
showValueIndexes: [1],
|
||||
totalStackedValues: [32],
|
||||
}),
|
||||
).toBe('32');
|
||||
});
|
||||
|
||||
@@ -16,9 +16,14 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { getNumberFormatter, NumberFormats } from '@superset-ui/core';
|
||||
import {
|
||||
getNumberFormatter,
|
||||
NumberFormats,
|
||||
TRUNCATION_MAX_CHARS,
|
||||
} from '@superset-ui/core';
|
||||
import { SeriesOption } from 'echarts';
|
||||
import {
|
||||
collapseForecastKeys,
|
||||
extractForecastSeriesContext,
|
||||
extractForecastValuesFromTooltipParams,
|
||||
formatForecastTooltipSeries,
|
||||
@@ -411,3 +416,84 @@ test('formatForecastTooltipSeries should skip non-finite forecast values', () =>
|
||||
}),
|
||||
).toEqual(['<img>qwerty', '10']);
|
||||
});
|
||||
|
||||
describe('formatForecastTooltipSeries truncation', () => {
|
||||
const marker =
|
||||
'<span style="display:inline-block;width:10px;height:10px;background-color:#1f77b4;"></span>';
|
||||
const longName = 'prod-us-east-1-service-checkout-latency-p99'; // 43 chars
|
||||
const intFormatter = getNumberFormatter(NumberFormats.INTEGER);
|
||||
|
||||
const format = (truncation?: 'off' | 'end' | 'start' | 'middle') =>
|
||||
formatForecastTooltipSeries({
|
||||
seriesName: longName,
|
||||
observation: 1,
|
||||
marker,
|
||||
formatter: intFormatter,
|
||||
...(truncation ? { truncation } : {}),
|
||||
})[0];
|
||||
|
||||
test('leaves the name intact by default and for off/end', () => {
|
||||
expect(format()).toContain(longName);
|
||||
expect(format('off')).toContain(longName);
|
||||
expect(format('end')).toContain(longName);
|
||||
});
|
||||
|
||||
test('slices the start of the name without harming the marker', () => {
|
||||
const cell = format('start');
|
||||
expect(cell).toContain(marker);
|
||||
expect(cell).toContain('…-us-east-1-service-checkout-latency-p99');
|
||||
expect(cell).not.toContain('prod-us-east');
|
||||
});
|
||||
|
||||
test('slices the middle of the name without harming the marker', () => {
|
||||
const cell = format('middle');
|
||||
expect(cell).toContain(marker);
|
||||
expect(cell).toContain('prod-us-east-1-servi…heckout-latency-p99');
|
||||
});
|
||||
|
||||
test('measures the budget against the name, not the marker markup', () => {
|
||||
// The marker alone is far longer than the budget. If truncation were
|
||||
// applied to the concatenated cell, a short name would be mangled.
|
||||
expect(marker.length).toBeGreaterThan(TRUNCATION_MAX_CHARS);
|
||||
const [cell] = formatForecastTooltipSeries({
|
||||
seriesName: 'cpu',
|
||||
observation: 1,
|
||||
marker,
|
||||
formatter: intFormatter,
|
||||
truncation: 'start',
|
||||
});
|
||||
expect(cell).toBe(`${marker}cpu`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collapseForecastKeys', () => {
|
||||
test('leaves plain observation series untouched and in order', () => {
|
||||
expect(collapseForecastKeys(['foo', 'bar'])).toEqual(['foo', 'bar']);
|
||||
});
|
||||
|
||||
test('folds a forecast bundle down to a single key', () => {
|
||||
expect(
|
||||
collapseForecastKeys([
|
||||
'foo',
|
||||
'foo__yhat',
|
||||
'foo__yhat_lower',
|
||||
'foo__yhat_upper',
|
||||
]),
|
||||
).toEqual(['foo']);
|
||||
});
|
||||
|
||||
test('keeps a key for metrics whose labels are entirely forecast suffixes', () => {
|
||||
// Charts can carry metrics literally labelled `ci__yhat*` with no plain
|
||||
// observation series. Callers match these against forecast-stripped keys,
|
||||
// so an uncollapsed id here would match nothing and drop every row.
|
||||
expect(
|
||||
collapseForecastKeys(['ci__yhat', 'ci__yhat_lower', 'ci__yhat_upper']),
|
||||
).toEqual(['ci']);
|
||||
});
|
||||
|
||||
test('preserves the incoming order of distinct series', () => {
|
||||
expect(
|
||||
collapseForecastKeys(['b__yhat_lower', 'a__yhat', 'b__yhat']),
|
||||
).toEqual(['b', 'a']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -290,6 +290,25 @@ test('isAdditiveMetric: non-additive aggregates, SQL, and saved metrics are not
|
||||
expect(isAdditiveMetric('count')).toBe(false);
|
||||
});
|
||||
|
||||
test('isAdditiveMetric: MEDIAN/STDDEV_SAMP/VAR_SAMP are non-additive, with no dedicated code needed', () => {
|
||||
// Regression guard: MEDIAN/STDDEV_SAMP/VAR_SAMP are new system-wide metric
|
||||
// aggregates (not pivot-table-specific). They must fall outside
|
||||
// ADDITIVE_AGGREGATES so totals/subtotals route through the correct
|
||||
// DB-rollup path automatically, same as AVG/COUNT_DISTINCT already do --
|
||||
// averaging per-group medians (or variances) is exactly the class of bug
|
||||
// SIP-216 fixed for AVG, and would be equally wrong here.
|
||||
(['MEDIAN', 'STDDEV_SAMP', 'VAR_SAMP'] as const).forEach(aggregate => {
|
||||
expect(
|
||||
isAdditiveMetric({
|
||||
expressionType: 'SIMPLE',
|
||||
aggregate,
|
||||
column: { column_name: 'num' },
|
||||
label: `${aggregate.toLowerCase()}_num`,
|
||||
} as QueryFormMetric),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test('allMetricsAdditive: all additive vs any non-additive vs empty', () => {
|
||||
const sum = {
|
||||
expressionType: 'SIMPLE',
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"mapbox-gl": "^3.28.1",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"react-map-gl": "^8.1.2",
|
||||
"supercluster": "^8.0.1"
|
||||
"supercluster": "^9.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
|
||||
@@ -164,7 +164,7 @@ export async function selectOption(option: string, selectName?: string) {
|
||||
const item = await waitFor(() =>
|
||||
within(
|
||||
// eslint-disable-next-line testing-library/no-node-access
|
||||
document.querySelector('.rc-virtual-list')!,
|
||||
document.querySelector('.ant-select-dropdown-list')!,
|
||||
).getByText(option),
|
||||
);
|
||||
await userEvent.click(item);
|
||||
|
||||
@@ -390,7 +390,7 @@ const ResultSet = ({
|
||||
// provides.
|
||||
redirect(getExportCsvUrl(query.id));
|
||||
},
|
||||
confirmText: t('OK'),
|
||||
confirmText: t('Confirm'),
|
||||
cancelText: t('Close'),
|
||||
});
|
||||
}
|
||||
|
||||
+25
-1
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import { render, screen, userEvent } from 'spec/helpers/testing-library';
|
||||
import SaveDatasetActionButton from 'src/SqlLab/components/SaveDatasetActionButton';
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
@@ -27,6 +27,7 @@ describe('SaveDatasetActionButton', () => {
|
||||
<SaveDatasetActionButton
|
||||
setShowSave={() => true}
|
||||
onSaveAsExplore={onSaveAsExplore}
|
||||
canSaveDataset
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -41,4 +42,27 @@ describe('SaveDatasetActionButton', () => {
|
||||
expect(saveBtn).toBeVisible();
|
||||
expect(saveDatasetBtn).toBeVisible();
|
||||
});
|
||||
|
||||
test('disables the save dataset button when the query did not run successfully', async () => {
|
||||
render(
|
||||
<SaveDatasetActionButton
|
||||
setShowSave={() => true}
|
||||
onSaveAsExplore={jest.fn()}
|
||||
canSaveDataset={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const saveDatasetBtn = screen.getByRole('button', {
|
||||
name: /save dataset/i,
|
||||
});
|
||||
expect(saveDatasetBtn).toBeDisabled();
|
||||
|
||||
// the disabled button is wrapped in a span so the tooltip still triggers
|
||||
userEvent.hover(saveDatasetBtn.parentElement as HTMLElement);
|
||||
expect(
|
||||
await screen.findByRole('tooltip', {
|
||||
name: 'You must run the query successfully first',
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,11 +23,13 @@ import { Button } from '@superset-ui/core/components';
|
||||
interface SaveDatasetActionButtonProps {
|
||||
setShowSave: (arg0: boolean) => void;
|
||||
onSaveAsExplore?: () => void;
|
||||
canSaveDataset: boolean;
|
||||
}
|
||||
|
||||
const SaveDatasetActionButton = ({
|
||||
setShowSave,
|
||||
onSaveAsExplore,
|
||||
canSaveDataset,
|
||||
}: SaveDatasetActionButtonProps) => (
|
||||
<>
|
||||
<Button
|
||||
@@ -43,8 +45,13 @@ const SaveDatasetActionButton = ({
|
||||
color="default"
|
||||
variant="text"
|
||||
onClick={() => onSaveAsExplore?.()}
|
||||
disabled={!canSaveDataset}
|
||||
icon={<Icons.TableOutlined />}
|
||||
tooltip={t('Save or Overwrite Dataset')}
|
||||
tooltip={
|
||||
canSaveDataset
|
||||
? t('Save or Overwrite Dataset')
|
||||
: t('You must run the query successfully first')
|
||||
}
|
||||
aria-label={t('Save dataset')}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -35,6 +35,7 @@ const mockedProps = {
|
||||
onSave: () => {},
|
||||
saveQueryWarning: null,
|
||||
columns: [],
|
||||
canSaveDataset: true,
|
||||
};
|
||||
|
||||
const mockState = {
|
||||
|
||||
@@ -52,6 +52,7 @@ interface SaveQueryProps {
|
||||
onUpdate: (arg0: QueryPayload, id: string) => void;
|
||||
saveQueryWarning: string | null;
|
||||
database: Partial<DatabaseObject> | undefined;
|
||||
canSaveDataset: boolean;
|
||||
}
|
||||
|
||||
export type QueryPayload = {
|
||||
@@ -81,6 +82,7 @@ const SaveQuery = ({
|
||||
saveQueryWarning,
|
||||
database,
|
||||
columns,
|
||||
canSaveDataset,
|
||||
}: SaveQueryProps) => {
|
||||
const queryEditor = useQueryEditor(queryEditorId, [
|
||||
'autorun',
|
||||
@@ -207,6 +209,7 @@ const SaveQuery = ({
|
||||
<SaveDatasetActionButton
|
||||
setShowSave={setShowSave}
|
||||
onSaveAsExplore={canExploreDatabase ? onSaveAsExplore : undefined}
|
||||
canSaveDataset={canSaveDataset}
|
||||
/>
|
||||
)}
|
||||
<SaveDatasetModal
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
isFeatureEnabled,
|
||||
getExtensionsRegistry,
|
||||
FeatureFlag,
|
||||
QueryState,
|
||||
} from '@superset-ui/core';
|
||||
import {
|
||||
act,
|
||||
@@ -334,6 +335,47 @@ describe('SqlEditor', () => {
|
||||
expect(await findByText('10 000')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const setupWithLatestQuery = (overrides: Partial<typeof latestQuery>) =>
|
||||
setup(
|
||||
mockedProps,
|
||||
createStore({
|
||||
...mockInitialState,
|
||||
sqlLab: {
|
||||
...mockInitialState.sqlLab,
|
||||
queries: {
|
||||
[latestQuery.id]: { ...latestQuery, ...overrides },
|
||||
},
|
||||
databases: {
|
||||
1991: {
|
||||
...mockInitialState.sqlLab.databases[1991],
|
||||
allows_virtual_table_explore: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
test('enables the save dataset button when the latest query succeeded', async () => {
|
||||
const { findByRole } = setupWithLatestQuery({ state: QueryState.Success });
|
||||
expect(await findByRole('button', { name: 'Save dataset' })).toBeEnabled();
|
||||
});
|
||||
|
||||
test('disables the save dataset button when the latest query failed', async () => {
|
||||
const { findByRole } = setupWithLatestQuery({
|
||||
state: QueryState.Failed,
|
||||
results: undefined,
|
||||
});
|
||||
expect(await findByRole('button', { name: 'Save dataset' })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('disables the save dataset button when the results are not loaded', async () => {
|
||||
const { findByRole } = setupWithLatestQuery({
|
||||
state: QueryState.Success,
|
||||
results: undefined,
|
||||
});
|
||||
expect(await findByRole('button', { name: 'Save dataset' })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('renders an Extension if provided', async () => {
|
||||
const extensionsRegistry = getExtensionsRegistry();
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
getExtensionsRegistry,
|
||||
QueryResponse,
|
||||
Query,
|
||||
QueryState,
|
||||
} from '@superset-ui/core';
|
||||
import { Alert } from '@apache-superset/core/components';
|
||||
import { css, styled, useTheme } from '@apache-superset/core/theme';
|
||||
@@ -295,6 +296,9 @@ const SqlEditor: FC<Props> = ({
|
||||
|
||||
const SqlFormExtension = extensionsRegistry.get('sqleditor.extension.form');
|
||||
|
||||
const successful = latestQuery?.state === QueryState.Success;
|
||||
const resultColumns = latestQuery?.results?.columns || [];
|
||||
|
||||
const startQuery = useCallback(
|
||||
(
|
||||
ctasArg = false,
|
||||
@@ -712,7 +716,6 @@ const SqlEditor: FC<Props> = ({
|
||||
|
||||
const getSecondaryMenuItems = () => {
|
||||
const qe = queryEditor;
|
||||
const successful = latestQuery?.state === 'success';
|
||||
const scheduleToolTip = successful
|
||||
? t('Schedule the query periodically')
|
||||
: t('You must run the query successfully first');
|
||||
@@ -858,13 +861,14 @@ const SqlEditor: FC<Props> = ({
|
||||
)}
|
||||
<SaveQuery
|
||||
queryEditorId={queryEditor.id}
|
||||
columns={latestQuery?.results?.columns || []}
|
||||
columns={resultColumns}
|
||||
onSave={onSaveQuery}
|
||||
onUpdate={(query, remoteId) =>
|
||||
dispatch(updateSavedQuery(query, remoteId))
|
||||
}
|
||||
saveQueryWarning={saveQueryWarning}
|
||||
database={database}
|
||||
canSaveDataset={successful && resultColumns.length > 0}
|
||||
/>
|
||||
<ShareSqlLabQuery queryEditorId={queryEditor.id} />
|
||||
</>
|
||||
|
||||
@@ -223,6 +223,11 @@ export default function chartReducer(
|
||||
}
|
||||
|
||||
if (action.type in actionHandlers) {
|
||||
// ADD_CHART creates the entry, so it runs without prior state; every other
|
||||
// handler reads state that is absent once the chart has been removed
|
||||
if (action.type !== actions.ADD_CHART && !charts[action.key]) {
|
||||
return charts;
|
||||
}
|
||||
return {
|
||||
...charts,
|
||||
[action.key]: actionHandlers[action.type](charts[action.key]),
|
||||
|
||||
@@ -91,4 +91,20 @@ describe('chart reducers', () => {
|
||||
expect(newState[chartKey].chartUpdateEndTime).toBeGreaterThan(0);
|
||||
expect(newState[chartKey].chartStatus).toEqual('failed');
|
||||
});
|
||||
|
||||
test('ignores an action for a chart that is no longer in state', () => {
|
||||
const action = actions.chartUpdateStopped(999, new AbortController());
|
||||
expect(() => chartReducer(charts, action)).not.toThrow();
|
||||
expect(chartReducer(charts, action)).toEqual(charts);
|
||||
});
|
||||
|
||||
test('still adds a chart that is not yet in state', () => {
|
||||
const newChartKey = 2;
|
||||
const newState = chartReducer(
|
||||
charts,
|
||||
actions.addChart({ ...chart, id: newChartKey }, newChartKey),
|
||||
);
|
||||
expect(newState[newChartKey].id).toEqual(newChartKey);
|
||||
expect(newState[chartKey]).toEqual(testChart);
|
||||
});
|
||||
});
|
||||
|
||||
+5
@@ -17,6 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import {
|
||||
cleanup,
|
||||
render,
|
||||
@@ -35,6 +36,10 @@ import { useDrillDetailMenuItems, DrillDetailMenuItemsProps } from './index';
|
||||
|
||||
/* eslint jest/expect-expect: ["warn", { "assertFunctionNames": ["expect*"] }] */
|
||||
|
||||
// Opening the context menu logs an event, and an unmatched request makes
|
||||
// fetch-mock throw inside the component.
|
||||
fetchMock.post('glob:*/log/?*', {});
|
||||
|
||||
jest.mock(
|
||||
'../DrillDetail/DrillDetailPane',
|
||||
() =>
|
||||
|
||||
+4
-4
@@ -120,7 +120,7 @@ describe('DatasourceModal', () => {
|
||||
});
|
||||
const saveButton = screen.getByTestId('datasource-modal-save');
|
||||
fireEvent.click(saveButton);
|
||||
const okButton = await screen.findByRole('button', { name: 'OK' });
|
||||
const okButton = await screen.findByRole('button', { name: 'Confirm' });
|
||||
fireEvent.click(okButton);
|
||||
await waitFor(() => {
|
||||
expect(onDatasourceSave).toHaveBeenCalled();
|
||||
@@ -142,7 +142,7 @@ describe('DatasourceModal', () => {
|
||||
|
||||
const saveButton = screen.getByTestId('datasource-modal-save');
|
||||
fireEvent.click(saveButton);
|
||||
const okButton = await screen.findByRole('button', { name: 'OK' });
|
||||
const okButton = await screen.findByRole('button', { name: 'Confirm' });
|
||||
fireEvent.click(okButton);
|
||||
|
||||
const errorElements = await screen.findAllByText('Error saving dataset');
|
||||
@@ -230,7 +230,7 @@ describe('DatasourceModal', () => {
|
||||
expect(checkbox).toBeChecked();
|
||||
|
||||
// Click OK to submit
|
||||
const okButton = screen.getByRole('button', { name: 'OK' });
|
||||
const okButton = screen.getByRole('button', { name: 'Confirm' });
|
||||
fireEvent.click(okButton);
|
||||
|
||||
// Verify the PUT request was made with override_columns=true
|
||||
@@ -297,7 +297,7 @@ describe('DatasourceModal', () => {
|
||||
expect(checkbox).not.toBeChecked();
|
||||
|
||||
// Click OK to submit
|
||||
const okButton = screen.getByRole('button', { name: 'OK' });
|
||||
const okButton = screen.getByRole('button', { name: 'Confirm' });
|
||||
fireEvent.click(okButton);
|
||||
|
||||
// Verify the PUT request was made with override_columns=false
|
||||
|
||||
@@ -395,7 +395,7 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
|
||||
show={confirmModalOpen}
|
||||
onHide={handleConfirmModalClose}
|
||||
onHandledPrimaryAction={handleConfirmSave}
|
||||
primaryButtonName={t('OK')}
|
||||
primaryButtonName={t('Confirm')}
|
||||
primaryButtonLoading={isSaving}
|
||||
>
|
||||
{getSaveDialog()}
|
||||
|
||||
+1
-3
@@ -1627,9 +1627,7 @@ function DatasourceEditor({
|
||||
{t(
|
||||
'Default URL to redirect to when accessing from the dataset list page. Accepts relative URLs such as',
|
||||
)}{' '}
|
||||
<Typography.Text code>
|
||||
/superset/dashboard/{'{id}'}/
|
||||
</Typography.Text>
|
||||
<Typography.Text code>/dashboard/{'{id}'}/</Typography.Text>
|
||||
</>
|
||||
}
|
||||
control={<TextControl controlId="default_endpoint" />}
|
||||
|
||||
+11
@@ -71,6 +71,17 @@ test('renders Tabs', async () => {
|
||||
expect(screen.getByTestId('edit-dataset-tabs')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('recommends a registered client route for the default URL', async () => {
|
||||
await asyncRender(createProps());
|
||||
|
||||
userEvent.click(screen.getByRole('tab', { name: 'Settings' }));
|
||||
|
||||
expect(await screen.findByText('/dashboard/{id}/')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('/superset/dashboard/{id}/'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('can sync columns from source', async () => {
|
||||
const testProps = createProps();
|
||||
await asyncRender({
|
||||
|
||||
@@ -135,6 +135,15 @@ describe('dashboardState actions', () => {
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('saveDashboardRequest', () => {
|
||||
const findDangerToast = (dispatch: jest.Mock) =>
|
||||
dispatch.mock.calls
|
||||
.map(call => call[0])
|
||||
.find(
|
||||
action =>
|
||||
action?.type === ADD_TOAST &&
|
||||
action.payload.toastType === ToastType.Danger,
|
||||
);
|
||||
|
||||
test('should dispatch UPDATE_COMPONENTS_PARENTS_LIST action', () => {
|
||||
const { getState, dispatch } = setup({
|
||||
dashboardState: { hasUnsavedChanges: false },
|
||||
@@ -227,6 +236,89 @@ describe('dashboardState actions', () => {
|
||||
const { body } = putStub.mock.calls[0][0];
|
||||
expect(body).toBe(JSON.stringify(confirmedDashboardData));
|
||||
});
|
||||
|
||||
test('warns about the overwrite values when a diff is detected', async () => {
|
||||
const { getState, dispatch } = setup();
|
||||
const thunk = saveDashboardRequest(
|
||||
newDashboardData,
|
||||
192,
|
||||
SAVE_TYPE_OVERWRITE,
|
||||
);
|
||||
thunk(dispatch, getState);
|
||||
await waitFor(() =>
|
||||
expect(findDangerToast(dispatch)?.payload.text).toBe(
|
||||
'Please confirm the overwrite values.',
|
||||
),
|
||||
);
|
||||
expect(putStub.mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
test('reports the actual error when the overwrite precheck fails', async () => {
|
||||
getStub.mockRestore();
|
||||
getStub = jest
|
||||
.spyOn(SupersetClient, 'get')
|
||||
.mockRejectedValue(new Error('precheck exploded'));
|
||||
const { getState, dispatch } = setup();
|
||||
const thunk = saveDashboardRequest(
|
||||
newDashboardData,
|
||||
192,
|
||||
SAVE_TYPE_OVERWRITE,
|
||||
);
|
||||
thunk(dispatch, getState);
|
||||
await waitFor(() =>
|
||||
expect(findDangerToast(dispatch)?.payload.text).toContain(
|
||||
'precheck exploded',
|
||||
),
|
||||
);
|
||||
expect(putStub.mock.calls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('when FeatureFlag.CONFIRM_DASHBOARD_DIFF is disabled', () => {
|
||||
beforeEach(() => {
|
||||
mockIsFeatureEnabled.mockImplementation(() => false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockIsFeatureEnabled.mockRestore();
|
||||
});
|
||||
|
||||
test('never runs the overwrite precheck', async () => {
|
||||
const { getState, dispatch } = setup();
|
||||
const thunk = saveDashboardRequest(
|
||||
newDashboardData,
|
||||
192,
|
||||
SAVE_TYPE_OVERWRITE,
|
||||
);
|
||||
thunk(dispatch, getState);
|
||||
await waitFor(() => expect(putStub.mock.calls.length).toBe(1));
|
||||
expect(getStub).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ endpoint: '/api/v1/dashboard/192' }),
|
||||
);
|
||||
});
|
||||
|
||||
// An unexpected failure used to reach the overwrite-confirm handler,
|
||||
// which reported it as "Please confirm the overwrite values." even with
|
||||
// the feature flag off, hiding the real error.
|
||||
test('reports the actual error when the update throws unexpectedly', async () => {
|
||||
putStub.mockRestore();
|
||||
putStub = jest.spyOn(SupersetClient, 'put').mockImplementation(() => {
|
||||
throw new Error('unexpected boom');
|
||||
});
|
||||
const { getState, dispatch } = setup();
|
||||
const thunk = saveDashboardRequest(
|
||||
newDashboardData,
|
||||
192,
|
||||
SAVE_TYPE_OVERWRITE,
|
||||
);
|
||||
thunk(dispatch, getState);
|
||||
await waitFor(() =>
|
||||
expect(findDangerToast(dispatch)?.payload.text).toContain(
|
||||
'unexpected boom',
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('should navigate to the new dashboard after Save As', async () => {
|
||||
@@ -379,15 +471,6 @@ describe('dashboardState actions', () => {
|
||||
// permission-denied copy, while a 403 from outside Superset (reverse proxy,
|
||||
// WAF, SSO gateway) carries a non-JSON body and must fall back to the
|
||||
// generic status-derived toast. See #42239.
|
||||
const findDangerToast = (dispatch: jest.Mock) =>
|
||||
dispatch.mock.calls
|
||||
.map(call => call[0])
|
||||
.find(
|
||||
action =>
|
||||
action?.type === ADD_TOAST &&
|
||||
action.payload.toastType === ToastType.Danger,
|
||||
);
|
||||
|
||||
test('maps a non-JSON 403 save failure to the generic error toast', async () => {
|
||||
const { getState, dispatch } = setup();
|
||||
putStub.mockRestore();
|
||||
|
||||
@@ -646,6 +646,7 @@ export function saveDashboardRequest(
|
||||
};
|
||||
|
||||
const onError = async (response: Response): Promise<void> => {
|
||||
logging.error(response);
|
||||
const { error, message } = await getClientErrorObject(response);
|
||||
let errorText = t('Sorry, an unknown error occurred');
|
||||
|
||||
@@ -689,64 +690,64 @@ export function saveDashboardRequest(
|
||||
}),
|
||||
};
|
||||
|
||||
const updateDashboard = (): Promise<JsonObject | void> =>
|
||||
SupersetClient.put({
|
||||
endpoint: `/api/v1/dashboard/${id}`,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updatedDashboard),
|
||||
})
|
||||
.then(response => onUpdateSuccess(response))
|
||||
.catch(response => onError(response));
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (
|
||||
!isFeatureEnabled(FeatureFlag.ConfirmDashboardDiff) ||
|
||||
saveType === SAVE_TYPE_OVERWRITE_CONFIRMED
|
||||
) {
|
||||
// skip overwrite precheck
|
||||
resolve();
|
||||
return;
|
||||
const updateDashboard = async (): Promise<JsonObject | void> => {
|
||||
try {
|
||||
const response = await SupersetClient.put({
|
||||
endpoint: `/api/v1/dashboard/${id}`,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updatedDashboard),
|
||||
});
|
||||
return await onUpdateSuccess(response);
|
||||
} catch (error) {
|
||||
return onError(error as Response);
|
||||
}
|
||||
};
|
||||
|
||||
// precheck for overwrite items
|
||||
SupersetClient.get({
|
||||
endpoint: `/api/v1/dashboard/${id}`,
|
||||
}).then((response: JsonObject) => {
|
||||
if (
|
||||
!isFeatureEnabled(FeatureFlag.ConfirmDashboardDiff) ||
|
||||
saveType === SAVE_TYPE_OVERWRITE_CONFIRMED
|
||||
) {
|
||||
// skip overwrite precheck
|
||||
return updateDashboard();
|
||||
}
|
||||
|
||||
// precheck for overwrite items
|
||||
return SupersetClient.get({
|
||||
endpoint: `/api/v1/dashboard/${id}`,
|
||||
})
|
||||
.then((response: JsonObject) => {
|
||||
const dashboard = (response.json as JsonObject).result as JsonObject;
|
||||
const overwriteConfirmItems = getOverwriteItems(
|
||||
dashboard,
|
||||
updatedDashboard,
|
||||
);
|
||||
if (overwriteConfirmItems.length > 0) {
|
||||
dispatch(
|
||||
setOverrideConfirm({
|
||||
updatedAt: dashboard.changed_on as string,
|
||||
updatedBy: dashboard.changed_by_name as string,
|
||||
overwriteConfirmItems:
|
||||
overwriteConfirmItems as DashboardState['overwriteConfirmMetadata'] extends
|
||||
| { overwriteConfirmItems: infer I }
|
||||
| undefined
|
||||
? I
|
||||
: never,
|
||||
dashboardId: id,
|
||||
data: updatedDashboard,
|
||||
}),
|
||||
);
|
||||
return reject(overwriteConfirmItems);
|
||||
if (overwriteConfirmItems.length === 0) {
|
||||
return updateDashboard();
|
||||
}
|
||||
return resolve();
|
||||
});
|
||||
})
|
||||
.then(updateDashboard)
|
||||
.catch((overwriteConfirmItems: JsonObject[]) => {
|
||||
const errorText = t('Please confirm the overwrite values.');
|
||||
dispatch(
|
||||
setOverrideConfirm({
|
||||
updatedAt: dashboard.changed_on as string,
|
||||
updatedBy: dashboard.changed_by_name as string,
|
||||
overwriteConfirmItems:
|
||||
overwriteConfirmItems as DashboardState['overwriteConfirmMetadata'] extends
|
||||
| { overwriteConfirmItems: infer I }
|
||||
| undefined
|
||||
? I
|
||||
: never,
|
||||
dashboardId: id,
|
||||
data: updatedDashboard,
|
||||
}),
|
||||
);
|
||||
dispatch(
|
||||
logEvent(LOG_ACTIONS_CONFIRM_OVERWRITE_DASHBOARD_METADATA, {
|
||||
dashboard_id: id,
|
||||
items: overwriteConfirmItems,
|
||||
}),
|
||||
);
|
||||
dispatch(addDangerToast(errorText));
|
||||
});
|
||||
dispatch(addDangerToast(t('Please confirm the overwrite values.')));
|
||||
return undefined;
|
||||
})
|
||||
.catch(onError);
|
||||
}
|
||||
// changing the data as the endpoint requires
|
||||
if (
|
||||
|
||||
@@ -468,7 +468,7 @@ function SliceAdder({
|
||||
<AutoSizer>
|
||||
{({ height, width }: { height: number; width: number }) => (
|
||||
<List
|
||||
style={{ width, height }}
|
||||
style={{ width, height, maxHeight: height }}
|
||||
rowCount={filteredSlices.length}
|
||||
rowHeight={DEFAULT_CELL_HEIGHT}
|
||||
rowProps={listRowProps}
|
||||
|
||||
+37
-1
@@ -16,8 +16,20 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
ChartCustomizationType,
|
||||
type ChartCustomization,
|
||||
} from '@superset-ui/core';
|
||||
import { LabeledValue } from '@superset-ui/core/components';
|
||||
import { createLabelSortComparator } from './GroupByFilterCard';
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import GroupByFilterCard, {
|
||||
createLabelSortComparator,
|
||||
} from './GroupByFilterCard';
|
||||
|
||||
jest.mock('src/utils/cachedSupersetGet', () => ({
|
||||
// Never resolves, pinning the card in its column-loading state.
|
||||
cachedSupersetGet: jest.fn(() => new Promise(() => {})),
|
||||
}));
|
||||
|
||||
const apple: LabeledValue = { value: 'a', label: 'Apple' };
|
||||
const banana: LabeledValue = { value: 'b', label: 'Banana' };
|
||||
@@ -39,3 +51,27 @@ test('preserves source order when sortAscending is unset', () => {
|
||||
expect(compare(apple, banana)).toBe(0);
|
||||
expect(compare(banana, apple)).toBe(0);
|
||||
});
|
||||
|
||||
const groupByCustomization: ChartCustomization = {
|
||||
id: 'groupby-1',
|
||||
name: 'Group By',
|
||||
filterType: 'filter_groupby',
|
||||
type: ChartCustomizationType.ChartCustomization,
|
||||
targets: [{ datasetId: 1 }],
|
||||
scope: { rootPath: [], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: {},
|
||||
};
|
||||
|
||||
test('renders the column-loading spinner small and muted', async () => {
|
||||
render(<GroupByFilterCard customizationItem={groupByCustomization} />, {
|
||||
useRedux: true,
|
||||
initialState: {
|
||||
dataMask: {},
|
||||
nativeFilters: { filters: {} },
|
||||
},
|
||||
});
|
||||
const spinner = await screen.findByTestId('loading-indicator');
|
||||
expect(spinner).toHaveClass('inline');
|
||||
expect(spinner).toHaveStyle({ opacity: 0.25, width: '40px' });
|
||||
});
|
||||
|
||||
+1
-1
@@ -645,7 +645,7 @@ const GroupByFilterCard: FC<GroupByFilterCardProps> = ({
|
||||
|
||||
{loading && (
|
||||
<div style={{ textAlign: 'center', marginTop: 8 }}>
|
||||
<Loading position="inline" />
|
||||
<Loading position="inline" size="s" muted />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ const typeIntoSelect = async (text: string) => {
|
||||
const findOption = (text: string) =>
|
||||
waitFor(() => {
|
||||
// eslint-disable-next-line testing-library/no-node-access
|
||||
const virtualList = document.querySelector('.rc-virtual-list');
|
||||
const virtualList = document.querySelector('.ant-select-dropdown-list');
|
||||
if (!virtualList) {
|
||||
throw new Error('Virtual list not found');
|
||||
}
|
||||
|
||||
+10
@@ -72,3 +72,13 @@ test('omits datasourceType when undefined', () => {
|
||||
});
|
||||
expect(target).not.toHaveProperty('datasourceType');
|
||||
});
|
||||
|
||||
test('omits datasourceType when there is no dataset', () => {
|
||||
// The modal stamps a hidden ``datasourceType`` field on every filter form,
|
||||
// including dataset-less types. Without a dataset there is nothing for it to
|
||||
// describe, and emitting it would diverge from the ``{}`` target the import
|
||||
// and seed paths write.
|
||||
expect(
|
||||
buildNativeFilterTarget({ datasourceType: DatasourceType.Table }),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
+8
-4
@@ -33,9 +33,9 @@ export interface TargetFormInputs {
|
||||
* Build the ``NativeFilterTarget`` carried by a native filter or chart
|
||||
* customization from its form inputs.
|
||||
*
|
||||
* Consolidates what used to live in three places — ``filterTransformer``,
|
||||
* ``customizationTransformer``, and ``createHandleSave`` — so changes to the
|
||||
* target shape only need to happen here.
|
||||
* Consolidates what used to live in ``filterTransformer`` and
|
||||
* ``customizationTransformer`` so changes to the target shape only need to
|
||||
* happen here.
|
||||
*/
|
||||
export function buildNativeFilterTarget(
|
||||
formInputs: TargetFormInputs,
|
||||
@@ -49,7 +49,11 @@ export function buildNativeFilterTarget(
|
||||
: formInputs.dataset;
|
||||
}
|
||||
|
||||
if (formInputs.datasourceType) {
|
||||
// ``datasourceType`` describes the selected dataset, so it only belongs on a
|
||||
// target that has one. Emitting it for a dataset-less filter (e.g.
|
||||
// ``filter_time``) would make a UI save serialize a target the import and
|
||||
// seed paths write as ``{}``.
|
||||
if (formInputs.dataset != null && formInputs.datasourceType) {
|
||||
target.datasourceType = formInputs.datasourceType;
|
||||
}
|
||||
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 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 { ChartCustomization, ChartCustomizationType } from '@superset-ui/core';
|
||||
import { ChartCustomizationsFormItem } from '../types';
|
||||
import { transformCustomizationForSave } from './customizationTransformer';
|
||||
|
||||
const baseFormItem = {
|
||||
type: ChartCustomizationType.ChartCustomization,
|
||||
scope: { rootPath: ['ROOT_ID'], excluded: [] },
|
||||
controlValues: {},
|
||||
requiredFirst: {},
|
||||
defaultValue: null,
|
||||
defaultDataMask: { filterState: {}, extraFormData: {} },
|
||||
sortMetric: null,
|
||||
description: '',
|
||||
// form-only field that must never leak into the saved customization
|
||||
defaultValueQueriesData: null,
|
||||
} as unknown as ChartCustomizationsFormItem;
|
||||
|
||||
test('serializes a dataset-less customization into a full ChartCustomization', () => {
|
||||
// Customization plugins declaring ``datasourceCount: 0`` render no dataset
|
||||
// control, so their form item carries neither ``dataset`` nor ``targets``.
|
||||
const formItem = {
|
||||
...baseFormItem,
|
||||
name: 'Layer visibility',
|
||||
filterType: 'customization_deckgl_layer_visibility',
|
||||
} as unknown as ChartCustomizationsFormItem;
|
||||
|
||||
const result = transformCustomizationForSave(
|
||||
'CHART_CUSTOMIZATION-abc',
|
||||
formItem,
|
||||
) as ChartCustomization;
|
||||
|
||||
expect(result.targets).toEqual([{}]);
|
||||
expect(result.defaultDataMask).toBeDefined();
|
||||
expect(result.removed).toBe(false);
|
||||
expect(result).not.toHaveProperty('defaultValueQueriesData');
|
||||
});
|
||||
|
||||
test('serializes a dataset-backed customization into a full ChartCustomization', () => {
|
||||
const formItem = {
|
||||
...baseFormItem,
|
||||
name: 'Group by',
|
||||
filterType: 'customization_dynamic_group_by',
|
||||
dataset: { value: 42, label: 'sales' },
|
||||
column: 'region',
|
||||
} as unknown as ChartCustomizationsFormItem;
|
||||
|
||||
const result = transformCustomizationForSave(
|
||||
'CHART_CUSTOMIZATION-def',
|
||||
formItem,
|
||||
) as ChartCustomization;
|
||||
|
||||
expect(result.targets).toEqual([
|
||||
{ datasetId: 42, column: { name: 'region' } },
|
||||
]);
|
||||
expect(result).not.toHaveProperty('defaultValueQueriesData');
|
||||
});
|
||||
|
||||
test('passes an already-saved ChartCustomization through untouched', () => {
|
||||
const saved: ChartCustomization = {
|
||||
id: 'CHART_CUSTOMIZATION-ghi',
|
||||
name: 'Group by',
|
||||
filterType: 'customization_dynamic_group_by',
|
||||
type: ChartCustomizationType.ChartCustomization,
|
||||
targets: [{ datasetId: 42, column: { name: 'region' } }],
|
||||
defaultDataMask: { filterState: {}, extraFormData: {} },
|
||||
controlValues: {},
|
||||
scope: { rootPath: ['ROOT_ID'], excluded: [] },
|
||||
description: ' needs trim ',
|
||||
chartsInScope: [1, 2],
|
||||
tabsInScope: ['TAB-1'],
|
||||
};
|
||||
|
||||
const result = transformCustomizationForSave(
|
||||
'CHART_CUSTOMIZATION-ghi',
|
||||
saved,
|
||||
) as ChartCustomization;
|
||||
|
||||
expect(result.targets).toEqual([
|
||||
{ datasetId: 42, column: { name: 'region' } },
|
||||
]);
|
||||
expect(result.chartsInScope).toEqual([1, 2]);
|
||||
expect(result.tabsInScope).toEqual(['TAB-1']);
|
||||
expect(result.description).toBe('needs trim');
|
||||
});
|
||||
+4
-1
@@ -69,7 +69,10 @@ function isDividerType(
|
||||
function isFormInput(
|
||||
formInputs: ChartCustomizationFormOrSaved,
|
||||
): formInputs is ChartCustomizationsFormItem {
|
||||
return 'dataset' in formInputs && typeof formInputs.dataset === 'object';
|
||||
// Mirrors `filterTransformer`: a saved customization always carries a
|
||||
// serialized `targets` array, and dataset-less types (e.g. the deck.gl layer
|
||||
// visibility customization) have no `dataset` to discriminate on.
|
||||
return !('targets' in formInputs);
|
||||
}
|
||||
|
||||
function transformCustomizationDivider(
|
||||
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* 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 { DatasourceType, Filter, NativeFilterType } from '@superset-ui/core';
|
||||
import { NativeFiltersFormItem } from '../types';
|
||||
import { transformFilterForSave } from './filterTransformer';
|
||||
|
||||
const baseFormItem = {
|
||||
type: NativeFilterType.NativeFilter,
|
||||
scope: { rootPath: ['ROOT_ID'], excluded: [] },
|
||||
controlValues: {},
|
||||
requiredFirst: {},
|
||||
defaultValue: null,
|
||||
defaultDataMask: { filterState: {}, extraFormData: {} },
|
||||
description: '',
|
||||
// form-only fields that must never leak into the saved filter
|
||||
defaultValueQueriesData: null,
|
||||
} as unknown as NativeFiltersFormItem;
|
||||
|
||||
test('serializes a dataset-less filter (filter_time) into a full Filter', () => {
|
||||
// A ``filter_time`` filter has no dataset/column controls, so its form item
|
||||
// carries neither a ``dataset`` nor a ``targets`` key. It must still be
|
||||
// transformed like any other native filter rather than persisted verbatim.
|
||||
const formItem: NativeFiltersFormItem = {
|
||||
...baseFormItem,
|
||||
name: 'Time Range',
|
||||
filterType: 'filter_time',
|
||||
dependencies: ['NATIVE_FILTER-parent'],
|
||||
// the modal stamps this on every filter form, dataset or not
|
||||
datasourceType: DatasourceType.Table,
|
||||
};
|
||||
|
||||
const result = transformFilterForSave(
|
||||
'NATIVE_FILTER-abc',
|
||||
formItem,
|
||||
) as Filter;
|
||||
|
||||
// Keys the bug used to strip are present and well-formed. The target matches
|
||||
// the ``{}`` the import and seed paths write, so one logical filter has one
|
||||
// serialization regardless of provenance.
|
||||
expect(result.targets).toEqual([{}]);
|
||||
expect(result.defaultDataMask).toBeDefined();
|
||||
expect(result.cascadeParentIds).toEqual(['NATIVE_FILTER-parent']);
|
||||
|
||||
// Form-only keys must not leak into the persisted config.
|
||||
expect(result).not.toHaveProperty('defaultValueQueriesData');
|
||||
expect(result).not.toHaveProperty('dependencies');
|
||||
// Empty requiredFirst collapses to undefined instead of the raw form object.
|
||||
expect(result.requiredFirst).toBeUndefined();
|
||||
|
||||
// A dataset-less filter has no sort metric control, so the persisted document
|
||||
// must not gain a ``sortMetric`` key it never had. Asserted on the serialized
|
||||
// form because ``undefined`` values survive in the object but not in JSON.
|
||||
expect(JSON.parse(JSON.stringify(result))).not.toHaveProperty('sortMetric');
|
||||
|
||||
expect(result.name).toBe('Time Range');
|
||||
expect(result.filterType).toBe('filter_time');
|
||||
});
|
||||
|
||||
test('serializes a dataset-backed filter (filter_select) into a full Filter', () => {
|
||||
const formItem: NativeFiltersFormItem = {
|
||||
...baseFormItem,
|
||||
name: 'Region',
|
||||
filterType: 'filter_select',
|
||||
dataset: { value: 42, label: 'sales' },
|
||||
column: 'region',
|
||||
dependencies: [],
|
||||
};
|
||||
|
||||
const result = transformFilterForSave(
|
||||
'NATIVE_FILTER-def',
|
||||
formItem,
|
||||
) as Filter;
|
||||
|
||||
expect(result.targets).toEqual([
|
||||
{ datasetId: 42, column: { name: 'region' } },
|
||||
]);
|
||||
expect(result.defaultDataMask).toBeDefined();
|
||||
expect(result.cascadeParentIds).toEqual([]);
|
||||
expect(result).not.toHaveProperty('defaultValueQueriesData');
|
||||
});
|
||||
|
||||
test('passes an already-saved Filter through untouched (aside from trimming)', () => {
|
||||
// Values coming from the stored filter config map (e.g. cascade-parent
|
||||
// cleanup) already carry a ``targets`` array and must be preserved as-is.
|
||||
const savedFilter: Filter = {
|
||||
id: 'NATIVE_FILTER-ghi',
|
||||
name: 'Time Range',
|
||||
filterType: 'filter_time',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
targets: [{}],
|
||||
defaultDataMask: { filterState: {}, extraFormData: {} },
|
||||
cascadeParentIds: ['NATIVE_FILTER-parent'],
|
||||
controlValues: {},
|
||||
scope: { rootPath: ['ROOT_ID'], excluded: [] },
|
||||
description: ' needs trim ',
|
||||
chartsInScope: [1, 2],
|
||||
tabsInScope: ['TAB-1'],
|
||||
};
|
||||
|
||||
const result = transformFilterForSave(
|
||||
'NATIVE_FILTER-ghi',
|
||||
savedFilter,
|
||||
) as Filter;
|
||||
|
||||
expect(result.targets).toEqual([{}]);
|
||||
expect(result.cascadeParentIds).toEqual(['NATIVE_FILTER-parent']);
|
||||
expect(result.chartsInScope).toEqual([1, 2]);
|
||||
expect(result.tabsInScope).toEqual(['TAB-1']);
|
||||
expect(result.description).toBe('needs trim');
|
||||
});
|
||||
|
||||
test('rebuilds a saved filter whose targets were already stripped', () => {
|
||||
// Dashboards affected by this bug hold ``filter_time`` entries with no
|
||||
// ``targets``. They no longer match the saved-filter branch, so they take the
|
||||
// form-item path and are repaired on the next save. ``cascadeParentIds`` is
|
||||
// read from the form's ``dependencies``, which such an entry does not carry —
|
||||
// the same write that stripped ``targets`` stripped ``cascadeParentIds`` too.
|
||||
const strippedFilter = {
|
||||
id: 'NATIVE_FILTER-jkl',
|
||||
name: 'Time Range',
|
||||
filterType: 'filter_time',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
scope: { rootPath: ['ROOT_ID'], excluded: [] },
|
||||
controlValues: { timeShift: false },
|
||||
description: '',
|
||||
requiredFirst: { 'NATIVE_FILTER-jkl': true },
|
||||
defaultValueQueriesData: null,
|
||||
} as unknown as NativeFiltersFormItem;
|
||||
|
||||
const result = transformFilterForSave(
|
||||
'NATIVE_FILTER-jkl',
|
||||
strippedFilter,
|
||||
) as Filter;
|
||||
|
||||
expect(result.targets).toEqual([{}]);
|
||||
expect(result.defaultDataMask).toBeDefined();
|
||||
expect(result.requiredFirst).toBe(true);
|
||||
expect(result.cascadeParentIds).toEqual([]);
|
||||
expect(result).not.toHaveProperty('defaultValueQueriesData');
|
||||
});
|
||||
+5
-2
@@ -67,7 +67,10 @@ function isDividerType(
|
||||
function isFormInput(
|
||||
formInputs: NativeFilterFormOrSaved,
|
||||
): formInputs is NativeFiltersFormItem {
|
||||
return 'dataset' in formInputs;
|
||||
// A saved filter always carries a serialized `targets` array; a form item
|
||||
// never does. Keying this off `dataset` misclassified filter types with no
|
||||
// dataset control (e.g. `filter_time`) as already saved.
|
||||
return !('targets' in formInputs);
|
||||
}
|
||||
|
||||
function transformDivider(
|
||||
@@ -115,7 +118,7 @@ function transformFormInput(
|
||||
adhoc_filters: formInputs.adhoc_filters,
|
||||
time_range: formInputs.time_range,
|
||||
granularity_sqla: formInputs.granularity_sqla,
|
||||
sortMetric: formInputs.sortMetric ?? null,
|
||||
sortMetric: formInputs.sortMetric,
|
||||
requiredFirst: formInputs.requiredFirst
|
||||
? Object.values(formInputs.requiredFirst).find(rf => rf)
|
||||
: undefined,
|
||||
|
||||
@@ -18,21 +18,15 @@
|
||||
*/
|
||||
import type { FormInstance } from '@superset-ui/core/components';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { getInitialDataMask } from 'src/dataMask/reducer';
|
||||
import {
|
||||
FilterConfiguration,
|
||||
NativeFilterType,
|
||||
NativeFilterTarget,
|
||||
Filter,
|
||||
Divider,
|
||||
ChartCustomizationType,
|
||||
ChartCustomizationConfiguration,
|
||||
ChartCustomization,
|
||||
ChartCustomizationDivider,
|
||||
} from '@superset-ui/core';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants';
|
||||
import { buildNativeFilterTarget } from './transformers/buildTarget';
|
||||
import {
|
||||
ChartCustomizationsForm,
|
||||
FilterChangesType,
|
||||
@@ -101,70 +95,6 @@ export const validateForm = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const createHandleSave =
|
||||
(
|
||||
saveForm: Function,
|
||||
filterChanges: FilterChangesType,
|
||||
values: NativeFiltersForm,
|
||||
filterConfigMap: Record<string, Filter | Divider>,
|
||||
) =>
|
||||
async () => {
|
||||
const transformFilter = (id: string) => {
|
||||
const formInputs = values.filters?.[id] || filterConfigMap[id];
|
||||
if (!formInputs) {
|
||||
return undefined;
|
||||
}
|
||||
if (formInputs.type === NativeFilterType.Divider) {
|
||||
return {
|
||||
id,
|
||||
type: NativeFilterType.Divider,
|
||||
scope: {
|
||||
rootPath: [DASHBOARD_ROOT_ID],
|
||||
excluded: [],
|
||||
},
|
||||
title: formInputs.title,
|
||||
description: formInputs.description,
|
||||
};
|
||||
}
|
||||
|
||||
const target: Partial<NativeFilterTarget> =
|
||||
buildNativeFilterTarget(formInputs);
|
||||
|
||||
return {
|
||||
id,
|
||||
adhoc_filters: formInputs.adhoc_filters,
|
||||
time_range: formInputs.time_range,
|
||||
controlValues: formInputs.controlValues ?? {},
|
||||
granularity_sqla: formInputs.granularity_sqla,
|
||||
...(formInputs.time_grains?.length
|
||||
? { time_grains: formInputs.time_grains }
|
||||
: {}),
|
||||
requiredFirst: Object.values(formInputs.requiredFirst ?? {}).find(
|
||||
rf => rf,
|
||||
),
|
||||
name: formInputs.name,
|
||||
filterType: formInputs.filterType,
|
||||
targets: [target],
|
||||
defaultDataMask: formInputs.defaultDataMask ?? getInitialDataMask(),
|
||||
cascadeParentIds: formInputs.dependencies || [],
|
||||
scope: formInputs.scope,
|
||||
sortMetric: formInputs.sortMetric,
|
||||
type: formInputs.type,
|
||||
description: (formInputs.description || '').trim(),
|
||||
};
|
||||
};
|
||||
|
||||
const transformedModified = filterChanges.modified
|
||||
.map(transformFilter)
|
||||
.filter(Boolean);
|
||||
|
||||
const newFilterChanges = {
|
||||
...filterChanges,
|
||||
modified: transformedModified,
|
||||
};
|
||||
await saveForm(newFilterChanges);
|
||||
};
|
||||
|
||||
export const createHandleRemoveItem =
|
||||
(
|
||||
setRemovedFilters: (
|
||||
|
||||
@@ -105,10 +105,17 @@ export const SamplesPane = ({
|
||||
1,
|
||||
)
|
||||
.then(response => {
|
||||
setData(ensureIsArray(response.data));
|
||||
setColnames(ensureIsArray(response.colnames));
|
||||
setColtypes(ensureIsArray(response.coltypes));
|
||||
setRowCount(response.rowcount);
|
||||
// A 200 that carries no `result` payload resolves to undefined here.
|
||||
// Read through it so the pane falls back to its empty state instead
|
||||
// of throwing a TypeError that surfaces as an internal error message.
|
||||
const rows = ensureIsArray(response?.data);
|
||||
setData(rows);
|
||||
setColnames(ensureIsArray(response?.colnames));
|
||||
setColtypes(ensureIsArray(response?.coltypes));
|
||||
// Fall back to the rows actually returned rather than to zero: the
|
||||
// controls only render when there are rows, and a hardcoded 0 would
|
||||
// label a populated table as "0 rows".
|
||||
setRowCount(response?.rowcount ?? rows.length);
|
||||
setResponseError('');
|
||||
cache.set(queryFormData, true);
|
||||
if (queryForce) {
|
||||
|
||||
@@ -60,6 +60,27 @@ describe('SamplesPane', () => {
|
||||
400,
|
||||
);
|
||||
|
||||
// A 200 response that carries no `result` payload, as reported in #36840.
|
||||
fetchMock.post(
|
||||
'end:/datasource/samples?force=false&datasource_type=table&datasource_id=37&per_page=100&page=1',
|
||||
{},
|
||||
);
|
||||
|
||||
// A 200 whose result carries rows but omits `rowcount`.
|
||||
fetchMock.post(
|
||||
'end:/datasource/samples?force=false&datasource_type=table&datasource_id=38&per_page=100&page=1',
|
||||
{
|
||||
result: {
|
||||
data: [
|
||||
{ __timestamp: 1230768000000, genre: 'Action' },
|
||||
{ __timestamp: 1230768000010, genre: 'Horror' },
|
||||
],
|
||||
colnames: ['__timestamp', 'genre'],
|
||||
coltypes: [2, 1],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const setForceQuery = jest.fn();
|
||||
|
||||
afterAll(() => {
|
||||
@@ -114,4 +135,29 @@ describe('SamplesPane', () => {
|
||||
expect(queryByText('Action')).toBeVisible();
|
||||
expect(queryByText('Horror')).toBeVisible();
|
||||
});
|
||||
|
||||
test('renders the empty state when the response carries no result payload', async () => {
|
||||
const props = createSamplesPaneProps({ datasourceId: 37 });
|
||||
const { findByText, queryByRole } = render(<SamplesPane {...props} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
await findByText('No samples were returned for this dataset'),
|
||||
).toBeVisible();
|
||||
// The pane should not leak an internal TypeError through the error alert.
|
||||
expect(queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('counts the returned rows when the response omits rowcount', async () => {
|
||||
const props = createSamplesPaneProps({ datasourceId: 38 });
|
||||
const { findByText, queryByText } = render(<SamplesPane {...props} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
expect(await findByText('Action')).toBeVisible();
|
||||
// Falling back to 0 here would label a populated table as "0 rows".
|
||||
expect(queryByText('0 rows')).not.toBeInTheDocument();
|
||||
expect(queryByText('2 rows')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -148,7 +148,7 @@ export const DatasourceItems = ({
|
||||
|
||||
return (
|
||||
<List
|
||||
style={{ width: width - BORDER_WIDTH, height }}
|
||||
style={{ width: width - BORDER_WIDTH, height, maxHeight: height }}
|
||||
rowHeight={rowHeight}
|
||||
rowCount={flattenedItems.length}
|
||||
rowProps={rowProps}
|
||||
|
||||
+4
-4
@@ -150,7 +150,7 @@ const waitForRender = (props?: any) =>
|
||||
test('renders with default props', async () => {
|
||||
await waitForRender();
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'OK' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Confirm' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeEnabled();
|
||||
});
|
||||
|
||||
@@ -188,7 +188,7 @@ test('enables apply and ok buttons', async () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: 'OK' })).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: 'Confirm' })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -203,7 +203,7 @@ test('triggers addAnnotationLayer and close when ok button is clicked', async ()
|
||||
const addAnnotationLayer = jest.fn();
|
||||
const close = jest.fn();
|
||||
await waitForRender({ name: 'Test', value: '2x', addAnnotationLayer, close });
|
||||
userEvent.click(screen.getByRole('button', { name: 'OK' }));
|
||||
userEvent.click(screen.getByRole('button', { name: 'Confirm' }));
|
||||
expect(addAnnotationLayer).toHaveBeenCalled();
|
||||
expect(close).toHaveBeenCalled();
|
||||
});
|
||||
@@ -724,7 +724,7 @@ test('Disable apply button if formula is incorrect', async () => {
|
||||
|
||||
const formulaInput = screen.getByRole('textbox', { name: 'Formula' });
|
||||
const applyButton = screen.getByRole('button', { name: 'Apply' });
|
||||
const okButton = screen.getByRole('button', { name: 'OK' });
|
||||
const okButton = screen.getByRole('button', { name: 'Confirm' });
|
||||
|
||||
userEvent.type(formulaInput, 'x+1');
|
||||
expect(formulaInput).toHaveValue('x+1');
|
||||
|
||||
+1
-1
@@ -1303,7 +1303,7 @@ function AnnotationLayer({
|
||||
disabled={!isValid}
|
||||
onClick={submitAnnotation}
|
||||
>
|
||||
{t('OK')}
|
||||
{t('Confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -251,4 +251,11 @@ export const DEFAULT_CONFIG_FORM_LAYOUT: ColumnConfigFormLayout = {
|
||||
{ name: 'horizontalAlign', override: { defaultValue: 'left' } },
|
||||
],
|
||||
],
|
||||
[GenericDataType.MultiValue]: [
|
||||
[
|
||||
'columnWidth',
|
||||
{ name: 'horizontalAlign', override: { defaultValue: 'left' } },
|
||||
],
|
||||
['truncateLongCells'],
|
||||
],
|
||||
};
|
||||
|
||||
+3
-3
@@ -187,7 +187,7 @@ async function openAndSaveChanges(
|
||||
await userEvent.click(screen.getByTestId('datasource-menu-trigger'));
|
||||
await userEvent.click(await screen.findByTestId('edit-dataset'));
|
||||
await userEvent.click(await screen.findByTestId('datasource-modal-save'));
|
||||
await userEvent.click(await screen.findByText('OK'));
|
||||
await userEvent.click(await screen.findByText('Confirm'));
|
||||
}
|
||||
|
||||
test('Should render', async () => {
|
||||
@@ -714,10 +714,10 @@ test('should handle metric save confirmation modal', async () => {
|
||||
await userEvent.click(await screen.findByTestId('datasource-modal-save'));
|
||||
|
||||
// Verify confirmation modal appears
|
||||
expect(await screen.findByText('OK')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Confirm')).toBeInTheDocument();
|
||||
|
||||
// Confirm save
|
||||
await userEvent.click(screen.getByText('OK'));
|
||||
await userEvent.click(screen.getByText('Confirm'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onDatasourceSave).toHaveBeenCalled();
|
||||
|
||||
+10
-6
@@ -214,7 +214,9 @@ test('Should filter simple columns by column_name and verbose_name', async () =>
|
||||
|
||||
await userEvent.type(combobox, 'revenue');
|
||||
|
||||
let dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
|
||||
let dropdown = document.querySelector(
|
||||
'.ant-select-dropdown-list',
|
||||
) as HTMLElement;
|
||||
expect(within(dropdown).getByText('Total Sales')).toBeInTheDocument();
|
||||
expect(
|
||||
within(dropdown).queryByText('User Identifier'),
|
||||
@@ -226,7 +228,7 @@ test('Should filter simple columns by column_name and verbose_name', async () =>
|
||||
await userEvent.clear(combobox);
|
||||
await userEvent.type(combobox, 'Identifier');
|
||||
|
||||
dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
|
||||
dropdown = document.querySelector('.ant-select-dropdown-list') as HTMLElement;
|
||||
expect(within(dropdown).getByText('User Identifier')).toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Total Sales')).not.toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Creation Date')).not.toBeInTheDocument();
|
||||
@@ -234,7 +236,7 @@ test('Should filter simple columns by column_name and verbose_name', async () =>
|
||||
await userEvent.clear(combobox);
|
||||
await userEvent.type(combobox, '_at');
|
||||
|
||||
dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
|
||||
dropdown = document.querySelector('.ant-select-dropdown-list') as HTMLElement;
|
||||
expect(within(dropdown).getByText('Creation Date')).toBeInTheDocument();
|
||||
expect(within(dropdown).getByText('Last Update')).toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Total Sales')).not.toBeInTheDocument();
|
||||
@@ -288,7 +290,9 @@ test('Should filter saved expressions by column_name and verbose_name', async ()
|
||||
|
||||
await userEvent.type(combobox, 'revenue');
|
||||
|
||||
let dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
|
||||
let dropdown = document.querySelector(
|
||||
'.ant-select-dropdown-list',
|
||||
) as HTMLElement;
|
||||
expect(within(dropdown).getByText('Total Sales')).toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Tax Amount')).not.toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Net Profit')).not.toBeInTheDocument();
|
||||
@@ -298,7 +302,7 @@ test('Should filter saved expressions by column_name and verbose_name', async ()
|
||||
await userEvent.clear(combobox);
|
||||
await userEvent.type(combobox, 'Rate');
|
||||
|
||||
dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
|
||||
dropdown = document.querySelector('.ant-select-dropdown-list') as HTMLElement;
|
||||
expect(within(dropdown).getByText('Discount Rate')).toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Total Sales')).not.toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Tax Amount')).not.toBeInTheDocument();
|
||||
@@ -306,7 +310,7 @@ test('Should filter saved expressions by column_name and verbose_name', async ()
|
||||
await userEvent.clear(combobox);
|
||||
await userEvent.type(combobox, 'profit');
|
||||
|
||||
dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
|
||||
dropdown = document.querySelector('.ant-select-dropdown-list') as HTMLElement;
|
||||
expect(within(dropdown).getByText('Net Profit')).toBeInTheDocument();
|
||||
expect(within(dropdown).getByText('Profit Margin')).toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Total Sales')).not.toBeInTheDocument();
|
||||
|
||||
+68
@@ -270,6 +270,74 @@ describe('AdhocFilter', () => {
|
||||
});
|
||||
expect(adhocFilter.comparator).toBe(undefined);
|
||||
});
|
||||
// Charts saved before #32701 persisted `==` as the operation for IS_TRUE and
|
||||
// IS_FALSE, alongside a boolean comparator. `translateToSql` and the backend
|
||||
// both key off `operator`, so dropping the comparator would render such a
|
||||
// filter as `col =` and query it as `col IS NULL`.
|
||||
test('keeps the legacy boolean comparator for IS_TRUE', () => {
|
||||
const adhocFilter = new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'col',
|
||||
operator: '==',
|
||||
operatorId: Operators.IsTrue,
|
||||
comparator: true,
|
||||
clause: Clauses.Where,
|
||||
});
|
||||
expect(adhocFilter.operator).toBe('==');
|
||||
expect(adhocFilter.comparator).toBe(true);
|
||||
expect(adhocFilter.translateToSql()).toBe("col = 'TRUE'");
|
||||
});
|
||||
test('keeps the legacy boolean comparator for IS_FALSE', () => {
|
||||
const adhocFilter = new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'col',
|
||||
operator: '==',
|
||||
operatorId: Operators.IsFalse,
|
||||
comparator: false,
|
||||
clause: Clauses.Where,
|
||||
});
|
||||
expect(adhocFilter.operator).toBe('==');
|
||||
expect(adhocFilter.comparator).toBe(false);
|
||||
expect(adhocFilter.translateToSql()).toBe("col = 'FALSE'");
|
||||
});
|
||||
test('restores the boolean even when the stored comparator is missing', () => {
|
||||
const adhocFilter = new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'col',
|
||||
operator: '==',
|
||||
operatorId: Operators.IsTrue,
|
||||
clause: Clauses.Where,
|
||||
});
|
||||
expect(adhocFilter.comparator).toBe(true);
|
||||
});
|
||||
test('keeps a legacy boolean filter intact when the control re-posts it', () => {
|
||||
const stored = {
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'col',
|
||||
operator: '==',
|
||||
operatorId: Operators.IsTrue,
|
||||
comparator: true,
|
||||
clause: Clauses.Where,
|
||||
};
|
||||
// DndFilterSelect wraps props.value and hands those instances to onChange
|
||||
const posted = JSON.parse(JSON.stringify(new AdhocFilter(stored)));
|
||||
expect(posted.operator).toBe('==');
|
||||
expect(posted.comparator).toBe(true);
|
||||
expect(posted.operatorId).toBe(Operators.IsTrue);
|
||||
});
|
||||
test('leaves a genuine equality filter on a boolean value alone', () => {
|
||||
const adhocFilter = new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'col',
|
||||
operator: '==',
|
||||
operatorId: Operators.Equals,
|
||||
comparator: true,
|
||||
clause: Clauses.Where,
|
||||
});
|
||||
expect(adhocFilter.operator).toBe('==');
|
||||
expect(adhocFilter.comparator).toBe(true);
|
||||
expect(adhocFilter.translateToSql()).toBe("col = 'TRUE'");
|
||||
});
|
||||
test('sets the label properly if subject is a string', () => {
|
||||
const adhocFilter = new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
|
||||
@@ -30,6 +30,15 @@ const CUSTOM_OPERATIONS = [...CUSTOM_OPERATORS].map(
|
||||
op => OPERATOR_ENUM_TO_OPERATOR_TYPE[op].operation,
|
||||
);
|
||||
|
||||
// Charts saved before #32701 store `==` for IS_TRUE/IS_FALSE with the boolean
|
||||
// in the comparator; blanking it makes them query `col IS NULL`. Restoring it
|
||||
// leaves the emitted SQL untouched -- reconciling `operator` to `IS TRUE`
|
||||
// would not, and Druid rejects that predicate on VARCHAR columns.
|
||||
const LEGACY_BOOLEAN_COMPARATORS = new Map<string, boolean>([
|
||||
[Operators.IsTrue, true],
|
||||
[Operators.IsFalse, false],
|
||||
]);
|
||||
|
||||
interface AdhocFilterInput {
|
||||
expressionType?: string;
|
||||
subject?: string | { column_name?: string; [key: string]: unknown } | null;
|
||||
@@ -77,6 +86,16 @@ export default class AdhocFilter {
|
||||
) {
|
||||
this.comparator = undefined;
|
||||
}
|
||||
if (
|
||||
this.operator ===
|
||||
OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.Equals].operation &&
|
||||
adhocFilter.operatorId &&
|
||||
LEGACY_BOOLEAN_COMPARATORS.has(adhocFilter.operatorId)
|
||||
) {
|
||||
this.comparator = LEGACY_BOOLEAN_COMPARATORS.get(
|
||||
adhocFilter.operatorId,
|
||||
);
|
||||
}
|
||||
this.clause = adhocFilter.clause || Clauses.Where;
|
||||
this.sqlExpression = null;
|
||||
} else if (this.expressionType === ExpressionTypes.Sql) {
|
||||
|
||||
+16
-2
@@ -367,8 +367,22 @@ function AdhocFilterEditPopover({
|
||||
</ErrorBoundary>
|
||||
),
|
||||
},
|
||||
...(datasource?.type === 'semantic_view'
|
||||
? []
|
||||
...(datasource?.type === 'semantic_view' ||
|
||||
[
|
||||
Operators.ContainsAny,
|
||||
Operators.ContainsAll,
|
||||
Operators.IsEmpty,
|
||||
Operators.IsNotEmpty,
|
||||
Operators.LengthEquals,
|
||||
Operators.LengthGreaterThan,
|
||||
Operators.LengthLessThan,
|
||||
Operators.LengthGreaterThanOrEqual,
|
||||
Operators.LengthLessThanOrEqual,
|
||||
].includes(adhocFilter.operatorId as Operators)
|
||||
? // Hide the Custom SQL tab for element-level array operators: they
|
||||
// have no portable SQL representation, and converting one would
|
||||
// silently turn the filter into invalid raw SQL.
|
||||
[]
|
||||
: [
|
||||
{
|
||||
key: ExpressionTypes.Sql,
|
||||
|
||||
+138
@@ -35,6 +35,7 @@ import {
|
||||
} from 'src/explore/constants';
|
||||
import AdhocMetric from 'src/explore/components/controls/MetricControl/AdhocMetric';
|
||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import fetchMock from 'fetch-mock';
|
||||
|
||||
import { TestDataset, Dataset } from '@superset-ui/chart-controls';
|
||||
@@ -252,6 +253,78 @@ test('shows boolean only operators when subject is number', () => {
|
||||
].map(operator => expect(isOperatorRelevant(operator, 'value')).toBe(true));
|
||||
});
|
||||
|
||||
test('shows array operators (tier 1 + tier 2) when subject is multi-value', () => {
|
||||
const props = setup({
|
||||
adhocFilter: new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'skills',
|
||||
operatorId: undefined,
|
||||
operator: undefined,
|
||||
comparator: undefined,
|
||||
clause: undefined,
|
||||
}),
|
||||
datasource: {
|
||||
columns: [
|
||||
{
|
||||
id: 3,
|
||||
column_name: 'skills',
|
||||
type: 'Array(String)',
|
||||
type_generic: GenericDataType.MultiValue,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const { isOperatorRelevant } = useSimpleTabFilterProps(
|
||||
props as unknown as Props,
|
||||
);
|
||||
// Tier 1 (whole-array) + Tier 2 (element-level) are all relevant.
|
||||
[
|
||||
Operators.Equals,
|
||||
Operators.NotEquals,
|
||||
Operators.In,
|
||||
Operators.NotIn,
|
||||
Operators.IsNull,
|
||||
Operators.IsNotNull,
|
||||
Operators.ContainsAny,
|
||||
Operators.ContainsAll,
|
||||
Operators.IsEmpty,
|
||||
Operators.IsNotEmpty,
|
||||
].forEach(operator =>
|
||||
expect(isOperatorRelevant(operator, 'skills')).toBe(true),
|
||||
);
|
||||
// scalar-only operators are hidden for array columns
|
||||
[Operators.GreaterThan, Operators.LessThan, Operators.Like].forEach(
|
||||
operator => expect(isOperatorRelevant(operator, 'skills')).toBe(false),
|
||||
);
|
||||
});
|
||||
|
||||
test('hides element-level array operators for non multi-value columns', () => {
|
||||
const props = setup({
|
||||
adhocFilter: new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'value',
|
||||
operatorId: undefined,
|
||||
operator: undefined,
|
||||
comparator: undefined,
|
||||
clause: undefined,
|
||||
}),
|
||||
datasource: {
|
||||
columns: [{ id: 3, column_name: 'value', type: 'STRING' }],
|
||||
},
|
||||
});
|
||||
const { isOperatorRelevant } = useSimpleTabFilterProps(
|
||||
props as unknown as Props,
|
||||
);
|
||||
[
|
||||
Operators.ContainsAny,
|
||||
Operators.ContainsAll,
|
||||
Operators.IsEmpty,
|
||||
Operators.IsNotEmpty,
|
||||
].forEach(operator =>
|
||||
expect(isOperatorRelevant(operator, 'value')).toBe(false),
|
||||
);
|
||||
});
|
||||
|
||||
test('will convert from individual comparator to array if the operator changes to multi', () => {
|
||||
const props = setup();
|
||||
const { onOperatorChange } = useSimpleTabFilterProps(
|
||||
@@ -309,6 +382,49 @@ test('will convert from array to individual comparators if the operator changes
|
||||
);
|
||||
});
|
||||
|
||||
test('resets the comparator when switching between array value families', () => {
|
||||
// Equal to (whole-array literal) -> Contains all (individual elements):
|
||||
// the value spaces are incompatible, so the stale value must be cleared.
|
||||
const wholeArrayFilter = new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'scores',
|
||||
operatorId: Operators.Equals,
|
||||
operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.Equals].operation,
|
||||
comparator: '[5,6,7]',
|
||||
clause: Clauses.Where,
|
||||
});
|
||||
const props = setup({ adhocFilter: wholeArrayFilter });
|
||||
const { onOperatorChange } = useSimpleTabFilterProps(
|
||||
props as unknown as Props,
|
||||
);
|
||||
onOperatorChange(Operators.ContainsAll);
|
||||
const lastCall =
|
||||
props.onChange.mock.calls[props.onChange.mock.calls.length - 1][0];
|
||||
expect(lastCall.operatorId).toEqual(Operators.ContainsAll);
|
||||
expect(lastCall.comparator).toBeUndefined();
|
||||
});
|
||||
|
||||
test('keeps the value when switching within the element family', () => {
|
||||
// Contains any <-> Contains all both take individual elements, so the
|
||||
// selected elements should carry over.
|
||||
const elementFilter = new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'scores',
|
||||
operatorId: Operators.ContainsAny,
|
||||
operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.ContainsAny].operation,
|
||||
comparator: ['5', '6'],
|
||||
clause: Clauses.Where,
|
||||
});
|
||||
const props = setup({ adhocFilter: elementFilter });
|
||||
const { onOperatorChange } = useSimpleTabFilterProps(
|
||||
props as unknown as Props,
|
||||
);
|
||||
onOperatorChange(Operators.ContainsAll);
|
||||
const lastCall =
|
||||
props.onChange.mock.calls[props.onChange.mock.calls.length - 1][0];
|
||||
expect(lastCall.comparator).toEqual(['5', '6']);
|
||||
});
|
||||
|
||||
test('passes the new adhocFilter to onChange after onComparatorChange', () => {
|
||||
const props = setup();
|
||||
const { onComparatorChange } = useSimpleTabFilterProps(
|
||||
@@ -399,6 +515,28 @@ test('will not display boolean operators when column type is string', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.each(['STRING', 'DATE'])(
|
||||
'will not display boolean operators when an expression column declares type %s',
|
||||
type => {
|
||||
const props = setup({
|
||||
datasource: {
|
||||
type: 'table' as const,
|
||||
datasource_name: 'table1',
|
||||
schema: 'schema',
|
||||
columns: [{ column_name: 'value', type, expression: '"value"' }],
|
||||
},
|
||||
adhocFilter: simpleAdhocFilter,
|
||||
});
|
||||
const { isOperatorRelevant } = useSimpleTabFilterProps(
|
||||
props as unknown as Props,
|
||||
);
|
||||
const booleanOnlyOperators = [Operators.IsTrue, Operators.IsFalse];
|
||||
booleanOnlyOperators.forEach(operator => {
|
||||
expect(isOperatorRelevant(operator, 'value')).toBe(false);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test('will display boolean operators when column is an expression', () => {
|
||||
const props = setup({
|
||||
datasource: {
|
||||
|
||||
+112
-14
@@ -32,6 +32,7 @@ import {
|
||||
isDefined,
|
||||
SupersetClient,
|
||||
} from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { styled, useTheme, css } from '@apache-superset/core/theme';
|
||||
import {
|
||||
Operators,
|
||||
@@ -118,6 +119,8 @@ export const useSimpleTabFilterProps = (props: Props) => {
|
||||
const isColumnNumber =
|
||||
!!column && (column.type === 'INT' || column.type === 'INTEGER');
|
||||
const isColumnFunction = !!column && !!column.expression;
|
||||
const isColumnMultiValue =
|
||||
!!column && column.type_generic === GenericDataType.MultiValue;
|
||||
|
||||
if (operator && operator === Operators.LatestPartition) {
|
||||
const { partitionColumn } = props;
|
||||
@@ -127,8 +130,41 @@ export const useSimpleTabFilterProps = (props: Props) => {
|
||||
// hide the TEMPORAL_RANGE operator
|
||||
return false;
|
||||
}
|
||||
// Element-level array operators only apply to multi-value columns.
|
||||
const arrayElementOperators = [
|
||||
Operators.ContainsAny,
|
||||
Operators.ContainsAll,
|
||||
Operators.IsEmpty,
|
||||
Operators.IsNotEmpty,
|
||||
Operators.LengthEquals,
|
||||
Operators.LengthGreaterThan,
|
||||
Operators.LengthLessThan,
|
||||
Operators.LengthGreaterThanOrEqual,
|
||||
Operators.LengthLessThanOrEqual,
|
||||
];
|
||||
if (arrayElementOperators.includes(operator)) {
|
||||
return isColumnMultiValue;
|
||||
}
|
||||
if (isColumnMultiValue) {
|
||||
// Array columns support whole-array operators (=, !=, In, Not in, null
|
||||
// checks) plus the element-level operators above. Scalar-only operators
|
||||
// (Like, <, >, <=, >=) are hidden because they aren't valid on an array.
|
||||
return [
|
||||
Operators.Equals,
|
||||
Operators.NotEquals,
|
||||
Operators.In,
|
||||
Operators.NotIn,
|
||||
Operators.IsNull,
|
||||
Operators.IsNotNull,
|
||||
...arrayElementOperators,
|
||||
].includes(operator);
|
||||
}
|
||||
if (operator === Operators.IsTrue || operator === Operators.IsFalse) {
|
||||
return isColumnBoolean || isColumnNumber || isColumnFunction;
|
||||
// An expression column may evaluate to a boolean, but that is only a
|
||||
// safe assumption while its type is unknown; a declared type wins.
|
||||
return (
|
||||
isColumnBoolean || isColumnNumber || (isColumnFunction && !column?.type)
|
||||
);
|
||||
}
|
||||
if (isColumnBoolean) {
|
||||
return operator === Operators.IsNull || operator === Operators.IsNotNull;
|
||||
@@ -167,9 +203,19 @@ export const useSimpleTabFilterProps = (props: Props) => {
|
||||
].operation
|
||||
: null;
|
||||
if (!isDefined(operator)) {
|
||||
// if operator is `null`, use the `IN` and reset the comparator.
|
||||
operator = Operators.In;
|
||||
operatorId = Operators.In;
|
||||
// The previous operator is not relevant for the new subject; pick a
|
||||
// sensible default and reset the comparator. Multi-value (array) columns
|
||||
// default to "Contains any" (element membership) rather than the
|
||||
// scalar-only IN.
|
||||
const newColumn = props.datasource.columns?.find(
|
||||
col => col.column_name === subject,
|
||||
);
|
||||
const defaultOperator =
|
||||
newColumn?.type_generic === GenericDataType.MultiValue
|
||||
? Operators.ContainsAny
|
||||
: Operators.In;
|
||||
operator = defaultOperator;
|
||||
operatorId = defaultOperator;
|
||||
comparator = undefined;
|
||||
}
|
||||
|
||||
@@ -193,10 +239,38 @@ export const useSimpleTabFilterProps = (props: Props) => {
|
||||
};
|
||||
const onOperatorChange = (operatorId: Operators) => {
|
||||
const currentComparator = props.adhocFilter.comparator;
|
||||
// The value space differs between operator families: element-level array
|
||||
// ops (Contains any/all) take individual elements, whole-array/scalar ops
|
||||
// (=, In, …) take whole arrays or scalars, Length ops take a count, and the
|
||||
// unary ops take nothing. A value from one family is meaningless in another,
|
||||
// so reset the value when the family changes (e.g. Equal to -> Contains all).
|
||||
const comparatorKind = (op?: Operators): string => {
|
||||
if (!op) return 'none';
|
||||
if (op === Operators.ContainsAny || op === Operators.ContainsAll) {
|
||||
return 'element';
|
||||
}
|
||||
if (
|
||||
op === Operators.LengthEquals ||
|
||||
op === Operators.LengthGreaterThan ||
|
||||
op === Operators.LengthLessThan ||
|
||||
op === Operators.LengthGreaterThanOrEqual ||
|
||||
op === Operators.LengthLessThanOrEqual
|
||||
) {
|
||||
return 'length';
|
||||
}
|
||||
if (DISABLE_INPUT_OPERATORS.includes(op)) return 'none';
|
||||
return 'value';
|
||||
};
|
||||
const valueFamilyChanged =
|
||||
comparatorKind(props.adhocFilter.operatorId as Operators | undefined) !==
|
||||
comparatorKind(operatorId);
|
||||
|
||||
let newComparator;
|
||||
// convert between list of comparators and individual comparators
|
||||
// (e.g. `in ('North America', 'Africa')` to `== 'North America'`)
|
||||
if (MULTI_OPERATORS.has(operatorId)) {
|
||||
if (valueFamilyChanged) {
|
||||
newComparator = undefined;
|
||||
} else if (MULTI_OPERATORS.has(operatorId)) {
|
||||
// convert between list of comparators and individual comparators
|
||||
// (e.g. `in ('North America', 'Africa')` to `== 'North America'`)
|
||||
newComparator = Array.isArray(currentComparator)
|
||||
? currentComparator
|
||||
: [currentComparator].filter(element => element != null);
|
||||
@@ -433,19 +507,42 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
|
||||
if (loadingComparatorSuggestions) {
|
||||
controller.abort();
|
||||
}
|
||||
// Element-level array operators (Contains any / Contains all) search
|
||||
// inside the array, so suggest individual elements; whole-array
|
||||
// operators (=, In, …) keep the default distinct-array suggestions.
|
||||
const { operatorId } = props.adhocFilter;
|
||||
const arrayElements =
|
||||
operatorId === Operators.ContainsAny ||
|
||||
operatorId === Operators.ContainsAll;
|
||||
setLoadingComparatorSuggestions(true);
|
||||
SupersetClient.get({
|
||||
signal,
|
||||
endpoint: `/api/v1/datasource/${datasource.type}/${datasource.id}/column/${col}/values/`,
|
||||
endpoint: `/api/v1/datasource/${datasource.type}/${datasource.id}/column/${col}/values/${
|
||||
arrayElements ? '?array_elements=true' : ''
|
||||
}`,
|
||||
})
|
||||
.then(({ json }) => {
|
||||
setSuggestions(
|
||||
json.result.map(
|
||||
(suggestion: null | number | boolean | string) => ({
|
||||
value: suggestion,
|
||||
label: optionLabel(suggestion),
|
||||
}),
|
||||
),
|
||||
json.result.map((suggestion: unknown) => {
|
||||
// Complex column values arrive as JS arrays or objects: whole
|
||||
// arrays for MULTI_VALUE columns (e.g. [5, 6, 7]) and Map/Tuple
|
||||
// objects for nested-container columns (e.g. {"a": ["x","y"]}).
|
||||
// A raw array/object is neither a valid single-select value
|
||||
// (antd collapses an array to its first element) nor renderable
|
||||
// as a React child (an object throws). Render it as its literal
|
||||
// string, which is also exactly what the backend's
|
||||
// parse_array_literal expects for the whole-array operators.
|
||||
if (suggestion !== null && typeof suggestion === 'object') {
|
||||
const literal = JSON.stringify(suggestion);
|
||||
return { value: literal, label: literal };
|
||||
}
|
||||
return {
|
||||
value: suggestion as null | number | boolean | string,
|
||||
label: optionLabel(
|
||||
suggestion as null | number | boolean | string,
|
||||
),
|
||||
};
|
||||
}),
|
||||
);
|
||||
setLoadingComparatorSuggestions(false);
|
||||
})
|
||||
@@ -464,6 +561,7 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
|
||||
}, [
|
||||
props.adhocFilter.subject,
|
||||
props.adhocFilter.clause,
|
||||
props.adhocFilter.operatorId,
|
||||
props.datasource,
|
||||
datePicker,
|
||||
]);
|
||||
|
||||
+11
@@ -44,6 +44,17 @@ export const OPERATORS_TO_SQL = {
|
||||
'IS NULL': 'IS NULL',
|
||||
'IS TRUE': 'IS TRUE',
|
||||
'IS FALSE': 'IS FALSE',
|
||||
// Element-level array operators (shown as filter labels; not executable SQL —
|
||||
// the Custom SQL tab is hidden for these).
|
||||
CONTAINS_ANY: 'CONTAINS ANY',
|
||||
CONTAINS_ALL: 'CONTAINS ALL',
|
||||
IS_EMPTY: 'IS EMPTY',
|
||||
IS_NOT_EMPTY: 'IS NOT EMPTY',
|
||||
LENGTH_EQUALS: 'LENGTH =',
|
||||
LENGTH_GREATER_THAN: 'LENGTH >',
|
||||
LENGTH_LESS_THAN: 'LENGTH <',
|
||||
LENGTH_GREATER_THAN_OR_EQUALS: 'LENGTH >=',
|
||||
LENGTH_LESS_THAN_OR_EQUALS: 'LENGTH <=',
|
||||
'LATEST PARTITION': ({
|
||||
datasource,
|
||||
}: {
|
||||
|
||||
@@ -191,6 +191,29 @@ describe('AdhocMetric', () => {
|
||||
expect(adhocMetric2.inferSqlExpressionAggregate()).toBeNull();
|
||||
});
|
||||
|
||||
test('can infer the new extended aggregates (STDDEV_SAMP/VAR_SAMP/MEDIAN) from sql expressions', () => {
|
||||
const stddevSamp = new AdhocMetric({
|
||||
expressionType: EXPRESSION_TYPES.SQL,
|
||||
sqlExpression: 'STDDEV_SAMP(my_column)',
|
||||
});
|
||||
expect(stddevSamp.inferSqlExpressionColumn()).toBe('my_column');
|
||||
expect(stddevSamp.inferSqlExpressionAggregate()).toBe('STDDEV_SAMP');
|
||||
|
||||
const varSamp = new AdhocMetric({
|
||||
expressionType: EXPRESSION_TYPES.SQL,
|
||||
sqlExpression: 'VAR_SAMP(my_column)',
|
||||
});
|
||||
expect(varSamp.inferSqlExpressionColumn()).toBe('my_column');
|
||||
expect(varSamp.inferSqlExpressionAggregate()).toBe('VAR_SAMP');
|
||||
|
||||
const median = new AdhocMetric({
|
||||
expressionType: EXPRESSION_TYPES.SQL,
|
||||
sqlExpression: 'MEDIAN(my_column)',
|
||||
});
|
||||
expect(median.inferSqlExpressionColumn()).toBe('my_column');
|
||||
expect(median.inferSqlExpressionAggregate()).toBe('MEDIAN');
|
||||
});
|
||||
|
||||
test('will infer columns and aggregates when converting to a simple expression', () => {
|
||||
const adhocMetric = new AdhocMetric({
|
||||
expressionType: EXPRESSION_TYPES.SQL,
|
||||
@@ -248,4 +271,20 @@ describe('AdhocMetric', () => {
|
||||
).toBe('COUNT_DISTINCT');
|
||||
expect(emptyColumnName.getDefaultLabel()).toBe('COUNT_DISTINCT');
|
||||
});
|
||||
|
||||
test('should prefill a portable MEDIAN expression for the Custom SQL tab, but keep the raw label', () => {
|
||||
const median = new AdhocMetric({
|
||||
column: valueColumn,
|
||||
aggregate: AGGREGATES.MEDIAN,
|
||||
hasCustomLabel: false,
|
||||
});
|
||||
// MEDIAN(column) isn't valid SQL on every engine this PR verifies it
|
||||
// for (e.g. PostgreSQL has no MEDIAN function), so the editable Custom
|
||||
// SQL tab is prefilled with the portable, standards-based spelling.
|
||||
expect(median.translateToSql({ transformCountDistinct: true })).toBe(
|
||||
'PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY value)',
|
||||
);
|
||||
// The display label stays the concise, human-readable form.
|
||||
expect(median.getDefaultLabel()).toBe('MEDIAN(value)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -153,6 +153,19 @@ export default class AdhocMetric {
|
||||
) {
|
||||
return `COUNT(DISTINCT ${column.slice(1, -1)})`;
|
||||
}
|
||||
// MEDIAN(column) isn't a real function on every engine this PR
|
||||
// verifies it for -- PostgreSQL/Redshift compile it to
|
||||
// PERCENTILE_CONT(0.5) WITHIN GROUP instead. `transformCountDistinct`
|
||||
// signals this call is prefilling the *editable, executable* Custom
|
||||
// SQL tab (not just a display label), so use the portable,
|
||||
// standards-based spelling there instead of the raw aggregate name.
|
||||
if (
|
||||
params.transformCountDistinct &&
|
||||
aggregate === AGGREGATES.MEDIAN &&
|
||||
/^\(.*\)$/.test(column)
|
||||
) {
|
||||
return `PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY ${column.slice(1, -1)})`;
|
||||
}
|
||||
return aggregate + column;
|
||||
}
|
||||
if (this.expressionType === EXPRESSION_TYPES.SQL) {
|
||||
|
||||
+10
-6
@@ -340,7 +340,9 @@ test('Should filter saved metrics by metric_name and verbose_name', async () =>
|
||||
|
||||
await userEvent.type(combobox, 'revenue');
|
||||
|
||||
let dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
|
||||
let dropdown = document.querySelector(
|
||||
'.ant-select-dropdown-list',
|
||||
) as HTMLElement;
|
||||
expect(within(dropdown).getByText('Gross Revenue')).toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Total Count')).not.toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Average Price')).not.toBeInTheDocument();
|
||||
@@ -352,7 +354,7 @@ test('Should filter saved metrics by metric_name and verbose_name', async () =>
|
||||
await userEvent.clear(combobox);
|
||||
await userEvent.type(combobox, 'Unique');
|
||||
|
||||
dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
|
||||
dropdown = document.querySelector('.ant-select-dropdown-list') as HTMLElement;
|
||||
expect(within(dropdown).getByText('Unique Users')).toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Total Count')).not.toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Gross Revenue')).not.toBeInTheDocument();
|
||||
@@ -360,7 +362,7 @@ test('Should filter saved metrics by metric_name and verbose_name', async () =>
|
||||
await userEvent.clear(combobox);
|
||||
await userEvent.type(combobox, 'total');
|
||||
|
||||
dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
|
||||
dropdown = document.querySelector('.ant-select-dropdown-list') as HTMLElement;
|
||||
expect(within(dropdown).getByText('Total Count')).toBeInTheDocument();
|
||||
expect(within(dropdown).getByText('Total Quantity')).toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Gross Revenue')).not.toBeInTheDocument();
|
||||
@@ -421,7 +423,9 @@ test('Should filter columns by column_name and verbose_name in Simple tab', asyn
|
||||
|
||||
await userEvent.type(columnCombobox, 'product');
|
||||
|
||||
let dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
|
||||
let dropdown = document.querySelector(
|
||||
'.ant-select-dropdown-list',
|
||||
) as HTMLElement;
|
||||
expect(within(dropdown).getByText('Product Title')).toBeInTheDocument();
|
||||
expect(
|
||||
within(dropdown).queryByText('User Identifier'),
|
||||
@@ -435,7 +439,7 @@ test('Should filter columns by column_name and verbose_name in Simple tab', asyn
|
||||
await userEvent.clear(columnCombobox);
|
||||
await userEvent.type(columnCombobox, 'Modified');
|
||||
|
||||
dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
|
||||
dropdown = document.querySelector('.ant-select-dropdown-list') as HTMLElement;
|
||||
expect(within(dropdown).getByText('Last Modified')).toBeInTheDocument();
|
||||
expect(
|
||||
within(dropdown).queryByText('User Identifier'),
|
||||
@@ -445,7 +449,7 @@ test('Should filter columns by column_name and verbose_name in Simple tab', asyn
|
||||
await userEvent.clear(columnCombobox);
|
||||
await userEvent.type(columnCombobox, '_at');
|
||||
|
||||
dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
|
||||
dropdown = document.querySelector('.ant-select-dropdown-list') as HTMLElement;
|
||||
expect(within(dropdown).getByText('Creation Timestamp')).toBeInTheDocument();
|
||||
expect(within(dropdown).getByText('Last Modified')).toBeInTheDocument();
|
||||
expect(
|
||||
|
||||
+2
-1
@@ -36,6 +36,7 @@ import {
|
||||
import sqlKeywords from 'src/SqlLab/utils/sqlKeywords';
|
||||
import { noOp } from 'src/utils/common';
|
||||
import {
|
||||
AGGREGATES_LABELS,
|
||||
AGGREGATES_OPTIONS,
|
||||
POPOVER_INITIAL_HEIGHT,
|
||||
POPOVER_INITIAL_WIDTH,
|
||||
@@ -548,7 +549,7 @@ function AdhocMetricEditPopover({
|
||||
<Select
|
||||
options={AGGREGATES_OPTIONS.map(option => ({
|
||||
value: option,
|
||||
label: option,
|
||||
label: AGGREGATES_LABELS[option] ?? option,
|
||||
key: option,
|
||||
}))}
|
||||
{...aggregateSelectProps}
|
||||
|
||||
@@ -23,11 +23,22 @@ export const AGGREGATES = {
|
||||
COUNT: 'COUNT',
|
||||
COUNT_DISTINCT: 'COUNT_DISTINCT',
|
||||
MAX: 'MAX',
|
||||
MEDIAN: 'MEDIAN',
|
||||
MIN: 'MIN',
|
||||
STDDEV_SAMP: 'STDDEV_SAMP',
|
||||
SUM: 'SUM',
|
||||
VAR_SAMP: 'VAR_SAMP',
|
||||
};
|
||||
export const AGGREGATES_OPTIONS = Object.values(AGGREGATES);
|
||||
|
||||
// Human-readable labels for aggregates whose raw enum value isn't
|
||||
// self-explanatory in the UI. Aggregates absent here (AVG, COUNT, MAX,
|
||||
// MEDIAN, MIN, SUM, ...) are already clear as their raw value.
|
||||
export const AGGREGATES_LABELS: Record<string, string> = {
|
||||
STDDEV_SAMP: t('Sample Standard Deviation'),
|
||||
VAR_SAMP: t('Sample Variance'),
|
||||
};
|
||||
|
||||
export enum Operators {
|
||||
Equals = 'EQUALS',
|
||||
NotEquals = 'NOT_EQUALS',
|
||||
@@ -45,6 +56,17 @@ export enum Operators {
|
||||
IsTrue = 'IS_TRUE',
|
||||
IsFalse = 'IS_FALSE',
|
||||
TemporalRange = 'TEMPORAL_RANGE',
|
||||
// Element-level operators for multi-value (array) columns
|
||||
ContainsAny = 'CONTAINS_ANY',
|
||||
ContainsAll = 'CONTAINS_ALL',
|
||||
IsEmpty = 'IS_EMPTY',
|
||||
IsNotEmpty = 'IS_NOT_EMPTY',
|
||||
// Length (element-count) comparison operators for array columns
|
||||
LengthEquals = 'LENGTH_EQUALS',
|
||||
LengthGreaterThan = 'LENGTH_GREATER_THAN',
|
||||
LengthLessThan = 'LENGTH_LESS_THAN',
|
||||
LengthGreaterThanOrEqual = 'LENGTH_GREATER_THAN_OR_EQUALS',
|
||||
LengthLessThanOrEqual = 'LENGTH_LESS_THAN_OR_EQUALS',
|
||||
}
|
||||
|
||||
export interface OperatorType {
|
||||
@@ -89,6 +111,39 @@ export const OPERATOR_ENUM_TO_OPERATOR_TYPE: {
|
||||
display: t('TEMPORAL_RANGE'),
|
||||
operation: 'TEMPORAL_RANGE',
|
||||
},
|
||||
[Operators.ContainsAny]: {
|
||||
display: t('Contains any'),
|
||||
operation: 'CONTAINS_ANY',
|
||||
},
|
||||
[Operators.ContainsAll]: {
|
||||
display: t('Contains all'),
|
||||
operation: 'CONTAINS_ALL',
|
||||
},
|
||||
[Operators.IsEmpty]: { display: t('Is empty'), operation: 'IS_EMPTY' },
|
||||
[Operators.IsNotEmpty]: {
|
||||
display: t('Is not empty'),
|
||||
operation: 'IS_NOT_EMPTY',
|
||||
},
|
||||
[Operators.LengthEquals]: {
|
||||
display: t('Length equals (=)'),
|
||||
operation: 'LENGTH_EQUALS',
|
||||
},
|
||||
[Operators.LengthGreaterThan]: {
|
||||
display: t('Length greater than (>)'),
|
||||
operation: 'LENGTH_GREATER_THAN',
|
||||
},
|
||||
[Operators.LengthLessThan]: {
|
||||
display: t('Length less than (<)'),
|
||||
operation: 'LENGTH_LESS_THAN',
|
||||
},
|
||||
[Operators.LengthGreaterThanOrEqual]: {
|
||||
display: t('Length greater or equal (>=)'),
|
||||
operation: 'LENGTH_GREATER_THAN_OR_EQUALS',
|
||||
},
|
||||
[Operators.LengthLessThanOrEqual]: {
|
||||
display: t('Length less or equal (<=)'),
|
||||
operation: 'LENGTH_LESS_THAN_OR_EQUALS',
|
||||
},
|
||||
};
|
||||
|
||||
export const OPERATORS_OPTIONS = Object.values(Operators) as Operators[];
|
||||
@@ -105,7 +160,12 @@ export const HAVING_OPERATORS = [
|
||||
Operators.GreaterThan,
|
||||
Operators.GreaterThanOrEqual,
|
||||
];
|
||||
export const MULTI_OPERATORS = new Set([Operators.In, Operators.NotIn]);
|
||||
export const MULTI_OPERATORS = new Set([
|
||||
Operators.In,
|
||||
Operators.NotIn,
|
||||
Operators.ContainsAny,
|
||||
Operators.ContainsAll,
|
||||
]);
|
||||
// CUSTOM_OPERATORS will show operator in simple mode,
|
||||
// but will generate customized sqlExpression
|
||||
export const CUSTOM_OPERATORS = new Set([
|
||||
@@ -120,12 +180,14 @@ export const DISABLE_INPUT_OPERATORS = [
|
||||
Operators.LatestPartition,
|
||||
Operators.IsTrue,
|
||||
Operators.IsFalse,
|
||||
Operators.IsEmpty,
|
||||
Operators.IsNotEmpty,
|
||||
];
|
||||
|
||||
export const sqlaAutoGeneratedMetricNameRegex =
|
||||
/^(sum|min|max|avg|count|count_distinct)__.*$/i;
|
||||
export const sqlaAutoGeneratedMetricRegex =
|
||||
/^(LONG|DOUBLE|FLOAT)?(SUM|AVG|MAX|MIN|COUNT)\([A-Z0-9_."]*\)$/i;
|
||||
/^(LONG|DOUBLE|FLOAT)?(SUM|AVG|MAX|MIN|COUNT|MEDIAN|STDDEV_SAMP|VAR_SAMP)\([A-Z0-9_."]*\)$/i;
|
||||
|
||||
export const TIME_FILTER_LABELS = {
|
||||
time_range: t('Time range'),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user