Compare commits

..
Author SHA1 Message Date
Elizabeth ThompsonandClaude Opus 4.8 c18c91a89d fix(chart): catch JSONDecodeError when parsing params on chart create
CreateChartCommand.__init__ called json.loads on the client-supplied
params string without guarding it. A malformed params value in
POST /api/v1/chart raised a raw JSONDecodeError out of __init__ --
before run()'s @transaction or validate() ran -- which the api.py
create() handler does not catch, surfacing as an opaque 500.

Wrap the parse and raise ChartInvalidError(exceptions=[...]) instead,
following the existing *ValidationError idiom, so the existing
except ChartInvalidError branch returns a 422. Adds a unit test
covering both the invalid-JSON and valid-JSON (happy path) cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 16:40:26 +00:00
296 changed files with 42455 additions and 56512 deletions
+2 -2
View File
@@ -67,7 +67,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
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@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
with:
category: "/language:${{matrix.language}}"
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Setup Java
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: "temurin"
java-version: "11"
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Setup Java
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: "temurin"
java-version: "11"
+1 -1
View File
@@ -118,7 +118,7 @@ jobs:
node-version-file: "./docs/.nvmrc"
- name: Setup Python
uses: ./.github/actions/setup-backend/
- uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: "zulu"
java-version: "21"
-1
View File
@@ -97,7 +97,6 @@ jobs:
mkdir -p ${{ github.workspace }}/superset-frontend/coverage
docker run \
-v ${{ github.workspace }}/superset-frontend/coverage:/app/superset-frontend/coverage \
-e CI=true \
--rm $TAG \
bash -c \
"npm run test -- --coverage --shard=${{ matrix.shard }}/8 --coverageReporters=json"
+3 -3
View File
@@ -88,9 +88,9 @@ repos:
language: system
pass_filenames: true
files: ^superset-frontend/.*\.(js|jsx|ts|tsx)$
- id: oxlint-docs
name: oxlint (docs)
entry: bash -c 'cd docs && FILES=$(printf "%s\n" "$@" | sed "s|^docs/||" | tr "\n" " ") && yarn lint --fix --quiet $FILES'
- id: eslint-docs
name: eslint (docs)
entry: bash -c 'cd docs && FILES=$(printf "%s\n" "$@" | sed "s|^docs/||" | tr "\n" " ") && yarn eslint --fix --quiet $FILES'
language: system
pass_filenames: true
files: ^docs/.*\.(js|jsx|ts|tsx)$
-117
View File
@@ -1,117 +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.
-->
## What Happened
Reproduction steps:
1. Create a Line chart on a dataset with a temporal column (e.g. `order_date`), set that column as the X-axis with **Month** as Time Grain, add a metric, and create the chart. This sets `groupby` to the temporal column and `time_grain_sqla` to `P1M`.
2. Switch the visualization type to **Table**.
3. Switch **Query Mode** to **Raw Records**.
4. Add the same temporal column as a column, then update the chart.
Expected: the temporal column renders raw (unaggregated) values, and the outgoing query for the chart data does not carry a time grain.
Actual: the outgoing request body's query object still contains `time_grain_sqla: "P1M"`, and the temporal column is rendered with monthly-grain formatting even though the SQL result rows are unaggregated raw records.
## Root Cause
`verified``superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx`, the `time_grain_sqla` control override (previously lines 267-295): its `visibility` function only checks whether the current `groupby` control value contains a temporal column. It never checks `query_mode`, unlike every sibling query-mode-dependent control in the same file (`groupby`, `metrics`, `percent_metrics`, `timeseries_limit_metric`, `show_totals`, `totals_aggregate`, all of which gate on `isAggMode`/`isRawMode`).
`groupby`'s own visibility is `isAggMode` with `resetOnHide: false` (line 241-242), so when the query mode is switched to Raw Records, `groupby`'s control row is hidden from the UI but its underlying value in `form_data` is deliberately *not* cleared (this lets a user switch back to Aggregate mode without losing their prior groupby selection). Because `time_grain_sqla`'s visibility only inspects `groupby`'s stale value, it keeps evaluating to `true` in Raw Records mode whenever that stale value happens to be a temporal column — which is exactly the case in this repro, since the user built the chart as an aggregated Line chart first.
Because the control is (incorrectly) still "visible", `StashFormDataContainer` (`superset-frontend/src/explore/components/StashFormDataContainer/index.tsx`) never stashes `time_grain_sqla` out of `form_data`, so the stale `P1M` value survives in the redux `form_data` used to build the outgoing query. The Table plugin's `buildQuery` (`superset-frontend/plugins/plugin-chart-table/src/buildQuery.ts:70-71`) then unconditionally reads `formData.time_grain_sqla` and passes it into `buildQueryContext`, which places it on the query object's `extras.time_grain_sqla` (`superset-frontend/packages/superset-ui-core/src/query/extractExtras.ts:76`) regardless of query mode — the core query builder has no concept of "query mode" and relies entirely on each chart plugin's `buildQuery` to omit fields that don't apply. The Table plugin only strips time-grain-derived behavior from the AGGREGATE branch (`buildQuery.ts:138`, `queryMode === QueryMode.Aggregate`); it never clears `time_grain_sqla` itself for the RAW branch.
On the render side, `transformProps.ts` already has a `queryMode !== QueryMode.Raw` guard (line 282) around applying granularity-based smart-date formatting, but that guard only prevents the *smart-date* auto-format path — it does not stop `time_grain_sqla` from being sent to (and, depending on backend/DB codec behavior, applied by) the query itself, which is what the ticket's network trace observed.
## Why It Wasn't Caught
Test gap: `plugins/plugin-chart-table/test/controlPanel.test.ts` only tested the case-insensitivity of the `groupby`-to-`is_dttm` lookup (added in PR #37893); no test exercised `query_mode` at all for this control. `git log -p --follow` on `controlPanel.tsx` shows this `visibility` function has never taken `query_mode`/`isAggMode` into account since it was first introduced in PR #21547 (2022) — the row containing the control was originally gated by `isFeatureEnabled(FeatureFlag.GENERIC_CHART_AXES) && isAggMode`, but `isAggMode` there is a function reference used in a boolean expression at module-eval time, not a call — so it was always truthy and never actually restricted anything to aggregate mode, even at introduction. That flag-gating wrapper (and the identical no-op `isAggMode` reference) was removed entirely in PR #26372 once `GENERIC_CHART_AXES` was made the default, leaving only the feature-flag-free `visibility` function seen today, which still never calls `isAggMode`/`isRawMode`. In other words, this was a latent gap from the control's original implementation, not a regression from an intentional design change.
Assumption gap: the author of the visibility function assumed "the `groupby` control holds a temporal column" was a sufficient proxy for "this chart is doing time-grain aggregation on that column," which is true only in AGGREGATE mode; the RAW_RECORDS query mode (where `groupby` is hidden and stale) was not considered.
## The Fix
The identical fix is applied in both `superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx` and `superset-frontend/plugins/plugin-chart-ag-grid-table/src/controlPanel.tsx` (see the sibling-plugin note above): the `time_grain_sqla` control override's `visibility` function.
Before:
```tsx
visibility: ({ controls }) => {
const dttmLookup = Object.fromEntries(
ensureIsArray(controls?.groupby?.options).map(option => [
(option.column_name || '').toLowerCase(),
option.is_dttm,
]),
);
return ensureIsArray(controls?.groupby.value)
.map(selection => {
if (isAdhocColumn(selection)) {
return true;
}
if (isPhysicalColumn(selection)) {
return !!dttmLookup[(selection || '').toLowerCase()];
}
return false;
})
.some(Boolean);
},
```
After: short-circuit to `false` unless the chart is in Aggregate query mode, using the same `isAggMode` helper every sibling control in this file already uses:
```tsx
visibility: ({ controls }) => {
if (!isAggMode({ controls })) {
return false;
}
const dttmLookup = Object.fromEntries(
ensureIsArray(controls?.groupby?.options).map(option => [
(option.column_name || '').toLowerCase(),
option.is_dttm,
]),
);
return ensureIsArray(controls?.groupby.value)
.map(selection => {
if (isAdhocColumn(selection)) {
return true;
}
if (isPhysicalColumn(selection)) {
return !!dttmLookup[(selection || '').toLowerCase()];
}
return false;
})
.some(Boolean);
},
```
With this change, switching to Raw Records mode makes `time_grain_sqla`'s visibility evaluate to `false` regardless of `groupby`'s stale value. `StashFormDataContainer` then stashes `time_grain_sqla` out of `form_data` (its default `disableStash` is unset and it does not opt out), so the outgoing query for a Raw Records table no longer carries a time grain, and the temporal column renders as raw, unformatted-by-grain data. Switching back to Aggregate mode (with a temporal `groupby` column) restores `time_grain_sqla`'s visibility and its previously stashed value, so the existing aggregated-table behavior is unchanged.
This fix does not touch the per-column `CUSTOMIZE` → D3 time format override path (`column_config` / `d3TimeFormat` in `transformProps.ts`), which continues to take precedence over any grain-derived formatting exactly as before.
`verified` — the sibling `plugin-chart-ag-grid-table` plugin (`superset-frontend/plugins/plugin-chart-ag-grid-table/src/controlPanel.tsx`) has the exact same `time_grain_sqla` visibility function, byte-for-byte the same logic as the pre-fix `plugin-chart-table` version, including the same `groupby` `resetOnHide: false` setup and a locally-defined `isAggMode` helper already in scope. It is registered in `superset-frontend/src/visualizations/presets/MainPreset.ts` as the `VizType.TableAgGrid` chart type behind `FeatureFlag.AgGridTableEnabled` (`AG_GRID_TABLE_ENABLED`, default `False` in `superset/config.py`). This flag is opt-in rather than dead/unshipped code — any deployment that enables it exposes a second, user-selectable "Table" style viz type through the same viz-picker/Explore flow, so it reproduces this bug identically once enabled. It is fixed with the identical change described above, rather than treated as an unreachable latent bug.
## Latent Bugs Found
- The same stale-`groupby`-value pattern (`resetOnHide: false`) is intentional and shared by several other controls in both `controlPanel.tsx` files (`metrics`, `percent_metrics`, `timeseries_limit_metric`, `order_by_cols`, `show_totals`, `totals_aggregate`); all of those already gate their own visibility on `isAggMode`/`isRawMode` directly, so they were not affected by this bug. Not fixed because out of scope: none found affected.
- `buildQuery.ts` (both `plugin-chart-table` and `plugin-chart-ag-grid-table`) reads `time_grain_sqla` via `extra_form_data?.time_grain_sqla || formData.time_grain_sqla` without any query-mode gate of its own; it happens to only be dangerous when `form_data.time_grain_sqla` is stale, which the controlPanel fixes now prevent. A defense-in-depth guard here (e.g. only reading `time_grain_sqla` when `queryMode === QueryMode.Aggregate`) was considered but not added, to keep this fix minimal and scoped to the one root cause.
## Prevention
Add a jest test asserting `query_mode`-dependent visibility for any control whose `visibility` function is customized per chart plugin, whenever the control also has a shared/base default visibility being overridden — a lint or contributor-doc note reminding authors that stale unrelated control values are the norm (`resetOnHide: false` is common) and any visibility override must re-derive its own query-mode gate rather than relying on another control's own gating. This regression is covered going forward by `plugins/plugin-chart-table/test/controlPanel.test.ts` and `plugins/plugin-chart-ag-grid-table/test/controlPanel.test.tsx`.
-5
View File
@@ -441,11 +441,6 @@ categories:
url: https://bestpair.info/
contributors: ["@stevensuting"]
- name: Veremes
url: https://www.veremes.com/
logo: veremes.svg
contributors: ["@verdier"]
- name: Virtuoso QA
url: https://www.virtuosoqa.com
+2 -23
View File
@@ -24,28 +24,9 @@ assists people when migrating to a new version.
## Next
### Archived dataset purge requires impact confirmation
`GET /api/v1/dataset/<uuid>/purge-impact` returns the charts and distinct
dashboards affected by permanently deleting an archived dataset, together with
an opaque `impact_token`. The dataset purge endpoint now requires that token in
the JSON body as `confirmed_impact_token`. API clients that call
`POST /api/v1/dataset/<uuid>/purge` must fetch and display the impact first;
requests with a missing or malformed token are rejected with 400.
The server rechecks the dependency identities immediately before mutation. If
they changed, purge performs no deletion and returns 409 with a refreshed impact
payload. Clients must display the new impact and obtain renewed confirmation
before retrying. Preview or recheck failures fail closed rather than treating
unknown impact as zero. Chart and dashboard purge endpoints are unchanged.
- `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.
- The `cockroachdb` extra (`pip install apache-superset[cockroachdb]`) now installs `sqlalchemy-cockroachdb` instead of the abandoned `cockroachdb` package, whose SQLAlchemy dialect could not be imported under SQLAlchemy 2.0. Existing environments with the old package installed should `pip uninstall cockroachdb && pip install sqlalchemy-cockroachdb` (or simply reinstall the extra) to restore CockroachDB connectivity.
### Native Value filter "Select all" always targets the whole column
The native "Value" filter's bulk "Select all" / "Clear" controls now operate on the entire loaded set of column values regardless of any text typed into the filter's search box. Previously the "Select all (N)" count briefly flickered to the search-scoped count before settling on the full-column count, and clicking "Select all" while searching could select only the currently matching subset. Search-scoped bulk selection was never a supported feature; the count is now stable and always matches what "Select all" selects (the full column). No configuration change is required.
### MCP tool results preserve stored string values
Structured MCP tool results no longer add `<UNTRUSTED-CONTENT>` wrappers or
@@ -249,10 +230,8 @@ Behavior changes to be aware of:
fail fast at the first phase check rather than erroring at setup.
- Dashboard reports whose charts have not mounted are no longer captured
blank: readiness is polled until the deadline, and the report fails loudly
if charts never mount. Large tiled reports also retry Chromium screenshot
stalls and suspicious uniform tiles, while persistent screenshot timeouts
fail loudly. Large tiled thumbnails use the same bounded retries, but retain
their previous failure contract after a persistent timeout.
if charts never mount. Thumbnails and non-report screenshots keep their
previous behavior.
### Embedded (guest token) API responses no longer echo database errors
+3 -3
View File
@@ -62,8 +62,8 @@ yarn version:remove:developer_docs <version> # Remove developer docs version
yarn version:remove:components <version> # Remove components version
# Quality Checks
yarn typecheck # TypeScript validation
yarn lint # Lint TypeScript/JavaScript files
yarn typecheck # TypeScript validation
yarn eslint # Lint TypeScript/JavaScript files
```
## 📁 Documentation Structure
@@ -431,7 +431,7 @@ yarn build
yarn typecheck
# Linting issues
yarn lint
yarn eslint
```
### Version Issues
@@ -566,55 +566,6 @@ def FLASK_APP_MUTATOR(app: Flask) -> None:
app.before_request_funcs.setdefault(None, []).append(make_session_permanent)
```
## Carrying extra data through chart and dashboard exports
Deployments often attach their own metadata to charts and dashboards — an owning
team, a catalogue entry, a cost centre — and need it to survive an export/import
round trip between environments. `EXTRA_ASSET_EXPORT_FIELDS` and
`EXTRA_ASSET_IMPORT_HANDLER` let you do that without forking the export commands.
The export hook receives the model and the asset type (`"chart"` or `"dashboard"`)
and returns a mapping, which is serialised under the `extra` key of the asset's
YAML. The import hook receives the model, the asset type and that same mapping,
once the asset exists and has an id:
```python
# superset_config.py
def _export_fields(model, asset_type):
return {"owning_team": lookup_team(model)}
def _import_handler(model, asset_type, extra):
if team := extra.get("owning_team"):
assign_team(model, team)
EXTRA_ASSET_EXPORT_FIELDS = _export_fields
EXTRA_ASSET_IMPORT_HANDLER = _import_handler
```
The exported YAML then carries:
```yaml
slice_name: Revenue by region
...
extra:
owning_team: analytics-platform
```
A few things worth knowing:
- **Both hooks are optional and default to `None`.** With neither configured,
exported files are byte-for-byte what they were before, and imports behave
identically.
- **Everything lives under the single `extra` key.** The import schemas reject
unknown top-level fields, so namespacing under `extra` keeps that strictness
while leaving you free to change the shape of your own payload later.
- **An export hook returning `None` or an empty mapping writes nothing**, so
assets without your metadata do not gain an empty `extra` block.
- **The import handler runs after the asset is created or updated**, which means
you can rely on `model.id`. Raising from it will fail the import.
## Customizing the landing page (index view)
The page served at `/` is rendered by an index view. By default Superset registers
@@ -253,58 +253,6 @@ def my_custom_auth_factory(app):
MCP_AUTH_FACTORY = my_custom_auth_factory
```
### Embedded Guest Authentication
Superset's [embedded dashboards](/user-docs/using-superset/embedding) feature mints short-lived **guest tokens** for anonymous/embedded viewers. The MCP server can accept these same guest tokens, so an embedded guest (e.g. an in-app chatbot next to an embedded dashboard) can call MCP tools scoped to the dashboards/resources named in its token.
This is opt-in and reuses the existing core guest-token configuration -- there is no MCP-specific guest secret or audience.
```python
# superset_config.py
FEATURE_FLAGS = {"EMBEDDED_SUPERSET": True} # required -- guest tokens only exist when this is on
MCP_EMBEDDED_GUEST_AUTH_ENABLED = True # opt-in for the MCP transport (default False)
```
Present the guest token the same way as any other bearer token:
```bash
curl -X POST http://localhost:5008/mcp \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer YOUR_GUEST_TOKEN' \
-d '{"jsonrpc": "2.0", "method": "tools/list", "id": 1}'
```
**How it works**
- A dedicated guest-token verifier validates the token against the same `GUEST_TOKEN_JWT_SECRET` / `GUEST_TOKEN_JWT_ALGO` / `GUEST_TOKEN_JWT_AUDIENCE` config used by embedded dashboards, replays the embedded structural checks, and enforces revocation (global version bumps and per-dashboard `guest_token_revoked_before` cutoffs). It runs *before* the JWT verifier described above, since guest tokens are signed with a different key/algorithm and would otherwise be rejected at the transport.
- A verified guest resolves to a Superset guest user as the highest-priority identity, so it's never downgraded to API-key / `MCP_DEV_USERNAME` / dev-mode resolution. Data access is scoped by the same checks (dataset allowlist, dashboard access, row-level security) that apply to embedded dashboard views.
- Guests are restricted to a default-deny allow-list, `MCP_GUEST_ALLOWED_TOOLS`, regardless of `MCP_RBAC_ENABLED`. Sensitive enumeration tools like `find_users` and `get_instance_info` are denied simply by being absent from the default list.
- Setting `MCP_AUTH_FACTORY` bypasses this whole path: a configured factory is tried first, and the default factory that wires up the guest-token verifier is never reached. If you rely on a custom auth factory (e.g. your own OIDC provider) alongside guest auth, that factory must verify guest tokens itself -- otherwise they're rejected regardless of `MCP_EMBEDDED_GUEST_AUTH_ENABLED`.
```python
# superset_config.py
MCP_GUEST_ALLOWED_TOOLS = {
"get_dashboard_info",
"get_dashboard_layout",
"list_dashboards",
"list_charts",
"get_chart_info",
"get_chart_data",
"get_chart_preview",
} # default
```
**Deployment requirements**
- The MCP server and the service that mints guest tokens (the Superset web app) must share `GUEST_TOKEN_JWT_SECRET` and `GUEST_TOKEN_JWT_AUDIENCE`. Set `GUEST_TOKEN_JWT_AUDIENCE` explicitly -- if it's unset, audience validation falls back to the URL host, which can differ between the two services and cause every guest token to fail validation.
- The `GUEST_ROLE_NAME` role (default `Public`) must exist -- a guest token is rejected if it does not.
- Don't set `MCP_DEV_USERNAME` on a deployment that also serves embedded guests.
- Restart the MCP process after toggling `EMBEDDED_SUPERSET` or `MCP_EMBEDDED_GUEST_AUTH_ENABLED` -- guest auth is wired up once at startup.
:::warning
`GUEST_TOKEN_JWT_SECRET` guards both the web embedding and MCP guest-auth surfaces. With `MCP_EMBEDDED_GUEST_AUTH_ENABLED` on, leaving it at its insecure default isn't just a forgery risk -- the MCP server refuses to start (`MCPAuthConfigError`) until you set a real secret shared with the guest-token minting service.
:::
---
## Connecting AI Clients
@@ -575,8 +523,6 @@ All MCP settings go in `superset_config.py`. Defaults are defined in `superset/m
| `MCP_JWT_DEBUG_ERRORS` | `False` | Log detailed JWT errors server-side (never exposed in HTTP responses per RFC 6750) |
| `MCP_AUTH_FACTORY` | `None` | Custom auth provider factory `(flask_app) -> auth_provider`. Takes precedence over built-in JWT |
| `MCP_USER_RESOLVER` | `None` | Custom function `(app, access_token) -> username` to extract a Superset username from a validated JWT token. When `None`, the default resolver checks `preferred_username`, `username`, `email`, and `sub` claims in that order. |
| `MCP_EMBEDDED_GUEST_AUTH_ENABLED` | `False` | Accept embedded [guest tokens](#embedded-guest-authentication) as Bearer auth. Also requires the `EMBEDDED_SUPERSET` feature flag. |
| `MCP_GUEST_ALLOWED_TOOLS` | see [default list](#embedded-guest-authentication) | The only tool names callable by embedded guests (default-deny), regardless of `MCP_RBAC_ENABLED`. |
### Response Size Guard
-33
View File
@@ -240,39 +240,6 @@ Font URLs are validated against a configurable allowlist. By default, fonts from
This feature works with the stock Docker image - no custom build required!
## Results Grid Configuration Overrides
Superset exposes a handful of opt-in tokens that customize the appearance of
the results grid in SQL Lab. These tokens have no effect unless explicitly
set, since the results grid otherwise falls back to its built-in defaults.
```python
THEME_DEFAULT = {
"token": {
"colorPrimary": "#2893B3",
# ... other Ant Design tokens
# Results grid overrides
"resultsGridRowHeight": 32,
"resultsGridHeaderFontSize": 13,
"resultsGridHeaderFontWeight": 600,
"resultsGridBorderRadius": 4,
"resultsGridNoStriping": True,
}
}
```
| Token | Type | Description |
| --- | --- | --- |
| `resultsGridRowHeight` | `number` | Row and header height, in pixels. |
| `resultsGridHeaderFontSize` | `number` | Header cell font size, in pixels. |
| `resultsGridHeaderFontWeight` | `number` | Header cell font weight. |
| `resultsGridBorderRadius` | `number` | Border radius applied to the grid and its wrapper, in pixels. |
| `resultsGridNoStriping` | `boolean` | When `true`, disables alternating row background striping. |
These tokens can also be set through the theme CRUD interface's JSON editor,
alongside any other Superset-specific tokens.
## ECharts Configuration Overrides
:::note
@@ -114,8 +114,8 @@ function MyExtension() {
## Source Links
- [Story file](https://github.com/apache/superset/blob/master/superset-frontend/packages/superset-core/src/components/Alert/Alert.stories.tsx)
- [Component source](https://github.com/apache/superset/blob/master/superset-frontend/packages/superset-core/src/components/Alert/index.tsx)
- [Story file](https://github.com/apache/superset/blob/master/superset-frontend/packages/superset-core/src/ui/components/Alert/Alert.stories.tsx)
- [Component source](https://github.com/apache/superset/blob/master/superset-frontend/packages/superset-core/src/ui/components/Alert/index.tsx)
---
@@ -47,8 +47,8 @@ export function MyExtensionPanel() {
Components in `@apache-superset/core/components` are automatically documented here. To add a new extension component:
1. Add the component to `superset-frontend/packages/superset-core/src/components/`
2. Export it from `superset-frontend/packages/superset-core/src/components/index.ts`
1. Add the component to `superset-frontend/packages/superset-core/src/ui/components/`
2. Export it from `superset-frontend/packages/superset-core/src/ui/components/index.ts`
3. Create a Storybook story with an `Interactive` export:
```tsx
@@ -49,10 +49,10 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
| ------ | ---------------------------------------------------------------------- | ------------------------------- |
| `GET` | [Get the CSRF token](/developer-docs/6.1.0/api/get-the-csrf-token) | `/api/v1/security/csrf_token/` |
| `POST` | [Get a guest token](/developer-docs/6.1.0/api/get-a-guest-token) | `/api/v1/security/guest_token/` |
| `POST` | [Create security login](/developer-docs/6.1.0/api/create-security-login) | `/api/v1/security/login` |
| `POST` | [Create security refresh](/developer-docs/6.1.0/api/create-security-refresh) | `/api/v1/security/refresh` |
| `GET` | [Get the CSRF token](/developer-docs/api/get-the-csrf-token) | `/api/v1/security/csrf_token/` |
| `POST` | [Get a guest token](/developer-docs/api/get-a-guest-token) | `/api/v1/security/guest_token/` |
| `POST` | [Create security login](/developer-docs/api/create-security-login) | `/api/v1/security/login` |
| `POST` | [Create security refresh](/developer-docs/api/create-security-refresh) | `/api/v1/security/refresh` |
---
@@ -65,34 +65,34 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
| -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
| `DELETE` | [Bulk delete dashboards](/developer-docs/6.1.0/api/bulk-delete-dashboards) | `/api/v1/dashboard/` |
| `GET` | [Get a list of dashboards](/developer-docs/6.1.0/api/get-a-list-of-dashboards) | `/api/v1/dashboard/` |
| `POST` | [Create a new dashboard](/developer-docs/6.1.0/api/create-a-new-dashboard) | `/api/v1/dashboard/` |
| `GET` | [Get metadata information about this API resource (dashboard--info)](/developer-docs/6.1.0/api/get-metadata-information-about-this-api-resource-dashboard-info) | `/api/v1/dashboard/_info` |
| `GET` | [Get a dashboard detail information](/developer-docs/6.1.0/api/get-a-dashboard-detail-information) | `/api/v1/dashboard/{id_or_slug}` |
| `GET` | [Get a dashboard's chart definitions.](/developer-docs/6.1.0/api/get-a-dashboards-chart-definitions) | `/api/v1/dashboard/{id_or_slug}/charts` |
| `POST` | [Create a copy of an existing dashboard](/developer-docs/6.1.0/api/create-a-copy-of-an-existing-dashboard) | `/api/v1/dashboard/{id_or_slug}/copy/` |
| `GET` | [Get dashboard's datasets](/developer-docs/6.1.0/api/get-dashboards-datasets) | `/api/v1/dashboard/{id_or_slug}/datasets` |
| `DELETE` | [Delete a dashboard's embedded configuration](/developer-docs/6.1.0/api/delete-a-dashboards-embedded-configuration) | `/api/v1/dashboard/{id_or_slug}/embedded` |
| `GET` | [Get the dashboard's embedded configuration](/developer-docs/6.1.0/api/get-the-dashboards-embedded-configuration) | `/api/v1/dashboard/{id_or_slug}/embedded` |
| `POST` | [Set a dashboard's embedded configuration](/developer-docs/6.1.0/api/set-a-dashboards-embedded-configuration) | `/api/v1/dashboard/{id_or_slug}/embedded` |
| `PUT` | [Update dashboard by id_or_slug embedded](/developer-docs/6.1.0/api/update-dashboard-by-id-or-slug-embedded) | `/api/v1/dashboard/{id_or_slug}/embedded` |
| `GET` | [Get dashboard's tabs](/developer-docs/6.1.0/api/get-dashboards-tabs) | `/api/v1/dashboard/{id_or_slug}/tabs` |
| `DELETE` | [Delete a dashboard](/developer-docs/6.1.0/api/delete-a-dashboard) | `/api/v1/dashboard/{pk}` |
| `PUT` | [Update a dashboard](/developer-docs/6.1.0/api/update-a-dashboard) | `/api/v1/dashboard/{pk}` |
| `POST` | [Compute and cache a screenshot (dashboard-pk-cache-dashboard-screenshot)](/developer-docs/6.1.0/api/compute-and-cache-a-screenshot-dashboard-pk-cache-dashboard-screenshot) | `/api/v1/dashboard/{pk}/cache_dashboard_screenshot/` |
| `PUT` | [Update chart customizations configuration for a dashboard.](/developer-docs/6.1.0/api/update-chart-customizations-configuration-for-a-dashboard) | `/api/v1/dashboard/{pk}/chart_customizations` |
| `PUT` | [Update colors configuration for a dashboard.](/developer-docs/6.1.0/api/update-colors-configuration-for-a-dashboard) | `/api/v1/dashboard/{pk}/colors` |
| `GET` | [Export dashboard as example bundle](/developer-docs/6.1.0/api/export-dashboard-as-example-bundle) | `/api/v1/dashboard/{pk}/export_as_example/` |
| `DELETE` | [Remove the dashboard from the user favorite list](/developer-docs/6.1.0/api/remove-the-dashboard-from-the-user-favorite-list) | `/api/v1/dashboard/{pk}/favorites/` |
| `POST` | [Mark the dashboard as favorite for the current user](/developer-docs/6.1.0/api/mark-the-dashboard-as-favorite-for-the-current-user) | `/api/v1/dashboard/{pk}/favorites/` |
| `PUT` | [Update native filters configuration for a dashboard.](/developer-docs/6.1.0/api/update-native-filters-configuration-for-a-dashboard) | `/api/v1/dashboard/{pk}/filters` |
| `GET` | [Get a computed screenshot from cache (dashboard-pk-screenshot-digest)](/developer-docs/6.1.0/api/get-a-computed-screenshot-from-cache-dashboard-pk-screenshot-digest) | `/api/v1/dashboard/{pk}/screenshot/{digest}/` |
| `GET` | [Get dashboard's thumbnail](/developer-docs/6.1.0/api/get-dashboards-thumbnail) | `/api/v1/dashboard/{pk}/thumbnail/{digest}/` |
| `GET` | [Download multiple dashboards as YAML files](/developer-docs/6.1.0/api/download-multiple-dashboards-as-yaml-files) | `/api/v1/dashboard/export/` |
| `GET` | [Check favorited dashboards for current user](/developer-docs/6.1.0/api/check-favorited-dashboards-for-current-user) | `/api/v1/dashboard/favorite_status/` |
| `POST` | [Import dashboard(s) with associated charts/datasets/databases](/developer-docs/6.1.0/api/import-dashboard-s-with-associated-charts-datasets-databases) | `/api/v1/dashboard/import/` |
| `GET` | [Get related fields data (dashboard-related-column-name)](/developer-docs/6.1.0/api/get-related-fields-data-dashboard-related-column-name) | `/api/v1/dashboard/related/{column_name}` |
| `DELETE` | [Bulk delete dashboards](/developer-docs/api/bulk-delete-dashboards) | `/api/v1/dashboard/` |
| `GET` | [Get a list of dashboards](/developer-docs/api/get-a-list-of-dashboards) | `/api/v1/dashboard/` |
| `POST` | [Create a new dashboard](/developer-docs/api/create-a-new-dashboard) | `/api/v1/dashboard/` |
| `GET` | [Get metadata information about this API resource (dashboard--info)](/developer-docs/api/get-metadata-information-about-this-api-resource-dashboard-info) | `/api/v1/dashboard/_info` |
| `GET` | [Get a dashboard detail information](/developer-docs/api/get-a-dashboard-detail-information) | `/api/v1/dashboard/{id_or_slug}` |
| `GET` | [Get a dashboard's chart definitions.](/developer-docs/api/get-a-dashboards-chart-definitions) | `/api/v1/dashboard/{id_or_slug}/charts` |
| `POST` | [Create a copy of an existing dashboard](/developer-docs/api/create-a-copy-of-an-existing-dashboard) | `/api/v1/dashboard/{id_or_slug}/copy/` |
| `GET` | [Get dashboard's datasets](/developer-docs/api/get-dashboards-datasets) | `/api/v1/dashboard/{id_or_slug}/datasets` |
| `DELETE` | [Delete a dashboard's embedded configuration](/developer-docs/api/delete-a-dashboards-embedded-configuration) | `/api/v1/dashboard/{id_or_slug}/embedded` |
| `GET` | [Get the dashboard's embedded configuration](/developer-docs/api/get-the-dashboards-embedded-configuration) | `/api/v1/dashboard/{id_or_slug}/embedded` |
| `POST` | [Set a dashboard's embedded configuration](/developer-docs/api/set-a-dashboards-embedded-configuration) | `/api/v1/dashboard/{id_or_slug}/embedded` |
| `PUT` | [Update dashboard by id_or_slug embedded](/developer-docs/api/update-dashboard-by-id-or-slug-embedded) | `/api/v1/dashboard/{id_or_slug}/embedded` |
| `GET` | [Get dashboard's tabs](/developer-docs/api/get-dashboards-tabs) | `/api/v1/dashboard/{id_or_slug}/tabs` |
| `DELETE` | [Delete a dashboard](/developer-docs/api/delete-a-dashboard) | `/api/v1/dashboard/{pk}` |
| `PUT` | [Update a dashboard](/developer-docs/api/update-a-dashboard) | `/api/v1/dashboard/{pk}` |
| `POST` | [Compute and cache a screenshot (dashboard-pk-cache-dashboard-screenshot)](/developer-docs/api/compute-and-cache-a-screenshot-dashboard-pk-cache-dashboard-screenshot) | `/api/v1/dashboard/{pk}/cache_dashboard_screenshot/` |
| `PUT` | [Update chart customizations configuration for a dashboard.](/developer-docs/api/update-chart-customizations-configuration-for-a-dashboard) | `/api/v1/dashboard/{pk}/chart_customizations` |
| `PUT` | [Update colors configuration for a dashboard.](/developer-docs/api/update-colors-configuration-for-a-dashboard) | `/api/v1/dashboard/{pk}/colors` |
| `GET` | [Export dashboard as example bundle](/developer-docs/api/export-dashboard-as-example-bundle) | `/api/v1/dashboard/{pk}/export_as_example/` |
| `DELETE` | [Remove the dashboard from the user favorite list](/developer-docs/api/remove-the-dashboard-from-the-user-favorite-list) | `/api/v1/dashboard/{pk}/favorites/` |
| `POST` | [Mark the dashboard as favorite for the current user](/developer-docs/api/mark-the-dashboard-as-favorite-for-the-current-user) | `/api/v1/dashboard/{pk}/favorites/` |
| `PUT` | [Update native filters configuration for a dashboard.](/developer-docs/api/update-native-filters-configuration-for-a-dashboard) | `/api/v1/dashboard/{pk}/filters` |
| `GET` | [Get a computed screenshot from cache (dashboard-pk-screenshot-digest)](/developer-docs/api/get-a-computed-screenshot-from-cache-dashboard-pk-screenshot-digest) | `/api/v1/dashboard/{pk}/screenshot/{digest}/` |
| `GET` | [Get dashboard's thumbnail](/developer-docs/api/get-dashboards-thumbnail) | `/api/v1/dashboard/{pk}/thumbnail/{digest}/` |
| `GET` | [Download multiple dashboards as YAML files](/developer-docs/api/download-multiple-dashboards-as-yaml-files) | `/api/v1/dashboard/export/` |
| `GET` | [Check favorited dashboards for current user](/developer-docs/api/check-favorited-dashboards-for-current-user) | `/api/v1/dashboard/favorite_status/` |
| `POST` | [Import dashboard(s) with associated charts/datasets/databases](/developer-docs/api/import-dashboard-s-with-associated-charts-datasets-databases) | `/api/v1/dashboard/import/` |
| `GET` | [Get related fields data (dashboard-related-column-name)](/developer-docs/api/get-related-fields-data-dashboard-related-column-name) | `/api/v1/dashboard/related/{column_name}` |
</details>
@@ -101,26 +101,26 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |
| `DELETE` | [Bulk delete charts](/developer-docs/6.1.0/api/bulk-delete-charts) | `/api/v1/chart/` |
| `GET` | [Get a list of charts](/developer-docs/6.1.0/api/get-a-list-of-charts) | `/api/v1/chart/` |
| `POST` | [Create a new chart](/developer-docs/6.1.0/api/create-a-new-chart) | `/api/v1/chart/` |
| `GET` | [Get metadata information about this API resource (chart--info)](/developer-docs/6.1.0/api/get-metadata-information-about-this-api-resource-chart-info) | `/api/v1/chart/_info` |
| `GET` | [Get a chart detail information](/developer-docs/6.1.0/api/get-a-chart-detail-information) | `/api/v1/chart/{id_or_uuid}` |
| `DELETE` | [Delete a chart](/developer-docs/6.1.0/api/delete-a-chart) | `/api/v1/chart/{pk}` |
| `PUT` | [Update a chart](/developer-docs/6.1.0/api/update-a-chart) | `/api/v1/chart/{pk}` |
| `GET` | [Compute and cache a screenshot (chart-pk-cache-screenshot)](/developer-docs/6.1.0/api/compute-and-cache-a-screenshot-chart-pk-cache-screenshot) | `/api/v1/chart/{pk}/cache_screenshot/` |
| `GET` | [Return payload data response for a chart](/developer-docs/6.1.0/api/return-payload-data-response-for-a-chart) | `/api/v1/chart/{pk}/data/` |
| `DELETE` | [Remove the chart from the user favorite list](/developer-docs/6.1.0/api/remove-the-chart-from-the-user-favorite-list) | `/api/v1/chart/{pk}/favorites/` |
| `POST` | [Mark the chart as favorite for the current user](/developer-docs/6.1.0/api/mark-the-chart-as-favorite-for-the-current-user) | `/api/v1/chart/{pk}/favorites/` |
| `GET` | [Get a computed screenshot from cache (chart-pk-screenshot-digest)](/developer-docs/6.1.0/api/get-a-computed-screenshot-from-cache-chart-pk-screenshot-digest) | `/api/v1/chart/{pk}/screenshot/{digest}/` |
| `GET` | [Get chart thumbnail](/developer-docs/6.1.0/api/get-chart-thumbnail) | `/api/v1/chart/{pk}/thumbnail/{digest}/` |
| `POST` | [Return payload data response for the given query (chart-data)](/developer-docs/6.1.0/api/return-payload-data-response-for-the-given-query-chart-data) | `/api/v1/chart/data` |
| `GET` | [Return payload data response for the given query (chart-data-cache-key)](/developer-docs/6.1.0/api/return-payload-data-response-for-the-given-query-chart-data-cache-key) | `/api/v1/chart/data/{cache_key}` |
| `GET` | [Download multiple charts as YAML files](/developer-docs/6.1.0/api/download-multiple-charts-as-yaml-files) | `/api/v1/chart/export/` |
| `GET` | [Check favorited charts for current user](/developer-docs/6.1.0/api/check-favorited-charts-for-current-user) | `/api/v1/chart/favorite_status/` |
| `POST` | [Import chart(s) with associated datasets and databases](/developer-docs/6.1.0/api/import-chart-s-with-associated-datasets-and-databases) | `/api/v1/chart/import/` |
| `GET` | [Get related fields data (chart-related-column-name)](/developer-docs/6.1.0/api/get-related-fields-data-chart-related-column-name) | `/api/v1/chart/related/{column_name}` |
| `PUT` | [Warm up the cache for the chart](/developer-docs/6.1.0/api/warm-up-the-cache-for-the-chart) | `/api/v1/chart/warm_up_cache` |
| `DELETE` | [Bulk delete charts](/developer-docs/api/bulk-delete-charts) | `/api/v1/chart/` |
| `GET` | [Get a list of charts](/developer-docs/api/get-a-list-of-charts) | `/api/v1/chart/` |
| `POST` | [Create a new chart](/developer-docs/api/create-a-new-chart) | `/api/v1/chart/` |
| `GET` | [Get metadata information about this API resource (chart--info)](/developer-docs/api/get-metadata-information-about-this-api-resource-chart-info) | `/api/v1/chart/_info` |
| `GET` | [Get a chart detail information](/developer-docs/api/get-a-chart-detail-information) | `/api/v1/chart/{id_or_uuid}` |
| `DELETE` | [Delete a chart](/developer-docs/api/delete-a-chart) | `/api/v1/chart/{pk}` |
| `PUT` | [Update a chart](/developer-docs/api/update-a-chart) | `/api/v1/chart/{pk}` |
| `GET` | [Compute and cache a screenshot (chart-pk-cache-screenshot)](/developer-docs/api/compute-and-cache-a-screenshot-chart-pk-cache-screenshot) | `/api/v1/chart/{pk}/cache_screenshot/` |
| `GET` | [Return payload data response for a chart](/developer-docs/api/return-payload-data-response-for-a-chart) | `/api/v1/chart/{pk}/data/` |
| `DELETE` | [Remove the chart from the user favorite list](/developer-docs/api/remove-the-chart-from-the-user-favorite-list) | `/api/v1/chart/{pk}/favorites/` |
| `POST` | [Mark the chart as favorite for the current user](/developer-docs/api/mark-the-chart-as-favorite-for-the-current-user) | `/api/v1/chart/{pk}/favorites/` |
| `GET` | [Get a computed screenshot from cache (chart-pk-screenshot-digest)](/developer-docs/api/get-a-computed-screenshot-from-cache-chart-pk-screenshot-digest) | `/api/v1/chart/{pk}/screenshot/{digest}/` |
| `GET` | [Get chart thumbnail](/developer-docs/api/get-chart-thumbnail) | `/api/v1/chart/{pk}/thumbnail/{digest}/` |
| `POST` | [Return payload data response for the given query (chart-data)](/developer-docs/api/return-payload-data-response-for-the-given-query-chart-data) | `/api/v1/chart/data` |
| `GET` | [Return payload data response for the given query (chart-data-cache-key)](/developer-docs/api/return-payload-data-response-for-the-given-query-chart-data-cache-key) | `/api/v1/chart/data/{cache_key}` |
| `GET` | [Download multiple charts as YAML files](/developer-docs/api/download-multiple-charts-as-yaml-files) | `/api/v1/chart/export/` |
| `GET` | [Check favorited charts for current user](/developer-docs/api/check-favorited-charts-for-current-user) | `/api/v1/chart/favorite_status/` |
| `POST` | [Import chart(s) with associated datasets and databases](/developer-docs/api/import-chart-s-with-associated-datasets-and-databases) | `/api/v1/chart/import/` |
| `GET` | [Get related fields data (chart-related-column-name)](/developer-docs/api/get-related-fields-data-chart-related-column-name) | `/api/v1/chart/related/{column_name}` |
| `PUT` | [Warm up the cache for the chart](/developer-docs/api/warm-up-the-cache-for-the-chart) | `/api/v1/chart/warm_up_cache` |
</details>
@@ -129,25 +129,25 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
| -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
| `DELETE` | [Bulk delete datasets](/developer-docs/6.1.0/api/bulk-delete-datasets) | `/api/v1/dataset/` |
| `GET` | [Get a list of datasets](/developer-docs/6.1.0/api/get-a-list-of-datasets) | `/api/v1/dataset/` |
| `POST` | [Create a new dataset](/developer-docs/6.1.0/api/create-a-new-dataset) | `/api/v1/dataset/` |
| `GET` | [Get metadata information about this API resource (dataset--info)](/developer-docs/6.1.0/api/get-metadata-information-about-this-api-resource-dataset-info) | `/api/v1/dataset/_info` |
| `GET` | [Get a dataset](/developer-docs/6.1.0/api/get-a-dataset) | `/api/v1/dataset/{id_or_uuid}` |
| `GET` | [Get charts and dashboards count associated to a dataset](/developer-docs/6.1.0/api/get-charts-and-dashboards-count-associated-to-a-dataset) | `/api/v1/dataset/{id_or_uuid}/related_objects` |
| `DELETE` | [Delete a dataset](/developer-docs/6.1.0/api/delete-a-dataset) | `/api/v1/dataset/{pk}` |
| `PUT` | [Update a dataset](/developer-docs/6.1.0/api/update-a-dataset) | `/api/v1/dataset/{pk}` |
| `DELETE` | [Delete a dataset column](/developer-docs/6.1.0/api/delete-a-dataset-column) | `/api/v1/dataset/{pk}/column/{column_id}` |
| `GET` | [Get dataset drill info](/developer-docs/6.1.0/api/get-dataset-drill-info) | `/api/v1/dataset/{pk}/drill_info/` |
| `DELETE` | [Delete a dataset metric](/developer-docs/6.1.0/api/delete-a-dataset-metric) | `/api/v1/dataset/{pk}/metric/{metric_id}` |
| `PUT` | [Refresh and update columns of a dataset](/developer-docs/6.1.0/api/refresh-and-update-columns-of-a-dataset) | `/api/v1/dataset/{pk}/refresh` |
| `GET` | [Get distinct values from field data (dataset-distinct-column-name)](/developer-docs/6.1.0/api/get-distinct-values-from-field-data-dataset-distinct-column-name) | `/api/v1/dataset/distinct/{column_name}` |
| `POST` | [Duplicate a dataset](/developer-docs/6.1.0/api/duplicate-a-dataset) | `/api/v1/dataset/duplicate` |
| `GET` | [Download multiple datasets as YAML files](/developer-docs/6.1.0/api/download-multiple-datasets-as-yaml-files) | `/api/v1/dataset/export/` |
| `POST` | [Retrieve a table by name, or create it if it does not exist](/developer-docs/6.1.0/api/retrieve-a-table-by-name-or-create-it-if-it-does-not-exist) | `/api/v1/dataset/get_or_create/` |
| `POST` | [Import dataset(s) with associated databases](/developer-docs/6.1.0/api/import-dataset-s-with-associated-databases) | `/api/v1/dataset/import/` |
| `GET` | [Get related fields data (dataset-related-column-name)](/developer-docs/6.1.0/api/get-related-fields-data-dataset-related-column-name) | `/api/v1/dataset/related/{column_name}` |
| `PUT` | [Warm up the cache for each chart powered by the given table](/developer-docs/6.1.0/api/warm-up-the-cache-for-each-chart-powered-by-the-given-table) | `/api/v1/dataset/warm_up_cache` |
| `DELETE` | [Bulk delete datasets](/developer-docs/api/bulk-delete-datasets) | `/api/v1/dataset/` |
| `GET` | [Get a list of datasets](/developer-docs/api/get-a-list-of-datasets) | `/api/v1/dataset/` |
| `POST` | [Create a new dataset](/developer-docs/api/create-a-new-dataset) | `/api/v1/dataset/` |
| `GET` | [Get metadata information about this API resource (dataset--info)](/developer-docs/api/get-metadata-information-about-this-api-resource-dataset-info) | `/api/v1/dataset/_info` |
| `GET` | [Get a dataset](/developer-docs/api/get-a-dataset) | `/api/v1/dataset/{id_or_uuid}` |
| `GET` | [Get charts and dashboards count associated to a dataset](/developer-docs/api/get-charts-and-dashboards-count-associated-to-a-dataset) | `/api/v1/dataset/{id_or_uuid}/related_objects` |
| `DELETE` | [Delete a dataset](/developer-docs/api/delete-a-dataset) | `/api/v1/dataset/{pk}` |
| `PUT` | [Update a dataset](/developer-docs/api/update-a-dataset) | `/api/v1/dataset/{pk}` |
| `DELETE` | [Delete a dataset column](/developer-docs/api/delete-a-dataset-column) | `/api/v1/dataset/{pk}/column/{column_id}` |
| `GET` | [Get dataset drill info](/developer-docs/api/get-dataset-drill-info) | `/api/v1/dataset/{pk}/drill_info/` |
| `DELETE` | [Delete a dataset metric](/developer-docs/api/delete-a-dataset-metric) | `/api/v1/dataset/{pk}/metric/{metric_id}` |
| `PUT` | [Refresh and update columns of a dataset](/developer-docs/api/refresh-and-update-columns-of-a-dataset) | `/api/v1/dataset/{pk}/refresh` |
| `GET` | [Get distinct values from field data (dataset-distinct-column-name)](/developer-docs/api/get-distinct-values-from-field-data-dataset-distinct-column-name) | `/api/v1/dataset/distinct/{column_name}` |
| `POST` | [Duplicate a dataset](/developer-docs/api/duplicate-a-dataset) | `/api/v1/dataset/duplicate` |
| `GET` | [Download multiple datasets as YAML files](/developer-docs/api/download-multiple-datasets-as-yaml-files) | `/api/v1/dataset/export/` |
| `POST` | [Retrieve a table by name, or create it if it does not exist](/developer-docs/api/retrieve-a-table-by-name-or-create-it-if-it-does-not-exist) | `/api/v1/dataset/get_or_create/` |
| `POST` | [Import dataset(s) with associated databases](/developer-docs/api/import-dataset-s-with-associated-databases) | `/api/v1/dataset/import/` |
| `GET` | [Get related fields data (dataset-related-column-name)](/developer-docs/api/get-related-fields-data-dataset-related-column-name) | `/api/v1/dataset/related/{column_name}` |
| `PUT` | [Warm up the cache for each chart powered by the given table](/developer-docs/api/warm-up-the-cache-for-each-chart-powered-by-the-given-table) | `/api/v1/dataset/warm_up_cache` |
</details>
@@ -156,36 +156,36 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| `GET` | [Get a list of databases](/developer-docs/6.1.0/api/get-a-list-of-databases) | `/api/v1/database/` |
| `POST` | [Create a new database](/developer-docs/6.1.0/api/create-a-new-database) | `/api/v1/database/` |
| `GET` | [Get metadata information about this API resource (database--info)](/developer-docs/6.1.0/api/get-metadata-information-about-this-api-resource-database-info) | `/api/v1/database/_info` |
| `DELETE` | [Delete a database](/developer-docs/6.1.0/api/delete-a-database) | `/api/v1/database/{pk}` |
| `GET` | [Get a database](/developer-docs/6.1.0/api/get-a-database) | `/api/v1/database/{pk}` |
| `PUT` | [Change a database](/developer-docs/6.1.0/api/change-a-database) | `/api/v1/database/{pk}` |
| `GET` | [Get all catalogs from a database](/developer-docs/6.1.0/api/get-all-catalogs-from-a-database) | `/api/v1/database/{pk}/catalogs/` |
| `GET` | [Get a database connection info](/developer-docs/6.1.0/api/get-a-database-connection-info) | `/api/v1/database/{pk}/connection` |
| `GET` | [Get function names supported by a database](/developer-docs/6.1.0/api/get-function-names-supported-by-a-database) | `/api/v1/database/{pk}/function_names/` |
| `GET` | [Get charts and dashboards count associated to a database](/developer-docs/6.1.0/api/get-charts-and-dashboards-count-associated-to-a-database) | `/api/v1/database/{pk}/related_objects/` |
| `GET` | [The list of the database schemas where to upload information](/developer-docs/6.1.0/api/the-list-of-the-database-schemas-where-to-upload-information) | `/api/v1/database/{pk}/schemas_access_for_file_upload/` |
| `GET` | [Get all schemas from a database](/developer-docs/6.1.0/api/get-all-schemas-from-a-database) | `/api/v1/database/{pk}/schemas/` |
| `GET` | [Get database select star for table (database-pk-select-star-table-name)](/developer-docs/6.1.0/api/get-database-select-star-for-table-database-pk-select-star-table-name) | `/api/v1/database/{pk}/select_star/{table_name}/` |
| `GET` | [Get database select star for table (database-pk-select-star-table-name-schema-name)](/developer-docs/6.1.0/api/get-database-select-star-for-table-database-pk-select-star-table-name-schema-name) | `/api/v1/database/{pk}/select_star/{table_name}/{schema_name}/` |
| `POST` | [Re-sync all permissions for a database connection](/developer-docs/6.1.0/api/re-sync-all-permissions-for-a-database-connection) | `/api/v1/database/{pk}/sync_permissions/` |
| `GET` | [Get table extra metadata (database-pk-table-extra-table-name-schema-name)](/developer-docs/6.1.0/api/get-table-extra-metadata-database-pk-table-extra-table-name-schema-name) | `/api/v1/database/{pk}/table_extra/{table_name}/{schema_name}/` |
| `GET` | [Get table metadata](/developer-docs/6.1.0/api/get-table-metadata) | `/api/v1/database/{pk}/table_metadata/` |
| `GET` | [Get table extra metadata (database-pk-table-metadata-extra)](/developer-docs/6.1.0/api/get-table-extra-metadata-database-pk-table-metadata-extra) | `/api/v1/database/{pk}/table_metadata/extra/` |
| `GET` | [Get database table metadata](/developer-docs/6.1.0/api/get-database-table-metadata) | `/api/v1/database/{pk}/table/{table_name}/{schema_name}/` |
| `GET` | [Get a list of tables for given database](/developer-docs/6.1.0/api/get-a-list-of-tables-for-given-database) | `/api/v1/database/{pk}/tables/` |
| `POST` | [Upload a file to a database table](/developer-docs/6.1.0/api/upload-a-file-to-a-database-table) | `/api/v1/database/{pk}/upload/` |
| `POST` | [Validate arbitrary SQL](/developer-docs/6.1.0/api/validate-arbitrary-sql) | `/api/v1/database/{pk}/validate_sql/` |
| `GET` | [Get names of databases currently available](/developer-docs/6.1.0/api/get-names-of-databases-currently-available) | `/api/v1/database/available/` |
| `GET` | [Download database(s) and associated dataset(s) as a zip file](/developer-docs/6.1.0/api/download-database-s-and-associated-dataset-s-as-a-zip-file) | `/api/v1/database/export/` |
| `POST` | [Import database(s) with associated datasets](/developer-docs/6.1.0/api/import-database-s-with-associated-datasets) | `/api/v1/database/import/` |
| `GET` | [Receive personal access tokens from OAuth2](/developer-docs/6.1.0/api/receive-personal-access-tokens-from-o-auth-2) | `/api/v1/database/oauth2/` |
| `GET` | [Get related fields data (database-related-column-name)](/developer-docs/6.1.0/api/get-related-fields-data-database-related-column-name) | `/api/v1/database/related/{column_name}` |
| `POST` | [Test a database connection](/developer-docs/6.1.0/api/test-a-database-connection) | `/api/v1/database/test_connection/` |
| `POST` | [Upload a file and returns file metadata](/developer-docs/6.1.0/api/upload-a-file-and-returns-file-metadata) | `/api/v1/database/upload_metadata/` |
| `POST` | [Validate database connection parameters](/developer-docs/6.1.0/api/validate-database-connection-parameters) | `/api/v1/database/validate_parameters/` |
| `GET` | [Get a list of databases](/developer-docs/api/get-a-list-of-databases) | `/api/v1/database/` |
| `POST` | [Create a new database](/developer-docs/api/create-a-new-database) | `/api/v1/database/` |
| `GET` | [Get metadata information about this API resource (database--info)](/developer-docs/api/get-metadata-information-about-this-api-resource-database-info) | `/api/v1/database/_info` |
| `DELETE` | [Delete a database](/developer-docs/api/delete-a-database) | `/api/v1/database/{pk}` |
| `GET` | [Get a database](/developer-docs/api/get-a-database) | `/api/v1/database/{pk}` |
| `PUT` | [Change a database](/developer-docs/api/change-a-database) | `/api/v1/database/{pk}` |
| `GET` | [Get all catalogs from a database](/developer-docs/api/get-all-catalogs-from-a-database) | `/api/v1/database/{pk}/catalogs/` |
| `GET` | [Get a database connection info](/developer-docs/api/get-a-database-connection-info) | `/api/v1/database/{pk}/connection` |
| `GET` | [Get function names supported by a database](/developer-docs/api/get-function-names-supported-by-a-database) | `/api/v1/database/{pk}/function_names/` |
| `GET` | [Get charts and dashboards count associated to a database](/developer-docs/api/get-charts-and-dashboards-count-associated-to-a-database) | `/api/v1/database/{pk}/related_objects/` |
| `GET` | [The list of the database schemas where to upload information](/developer-docs/api/the-list-of-the-database-schemas-where-to-upload-information) | `/api/v1/database/{pk}/schemas_access_for_file_upload/` |
| `GET` | [Get all schemas from a database](/developer-docs/api/get-all-schemas-from-a-database) | `/api/v1/database/{pk}/schemas/` |
| `GET` | [Get database select star for table (database-pk-select-star-table-name)](/developer-docs/api/get-database-select-star-for-table-database-pk-select-star-table-name) | `/api/v1/database/{pk}/select_star/{table_name}/` |
| `GET` | [Get database select star for table (database-pk-select-star-table-name-schema-name)](/developer-docs/api/get-database-select-star-for-table-database-pk-select-star-table-name-schema-name) | `/api/v1/database/{pk}/select_star/{table_name}/{schema_name}/` |
| `POST` | [Re-sync all permissions for a database connection](/developer-docs/api/re-sync-all-permissions-for-a-database-connection) | `/api/v1/database/{pk}/sync_permissions/` |
| `GET` | [Get table extra metadata (database-pk-table-extra-table-name-schema-name)](/developer-docs/api/get-table-extra-metadata-database-pk-table-extra-table-name-schema-name) | `/api/v1/database/{pk}/table_extra/{table_name}/{schema_name}/` |
| `GET` | [Get table metadata](/developer-docs/api/get-table-metadata) | `/api/v1/database/{pk}/table_metadata/` |
| `GET` | [Get table extra metadata (database-pk-table-metadata-extra)](/developer-docs/api/get-table-extra-metadata-database-pk-table-metadata-extra) | `/api/v1/database/{pk}/table_metadata/extra/` |
| `GET` | [Get database table metadata](/developer-docs/api/get-database-table-metadata) | `/api/v1/database/{pk}/table/{table_name}/{schema_name}/` |
| `GET` | [Get a list of tables for given database](/developer-docs/api/get-a-list-of-tables-for-given-database) | `/api/v1/database/{pk}/tables/` |
| `POST` | [Upload a file to a database table](/developer-docs/api/upload-a-file-to-a-database-table) | `/api/v1/database/{pk}/upload/` |
| `POST` | [Validate arbitrary SQL](/developer-docs/api/validate-arbitrary-sql) | `/api/v1/database/{pk}/validate_sql/` |
| `GET` | [Get names of databases currently available](/developer-docs/api/get-names-of-databases-currently-available) | `/api/v1/database/available/` |
| `GET` | [Download database(s) and associated dataset(s) as a zip file](/developer-docs/api/download-database-s-and-associated-dataset-s-as-a-zip-file) | `/api/v1/database/export/` |
| `POST` | [Import database(s) with associated datasets](/developer-docs/api/import-database-s-with-associated-datasets) | `/api/v1/database/import/` |
| `GET` | [Receive personal access tokens from OAuth2](/developer-docs/api/receive-personal-access-tokens-from-o-auth-2) | `/api/v1/database/oauth2/` |
| `GET` | [Get related fields data (database-related-column-name)](/developer-docs/api/get-related-fields-data-database-related-column-name) | `/api/v1/database/related/{column_name}` |
| `POST` | [Test a database connection](/developer-docs/api/test-a-database-connection) | `/api/v1/database/test_connection/` |
| `POST` | [Upload a file and returns file metadata](/developer-docs/api/upload-a-file-and-returns-file-metadata) | `/api/v1/database/upload_metadata/` |
| `POST` | [Validate database connection parameters](/developer-docs/api/validate-database-connection-parameters) | `/api/v1/database/validate_parameters/` |
</details>
@@ -196,7 +196,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------ |
| `GET` | [Assemble Explore related information in a single endpoint](/developer-docs/6.1.0/api/assemble-explore-related-information-in-a-single-endpoint) | `/api/v1/explore/` |
| `GET` | [Assemble Explore related information in a single endpoint](/developer-docs/api/assemble-explore-related-information-in-a-single-endpoint) | `/api/v1/explore/` |
</details>
@@ -205,13 +205,13 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
| ------ | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------ |
| `GET` | [Get the bootstrap data for SqlLab page](/developer-docs/6.1.0/api/get-the-bootstrap-data-for-sql-lab-page) | `/api/v1/sqllab/` |
| `POST` | [Estimate the SQL query execution cost](/developer-docs/6.1.0/api/estimate-the-sql-query-execution-cost) | `/api/v1/sqllab/estimate/` |
| `POST` | [Execute a SQL query](/developer-docs/6.1.0/api/execute-a-sql-query) | `/api/v1/sqllab/execute/` |
| `POST` | [Export SQL query results to CSV with streaming](/developer-docs/6.1.0/api/export-sql-query-results-to-csv-with-streaming) | `/api/v1/sqllab/export_streaming/` |
| `GET` | [Export the SQL query results to a CSV](/developer-docs/6.1.0/api/export-the-sql-query-results-to-a-csv) | `/api/v1/sqllab/export/{client_id}/` |
| `POST` | [Format SQL code](/developer-docs/6.1.0/api/format-sql-code) | `/api/v1/sqllab/format_sql/` |
| `GET` | [Get the result of a SQL query execution](/developer-docs/6.1.0/api/get-the-result-of-a-sql-query-execution) | `/api/v1/sqllab/results/` |
| `GET` | [Get the bootstrap data for SqlLab page](/developer-docs/api/get-the-bootstrap-data-for-sql-lab-page) | `/api/v1/sqllab/` |
| `POST` | [Estimate the SQL query execution cost](/developer-docs/api/estimate-the-sql-query-execution-cost) | `/api/v1/sqllab/estimate/` |
| `POST` | [Execute a SQL query](/developer-docs/api/execute-a-sql-query) | `/api/v1/sqllab/execute/` |
| `POST` | [Export SQL query results to CSV with streaming](/developer-docs/api/export-sql-query-results-to-csv-with-streaming) | `/api/v1/sqllab/export_streaming/` |
| `GET` | [Export the SQL query results to a CSV](/developer-docs/api/export-the-sql-query-results-to-a-csv) | `/api/v1/sqllab/export/{client_id}/` |
| `POST` | [Format SQL code](/developer-docs/api/format-sql-code) | `/api/v1/sqllab/format_sql/` |
| `GET` | [Get the result of a SQL query execution](/developer-docs/api/get-the-result-of-a-sql-query-execution) | `/api/v1/sqllab/results/` |
</details>
@@ -220,23 +220,23 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------- |
| `GET` | [Get a list of queries](/developer-docs/6.1.0/api/get-a-list-of-queries) | `/api/v1/query/` |
| `GET` | [Get query detail information](/developer-docs/6.1.0/api/get-query-detail-information) | `/api/v1/query/{pk}` |
| `GET` | [Get distinct values from field data (query-distinct-column-name)](/developer-docs/6.1.0/api/get-distinct-values-from-field-data-query-distinct-column-name) | `/api/v1/query/distinct/{column_name}` |
| `GET` | [Get related fields data (query-related-column-name)](/developer-docs/6.1.0/api/get-related-fields-data-query-related-column-name) | `/api/v1/query/related/{column_name}` |
| `POST` | [Manually stop a query with client_id](/developer-docs/6.1.0/api/manually-stop-a-query-with-client-id) | `/api/v1/query/stop` |
| `GET` | [Get a list of queries that changed after last_updated_ms](/developer-docs/6.1.0/api/get-a-list-of-queries-that-changed-after-last-updated-ms) | `/api/v1/query/updated_since` |
| `DELETE` | [Bulk delete saved queries](/developer-docs/6.1.0/api/bulk-delete-saved-queries) | `/api/v1/saved_query/` |
| `GET` | [Get a list of saved queries](/developer-docs/6.1.0/api/get-a-list-of-saved-queries) | `/api/v1/saved_query/` |
| `POST` | [Create a saved query](/developer-docs/6.1.0/api/create-a-saved-query) | `/api/v1/saved_query/` |
| `GET` | [Get metadata information about this API resource (saved-query--info)](/developer-docs/6.1.0/api/get-metadata-information-about-this-api-resource-saved-query-info) | `/api/v1/saved_query/_info` |
| `DELETE` | [Delete a saved query](/developer-docs/6.1.0/api/delete-a-saved-query) | `/api/v1/saved_query/{pk}` |
| `GET` | [Get a saved query](/developer-docs/6.1.0/api/get-a-saved-query) | `/api/v1/saved_query/{pk}` |
| `PUT` | [Update a saved query](/developer-docs/6.1.0/api/update-a-saved-query) | `/api/v1/saved_query/{pk}` |
| `GET` | [Get distinct values from field data (saved-query-distinct-column-name)](/developer-docs/6.1.0/api/get-distinct-values-from-field-data-saved-query-distinct-column-name) | `/api/v1/saved_query/distinct/{column_name}` |
| `GET` | [Download multiple saved queries as YAML files](/developer-docs/6.1.0/api/download-multiple-saved-queries-as-yaml-files) | `/api/v1/saved_query/export/` |
| `POST` | [Import saved queries with associated databases](/developer-docs/6.1.0/api/import-saved-queries-with-associated-databases) | `/api/v1/saved_query/import/` |
| `GET` | [Get related fields data (saved-query-related-column-name)](/developer-docs/6.1.0/api/get-related-fields-data-saved-query-related-column-name) | `/api/v1/saved_query/related/{column_name}` |
| `GET` | [Get a list of queries](/developer-docs/api/get-a-list-of-queries) | `/api/v1/query/` |
| `GET` | [Get query detail information](/developer-docs/api/get-query-detail-information) | `/api/v1/query/{pk}` |
| `GET` | [Get distinct values from field data (query-distinct-column-name)](/developer-docs/api/get-distinct-values-from-field-data-query-distinct-column-name) | `/api/v1/query/distinct/{column_name}` |
| `GET` | [Get related fields data (query-related-column-name)](/developer-docs/api/get-related-fields-data-query-related-column-name) | `/api/v1/query/related/{column_name}` |
| `POST` | [Manually stop a query with client_id](/developer-docs/api/manually-stop-a-query-with-client-id) | `/api/v1/query/stop` |
| `GET` | [Get a list of queries that changed after last_updated_ms](/developer-docs/api/get-a-list-of-queries-that-changed-after-last-updated-ms) | `/api/v1/query/updated_since` |
| `DELETE` | [Bulk delete saved queries](/developer-docs/api/bulk-delete-saved-queries) | `/api/v1/saved_query/` |
| `GET` | [Get a list of saved queries](/developer-docs/api/get-a-list-of-saved-queries) | `/api/v1/saved_query/` |
| `POST` | [Create a saved query](/developer-docs/api/create-a-saved-query) | `/api/v1/saved_query/` |
| `GET` | [Get metadata information about this API resource (saved-query--info)](/developer-docs/api/get-metadata-information-about-this-api-resource-saved-query-info) | `/api/v1/saved_query/_info` |
| `DELETE` | [Delete a saved query](/developer-docs/api/delete-a-saved-query) | `/api/v1/saved_query/{pk}` |
| `GET` | [Get a saved query](/developer-docs/api/get-a-saved-query) | `/api/v1/saved_query/{pk}` |
| `PUT` | [Update a saved query](/developer-docs/api/update-a-saved-query) | `/api/v1/saved_query/{pk}` |
| `GET` | [Get distinct values from field data (saved-query-distinct-column-name)](/developer-docs/api/get-distinct-values-from-field-data-saved-query-distinct-column-name) | `/api/v1/saved_query/distinct/{column_name}` |
| `GET` | [Download multiple saved queries as YAML files](/developer-docs/api/download-multiple-saved-queries-as-yaml-files) | `/api/v1/saved_query/export/` |
| `POST` | [Import saved queries with associated databases](/developer-docs/api/import-saved-queries-with-associated-databases) | `/api/v1/saved_query/import/` |
| `GET` | [Get related fields data (saved-query-related-column-name)](/developer-docs/api/get-related-fields-data-saved-query-related-column-name) | `/api/v1/saved_query/related/{column_name}` |
</details>
@@ -245,8 +245,8 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
| ------ | -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `GET` | [Get possible values for a datasource column](/developer-docs/6.1.0/api/get-possible-values-for-a-datasource-column) | `/api/v1/datasource/{datasource_type}/{datasource_id}/column/{column_name}/values/` |
| `POST` | [Validate a SQL expression against a datasource](/developer-docs/6.1.0/api/validate-a-sql-expression-against-a-datasource) | `/api/v1/datasource/{datasource_type}/{datasource_id}/validate_expression/` |
| `GET` | [Get possible values for a datasource column](/developer-docs/api/get-possible-values-for-a-datasource-column) | `/api/v1/datasource/{datasource_type}/{datasource_id}/column/{column_name}/values/` |
| `POST` | [Validate a SQL expression against a datasource](/developer-docs/api/validate-a-sql-expression-against-a-datasource) | `/api/v1/datasource/{datasource_type}/{datasource_id}/validate_expression/` |
</details>
@@ -255,8 +255,8 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
| ------ | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------ |
| `GET` | [Return an AdvancedDataTypeResponse](/developer-docs/6.1.0/api/return-an-advanced-data-type-response) | `/api/v1/advanced_data_type/convert` |
| `GET` | [Return a list of available advanced data types](/developer-docs/6.1.0/api/return-a-list-of-available-advanced-data-types) | `/api/v1/advanced_data_type/types` |
| `GET` | [Return an AdvancedDataTypeResponse](/developer-docs/api/return-an-advanced-data-type-response) | `/api/v1/advanced_data_type/convert` |
| `GET` | [Return a list of available advanced data types](/developer-docs/api/return-a-list-of-available-advanced-data-types) | `/api/v1/advanced_data_type/types` |
</details>
@@ -267,21 +267,21 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
| -------- | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------- |
| `DELETE` | [Bulk delete tags](/developer-docs/6.1.0/api/bulk-delete-tags) | `/api/v1/tag/` |
| `GET` | [Get a list of tags](/developer-docs/6.1.0/api/get-a-list-of-tags) | `/api/v1/tag/` |
| `POST` | [Create a tag](/developer-docs/6.1.0/api/create-a-tag) | `/api/v1/tag/` |
| `GET` | [Get metadata information about tag API endpoints](/developer-docs/6.1.0/api/get-metadata-information-about-tag-api-endpoints) | `/api/v1/tag/_info` |
| `POST` | [Add tags to an object](/developer-docs/6.1.0/api/add-tags-to-an-object) | `/api/v1/tag/{object_type}/{object_id}/` |
| `DELETE` | [Delete a tagged object](/developer-docs/6.1.0/api/delete-a-tagged-object) | `/api/v1/tag/{object_type}/{object_id}/{tag}/` |
| `DELETE` | [Delete a tag](/developer-docs/6.1.0/api/delete-a-tag) | `/api/v1/tag/{pk}` |
| `GET` | [Get a tag detail information](/developer-docs/6.1.0/api/get-a-tag-detail-information) | `/api/v1/tag/{pk}` |
| `PUT` | [Update a tag](/developer-docs/6.1.0/api/update-a-tag) | `/api/v1/tag/{pk}` |
| `DELETE` | [Delete tag by pk favorites](/developer-docs/6.1.0/api/delete-tag-by-pk-favorites) | `/api/v1/tag/{pk}/favorites/` |
| `POST` | [Create tag by pk favorites](/developer-docs/6.1.0/api/create-tag-by-pk-favorites) | `/api/v1/tag/{pk}/favorites/` |
| `POST` | [Bulk create tags and tagged objects](/developer-docs/6.1.0/api/bulk-create-tags-and-tagged-objects) | `/api/v1/tag/bulk_create` |
| `GET` | [Get tag favorite status](/developer-docs/6.1.0/api/get-tag-favorite-status) | `/api/v1/tag/favorite_status/` |
| `GET` | [Get all objects associated with a tag](/developer-docs/6.1.0/api/get-all-objects-associated-with-a-tag) | `/api/v1/tag/get_objects/` |
| `GET` | [Get related fields data (tag-related-column-name)](/developer-docs/6.1.0/api/get-related-fields-data-tag-related-column-name) | `/api/v1/tag/related/{column_name}` |
| `DELETE` | [Bulk delete tags](/developer-docs/api/bulk-delete-tags) | `/api/v1/tag/` |
| `GET` | [Get a list of tags](/developer-docs/api/get-a-list-of-tags) | `/api/v1/tag/` |
| `POST` | [Create a tag](/developer-docs/api/create-a-tag) | `/api/v1/tag/` |
| `GET` | [Get metadata information about tag API endpoints](/developer-docs/api/get-metadata-information-about-tag-api-endpoints) | `/api/v1/tag/_info` |
| `POST` | [Add tags to an object](/developer-docs/api/add-tags-to-an-object) | `/api/v1/tag/{object_type}/{object_id}/` |
| `DELETE` | [Delete a tagged object](/developer-docs/api/delete-a-tagged-object) | `/api/v1/tag/{object_type}/{object_id}/{tag}/` |
| `DELETE` | [Delete a tag](/developer-docs/api/delete-a-tag) | `/api/v1/tag/{pk}` |
| `GET` | [Get a tag detail information](/developer-docs/api/get-a-tag-detail-information) | `/api/v1/tag/{pk}` |
| `PUT` | [Update a tag](/developer-docs/api/update-a-tag) | `/api/v1/tag/{pk}` |
| `DELETE` | [Delete tag by pk favorites](/developer-docs/api/delete-tag-by-pk-favorites) | `/api/v1/tag/{pk}/favorites/` |
| `POST` | [Create tag by pk favorites](/developer-docs/api/create-tag-by-pk-favorites) | `/api/v1/tag/{pk}/favorites/` |
| `POST` | [Bulk create tags and tagged objects](/developer-docs/api/bulk-create-tags-and-tagged-objects) | `/api/v1/tag/bulk_create` |
| `GET` | [Get tag favorite status](/developer-docs/api/get-tag-favorite-status) | `/api/v1/tag/favorite_status/` |
| `GET` | [Get all objects associated with a tag](/developer-docs/api/get-all-objects-associated-with-a-tag) | `/api/v1/tag/get_objects/` |
| `GET` | [Get related fields data (tag-related-column-name)](/developer-docs/api/get-related-fields-data-tag-related-column-name) | `/api/v1/tag/related/{column_name}` |
</details>
@@ -290,20 +290,20 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------- |
| `DELETE` | [Delete multiple annotation layers in a bulk operation](/developer-docs/6.1.0/api/delete-multiple-annotation-layers-in-a-bulk-operation) | `/api/v1/annotation_layer/` |
| `GET` | [Get a list of annotation layers (annotation-layer)](/developer-docs/6.1.0/api/get-a-list-of-annotation-layers-annotation-layer) | `/api/v1/annotation_layer/` |
| `POST` | [Create an annotation layer (annotation-layer)](/developer-docs/6.1.0/api/create-an-annotation-layer-annotation-layer) | `/api/v1/annotation_layer/` |
| `GET` | [Get metadata information about this API resource (annotation-layer--info)](/developer-docs/6.1.0/api/get-metadata-information-about-this-api-resource-annotation-layer-info) | `/api/v1/annotation_layer/_info` |
| `DELETE` | [Delete annotation layer (annotation-layer-pk)](/developer-docs/6.1.0/api/delete-annotation-layer-annotation-layer-pk) | `/api/v1/annotation_layer/{pk}` |
| `GET` | [Get an annotation layer (annotation-layer-pk)](/developer-docs/6.1.0/api/get-an-annotation-layer-annotation-layer-pk) | `/api/v1/annotation_layer/{pk}` |
| `PUT` | [Update an annotation layer (annotation-layer-pk)](/developer-docs/6.1.0/api/update-an-annotation-layer-annotation-layer-pk) | `/api/v1/annotation_layer/{pk}` |
| `DELETE` | [Bulk delete annotation layers](/developer-docs/6.1.0/api/bulk-delete-annotation-layers) | `/api/v1/annotation_layer/{pk}/annotation/` |
| `GET` | [Get a list of annotation layers (annotation-layer-pk-annotation)](/developer-docs/6.1.0/api/get-a-list-of-annotation-layers-annotation-layer-pk-annotation) | `/api/v1/annotation_layer/{pk}/annotation/` |
| `POST` | [Create an annotation layer (annotation-layer-pk-annotation)](/developer-docs/6.1.0/api/create-an-annotation-layer-annotation-layer-pk-annotation) | `/api/v1/annotation_layer/{pk}/annotation/` |
| `DELETE` | [Delete annotation layer (annotation-layer-pk-annotation-annotation-id)](/developer-docs/6.1.0/api/delete-annotation-layer-annotation-layer-pk-annotation-annotation-id) | `/api/v1/annotation_layer/{pk}/annotation/{annotation_id}` |
| `GET` | [Get an annotation layer (annotation-layer-pk-annotation-annotation-id)](/developer-docs/6.1.0/api/get-an-annotation-layer-annotation-layer-pk-annotation-annotation-id) | `/api/v1/annotation_layer/{pk}/annotation/{annotation_id}` |
| `PUT` | [Update an annotation layer (annotation-layer-pk-annotation-annotation-id)](/developer-docs/6.1.0/api/update-an-annotation-layer-annotation-layer-pk-annotation-annotation-id) | `/api/v1/annotation_layer/{pk}/annotation/{annotation_id}` |
| `GET` | [Get related fields data (annotation-layer-related-column-name)](/developer-docs/6.1.0/api/get-related-fields-data-annotation-layer-related-column-name) | `/api/v1/annotation_layer/related/{column_name}` |
| `DELETE` | [Delete multiple annotation layers in a bulk operation](/developer-docs/api/delete-multiple-annotation-layers-in-a-bulk-operation) | `/api/v1/annotation_layer/` |
| `GET` | [Get a list of annotation layers (annotation-layer)](/developer-docs/api/get-a-list-of-annotation-layers-annotation-layer) | `/api/v1/annotation_layer/` |
| `POST` | [Create an annotation layer (annotation-layer)](/developer-docs/api/create-an-annotation-layer-annotation-layer) | `/api/v1/annotation_layer/` |
| `GET` | [Get metadata information about this API resource (annotation-layer--info)](/developer-docs/api/get-metadata-information-about-this-api-resource-annotation-layer-info) | `/api/v1/annotation_layer/_info` |
| `DELETE` | [Delete annotation layer (annotation-layer-pk)](/developer-docs/api/delete-annotation-layer-annotation-layer-pk) | `/api/v1/annotation_layer/{pk}` |
| `GET` | [Get an annotation layer (annotation-layer-pk)](/developer-docs/api/get-an-annotation-layer-annotation-layer-pk) | `/api/v1/annotation_layer/{pk}` |
| `PUT` | [Update an annotation layer (annotation-layer-pk)](/developer-docs/api/update-an-annotation-layer-annotation-layer-pk) | `/api/v1/annotation_layer/{pk}` |
| `DELETE` | [Bulk delete annotation layers](/developer-docs/api/bulk-delete-annotation-layers) | `/api/v1/annotation_layer/{pk}/annotation/` |
| `GET` | [Get a list of annotation layers (annotation-layer-pk-annotation)](/developer-docs/api/get-a-list-of-annotation-layers-annotation-layer-pk-annotation) | `/api/v1/annotation_layer/{pk}/annotation/` |
| `POST` | [Create an annotation layer (annotation-layer-pk-annotation)](/developer-docs/api/create-an-annotation-layer-annotation-layer-pk-annotation) | `/api/v1/annotation_layer/{pk}/annotation/` |
| `DELETE` | [Delete annotation layer (annotation-layer-pk-annotation-annotation-id)](/developer-docs/api/delete-annotation-layer-annotation-layer-pk-annotation-annotation-id) | `/api/v1/annotation_layer/{pk}/annotation/{annotation_id}` |
| `GET` | [Get an annotation layer (annotation-layer-pk-annotation-annotation-id)](/developer-docs/api/get-an-annotation-layer-annotation-layer-pk-annotation-annotation-id) | `/api/v1/annotation_layer/{pk}/annotation/{annotation_id}` |
| `PUT` | [Update an annotation layer (annotation-layer-pk-annotation-annotation-id)](/developer-docs/api/update-an-annotation-layer-annotation-layer-pk-annotation-annotation-id) | `/api/v1/annotation_layer/{pk}/annotation/{annotation_id}` |
| `GET` | [Get related fields data (annotation-layer-related-column-name)](/developer-docs/api/get-related-fields-data-annotation-layer-related-column-name) | `/api/v1/annotation_layer/related/{column_name}` |
</details>
@@ -312,14 +312,14 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- |
| `DELETE` | [Bulk delete CSS templates](/developer-docs/6.1.0/api/bulk-delete-css-templates) | `/api/v1/css_template/` |
| `GET` | [Get a list of CSS templates](/developer-docs/6.1.0/api/get-a-list-of-css-templates) | `/api/v1/css_template/` |
| `POST` | [Create a CSS template](/developer-docs/6.1.0/api/create-a-css-template) | `/api/v1/css_template/` |
| `GET` | [Get metadata information about this API resource (css-template--info)](/developer-docs/6.1.0/api/get-metadata-information-about-this-api-resource-css-template-info) | `/api/v1/css_template/_info` |
| `DELETE` | [Delete a CSS template](/developer-docs/6.1.0/api/delete-a-css-template) | `/api/v1/css_template/{pk}` |
| `GET` | [Get a CSS template](/developer-docs/6.1.0/api/get-a-css-template) | `/api/v1/css_template/{pk}` |
| `PUT` | [Update a CSS template](/developer-docs/6.1.0/api/update-a-css-template) | `/api/v1/css_template/{pk}` |
| `GET` | [Get related fields data (css-template-related-column-name)](/developer-docs/6.1.0/api/get-related-fields-data-css-template-related-column-name) | `/api/v1/css_template/related/{column_name}` |
| `DELETE` | [Bulk delete CSS templates](/developer-docs/api/bulk-delete-css-templates) | `/api/v1/css_template/` |
| `GET` | [Get a list of CSS templates](/developer-docs/api/get-a-list-of-css-templates) | `/api/v1/css_template/` |
| `POST` | [Create a CSS template](/developer-docs/api/create-a-css-template) | `/api/v1/css_template/` |
| `GET` | [Get metadata information about this API resource (css-template--info)](/developer-docs/api/get-metadata-information-about-this-api-resource-css-template-info) | `/api/v1/css_template/_info` |
| `DELETE` | [Delete a CSS template](/developer-docs/api/delete-a-css-template) | `/api/v1/css_template/{pk}` |
| `GET` | [Get a CSS template](/developer-docs/api/get-a-css-template) | `/api/v1/css_template/{pk}` |
| `PUT` | [Update a CSS template](/developer-docs/api/update-a-css-template) | `/api/v1/css_template/{pk}` |
| `GET` | [Get related fields data (css-template-related-column-name)](/developer-docs/api/get-related-fields-data-css-template-related-column-name) | `/api/v1/css_template/related/{column_name}` |
</details>
@@ -330,8 +330,8 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
| ------ | ----------------------------------------------------------------------------------------------------- | ----------------------------------- |
| `POST` | [Create a new dashboard's permanent link](/developer-docs/6.1.0/api/create-a-new-dashboards-permanent-link) | `/api/v1/dashboard/{pk}/permalink` |
| `GET` | [Get dashboard's permanent link state](/developer-docs/6.1.0/api/get-dashboards-permanent-link-state) | `/api/v1/dashboard/permalink/{key}` |
| `POST` | [Create a new dashboard's permanent link](/developer-docs/api/create-a-new-dashboards-permanent-link) | `/api/v1/dashboard/{pk}/permalink` |
| `GET` | [Get dashboard's permanent link state](/developer-docs/api/get-dashboards-permanent-link-state) | `/api/v1/dashboard/permalink/{key}` |
</details>
@@ -340,8 +340,8 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
| ------ | -------------------------------------------------------------------------------------------------------------------- | --------------------------------- |
| `POST` | [Create a new permanent link (explore-permalink)](/developer-docs/6.1.0/api/create-a-new-permanent-link-explore-permalink) | `/api/v1/explore/permalink` |
| `GET` | [Get chart's permanent link state](/developer-docs/6.1.0/api/get-charts-permanent-link-state) | `/api/v1/explore/permalink/{key}` |
| `POST` | [Create a new permanent link (explore-permalink)](/developer-docs/api/create-a-new-permanent-link-explore-permalink) | `/api/v1/explore/permalink` |
| `GET` | [Get chart's permanent link state](/developer-docs/api/get-charts-permanent-link-state) | `/api/v1/explore/permalink/{key}` |
</details>
@@ -350,8 +350,8 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
| ------ | ------------------------------------------------------------------------------------------------------------------ | -------------------------------- |
| `POST` | [Create a new permanent link (sqllab-permalink)](/developer-docs/6.1.0/api/create-a-new-permanent-link-sqllab-permalink) | `/api/v1/sqllab/permalink` |
| `GET` | [Get permanent link state for SQLLab editor.](/developer-docs/6.1.0/api/get-permanent-link-state-for-sql-lab-editor) | `/api/v1/sqllab/permalink/{key}` |
| `POST` | [Create a new permanent link (sqllab-permalink)](/developer-docs/api/create-a-new-permanent-link-sqllab-permalink) | `/api/v1/sqllab/permalink` |
| `GET` | [Get permanent link state for SQLLab editor.](/developer-docs/api/get-permanent-link-state-for-sql-lab-editor) | `/api/v1/sqllab/permalink/{key}` |
</details>
@@ -360,7 +360,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
| ------ | ---------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- |
| `GET` | [Get a report schedule log (embedded-dashboard-uuid)](/developer-docs/6.1.0/api/get-a-report-schedule-log-embedded-dashboard-uuid) | `/api/v1/embedded_dashboard/{uuid}` |
| `GET` | [Get a report schedule log (embedded-dashboard-uuid)](/developer-docs/api/get-a-report-schedule-log-embedded-dashboard-uuid) | `/api/v1/embedded_dashboard/{uuid}` |
</details>
@@ -369,10 +369,10 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
| -------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------- |
| `POST` | [Create a dashboard's filter state](/developer-docs/6.1.0/api/create-a-dashboards-filter-state) | `/api/v1/dashboard/{pk}/filter_state` |
| `DELETE` | [Delete a dashboard's filter state value](/developer-docs/6.1.0/api/delete-a-dashboards-filter-state-value) | `/api/v1/dashboard/{pk}/filter_state/{key}` |
| `GET` | [Get a dashboard's filter state value](/developer-docs/6.1.0/api/get-a-dashboards-filter-state-value) | `/api/v1/dashboard/{pk}/filter_state/{key}` |
| `PUT` | [Update a dashboard's filter state value](/developer-docs/6.1.0/api/update-a-dashboards-filter-state-value) | `/api/v1/dashboard/{pk}/filter_state/{key}` |
| `POST` | [Create a dashboard's filter state](/developer-docs/api/create-a-dashboards-filter-state) | `/api/v1/dashboard/{pk}/filter_state` |
| `DELETE` | [Delete a dashboard's filter state value](/developer-docs/api/delete-a-dashboards-filter-state-value) | `/api/v1/dashboard/{pk}/filter_state/{key}` |
| `GET` | [Get a dashboard's filter state value](/developer-docs/api/get-a-dashboards-filter-state-value) | `/api/v1/dashboard/{pk}/filter_state/{key}` |
| `PUT` | [Update a dashboard's filter state value](/developer-docs/api/update-a-dashboards-filter-state-value) | `/api/v1/dashboard/{pk}/filter_state/{key}` |
</details>
@@ -381,10 +381,10 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
| -------- | -------------------------------------------------------------------------------- | --------------------------------- |
| `POST` | [Create a new form_data](/developer-docs/6.1.0/api/create-a-new-form-data) | `/api/v1/explore/form_data` |
| `DELETE` | [Delete a form_data](/developer-docs/6.1.0/api/delete-a-form-data) | `/api/v1/explore/form_data/{key}` |
| `GET` | [Get a form_data](/developer-docs/6.1.0/api/get-a-form-data) | `/api/v1/explore/form_data/{key}` |
| `PUT` | [Update an existing form_data](/developer-docs/6.1.0/api/update-an-existing-form-data) | `/api/v1/explore/form_data/{key}` |
| `POST` | [Create a new form_data](/developer-docs/api/create-a-new-form-data) | `/api/v1/explore/form_data` |
| `DELETE` | [Delete a form_data](/developer-docs/api/delete-a-form-data) | `/api/v1/explore/form_data/{key}` |
| `GET` | [Get a form_data](/developer-docs/api/get-a-form-data) | `/api/v1/explore/form_data/{key}` |
| `PUT` | [Update an existing form_data](/developer-docs/api/update-an-existing-form-data) | `/api/v1/explore/form_data/{key}` |
</details>
@@ -395,17 +395,17 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
| `DELETE` | [Bulk delete report schedules](/developer-docs/6.1.0/api/bulk-delete-report-schedules) | `/api/v1/report/` |
| `GET` | [Get a list of report schedules](/developer-docs/6.1.0/api/get-a-list-of-report-schedules) | `/api/v1/report/` |
| `POST` | [Create a report schedule](/developer-docs/6.1.0/api/create-a-report-schedule) | `/api/v1/report/` |
| `GET` | [Get metadata information about this API resource (report--info)](/developer-docs/6.1.0/api/get-metadata-information-about-this-api-resource-report-info) | `/api/v1/report/_info` |
| `DELETE` | [Delete a report schedule](/developer-docs/6.1.0/api/delete-a-report-schedule) | `/api/v1/report/{pk}` |
| `GET` | [Get a report schedule](/developer-docs/6.1.0/api/get-a-report-schedule) | `/api/v1/report/{pk}` |
| `PUT` | [Update a report schedule](/developer-docs/6.1.0/api/update-a-report-schedule) | `/api/v1/report/{pk}` |
| `GET` | [Get a list of report schedule logs](/developer-docs/6.1.0/api/get-a-list-of-report-schedule-logs) | `/api/v1/report/{pk}/log/` |
| `GET` | [Get a report schedule log (report-pk-log-log-id)](/developer-docs/6.1.0/api/get-a-report-schedule-log-report-pk-log-log-id) | `/api/v1/report/{pk}/log/{log_id}` |
| `GET` | [Get related fields data (report-related-column-name)](/developer-docs/6.1.0/api/get-related-fields-data-report-related-column-name) | `/api/v1/report/related/{column_name}` |
| `GET` | [Get slack channels](/developer-docs/6.1.0/api/get-slack-channels) | `/api/v1/report/slack_channels/` |
| `DELETE` | [Bulk delete report schedules](/developer-docs/api/bulk-delete-report-schedules) | `/api/v1/report/` |
| `GET` | [Get a list of report schedules](/developer-docs/api/get-a-list-of-report-schedules) | `/api/v1/report/` |
| `POST` | [Create a report schedule](/developer-docs/api/create-a-report-schedule) | `/api/v1/report/` |
| `GET` | [Get metadata information about this API resource (report--info)](/developer-docs/api/get-metadata-information-about-this-api-resource-report-info) | `/api/v1/report/_info` |
| `DELETE` | [Delete a report schedule](/developer-docs/api/delete-a-report-schedule) | `/api/v1/report/{pk}` |
| `GET` | [Get a report schedule](/developer-docs/api/get-a-report-schedule) | `/api/v1/report/{pk}` |
| `PUT` | [Update a report schedule](/developer-docs/api/update-a-report-schedule) | `/api/v1/report/{pk}` |
| `GET` | [Get a list of report schedule logs](/developer-docs/api/get-a-list-of-report-schedule-logs) | `/api/v1/report/{pk}/log/` |
| `GET` | [Get a report schedule log (report-pk-log-log-id)](/developer-docs/api/get-a-report-schedule-log-report-pk-log-log-id) | `/api/v1/report/{pk}/log/{log_id}` |
| `GET` | [Get related fields data (report-related-column-name)](/developer-docs/api/get-related-fields-data-report-related-column-name) | `/api/v1/report/related/{column_name}` |
| `GET` | [Get slack channels](/developer-docs/api/get-slack-channels) | `/api/v1/report/slack_channels/` |
</details>
@@ -416,17 +416,17 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
| -------- | ---------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
| `GET` | [Get security roles](/developer-docs/6.1.0/api/get-security-roles) | `/api/v1/security/roles/` |
| `POST` | [Create security roles](/developer-docs/6.1.0/api/create-security-roles) | `/api/v1/security/roles/` |
| `GET` | [Get security roles info](/developer-docs/6.1.0/api/get-security-roles-info) | `/api/v1/security/roles/_info` |
| `DELETE` | [Delete security roles by pk](/developer-docs/6.1.0/api/delete-security-roles-by-pk) | `/api/v1/security/roles/{pk}` |
| `GET` | [Get security roles by pk](/developer-docs/6.1.0/api/get-security-roles-by-pk) | `/api/v1/security/roles/{pk}` |
| `PUT` | [Update security roles by pk](/developer-docs/6.1.0/api/update-security-roles-by-pk) | `/api/v1/security/roles/{pk}` |
| `PUT` | [Update security roles by role_id groups](/developer-docs/6.1.0/api/update-security-roles-by-role-id-groups) | `/api/v1/security/roles/{role_id}/groups` |
| `POST` | [Create security roles by role_id permissions](/developer-docs/6.1.0/api/create-security-roles-by-role-id-permissions) | `/api/v1/security/roles/{role_id}/permissions` |
| `GET` | [Get security roles by role_id permissions](/developer-docs/6.1.0/api/get-security-roles-by-role-id-permissions) | `/api/v1/security/roles/{role_id}/permissions/` |
| `PUT` | [Update security roles by role_id users](/developer-docs/6.1.0/api/update-security-roles-by-role-id-users) | `/api/v1/security/roles/{role_id}/users` |
| `GET` | [List roles](/developer-docs/6.1.0/api/list-roles) | `/api/v1/security/roles/search/` |
| `GET` | [Get security roles](/developer-docs/api/get-security-roles) | `/api/v1/security/roles/` |
| `POST` | [Create security roles](/developer-docs/api/create-security-roles) | `/api/v1/security/roles/` |
| `GET` | [Get security roles info](/developer-docs/api/get-security-roles-info) | `/api/v1/security/roles/_info` |
| `DELETE` | [Delete security roles by pk](/developer-docs/api/delete-security-roles-by-pk) | `/api/v1/security/roles/{pk}` |
| `GET` | [Get security roles by pk](/developer-docs/api/get-security-roles-by-pk) | `/api/v1/security/roles/{pk}` |
| `PUT` | [Update security roles by pk](/developer-docs/api/update-security-roles-by-pk) | `/api/v1/security/roles/{pk}` |
| `PUT` | [Update security roles by role_id groups](/developer-docs/api/update-security-roles-by-role-id-groups) | `/api/v1/security/roles/{role_id}/groups` |
| `POST` | [Create security roles by role_id permissions](/developer-docs/api/create-security-roles-by-role-id-permissions) | `/api/v1/security/roles/{role_id}/permissions` |
| `GET` | [Get security roles by role_id permissions](/developer-docs/api/get-security-roles-by-role-id-permissions) | `/api/v1/security/roles/{role_id}/permissions/` |
| `PUT` | [Update security roles by role_id users](/developer-docs/api/update-security-roles-by-role-id-users) | `/api/v1/security/roles/{role_id}/users` |
| `GET` | [List roles](/developer-docs/api/list-roles) | `/api/v1/security/roles/search/` |
</details>
@@ -435,12 +435,12 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
| -------- | ------------------------------------------------------------------------------ | ------------------------------ |
| `GET` | [Get security users](/developer-docs/6.1.0/api/get-security-users) | `/api/v1/security/users/` |
| `POST` | [Create security users](/developer-docs/6.1.0/api/create-security-users) | `/api/v1/security/users/` |
| `GET` | [Get security users info](/developer-docs/6.1.0/api/get-security-users-info) | `/api/v1/security/users/_info` |
| `DELETE` | [Delete security users by pk](/developer-docs/6.1.0/api/delete-security-users-by-pk) | `/api/v1/security/users/{pk}` |
| `GET` | [Get security users by pk](/developer-docs/6.1.0/api/get-security-users-by-pk) | `/api/v1/security/users/{pk}` |
| `PUT` | [Update security users by pk](/developer-docs/6.1.0/api/update-security-users-by-pk) | `/api/v1/security/users/{pk}` |
| `GET` | [Get security users](/developer-docs/api/get-security-users) | `/api/v1/security/users/` |
| `POST` | [Create security users](/developer-docs/api/create-security-users) | `/api/v1/security/users/` |
| `GET` | [Get security users info](/developer-docs/api/get-security-users-info) | `/api/v1/security/users/_info` |
| `DELETE` | [Delete security users by pk](/developer-docs/api/delete-security-users-by-pk) | `/api/v1/security/users/{pk}` |
| `GET` | [Get security users by pk](/developer-docs/api/get-security-users-by-pk) | `/api/v1/security/users/{pk}` |
| `PUT` | [Update security users by pk](/developer-docs/api/update-security-users-by-pk) | `/api/v1/security/users/{pk}` |
</details>
@@ -449,9 +449,9 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
| ------ | ------------------------------------------------------------------------------------ | ------------------------------------ |
| `GET` | [Get security permissions](/developer-docs/6.1.0/api/get-security-permissions) | `/api/v1/security/permissions/` |
| `GET` | [Get security permissions info](/developer-docs/6.1.0/api/get-security-permissions-info) | `/api/v1/security/permissions/_info` |
| `GET` | [Get security permissions by pk](/developer-docs/6.1.0/api/get-security-permissions-by-pk) | `/api/v1/security/permissions/{pk}` |
| `GET` | [Get security permissions](/developer-docs/api/get-security-permissions) | `/api/v1/security/permissions/` |
| `GET` | [Get security permissions info](/developer-docs/api/get-security-permissions-info) | `/api/v1/security/permissions/_info` |
| `GET` | [Get security permissions by pk](/developer-docs/api/get-security-permissions-by-pk) | `/api/v1/security/permissions/{pk}` |
</details>
@@ -460,12 +460,12 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
| -------- | -------------------------------------------------------------------------------------- | ---------------------------------- |
| `GET` | [Get security resources](/developer-docs/6.1.0/api/get-security-resources) | `/api/v1/security/resources/` |
| `POST` | [Create security resources](/developer-docs/6.1.0/api/create-security-resources) | `/api/v1/security/resources/` |
| `GET` | [Get security resources info](/developer-docs/6.1.0/api/get-security-resources-info) | `/api/v1/security/resources/_info` |
| `DELETE` | [Delete security resources by pk](/developer-docs/6.1.0/api/delete-security-resources-by-pk) | `/api/v1/security/resources/{pk}` |
| `GET` | [Get security resources by pk](/developer-docs/6.1.0/api/get-security-resources-by-pk) | `/api/v1/security/resources/{pk}` |
| `PUT` | [Update security resources by pk](/developer-docs/6.1.0/api/update-security-resources-by-pk) | `/api/v1/security/resources/{pk}` |
| `GET` | [Get security resources](/developer-docs/api/get-security-resources) | `/api/v1/security/resources/` |
| `POST` | [Create security resources](/developer-docs/api/create-security-resources) | `/api/v1/security/resources/` |
| `GET` | [Get security resources info](/developer-docs/api/get-security-resources-info) | `/api/v1/security/resources/_info` |
| `DELETE` | [Delete security resources by pk](/developer-docs/api/delete-security-resources-by-pk) | `/api/v1/security/resources/{pk}` |
| `GET` | [Get security resources by pk](/developer-docs/api/get-security-resources-by-pk) | `/api/v1/security/resources/{pk}` |
| `PUT` | [Update security resources by pk](/developer-docs/api/update-security-resources-by-pk) | `/api/v1/security/resources/{pk}` |
</details>
@@ -474,12 +474,12 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
| -------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
| `GET` | [Get security permissions resources](/developer-docs/6.1.0/api/get-security-permissions-resources) | `/api/v1/security/permissions-resources/` |
| `POST` | [Create security permissions resources](/developer-docs/6.1.0/api/create-security-permissions-resources) | `/api/v1/security/permissions-resources/` |
| `GET` | [Get security permissions resources info](/developer-docs/6.1.0/api/get-security-permissions-resources-info) | `/api/v1/security/permissions-resources/_info` |
| `DELETE` | [Delete security permissions resources by pk](/developer-docs/6.1.0/api/delete-security-permissions-resources-by-pk) | `/api/v1/security/permissions-resources/{pk}` |
| `GET` | [Get security permissions resources by pk](/developer-docs/6.1.0/api/get-security-permissions-resources-by-pk) | `/api/v1/security/permissions-resources/{pk}` |
| `PUT` | [Update security permissions resources by pk](/developer-docs/6.1.0/api/update-security-permissions-resources-by-pk) | `/api/v1/security/permissions-resources/{pk}` |
| `GET` | [Get security permissions resources](/developer-docs/api/get-security-permissions-resources) | `/api/v1/security/permissions-resources/` |
| `POST` | [Create security permissions resources](/developer-docs/api/create-security-permissions-resources) | `/api/v1/security/permissions-resources/` |
| `GET` | [Get security permissions resources info](/developer-docs/api/get-security-permissions-resources-info) | `/api/v1/security/permissions-resources/_info` |
| `DELETE` | [Delete security permissions resources by pk](/developer-docs/api/delete-security-permissions-resources-by-pk) | `/api/v1/security/permissions-resources/{pk}` |
| `GET` | [Get security permissions resources by pk](/developer-docs/api/get-security-permissions-resources-by-pk) | `/api/v1/security/permissions-resources/{pk}` |
| `PUT` | [Update security permissions resources by pk](/developer-docs/api/update-security-permissions-resources-by-pk) | `/api/v1/security/permissions-resources/{pk}` |
</details>
@@ -488,14 +488,14 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ |
| `DELETE` | [Bulk delete RLS rules](/developer-docs/6.1.0/api/bulk-delete-rls-rules) | `/api/v1/rowlevelsecurity/` |
| `GET` | [Get a list of RLS](/developer-docs/6.1.0/api/get-a-list-of-rls) | `/api/v1/rowlevelsecurity/` |
| `POST` | [Create a new RLS rule](/developer-docs/6.1.0/api/create-a-new-rls-rule) | `/api/v1/rowlevelsecurity/` |
| `GET` | [Get metadata information about this API resource (rowlevelsecurity--info)](/developer-docs/6.1.0/api/get-metadata-information-about-this-api-resource-rowlevelsecurity-info) | `/api/v1/rowlevelsecurity/_info` |
| `DELETE` | [Delete an RLS](/developer-docs/6.1.0/api/delete-an-rls) | `/api/v1/rowlevelsecurity/{pk}` |
| `GET` | [Get an RLS](/developer-docs/6.1.0/api/get-an-rls) | `/api/v1/rowlevelsecurity/{pk}` |
| `PUT` | [Update an RLS rule](/developer-docs/6.1.0/api/update-an-rls-rule) | `/api/v1/rowlevelsecurity/{pk}` |
| `GET` | [Get related fields data (rowlevelsecurity-related-column-name)](/developer-docs/6.1.0/api/get-related-fields-data-rowlevelsecurity-related-column-name) | `/api/v1/rowlevelsecurity/related/{column_name}` |
| `DELETE` | [Bulk delete RLS rules](/developer-docs/api/bulk-delete-rls-rules) | `/api/v1/rowlevelsecurity/` |
| `GET` | [Get a list of RLS](/developer-docs/api/get-a-list-of-rls) | `/api/v1/rowlevelsecurity/` |
| `POST` | [Create a new RLS rule](/developer-docs/api/create-a-new-rls-rule) | `/api/v1/rowlevelsecurity/` |
| `GET` | [Get metadata information about this API resource (rowlevelsecurity--info)](/developer-docs/api/get-metadata-information-about-this-api-resource-rowlevelsecurity-info) | `/api/v1/rowlevelsecurity/_info` |
| `DELETE` | [Delete an RLS](/developer-docs/api/delete-an-rls) | `/api/v1/rowlevelsecurity/{pk}` |
| `GET` | [Get an RLS](/developer-docs/api/get-an-rls) | `/api/v1/rowlevelsecurity/{pk}` |
| `PUT` | [Update an RLS rule](/developer-docs/api/update-an-rls-rule) | `/api/v1/rowlevelsecurity/{pk}` |
| `GET` | [Get related fields data (rowlevelsecurity-related-column-name)](/developer-docs/api/get-related-fields-data-rowlevelsecurity-related-column-name) | `/api/v1/rowlevelsecurity/related/{column_name}` |
</details>
@@ -506,8 +506,8 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
| ------ | -------------------------------------------------------------------- | ------------------------ |
| `GET` | [Export all assets](/developer-docs/6.1.0/api/export-all-assets) | `/api/v1/assets/export/` |
| `POST` | [Import multiple assets](/developer-docs/6.1.0/api/import-multiple-assets) | `/api/v1/assets/import/` |
| `GET` | [Export all assets](/developer-docs/api/export-all-assets) | `/api/v1/assets/export/` |
| `POST` | [Import multiple assets](/developer-docs/api/import-multiple-assets) | `/api/v1/assets/import/` |
</details>
@@ -516,7 +516,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
| ------ | ---------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- |
| `POST` | [Invalidate cache records and remove the database records](/developer-docs/6.1.0/api/invalidate-cache-records-and-remove-the-database-records) | `/api/v1/cachekey/invalidate` |
| `POST` | [Invalidate cache records and remove the database records](/developer-docs/api/invalidate-cache-records-and-remove-the-database-records) | `/api/v1/cachekey/invalidate` |
</details>
@@ -525,10 +525,10 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
| ------ | ---------------------------------------------------------------------------------------------- | ------------------------------ |
| `GET` | [Get a list of logs](/developer-docs/6.1.0/api/get-a-list-of-logs) | `/api/v1/log/` |
| `POST` | [Create log](/developer-docs/6.1.0/api/create-log) | `/api/v1/log/` |
| `GET` | [Get a log detail information](/developer-docs/6.1.0/api/get-a-log-detail-information) | `/api/v1/log/{pk}` |
| `GET` | [Get recent activity data for a user](/developer-docs/6.1.0/api/get-recent-activity-data-for-a-user) | `/api/v1/log/recent_activity/` |
| `GET` | [Get a list of logs](/developer-docs/api/get-a-list-of-logs) | `/api/v1/log/` |
| `POST` | [Create log](/developer-docs/api/create-log) | `/api/v1/log/` |
| `GET` | [Get a log detail information](/developer-docs/api/get-a-log-detail-information) | `/api/v1/log/{pk}` |
| `GET` | [Get recent activity data for a user](/developer-docs/api/get-recent-activity-data-for-a-user) | `/api/v1/log/recent_activity/` |
</details>
@@ -539,9 +539,9 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
| ------ | ---------------------------------------------------------------------- | ------------------- |
| `GET` | [Get the user object](/developer-docs/6.1.0/api/get-the-user-object) | `/api/v1/me/` |
| `PUT` | [Update the current user](/developer-docs/6.1.0/api/update-the-current-user) | `/api/v1/me/` |
| `GET` | [Get the user roles](/developer-docs/6.1.0/api/get-the-user-roles) | `/api/v1/me/roles/` |
| `GET` | [Get the user object](/developer-docs/api/get-the-user-object) | `/api/v1/me/` |
| `PUT` | [Update the current user](/developer-docs/api/update-the-current-user) | `/api/v1/me/` |
| `GET` | [Get the user roles](/developer-docs/api/get-the-user-roles) | `/api/v1/me/roles/` |
</details>
@@ -550,7 +550,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
| ------ | -------------------------------------------------------------- | ----------------------------------- |
| `GET` | [Get the user avatar](/developer-docs/6.1.0/api/get-the-user-avatar) | `/api/v1/user/{user_id}/avatar.png` |
| `GET` | [Get the user avatar](/developer-docs/api/get-the-user-avatar) | `/api/v1/user/{user_id}/avatar.png` |
</details>
@@ -559,7 +559,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
| ------ | ---------------------------------------- | --------------- |
| `GET` | [Get menu](/developer-docs/6.1.0/api/get-menu) | `/api/v1/menu/` |
| `GET` | [Get menu](/developer-docs/api/get-menu) | `/api/v1/menu/` |
</details>
@@ -568,7 +568,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
| ------ | -------------------------------------------------------------------------- | ---------------------------- |
| `GET` | [Get all available domains](/developer-docs/6.1.0/api/get-all-available-domains) | `/api/v1/available_domains/` |
| `GET` | [Get all available domains](/developer-docs/api/get-all-available-domains) | `/api/v1/available_domains/` |
</details>
@@ -577,7 +577,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
| ------ | ---------------------------------------------------------------------------------------------- | ---------------------- |
| `GET` | [Read off of the Redis events stream](/developer-docs/6.1.0/api/read-off-of-the-redis-events-stream) | `/api/v1/async_event/` |
| `GET` | [Read off of the Redis events stream](/developer-docs/api/read-off-of-the-redis-events-stream) | `/api/v1/async_event/` |
</details>
@@ -586,7 +586,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
| ------ | ---------------------------------------------------------------------------- | ------------------------- |
| `GET` | [Get api by version openapi](/developer-docs/6.1.0/api/get-api-by-version-openapi) | `/api/{version}/_openapi` |
| `GET` | [Get api by version openapi](/developer-docs/api/get-api-by-version-openapi) | `/api/{version}/_openapi` |
</details>
@@ -597,12 +597,12 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
| -------- | -------------------------------------------------------------------------------- | ------------------------------- |
| `GET` | [Get security groups](/developer-docs/6.1.0/api/get-security-groups) | `/api/v1/security/groups/` |
| `POST` | [Create security groups](/developer-docs/6.1.0/api/create-security-groups) | `/api/v1/security/groups/` |
| `GET` | [Get security groups info](/developer-docs/6.1.0/api/get-security-groups-info) | `/api/v1/security/groups/_info` |
| `DELETE` | [Delete security groups by pk](/developer-docs/6.1.0/api/delete-security-groups-by-pk) | `/api/v1/security/groups/{pk}` |
| `GET` | [Get security groups by pk](/developer-docs/6.1.0/api/get-security-groups-by-pk) | `/api/v1/security/groups/{pk}` |
| `PUT` | [Update security groups by pk](/developer-docs/6.1.0/api/update-security-groups-by-pk) | `/api/v1/security/groups/{pk}` |
| `GET` | [Get security groups](/developer-docs/api/get-security-groups) | `/api/v1/security/groups/` |
| `POST` | [Create security groups](/developer-docs/api/create-security-groups) | `/api/v1/security/groups/` |
| `GET` | [Get security groups info](/developer-docs/api/get-security-groups-info) | `/api/v1/security/groups/_info` |
| `DELETE` | [Delete security groups by pk](/developer-docs/api/delete-security-groups-by-pk) | `/api/v1/security/groups/{pk}` |
| `GET` | [Get security groups by pk](/developer-docs/api/get-security-groups-by-pk) | `/api/v1/security/groups/{pk}` |
| `PUT` | [Update security groups by pk](/developer-docs/api/update-security-groups-by-pk) | `/api/v1/security/groups/{pk}` |
</details>
@@ -611,20 +611,20 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
| `DELETE` | [Bulk delete themes](/developer-docs/6.1.0/api/bulk-delete-themes) | `/api/v1/theme/` |
| `GET` | [Get a list of themes](/developer-docs/6.1.0/api/get-a-list-of-themes) | `/api/v1/theme/` |
| `POST` | [Create a theme](/developer-docs/6.1.0/api/create-a-theme) | `/api/v1/theme/` |
| `GET` | [Get metadata information about this API resource (theme--info)](/developer-docs/6.1.0/api/get-metadata-information-about-this-api-resource-theme-info) | `/api/v1/theme/_info` |
| `DELETE` | [Delete a theme](/developer-docs/6.1.0/api/delete-a-theme) | `/api/v1/theme/{pk}` |
| `GET` | [Get a theme](/developer-docs/6.1.0/api/get-a-theme) | `/api/v1/theme/{pk}` |
| `PUT` | [Update a theme](/developer-docs/6.1.0/api/update-a-theme) | `/api/v1/theme/{pk}` |
| `PUT` | [Set a theme as the system dark theme](/developer-docs/6.1.0/api/set-a-theme-as-the-system-dark-theme) | `/api/v1/theme/{pk}/set_system_dark` |
| `PUT` | [Set a theme as the system default theme](/developer-docs/6.1.0/api/set-a-theme-as-the-system-default-theme) | `/api/v1/theme/{pk}/set_system_default` |
| `GET` | [Download multiple themes as YAML files](/developer-docs/6.1.0/api/download-multiple-themes-as-yaml-files) | `/api/v1/theme/export/` |
| `POST` | [Import themes from a ZIP file](/developer-docs/6.1.0/api/import-themes-from-a-zip-file) | `/api/v1/theme/import/` |
| `GET` | [Get related fields data (theme-related-column-name)](/developer-docs/6.1.0/api/get-related-fields-data-theme-related-column-name) | `/api/v1/theme/related/{column_name}` |
| `DELETE` | [Clear the system dark theme](/developer-docs/6.1.0/api/clear-the-system-dark-theme) | `/api/v1/theme/unset_system_dark` |
| `DELETE` | [Clear the system default theme](/developer-docs/6.1.0/api/clear-the-system-default-theme) | `/api/v1/theme/unset_system_default` |
| `DELETE` | [Bulk delete themes](/developer-docs/api/bulk-delete-themes) | `/api/v1/theme/` |
| `GET` | [Get a list of themes](/developer-docs/api/get-a-list-of-themes) | `/api/v1/theme/` |
| `POST` | [Create a theme](/developer-docs/api/create-a-theme) | `/api/v1/theme/` |
| `GET` | [Get metadata information about this API resource (theme--info)](/developer-docs/api/get-metadata-information-about-this-api-resource-theme-info) | `/api/v1/theme/_info` |
| `DELETE` | [Delete a theme](/developer-docs/api/delete-a-theme) | `/api/v1/theme/{pk}` |
| `GET` | [Get a theme](/developer-docs/api/get-a-theme) | `/api/v1/theme/{pk}` |
| `PUT` | [Update a theme](/developer-docs/api/update-a-theme) | `/api/v1/theme/{pk}` |
| `PUT` | [Set a theme as the system dark theme](/developer-docs/api/set-a-theme-as-the-system-dark-theme) | `/api/v1/theme/{pk}/set_system_dark` |
| `PUT` | [Set a theme as the system default theme](/developer-docs/api/set-a-theme-as-the-system-default-theme) | `/api/v1/theme/{pk}/set_system_default` |
| `GET` | [Download multiple themes as YAML files](/developer-docs/api/download-multiple-themes-as-yaml-files) | `/api/v1/theme/export/` |
| `POST` | [Import themes from a ZIP file](/developer-docs/api/import-themes-from-a-zip-file) | `/api/v1/theme/import/` |
| `GET` | [Get related fields data (theme-related-column-name)](/developer-docs/api/get-related-fields-data-theme-related-column-name) | `/api/v1/theme/related/{column_name}` |
| `DELETE` | [Clear the system dark theme](/developer-docs/api/clear-the-system-dark-theme) | `/api/v1/theme/unset_system_dark` |
| `DELETE` | [Clear the system default theme](/developer-docs/api/clear-the-system-default-theme) | `/api/v1/theme/unset_system_default` |
</details>
@@ -633,14 +633,14 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
| `GET` | [Get security user registrations](/developer-docs/6.1.0/api/get-security-user-registrations) | `/api/v1/security/user_registrations/` |
| `POST` | [Create security user registrations](/developer-docs/6.1.0/api/create-security-user-registrations) | `/api/v1/security/user_registrations/` |
| `GET` | [Get security user registrations info](/developer-docs/6.1.0/api/get-security-user-registrations-info) | `/api/v1/security/user_registrations/_info` |
| `DELETE` | [Delete security user registrations by pk](/developer-docs/6.1.0/api/delete-security-user-registrations-by-pk) | `/api/v1/security/user_registrations/{pk}` |
| `GET` | [Get security user registrations by pk](/developer-docs/6.1.0/api/get-security-user-registrations-by-pk) | `/api/v1/security/user_registrations/{pk}` |
| `PUT` | [Update security user registrations by pk](/developer-docs/6.1.0/api/update-security-user-registrations-by-pk) | `/api/v1/security/user_registrations/{pk}` |
| `GET` | [Get distinct values from field data (security-user-registrations-distinct-column-name)](/developer-docs/6.1.0/api/get-distinct-values-from-field-data-security-user-registrations-distinct-column-name) | `/api/v1/security/user_registrations/distinct/{column_name}` |
| `GET` | [Get related fields data (security-user-registrations-related-column-name)](/developer-docs/6.1.0/api/get-related-fields-data-security-user-registrations-related-column-name) | `/api/v1/security/user_registrations/related/{column_name}` |
| `GET` | [Get security user registrations](/developer-docs/api/get-security-user-registrations) | `/api/v1/security/user_registrations/` |
| `POST` | [Create security user registrations](/developer-docs/api/create-security-user-registrations) | `/api/v1/security/user_registrations/` |
| `GET` | [Get security user registrations info](/developer-docs/api/get-security-user-registrations-info) | `/api/v1/security/user_registrations/_info` |
| `DELETE` | [Delete security user registrations by pk](/developer-docs/api/delete-security-user-registrations-by-pk) | `/api/v1/security/user_registrations/{pk}` |
| `GET` | [Get security user registrations by pk](/developer-docs/api/get-security-user-registrations-by-pk) | `/api/v1/security/user_registrations/{pk}` |
| `PUT` | [Update security user registrations by pk](/developer-docs/api/update-security-user-registrations-by-pk) | `/api/v1/security/user_registrations/{pk}` |
| `GET` | [Get distinct values from field data (security-user-registrations-distinct-column-name)](/developer-docs/api/get-distinct-values-from-field-data-security-user-registrations-distinct-column-name) | `/api/v1/security/user_registrations/distinct/{column_name}` |
| `GET` | [Get related fields data (security-user-registrations-related-column-name)](/developer-docs/api/get-related-fields-data-security-user-registrations-related-column-name) | `/api/v1/security/user_registrations/related/{column_name}` |
</details>
+1 -1
View File
@@ -58,7 +58,7 @@ A modern, enterprise-ready business intelligence web application.
[**Why Superset?**](#why-superset) |
[**Supported Databases**](#supported-databases) |
[**Installation and Configuration**](#installation-and-configuration) |
[**Release Notes**](https://github.com/apache/superset/releases) |
[**Release Notes**](https://github.com/apache/superset/blob/master/RELEASING/README.md#release-notes-for-recent-releases) |
[**Get Involved**](#get-involved) |
[**Contributor Guide**](#contributor-guide) |
[**Resources**](#resources) |
@@ -151,20 +151,6 @@ see some data!
You should see months in the rows and Department and Travel Class in the columns. Publish this chart
to your existing Tutorial Dashboard you created earlier.
:::note
Row and column totals/subtotals for the Pivot Table are correct even for non-additive metrics,
such as ratios (`SUM(a)/SUM(b)`), `COUNT_DISTINCT`, `AVG`, and percentiles, not just additive ones
like `SUM` or `COUNT`. Totals derive client-side, by reducing the same full-detail query results
used to build the table, only when every selected metric is additive; if any selected metric is
non-additive, Superset instead issues a database query at each total's own granularity for all
metrics, so the total reflects each metric's own definition evaluated at that level rather than an
incorrect combination of the displayed cells. Because of this, there's no separate "Aggregation
function" control for totals in the Pivot Table: a total always reflects the metric's own
definition. The Table chart's **Show summary** row is different: its **Summary aggregation**
control can override a simple metric's own aggregation (to Sum or Average) for the summary row
only — metrics built from custom SQL keep their own aggregation regardless.
:::
### Line Chart
In this section, we are going to create a line chart to understand the average price of a ticket by
@@ -70,24 +70,5 @@ alert or report is removed, and the reason is shown. Charts that belong to
dashboards are removed from those dashboards as part of the deletion; the
dashboards themselves are left in place.
Before an archived dataset is deleted permanently, Superset checks which
charts still use it and which dashboards contain those charts. The confirmation
shows the total number of affected charts and dashboards, identifies the ones
you are allowed to access, and reports the remaining objects only as restricted
counts. Restricted names, identifiers, and links are not displayed. Archived
dependents are included because they can still be recovered after the dataset
is gone.
Deleting the dataset does not delete those charts or dashboards. They remain
in place without a usable dataset and may therefore be broken. If there are no
dependents, the confirmation explicitly reports zero affected charts and
dashboards.
The dependency check fails closed. While it is loading, or if its result is
unavailable, permanent deletion is disabled; cancel or retry the check. Superset
checks again when you submit. If dependencies changed while the confirmation
was open, the refreshed impact replaces the previous result and you must type
DELETE again before proceeding.
Objects are also deleted permanently on their own once they have been in the
archive longer than the retention window, without anyone acting.
@@ -78,18 +78,6 @@ Charts are **not saved by default**. The workflow is intentionally iterative:
To skip the preview and save immediately, include "and save it" in your prompt.
:::
:::info Deployment-specific chart types
Use `get_chart_type_schema` before generating a chart to discover the types
available on your Superset instance. Some deployments expose additional
feature-gated visualizations. For example, a deployment with an AG Grid pivot
extension enabled can expose `interactive_pivot`, which supports interactive
row groups, pivot columns, totals, and period-over-period comparisons. Pair
`comparison_period` (for example, `1 year ago`) with `comparison_type`
(`values`, `difference`, `percentage`, or `ratio`). It is distinct from the
built-in `pivot_table` chart type and is not offered when the host visualization
is unavailable.
:::
### Create Dashboards
Build dashboards from a collection of charts:
+71
View File
@@ -0,0 +1,71 @@
/* eslint-env node */
/**
* 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.
*/
const typescriptEslintParser = require('@typescript-eslint/parser');
const typescriptEslintPlugin = require('@typescript-eslint/eslint-plugin');
const js = require('@eslint/js');
const ts = require('typescript-eslint');
const react = require('eslint-plugin-react');
const globals = require('globals');
const { defineConfig, globalIgnores } = require('eslint/config');
module.exports = defineConfig([
{
files: ['**/*.{js,jsx,ts,tsx}'],
},
globalIgnores(['build/**/*', '.docusaurus/**/*', 'node_modules/**/*']),
js.configs.recommended,
...ts.configs.recommended,
{
files: ['eslint.config.js'],
rules: {
'@typescript-eslint/no-require-imports': 'off',
},
},
{
languageOptions: {
parser: typescriptEslintParser,
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaVersion: 2020,
sourceType: 'module',
},
globals: {
...globals.browser,
...globals.node,
},
},
plugins: {
typescript: typescriptEslintPlugin,
react,
},
rules: {
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
},
settings: {
react: {
version: 'detect',
},
},
},
]);
-139
View File
@@ -1,139 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": [
"typescript",
"react"
],
"categories": {
"correctness": "off"
},
"env": {
"builtin": true,
"browser": true,
"node": true
},
"ignorePatterns": [
"build/**/*",
".docusaurus/**/*",
"node_modules/**/*"
],
"settings": {
"react": {
"version": "18.3.1"
}
},
"options": {
"typeAware": true
},
"rules": {
"constructor-super": "error",
"for-direction": "error",
"getter-return": "error",
"no-async-promise-executor": "error",
"no-case-declarations": "error",
"no-class-assign": "error",
"no-compare-neg-zero": "error",
"no-cond-assign": "error",
"no-const-assign": "error",
"no-constant-binary-expression": "error",
"no-constant-condition": "error",
"no-control-regex": "error",
"no-debugger": "error",
"no-delete-var": "error",
"no-dupe-class-members": "error",
"no-dupe-else-if": "error",
"no-dupe-keys": "error",
"no-duplicate-case": "error",
"no-empty": "error",
"no-empty-character-class": "error",
"no-empty-pattern": "error",
"no-empty-static-block": "error",
"no-ex-assign": "error",
"no-extra-boolean-cast": "error",
"no-fallthrough": "error",
"no-func-assign": "error",
"no-global-assign": "error",
"no-import-assign": "error",
"no-invalid-regexp": "error",
"no-irregular-whitespace": "error",
"no-loss-of-precision": "error",
"no-misleading-character-class": "error",
"no-new-native-nonconstructor": "error",
"no-nonoctal-decimal-escape": "error",
"no-obj-calls": "error",
"no-prototype-builtins": "error",
"no-redeclare": "error",
"no-regex-spaces": "error",
"no-self-assign": "error",
"no-setter-return": "error",
"no-shadow-restricted-names": "error",
"no-sparse-arrays": "error",
"no-this-before-super": "error",
"no-unexpected-multiline": "error",
"no-unreachable": "error",
"no-unsafe-finally": "error",
"no-unsafe-negation": "error",
"no-unsafe-optional-chaining": "error",
"no-unused-labels": "error",
"no-unused-private-class-members": "error",
"no-unused-vars": "error",
"no-useless-backreference": "error",
"no-useless-catch": "error",
"no-useless-escape": "error",
"no-with": "error",
"require-yield": "error",
"use-isnan": "error",
"valid-typeof": "error",
"no-array-constructor": "error",
"no-unused-expressions": "error",
"typescript/ban-ts-comment": "error",
"typescript/no-duplicate-enum-values": "error",
"typescript/no-empty-object-type": "error",
"typescript/no-explicit-any": "error",
"typescript/no-extra-non-null-assertion": "error",
"typescript/no-misused-new": "error",
"typescript/no-namespace": "error",
"typescript/no-non-null-asserted-optional-chain": "error",
"typescript/no-require-imports": "error",
"typescript/no-this-alias": "error",
"typescript/no-unnecessary-type-constraint": "error",
"typescript/no-unsafe-declaration-merging": "error",
"typescript/no-unsafe-function-type": "error",
"typescript/no-wrapper-object-types": "error",
"typescript/prefer-as-const": "error",
"typescript/prefer-namespace-keyword": "error",
"typescript/triple-slash-reference": "error"
},
"overrides": [
{
"files": [
"**/*.ts",
"**/*.tsx",
"**/*.mts",
"**/*.cts"
],
"rules": {
"constructor-super": "off",
"getter-return": "off",
"no-class-assign": "off",
"no-const-assign": "off",
"no-dupe-class-members": "off",
"no-dupe-keys": "off",
"no-func-assign": "off",
"no-import-assign": "off",
"no-new-native-nonconstructor": "off",
"no-obj-calls": "off",
"no-redeclare": "off",
"no-setter-return": "off",
"no-this-before-super": "off",
"no-unreachable": "off",
"no-unsafe-negation": "off",
"no-var": "error",
"no-with": "off",
"prefer-const": "error",
"prefer-rest-params": "error",
"prefer-spread": "error"
}
}
]
}
+14 -8
View File
@@ -29,7 +29,7 @@
"lint:db-metadata": "python3 ../superset/db_engine_specs/lint_metadata.py",
"lint:db-metadata:report": "python3 ../superset/db_engine_specs/lint_metadata.py --markdown -o ../superset/db_engine_specs/METADATA_STATUS.md",
"update:readme-db-logos": "node scripts/generate-database-docs.mjs --update-readme",
"lint": "oxlint --config oxlint.json",
"eslint": "eslint .",
"lint:docs-links": "node scripts/lint-docs-links.mjs",
"version:add": "node scripts/manage-versions.mjs add",
"version:remove": "node scripts/manage-versions.mjs remove",
@@ -62,11 +62,11 @@
"@superset-ui/core": "^0.20.4",
"@swc/core": "^1.16.1",
"antd": "^6.6.1",
"baseline-browser-mapping": "^2.11.19",
"baseline-browser-mapping": "^2.11.17",
"caniuse-lite": "^1.0.30001809",
"docusaurus-plugin-openapi-docs": "^5.2.0",
"docusaurus-theme-openapi-docs": "^5.2.0",
"js-yaml": "^5.4.1",
"js-yaml": "^5.3.0",
"json-bigint": "^1.0.0",
"prism-react-renderer": "^2.4.1",
"react": "^18.3.1",
@@ -85,13 +85,19 @@
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.10.2",
"@docusaurus/tsconfig": "^3.10.2",
"@eslint/js": "^9.39.2",
"@types/js-yaml": "^4.0.9",
"@types/react": "^19.1.8",
"oxfmt": "^0.65.0",
"oxlint": "^1.80.0",
"oxlint-tsgolint": "^7.0.2001",
"typescript": "7.0.2",
"webpack": "^5.110.1"
"@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.11.0",
"oxfmt": "^0.64.0",
"typescript": "~6.0.3",
"typescript-eslint": "^8.67.0",
"webpack": "^5.109.2"
},
"browserslist": {
"production": [
-51
View File
@@ -1,51 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 238 55" style="enable-background:new 0 0 238 55;" xml:space="preserve">
<path d="M234.9,34.8c-0.9-1.3-2.2-2.2-3.6-2.8c-0.4-0.2-1.2-0.4-2.5-0.8c-1-0.3-1.9-0.6-2.8-1.1c-0.6-0.3-1.1-0.8-1.5-1.4
c-0.3-0.6-0.5-1.3-0.5-1.9c0-1,0.4-2,1.1-2.7c0.7-0.7,1.7-1.1,2.8-1.1c1.1-0.1,2.1,0.3,2.9,1c0.8,0.8,1.2,1.8,1.2,2.9h3.6
c0-2-0.7-3.9-2.1-5.3c-1.5-1.3-3.4-2-5.4-1.9c-2,0-4,0.7-5.5,2.1c-1.5,1.3-2.3,3.2-2.3,5.2c-0.1,1.5,0.4,3.1,1.3,4.3
c0.9,1.1,2.8,2.1,5.8,3.1c1.4,0.4,2.7,1.2,3.8,2.1c0.7,0.8,1.1,1.9,1.1,3c0,1.2-0.5,2.3-1.3,3.1c-0.9,0.8-2.1,1.3-3.3,1.2
c-1.4,0-2.6-0.6-3.5-1.6c-1-1.1-1.5-2.6-1.4-4.1V38h-3.5c-0.1,2.4,0.8,4.7,2.4,6.5c1.5,1.6,3.7,2.5,6,2.4c2.2,0.1,4.4-0.7,6.1-2.2
c1.6-1.4,2.5-3.5,2.4-5.6C236.2,37.6,235.8,36.1,234.9,34.8z"/>
<path d="M117,31.4c0.3-2.4,1.5-4.7,3.3-6.4c1.8-1.6,4.1-2.4,6.5-2.4c2.4-0.1,4.8,0.7,6.7,2.2c1.9,1.8,3.1,4.1,3.6,6.6L117,31.4z
M140.9,34.1c0-4.3-1.3-7.9-3.8-10.6s-6.1-4.2-9.8-4.1c-3.7-0.1-7.4,1.3-10,3.9s-4,6.2-3.9,9.9c-0.1,3.7,1.4,7.2,4,9.8
c2.5,2.7,6.1,4.1,9.8,4.1c2.7,0.1,5.4-0.8,7.7-2.3c2.3-1.7,4.1-4,5.2-6.7h-3.8c-0.8,1.7-2.1,3.2-3.7,4.2c-1.6,1-3.5,1.6-5.4,1.5
c-2.6,0.1-5.1-0.9-7-2.6c-1.9-1.8-3-4.3-3.1-6.9h24L140.9,34.1z"/>
<path d="M175.1,19.6c-1.9,0-3.7,0.4-5.4,1.2c-1.6,0.8-3,2-4,3.5c-0.9-1.5-2.2-2.6-3.8-3.4c-1.7-0.8-3.6-1.3-5.5-1.3
c-1.6,0-3.2,0.3-4.7,0.9c-1.4,0.6-2.6,1.5-3.6,2.6v-2.9h-3.4v26.2h3.3V33.6c0-1.6,0-3.2,0.2-4.8c0.2-0.9,0.4-1.7,0.9-2.4
c0.6-1.1,1.6-2,2.8-2.6c1.3-0.6,2.7-1,4.1-0.9c2.6,0,4.5,0.7,5.7,2.2c1.2,1.4,1.9,3.7,1.9,6.7v14.7h3.3V33.6c0-1.6,0-3.2,0.3-4.8
c0.2-0.9,0.5-1.7,0.9-2.4c0.6-1.1,1.6-2,2.7-2.6c1.2-0.6,2.6-1,4-0.9c2.6,0,4.5,0.7,5.7,2.2s1.7,3.9,1.7,7.4v13.9h3.3V33.1
c0-4.7-0.8-8.2-2.5-10.3S178.7,19.6,175.1,19.6z"/>
<path d="M193.1,31.4c0.3-2.4,1.5-4.7,3.3-6.4c1.8-1.6,4.1-2.4,6.5-2.4c2.4-0.1,4.9,0.7,6.8,2.2c1.9,1.8,3.1,4.1,3.5,6.6L193.1,31.4z
M216.9,34.1c0-4.3-1.3-7.9-3.8-10.6s-6.1-4.2-9.8-4.1c-3.7-0.1-7.4,1.3-10,3.9s-4,6.2-3.9,9.9c-0.1,3.7,1.4,7.2,4,9.8
c2.5,2.7,6.1,4.2,9.8,4.1c2.7,0,5.4-0.8,7.7-2.3c2.3-1.7,4.1-4,5.2-6.7h-3.8c-0.8,1.7-2.1,3.2-3.8,4.2c-1.6,1-3.5,1.5-5.4,1.5
c-2.6,0.1-5.2-0.9-7.1-2.6c-1.9-1.8-3-4.3-3.1-6.9h24L216.9,34.1L216.9,34.1z"/>
<path d="M108.4,20.7c-1.1,0.7-2,1.5-2.7,2.6v-3.1h-3.2v26.2h3.5V30.6c0-2.4,0.5-4.1,1.4-5.2c0.9-1.1,2.4-1.7,4.6-1.9v-3.7
C110.6,19.8,109.4,20.1,108.4,20.7z"/>
<path d="M74.6,31.4c0.3-2.4,1.5-4.7,3.3-6.4c1.8-1.6,4.1-2.4,6.5-2.4c2.4-0.1,4.8,0.7,6.7,2.2c1.9,1.8,3.1,4.1,3.5,6.6L74.6,31.4z
M98.5,34.1c0-4.3-1.3-7.9-3.8-10.6s-6.1-4.2-9.8-4.1c-3.7-0.1-7.4,1.3-10,3.9s-4,6.2-3.9,9.9c-0.1,3.7,1.4,7.2,4,9.8
c2.5,2.7,6.1,4.1,9.8,4.1c2.7,0,5.4-0.8,7.7-2.3c2.3-1.7,4.1-4,5.1-6.7h-3.8c-0.8,1.7-2.1,3.2-3.7,4.2s-3.5,1.5-5.4,1.5
c-2.6,0.1-5.1-0.9-7-2.6c-1.9-1.8-3-4.3-3.1-6.9h24L98.5,34.1z"/>
<path d="M47.4,11.7c-1.7-4-4.2-8.4-8.5-10.1C34.2,0,29.1,1,25.4,4.2c-2.7,2.4-4.3,6.1-4.8,11L20.4,16c-0.1,1.3-0.3,2.6-0.3,3.8
c-0.8-3.5-0.5-5.9-0.7-9.7c-0.8,0.4-1.2,1.2-1.2,2.1c0,0.2-1-0.4-1.2-0.3c-0.8,0.4-1.6-1-2-1.5c-0.2,0.4-0.4,0.7-0.7,1.1
C14,11,13.5,11,13,10.5c-0.4,1-1.6,0.5-2.7,0.5c0.6,1,0.8,2-0.1,2.4c0.1,0.1,0.9,0.3,0.9,0.5c-0.3,0.4-1.3,0-1.8-0.1v0.8
c-1.4-0.8-3.7-2.3-2.9,0.9c-1,0.2-0.5,0-0.5,0.9c-0.4,0.1-0.4,0.1-0.4,0.6c-1.2-0.6-2.2,4.9-2.1,6.5c0.9,0.2,1,0.5,1.5,1.4
c-0.7,0.3-1.1,1-1.6,1.3L4,26.6c-0.6,0.3-1.1,0.7-1.5,1.3c0.2-0.3,0.6,0.4,0.5,0.3L2.6,28c0.1,0.4,0.3,0.8,0.4,1.2
c-0.8,0.3-0.6,0.6-1.1,1.3c0.2,0,0.5,0.1,0.7,0.1c-0.4,0-0.5,1.9-0.4,2.2c0.2-0.4,0.6-0.9,0.8-1.4l0.5,0.5l-0.7,0.6
c1.8,0.5-0.2,1.7,0.6,3.1C3.6,34.8,4,34.7,4,33.8l0.4,0.3c-0.2,0.3-1.4,2.6-0.3,2.6c0.1,0,0.1,3.8,0.3,4.8C4.6,41.3,4.8,41,5,40.6
c0,0.6,0.3,0.8,0.1,1.5c1.8-0.5,1.1,0.5,0.4,1.4c0.6-0.1,1.2-0.3,1.8-0.5c0.8-0.3,0.1,1.1,0.4,1.1c0.3,0,1.2-1.8,1.4-2.1v0.6
c0.4-0.2,1.8-2,2-2c0.2,0.3,0.3,0.7,0.2,1.1c1.6-2,3-4.2,4-6.6c0.1,0.1,0.2,0.2,0.4,0.2c-0.2,0-4.1,7.7-4.1,8.1l0.9-0.2
c-0.3,0.6-0.5,1.2-0.6,1.8c1.6-0.2,0.9,1.2,0.9,2.5c1.3-0.8,3.2-1.3,2.5,0.9c0.4-0.2,0.9-0.4,1.3-0.6c-1.1,0.4,0.5,2.6,0.7,3.2
c0.2,0.7,2.3,0.2,3,0.4c0.7-1.1,1,0.2,1.2,1.3s1.4-0.6,1.9-0.4c0.4,0.1-0.3,2.7,1.2,1.6c0.6-0.5,0.8,0.4,1.7-0.5
c0,0,2.7,0.4,2.6,0.4c0.5-2.6,2.5-0.1,2.2-2.4h0.6c-0.1-2,2,0.9,2-2c0-0.7-1.6-1.5,0.4-0.9c-0.2-0.9,0.2-1.1-0.8-1.3
c-0.1-0.2-0.1-0.4,0.1-0.5c0.9,0,2.5,1.7,3.1,0.9c0.2-0.2-0.8-2-0.9-2.5c0.5,0.1,1.1-0.1,1.6,0c-0.9-0.5-0.3-0.6-1.4-0.9
c0.9-1.7,2.6-0.1,3.4-1.4c-1.7,0.3-2.6-3.3-1.5-3.6c-0.9-0.8-1.6-1-2.3-1.9c-1.8-2.3,1.7,0.4,2.3,0.9c0-0.4,0.2-0.9,0.2-1.3l0.9,0.8
c0-0.2,0.1-0.4,0.1-0.6c0.8,0.7,1.9,1,2.9,0.9c-0.2-0.5,0-0.6-0.2-1.1c0.8,0.2,1.3,0,2.2,0.1c-0.6-1.8,1.5-1.7,2.9-2.1
c2.3-0.7-1.4-1-1.6-1.2c-0.4-0.4,0.5-0.9,0.6-0.9s-0.8-1.1-0.6-0.8c-0.3-1-2.6-0.3-0.6-1.6c-0.9-0.2-2.1-0.4-2.1-1.6
c0.7-0.1,1.5-0.2,2.2-0.4c-0.5-0.4-0.9-1.1-1.5-1.4l0.5-0.2c-2.8-0.5,0.3-2.7,1-4c-1.9,0.4-2-1.1-3.7-1.3l0.8-0.7
c-0.9,0-1.9-0.1-2.8-0.3c0.2-1,0.9-1.9,1.9-2.2c-3.1-1.5-2.4-3.8-5.6-4.7l0.7-0.7c-0.9-1.3-1.6-0.4-2.6-0.7
c-2.1-0.6-1.9,2.3-1.9-1.1c0,0.4-0.5-0.4-0.6-0.6c-0.8,0.5-3.4,2.6-3.9,2.1C26.1,12,26,12.2,25.5,13c-0.3,0.3-1.3,0.9-1.1,0.5
c-0.6,0.9-0.6,4.3-1.3,6.6c0-1.2,0.2-2.4,0.3-3.6l0.1-0.9c0.4-4.1,1.7-7.1,3.8-8.9c2.8-2.4,6.7-3.1,10.3-2c3.4,1.2,5.5,5.4,7,9.1
L57,46.5h2.7l13.2-34.7h-3.5L58.5,41.2L47.4,11.7z"/>
</svg>

Before

Width:  |  Height:  |  Size: 5.3 KiB

+9130 -7300
View File
File diff suppressed because it is too large Load Diff
+6 -23
View File
@@ -1,30 +1,14 @@
{
// This file is not used in compilation. It is here just for a nice editor experience.
// "extends": "@docusaurus/tsconfig",
// First compilerOptions section comes from above commented @docusaurus/tsconfig
// We moved them here to help with TS v7 migration so whenever Docusaurus readily supports TS v7,
// re-install @docusaurus/tsconfig and remove said section.
// Commented options are overriden in the next section.
"extends": "@docusaurus/tsconfig",
"compilerOptions": {
"allowJs": true,
// "esModuleInterop": true,
// "jsx": "preserve",
"target": "ES2022",
"lib": ["ES2022", "DOM"],
// "moduleResolution": "bundler",
"module": "esnext",
"noEmit": true,
// "paths": {
// "@site/*": ["./*"]
// },
// "skipLibCheck": true,
"baseUrl": ".",
"ignoreDeprecations": "6.0",
"skipLibCheck": true,
"noImplicitAny": false,
"strict": false,
"jsx": "react-jsx",
"moduleResolution": "bundler",
"moduleResolution": "node",
"resolveJsonModule": true,
"esModuleInterop": true,
"types": ["@docusaurus/module-type-aliases"],
@@ -39,10 +23,9 @@
// Runtime resolution uses webpack alias pointing to actual source (see src/webpack.extend.ts)
// Using /ui path matches the established pattern used throughout the Superset codebase
"@apache-superset/core/components": ["./src/types/apache-superset-core"],
"@site/*": ["./*"],
"*": ["./src/*", "./node_modules/*"]
"*": ["src/*", "node_modules/*"]
}
},
"include": ["./src/**/*.ts", "./src/**/*.tsx", "./src/**/*.d.ts"],
"exclude": ["./node_modules", "../superset-frontend/**/*", "src/shims/**"]
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"],
"exclude": ["node_modules", "../superset-frontend/**/*", "src/shims/**"]
}
+1374 -479
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -289,11 +289,11 @@ function extractArgs(args, regexes) {
* For example: `superset-frontend/foo/bar.ts` -> `foo/bar.ts`
*
* @param {string[]} args
* @param {string} packageName
* @param {string} package
* @returns {string[]}
*/
function removePackageSegment(args, packageName) {
const packageSegment = packageName.concat(sep);
function removePackageSegment(args, package) {
const packageSegment = package.concat(sep);
return args.map((arg) => {
const normalizedPath = normalize(arg);
@@ -146,8 +146,6 @@ class Operator(str, enum.Enum):
NOT_IN = "NOT IN"
LIKE = "LIKE"
NOT_LIKE = "NOT LIKE"
ILIKE = "ILIKE"
NOT_ILIKE = "NOT ILIKE"
IS_NULL = "IS NULL"
IS_NOT_NULL = "IS NOT NULL"
ADHOC = "ADHOC"
+3 -3
View File
@@ -3237,9 +3237,9 @@
"dev": true
},
"node_modules/fast-uri": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"dev": true,
"funding": [
{
+48 -61
View File
@@ -3163,15 +3163,11 @@
]
},
"node_modules/baseline-browser-mapping": {
"version": "2.11.20",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz",
"integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==",
"license": "Apache-2.0",
"version": "2.9.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
"integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
"bin": {
"baseline-browser-mapping": "dist/cli.cjs"
},
"engines": {
"node": ">=6.0.0"
"baseline-browser-mapping": "dist/cli.js"
}
},
"node_modules/bcrypt-pbkdf": {
@@ -3213,9 +3209,9 @@
}
},
"node_modules/browserslist": {
"version": "4.28.8",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
"integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
"funding": [
{
"type": "opencollective",
@@ -3230,13 +3226,12 @@
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"baseline-browser-mapping": "^2.11.12",
"caniuse-lite": "^1.0.30001809",
"electron-to-chromium": "^1.5.402",
"node-releases": "^2.0.53",
"update-browserslist-db": "^1.3.0"
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
"electron-to-chromium": "^1.5.263",
"node-releases": "^2.0.27",
"update-browserslist-db": "^1.2.0"
},
"bin": {
"browserslist": "cli.js"
@@ -3330,9 +3325,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001810",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
"integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
"version": "1.0.30001769",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz",
"integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==",
"funding": [
{
"type": "opencollective",
@@ -3346,8 +3341,7 @@
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "CC-BY-4.0"
]
},
"node_modules/caseless": {
"version": "0.12.0",
@@ -4003,10 +3997,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.418",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.418.tgz",
"integrity": "sha512-UzS26r3AEbG5wSoGVpJKqwHIU9zwQN7LHdVIThDrJpS0I5KdlXFMEb8543fhc9dVnIIAST6ar8rhwa00AL5MlA==",
"license": "ISC"
"version": "1.5.286",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz",
"integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="
},
"node_modules/emoji-regex": {
"version": "8.0.0",
@@ -4115,7 +4108,6 @@
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
@@ -6638,13 +6630,9 @@
}
},
"node_modules/node-releases": {
"version": "2.0.54",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz",
"integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="
},
"node_modules/npm-run-path": {
"version": "4.0.1",
@@ -8356,9 +8344,9 @@
}
},
"node_modules/update-browserslist-db": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz",
"integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==",
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"funding": [
{
"type": "opencollective",
@@ -8373,7 +8361,6 @@
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"escalade": "^3.2.0",
"picocolors": "^1.1.1"
@@ -11131,9 +11118,9 @@
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
},
"baseline-browser-mapping": {
"version": "2.11.20",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz",
"integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw=="
"version": "2.9.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
"integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="
},
"bcrypt-pbkdf": {
"version": "1.0.2",
@@ -11169,15 +11156,15 @@
}
},
"browserslist": {
"version": "4.28.8",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
"integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
"requires": {
"baseline-browser-mapping": "^2.11.12",
"caniuse-lite": "^1.0.30001809",
"electron-to-chromium": "^1.5.402",
"node-releases": "^2.0.53",
"update-browserslist-db": "^1.3.0"
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
"electron-to-chromium": "^1.5.263",
"node-releases": "^2.0.27",
"update-browserslist-db": "^1.2.0"
}
},
"buffer-crc32": {
@@ -11237,9 +11224,9 @@
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="
},
"caniuse-lite": {
"version": "1.0.30001810",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
"integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="
"version": "1.0.30001769",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz",
"integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg=="
},
"caseless": {
"version": "0.12.0",
@@ -11738,9 +11725,9 @@
}
},
"electron-to-chromium": {
"version": "1.5.418",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.418.tgz",
"integrity": "sha512-UzS26r3AEbG5wSoGVpJKqwHIU9zwQN7LHdVIThDrJpS0I5KdlXFMEb8543fhc9dVnIIAST6ar8rhwa00AL5MlA=="
"version": "1.5.286",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz",
"integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="
},
"emoji-regex": {
"version": "8.0.0",
@@ -13483,9 +13470,9 @@
}
},
"node-releases": {
"version": "2.0.54",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz",
"integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ=="
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="
},
"npm-run-path": {
"version": "4.0.1",
@@ -14712,9 +14699,9 @@
"integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw=="
},
"update-browserslist-db": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz",
"integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==",
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"requires": {
"escalade": "^3.2.0",
"picocolors": "^1.1.1"
@@ -1,5 +1,5 @@
{
"name": "@superset-ui/eslint-plugin-i18n-strings",
"name": "eslint-plugin-i18n-strings",
"version": "1.0.0",
"description": "Warns about translation variables",
"keywords": [],
@@ -1,5 +1,5 @@
{
"name": "@superset-ui/eslint-plugin-icons",
"name": "eslint-plugin-icons",
"version": "1.0.0",
"description": "Warns about direct usage of Ant Design icons",
"keywords": [],
@@ -1,5 +1,5 @@
{
"name": "@superset-ui/eslint-plugin-theme-colors",
"name": "eslint-plugin-theme-colors",
"version": "1.0.0",
"description": "Warns about rgb(a)/hex/literal colors",
"keywords": [],
+3 -3
View File
@@ -37,9 +37,9 @@
require('tsx/cjs');
const tsParser = require('@typescript-eslint/parser');
const themeColorsPlugin = require('@superset-ui/eslint-plugin-theme-colors');
const iconsPlugin = require('@superset-ui/eslint-plugin-icons');
const i18nStringsPlugin = require('@superset-ui/eslint-plugin-i18n-strings');
const themeColorsPlugin = require('eslint-plugin-theme-colors');
const iconsPlugin = require('eslint-plugin-icons');
const i18nStringsPlugin = require('eslint-plugin-i18n-strings');
module.exports = [
// Files this config applies to. Flat config has no `--ext`; globs live here.
+9 -14
View File
@@ -18,19 +18,6 @@
*/
// timezone for unit tests
process.env.TZ = 'America/New_York';
const reporters = ['default'];
// HTML reporter is not used on CI so skipping its generation for saving time
if (!process.env.CI) {
reporters.push([
'./node_modules/jest-html-reporter',
{
pageTitle: 'Test Report',
},
]);
}
module.exports = {
// [/\\] matches both path separators so the suite also collects on
// native Windows, where jest hands the regex backslash-separated paths.
@@ -101,6 +88,14 @@ module.exports = {
__DEV__: true,
caches: true,
},
reporters: reporters,
reporters: [
'default',
[
'./node_modules/jest-html-reporter',
{
pageTitle: 'Test Report',
},
],
],
testTimeout: 20000,
};
+1044 -1002
View File
File diff suppressed because it is too large Load Diff
+17 -17
View File
@@ -146,6 +146,7 @@
"@superset-ui/plugin-chart-world-map": "file:./plugins/plugin-chart-world-map",
"@superset-ui/preset-chart-deckgl": "file:./plugins/preset-chart-deckgl",
"@superset-ui/switchboard": "file:./packages/superset-ui-switchboard",
"@types/d3-format": "^3.0.1",
"@types/d3-selection": "^3.0.11",
"@types/d3-time-format": "^4.0.3",
"@types/react-google-recaptcha": "^2.1.9",
@@ -192,7 +193,6 @@
"mustache": "^4.2.0",
"nanoid": "^6.0.1",
"ol": "^10.10.0",
"postcss": "^8.5.15",
"query-string": "9.5.0",
"re-resizable": "^6.11.2",
"react": "^18.3.0",
@@ -203,7 +203,7 @@
"react-dnd-html5-backend": "^11.1.3",
"react-dom": "^18.3.0",
"react-google-recaptcha": "^3.1.0",
"react-intersection-observer": "^11.0.1",
"react-intersection-observer": "^11.0.0",
"react-json-tree": "^0.20.0",
"react-lines-ellipsis": "^0.16.1",
"react-loadable": "^5.5.0",
@@ -256,14 +256,11 @@
"@formatjs/intl-durationformat": "^0.10.18",
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@playwright/test": "^1.62.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.3",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
"@storybook/addon-docs": "10.5.10",
"@storybook/addon-links": "10.5.10",
"@storybook/react-webpack5": "10.5.10",
"@storybook/test-runner": "0.24.4",
"@superset-ui/eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
"@superset-ui/eslint-plugin-icons": "file:eslint-rules/eslint-plugin-icons",
"@superset-ui/eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.16.1",
"@swc/plugin-emotion": "^15.0.0",
@@ -280,7 +277,7 @@
"@types/json-bigint": "^1.0.4",
"@types/lodash-es": "^4.17.12",
"@types/mousetrap": "^1.6.15",
"@types/node": "^26.4.0",
"@types/node": "^26.2.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@types/react-loadable": "^5.5.11",
@@ -292,21 +289,23 @@
"@types/rison": "0.1.0",
"@types/tinycolor2": "^1.4.3",
"@types/unzipper": "^0.10.11",
"@typescript-eslint/eslint-plugin": "^8.68.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.19",
"baseline-browser-mapping": "^2.11.17",
"cheerio": "1.2.0",
"concurrently": "^10.0.5",
"copy-webpack-plugin": "^14.0.0",
"cross-env": "^10.1.0",
"css-loader": "^7.1.4",
"eslint": "^10.9.1",
"eslint": "^10.9.0",
"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",
"eslint-plugin-icons": "file:eslint-rules/eslint-plugin-icons",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jest-dom": "^5.10.1",
"eslint-plugin-lodash": "^8.0.0",
@@ -315,6 +314,7 @@
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.2",
"eslint-plugin-storybook": "10.5.10",
"eslint-plugin-testing-library": "^7.16.2",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
"fetch-mock": "^12.6.0",
"fork-ts-checker-webpack-plugin": "^9.1.0",
"history": "^5.3.0",
@@ -329,10 +329,10 @@
"lerna": "^10.0.1",
"lightningcss": "^1.33.0",
"mini-css-extract-plugin": "^2.10.2",
"minimizer-webpack-plugin": "^5.7.0",
"minimizer-webpack-plugin": "^5.6.1",
"open-cli": "^9.0.0",
"oxfmt": "^0.65.0",
"oxlint": "^1.80.0",
"oxfmt": "^0.64.0",
"oxlint": "^1.79.0",
"po2json": "^0.4.5",
"postcss-styled-syntax": "^0.7.2",
"process": "^0.11.10",
@@ -354,9 +354,9 @@
"unzipper": "^0.12.5",
"wait-on": "^9.1.0",
"webpack": "^5.109.2",
"webpack-bundle-analyzer": "^5.3.2",
"webpack-cli": "^7.2.3",
"webpack-dev-server": "^6.0.0",
"webpack-bundle-analyzer": "^5.3.1",
"webpack-cli": "^7.0.3",
"webpack-dev-server": "^5.2.5",
"webpack-manifest-plugin": "^6.0.1",
"webpack-sources": "^3.5.1",
"webpack-visualizer-plugin2": "^2.0.0"
@@ -406,7 +406,7 @@
"eslint-plugin-jest-dom": {
"eslint": "$eslint"
},
"fast-uri": "^3.1.7",
"fast-uri": "^3.1.5",
"fast-xml-parser": "^5.8.0",
"http-proxy-middleware": "^2.0.10",
"jest-circus": "^30.4.0",
@@ -33,7 +33,7 @@
"dependencies": {
"chalk": "^6.0.0",
"lodash-es": "^4.18.1",
"yeoman-generator": "^8.3.0",
"yeoman-generator": "^8.2.2",
"yosay": "^3.0.0"
},
"devDependencies": {
@@ -550,12 +550,6 @@ export interface ThemeContextType {
canDetectOSPreference: () => boolean;
createDashboardThemeProvider: (themeId: string) => Promise<Theme | null>;
getAppliedThemeId: () => number | null;
/**
* Re-reads the persisted system default/dark themes from the server and
* re-applies them live, so changes made on the Themes admin page take effect
* without a full page reload.
*/
refreshSystemThemes: () => Promise<void>;
}
/**
@@ -53,7 +53,6 @@
"@apache-superset/core": "*",
"@babel/runtime": "^7.29.7",
"@braintree/sanitize-url": "^7.1.2",
"@types/d3-format": "^3.0.4",
"@types/json-bigint": "^1.0.4",
"@visx/responsive": "^4.0.0",
"ace-builds": "^1.44.0",
@@ -97,13 +96,14 @@
},
"devDependencies": {
"@emotion/styled": "^11.14.1",
"@types/d3-format": "^3.0.4",
"@types/d3-interpolate": "^3.0.4",
"@types/d3-scale": "^4.0.9",
"@types/d3-time": "^3.0.4",
"@types/d3-time-format": "^4.0.3",
"@types/jquery": "^4.0.1",
"@types/lodash": "^4.17.25",
"@types/node": "^26.4.0",
"@types/node": "^26.2.0",
"@types/prop-types": "^15.7.15",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/react-table": "^7.7.20",
@@ -146,61 +146,3 @@ test('Calling "onConfirm" only after typing "delete" in the input', async () =>
// confirm input has been cleared
expect(screen.getByTestId('delete-modal-input')).toHaveValue('');
});
test('external disable keeps the destructive action unavailable after confirmation', async () => {
const onConfirm = jest.fn();
render(
<DeleteModal
title="Delete permanently?"
description="This cannot be undone."
onConfirm={onConfirm}
onHide={jest.fn()}
open
disablePrimaryButton
/>,
);
await userEvent.type(screen.getByTestId('delete-modal-input'), 'DELETE');
expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled();
await userEvent.click(screen.getByRole('button', { name: 'Delete' }));
expect(onConfirm).not.toHaveBeenCalled();
});
test('loading disables the destructive action and exposes busy state', async () => {
render(
<DeleteModal
title="Delete permanently?"
description="Checking dependencies"
onConfirm={jest.fn()}
onHide={jest.fn()}
open
loading
/>,
);
await userEvent.type(screen.getByTestId('delete-modal-input'), 'DELETE');
expect(screen.getByTestId('modal-confirm-button')).toBeDisabled();
expect(screen.getByTestId('antd-modal')).toHaveAttribute('aria-busy', 'true');
});
test('confirmation reset key clears and re-arms type-to-confirm', async () => {
const props = {
title: 'Delete permanently?',
description: 'This cannot be undone.',
onConfirm: jest.fn(),
onHide: jest.fn(),
open: true,
};
const { rerender } = render(
<DeleteModal {...props} confirmationResetKey="initial" />,
);
await userEvent.type(screen.getByTestId('delete-modal-input'), 'DELETE');
expect(screen.getByRole('button', { name: 'Delete' })).toBeEnabled();
rerender(<DeleteModal {...props} confirmationResetKey="impact-changed" />);
expect(screen.getByTestId('delete-modal-input')).toHaveValue('');
expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled();
});
@@ -40,9 +40,6 @@ export function DeleteModal({
title,
name,
recoverable = false,
disablePrimaryButton = false,
loading = false,
confirmationResetKey,
}: DeleteModalProps) {
// Recoverable (archive) deletes drop the "type DELETE to confirm" step;
// a permanent delete keeps it.
@@ -57,11 +54,6 @@ export function DeleteModal({
}
}, [open]);
useEffect(() => {
setConfirmation('');
setDisableChange(true);
}, [confirmationResetKey]);
// Re-arm the gate alongside clearing the text: resetting only the string
// leaves disableChange=false behind, so a user who typed DELETE, cancelled,
// and reopened would face an enabled Delete button over an empty input.
@@ -84,19 +76,14 @@ export function DeleteModal({
};
const onPressEnter = () => {
if (!disableChange && !disablePrimaryButton && !loading) {
if (!disableChange) {
confirm();
}
};
return (
<Modal
disablePrimaryButton={
disablePrimaryButton ||
loading ||
(showConfirmationInput ? disableChange : false)
}
primaryButtonLoading={loading}
disablePrimaryButton={showConfirmationInput ? disableChange : false}
onHide={hide}
onHandledPrimaryAction={confirm}
primaryButtonName={recoverable ? t('Archive') : t('Delete')}
@@ -104,7 +91,6 @@ export function DeleteModal({
show={open}
name={name}
title={title}
wrapProps={{ 'aria-busy': loading }}
centered
>
{description}
@@ -32,10 +32,4 @@ export interface DeleteModalProps {
* friction and uses a primary (non-danger) confirm button.
*/
recoverable?: boolean;
/** Disable confirmation independently of the typed-text gate. */
disablePrimaryButton?: boolean;
/** Show progress on the primary action and prevent duplicate submission. */
loading?: boolean;
/** Clear and re-arm the typed-text gate when the reviewed data changes. */
confirmationResetKey?: string | number;
}
@@ -752,97 +752,6 @@ test('displays an error message when an exception is thrown while fetching', asy
expect(screen.getByText(error)).toBeInTheDocument();
});
test('clears a previous fetch error once a later fetch succeeds', async () => {
const error = 'Fetch error';
const loadOptions = jest.fn(async (search: string) => {
if (search === 'fail') {
throw new Error(error);
}
// Report more results than are loaded so every new search hits the server.
return { data: [{ label: search, value: search }], totalCount: 100 };
});
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
await open();
await type('fail');
expect(await screen.findByText(error)).toBeInTheDocument();
await type('retry');
expect(await findSelectOption('retry')).toBeInTheDocument();
expect(screen.queryByText(error)).not.toBeInTheDocument();
});
test('clears a previous fetch error when the next page comes from cache', async () => {
const error = 'Fetch error';
const loadOptions = jest.fn(
async (search: string, page: number, pageSize: number) => {
if (search === 'fail') {
throw new Error(error);
}
return defaultProps.options(search, page, pageSize);
},
);
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
await open();
await findSelectOption(OPTIONS[0].label);
await type('fail');
expect(await screen.findByText(error)).toBeInTheDocument();
// Clearing the input re-requests the first page, which is already cached
// and therefore never reaches the network.
await userEvent.clear(getSelect());
expect(await findSelectOption(OPTIONS[0].label)).toBeInTheDocument();
expect(screen.queryByText(error)).not.toBeInTheDocument();
});
test('ignores a late failure from a search the user has moved on from', async () => {
const error = 'Fetch error';
let rejectSlow: (reason: Error) => void = () => {};
const loadOptions = jest.fn(async (search: string) => {
if (search === 'slow') {
return new Promise<never>((_, reject) => {
rejectSlow = reject;
});
}
return { data: [{ label: search, value: search }], totalCount: 100 };
});
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
await open();
await type('slow');
await waitFor(() => expect(loadOptions).toHaveBeenCalledWith('slow', 0, 10));
await type('fast');
expect(await findSelectOption('fast')).toBeInTheDocument();
rejectSlow(new Error(error));
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(3));
expect(screen.queryByText(error)).not.toBeInTheDocument();
expect(await findSelectOption('fast')).toBeInTheDocument();
});
test('still surfaces a base-fetch failure that lands mid-search', async () => {
const error = 'Fetch error';
let rejectBase: (reason: Error) => void = () => {};
const loadOptions = jest.fn(async (search: string) => {
if (search === '') {
// Defer the base page so it can fail after the user starts searching.
return new Promise<never>((_, reject) => {
rejectBase = reject;
});
}
return { data: [{ label: search, value: search }], totalCount: 100 };
});
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
await open();
await type('abc');
expect(await findSelectOption('abc')).toBeInTheDocument();
// Base fetches keep the accumulator and allValuesLoaded up to date even
// mid-search, so their failures must surface too.
rejectBase(new Error(error));
expect(await screen.findByText(error)).toBeInTheDocument();
});
test('does not fire a new request for the same search input', async () => {
const loadOptions = jest.fn(async () => ({ data: [], totalCount: 0 }));
render(
@@ -401,10 +401,6 @@ const AsyncSelect = forwardRef(
const fetchPage = useMemo(
() => (search: string, page: number) => {
setPage(page);
// A previous fetch may have left an error on screen. Clear it before
// any early return so a page served from cache, or from an already
// complete option set, is not shown next to a stale error.
setError('');
if (allValuesLoaded) {
setIsLoading(false);
return;
@@ -490,19 +486,7 @@ const AsyncSelect = forwardRef(
setTotalCount(totalCount);
}
})
.catch((response: Response) => {
// Mirror the results guard above: a failure belonging to a search
// the user has since moved on from must not replace the outcome
// of the fetch that superseded it. Base fetches (search === '')
// are exempt exactly as their results are — they maintain the
// accumulator and allValuesLoaded, so their failures must stay
// visible even when they land mid-search. The consumer's onError
// is skipped along with the banner for superseded searches.
if (search && inputValueRef.current !== search) {
return undefined;
}
return internalOnError(response);
})
.catch(internalOnError)
.finally(() => {
inFlightFetchesRef.current = Math.max(
0,
@@ -17,7 +17,6 @@
* under the License.
*/
import {
act,
createEvent,
fireEvent,
render,
@@ -27,7 +26,6 @@ import {
within,
} from '@superset-ui/core/spec';
import { formatNumber } from '@superset-ui/core';
import { Constants } from '@superset-ui/core/components';
import { Select } from '.';
type Option = {
@@ -71,38 +69,6 @@ const NULL_OPTION = { label: '<NULL>', value: null } as unknown as {
value: number;
};
// A dedicated option set for the stableSelectAll tests, kept local so it is
// isolated from tests that mutate the shared OPTIONS array (e.g. toggling
// `disabled`). A search for "Ap" matches a strict subset (Apple, Apricot).
const STABLE_OPTIONS = [
{ label: 'Apple', value: 1 },
{ label: 'Apricot', value: 2 },
{ label: 'Banana', value: 3 },
{ label: 'Blueberry', value: 4 },
{ label: 'Cherry', value: 5 },
{ label: 'Cranberry', value: 6 },
];
// A grouped option list for the stableSelectAll tests: bulk "Select all" must
// target the five leaf options, not the two value-less group headers.
const GROUPED_STABLE_OPTIONS = [
{
label: 'Citrus',
options: [
{ label: 'Orange', value: 1 },
{ label: 'Lemon', value: 2 },
],
},
{
label: 'Berries',
options: [
{ label: 'Strawberry', value: 3 },
{ label: 'Blueberry', value: 4 },
{ label: 'Raspberry', value: 5 },
],
},
];
const defaultProps = {
allowClear: true,
ariaLabel: ARIA_LABEL,
@@ -1186,342 +1152,6 @@ test('abbreviates large numbers in bulk action buttons', async () => {
expect(await screen.findByText('Select all (1.5k)')).toBeInTheDocument();
});
// The stableSelectAll tests advance fake timers past the FAST_DEBOUNCE so the
// component's own search filter narrows `visibleOptions` (and flips
// `isSearching`) before asserting — the exact point at which the un-fixed code
// drops the badge to the search-scoped count. Asserting before that debounce
// fires (as an earlier revision did) would pass against the un-fixed code too.
test('stableSelectAll pins the "Select all" count to the full option set while searching', async () => {
jest.useFakeTimers({ advanceTimers: true });
try {
render(
<Select
{...defaultProps}
options={STABLE_OPTIONS}
mode="multiple"
stableSelectAll
/>,
);
const select = getSelect();
userEvent.click(select);
// Baseline: the full-column count is shown before any search.
expect(
await screen.findByText(selectAllButtonText(STABLE_OPTIONS.length)),
).toBeInTheDocument();
await userEvent.type(select, 'Ap');
act(() => {
jest.advanceTimersByTime(Constants.FAST_DEBOUNCE + 50);
});
// The visible list narrows to the searched subset...
await waitFor(() => expect(getAllSelectOptions().length).toBe(2));
// ...but the badge must still report the full column count.
expect(
screen.getByText(selectAllButtonText(STABLE_OPTIONS.length)),
).toBeInTheDocument();
expect(screen.queryByText(selectAllButtonText(2))).not.toBeInTheDocument();
} finally {
jest.useRealTimers();
}
});
test('without stableSelectAll the "Select all" count narrows to the searched subset', async () => {
jest.useFakeTimers({ advanceTimers: true });
try {
render(
<Select {...defaultProps} options={STABLE_OPTIONS} mode="multiple" />,
);
const select = getSelect();
userEvent.click(select);
expect(
await screen.findByText(selectAllButtonText(STABLE_OPTIONS.length)),
).toBeInTheDocument();
await userEvent.type(select, 'Ap');
act(() => {
jest.advanceTimersByTime(Constants.FAST_DEBOUNCE + 50);
});
await waitFor(() => expect(getAllSelectOptions().length).toBe(2));
// Generic consumers keep the search-scoped count.
expect(screen.getByText(selectAllButtonText(2))).toBeInTheDocument();
expect(
screen.queryByText(selectAllButtonText(STABLE_OPTIONS.length)),
).not.toBeInTheDocument();
} finally {
jest.useRealTimers();
}
});
test('stableSelectAll selects the entire option set even while a search is active', async () => {
jest.useFakeTimers({ advanceTimers: true });
try {
const onChange = jest.fn();
render(
<Select
{...defaultProps}
options={STABLE_OPTIONS}
mode="multiple"
stableSelectAll
onChange={onChange}
/>,
);
const select = getSelect();
userEvent.click(select);
await screen.findByText(selectAllButtonText(STABLE_OPTIONS.length));
await userEvent.type(select, 'Ap');
act(() => {
jest.advanceTimersByTime(Constants.FAST_DEBOUNCE + 50);
});
await waitFor(() => expect(getAllSelectOptions().length).toBe(2));
await userEvent.click(
screen.getByText(selectAllButtonText(STABLE_OPTIONS.length)),
);
await waitFor(() => expect(onChange).toHaveBeenCalled());
expect(onChange.mock.calls.at(-1)?.[0]).toHaveLength(STABLE_OPTIONS.length);
} finally {
jest.useRealTimers();
}
});
test('stableSelectAll excludes disabled options from the full-set count while searching', async () => {
jest.useFakeTimers({ advanceTimers: true });
try {
// Cherry is disabled → 5 of the 6 options are selectable.
const options = STABLE_OPTIONS.map(option =>
option.label === 'Cherry' ? { ...option, disabled: true } : option,
);
render(
<Select
{...defaultProps}
options={options}
mode="multiple"
stableSelectAll
/>,
);
const select = getSelect();
userEvent.click(select);
expect(await screen.findByText(selectAllButtonText(5))).toBeInTheDocument();
await userEvent.type(select, 'Ap');
act(() => {
jest.advanceTimersByTime(Constants.FAST_DEBOUNCE + 50);
});
await waitFor(() => expect(getAllSelectOptions().length).toBe(2));
// The count reflects the full selectable set (5), not the 2 visible.
expect(screen.getByText(selectAllButtonText(5))).toBeInTheDocument();
expect(screen.queryByText(selectAllButtonText(2))).not.toBeInTheDocument();
} finally {
jest.useRealTimers();
}
});
test('stableSelectAll deduplicates already-selected values when selecting the full set', async () => {
jest.useFakeTimers({ advanceTimers: true });
try {
const onChange = jest.fn();
// Apple (1) is already selected; the "Ap" search narrows the visible list
// to a subset while "Select all" still targets the whole set.
render(
<Select
{...defaultProps}
options={STABLE_OPTIONS}
mode="multiple"
stableSelectAll
value={[{ label: 'Apple', value: 1 }]}
onChange={onChange}
/>,
);
const select = getSelect();
userEvent.click(select);
await screen.findByText(selectAllButtonText(STABLE_OPTIONS.length));
await userEvent.type(select, 'Ap');
act(() => {
jest.advanceTimersByTime(Constants.FAST_DEBOUNCE + 50);
});
await waitFor(() => expect(getAllSelectOptions().length).toBe(2));
await userEvent.click(
screen.getByText(selectAllButtonText(STABLE_OPTIONS.length)),
);
await waitFor(() => expect(onChange).toHaveBeenCalled());
// Full set is 6; the already-selected Apple is not duplicated (a failed
// dedup would push it again and yield 7).
expect(onChange.mock.calls.at(-1)?.[0]).toHaveLength(STABLE_OPTIONS.length);
} finally {
jest.useRealTimers();
}
});
test('stableSelectAll keeps "Clear" counting and clearing the full selection while searching', async () => {
jest.useFakeTimers({ advanceTimers: true });
try {
const onChange = jest.fn();
// Pre-select Banana (3) and Cherry (5); neither matches the "Ap" search.
render(
<Select
{...defaultProps}
options={STABLE_OPTIONS}
mode="multiple"
stableSelectAll
value={[
{ label: 'Banana', value: 3 },
{ label: 'Cherry', value: 5 },
]}
onChange={onChange}
/>,
);
const select = getSelect();
userEvent.click(select);
expect(
await screen.findByText(deselectAllButtonText(2)),
).toBeInTheDocument();
await userEvent.type(select, 'Ap');
act(() => {
jest.advanceTimersByTime(Constants.FAST_DEBOUNCE + 50);
});
await waitFor(() => expect(getAllSelectOptions().length).toBe(2));
// "Clear" still reflects the full selection (2), not the visible subset (0).
expect(screen.getByText(deselectAllButtonText(2))).toBeInTheDocument();
expect(
screen.queryByText(deselectAllButtonText(0)),
).not.toBeInTheDocument();
// Clicking it removes the whole selection even though those values are not
// in the search results.
await userEvent.click(screen.getByText(deselectAllButtonText(2)));
await waitFor(() => expect(onChange).toHaveBeenCalled());
expect(onChange.mock.calls.at(-1)?.[0]).toHaveLength(0);
} finally {
jest.useRealTimers();
}
});
test('stableSelectAll "Clear" count matches the action for a selected <NULL> value while searching', async () => {
jest.useFakeTimers({ advanceTimers: true });
try {
const onChange = jest.fn();
// The <NULL> option carries a falsy value, which "Select all" skips but
// "Clear" (like the un-gated path) still removes. Pre-select <NULL> and
// Banana (3); "Ap" hides both. The Clear count must equal what Clear
// removes — otherwise the label overstates the action.
render(
<Select
{...defaultProps}
options={[...STABLE_OPTIONS, NULL_OPTION]}
mode="multiple"
stableSelectAll
value={[NULL_OPTION, { label: 'Banana', value: 3 }]}
onChange={onChange}
/>,
);
const select = getSelect();
userEvent.click(select);
expect(
await screen.findByText(deselectAllButtonText(2)),
).toBeInTheDocument();
await userEvent.type(select, 'Ap');
act(() => {
jest.advanceTimersByTime(Constants.FAST_DEBOUNCE + 50);
});
await waitFor(() => expect(getAllSelectOptions().length).toBe(2));
// Count still reflects the full selection (2)...
expect(screen.getByText(deselectAllButtonText(2))).toBeInTheDocument();
// ...and clicking removes all of it, including the <NULL> selection.
await userEvent.click(screen.getByText(deselectAllButtonText(2)));
await waitFor(() => expect(onChange).toHaveBeenCalled());
expect(onChange.mock.calls.at(-1)?.[0]).toHaveLength(0);
} finally {
jest.useRealTimers();
}
});
test('stableSelectAll does not read a normal selection as the "Select all" sentinel while searching', async () => {
jest.useFakeTimers({ advanceTimers: true });
try {
// Pre-select four values, then search so exactly three eligible options are
// visible: selectValue.length (4) === selectAllEligible.length (3) + 1 — the
// coincidence that used to flip selectAllMode true. stableSelectAll adds real
// values with no phantom "Select all" slot, so the collapsed-tag count must
// not subtract one for a sentinel that does not exist.
render(
<Select
{...defaultProps}
options={STABLE_OPTIONS}
mode="multiple"
stableSelectAll
maxTagCount={2}
value={[
{ label: 'Apple', value: 1 },
{ label: 'Apricot', value: 2 },
{ label: 'Banana', value: 3 },
{ label: 'Blueberry', value: 4 },
]}
/>,
);
const select = getSelect();
userEvent.click(select);
await userEvent.type(select, 'erry');
act(() => {
jest.advanceTimersByTime(Constants.FAST_DEBOUNCE + 50);
});
// Blueberry, Cherry, Cranberry match "erry".
await waitFor(() => expect(getAllSelectOptions().length).toBe(3));
// Four selected, two shown → two hidden. The overflow badge must report
// "+ 2 ...", not the sentinel-undercounted "+ 1 ...".
expect(screen.getByText('+ 2 ...')).toBeInTheDocument();
expect(screen.queryByText('+ 1 ...')).not.toBeInTheDocument();
} finally {
jest.useRealTimers();
}
});
test('stableSelectAll counts and selects grouped options by their leaf values', async () => {
const onChange = jest.fn();
render(
<Select
{...defaultProps}
options={GROUPED_STABLE_OPTIONS}
mode="multiple"
stableSelectAll
onChange={onChange}
/>,
);
const select = getSelect();
userEvent.click(select);
// Five leaf options across two groups. The group headers carry no value, so
// without flattening the full set the count collapses to zero and the bulk
// actions disappear entirely.
const selectAll = await screen.findByText(selectAllButtonText(5));
expect(selectAll).toBeInTheDocument();
await userEvent.click(selectAll);
await waitFor(() => expect(onChange).toHaveBeenCalled());
const [selectedValues, selectedOptions] = onChange.mock.calls.at(-1) ?? [];
const valueOf = (item: number | { value: number }) =>
item && typeof item === 'object' ? item.value : item;
const sortNumeric = (values: number[]) => [...values].sort((a, b) => a - b);
// The five leaf values are selected — never the value-less group headers.
expect(sortNumeric(selectedValues.map(valueOf))).toEqual([1, 2, 3, 4, 5]);
// The option metadata alongside the values is also the flattened leaf set,
// not an empty array left over from filtering the group nodes.
expect(sortNumeric(selectedOptions.map(valueOf))).toEqual([1, 2, 3, 4, 5]);
});
test('dropdown takes full width of the select input for multi select', async () => {
render(
<div style={{ width: '400px' }}>
@@ -78,23 +78,6 @@ import {
import { Space } from '../Space';
import { Button } from '../Button';
// An option is eligible for a bulk "Select all" when it carries a truthy value
// and is neither disabled nor the transient "create new option" entry. Shared
// by the count, the visibility gate, and the click handler so they cannot drift.
const isBulkSelectable = (option: SelectOptionsType[number]): boolean =>
Boolean(option.value) && !option.disabled && !option.isNewOption;
// Grouped option lists nest their selectable entries under `.options`; the group
// headers themselves carry no `value`. Flatten one level so the bulk "Select
// all" machinery targets the actual leaf options rather than the headers. A flat
// list passes through unchanged.
const flattenGroupedOptions = (options: SelectOptionsType): SelectOptionsType =>
options.flatMap(option =>
'options' in option && Array.isArray(option.options)
? (option.options as SelectOptionsType)
: option,
);
/**
* This component is a customized version of the Antdesign 4.X Select component
* https://ant.design/components/select/.
@@ -115,7 +98,6 @@ const Select = forwardRef(
allowNewOptions = false,
allowNewOptionsOnPaste = false,
allowSelectAll = true,
stableSelectAll = false,
ariaLabel,
autoClearSearchValue = true,
filterOption = true,
@@ -311,27 +293,6 @@ const Select = forwardRef(
[visibleOptions],
);
// The full (search-independent) option set flattened to its leaf options, so
// the stableSelectAll bulk machinery treats grouped columns the same as flat
// ones. Gated on stableSelectAll so consumers that don't use the feature skip
// the work; a flat list is returned unchanged either way.
const flatFullSelectOptions = useMemo(
() =>
stableSelectAll
? flattenGroupedOptions(fullSelectOptions)
: EMPTY_OPTIONS,
[fullSelectOptions, stableSelectAll],
);
// The stable, full-set counterpart of enabledOptions: every bulk-selectable
// option across the entire (search-independent) option set. Used when
// stableSelectAll is on so the "Select all" action and visibility stay
// pinned to the full column while a search narrows visibleOptions.
const fullSelectAllOptions = useMemo(
() => flatFullSelectOptions.filter(isBulkSelectable),
[flatFullSelectOptions],
);
const selectAllEligible = useMemo(
() =>
visibleOptions.filter(
@@ -347,62 +308,49 @@ const Select = forwardRef(
!isSingleMode &&
allowSelectAll &&
selectOptions.length > 0 &&
// When stableSelectAll is on, gate visibility on the full eligible set
// so the bulk control does not hide/flicker while a search narrows
// visibleOptions.
(stableSelectAll
? fullSelectAllOptions.length
: enabledOptions.length) > 1,
enabledOptions.length > 1,
[
isSingleMode,
allowSelectAll,
selectOptions.length,
enabledOptions.length,
stableSelectAll,
fullSelectAllOptions.length,
],
);
const selectAllMode = useMemo(
() =>
// stableSelectAll adds real values across the full column with no legacy
// "Select all" sentinel occupying a slot in selectValue, so the
// eligible+1 phantom detection does not apply. Force it off so a search
// that narrows selectAllEligible cannot make a normal selection read as
// the sentinel (which drives the collapsed-tag off-by-one).
!stableSelectAll &&
ensureIsArray(selectValue).length === selectAllEligible.length + 1,
[selectValue, selectAllEligible, stableSelectAll],
() => ensureIsArray(selectValue).length === selectAllEligible.length + 1,
[selectValue, selectAllEligible],
);
const bulkSelectCounts = useMemo(() => {
const selectedValuesSet = new Set(
ensureIsArray(selectValue).map(getValue),
);
// When stableSelectAll is on, both counts reduce over the full
// (search-independent) loaded option set — flattened so grouped columns
// count their leaf options — so they stay stable and consistent with each
// other while searching. When it is off, countSource === visibleOptions,
// so the counts are unchanged for generic consumers.
const countSource = stableSelectAll
? flatFullSelectOptions
: visibleOptions;
const selectable = countSource.reduce((acc, option) => {
// "Select all" only adds, so the post-click count is every eligible
// option plus any already-selected option that will remain selected
// (a selected disabled/new option is not toggled off). Falsy-valued
// options (e.g. the <NULL> option) are never bulk-selectable.
const willBeSelected =
isBulkSelectable(option) ||
(Boolean(option.value) && selectedValuesSet.has(option.value));
return willBeSelected ? acc + 1 : acc;
}, 0);
const deselectable = countSource.reduce((acc, option) => {
const isSelected = selectedValuesSet.has(option.value);
return isSelected && !option.disabled ? acc + 1 : acc;
}, 0);
return { selectable, deselectable };
}, [visibleOptions, selectValue, flatFullSelectOptions, stableSelectAll]);
return visibleOptions.reduce(
(acc, option) => {
const isSelected = selectedValuesSet.has(option.value);
const isDisabled = option.disabled;
const isNew = option.isNewOption;
// Mirror handleSelectAll, which skips falsy-valued options (e.g. the
// <NULL> option whose value is null): they are not bulk-selectable,
// so counting them here makes the "Select all" badge overstate what
// gets selected.
if (
option.value &&
(!isDisabled || isSelected) &&
((isNew && isSelected) || !isNew)
) {
acc.selectable += 1;
}
if (isSelected && !isDisabled) {
acc.deselectable += 1;
}
return acc;
},
{ selectable: 0, deselectable: 0 },
);
}, [visibleOptions, selectValue]);
const handleOnSelect: SelectProps['onSelect'] = (selectedItem, option) => {
if (isSingleMode) {
@@ -646,15 +594,9 @@ const Select = forwardRef(
const handleSelectAll = useCallback(() => {
if (isSingleMode) return;
const searchScopedOptions = isSearching
const optionsToSelect = isSearching
? visibleOptions.filter(option => !option.isNewOption)
: enabledOptions;
// When stableSelectAll is on, always select the full eligible set
// regardless of any active search, so "Select all" targets the whole
// column rather than the search-filtered subset.
const optionsToSelect = stableSelectAll
? fullSelectAllOptions
: searchScopedOptions;
const currentValues = ensureIsArray(selectValue);
const currentValuesSet = new Set(currentValues.map(getValue));
@@ -677,8 +619,6 @@ const Select = forwardRef(
isSearching,
visibleOptions,
enabledOptions,
stableSelectAll,
fullSelectAllOptions,
selectValue,
fireOnChange,
]);
@@ -686,18 +626,7 @@ const Select = forwardRef(
const handleDeselectAll = useCallback(() => {
if (isSingleMode) return;
// In stableSelectAll mode "Clear" removes the whole non-disabled
// selection across the full loaded set — flattened so grouped columns
// clear their leaf options, the full-set parallel of enabledOptions — so
// it matches the `deselectable` count (which counts every selected
// non-disabled option). Otherwise it clears only the search-scoped visible
// options.
const deselectionSource = stableSelectAll
? flatFullSelectOptions.filter(option => !option.disabled)
: enabledOptions;
const deselectionValues = new Set(
deselectionSource.map(opt => opt.value),
);
const deselectionValues = new Set(enabledOptions.map(opt => opt.value));
const newValues = ensureIsArray(selectValue).filter(item => {
const itemValue = getValue(item);
@@ -706,14 +635,7 @@ const Select = forwardRef(
setSelectValue(newValues);
fireOnChange();
}, [
isSingleMode,
enabledOptions,
stableSelectAll,
flatFullSelectOptions,
selectValue,
fireOnChange,
]);
}, [isSingleMode, enabledOptions, selectValue, fireOnChange]);
const bulkSelectComponent = useMemo(
() => (
@@ -832,16 +754,8 @@ const Select = forwardRef(
if (onChangeCount !== previousChangeCount) {
const array = ensureIsArray(selectValue);
const set = new Set(array.map(getValue));
// Flatten grouped columns so the option metadata resolves to the leaf
// options rather than the value-less group headers (which would leave
// this array empty). A flat list is returned unchanged.
// Flatten grouped columns so the option metadata resolves to the leaf
// options rather than the value-less group headers (which would leave
// this array empty). A flat list is returned unchanged.
const options = mapOptions(
flattenGroupedOptions(fullSelectOptions).filter(opt =>
set.has(opt.value),
),
fullSelectOptions.filter(opt => set.has(opt.value)),
);
if (isSingleMode) {
handleOnChange(selectValue, selectValue ? options[0] : undefined);
@@ -205,21 +205,6 @@ export interface SelectProps extends BaseSelectProps {
* True by default.
* */
allowSelectAll?: boolean;
/**
* When true, the bulk "Select all" / "Clear" controls operate on the full
* loaded option set instead of the search-filtered subset, so their counts
* stay stable and the controls stay visible while searching. Clicking
* "Select all" selects every selectable option in the loaded set regardless
* of the active search, and "Clear" removes the corresponding selections;
* options with a falsy value (e.g. `0`, `''`, `false`, `<NULL>`), disabled
* options, and the transient "create new option" entry are not selectable.
* "Full set" here means the currently loaded options, which for async or
* row-limited selects may be fewer than the column's full cardinality.
* Intended for the native Value filter, whose "Select all" targets the whole
* column.
* Default false.
* */
stableSelectAll?: boolean;
/**
* It defines the options of the Select.
* The options can be static, an array of options.
@@ -19,8 +19,6 @@
export { default as NumberFormats } from './NumberFormats';
export { default as NumberFormatter, PREVIEW_VALUE } from './NumberFormatter';
export { formatSpecifier } from 'd3-format';
export type { FormatLocaleDefinition } from 'd3-format';
export { DEFAULT_D3_FORMAT } from './D3FormatConfig';
export {
@@ -47,8 +47,6 @@ export default function extractQueryFields(
metric: 'metrics',
metric_2: 'metrics',
secondary_metric: 'metrics',
left_metric: 'metrics',
right_metric: 'metrics',
x: 'metrics',
y: 'metrics',
size: 'metrics',
@@ -59,16 +59,6 @@ describe('extractQueryFields', () => {
).toEqual(['metric_1', 'metric_2', 'my_custom_metric']);
});
test('should extract butterfly chart metrics', () => {
expect(
extractQueryFields({
groupby: ['category'],
left_metric: 'left_sum',
right_metric: 'right_sum',
}).metrics,
).toEqual(['left_sum', 'right_sum']);
});
test('should extract columns', () => {
expect(extractQueryFields({ columns: 'col_1' })).toEqual({
columns: ['col_1'],
@@ -247,9 +247,6 @@ const config: ControlPanelConfig = {
config: {
...sharedControls.time_grain_sqla,
visibility: ({ controls }) => {
if (!isAggMode({ controls })) {
return false;
}
const dttmLookup = Object.fromEntries(
ensureIsArray(controls?.groupby?.options).map(option => [
(option.column_name || '').toLowerCase(),
@@ -17,58 +17,18 @@
* under the License.
*/
import { GenericDataType } from '@apache-superset/core/common';
import { QueryFormData, QueryMode } from '@superset-ui/core';
import { QueryFormData } from '@superset-ui/core';
import {
ColumnMeta,
Dataset,
isCustomControlItem,
ControlConfig,
ControlPanelsContainerProps,
ControlPanelState,
ControlState,
ColorSchemeEnum,
} from '@superset-ui/chart-controls';
import config from '../src/controlPanel';
type VisibilityFn = (
props: ControlPanelsContainerProps,
control?: ControlState,
) => boolean;
const findTimeGrainSqlaVisibility = (): VisibilityFn | null => {
for (const section of config.controlPanelSections) {
if (!section) continue;
for (const row of section.controlSetRows) {
for (const control of row) {
if (
isCustomControlItem(control) &&
control.name === 'time_grain_sqla' &&
typeof control.config.visibility === 'function'
) {
return control.config.visibility as VisibilityFn;
}
}
}
}
return null;
};
function mkTimeGrainProps(
groupbyValue: string[],
options = [
{ column_name: 'ORDERDATE', is_dttm: true },
{ column_name: 'some_other_col', is_dttm: false },
],
queryMode?: QueryMode,
): ControlPanelsContainerProps {
return {
controls: {
groupby: { value: groupbyValue, options },
...(queryMode ? { query_mode: { value: queryMode } } : {}),
},
} as unknown as ControlPanelsContainerProps;
}
const findConditionalFormattingControl = (): ControlConfig | null => {
for (const section of config.controlPanelSections) {
if (!section) continue;
@@ -313,39 +273,3 @@ test('metrics control includes non-filterable columns', () => {
]),
);
});
test('time_grain_sqla visibility is false in raw records mode even with a stale temporal groupby value', () => {
const vis = findTimeGrainSqlaVisibility();
expect(vis).toBeTruthy();
const controlState = {} as ControlState;
// Simulates switching from an aggregated chart (groupby set to a temporal
// column) to this plugin's Raw Records mode: groupby's value survives the
// switch (its own visibility uses resetOnHide: false), but the query is no
// longer aggregated, so time_grain_sqla must not stay visible/applied.
expect(
vis!(
mkTimeGrainProps(['orderdate'], undefined, QueryMode.Raw),
controlState,
),
).toBe(false);
});
test('time_grain_sqla visibility still requires aggregate mode plus a temporal groupby column', () => {
const vis = findTimeGrainSqlaVisibility();
expect(vis).toBeTruthy();
const controlState = {} as ControlState;
expect(
vis!(
mkTimeGrainProps(['orderdate'], undefined, QueryMode.Aggregate),
controlState,
),
).toBe(true);
expect(
vis!(
mkTimeGrainProps(['some_other_col'], undefined, QueryMode.Aggregate),
controlState,
),
).toBe(false);
});
@@ -16,44 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
import { allEventHandlers, type Event } from '../utils/eventHandlers';
import Echart from '../components/Echart';
import { EventHandlers } from '../types';
import { ButterflyTransformedProps } from './types';
type ButterflyChartEvent = {
name?: string;
data?: { name?: string };
event?: Event['event'];
};
function getCategoryKey(params: ButterflyChartEvent): string {
return params.data?.name ?? params.name ?? '';
}
import { EventHandlers } from '../types';
export default function Butterfly(props: ButterflyTransformedProps) {
const {
height,
width,
echartOptions,
selectedValues,
refs,
onLegendStateChanged,
formData,
} = props;
const { click, contextmenu } = allEventHandlers(props);
const { height, width, echartOptions, refs, onLegendStateChanged, formData } =
props;
const eventHandlers: EventHandlers = {
click: (params: ButterflyChartEvent) => {
click({ name: getCategoryKey(params) });
},
contextmenu: (params: ButterflyChartEvent) => {
contextmenu({
...params,
name: getCategoryKey(params),
});
},
legendselectchanged: payload => {
onLegendStateChanged?.(payload.selected);
},
@@ -72,7 +43,6 @@ export default function Butterfly(props: ButterflyTransformedProps) {
width={width}
echartOptions={echartOptions}
eventHandlers={eventHandlers}
selectedValues={selectedValues}
vizType={formData.vizType}
/>
);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

@@ -17,15 +17,11 @@
* under the License.
*/
import { t } from '@apache-superset/core/translation';
import { Behavior, ChartMetadata, ChartPlugin } from '@superset-ui/core';
import { ChartMetadata, ChartPlugin } from '@superset-ui/core';
import buildQuery from './buildQuery';
import controlPanel from './controlPanel';
import transformProps from './transformProps';
import { EchartsButterflyChartProps, EchartsButterflyFormData } from './types';
import example from './images/example.png';
import exampleDark from './images/example-dark.png';
import thumbnail from './images/thumbnail.png';
import thumbnailDark from './images/thumbnail-dark.png';
export default class EchartsButterflyChartPlugin extends ChartPlugin<
EchartsButterflyFormData,
@@ -37,18 +33,12 @@ export default class EchartsButterflyChartPlugin extends ChartPlugin<
controlPanel,
loadChart: () => import('./Butterfly'),
metadata: new ChartMetadata({
behaviors: [
Behavior.InteractiveChart,
Behavior.DrillToDetail,
Behavior.DrillBy,
],
credits: ['https://echarts.apache.org'],
category: t('Comparison'),
description: t(
'A butterfly chart compares two metrics across categories using horizontal bars ' +
'that extend left and right from a central axis.',
),
exampleGallery: [{ url: example, urlDark: exampleDark }],
name: t('Butterfly Chart'),
tags: [
t('Categorical'),
@@ -56,8 +46,7 @@ export default class EchartsButterflyChartPlugin extends ChartPlugin<
t('ECharts'),
t('Multi-Variables'),
],
thumbnail,
thumbnailDark,
thumbnail: '',
}),
transformProps,
});
@@ -34,13 +34,8 @@ import { DEFAULT_FORM_DATA } from './constants';
import { defaultGrid } from '../defaults';
import { getDefaultTooltip } from '../utils/tooltip';
import { Refs } from '../types';
import { OpacityEnum } from '../constants';
import {
getChartPadding,
getLegendProps,
getColtypesMapping,
extractGroupbyLabel,
} from '../utils/series';
import { NULL_STRING } from '../constants';
import { getChartPadding, getLegendProps } from '../utils/series';
import { resolveLegendLayout } from '../utils/legendLayout';
import { convertInteger } from '../utils/convertInteger';
@@ -49,11 +44,19 @@ type EChartsOption = ComposeOption<BarSeriesOption>;
const LABEL_LEFT = { position: 'left' as const };
const LABEL_RIGHT = { position: 'right' as const };
function formatCategory(value: unknown): string {
if (value == null) {
return NULL_STRING;
}
if (typeof value === 'string' || typeof value === 'number') {
return String(value);
}
return String(value);
}
function formatTooltip(
params: CallbackDataParams[],
formatter: NumberFormatter | CurrencyFormatter,
categoryLabels: string[],
categoryByKey: Map<string, string>,
) {
const axisParams = params.filter(
param => param.seriesName && typeof param.value === 'number',
@@ -62,13 +65,7 @@ function formatTooltip(
return '';
}
const { dataIndex, name } = axisParams[0];
const title =
(typeof dataIndex === 'number'
? categoryLabels.at(dataIndex)
: undefined) ??
(typeof name === 'string' ? categoryByKey.get(name) : undefined) ??
name;
const title = axisParams[0].name;
const rows = axisParams.map(param => [
param.seriesName!,
formatter(Math.abs(param.value as number)),
@@ -89,8 +86,6 @@ export default function transformProps(
hooks,
theme,
inContextMenu,
filterState,
emitCrossFilters,
} = chartProps;
const refs: Refs = {};
const { data = [] } = queriesData[0];
@@ -122,75 +117,32 @@ export default function transformProps(
...formData,
};
const groupbyColumn = ensureIsArray(groupby)[0];
const categoryLabel = getColumnLabel(groupbyColumn);
const leftMetricLabel = leftMetric ? getMetricLabel(leftMetric) : '';
const rightMetricLabel = rightMetric ? getMetricLabel(rightMetric) : '';
const leftSeriesName = leftLabel || leftMetricLabel;
const rightSeriesName = rightLabel || rightMetricLabel;
const coltypeMapping = getColtypesMapping(queriesData[0]);
const groupbyColumns = ensureIsArray(groupby);
const groupbyLabels = groupbyColumns.map(getColumnLabel);
const defaultFormatter = currencyFormat?.symbol
? new CurrencyFormatter({ d3Format: xAxisFormat, currency: currencyFormat })
: getNumberFormatter(xAxisFormat);
const categories = data.map(datum =>
extractGroupbyLabel({ datum, groupby: groupbyLabels, coltypeMapping }),
);
const categoryKeys = data.map((datum, index) => {
const label = categories.at(index) ?? '';
return `${label}__${JSON.stringify(
groupbyLabels.map(col =>
Object.hasOwn(datum, col) ? datum[col] : undefined,
),
)}`;
const categories = data.map(row => formatCategory(row[categoryLabel]));
const leftData = data.map(row => {
const value = Number(row[leftMetricLabel] ?? 0);
return {
value: -Math.abs(value),
label: LABEL_LEFT,
};
});
const rightData = data.map(row => {
const value = Number(row[rightMetricLabel] ?? 0);
return {
value: Math.abs(value),
label: LABEL_RIGHT,
};
});
const categoryByKey = new Map(
categoryKeys.flatMap((key, index) => {
const label = categories.at(index);
return label === undefined ? [] : [[key, label] as const];
}),
);
const labelMap = data.reduce<Record<string, string[]>>(
(acc, datum, index) => {
const uniqueKey = categoryKeys.at(index);
if (uniqueKey === undefined) {
return acc;
}
acc[uniqueKey] = groupbyLabels.map(col =>
Object.hasOwn(datum, col) ? (datum[col] as string) : '',
);
return acc;
},
{},
);
const selectedValues = (filterState.selectedValues || []).reduce(
(acc: Record<number, string>, value: string) => {
const index = categoryKeys.indexOf(value);
return index >= 0 ? { ...acc, [index]: value } : acc;
},
{},
);
const getOpacity = (categoryKey: string) =>
filterState.selectedValues?.length &&
!filterState.selectedValues.includes(categoryKey)
? OpacityEnum.SemiTransparent
: OpacityEnum.NonTransparent;
const leftData = data.map((row, i) => ({
name: categoryKeys[i],
value: -Math.abs(Number(row[leftMetricLabel] ?? 0)),
label: LABEL_LEFT,
itemStyle: { opacity: getOpacity(categoryKeys[i]) },
}));
const rightData = data.map((row, i) => ({
name: categoryKeys[i],
value: Math.abs(Number(row[rightMetricLabel] ?? 0)),
label: LABEL_RIGHT,
itemStyle: { opacity: getOpacity(categoryKeys[i]) },
}));
const labelFormatter = (params: CallbackDataParams) => {
const value = Math.abs(params.value as number);
@@ -328,8 +280,6 @@ export default function transformProps(
formatTooltip(
ensureIsArray(params) as CallbackDataParams[],
defaultFormatter,
categories,
categoryByKey,
),
},
series,
@@ -344,10 +294,5 @@ export default function transformProps(
setDataMask,
onContextMenu,
onLegendStateChanged,
groupby: groupbyColumns,
labelMap,
selectedValues,
emitCrossFilters,
coltypeMapping,
};
}
@@ -24,12 +24,7 @@ import {
QueryFormMetric,
RgbaColor,
} from '@superset-ui/core';
import {
BaseTransformedProps,
LegendFormData,
TitleFormData,
CrossFilterTransformedProps,
} from '../types';
import { BaseTransformedProps, LegendFormData, TitleFormData } from '../types';
export type EchartsButterflyFormData = QueryFormData &
LegendFormData &
@@ -54,4 +49,4 @@ export interface EchartsButterflyChartProps extends ChartProps {
}
export type ButterflyTransformedProps =
BaseTransformedProps<EchartsButterflyFormData> & CrossFilterTransformedProps;
BaseTransformedProps<EchartsButterflyFormData>;
@@ -59,7 +59,6 @@ import {
LegendOrientation,
Refs,
} from '../types';
import { BarValueLabelPosition } from '../Timeseries/types';
import { parseAxisBound } from '../utils/controls';
import { safeParseEChartOptions } from '../utils/safeEChartOptionsParser';
import {
@@ -74,8 +73,6 @@ import {
getLegendProps,
getMinAndMaxFromBounds,
getOverMaxHiddenFormatter,
getTemporalAxisTickConfig,
resolveTemporalTickValues,
} from '../utils/series';
import { resolveLegendLayout } from '../utils/legendLayout';
import {
@@ -103,9 +100,7 @@ import {
import { TIMEGRAIN_TO_TIMESTAMP, TIMESERIES_CONSTANTS } from '../constants';
import { getDefaultTooltip } from '../utils/tooltip';
import {
createSpacedXAxisFormatter,
getTooltipTimeFormatter,
getXAxisDomain,
getXAxisFormatter,
getYAxisFormatter,
} from '../utils/formatters';
@@ -522,7 +517,6 @@ export default function transformProps(
areaOpacity: opacity,
seriesType,
showValue,
valueLabelPosition: BarValueLabelPosition.OutsideEnd,
onlyTotal,
stack: Boolean(stack),
stackIdSuffix: '\na',
@@ -612,7 +606,6 @@ export default function transformProps(
areaOpacity: opacityB,
seriesType: seriesTypeB,
showValue: showValueB,
valueLabelPosition: BarValueLabelPosition.OutsideEnd,
onlyTotal: onlyTotalB,
stack: Boolean(stackB),
stackIdSuffix: '\nb',
@@ -666,26 +659,44 @@ export default function transformProps(
? getXAxisFormatter(xAxisTimeFormat, resolvedTimeGrain)
: String;
// hideOverlap must stay off so the forced boundary label from showMaxLabel
// is never suppressed (#39899). The formatter itself dedupes consecutive
// identical labels and thins out labels that would otherwise visually
// collide, since hideOverlap can no longer do that for us.
const showMaxLabel =
xAxisType === AxisType.Time &&
xAxisLabelRotation === 0 &&
!!resolvedTimeGrain;
const deduplicatedFormatter = showMaxLabel
? createSpacedXAxisFormatter(
xAxisFormatter,
...getXAxisDomain(
[
rebasedDataA as Record<string, unknown>[],
rebasedDataB as Record<string, unknown>[],
],
xAxisLabel,
),
Math.max(width - 2 * TIMESERIES_CONSTANTS.gridOffsetLeft, 0),
)
? (() => {
let lastLabel: string | undefined;
let lastValue: number | undefined;
const wrapper = (value: number | string) => {
// ECharts formats the labels in repeated ascending passes. Reset the
// dedup state when the sequence restarts so a forced boundary label
// (e.g. the min date) isn't blanked by the previous pass's last label
// when both format identically (e.g. a May-to-May range).
if (
typeof value === 'number' &&
lastValue !== undefined &&
value <= lastValue
) {
lastLabel = undefined;
}
if (typeof value === 'number') {
lastValue = value;
}
const label =
typeof xAxisFormatter === 'function'
? (xAxisFormatter as Function)(value)
: String(value);
if (label === lastLabel) {
return '';
}
lastLabel = label;
return label;
};
if (typeof xAxisFormatter === 'function' && 'id' in xAxisFormatter) {
(wrapper as any).id = (xAxisFormatter as any).id;
}
return wrapper;
})()
: xAxisFormatter;
const yAxisTitleMarginPx = convertInteger(yAxisTitleMargin);
@@ -753,26 +764,6 @@ export default function transformProps(
const { setDataMask = () => {}, onContextMenu } = hooks;
const alignTicks = yAxisIndex !== yAxisIndexB;
// Both queries share the axis, so a bucket contributed by either needs a tick.
const temporalTickValues = resolveTemporalTickValues(
[...rebasedDataA, ...rebasedDataB],
xAxisLabel,
xAxisType,
resolvedTimeGrain,
annotationLayers,
);
const temporalAxisTickConfig = getTemporalAxisTickConfig(
temporalTickValues,
showMaxLabel,
xAxisType,
xAxisLabelRotation,
xAxisLabelInterval,
deduplicatedFormatter,
false,
zoomable,
);
const echartOptions: EChartsCoreOption = {
useUTC: true,
grid: {
@@ -784,12 +775,22 @@ export default function transformProps(
name: xAxisTitle,
nameGap: xAxisTitleMarginPx,
nameLocation: 'middle',
...temporalAxisTickConfig,
minorTick: { show: minorTicks },
axisTick: {
...temporalAxisTickConfig.axisTick,
show: axisTicks ? 'auto' : false,
axisLabel: {
hideOverlap: showMaxLabel
? false
: !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0),
formatter: deduplicatedFormatter,
rotate: xAxisLabelRotation,
interval: xAxisLabelInterval,
...(showMaxLabel && {
showMaxLabel: true,
alignMaxLabel: 'right',
showMinLabel: true,
alignMinLabel: 'left',
}),
},
minorTick: { show: minorTicks },
axisTick: { show: axisTicks ? 'auto' : false },
...(gridlines ? {} : { splitLine: { show: false } }),
minInterval:
xAxisType === AxisType.Time && resolvedTimeGrain && !forceMaxInterval
@@ -40,7 +40,6 @@ import {
} from '../types';
import EchartsTimeseries from './EchartsTimeseries';
import {
BarValueLabelPosition,
EchartsTimeseriesSeriesType,
OrientationType,
type EchartsTimeseriesFormData,
@@ -160,7 +159,6 @@ const defaultFormData: EchartsTimeseriesFormData & {
xAxisLabelRotation: 0,
xAxisLabelInterval: 0,
showValue: false,
valueLabelPosition: BarValueLabelPosition.Auto,
onlyTotal: false,
showExtraControls: true,
percentageThreshold: 0,
@@ -23,7 +23,6 @@ import {
import { t } from '@apache-superset/core/translation';
import { LegendOrientation, LegendType } from '../types';
import {
BarValueLabelPosition,
OrientationType,
EchartsTimeseriesSeriesType,
EchartsTimeseriesFormData,
@@ -89,10 +88,6 @@ export const DEFAULT_FORM_DATA: EchartsTimeseriesFormData = {
xAxisLabelInterval: defaultXAxis.xAxisLabelInterval,
groupby: [],
showValue: false,
// Legacy charts saved before this field existed have no valueLabelPosition
// in form_data and must keep their pre-existing Outside End placement;
// Auto is opt-in via the Value label position control, not the default.
valueLabelPosition: BarValueLabelPosition.OutsideEnd,
labelPosition: 'auto',
onlyTotal: false,
percentageThreshold: 0,
@@ -47,7 +47,6 @@ import {
NumberFormats,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { isThemeDark } from '@apache-superset/core/theme';
import {
extractExtraMetrics,
getOriginalSeries,
@@ -64,7 +63,6 @@ import {
EchartsTimeseriesChartProps,
EchartsTimeseriesFormData,
EchartsTimeseriesSeriesType,
BarValueLabelPosition,
OrientationType,
TimeseriesChartTransformedProps,
} from './types';
@@ -90,8 +88,6 @@ import {
getHorizontalLegendAvailableWidth,
getLegendProps,
getMinAndMaxFromBounds,
getTemporalAxisTickConfig,
resolveTemporalTickValues,
} from '../utils/series';
import { resolveLegendLayout } from '../utils/legendLayout';
import {
@@ -126,11 +122,8 @@ import {
} from '../constants';
import { getDefaultTooltip } from '../utils/tooltip';
import {
createDedupXAxisFormatter,
createSpacedXAxisFormatter,
getPercentFormatter,
getTooltipTimeFormatter,
getXAxisDomain,
getXAxisFormatter,
getYAxisFormatter,
} from '../utils/formatters';
@@ -300,7 +293,6 @@ export default function transformProps(
seriesType,
showLegend,
showValue,
valueLabelPosition,
size,
labelPosition,
colorByPrimaryAxis,
@@ -339,8 +331,6 @@ export default function transformProps(
zoomable,
stackDimension,
}: EchartsTimeseriesFormData = { ...DEFAULT_FORM_DATA, ...formData };
const resolvedValueLabelPosition =
valueLabelPosition ?? BarValueLabelPosition.OutsideEnd;
const refs: Refs = {};
const groupBy = ensureIsArray(groupby);
@@ -751,7 +741,6 @@ export default function transformProps(
labelMap?.[seriesName]?.[0],
) ?? defaultFormatter),
showValue,
valueLabelPosition: resolvedValueLabelPosition,
onlyTotal,
totalStackedValues: sortedTotalValues,
showValueIndexes,
@@ -1216,50 +1205,48 @@ export default function transformProps(
// When showMaxLabel is true, ECharts may render a label at the axis
// boundary that formats identically to the last data-point tick (e.g.
// "2005" appears twice with Year grain), and hideOverlap must stay off so
// that forced boundary label is never suppressed (#39899). Wrap the
// formatter to suppress consecutive duplicate labels and to thin out
// labels that would otherwise visually collide, since hideOverlap can no
// longer do that for us. The spacing estimate assumes the axis runs along
// the bottom of the chart (pixel width, character width); a horizontal
// orientation chart puts the time axis on the side instead, so it falls
// back to dedup-only there.
// "2005" appears twice with Year grain). Wrap the formatter to suppress
// consecutive duplicate labels.
const showMaxLabel =
xAxisType === AxisType.Time &&
xAxisLabelRotation === 0 &&
!!resolvedTimeGrain;
const deduplicatedFormatter = showMaxLabel
? isHorizontal
? createDedupXAxisFormatter(xAxisFormatter)
: createSpacedXAxisFormatter(
xAxisFormatter,
...getXAxisDomain(
[rebasedData as Record<string, unknown>[]],
xAxisLabel,
),
Math.max(width - 2 * TIMESERIES_CONSTANTS.gridOffsetLeft, 0),
)
? (() => {
let lastLabel: string | undefined;
let lastValue: number | undefined;
const wrapper = (value: number | string) => {
// ECharts formats the labels in repeated ascending passes. Reset the
// dedup state when the sequence restarts so a forced boundary label
// (e.g. the min date) isn't blanked by the previous pass's last label
// when both format identically (e.g. a May-to-May range).
if (
typeof value === 'number' &&
lastValue !== undefined &&
value <= lastValue
) {
lastLabel = undefined;
}
if (typeof value === 'number') {
lastValue = value;
}
const label =
typeof xAxisFormatter === 'function'
? (xAxisFormatter as Function)(value)
: String(value);
if (label === lastLabel) {
return '';
}
lastLabel = label;
return label;
};
if (typeof xAxisFormatter === 'function' && 'id' in xAxisFormatter) {
(wrapper as any).id = (xAxisFormatter as any).id;
}
return wrapper;
})()
: xAxisFormatter;
const temporalTickValues = resolveTemporalTickValues(
rebasedData,
xAxisLabel,
xAxisType,
resolvedTimeGrain,
annotationLayers,
);
const temporalAxisTickConfig = getTemporalAxisTickConfig(
temporalTickValues,
showMaxLabel,
xAxisType,
xAxisLabelRotation,
xAxisLabelInterval,
deduplicatedFormatter,
isHorizontal,
zoomable,
);
let xAxis: any = {
type: xAxisType,
name: xAxisTitle,
@@ -1269,12 +1256,38 @@ export default function transformProps(
groupBy.length === 0 && {
triggerEvent: true,
}),
...temporalAxisTickConfig,
minorTick: { show: minorTicks },
axisTick: {
...temporalAxisTickConfig.axisTick,
show: axisTicks ? 'auto' : false,
axisLabel: {
// When rotation is applied on time axes, hideOverlap can
// aggressively hide the last label. Rotated labels already
// have less overlap, so disabling hideOverlap is safe.
// At 0° rotation, also disable hideOverlap when showMaxLabel
// is active so the forced boundary label is never suppressed
// by ECharts' overlap detection (#39899).
hideOverlap: showMaxLabel
? false
: !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0),
formatter: deduplicatedFormatter,
rotate: xAxisLabelRotation,
interval: xAxisLabelInterval,
// Force the boundary labels on non-rotated time axes so the first
// and last dates stay visible: hideOverlap can hide the last label,
// and a min date that falls between "nice" ticks otherwise renders
// no beginning label. Skipped when rotated to avoid phantom labels
// at the axis boundary.
...(showMaxLabel && {
showMaxLabel: true,
showMinLabel: true,
}),
// The alignments assume the axis runs along the bottom; a horizontal
// chart puts this axis on the side, where they misplace the labels.
...(showMaxLabel &&
!isHorizontal && {
alignMaxLabel: 'right',
alignMinLabel: 'left',
}),
},
minorTick: { show: minorTicks },
axisTick: { show: axisTicks ? 'auto' : false },
...(gridlines ? {} : { splitLine: { show: false } }),
minInterval:
xAxisType === AxisType.Time && resolvedTimeGrain && !forceMaxInterval
@@ -1371,10 +1384,6 @@ export default function transformProps(
const echartOptions: EChartsCoreOption = {
useUTC: true,
...(seriesType === EchartsTimeseriesSeriesType.Bar &&
resolvedValueLabelPosition === BarValueLabelPosition.Auto
? { darkMode: isThemeDark(theme) }
: {}),
grid: {
...defaultGrid,
...padding,
@@ -35,9 +35,6 @@ import type {
CallbackDataParams,
DefaultStatesMixin,
ItemStyleOption,
LabelLayoutOption,
LabelLayoutOptionCallback,
LabelLayoutOptionCallbackParams,
LineStyleOption,
OptionName,
SeriesLabelOption,
@@ -52,7 +49,6 @@ import type {
import type { MarkLine1DDataItemOption } from 'echarts/types/src/component/marker/MarkLineModel';
import { extractForecastSeriesContext } from '../utils/forecast';
import {
BarValueLabelPosition,
EchartsTimeseriesSeriesType,
ForecastSeriesEnum,
LabelPositionEnum,
@@ -74,107 +70,6 @@ import {
TIMESERIES_CONSTANTS,
} from '../constants';
const AUTO_LABEL_FIT_RATIO = 0.8;
const BAR_LABEL_DISTANCE = 5;
// Neither an inside nor an outside placement gives a stacked segment's value
// label legible, non-overlapping room once the segment's own extent drops
// below roughly the label text's height, since the closest available
// placement then collides with a neighboring segment's label regardless of
// which side it's drawn on. Highcharts and D3 apply the same kind of floor.
// The label font size is theme.fontSizeSM (~12px, see series.ts), so 16px
// covers the glyph height plus a couple of pixels of breathing room.
const MIN_LABEL_SEGMENT_SIZE_PX = 16;
// The labelLayout callback only applies align/verticalAlign/width/height/
// fontSize from its return value (LABEL_OPTION_TO_STYLE_KEYS in ECharts'
// LabelManager) — there is no hide/ignore field, so a zero font size is the
// supported way to suppress an individual label from this callback.
const HIDDEN_LABEL_LAYOUT: LabelLayoutOption = { fontSize: 0 };
type BarLabelPosition =
| 'bottom'
| 'inside'
| 'insideBottom'
| 'insideLeft'
| 'insideRight'
| 'insideTop'
| 'left'
| 'right'
| 'top';
type NegativeBarLabelPosition = BarLabelPosition | 'outside';
/** Resolve the fixed ECharts label position for a bar value. */
function getBarLabelPosition(
position: BarValueLabelPosition,
isHorizontal: boolean,
isNegative = false,
): BarLabelPosition {
if (position === BarValueLabelPosition.OutsideEnd) {
if (isHorizontal) return isNegative ? 'left' : 'right';
return isNegative ? 'bottom' : 'top';
}
if (position === BarValueLabelPosition.InsideCenter) return 'inside';
const isEnd = position !== BarValueLabelPosition.InsideBase;
const usePositiveEnd = isEnd !== isNegative;
if (isHorizontal) return usePositiveEnd ? 'insideRight' : 'insideLeft';
return usePositiveEnd ? 'insideTop' : 'insideBottom';
}
/** Place a horizontal bar label just beyond its value end. */
function getHorizontalOutsideLayout(
params: LabelLayoutOptionCallbackParams,
isNegative: boolean,
): LabelLayoutOption {
return {
x: isNegative
? params.rect.x - BAR_LABEL_DISTANCE
: params.rect.x + params.rect.width + BAR_LABEL_DISTANCE,
y: params.rect.y + params.rect.height / 2,
align: isNegative ? 'right' : 'left',
verticalAlign: 'middle',
};
}
/** Place a vertical bar label just beyond its value end. */
function getVerticalOutsideLayout(
params: LabelLayoutOptionCallbackParams,
isNegative: boolean,
): LabelLayoutOption {
return {
x: params.rect.x + params.rect.width / 2,
y: isNegative
? params.rect.y + params.rect.height + BAR_LABEL_DISTANCE
: params.rect.y - BAR_LABEL_DISTANCE,
align: 'center',
verticalAlign: isNegative ? 'top' : 'bottom',
};
}
/** Keep fitting labels inside, move oversized labels outside the bar, and
* suppress labels for segments too small to legibly fit one either way. */
export function getAutoBarLabelLayout(
params: LabelLayoutOptionCallbackParams,
isHorizontal: boolean,
isNegative = false,
): LabelLayoutOption {
const segmentSize = isHorizontal
? Math.abs(params.rect.width)
: Math.abs(params.rect.height);
if (segmentSize < MIN_LABEL_SEGMENT_SIZE_PX) {
return HIDDEN_LABEL_LAYOUT;
}
const fitsWidth =
params.labelRect.width <=
Math.abs(params.rect.width) * AUTO_LABEL_FIT_RATIO;
const fitsHeight =
params.labelRect.height <=
Math.abs(params.rect.height) * AUTO_LABEL_FIT_RATIO;
if (fitsWidth && fitsHeight) return {};
return isHorizontal
? getHorizontalOutsideLayout(params, isNegative)
: getVerticalOutsideLayout(params, isNegative);
}
function parseTimeShiftToMs(timeShift?: string | null): number {
if (!timeShift) return 0;
@@ -273,69 +168,35 @@ export const getBaselineSeriesForStream = (
};
};
/** Identify object-form ECharts data items. */
function isDataItemObject(
dataItem: unknown,
): dataItem is Record<string, unknown> {
return (
typeof dataItem === 'object' &&
dataItem !== null &&
!Array.isArray(dataItem)
);
}
/** Return whether an ECharts bar datum is negative on its value axis. */
function isNegativeBarDataItem(
dataItem: unknown,
isHorizontal: boolean,
): boolean {
const value = isDataItemObject(dataItem) ? dataItem.value : dataItem;
const axisValue = Array.isArray(value)
? value[isHorizontal ? 0 : 1]
: undefined;
return typeof axisValue === 'number' && axisValue < 0;
}
/** Create a fit-aware layout callback bound to one bar series. */
function createAutoBarLabelLayout(
data: unknown,
isHorizontal: boolean,
): LabelLayoutOptionCallback {
return params => {
const dataItem =
Array.isArray(data) && params.dataIndex !== undefined
? data[params.dataIndex]
: undefined;
return getAutoBarLabelLayout(
params,
isHorizontal,
isNegativeBarDataItem(dataItem, isHorizontal),
);
};
}
/** Apply the value-end label position to a negative bar datum. */
function transformNegativeLabel(
dataItem: unknown,
isHorizontal: boolean,
negativePosition: NegativeBarLabelPosition,
): unknown {
if (!isNegativeBarDataItem(dataItem, isHorizontal)) return dataItem;
const value = isDataItemObject(dataItem) ? dataItem.value : dataItem;
const item = isDataItemObject(dataItem) ? dataItem : { value };
const label = isDataItemObject(item.label) ? item.label : {};
return { ...item, label: { ...label, position: negativePosition } };
}
/** Adjust label positions for negative values in a bar series. */
export function transformNegativeLabelsPosition(
series: SeriesOption,
isHorizontal: boolean,
negativePosition: NegativeBarLabelPosition = 'outside',
labelPosition?: string,
): TimeseriesDataRecord[] {
return (series.data as unknown[]).map(dataItem =>
transformNegativeLabel(dataItem, isHorizontal, negativePosition),
) as TimeseriesDataRecord[];
/*
* Adjusts label position for negative values in bar series
* @param series - Array of series options
* @param isHorizontal - Whether chart is horizontal
* @returns data with adjusted label positions for negative values
*/
const transformValue = (value: any) => {
const [xValue, yValue] = Array.isArray(value) ? value : [null, null];
const axisValue = isHorizontal ? xValue : yValue;
return axisValue < 0
? {
value,
label: {
position:
labelPosition && labelPosition !== 'auto'
? labelPosition
: 'outside',
},
}
: value;
};
return (series.data as TimeseriesDataRecord[]).map(transformValue);
}
export function applyColorByPrimaryAxis(
@@ -381,7 +242,6 @@ export function transformSeries(
stackIdSuffix?: string;
yAxisIndex?: number;
showValue?: boolean;
valueLabelPosition?: BarValueLabelPosition;
onlyTotal?: boolean;
legendState?: LegendState;
formatter?: ValueFormatter;
@@ -418,7 +278,6 @@ export function transformSeries(
stackIdSuffix,
yAxisIndex = 0,
showValue,
valueLabelPosition = BarValueLabelPosition.Auto,
onlyTotal,
formatter,
legendState,
@@ -540,40 +399,29 @@ export function transformSeries(
symbol = opts.lineSymbol || (isDarkMode ? 'circle' : 'emptyCircle');
}
let transformedData = data;
if (Array.isArray(data) && colorByPrimaryAxis) {
transformedData = applyColorByPrimaryAxis(
series,
colorScale,
sliceId,
opacity,
isHorizontal,
);
}
if (Array.isArray(transformedData) && plotType === 'bar') {
// An explicit labelPosition (set before valueLabelPosition existed, or
// still relevant to a saved chart) takes precedence for negative values;
// otherwise fall back to the fit-aware valueLabelPosition-derived spot.
const negativeLabelPosition: NegativeBarLabelPosition =
labelPosition && labelPosition !== 'auto'
? (labelPosition as NegativeBarLabelPosition)
: getBarLabelPosition(valueLabelPosition, isHorizontal, true);
transformedData = transformNegativeLabelsPosition(
{ ...series, data: transformedData },
isHorizontal,
negativeLabelPosition,
);
}
const isAutoBarLabel =
plotType === 'bar' && valueLabelPosition === BarValueLabelPosition.Auto;
const isInsideBarLabel =
plotType === 'bar' &&
valueLabelPosition !== BarValueLabelPosition.OutsideEnd;
return {
...series,
...(Array.isArray(data) ? { data: transformedData } : null),
...(Array.isArray(data)
? colorByPrimaryAxis
? {
data: applyColorByPrimaryAxis(
series,
colorScale,
sliceId,
opacity,
isHorizontal,
),
}
: seriesType === 'bar' && !stack
? {
data: transformNegativeLabelsPosition(
series,
isHorizontal,
labelPosition,
),
}
: null
: null),
connectNulls,
queryIndex,
yAxisIndex,
@@ -606,31 +454,15 @@ export function transformSeries(
showSymbol,
symbol,
symbolSize: symbolSizeFn ?? markerSize,
...(isAutoBarLabel
? {
labelLayout: createAutoBarLabelLayout(transformedData, isHorizontal),
}
: {}),
label: {
show: !!showValue,
// An explicit labelPosition (the generic control still used by
// MixedTimeseries' bar series, and by standalone bar charts saved
// before valueLabelPosition existed) wins outright. Otherwise bar
// charts fall back to the fit-aware valueLabelPosition control, and
// every other "Show value" chart type falls back to an
// orientation-aware default.
position:
labelPosition && labelPosition !== 'auto'
? (labelPosition as LabelPositionEnum)
: plotType === 'bar'
? getBarLabelPosition(valueLabelPosition, isHorizontal)
: isHorizontal
? LabelPositionEnum.Right
: LabelPositionEnum.Top,
// ECharts derives contrast from the bar fill for inside positions.
// Auto x/y overflow clears the position, selecting its outside fill.
...(isInsideBarLabel ? {} : { color: theme?.colorText }),
position: (labelPosition === 'auto' || !labelPosition
? isHorizontal
? LabelPositionEnum.Right
: LabelPositionEnum.Top
: labelPosition) as LabelPositionEnum,
...(plotType === 'bar' ? { overflow: 'truncate' } : {}),
color: theme?.colorText,
textBorderWidth: 0,
formatter: (params: any) => {
// don't show confidence band value labels, as they're already visible on the tooltip
@@ -53,14 +53,6 @@ export enum EchartsTimeseriesSeriesType {
End = 'end',
}
export enum BarValueLabelPosition {
Auto = 'auto',
InsideEnd = 'insideEnd',
OutsideEnd = 'outsideEnd',
InsideCenter = 'insideCenter',
InsideBase = 'insideBase',
}
export type EchartsTimeseriesFormData = QueryFormData & {
annotationLayers: AnnotationLayer[];
area: boolean;
@@ -110,7 +102,6 @@ export type EchartsTimeseriesFormData = QueryFormData & {
xAxisLabelRotation: number;
xAxisLabelInterval: number | string;
showValue: boolean;
valueLabelPosition: BarValueLabelPosition;
/**
* Where the data label sits relative to its data point, applied when
* `showValue` is on.
@@ -52,12 +52,6 @@ export const TIMESERIES_CONSTANTS = {
microChartHeight: 60,
// One y-axis tick per this many pixels of chart height
yAxisPixelsPerTick: 80,
// Rough average glyph width (px) used to estimate whether adjacent x-axis
// time labels would visually collide, since the real rendered width isn't
// known until ECharts lays out the axis.
xAxisLabelCharWidthPx: 7,
// Minimum gap (px) to keep between adjacent x-axis time labels.
xAxisLabelMinGapPx: 8,
};
export enum OpacityEnum {
@@ -95,16 +89,6 @@ export const StackControlOptionsWithoutStream: [
[StackControlsValue.Stack, t('Stack')],
];
// Grains ECharts' time axis cannot tick on; see getTemporalTickValues in
// utils/series.
export const WEEKLY_TIME_GRAINS: ReadonlySet<string> = new Set([
TimeGranularity.WEEK,
TimeGranularity.WEEK_STARTING_SUNDAY,
TimeGranularity.WEEK_STARTING_MONDAY,
TimeGranularity.WEEK_ENDING_SATURDAY,
TimeGranularity.WEEK_ENDING_SUNDAY,
]);
export const TIMEGRAIN_TO_TIMESTAMP = {
[TimeGranularity.HOUR]: 3600 * 1000,
[TimeGranularity.DAY]: 3600 * 1000 * 24,
@@ -34,7 +34,6 @@ import {
StackControlOptionsWithoutStream,
} from './constants';
import { DEFAULT_FORM_DATA } from './Timeseries/constants';
import { BarValueLabelPosition } from './Timeseries/types';
import { defaultXAxis } from './defaults';
const { legendMargin, legendOrientation, legendType, showLegend } =
@@ -141,32 +140,6 @@ export const showValueControl: ControlSetItem = {
},
};
// Bar-only: fit-aware placement (Auto avoids/suppresses colliding labels on
// stacked segments) plus explicit end/center/base positions. Wired into
// showValueSectionWithoutStream (Bar charts) instead of labelPositionControl
// below, which stays the generic picker for every other "Show value" chart.
export const valueLabelPositionControl: ControlSetItem = {
name: 'value_label_position',
config: {
type: 'SelectControl',
freeForm: false,
clearable: false,
label: t('Value label position'),
choices: [
[BarValueLabelPosition.Auto, t('Auto')],
[BarValueLabelPosition.InsideEnd, t('Inside End')],
[BarValueLabelPosition.OutsideEnd, t('Outside End')],
[BarValueLabelPosition.InsideCenter, t('Inside Center')],
[BarValueLabelPosition.InsideBase, t('Inside Base')],
],
default: DEFAULT_FORM_DATA.valueLabelPosition,
renderTrigger: true,
description: t('Choose where to display values relative to the bars'),
visibility: ({ controls }: ControlPanelsContainerProps) =>
Boolean(controls?.show_value?.value),
},
};
export const labelPositionControl: ControlSetItem = {
name: 'label_position',
config: {
@@ -284,11 +257,9 @@ export const showValueSectionWithoutStack: ControlSetRow[] = [
[onlyTotalControl],
];
// Bar charts (the only consumer of this section) use the fit-aware
// valueLabelPositionControl instead of the generic labelPositionControl.
export const showValueSectionWithoutStream: ControlSetRow[] = [
[showValueControl],
[valueLabelPositionControl],
[labelPositionControl],
[stackControlWithoutStream],
[onlyTotalControl],
[percentageThresholdControl],
@@ -24,7 +24,6 @@ import {
getTimeFormatter,
isSavedMetric,
NumberFormats,
NumberFormatter,
QueryFormMetric,
SMART_DATE_DETAILED_ID,
SMART_DATE_ID,
@@ -33,7 +32,6 @@ import {
TimeGranularity,
ValueFormatter,
} from '@superset-ui/core';
import { TIMESERIES_CONSTANTS } from '../constants';
export const getSmartDateDetailedFormatter = () =>
getTimeFormatter(SMART_DATE_DETAILED_ID);
@@ -215,151 +213,3 @@ export function getXAxisFormatter(
}
return String;
}
type XAxisFormatterFn =
| TimeFormatter
| NumberFormatter
| StringConstructor
| ((value: number | string) => string);
/**
* Wraps an x-axis time formatter so that consecutive ticks that format to
* identical text are blanked (e.g. the boundary label forced by
* showMaxLabel duplicating the last real tick).
*
* Use this instead of createSpacedXAxisFormatter when the axis geometry
* doesn't match the spacing model's horizontal-plot assumptions, e.g. a
* horizontal orientation chart, where the time axis runs vertically along
* the side of the chart rather than along the bottom.
*/
export function createDedupXAxisFormatter(
xAxisFormatter: XAxisFormatterFn | undefined,
): (value: number | string) => string {
let lastLabel: string | undefined;
let lastValue: number | undefined;
const wrapper = (value: number | string) => {
// ECharts formats the labels in repeated ascending passes. Reset the
// dedup state when the sequence restarts so a forced boundary label
// (e.g. the min date) isn't blanked by the previous pass's last label
// when both format identically (e.g. a May-to-May range).
if (
typeof value === 'number' &&
lastValue !== undefined &&
value <= lastValue
) {
lastLabel = undefined;
}
if (typeof value === 'number') {
lastValue = value;
}
const label =
typeof xAxisFormatter === 'function'
? (xAxisFormatter as Function)(value)
: String(value);
if (label === lastLabel) {
return '';
}
lastLabel = label;
return label;
};
if (typeof xAxisFormatter === 'function' && 'id' in xAxisFormatter) {
(wrapper as { id?: unknown }).id = (xAxisFormatter as { id?: unknown }).id;
}
return wrapper;
}
/**
* Wraps an x-axis time formatter so that:
* - consecutive ticks that format to identical text are blanked (e.g. the
* boundary label forced by showMaxLabel duplicating the last real tick).
* - ticks that would render close enough to visually collide with the
* previously shown label are blanked, since disabling ECharts'
* `hideOverlap` (required to keep the forced boundary label visible, see
* #39899) also disables its native overlap suppression for every other
* label on the axis.
*
* The forced axis boundary labels (domainMin/domainMax) are never blanked by
* the spacing check so they stay visible regardless of density.
*/
export function createSpacedXAxisFormatter(
xAxisFormatter: XAxisFormatterFn | undefined,
domainMin: number | undefined,
domainMax: number | undefined,
plotWidthPx: number,
): (value: number | string) => string {
const pixelsPerMs =
domainMin !== undefined && domainMax !== undefined && domainMax > domainMin
? plotWidthPx / (domainMax - domainMin)
: undefined;
let lastLabel: string | undefined;
let lastValue: number | undefined;
let lastShownValue: number | undefined;
const wrapper = (value: number | string) => {
// ECharts formats the labels in repeated ascending passes. Reset the
// dedup/spacing state when the sequence restarts so a forced boundary
// label (e.g. the min date) isn't blanked by the previous pass's state
// when both format identically (e.g. a May-to-May range).
if (
typeof value === 'number' &&
lastValue !== undefined &&
value <= lastValue
) {
lastLabel = undefined;
lastShownValue = undefined;
}
if (typeof value === 'number') {
lastValue = value;
}
const label =
typeof xAxisFormatter === 'function'
? (xAxisFormatter as Function)(value)
: String(value);
if (label === lastLabel) {
return '';
}
const isBoundary =
typeof value === 'number' && (value === domainMin || value === domainMax);
if (
!isBoundary &&
typeof value === 'number' &&
pixelsPerMs !== undefined &&
lastShownValue !== undefined &&
(value - lastShownValue) * pixelsPerMs <
label.length * TIMESERIES_CONSTANTS.xAxisLabelCharWidthPx +
TIMESERIES_CONSTANTS.xAxisLabelMinGapPx
) {
return '';
}
lastLabel = label;
if (typeof value === 'number') {
lastShownValue = value;
}
return label;
};
if (typeof xAxisFormatter === 'function' && 'id' in xAxisFormatter) {
(wrapper as { id?: unknown }).id = (xAxisFormatter as { id?: unknown }).id;
}
return wrapper;
}
/**
* Computes the [min, max] of a temporal x-axis column across one or more
* data record arrays, for use with createSpacedXAxisFormatter.
*/
export function getXAxisDomain(
dataRecordArrays: Record<string, unknown>[][],
xAxisCol: string,
): [number | undefined, number | undefined] {
let domainMin: number | undefined;
let domainMax: number | undefined;
dataRecordArrays.forEach(records => {
records.forEach(record => {
const value = record[xAxisCol];
if (typeof value === 'number') {
if (domainMin === undefined || value < domainMin) domainMin = value;
if (domainMax === undefined || value > domainMax) domainMax = value;
}
});
});
return [domainMin, domainMax];
}
@@ -18,14 +18,12 @@
* under the License.
*/
import {
AnnotationLayer,
AxisType,
ChartDataResponseResult,
DataRecord,
DataRecordValue,
DTTM_ALIAS,
ensureIsArray,
isTimeseriesAnnotationLayer,
LegendState,
normalizeTimestamp,
NumberFormats,
@@ -44,7 +42,6 @@ import {
NULL_STRING,
StackControlsValue,
TIMESERIES_CONSTANTS,
WEEKLY_TIME_GRAINS,
} from '../constants';
import {
EchartsTimeseriesSeriesType,
@@ -989,167 +986,6 @@ export function getAxisType(
return AxisType.Category;
}
// `new Date('2024-04-06')` parses as UTC, but ECharts' own date parser treats
// zone-less strings as local time — mismatch would offset the pinned tick.
const DATE_ONLY_RE = /^(\d{4})(?:-(\d{1,2})(?:-(\d{1,2}))?)?$/;
function parseTemporalString(value: string): number {
const dateOnly = DATE_ONLY_RE.exec(value);
if (dateOnly) {
const [, year, month, day] = dateOnly;
return new Date(
Number(year),
Number(month || 1) - 1,
Number(day || 1),
).getTime();
}
return new Date(value).getTime();
}
/**
* Bucket timestamps a temporal axis should tick on, or undefined to let ECharts
* choose.
*
* ECharts generates time ticks from a calendar ladder with no week unit, so for
* weekly data it steps days from the 1st of each month instead: labels drift
* across weekdays and snap to month starts (#17226). Coarser grains already land
* on their data and keep ECharts' calendar-nice labels.
*/
export function getTemporalTickValues(
data: DataRecord[],
xAxisLabel: string,
xAxisType: AxisType,
timeGrain?: string,
): number[] | undefined {
if (
xAxisType !== AxisType.Time ||
!timeGrain ||
!WEEKLY_TIME_GRAINS.has(timeGrain)
) {
return undefined;
}
const values = new Set<number>();
data.forEach(row => {
const value = row[xAxisLabel];
const timestamp =
// eslint-disable-next-line no-nested-ternary
value instanceof Date
? value.getTime()
: typeof value === 'string'
? parseTemporalString(value)
: Number(value ?? NaN);
if (Number.isFinite(timestamp)) {
values.add(timestamp);
}
});
return values.size ? [...values].sort((a, b) => a - b) : undefined;
}
/**
* Weekly grains: pin the ticks to the buckets ECharts would otherwise miss.
* A timeseries annotation contributes its own timestamps and widens the axis
* past the buckets, and ECharts clips pinned ticks to the extent, so that
* span would render bare leave those charts on ECharts' own ticks.
*/
export function resolveTemporalTickValues(
data: DataRecord[],
xAxisLabel: string,
xAxisType: AxisType,
timeGrain: string | undefined,
annotationLayers: AnnotationLayer[],
): number[] | undefined {
const hasTimeseriesAnnotation = annotationLayers.some(
layer => layer.show && isTimeseriesAnnotationLayer(layer),
);
return hasTimeseriesAnnotation
? undefined
: getTemporalTickValues(data, xAxisLabel, xAxisType, timeGrain);
}
// Unlike axisLabel, axisTick has no overlap-based thinning, so pinning it to
// every bucket combs a long weekly range. Downsample evenly, keeping ends.
const MAX_PINNED_AXIS_TICKS = 60;
export function capTickMarks(
values: number[],
maxTicks: number = MAX_PINNED_AXIS_TICKS,
): number[] {
if (values.length <= maxTicks) {
return values;
}
const step = Math.ceil(values.length / maxTicks);
const capped = values.filter((_, index) => index % step === 0);
const last = values[values.length - 1];
if (capped[capped.length - 1] !== last) {
capped.push(last);
}
return capped;
}
/**
* axisLabel/axisTick fragment for a temporal x-axis, shared by Timeseries and
* MixedTimeseries. When temporalTickValues pins the axis to weekly buckets,
* axisTick.customValues (what splitLine/gridlines follow) is downsampled to
* avoid combing a long weekly range. axisLabel.customValues (what hideOverlap
* thins from) uses the same capped set on a non-zoomable axis, so a label
* surviving hideOverlap thinning always lands on a real tick and gridline
* rather than a capped-away bucket. On a zoomable axis the full set is used
* instead zooming lets the user reach any bucket, but customValues never
* recomputes on dataZoom, so a capped set there would freeze the visible
* labels to the pre-zoom subset.
*/
export function getTemporalAxisTickConfig(
temporalTickValues: number[] | undefined,
showMaxLabel: boolean,
xAxisType: AxisType,
xAxisLabelRotation: number,
xAxisLabelInterval: number | string | undefined,
formatter: unknown,
isHorizontal: boolean = false,
zoomable: boolean = false,
): {
axisLabel: Record<string, unknown>;
axisTick?: { customValues: number[] };
} {
const cappedTickValues = temporalTickValues
? capTickMarks(temporalTickValues)
: undefined;
const labelCustomValues = zoomable ? temporalTickValues : cappedTickValues;
return {
axisLabel: {
// Pinned ticks label every bucket, which does crowd, so thinning
// always wins there.
hideOverlap:
!!temporalTickValues ||
(showMaxLabel
? false
: !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0)),
formatter,
rotate: xAxisLabelRotation,
interval: xAxisLabelInterval,
// Force the boundary labels so the first and last dates stay visible:
// hideOverlap can hide the last label, and a min date that falls
// between "nice" ticks otherwise renders no beginning label. Applied
// for pinned axes too — showMaxLabel only shields its immediate
// neighbour, so a farther label on a crowded weekly axis can still be
// dropped, but that's strictly better than no shielding at all.
...(showMaxLabel && {
showMaxLabel: true,
showMinLabel: true,
}),
// The alignments assume the axis runs along the bottom; a horizontal
// chart puts this axis on the side, where they misplace the labels.
...(showMaxLabel &&
!isHorizontal && {
alignMaxLabel: 'right',
alignMinLabel: 'left',
}),
...(labelCustomValues && { customValues: labelCustomValues }),
},
...(cappedTickValues && { axisTick: { customValues: cappedTickValues } }),
};
}
export function getOverMaxHiddenFormatter(
config: {
max?: number;
@@ -1,202 +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.
*/
import { render } from '@testing-library/react';
import { ChartProps } from '@superset-ui/core';
import { supersetTheme } from '@apache-superset/core/theme';
import Butterfly from '../../src/Butterfly/Butterfly';
import transformProps from '../../src/Butterfly/transformProps';
import { EchartsButterflyChartProps } from '../../src/Butterfly/types';
import Echart from '../../src/components/Echart';
import { EventHandlers } from '../../src/types';
jest.mock('../../src/components/Echart', () => ({
__esModule: true,
default: jest.fn(() => null),
}));
const mockedEchart = jest.mocked(Echart);
const data = [
{ category: 'A', left_sum: 10, right_sum: 25 },
{ category: 'B', left_sum: 5, right_sum: 19 },
];
const categoryKeyA = 'A__["A"]';
const categoryKeyB = 'B__["B"]';
function setup(
overrides: {
filterState?: { selectedValues?: string[] };
onLegendStateChanged?: jest.Mock;
} = {},
) {
const onContextMenu = jest.fn();
const setDataMask = jest.fn();
const onLegendStateChanged = overrides.onLegendStateChanged ?? jest.fn();
const chartProps = {
...new ChartProps({
formData: {
groupby: ['category'],
left_metric: 'left_sum',
right_metric: 'right_sum',
viz_type: 'butterfly',
},
width: 800,
height: 600,
queriesData: [{ data }],
theme: supersetTheme,
hooks: { onContextMenu, setDataMask, onLegendStateChanged },
}),
filterState: overrides.filterState ?? {},
emitCrossFilters: true,
} as unknown as EchartsButterflyChartProps;
const transformed = transformProps(chartProps);
render(
<Butterfly
{...transformed}
onContextMenu={onContextMenu}
setDataMask={setDataMask}
onLegendStateChanged={onLegendStateChanged}
emitCrossFilters
/>,
);
const lastCall = mockedEchart.mock.calls[mockedEchart.mock.calls.length - 1];
const { eventHandlers, selectedValues } = lastCall[0] as {
eventHandlers: EventHandlers;
selectedValues: Record<number, string>;
};
return {
eventHandlers,
onContextMenu,
setDataMask,
onLegendStateChanged,
selectedValues,
};
}
beforeEach(() => {
mockedEchart.mockClear();
});
test('context menu exposes drill to detail for the selected category', () => {
const { eventHandlers, onContextMenu } = setup();
eventHandlers.contextmenu({
name: 'A',
data: { name: categoryKeyA },
event: { stop: jest.fn(), event: { clientX: 10, clientY: 20 } },
});
expect(onContextMenu).toHaveBeenCalledTimes(1);
const [x, y, payload] = onContextMenu.mock.calls[0];
expect(x).toBe(10);
expect(y).toBe(20);
expect(payload.drillToDetail).toEqual([
expect.objectContaining({
col: 'category',
op: '==',
val: 'A',
formattedVal: 'A',
}),
]);
});
test('context menu exposes drill by for the selected category', () => {
const { eventHandlers, onContextMenu } = setup();
eventHandlers.contextmenu({
name: 'A',
data: { name: categoryKeyA },
event: { stop: jest.fn(), event: { clientX: 10, clientY: 20 } },
});
const payload = onContextMenu.mock.calls[0][2];
expect(payload.drillBy).toEqual({
filters: [
expect.objectContaining({
col: 'category',
op: '==',
val: 'A',
formattedVal: 'A',
}),
],
groupbyFieldName: 'groupby',
});
});
test('click emits cross-filter for the selected category', () => {
const { eventHandlers, setDataMask } = setup();
eventHandlers.click({ name: 'B', data: { name: categoryKeyB } });
expect(setDataMask).toHaveBeenCalledWith(
expect.objectContaining({
extraFormData: {
filters: [{ col: 'category', op: 'IN', val: ['B'] }],
},
filterState: {
value: [['B']],
selectedValues: [categoryKeyB],
},
}),
);
});
test('click clears cross-filter when the category is already selected', () => {
const { eventHandlers, setDataMask } = setup({
filterState: { selectedValues: [categoryKeyB] },
});
eventHandlers.click({ name: 'B', data: { name: categoryKeyB } });
expect(setDataMask).toHaveBeenCalledWith(
expect.objectContaining({
extraFormData: {
filters: [],
},
filterState: {
value: null,
selectedValues: null,
},
}),
);
});
test('legend selection forwards legend state to the chart hook', () => {
const onLegendStateChanged = jest.fn();
const { eventHandlers } = setup({ onLegendStateChanged });
const selected = { left_sum: true, right_sum: false };
eventHandlers.legendselectchanged({ selected });
eventHandlers.legendselectall({ selected });
eventHandlers.legendinverseselect({ selected });
expect(onLegendStateChanged).toHaveBeenCalledTimes(3);
expect(onLegendStateChanged).toHaveBeenCalledWith(selected);
});
test('passes selectedValues through to the chart component', () => {
const { selectedValues } = setup({
filterState: { selectedValues: [categoryKeyA] },
});
expect(selectedValues).toEqual({ 0: categoryKeyA });
});
@@ -18,40 +18,25 @@
*/
import { ChartProps } from '@superset-ui/core';
import { supersetTheme } from '@apache-superset/core/theme';
import type { CallbackDataParams } from 'echarts/types/src/util/types';
import {
EchartsButterflyChartProps,
ButterflyTransformedProps,
} from '../../src/Butterfly/types';
import transformProps from '../../src/Butterfly/transformProps';
import { NULL_STRING, OpacityEnum } from '../../src/constants';
import { NULL_STRING } from '../../src/constants';
const categoryKeyA = 'A__["A"]';
const categoryKeyB = 'B__["B"]';
type SeriesDataPoint = {
name?: string;
value?: number;
itemStyle?: { opacity?: number };
};
type SeriesDataPoint = { value?: number } | number;
type ButterflyTestSeries = {
name?: string;
data?: SeriesDataPoint[];
itemStyle?: { color?: string };
label?: {
show?: boolean;
formatter?: (params: CallbackDataParams) => string;
};
label?: { show?: boolean };
};
type ButterflyTestEchartOptions = {
series?: ButterflyTestSeries[];
xAxis?: {
name?: string;
nameGap?: number;
axisLabel?: { formatter?: (value: number) => string };
};
xAxis?: { name?: string; nameGap?: number };
yAxis?: {
name?: string;
nameGap?: number;
@@ -60,10 +45,7 @@ type ButterflyTestEchartOptions = {
};
legend?: { orient?: string; data?: string[] };
grid?: { left?: number; top?: number };
tooltip?: {
show?: boolean;
formatter?: (params: CallbackDataParams | CallbackDataParams[]) => string;
};
tooltip?: { show?: boolean };
};
const getEchartOptions = (
@@ -73,7 +55,13 @@ const getEchartOptions = (
const extractSeriesValues = (props: ButterflyTransformedProps) => {
const series = getEchartOptions(props).series ?? [];
return series.map(item => (item.data ?? []).map(entry => entry.value));
return series.map(item =>
(item.data ?? []).map(entry =>
typeof entry === 'object' && entry !== null && 'value' in entry
? entry.value
: entry,
),
);
};
const extractSeriesNames = (props: ButterflyTransformedProps) => {
@@ -100,22 +88,19 @@ const createChartProps = (
overrides: Record<string, unknown> = {},
queryData: Record<string, unknown>[] = data,
) =>
({
...new ChartProps({
formData: { ...formData, ...overrides },
width: 800,
height: 600,
queriesData: [{ data: queryData }],
theme: supersetTheme,
...((overrides.hooks ? { hooks: overrides.hooks } : {}) as object),
}),
filterState: overrides.filterState ?? {},
emitCrossFilters: overrides.emitCrossFilters,
inContextMenu: overrides.inContextMenu,
}) as unknown as EchartsButterflyChartProps;
new ChartProps({
formData: { ...formData, ...overrides },
width: 800,
height: 600,
queriesData: [{ data: queryData }],
theme: supersetTheme,
...((overrides.hooks ? { hooks: overrides.hooks } : {}) as object),
});
test('transforms chart props into diverging bar series', () => {
const transformedProps = transformProps(createChartProps());
const transformedProps = transformProps(
createChartProps() as unknown as EchartsButterflyChartProps,
);
expect(extractSeriesValues(transformedProps)).toEqual([
[-10, -5],
@@ -123,23 +108,11 @@ test('transforms chart props into diverging bar series', () => {
]);
});
test('assigns composite category keys to each bar data point', () => {
const transformedProps = transformProps(createChartProps());
const series = getEchartOptions(transformedProps).series ?? [];
expect(series[0]?.data?.map(point => point.name)).toEqual([
categoryKeyA,
categoryKeyB,
]);
expect(series[1]?.data?.map(point => point.name)).toEqual([
categoryKeyA,
categoryKeyB,
]);
});
test('uses absolute values for negative right-side metrics', () => {
const transformedProps = transformProps(
createChartProps({}, [{ category: 'A', left_sum: -8, right_sum: -15 }]),
createChartProps({}, [
{ category: 'A', left_sum: -8, right_sum: -15 },
]) as unknown as EchartsButterflyChartProps,
);
expect(extractSeriesValues(transformedProps)).toEqual([[-8], [15]]);
@@ -149,7 +122,7 @@ test('formats null categories and missing metric values', () => {
const transformedProps = transformProps(
createChartProps({}, [
{ category: null, left_sum: undefined, right_sum: 7 },
]),
]) as unknown as EchartsButterflyChartProps,
);
const { yAxis } = getEchartOptions(transformedProps);
@@ -168,7 +141,7 @@ test('applies custom series labels, colors, and axis titles', () => {
right_color: { r: 0, g: 255, b: 0 },
x_axis_label: 'Value axis',
y_axis_label: 'Category axis',
}),
}) as unknown as EchartsButterflyChartProps,
);
const { series, xAxis, yAxis } = getEchartOptions(transformedProps);
@@ -190,7 +163,7 @@ test('applies legend orientation, sort, and axis margin settings', () => {
xAxisLabelRotation: 45,
x_axis_title_margin: 60,
y_axis_title_margin: 80,
}),
}) as unknown as EchartsButterflyChartProps,
);
const { legend, xAxis, yAxis, grid } = getEchartOptions(transformedProps);
@@ -205,7 +178,9 @@ test('applies legend orientation, sort, and axis margin settings', () => {
test('hides value labels when showValue is false', () => {
const transformedProps = transformProps(
createChartProps({ showValue: false }),
createChartProps({
showValue: false,
}) as unknown as EchartsButterflyChartProps,
);
const { series } = getEchartOptions(transformedProps);
@@ -213,129 +188,15 @@ test('hides value labels when showValue is false', () => {
expect(series?.[1]?.label?.show).toBe(false);
});
test('hides zero value labels but keeps non-zero labels', () => {
const transformedProps = transformProps(
createChartProps({}, [{ category: 'A', left_sum: 0, right_sum: 12 }]),
);
const formatter =
getEchartOptions(transformedProps).series?.[0]?.label?.formatter;
expect(formatter?.({ value: 0 } as CallbackDataParams)).toBe('');
expect(formatter?.({ value: -10 } as CallbackDataParams)).toBe('10');
});
test('formats axis and tooltip values as absolute numbers', () => {
const transformedProps = transformProps(createChartProps());
const { xAxis, tooltip } = getEchartOptions(transformedProps);
expect(xAxis?.axisLabel?.formatter?.(-25)).toBe('25');
const tooltipHtml = tooltip?.formatter?.([
{
name: categoryKeyA,
dataIndex: 0,
seriesName: 'left_sum',
value: -10,
} as CallbackDataParams,
{
name: categoryKeyA,
dataIndex: 0,
seriesName: 'right_sum',
value: 25,
} as CallbackDataParams,
]);
expect(tooltipHtml).toContain('A');
expect(tooltipHtml).not.toContain(categoryKeyA);
expect(tooltipHtml).toContain('left_sum');
expect(tooltipHtml).toContain('right_sum');
expect(tooltipHtml).toContain('10');
expect(tooltipHtml).toContain('25');
});
test('shows the category label in the tooltip when ECharts reports a unique key', () => {
const transformedProps = transformProps(createChartProps());
const tooltipHtml = getEchartOptions(transformedProps).tooltip?.formatter?.({
name: categoryKeyA,
seriesName: 'left_sum',
value: -10,
} as CallbackDataParams);
expect(tooltipHtml).toContain('A');
expect(tooltipHtml).not.toContain(categoryKeyA);
});
test('hides tooltip while the context menu is open', () => {
const transformedProps = transformProps(createChartProps());
const withContextMenu = transformProps(
createChartProps({ inContextMenu: true }),
const transformedProps = transformProps(
createChartProps({}, data) as unknown as EchartsButterflyChartProps,
);
const withContextMenu = transformProps({
...createChartProps(),
inContextMenu: true,
} as unknown as EchartsButterflyChartProps);
expect(getEchartOptions(transformedProps).tooltip?.show).toBe(true);
expect(getEchartOptions(withContextMenu).tooltip?.show).toBe(false);
});
test('builds labelMap and groupby for drill and cross-filter handlers', () => {
const transformedProps = transformProps(createChartProps());
expect(transformedProps.groupby).toEqual(['category']);
expect(transformedProps.labelMap).toEqual({
'A__["A"]': ['A'],
'B__["B"]': ['B'],
});
});
test('uses unique keys for interactions and readable labels on the y-axis', () => {
const transformedProps = transformProps(
createChartProps({ groupby: ['country', 'state'] }, [
{ country: 'US', state: 'CA', left_sum: 4, right_sum: 6 },
{ country: 'US', state: 'NY', left_sum: 8, right_sum: 3 },
]),
);
const series = getEchartOptions(transformedProps).series ?? [];
const firstKey = 'US, CA__["US","CA"]';
const secondKey = 'US, NY__["US","NY"]';
expect(firstKey).not.toBe(secondKey);
expect(series[0]?.data?.map(point => point.name)).toEqual([
firstKey,
secondKey,
]);
expect(transformedProps.labelMap).toEqual({
[firstKey]: ['US', 'CA'],
[secondKey]: ['US', 'NY'],
});
expect(getEchartOptions(transformedProps).yAxis?.data).toEqual([
'US, CA',
'US, NY',
]);
});
test('dims unselected categories when a cross-filter is active', () => {
const transformedProps = transformProps(
createChartProps({
filterState: { selectedValues: [categoryKeyA] },
}),
);
const series = getEchartOptions(transformedProps).series ?? [];
expect(series[0]?.data?.[0]?.itemStyle?.opacity).toBe(
OpacityEnum.NonTransparent,
);
expect(series[0]?.data?.[1]?.itemStyle?.opacity).toBe(
OpacityEnum.SemiTransparent,
);
expect(series[1]?.data?.[1]?.itemStyle?.opacity).toBe(
OpacityEnum.SemiTransparent,
);
});
test('maps selectedValues to category indexes', () => {
const transformedProps = transformProps(
createChartProps({
filterState: { selectedValues: [categoryKeyB] },
}),
);
expect(transformedProps.selectedValues).toEqual({ 1: categoryKeyB });
});
@@ -196,61 +196,6 @@ function formatSeriesLabel(
});
}
test('bar value labels retain their legacy outside position', () => {
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsMixedTimeseriesFormData,
EchartsMixedTimeseriesProps
>({
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
defaultQueriesData: queriesData,
formData: { ...formData, showValueB: true },
queriesData,
});
const transformed = transformProps(chartProps);
const barSeries = (transformed.echartOptions.series as SeriesOption[]).filter(
(series): series is BarSeriesOption => series.type === 'bar',
);
expect(barSeries).not.toHaveLength(0);
barSeries.forEach(series => {
expect(series.label).toMatchObject({ show: true, position: 'top' });
expect(series.labelLayout).toBeUndefined();
});
});
test('negative bar values retain their legacy outside position', () => {
const negativeRows = [
{ boy: -1, girl: -2, ds: 599616000000 },
{ boy: -3, girl: -4, ds: 599916000000 },
];
const negativeQueriesData = [
createTestQueryData(negativeRows, { label_map: defaultLabelMap }),
createTestQueryData(negativeRows, { label_map: defaultLabelMap }),
];
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsMixedTimeseriesFormData,
EchartsMixedTimeseriesProps
>({
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
defaultQueriesData: negativeQueriesData,
formData: { ...formData, showValueB: true },
queriesData: negativeQueriesData,
});
const transformed = transformProps(chartProps);
const barSeries = (transformed.echartOptions.series as SeriesOption[]).filter(
(series): series is BarSeriesOption => series.type === 'bar',
);
expect(barSeries).not.toHaveLength(0);
barSeries.forEach(series => {
expect(series.data?.[0]).toMatchObject({
label: { position: 'bottom' },
});
});
});
test('should transform chart props for viz with showQueryIdentifiers=false', () => {
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsMixedTimeseriesFormData,
@@ -1379,58 +1324,6 @@ test('#39899 - x-axis dates do not overlap and last label stays visible at 0° r
expect(axisLabel.hideOverlap).toBe(false);
});
test('#39899 - closely spaced x-axis time labels do not visually overlap (mixed)', () => {
const startTime = Date.UTC(2026, 0, 1);
const data = Array.from({ length: 20 }, (_, i) => ({
__timestamp: startTime + i * 60 * 1000,
sum__num: i,
}));
const queryData = createTestQueryData(data, {
colnames: ['__timestamp', 'sum__num'],
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
label_map: { __timestamp: ['__timestamp'], sum__num: ['sum__num'] },
});
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsMixedTimeseriesFormData,
EchartsMixedTimeseriesProps
>({
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
width: 300,
height: 400,
defaultQueriesData: [queryData, queryData],
formData: {
...formData,
x_axis: '__timestamp',
xAxisTimeFormat: '%Y-%m-%d %H:%M:%S',
metrics: ['sum__num'],
metricsB: ['sum__num'],
groupby: [],
groupbyB: [],
xAxisLabelRotation: 0,
timeGrainSqla: TimeGranularity.MINUTE,
},
queriesData: [queryData, queryData],
});
const { echartOptions } = transformProps(chartProps);
const { axisLabel } = echartOptions.xAxis as Record<string, any>;
const labels = data.map(({ __timestamp }) =>
axisLabel.formatter(__timestamp),
);
// hideOverlap must stay off so ECharts' own collision detection can never
// suppress the forced boundary label (#39899 must not regress).
expect(axisLabel.hideOverlap).toBe(false);
// The formatter itself must thin out labels that are too close together to
// render legibly in the available width.
expect(labels.filter(label => label === '').length).toBeGreaterThan(0);
// The first and last labels are the forced axis boundaries and must always
// stay visible.
expect(labels[0]).not.toBe('');
expect(labels[labels.length - 1]).not.toBe('');
});
test('regression #37921: multi-metric Query A with groupby does not duplicate first metric in series names', () => {
// Regression test for https://github.com/apache/superset/issues/37921
// ("Residual" follow-up to #37055).
@@ -1619,98 +1512,6 @@ describe('EchartsMixedTimeseries tooltip truncation', () => {
});
});
describe('weekly x-axis tick alignment', () => {
const WEEK_MS = 7 * 24 * 3600 * 1000;
const MONDAYS = Array.from(
{ length: 6 },
(_, i) => Date.UTC(2026, 3, 6) + i * WEEK_MS,
);
const weeklyLabelMap = { ds: ['ds'], sum__num: ['sum__num'] };
const weeklyQuery = (timestamps: number[]) =>
createTestQueryData(
timestamps.map((ds, i) => ({ ds, sum__num: 10 + i })),
{
label_map: weeklyLabelMap,
colnames: ['ds', 'sum__num'],
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
},
);
const weeklyChartProps = (
queryA: number[],
queryB: number[],
overrides: Partial<EchartsMixedTimeseriesFormData> = {},
) =>
createEchartsTimeseriesTestChartProps<
EchartsMixedTimeseriesFormData,
EchartsMixedTimeseriesProps
>({
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
defaultQueriesData: [weeklyQuery(queryA), weeklyQuery(queryB)],
formData: {
...formData,
groupby: [],
groupbyB: [],
timeGrainSqla: TimeGranularity.WEEK_STARTING_MONDAY,
...overrides,
},
queriesData: [weeklyQuery(queryA), weeklyQuery(queryB)],
});
test('pins ticks, labels and gridlines to the weekly buckets', () => {
const { xAxis } = transformProps(weeklyChartProps(MONDAYS, MONDAYS))
.echartOptions as any;
expect(xAxis.type).toBe(AxisType.Time);
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
// Gridlines follow axisTick.customValues, so splitLine needs no own copy.
expect(xAxis.axisTick.customValues).toEqual(MONDAYS);
expect(xAxis.splitLine).toBeUndefined();
});
test('keeps label thinning on when the labels are rotated', () => {
const { xAxis } = transformProps(
weeklyChartProps(MONDAYS, MONDAYS, { xAxisLabelRotation: 45 }),
).echartOptions as any;
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
expect(xAxis.axisLabel.hideOverlap).toBe(true);
});
test('keeps the showMaxLabel override at 0° rotation on pinned axes', () => {
// hideOverlap stays on for pinned ticks (they label every bucket), but
// showMaxLabel still shields the boundary label's immediate neighbour
// so the last bucket isn't silently dropped (#39899).
const { xAxis } = transformProps(weeklyChartProps(MONDAYS, MONDAYS))
.echartOptions as any;
expect(xAxis.axisLabel.showMaxLabel).toBe(true);
expect(xAxis.axisLabel.hideOverlap).toBe(true);
});
test('covers buckets contributed by either query', () => {
// The two queries share one axis, so a bucket present in only one of them
// still needs a tick.
const { xAxis } = transformProps(
weeklyChartProps(MONDAYS.slice(0, 3), MONDAYS.slice(2)),
).echartOptions as any;
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
});
test('leaves grains ECharts places correctly untouched', () => {
const { xAxis } = transformProps(
weeklyChartProps(MONDAYS, MONDAYS, {
timeGrainSqla: TimeGranularity.MONTH,
}),
).echartOptions as any;
expect(xAxis.axisLabel.customValues).toBeUndefined();
expect(xAxis.axisTick?.customValues).toBeUndefined();
});
});
function transformWithChrome(
overrides: Partial<EchartsMixedTimeseriesFormData>,
) {
@@ -23,10 +23,14 @@ import {
StackControlOptionsWithoutStream,
StackControlsValue,
} from '../../../src/constants';
import {
BarValueLabelPosition,
OrientationType,
} from '../../../src/Timeseries/types';
import { OrientationType } from '../../../src/Timeseries/types';
// Narrow shape of the control under test: enough to exercise `visibility`
// without reaching for `any`.
type VisibilityControl = {
name: string;
config: { visibility: (props: ControlPanelsContainerProps) => boolean };
};
const config = controlPanel;
@@ -135,41 +139,6 @@ test('should include stack control in the panel', () => {
expect(stackControl).toBeDefined();
});
test('should expose Auto and manual value label positions for Bar charts', () => {
const valueLabelPositionControl = getControl(
'value_label_position',
) as unknown as {
config: {
choices: [BarValueLabelPosition, string][];
default: BarValueLabelPosition;
visibility: (props: ControlPanelsContainerProps) => boolean;
};
};
expect(valueLabelPositionControl.config.default).toBe(
BarValueLabelPosition.OutsideEnd,
);
expect(
valueLabelPositionControl.config.choices.map(([value]) => value),
).toEqual([
BarValueLabelPosition.Auto,
BarValueLabelPosition.InsideEnd,
BarValueLabelPosition.OutsideEnd,
BarValueLabelPosition.InsideCenter,
BarValueLabelPosition.InsideBase,
]);
expect(
valueLabelPositionControl.config.visibility({
controls: { show_value: { value: true } },
} as unknown as ControlPanelsContainerProps),
).toBe(true);
expect(
valueLabelPositionControl.config.visibility({
controls: { show_value: { value: false } },
} as unknown as ControlPanelsContainerProps),
).toBe(false);
});
test('should use StackControlOptionsWithoutStream for stack control', () => {
const stackControl: any = getControl('stack');
expect(stackControl).toBeDefined();
@@ -330,3 +299,42 @@ test('x_axis_time_format should be hidden for numeric columns', () => {
false,
);
});
test('should have visibility function for label_position', () => {
const labelPositionCtrl = getControl(
'label_position',
) as unknown as VisibilityControl;
expect(labelPositionCtrl).toBeDefined();
expect(labelPositionCtrl.config.visibility).toBeDefined();
expect(typeof labelPositionCtrl.config.visibility).toBe('function');
expect(
labelPositionCtrl.config.visibility({
controls: {
show_value: { value: true },
show_valueB: { value: false },
},
} as unknown as ControlPanelsContainerProps),
).toBe(true);
// Visibility follows `show_value` alone. No Timeseries panel defines
// `show_valueB` — Mixed declares its own suffixed controls — so it must not
// reveal the control on its own.
expect(
labelPositionCtrl.config.visibility({
controls: {
show_value: { value: false },
show_valueB: { value: true },
},
} as unknown as ControlPanelsContainerProps),
).toBe(false);
expect(
labelPositionCtrl.config.visibility({
controls: {
show_value: { value: false },
show_valueB: { value: false },
},
} as unknown as ControlPanelsContainerProps),
).toBe(false);
});
@@ -29,7 +29,6 @@ import type {
GridComponentOption,
LegendComponentOption,
} from 'echarts/components';
import type { BarSeriesOption } from 'echarts/charts';
import {
EchartsTimeseriesChartProps,
LegendOrientation,
@@ -38,7 +37,6 @@ import {
import transformProps from '../../../src/Timeseries/transformProps';
import { DEFAULT_FORM_DATA } from '../../../src/Timeseries/constants';
import {
BarValueLabelPosition,
EchartsTimeseriesFormData,
OrientationType,
EchartsTimeseriesSeriesType,
@@ -76,106 +74,6 @@ function createTestQueryData(
};
}
test('manual Bar value label position flows through transformProps', () => {
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsTimeseriesFormData,
EchartsTimeseriesChartProps
>({
defaultFormData: DEFAULT_FORM_DATA,
defaultVizType: 'echarts_timeseries_bar',
formData: {
seriesType: EchartsTimeseriesSeriesType.Bar,
valueLabelPosition: BarValueLabelPosition.OutsideEnd,
metrics: ['Sales'],
xAxis: '__timestamp',
showValue: true,
},
queriesData: [
createTestQueryData([{ Sales: 100, __timestamp: 1609459200000 }], {
colnames: ['Sales', '__timestamp'],
coltypes: [GenericDataType.Numeric, GenericDataType.Temporal],
}),
],
});
const { echartOptions } = transformProps(chartProps);
const [series] = echartOptions.series as BarSeriesOption[];
expect(series.label).toMatchObject({ position: 'top' });
expect(series.labelLayout).toBeUndefined();
expect(echartOptions.darkMode).toBeUndefined();
});
test('Auto Bar labels enable theme-aware ECharts contrast', () => {
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsTimeseriesFormData,
EchartsTimeseriesChartProps
>({
defaultFormData: DEFAULT_FORM_DATA,
defaultVizType: 'echarts_timeseries_bar',
formData: {
seriesType: EchartsTimeseriesSeriesType.Bar,
valueLabelPosition: BarValueLabelPosition.Auto,
metrics: ['Sales'],
xAxis: '__timestamp',
showValue: true,
},
queriesData: [
createTestQueryData([{ Sales: 100, __timestamp: 1609459200000 }], {
colnames: ['Sales', '__timestamp'],
coltypes: [GenericDataType.Numeric, GenericDataType.Temporal],
}),
],
});
const { echartOptions } = transformProps(chartProps);
const [series] = echartOptions.series as BarSeriesOption[];
expect(typeof series.labelLayout).toBe('function');
expect(echartOptions.darkMode).toBe(false);
});
test('legacy Bar labels without a saved position keep their pre-existing Outside End placement', () => {
const legacyFormData: Partial<EchartsTimeseriesFormData> = {
...DEFAULT_FORM_DATA,
};
delete legacyFormData.valueLabelPosition;
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsTimeseriesFormData,
EchartsTimeseriesChartProps
>({
defaultFormData: legacyFormData as EchartsTimeseriesFormData,
defaultVizType: 'echarts_timeseries_bar',
formData: {
seriesType: EchartsTimeseriesSeriesType.Bar,
metrics: ['Sales'],
xAxis: '__timestamp',
showValue: true,
},
queriesData: [
createTestQueryData([{ Sales: 100, __timestamp: 1609459200000 }], {
colnames: ['Sales', '__timestamp'],
coltypes: [GenericDataType.Numeric, GenericDataType.Temporal],
}),
],
});
expect(chartProps.formData).not.toHaveProperty('valueLabelPosition');
const { echartOptions } = transformProps(chartProps);
const [series] = echartOptions.series as BarSeriesOption[];
expect(series.label).toMatchObject({ position: 'top' });
expect(series.labelLayout).toBeUndefined();
Reflect.set(chartProps.formData, 'valueLabelPosition', undefined);
const undefinedPositionOptions = transformProps(chartProps).echartOptions;
const [undefinedPositionSeries] =
undefinedPositionOptions.series as BarSeriesOption[];
expect(undefinedPositionSeries.label).toMatchObject({ position: 'top' });
expect(undefinedPositionSeries.labelLayout).toBeUndefined();
});
describe('Bar Chart X-axis Time Formatting', () => {
const baseFormData: SqlaFormData = {
...DEFAULT_FORM_DATA,
@@ -17,7 +17,6 @@
* under the License.
*/
import {
AnnotationData,
AnnotationSourceType,
AnnotationStyle,
AnnotationType,
@@ -2707,297 +2706,6 @@ describe('EchartsTimeseries tooltip truncation', () => {
});
});
describe('weekly x-axis tick alignment', () => {
// 13 Monday-aligned weekly buckets, the shape produced by a dataset that is
// pre-aggregated to weeks.
const WEEK_MS = 7 * 24 * 3600 * 1000;
const MONDAYS = Array.from(
{ length: 13 },
(_, i) => Date.UTC(2026, 3, 6) + i * WEEK_MS,
);
const weeklyChartProps = (
formDataOverrides: Partial<EchartsTimeseriesFormData> = {},
annotationData?: AnnotationData,
) =>
createTestChartProps({
annotationData,
formData: {
granularity_sqla: 'ds',
timeGrainSqla: TimeGranularity.WEEK_STARTING_MONDAY,
xAxisTimeFormat: '%m-%d',
...formDataOverrides,
},
queriesData: [
createTestQueryData(
MONDAYS.map((__timestamp, i) => ({ __timestamp, sales: 100 + i })),
{
colnames: ['__timestamp', 'sales'],
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
// transformProps reads annotations off the query, not chartProps.
...(annotationData && { annotation_data: annotationData }),
},
),
],
});
test('pins ticks, labels and gridlines to the weekly buckets', () => {
const { xAxis } = transformProps(weeklyChartProps()).echartOptions as any;
expect(xAxis.type).toBe(AxisType.Time);
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
// Gridlines follow axisTick.customValues, so splitLine needs no own copy.
expect(xAxis.axisTick.customValues).toEqual(MONDAYS);
expect(xAxis.splitLine).toBeUndefined();
});
const manyMondaysChartProps = (overrides: Record<string, unknown> = {}) => {
const manyMondays = Array.from(
{ length: 261 },
(_, i) => Date.UTC(2021, 0, 4) + i * WEEK_MS,
);
return {
manyMondays,
chartProps: createTestChartProps({
formData: {
granularity_sqla: 'ds',
timeGrainSqla: TimeGranularity.WEEK_STARTING_MONDAY,
xAxisTimeFormat: '%m-%d',
...overrides,
},
queriesData: [
createTestQueryData(
manyMondays.map((__timestamp, i) => ({
__timestamp,
sales: 100 + i,
})),
{
colnames: ['__timestamp', 'sales'],
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
},
),
],
}),
};
};
test('caps both axisTick and axisLabel customValues on a non-zoomable axis', () => {
// customValues never recomputes, so on a non-zoomable axis (no dataZoom
// to reach hidden buckets) axisLabel is capped to the same subset as
// axisTick: a label surviving hideOverlap thinning then always lands on
// a real tick and gridline rather than a capped-away bucket.
const { manyMondays, chartProps } = manyMondaysChartProps();
const { xAxis } = transformProps(chartProps).echartOptions as any;
expect(xAxis.axisTick.customValues.length).toBeLessThan(manyMondays.length);
expect(xAxis.axisLabel.customValues).toEqual(xAxis.axisTick.customValues);
});
test('keeps the full bucket set for axisLabel on a zoomable axis', () => {
// A capped, uncapped label set would freeze the visible labels to the
// pre-zoom subset since customValues never recomputes on dataZoom, so a
// zoomable axis keeps the full set for axisLabel and lets hideOverlap
// thin it dynamically; only axisTick (no such thinning) stays capped.
const { manyMondays, chartProps } = manyMondaysChartProps({
zoomable: true,
});
const { xAxis } = transformProps(chartProps).echartOptions as any;
expect(xAxis.axisTick.customValues.length).toBeLessThan(manyMondays.length);
expect(xAxis.axisLabel.customValues).toEqual(manyMondays);
});
test('keeps the showMaxLabel override at 0° rotation on pinned axes', () => {
// hideOverlap stays on for pinned ticks (they label every bucket), but
// showMaxLabel still shields the boundary label's immediate neighbour
// so the last bucket isn't silently dropped (#39899).
const { xAxis } = transformProps(weeklyChartProps()).echartOptions as any;
expect(xAxis.axisLabel.showMaxLabel).toBe(true);
expect(xAxis.axisLabel.hideOverlap).toBe(true);
});
test('pins ticks when the bucket column holds ISO date strings', () => {
// A dataset can arrive with __timestamp serialized as an ISO string
// rather than a Date/epoch-ms value.
const chartProps = createTestChartProps({
formData: {
granularity_sqla: 'ds',
timeGrainSqla: TimeGranularity.WEEK_STARTING_MONDAY,
},
queriesData: [
createTestQueryData(
MONDAYS.map((__timestamp, i) => ({
__timestamp: new Date(__timestamp).toISOString(),
sales: 100 + i,
})),
{
colnames: ['__timestamp', 'sales'],
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
},
),
],
});
const { xAxis } = transformProps(chartProps).echartOptions as any;
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
});
test('keeps label thinning on when the labels are rotated', () => {
// Rotation normally turns hideOverlap off, but pinned ticks put a label on
// every bucket, so without thinning a multi-year range draws hundreds.
const { xAxis } = transformProps(
weeklyChartProps({ xAxisLabelRotation: 45 }),
).echartOptions as any;
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
expect(xAxis.axisLabel.hideOverlap).toBe(true);
});
test('leaves rotation thinning alone when the ticks are not pinned', () => {
const { xAxis } = transformProps(
weeklyChartProps({
timeGrainSqla: TimeGranularity.MONTH,
xAxisLabelRotation: 45,
}),
).echartOptions as any;
expect(xAxis.axisLabel.customValues).toBeUndefined();
expect(xAxis.axisLabel.hideOverlap).toBe(false);
});
const timeseriesLayer = (show: boolean) =>
({
name: 'my annotation',
annotationType: AnnotationType.Timeseries,
sourceType: AnnotationSourceType.Line,
style: AnnotationStyle.Solid,
show,
value: 1,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any;
// The annotation's own timestamps run a year past the last bucket.
const annotationRecords = {
'my annotation': {
records: [
{ ds: MONDAYS[0], y: 1 },
{ ds: MONDAYS[12] + 52 * WEEK_MS, y: 2 },
],
},
};
test('does not pin ticks when a timeseries annotation widens the axis', () => {
// A Time axis takes no min/max, so it stretches to cover the annotation
// while ECharts clips pinned ticks to the extent — that span would be bare.
const { xAxis } = transformProps(
weeklyChartProps(
{ annotationLayers: [timeseriesLayer(true)] },
annotationRecords,
),
).echartOptions as any;
expect(xAxis.axisLabel.customValues).toBeUndefined();
expect(xAxis.axisTick?.customValues).toBeUndefined();
});
test('still pins ticks for a hidden timeseries annotation', () => {
const { xAxis } = transformProps(
weeklyChartProps(
{ annotationLayers: [timeseriesLayer(false)] },
annotationRecords,
),
).echartOptions as any;
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
});
test.each([
TimeGranularity.WEEK,
TimeGranularity.WEEK_STARTING_SUNDAY,
TimeGranularity.WEEK_STARTING_MONDAY,
TimeGranularity.WEEK_ENDING_SATURDAY,
TimeGranularity.WEEK_ENDING_SUNDAY,
])('applies to the %s grain', grain => {
const { xAxis } = transformProps(weeklyChartProps({ timeGrainSqla: grain }))
.echartOptions as any;
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
});
test('a dashboard time-grain override drives the alignment', () => {
const { xAxis } = transformProps(
weeklyChartProps({
timeGrainSqla: TimeGranularity.DAY,
extraFormData: { time_grain_sqla: TimeGranularity.WEEK },
}),
).echartOptions as any;
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
});
test('deduplicates and sorts the bucket timestamps', () => {
// A grouped query repeats each bucket once per series, and the rows are
// not necessarily ordered.
const chartProps = createTestChartProps({
formData: {
granularity_sqla: 'ds',
timeGrainSqla: TimeGranularity.WEEK,
groupby: ['region'],
},
queriesData: [
createTestQueryData(
[
{ __timestamp: MONDAYS[1], region: 'b', sales: 2 },
{ __timestamp: MONDAYS[0], region: 'a', sales: 1 },
{ __timestamp: MONDAYS[1], region: 'a', sales: 3 },
{ __timestamp: MONDAYS[0], region: 'b', sales: 4 },
],
{
colnames: ['__timestamp', 'region', 'sales'],
coltypes: [
GenericDataType.Temporal,
GenericDataType.String,
GenericDataType.Numeric,
],
},
),
],
});
const { xAxis } = transformProps(chartProps).echartOptions as any;
expect(xAxis.axisLabel.customValues).toEqual([MONDAYS[0], MONDAYS[1]]);
});
test('leaves grains ECharts places correctly untouched', () => {
(
[
TimeGranularity.DAY,
TimeGranularity.MONTH,
TimeGranularity.QUARTER,
TimeGranularity.YEAR,
undefined,
] as const
).forEach(grain => {
const { xAxis } = transformProps(
weeklyChartProps({ timeGrainSqla: grain }),
).echartOptions as any;
expect(xAxis.axisLabel.customValues).toBeUndefined();
expect(xAxis.axisTick?.customValues).toBeUndefined();
});
});
test('leaves a categorical x-axis untouched', () => {
const { xAxis } = transformProps(
weeklyChartProps({ xAxisForceCategorical: true }),
).echartOptions as any;
expect(xAxis.type).toBe(AxisType.Category);
expect(xAxis.axisLabel.customValues).toBeUndefined();
});
});
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'];
@@ -3132,44 +2840,6 @@ test('applies gridlines to the value axis after a horizontal orientation swaps i
expect((echartOptions.xAxis as any).splitLine.show).toBe(false);
});
test('#39899 - horizontal orientation does not over-thin the time axis labels', () => {
// The spacing formatter estimates label collisions using horizontal plot
// geometry (width, 7px/char). A horizontal chart swaps the time axis onto
// the side of the chart, where that geometry no longer applies, so the
// spacing formatter must not be used there.
const monthData = Array.from({ length: 24 }, (_, i) => ({
__timestamp: Date.UTC(2020, i, 1),
sales: i,
}));
const { echartOptions } = transformProps(
createTestChartProps({
formData: {
granularity_sqla: 'ds',
timeGrainSqla: TimeGranularity.MONTH,
xAxisTimeFormat: '%Y-%m',
seriesType: EchartsTimeseriesSeriesType.Bar,
orientation: OrientationType.Horizontal,
},
width: 800,
queriesData: [
createTestQueryData(monthData, {
colnames: ['__timestamp', 'sales'],
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
}),
],
}),
);
// Horizontal swaps the axes, so the time axis ends up as yAxis.
const { axisLabel } = echartOptions.yAxis as Record<string, any>;
const labels = monthData.map(({ __timestamp }) =>
axisLabel.formatter(__timestamp),
);
// Every month is a distinct label, so none should be blanked by the
// spacing/dedup formatter on a horizontal chart.
expect(labels.filter(label => label === '')).toHaveLength(0);
});
test('boundary label alignment is dropped when the orientation moves the time axis to the side', () => {
// The alignments position labels against the left and right edges of a
// bottom axis. A horizontal chart swaps the axes, so applying them there
@@ -19,19 +19,14 @@
import {
CategoricalColorScale,
ChartProps,
NumberFormatter,
TimeGranularity,
getNumberFormatter,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { supersetTheme } from '@apache-superset/core/theme';
import { init, type SeriesOption } from 'echarts';
import type {
BarSeriesOption,
LineSeriesOption,
ScatterSeriesOption,
} from 'echarts/charts';
import { BarValueLabelPosition, EchartsTimeseriesSeriesType } from '../../src';
import type { SeriesOption } from 'echarts';
import type { ScatterSeriesOption } from 'echarts/charts';
import { EchartsTimeseriesSeriesType } from '../../src';
import { StackControlsValue, TIMESERIES_CONSTANTS } from '../../src/constants';
import {
LegendOrientation,
@@ -40,7 +35,6 @@ import {
import {
transformSeries,
transformNegativeLabelsPosition,
getAutoBarLabelLayout,
getPadding,
} from '../../src/Timeseries/transformers';
import transformProps from '../../src/Timeseries/transformProps';
@@ -165,484 +159,8 @@ describe('transformSeries', () => {
expect((result as ScatterSeriesOption).symbolSize).toBe(7);
});
test('does not render a per-series stacked label for a zero-value segment (#42702)', () => {
const opts = {
seriesType: EchartsTimeseriesSeriesType.Bar,
stack: true,
onlyTotal: false,
isHorizontal: false,
timeShiftColor: false,
// percentage_threshold defaults to 0, so thresholdValues[dataIndex] is
// 0 too — a value of exactly 0 would satisfy `numericValue >= (thresholdValues[dataIndex] || Number.MIN_SAFE_INTEGER)`
// without the explicit `numericValue !== 0` guard.
thresholdValues: [0],
formatter: new NumberFormatter({
id: 'test-formatter',
formatFunc: (value: number) => `${value}`,
}),
};
const result = transformSeries(series, mockColorScale, 'test-key', opts);
const { formatter: labelFormatter } = (result as any).label;
const zeroValueLabel = labelFormatter({
value: [null, 0],
dataIndex: 0,
seriesIndex: 0,
seriesName: 'test-series',
});
expect(zeroValueLabel).toBe('');
const nonZeroValueLabel = labelFormatter({
value: [null, 32],
dataIndex: 0,
seriesIndex: 0,
seriesName: 'test-series',
});
expect(nonZeroValueLabel).toBe('32');
});
test('still renders a per-series stacked label for a genuine negative value that clears the threshold', () => {
const opts = {
seriesType: EchartsTimeseriesSeriesType.Bar,
stack: true,
onlyTotal: false,
isHorizontal: false,
timeShiftColor: false,
// A category whose stacked total is itself negative produces a
// negative threshold — a strictly-positive check would wrongly
// suppress a real, meaningful negative-value label here.
thresholdValues: [-10],
formatter: new NumberFormatter({
id: 'test-formatter',
formatFunc: (value: number) => `${value}`,
}),
};
const result = transformSeries(series, mockColorScale, 'test-key', opts);
const { formatter: labelFormatter } = (result as any).label;
const negativeValueLabel = labelFormatter({
value: [null, -5],
dataIndex: 0,
seriesIndex: 0,
seriesName: 'test-series',
});
expect(negativeValueLabel).toBe('-5');
});
});
test('Auto bar labels move outside narrow stacked segments', () => {
const result = transformSeries(
{ name: 'test-series', type: 'bar', data: [[2026, 1]] },
mockColorScale,
'test-key',
{
seriesType: EchartsTimeseriesSeriesType.Bar,
stack: StackControlsValue.Stack,
showValue: true,
},
) as BarSeriesOption;
const { labelLayout } = result;
expect(result.label).toMatchObject({
show: true,
position: 'insideTop',
});
expect((result.label as { color?: string }).color).toBeUndefined();
expect(typeof labelLayout).toBe('function');
if (typeof labelLayout !== 'function') return;
expect(
labelLayout({
dataIndex: 0,
seriesIndex: 0,
text: '1,000',
align: 'center',
verticalAlign: 'middle',
rect: { x: 10, y: 20, width: 12, height: 20 },
labelRect: { x: 1, y: 22, width: 30, height: 14 },
}),
).toEqual({
x: 16,
y: 15,
align: 'center',
verticalAlign: 'bottom',
});
});
test('Auto labels stay inside when both dimensions fit within 80% of the bar', () => {
const result = transformSeries(
{ name: 'test-series', type: 'bar', data: [[2026, 1]] },
mockColorScale,
'test-key',
{ seriesType: EchartsTimeseriesSeriesType.Bar },
) as BarSeriesOption;
const { labelLayout } = result;
expect(typeof labelLayout).toBe('function');
if (typeof labelLayout !== 'function') return;
expect(
labelLayout({
dataIndex: 0,
seriesIndex: 0,
text: '1,000',
align: 'center',
verticalAlign: 'top',
rect: { x: 10, y: 20, width: 50, height: 40 },
labelRect: { x: 19, y: 25, width: 32, height: 14 },
}),
).toEqual({});
});
test('Auto moves wide labels outside tall narrow vertical bars', () => {
const result = transformSeries(
{ name: 'test-series', type: 'bar', data: [[2026, 100]] },
mockColorScale,
'test-key',
{ seriesType: EchartsTimeseriesSeriesType.Bar },
) as BarSeriesOption;
const { labelLayout } = result;
expect(typeof labelLayout).toBe('function');
if (typeof labelLayout !== 'function') return;
expect(
labelLayout({
dataIndex: 0,
seriesIndex: 0,
text: '1,000',
align: 'center',
verticalAlign: 'top',
rect: { x: 10, y: 20, width: 12, height: 200 },
labelRect: { x: 1, y: 25, width: 30, height: 14 },
}),
).toEqual({
x: 16,
y: 15,
align: 'center',
verticalAlign: 'bottom',
});
});
test('Auto overflow uses ECharts outside-label text color', () => {
const darkBarColorScale = jest.fn(() => '#111111');
const series = transformSeries(
{ name: 'test-series', type: 'bar', data: [[0, 123456789012]] },
darkBarColorScale as unknown as CategoricalColorScale,
'test-key',
{
formatter: getNumberFormatter('d'),
seriesType: EchartsTimeseriesSeriesType.Bar,
showValue: true,
},
) as BarSeriesOption;
const chart = init(null, null, {
renderer: 'svg',
ssr: true,
width: 300,
height: 220,
});
chart.setOption({
animation: false,
darkMode: false,
xAxis: { type: 'category', data: ['A'], show: false },
// A tall bar (well above the segment-legibility floor) whose 12-digit
// label is too wide to fit inside, so ECharts still moves it outside.
yAxis: { type: 'value', max: 250_000_000_000, show: false },
series: [series],
});
expect(chart.renderToSVGString()).toMatch(
/fill="#333"[^>]*>123456789012<\/text>/,
);
chart.dispose();
});
test('Auto bar labels use horizontal bar length and move to the value end', () => {
const result = transformSeries(
{ name: 'test-series', type: 'bar', data: [[1, 2026]] },
mockColorScale,
'test-key',
{ seriesType: EchartsTimeseriesSeriesType.Bar, isHorizontal: true },
) as BarSeriesOption;
const { labelLayout } = result;
expect(typeof labelLayout).toBe('function');
if (typeof labelLayout !== 'function') return;
expect(
labelLayout({
dataIndex: 0,
seriesIndex: 0,
text: '1,000',
align: 'right',
verticalAlign: 'middle',
rect: { x: 10, y: 20, width: 20, height: 12 },
labelRect: { x: 0, y: 19, width: 30, height: 14 },
}),
).toEqual({
x: 35,
y: 26,
align: 'left',
verticalAlign: 'middle',
});
});
test.each([
[BarValueLabelPosition.InsideEnd, 'insideTop'],
[BarValueLabelPosition.OutsideEnd, 'top'],
[BarValueLabelPosition.InsideCenter, 'inside'],
[BarValueLabelPosition.InsideBase, 'insideBottom'],
] as const)(
'manual %s bar labels use fixed position %s',
(position, expected) => {
const result = transformSeries(
{ name: 'test-series', type: 'bar', data: [[2026, 1]] },
mockColorScale,
'test-key',
{
seriesType: EchartsTimeseriesSeriesType.Bar,
valueLabelPosition: position,
theme: supersetTheme,
},
) as BarSeriesOption;
expect(result.labelLayout).toBeUndefined();
expect(result.label).toMatchObject({ position: expected });
if (position === BarValueLabelPosition.OutsideEnd) {
expect(result.label).toMatchObject({ color: supersetTheme.colorText });
} else {
expect(result.label).not.toHaveProperty('color');
}
},
);
test('manual Outside End positions negative stacked segments below the bar', () => {
const result = transformSeries(
{ name: 'test-series', type: 'bar', data: [[2026, -1]] },
mockColorScale,
'test-key',
{
seriesType: EchartsTimeseriesSeriesType.Bar,
stack: StackControlsValue.Stack,
valueLabelPosition: BarValueLabelPosition.OutsideEnd,
},
) as BarSeriesOption;
expect(result.data).toEqual([
{
value: [2026, -1],
label: { position: 'bottom' },
},
]);
expect(result.labelLayout).toBeUndefined();
});
test('Auto positions negative stacked segments at their inside end', () => {
const result = transformSeries(
{ name: 'test-series', type: 'bar', data: [[2026, -1]] },
mockColorScale,
'test-key',
{
seriesType: EchartsTimeseriesSeriesType.Bar,
stack: StackControlsValue.Stack,
},
) as BarSeriesOption;
expect(result.data).toEqual([
{
value: [2026, -1],
label: { position: 'insideBottom' },
},
]);
expect(typeof result.labelLayout).toBe('function');
if (typeof result.labelLayout !== 'function') return;
expect(
result.labelLayout({
dataIndex: 0,
seriesIndex: 0,
text: '-1,000',
align: 'center',
verticalAlign: 'bottom',
rect: { x: 10, y: 20, width: 12, height: 30 },
labelRect: { x: 1, y: 35, width: 30, height: 14 },
}),
).toEqual({
x: 16,
y: 55,
align: 'center',
verticalAlign: 'top',
});
});
test('Auto moves horizontal negative labels beyond their value end', () => {
const result = transformSeries(
{ name: 'test-series', type: 'bar', data: [[-1, 2026]] },
mockColorScale,
'test-key',
{ seriesType: EchartsTimeseriesSeriesType.Bar, isHorizontal: true },
) as BarSeriesOption;
expect(result.data).toEqual([
{
value: [-1, 2026],
label: { position: 'insideLeft' },
},
]);
expect(typeof result.labelLayout).toBe('function');
if (typeof result.labelLayout !== 'function') return;
expect(
result.labelLayout({
dataIndex: 0,
seriesIndex: 0,
text: '-1,000',
align: 'left',
verticalAlign: 'middle',
rect: { x: 10, y: 20, width: 20, height: 12 },
labelRect: { x: 10, y: 19, width: 30, height: 14 },
}),
).toEqual({
x: 5,
y: 26,
align: 'right',
verticalAlign: 'middle',
});
});
test('Auto label layout does not change non-Bar series', () => {
const result = transformSeries(
{ name: 'test-series', type: 'line', data: [[2026, 1]] },
mockColorScale,
'test-key',
{
seriesType: EchartsTimeseriesSeriesType.Line,
theme: supersetTheme,
},
) as LineSeriesOption;
expect(result).not.toHaveProperty('labelLayout');
expect(result.label).toMatchObject({
position: 'top',
color: supersetTheme.colorText,
});
});
test('Auto suppresses the label for a vertical segment below the legibility floor', () => {
// A 10px-tall stacked segment can't legibly fit its 14px-tall label inside
// or outside without colliding with a neighboring segment's label.
expect(
getAutoBarLabelLayout(
{
dataIndex: 0,
seriesIndex: 0,
text: '0.14',
align: 'center',
verticalAlign: 'middle',
rect: { x: 10, y: 20, width: 40, height: 10 },
labelRect: { x: 12, y: 22, width: 20, height: 14 },
},
false,
),
).toEqual({ fontSize: 0 });
});
test('Auto keeps placing labels normally for a vertical segment at the legibility floor', () => {
expect(
getAutoBarLabelLayout(
{
dataIndex: 0,
seriesIndex: 0,
text: '0.14',
align: 'center',
verticalAlign: 'middle',
rect: { x: 10, y: 20, width: 40, height: 16 },
labelRect: { x: 12, y: 22, width: 20, height: 14 },
},
false,
),
).not.toEqual({ fontSize: 0 });
});
test('Auto suppresses the label for a horizontal segment below the legibility floor', () => {
// Horizontal bars stack along the x axis, so the value-axis dimension that
// matters is rect.width rather than rect.height.
expect(
getAutoBarLabelLayout(
{
dataIndex: 0,
seriesIndex: 0,
text: '0.14',
align: 'left',
verticalAlign: 'middle',
rect: { x: 10, y: 20, width: 10, height: 40 },
labelRect: { x: 12, y: 22, width: 20, height: 14 },
},
true,
),
).toEqual({ fontSize: 0 });
});
test('Auto suppresses labels for tiny adjacent stacked segments end to end', () => {
const result = transformSeries(
{ name: 'test-series', type: 'bar', data: [[2026, 0.14]] },
mockColorScale,
'test-key',
{
seriesType: EchartsTimeseriesSeriesType.Bar,
stack: StackControlsValue.Stack,
showValue: true,
},
) as BarSeriesOption;
const { labelLayout } = result;
expect(typeof labelLayout).toBe('function');
if (typeof labelLayout !== 'function') return;
expect(
labelLayout({
dataIndex: 0,
seriesIndex: 0,
text: '0.14',
align: 'center',
verticalAlign: 'middle',
rect: { x: 10, y: 20, width: 40, height: 8 },
labelRect: { x: 12, y: 22, width: 20, height: 14 },
}),
).toEqual({ fontSize: 0 });
});
test.each([
[BarValueLabelPosition.InsideEnd, 'insideTop'],
[BarValueLabelPosition.OutsideEnd, 'top'],
[BarValueLabelPosition.InsideCenter, 'inside'],
[BarValueLabelPosition.InsideBase, 'insideBottom'],
] as const)(
'manual %s label placement is unaffected by tiny segments (no labelLayout applied)',
(position, expected) => {
const result = transformSeries(
{ name: 'test-series', type: 'bar', data: [[2026, 0.14]] },
mockColorScale,
'test-key',
{
seriesType: EchartsTimeseriesSeriesType.Bar,
stack: StackControlsValue.Stack,
valueLabelPosition: position,
showValue: true,
theme: supersetTheme,
},
) as BarSeriesOption;
// Manual positions don't use the fit-aware labelLayout callback at all,
// so a tiny segment can't trigger the Auto-only suppression behavior.
expect(result.labelLayout).toBeUndefined();
expect(result.label).toMatchObject({ position: expected });
},
);
describe('transformNegativeLabelsPosition', () => {
test('label position bottom of negative value no Horizontal', () => {
const isHorizontal = false;
@@ -834,55 +352,6 @@ test('#39899 - x-axis dates do not overlap and last label stays visible at 0° r
expect(axisLabel.hideOverlap).toBe(false);
});
test('#39899 - closely spaced x-axis time labels do not visually overlap', () => {
const formData = {
colorScheme: 'bnbColors',
datasource: '3__table',
granularity_sqla: 'ds',
timeGrainSqla: TimeGranularity.MINUTE,
x_axis_time_format: '%Y-%m-%d %H:%M:%S',
metric: 'sum__num',
viz_type: 'my_viz',
};
const startTime = new Date('2026-01-01T00:00:00Z').getTime();
const data = Array.from({ length: 20 }, (_, i) => ({
sum__num: i,
__timestamp: startTime + i * 60 * 1000,
}));
const chartProps = new ChartProps({
formData,
width: 300,
height: 400,
queriesData: [
{
data,
colnames: ['sum__num', '__timestamp'],
coltypes: [GenericDataType.Numeric, GenericDataType.Temporal],
},
],
theme: supersetTheme,
});
const result = transformProps(
chartProps as unknown as EchartsTimeseriesChartProps,
);
const { axisLabel } = result.echartOptions.xAxis as Record<string, any>;
const labels = data.map(({ __timestamp }) =>
axisLabel.formatter(__timestamp),
);
// hideOverlap must stay off so ECharts' own collision detection can never
// suppress the forced boundary label (#39899 must not regress).
expect(axisLabel.hideOverlap).toBe(false);
// The formatter itself must thin out labels that are too close together to
// render legibly in the available width.
expect(labels.filter(label => label === '').length).toBeGreaterThan(0);
// The first and last labels are the forced axis boundaries and must always
// stay visible.
expect(labels[0]).not.toBe('');
expect(labels[labels.length - 1]).not.toBe('');
});
test('last x-axis date is visible and not cut off when rotated -45°', () => {
const lastDataPointTimestamp = new Date('2026-12-01').getTime();
const result = transformProps(
@@ -22,7 +22,6 @@ import {
DataRecord,
getNumberFormatter,
getTimeFormatter,
TimeGranularity,
} from '@superset-ui/core';
import { supersetTheme as theme } from '@apache-superset/core/theme';
import { GenericDataType } from '@apache-superset/core/common';
@@ -41,8 +40,6 @@ import {
getLegendProps,
getOverMaxHiddenFormatter,
getMinAndMaxFromBounds,
capTickMarks,
getTemporalTickValues,
sanitizeHtml,
sortAndFilterSeries,
sortRows,
@@ -1708,148 +1705,6 @@ test('getAxisType does not coerce Numeric x-axis to Time regardless of values',
);
});
describe('getTemporalTickValues', () => {
const xAxisLabel = '__timestamp';
test('returns undefined for a non-time axis', () => {
const data: DataRecord[] = [{ [xAxisLabel]: 1712361600000 }];
expect(
getTemporalTickValues(
data,
xAxisLabel,
AxisType.Category,
TimeGranularity.WEEK,
),
).toBeUndefined();
});
test('returns undefined when there is no time grain', () => {
const data: DataRecord[] = [{ [xAxisLabel]: 1712361600000 }];
expect(
getTemporalTickValues(data, xAxisLabel, AxisType.Time, undefined),
).toBeUndefined();
});
test('returns undefined for a non-weekly time grain', () => {
const data: DataRecord[] = [{ [xAxisLabel]: 1712361600000 }];
expect(
getTemporalTickValues(
data,
xAxisLabel,
AxisType.Time,
TimeGranularity.MONTH,
),
).toBeUndefined();
});
test('returns sorted, de-duplicated bucket timestamps for numbers and Dates', () => {
const t0 = Date.UTC(2026, 3, 6);
const t1 = Date.UTC(2026, 3, 13);
const data: DataRecord[] = [
{ [xAxisLabel]: t1 },
{ [xAxisLabel]: new Date(t0) },
{ [xAxisLabel]: t0 }, // duplicate of the Date row above
];
expect(
getTemporalTickValues(
data,
xAxisLabel,
AxisType.Time,
TimeGranularity.WEEK,
),
).toEqual([t0, t1]);
});
test('parses a zoned ISO string as the instant it names', () => {
const data: DataRecord[] = [{ [xAxisLabel]: '2026-04-06T00:00:00.000Z' }];
expect(
getTemporalTickValues(
data,
xAxisLabel,
AxisType.Time,
TimeGranularity.WEEK,
),
).toEqual([Date.UTC(2026, 3, 6)]);
});
test('parses a zone-less datetime string as local time, matching ECharts', () => {
const data: DataRecord[] = [{ [xAxisLabel]: '2026-04-06T00:00:00' }];
expect(
getTemporalTickValues(
data,
xAxisLabel,
AxisType.Time,
TimeGranularity.WEEK,
),
).toEqual([new Date(2026, 3, 6, 0, 0, 0).getTime()]);
});
test('parses a bare date string as local midnight, matching ECharts rather than native Date', () => {
// `new Date('2026-04-06')` is UTC, but ECharts parses it as local time.
// jest.config.js fixes the test TZ to America/New_York, so they disagree.
const data: DataRecord[] = [{ [xAxisLabel]: '2026-04-06' }];
const localMidnight = new Date(2026, 3, 6).getTime();
expect(localMidnight).not.toEqual(new Date('2026-04-06').getTime());
expect(
getTemporalTickValues(
data,
xAxisLabel,
AxisType.Time,
TimeGranularity.WEEK,
),
).toEqual([localMidnight]);
});
test('drops unparseable or nullish values and returns undefined when none remain', () => {
const data: DataRecord[] = [
{ [xAxisLabel]: 'not-a-date' },
{ [xAxisLabel]: null },
];
expect(
getTemporalTickValues(
data,
xAxisLabel,
AxisType.Time,
TimeGranularity.WEEK,
),
).toBeUndefined();
});
});
describe('capTickMarks', () => {
test('returns values unchanged when within the cap', () => {
const values = [1, 2, 3];
expect(capTickMarks(values, 60)).toEqual(values);
});
test('downsamples to every step-th value when the last value already lands on the step', () => {
const values = Array.from({ length: 261 }, (_, i) => i);
// step = ceil(261 / 60) = 5, and 260 is already a multiple of 5, so
// nothing needs to be appended for the last bucket.
expect(capTickMarks(values, 60)).toEqual(
Array.from({ length: 53 }, (_, i) => i * 5),
);
});
test('appends the last value when it does not land on the step', () => {
const values = Array.from({ length: 262 }, (_, i) => i);
// step = ceil(262 / 60) = 5, stepping lands on 0..260, and the true last
// value (261) is appended on top since it isn't a multiple of 5.
expect(capTickMarks(values, 60)).toEqual([
...Array.from({ length: 53 }, (_, i) => i * 5),
261,
]);
});
test('maxTicks is not a hard bound once the last value has to be appended', () => {
const values = Array.from({ length: 300 }, (_, i) => i);
// step = ceil(300 / 60) = 5, which already lands on 60 stepped values
// (0..295) plus the appended last value (299), totaling 61 — one over
// maxTicks. Keeping the true last bucket wins over a hard cap.
expect(capTickMarks(values, 60)).toHaveLength(61);
});
});
test('getMinAndMaxFromBounds returns empty object when not truncating', () => {
expect(
getMinAndMaxFromBounds(
@@ -270,9 +270,6 @@ const config: ControlPanelConfig = {
config: {
...sharedControls.time_grain_sqla,
visibility: ({ controls }) => {
if (!isAggMode({ controls })) {
return false;
}
const dttmLookup = Object.fromEntries(
ensureIsArray(controls?.groupby?.options).map(option => [
(option.column_name || '').toLowerCase(),
@@ -17,7 +17,6 @@ import {
ControlState,
CustomControlItem,
} from '@superset-ui/chart-controls';
import { QueryMode } from '@superset-ui/core';
import config from '../src/controlPanel';
type VisibilityFn = (
@@ -60,12 +59,10 @@ function mkProps(
{ column_name: 'ORDERDATE', is_dttm: true },
{ column_name: 'some_other_col', is_dttm: false },
],
queryMode?: QueryMode,
): ControlPanelsContainerProps {
return {
controls: {
groupby: { value: groupbyValue, options },
...(queryMode ? { query_mode: { value: queryMode } } : {}),
},
} as unknown as ControlPanelsContainerProps;
}
@@ -78,31 +75,3 @@ test('time_grain_sqla visibility should be case-insensitive', () => {
expect(vis(mkProps(['ORDERDATE']), controlState)).toBe(true);
expect(vis(mkProps(['some_other_col']), controlState)).toBe(false);
});
test('time_grain_sqla visibility is false in raw records mode even with a stale temporal groupby value', () => {
const vis = getVisibility(config, 'time_grain_sqla');
const controlState = {} as ControlState;
// Simulates switching from an aggregated Line chart (groupby set to a
// temporal column) to Table's Raw Records mode: groupby's value survives
// the switch (its own visibility uses resetOnHide: false), but the query
// is no longer aggregated, so time_grain_sqla must not stay visible/applied.
expect(
vis(mkProps(['orderdate'], undefined, QueryMode.Raw), controlState),
).toBe(false);
});
test('time_grain_sqla visibility still requires aggregate mode plus a temporal groupby column', () => {
const vis = getVisibility(config, 'time_grain_sqla');
const controlState = {} as ControlState;
expect(
vis(mkProps(['orderdate'], undefined, QueryMode.Aggregate), controlState),
).toBe(true);
expect(
vis(
mkProps(['some_other_col'], undefined, QueryMode.Aggregate),
controlState,
),
).toBe(false);
});
@@ -18,7 +18,7 @@
*/
import fetchMock from 'fetch-mock';
import { FeatureFlag, isFeatureEnabled, QueryState } from '@superset-ui/core';
import { render, screen, waitFor, within } from 'spec/helpers/testing-library';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import QueryHistory from 'src/SqlLab/components/QueryHistory';
import {
initialState,
@@ -252,247 +252,6 @@ test('displays multiple queries with newest query first', async () => {
isFeatureEnabledMock.mockClear();
});
// `sql` is never part of the merge's overlay bundle, so a merged row's `sql`
// always comes from the `{...remoteQuery}` base, whether or not an override
// happened. Every live-only Redux fixture below uses `sql: 'SELECT 1'`,
// while the backend snapshot uses this distinctive query text - so this can
// only resolve once the backend response has actually loaded *and* been
// folded into the rendered row, unlike `waitFor(() => calls.length === 1)`,
// which resolves as soon as the request is issued, while `data` is still
// `undefined` and the component is still rendering the pre-merge,
// Redux-only fallback. Deliberately not a Duration-cell/`endDttm` barrier:
// a real Redux row that has concluded always has an `endDttm` (see
// `QUERY_SUCCESS` in `reducers/sqlLab.ts`), so that barrier would silently
// go vacuous the moment a fixture became realistic about timestamps.
const findRemoteSqlCell = () => screen.findByText(/FCC 2018 Survey/);
// The barrier above holds only while the live fixture's sql differs from the
// snapshot's. If they ever match, findRemoteSqlCell() resolves pre-merge and
// every assertion after it goes vacuous. Fail loudly rather than silently.
const assertLiveSqlDiffersFromSnapshot = (q: { sql: string }) =>
expect(q.sql).not.toMatch(/FCC 2018 Survey/);
test('overrides a stale non-concluded backend snapshot with a concluded live Redux state', async () => {
const isFeatureEnabledMock = mockedIsFeatureEnabled.mockImplementation(
featureFlag => featureFlag === FeatureFlag.SqllabBackendPersistence,
);
// A non-concluded row's `end_time` is never set by the backend (every
// write of `end_time` is paired with a concluded status - see
// `superset/sql_lab.py` and `superset/daos/query.py`). Note this maps to
// `endDttm: 0`, not `undefined` - `mapQueryResponse` does
// `Number(query.end_time)` and `Number(null) === 0`.
const staleApiResult = {
count: 1,
ids: [692],
result: [
{
...fakeApiResult.result[0],
client_id: 'stuckClientId',
status: QueryState.Running,
progress: 0,
rows: 0,
end_time: null,
sql_editor_id: defaultQueryEditor.id,
},
],
};
const editorQueryApiRoute = `glob:*/api/v1/query/?q=*`;
fetchMock.get(editorQueryApiRoute, staleApiResult);
const stateWithLiveQuery = {
...initialState,
sqlLab: {
...initialState.sqlLab,
queries: {
stuckClientId: {
id: 'stuckClientId',
sqlEditorId: defaultQueryEditor.id,
sql: 'SELECT 1',
state: QueryState.Success,
startDttm: 1710273662445,
// A real Redux row at Success always has an endDttm too -
// QUERY_SUCCESS sets both together.
endDttm: 1710273662500,
progress: 100,
rows: 443,
},
},
},
};
assertLiveSqlDiffersFromSnapshot(
stateWithLiveQuery.sqlLab.queries.stuckClientId,
);
render(setup(), { useRedux: true, initialState: stateWithLiveQuery });
await waitFor(() =>
expect(fetchMock.callHistory.calls(editorQueryApiRoute).length).toBe(1),
);
await findRemoteSqlCell();
const row = screen.getByText('443').closest('tr') as HTMLElement;
expect(within(row).getByLabelText('check')).toBeInTheDocument();
expect(within(row).queryByLabelText('loading')).not.toBeInTheDocument();
isFeatureEnabledMock.mockClear();
});
test('does not override an already-concluded backend snapshot with a non-concluded Redux state', async () => {
const isFeatureEnabledMock = mockedIsFeatureEnabled.mockImplementation(
featureFlag => featureFlag === FeatureFlag.SqllabBackendPersistence,
);
const concludedApiResult = {
count: 1,
ids: [692],
result: [
{
...fakeApiResult.result[0],
client_id: 'scheduledClientId',
status: QueryState.Success,
progress: 100,
rows: 443,
sql_editor_id: defaultQueryEditor.id,
},
],
};
const editorQueryApiRoute = `glob:*/api/v1/query/?q=*`;
fetchMock.get(editorQueryApiRoute, concludedApiResult);
// Redux hasn't observed this query conclude yet: it's still Scheduled.
// Deliberately not Running/Pending with progress 0, which is the tuple
// CLEAR_INACTIVE_QUERIES evicts once stale - that combination can't
// actually reach this merge in production.
const stateWithScheduledQuery = {
...initialState,
sqlLab: {
...initialState.sqlLab,
queries: {
scheduledClientId: {
id: 'scheduledClientId',
sqlEditorId: defaultQueryEditor.id,
sql: 'SELECT 1',
state: QueryState.Scheduled,
startDttm: 1710273662445,
progress: 0,
rows: 0,
},
},
},
};
assertLiveSqlDiffersFromSnapshot(
stateWithScheduledQuery.sqlLab.queries.scheduledClientId,
);
render(setup(), { useRedux: true, initialState: stateWithScheduledQuery });
await waitFor(() =>
expect(fetchMock.callHistory.calls(editorQueryApiRoute).length).toBe(1),
);
await findRemoteSqlCell();
const row = screen.getByText('443').closest('tr') as HTMLElement;
expect(within(row).getByLabelText('check')).toBeInTheDocument();
expect(within(row).queryByLabelText('loading')).not.toBeInTheDocument();
isFeatureEnabledMock.mockClear();
});
test('renders a backend-only historical query the client never ran, alongside a live one', async () => {
const isFeatureEnabledMock = mockedIsFeatureEnabled.mockImplementation(
featureFlag => featureFlag === FeatureFlag.SqllabBackendPersistence,
);
const twoRowApiResult = {
count: 2,
ids: [692, 700],
result: [
{
...fakeApiResult.result[0],
client_id: 'liveClientId',
status: QueryState.Running,
progress: 0,
rows: 0,
// Non-concluded: the backend never sets end_time for this status
// (maps to endDttm: 0, not undefined - see the comment above).
end_time: null,
sql_editor_id: defaultQueryEditor.id,
},
{
...fakeApiResult.result[0],
id: 700,
client_id: 'historicalOnlyClientId',
status: QueryState.Success,
progress: 100,
rows: 12,
sql_editor_id: defaultQueryEditor.id,
start_time: '1710273660000.000000',
// A different table than the live row's, so findRemoteSqlCell's
// target text is unique to that row, not duplicated on this one.
sql: 'SELECT * from "Population"',
executed_sql: 'SELECT * from "Population"\nLIMIT 1001',
},
],
};
const editorQueryApiRoute = `glob:*/api/v1/query/?q=*`;
fetchMock.get(editorQueryApiRoute, twoRowApiResult);
const stateWithOnlyOneLiveQuery = {
...initialState,
sqlLab: {
...initialState.sqlLab,
queries: {
liveClientId: {
id: 'liveClientId',
sqlEditorId: defaultQueryEditor.id,
sql: 'SELECT 1',
state: QueryState.Success,
startDttm: 1710273662445,
// A real Redux row at Success always has an endDttm too -
// QUERY_SUCCESS sets both together.
endDttm: 1710273662500,
progress: 100,
rows: 443,
},
},
},
};
assertLiveSqlDiffersFromSnapshot(
stateWithOnlyOneLiveQuery.sqlLab.queries.liveClientId,
);
const { container } = render(setup(), {
useRedux: true,
initialState: stateWithOnlyOneLiveQuery,
});
await waitFor(() =>
expect(fetchMock.callHistory.calls(editorQueryApiRoute).length).toBe(1),
);
await findRemoteSqlCell();
const tableRows = container.querySelectorAll(
'table > tbody > tr:not(.ant-table-measure-row)',
);
expect(tableRows).toHaveLength(2);
const liveRow = screen.getByText('443').closest('tr') as HTMLElement;
expect(within(liveRow).getByLabelText('check')).toBeInTheDocument();
expect(within(liveRow).queryByLabelText('loading')).not.toBeInTheDocument();
const historicalRow = screen.getByText('12').closest('tr') as HTMLElement;
expect(within(historicalRow).getByLabelText('check')).toBeInTheDocument();
expect(
within(historicalRow).queryByLabelText('loading'),
).not.toBeInTheDocument();
isFeatureEnabledMock.mockClear();
});
test('renders contributed toolbar action in queryHistory slot', () => {
registerToolbarAction(
ViewLocations.sqllab.queryHistory,
@@ -19,6 +19,7 @@
import { useEffect, useMemo, useState } from 'react';
import { shallowEqual, useSelector } from 'react-redux';
import { useInView } from 'react-intersection-observer';
import { omit } from 'lodash-es';
import { EmptyState, Skeleton } from '@superset-ui/core/components';
import { t } from '@apache-superset/core/translation';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
@@ -30,7 +31,6 @@ import useEffectEvent from 'src/hooks/useEffectEvent';
import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
import PanelToolbar from 'src/components/PanelToolbar';
import { ViewLocations } from 'src/SqlLab/contributions';
import { mergeQueryStatus } from './mergeQueryStatus';
interface QueryHistoryProps {
queryEditorId: string | number;
@@ -82,26 +82,25 @@ const QueryHistory = ({
skip: !isFeatureEnabled(FeatureFlag.SqllabBackendPersistence),
},
);
const editorQueries = useMemo(() => {
if (!data) {
return getEditorQueries(queries, editorId);
}
const remoteIds = new Set(data.result.map(({ id }) => id));
const mergedRemoteQueries = data.result.map(remoteQuery => {
const localQuery = queries[remoteQuery.id];
return localQuery
? mergeQueryStatus(remoteQuery, localQuery)
: remoteQuery;
});
return getEditorQueries(queries, editorId)
.filter(({ id }) => !remoteIds.has(id))
.concat(mergedRemoteQueries)
.sort((a, b) => {
const aTime = a.startDttm || 0;
const bTime = b.startDttm || 0;
return aTime - bTime;
});
}, [queries, data, editorId]);
const editorQueries = useMemo(
() =>
data
? getEditorQueries(
omit(
queries,
data.result.map(({ id }) => id),
),
editorId,
)
.concat(data.result)
.sort((a, b) => {
const aTime = a.startDttm || 0;
const bTime = b.startDttm || 0;
return aTime - bTime;
})
: getEditorQueries(queries, editorId),
[queries, data, editorId],
);
const loadNext = useEffectEvent(() => {
setPageIndex(pageIndex + 1);
@@ -1,151 +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.
*/
import { QueryState, testQueryResponse } from '@superset-ui/core';
import { mergeQueryStatus } from './mergeQueryStatus';
// remoteBase and localBase deliberately differ on backend-only metadata
// (queryId, tab, executedSql) that the merge bundle never touches, not just
// on the seven bundle fields. If they only differed on the bundle fields,
// `{...remoteQuery, <bundle>}` and `{...localQuery, <bundle>}` would be
// structurally equal and toEqual/toBe could not tell a correct base from a
// wrong one — see the "keeps the snapshot's backend-only metadata" test
// below, which exists specifically to catch that class of regression.
const remoteBase = {
...testQueryResponse,
queryId: 692,
tab: 'Untitled Query 16',
executedSql: 'SELECT * from "FCC 2018 Survey"\nLIMIT 1001',
state: QueryState.Running,
progress: 0,
rows: 0,
startDttm: 1000,
endDttm: undefined as unknown as number,
resultsKey: null,
errorMessage: null,
};
const localBase = {
...testQueryResponse,
queryId: undefined as unknown as number,
tab: 'stale local tab',
executedSql: undefined as unknown as string,
state: QueryState.Success,
progress: 100,
rows: 443,
startDttm: 2000,
endDttm: 3000,
resultsKey: 'a-results-key',
errorMessage: null,
};
test('both non-concluded: returns the remote row unchanged', () => {
const remote = { ...remoteBase, state: QueryState.Running };
const local = { ...localBase, state: QueryState.Scheduled };
expect(mergeQueryStatus(remote, local)).toBe(remote);
});
test('remote concluded, local not: returns the remote row unchanged', () => {
const remote = { ...remoteBase, state: QueryState.Success };
const local = { ...localBase, state: QueryState.Running };
expect(mergeQueryStatus(remote, local)).toBe(remote);
});
test('both concluded: declines to override, returns the remote row unchanged', () => {
const remote = { ...remoteBase, state: QueryState.Success };
const local = { ...localBase, state: QueryState.Stopped };
expect(mergeQueryStatus(remote, local)).toBe(remote);
});
test('local concluded, remote not: local supplies status fields and both timestamps together', () => {
const remote = { ...remoteBase, state: QueryState.Running };
const local = { ...localBase, state: QueryState.Success };
expect(mergeQueryStatus(remote, local)).toEqual({
...remote,
state: QueryState.Success,
progress: 100,
rows: 443,
startDttm: 2000,
endDttm: 3000,
resultsKey: 'a-results-key',
errorMessage: null,
});
});
test('local concluded, remote not: undefined local fields fall back to the remote value', () => {
const remote = {
...remoteBase,
state: QueryState.Running,
startDttm: 1000,
endDttm: 1500,
resultsKey: 'remote-results-key',
errorMessage: 'remote error',
};
const local = {
...localBase,
state: QueryState.Success,
startDttm: undefined as unknown as number,
endDttm: undefined as unknown as number,
resultsKey: undefined as unknown as string,
errorMessage: undefined as unknown as string,
};
const merged = mergeQueryStatus(remote, local);
expect(merged.startDttm).toBe(1000);
expect(merged.endDttm).toBe(1500);
expect(merged.resultsKey).toBe('remote-results-key');
expect(merged.errorMessage).toBe('remote error');
});
test('local concluded, remote not: a null local field overrides a remote value (does not fall back)', () => {
const remote = {
...remoteBase,
state: QueryState.Running,
resultsKey: 'remote-results-key',
errorMessage: 'remote error',
};
const local = {
...localBase,
state: QueryState.Failed,
resultsKey: null,
errorMessage: null,
};
const merged = mergeQueryStatus(remote, local);
expect(merged.resultsKey).toBeNull();
expect(merged.errorMessage).toBeNull();
});
test('local concluded, remote not: keeps the snapshot-only metadata (queryId, tab, executedSql)', () => {
const remote = { ...remoteBase, state: QueryState.Running };
const local = { ...localBase, state: QueryState.Success };
const merged = mergeQueryStatus(remote, local);
expect(merged.queryId).toBe(692);
expect(merged.tab).toBe('Untitled Query 16');
expect(merged.executedSql).toBe(
'SELECT * from "FCC 2018 Survey"\nLIMIT 1001',
);
});
@@ -1,71 +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.
*/
import { concludedQueryStateList, QueryResponse } from '@superset-ui/core';
export const isConcludedState = (state: QueryResponse['state']) =>
concludedQueryStateList.includes(state);
// The backend history snapshot fetched by `useEditorQueriesQuery` is a
// one-shot fetch that is never invalidated, so it can strand a query at a
// non-terminal state forever once `QueryAutoRefresh` stops polling it (see
// `QueryAutoRefresh.MAX_QUERY_AGE_TO_POLL`). This function corrects only
// that one case: when the live Redux copy has concluded and the snapshot
// has not, the live row supplies the status fields and both timestamps
// together, since they must come from a single clock (the backend records
// both `startDttm`/`endDttm` in server time, while the client stamps
// `endDttm` from the browser clock when a query concludes locally). Every
// other combination — including both sides concluded, or the snapshot
// already concluded — returns the snapshot row unchanged.
export const mergeQueryStatus = (
remoteQuery: QueryResponse,
localQuery: QueryResponse,
): QueryResponse => {
if (
!isConcludedState(localQuery.state) ||
isConcludedState(remoteQuery.state)
) {
return remoteQuery;
}
return {
...remoteQuery,
state:
localQuery.state !== undefined ? localQuery.state : remoteQuery.state,
progress:
localQuery.progress !== undefined
? localQuery.progress
: remoteQuery.progress,
rows: localQuery.rows !== undefined ? localQuery.rows : remoteQuery.rows,
startDttm:
localQuery.startDttm !== undefined
? localQuery.startDttm
: remoteQuery.startDttm,
endDttm:
localQuery.endDttm !== undefined
? localQuery.endDttm
: remoteQuery.endDttm,
resultsKey:
localQuery.resultsKey !== undefined
? localQuery.resultsKey
: remoteQuery.resultsKey,
errorMessage:
localQuery.errorMessage !== undefined
? localQuery.errorMessage
: remoteQuery.errorMessage,
};
};
@@ -43,23 +43,6 @@ describe('SaveDatasetActionButton', () => {
expect(saveDatasetBtn).toBeVisible();
});
test('disables only the dataset button when canSaveDataset is false', () => {
const onSaveAsExplore = jest.fn();
render(
<SaveDatasetActionButton
setShowSave={() => true}
onSaveAsExplore={onSaveAsExplore}
canSaveDataset={false}
/>,
);
// Saving the query needs no results.
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled();
expect(
screen.getByRole('button', { name: /save dataset/i }),
).toBeDisabled();
});
test('disables the save dataset button when the query did not run successfully', async () => {
render(
<SaveDatasetActionButton
@@ -19,14 +19,12 @@
import { act, type ComponentProps } from 'react';
import {
cleanup,
createStore,
fireEvent,
render,
screen,
userEvent,
waitFor,
} from 'spec/helpers/testing-library';
import reducerIndex from 'spec/helpers/reducerIndex';
import fetchMock from 'fetch-mock';
import { SaveDatasetModal } from 'src/SqlLab/components/SaveDatasetModal';
import { createDatasource } from 'src/SqlLab/actions/sqlLab';
@@ -65,12 +63,6 @@ beforeEach(() => {
cleanup();
});
afterEach(() => {
// In-body restores are skipped when an assertion throws, leaking a
// configured spy into later tests.
jest.restoreAllMocks();
});
// Mock createDatasource to return a thunk that resolves with the dataset's
// new id. The test's mock store includes redux-thunk middleware (from RTK's
// getDefaultMiddleware), so dispatch(createDatasource(...)) properly unwraps
@@ -526,39 +518,6 @@ describe('SaveDatasetModal', () => {
});
});
test('surfaces the error and keeps the modal open when saving fails', async () => {
// The chart-payload step's toast was built but never dispatched, so a
// failure there was silent.
const postFormData = jest.spyOn(
require('src/explore/exploreUtils/formData'),
'postFormData',
);
postFormData.mockRejectedValue(new Error('Boom'));
const onHide = jest.fn();
const store = createStore({ user }, reducerIndex);
render(<SaveDatasetModal {...mockedProps} onHide={onHide} />, { store });
fireEvent.change(screen.getByDisplayValue(/unimportant/i), {
target: { value: 'my dataset' },
});
userEvent.click(screen.getByRole('button', { name: /save/i }));
// `createStore` builds its reducer map at runtime, so state isn't typed.
const toasts = () =>
(
store.getState() as unknown as {
messageToasts: { toastType: string }[];
}
).messageToasts;
await waitFor(() => {
expect(toasts()).toHaveLength(1);
});
expect(toasts()[0].toastType).toBe('DANGER_TOAST');
expect(onHide).not.toHaveBeenCalled();
});
test('clearDatasetCache is imported and available', () => {
const { clearDatasetCache } = require('src/utils/cachedSupersetGet');
@@ -61,9 +61,6 @@ import type Subject from 'src/types/Subject';
import { openInNewTab, redirect } from 'src/utils/navigationUtils';
import { mapSubjectValuesToIds } from 'src/features/subjects/SubjectPicker';
// Derived so it can't drift from what `getClientErrorObject` accepts.
type SaveErrorSource = Parameters<typeof getClientErrorObject>[0];
interface QueryDatabase {
id?: number;
}
@@ -394,18 +391,9 @@ export const SaveDatasetModal = ({
setDatasetName(getDefaultDatasetName());
onHide();
})
.catch((error?: SaveErrorSource) => {
.catch(() => {
setLoading(false);
// `createDatasource` already toasted the server's message and rejects
// with nothing; only the chart-payload step needs its own.
if (!error) {
return;
}
getClientErrorObject(error).then(e =>
dispatch(
addDangerToast(e.error || t('An error occurred saving dataset')),
),
);
addDangerToast(t('An error occurred saving dataset'));
});
};
@@ -27,8 +27,6 @@ import {
import SaveQuery from 'src/SqlLab/components/SaveQuery';
import { initialState, databases } from 'src/SqlLab/fixtures';
const RESULT_COLUMNS = [{ column_name: 'col', type: 'STRING' }];
const mockedProps = {
queryEditorId: '123',
animation: false,
@@ -37,6 +35,7 @@ const mockedProps = {
onSave: () => {},
saveQueryWarning: null,
columns: [],
canSaveDataset: true,
};
const mockState = {
@@ -61,31 +60,8 @@ const splitSaveBtnProps = {
...mockedProps.database,
allows_virtual_table_explore: true,
},
columns: RESULT_COLUMNS,
};
const EDITOR_SQL = 'SELECT * FROM t';
const stateWithLatestQuery = ({
id,
state,
sql = EDITOR_SQL,
}: {
id: string;
state: string;
sql?: string;
}) => ({
...mockState,
sqlLab: {
...mockState.sqlLab,
queryEditors: mockState.sqlLab.queryEditors.map(qe => ({
...qe,
latestQueryId: id,
})),
queries: { [id]: { id, state, sql } },
},
});
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
@@ -121,71 +97,6 @@ describe('SavedQuery', () => {
expect(saveBtn).toBeVisible();
});
test('blocks "Save dataset" until the query has run successfully', () => {
// Without a successful run the save can only fail server-side.
render(<SaveQuery {...splitSaveBtnProps} />, {
useRedux: true,
store: mockStore(stateWithLatestQuery({ id: 'qid-1', state: 'failed' })),
});
expect(
screen.getByRole('button', { name: /save dataset/i }),
).toBeDisabled();
// Saving the query itself is unaffected.
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled();
});
test('blocks "Save dataset" when no query has been run at all', () => {
render(<SaveQuery {...splitSaveBtnProps} />, {
useRedux: true,
store: mockStore(mockState),
});
expect(
screen.getByRole('button', { name: /save dataset/i }),
).toBeDisabled();
});
test('blocks "Save dataset" when the SQL changed after a successful run', () => {
// The run succeeded, but not for what is in the editor now -- and it is
// the editor's SQL that gets saved.
render(<SaveQuery {...splitSaveBtnProps} />, {
useRedux: true,
store: mockStore(
stateWithLatestQuery({
id: 'qid-1',
state: 'success',
sql: 'SELECT 1 AS ran_earlier',
}),
),
});
expect(
screen.getByRole('button', { name: /save dataset/i }),
).toBeDisabled();
});
test('blocks "Save dataset" when the successful query returned no columns', () => {
// e.g. a DDL/DML statement -- there is nothing to introspect into a dataset.
render(<SaveQuery {...splitSaveBtnProps} columns={[]} />, {
useRedux: true,
store: mockStore(stateWithLatestQuery({ id: 'qid-1', state: 'success' })),
});
expect(
screen.getByRole('button', { name: /save dataset/i }),
).toBeDisabled();
});
test('enables "Save dataset" once the query has succeeded', () => {
render(<SaveQuery {...splitSaveBtnProps} />, {
useRedux: true,
store: mockStore(stateWithLatestQuery({ id: 'qid-1', state: 'success' })),
});
expect(screen.getByRole('button', { name: /save dataset/i })).toBeEnabled();
});
test('renders a save query modal when user clicks save button', () => {
render(<SaveQuery {...mockedProps} />, {
useRedux: true,
@@ -323,7 +234,7 @@ describe('SavedQuery', () => {
test('renders a save dataset modal when user clicks "save dataset" menu item', async () => {
render(<SaveQuery {...splitSaveBtnProps} />, {
useRedux: true,
store: mockStore(stateWithLatestQuery({ id: 'qid-1', state: 'success' })),
store: mockStore(mockState),
});
const saveDatasetMenuItem = await screen.findByLabelText(/save dataset/i);
@@ -337,7 +248,7 @@ describe('SavedQuery', () => {
test('renders the save dataset modal UI', async () => {
render(<SaveQuery {...splitSaveBtnProps} />, {
useRedux: true,
store: mockStore(stateWithLatestQuery({ id: 'qid-1', state: 'success' })),
store: mockStore(mockState),
});
const saveDatasetMenuItem = await screen.findByLabelText(/save dataset/i);
userEvent.click(saveDatasetMenuItem);
@@ -17,8 +17,6 @@
* under the License.
*/
import { useState, useEffect, useMemo, ChangeEvent } from 'react';
import { useSelector } from 'react-redux';
import { Query, QueryState } from '@superset-ui/core';
import type { DatabaseObject } from 'src/features/databases/types';
import { t } from '@apache-superset/core/translation';
import { styled } from '@apache-superset/core/theme';
@@ -39,7 +37,7 @@ import {
} from 'src/SqlLab/components/SaveDatasetModal';
import { getDatasourceAsSaveableDataset } from 'src/utils/datasourceUtils';
import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
import { QueryEditor, SqlLabRootState } from 'src/SqlLab/types';
import { QueryEditor } from 'src/SqlLab/types';
import useLogAction from 'src/logger/useLogAction';
import {
LOG_ACTIONS_SQLLAB_CREATE_CHART,
@@ -54,6 +52,7 @@ interface SaveQueryProps {
onUpdate: (arg0: QueryPayload, id: string) => void;
saveQueryWarning: string | null;
database: Partial<DatabaseObject> | undefined;
canSaveDataset: boolean;
}
export type QueryPayload = {
@@ -83,6 +82,7 @@ const SaveQuery = ({
saveQueryWarning,
database,
columns,
canSaveDataset,
}: SaveQueryProps) => {
const queryEditor = useQueryEditor(queryEditorId, [
'autorun',
@@ -113,17 +113,6 @@ const SaveQuery = ({
const [label, setLabel] = useState<string>(defaultLabel);
const [showSave, setShowSave] = useState<boolean>(false);
const [showSaveDatasetModal, setShowSaveDatasetModal] = useState(false);
// Saving a dataset runs the SQL to introspect columns, so it needs a
// successful run of the SQL being saved that produced at least one column
// -- editing after a run invalidates it, and running a selection only
// validates that selection.
const latestQuery = useSelector<SqlLabRootState, Query | undefined>(
({ sqlLab }) => sqlLab.queries[queryEditor.latestQueryId || ''],
);
const canSaveDataset =
latestQuery?.state === QueryState.Success &&
latestQuery.sql === queryEditor.sql &&
columns.length > 0;
const isSaved = !!query.remoteId;
const isLabelEmpty = label.trim().length === 0;
const canExploreDatabase = !!database?.allows_virtual_table_explore;
@@ -362,7 +362,6 @@ describe('SqlEditor', () => {
test('enables the save dataset button when the latest query succeeded', async () => {
const { findByLabelText } = setupWithLatestQuery({
state: QueryState.Success,
sql: mockedProps.queryEditor.sql,
});
expect(await findByLabelText('Save dataset')).toBeEnabled();
});
@@ -868,6 +868,7 @@ const SqlEditor: FC<Props> = ({
}
saveQueryWarning={saveQueryWarning}
database={database}
canSaveDataset={successful && resultColumns.length > 0}
/>
<ShareSqlLabQuery queryEditorId={queryEditor.id} />
</>
@@ -22,13 +22,10 @@ import {
waitFor,
fireEvent,
cleanup,
userEvent,
act,
defaultStore as store,
} from 'spec/helpers/testing-library';
import fetchMock from 'fetch-mock';
import { SupersetClient } from '@superset-ui/core';
import { Constants } from '@superset-ui/core/components';
import mockDatasource from 'spec/fixtures/mockDatasource';
import React from 'react';
import DatasourceModalComponent, { buildExtraJsonObject } from '.';
@@ -89,10 +86,6 @@ beforeEach(async () => {
await waitForSaveEnabled();
});
afterEach(() => {
jest.useRealTimers();
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('DatasourceModal', () => {
test('renders', async () => {
@@ -152,93 +145,6 @@ describe('DatasourceModal', () => {
expect(JSON.parse(putCall?.options?.body as string).editors).toEqual([1]);
});
test('saves dataset certification from Settings without dropping Extra metadata', async () => {
cleanup();
renderAndWait({
...mockedProps,
datasource: {
...mockedProps.datasource,
extra: JSON.stringify({
custom_key: { enabled: true },
warning_markdown: 'Use only finalized records',
}),
} as typeof mockedProps.datasource & { extra: string },
});
await userEvent.click(await screen.findByRole('tab', { name: 'Settings' }));
const defaultUrlLabel = await screen.findByText('Default URL');
const defaultUrl = defaultUrlLabel
.closest('.ant-form-item')
?.querySelector('input');
expect(defaultUrl).not.toBeNull();
const certifiedBy = await screen.findByPlaceholderText('Certified by');
const details = screen.getByPlaceholderText('Certification details');
jest.useFakeTimers();
fireEvent.change(defaultUrl as HTMLInputElement, {
target: { value: '/dashboard/7/' },
});
fireEvent.change(certifiedBy, { target: { value: 'E2E Team' } });
fireEvent.change(details, {
target: { value: 'Reviewed for production' },
});
act(() => {
jest.advanceTimersByTime(Constants.FAST_DEBOUNCE);
});
jest.useRealTimers();
fireEvent.click(screen.getByTestId('datasource-modal-save'));
fireEvent.click(await screen.findByRole('button', { name: 'Confirm' }));
await waitFor(() => {
const putCall = fetchMock.callHistory
.calls()
.find(
call =>
call.url.includes('/api/v1/dataset/7') &&
call.options?.method === 'put',
);
expect(putCall).toBeDefined();
const payload = JSON.parse(putCall?.options?.body as string);
expect(payload.default_endpoint).toBe('/dashboard/7/');
expect(JSON.parse(payload.extra)).toEqual({
custom_key: { enabled: true },
warning_markdown: 'Use only finalized records',
certification: {
certified_by: 'E2E Team',
details: 'Reviewed for production',
},
});
});
});
test('shows existing dataset certification in Settings', async () => {
cleanup();
renderAndWait({
...mockedProps,
datasource: {
...mockedProps.datasource,
extra: JSON.stringify({
certification: {
certified_by: 'Data Platform Team',
details: 'Source of truth',
},
}),
} as typeof mockedProps.datasource & { extra: string },
});
await userEvent.click(await screen.findByRole('tab', { name: 'Settings' }));
expect(await screen.findByPlaceholderText('Certified by')).toHaveValue(
'Data Platform Team',
);
expect(screen.getByPlaceholderText('Certification details')).toHaveValue(
'Source of truth',
);
});
test('should render error dialog', async () => {
const putSpy = jest
.spyOn(SupersetClient, 'put')
@@ -43,7 +43,6 @@ import type { DatasetObject } from 'src/features/datasets/types';
import { withCertificationFields } from '../utils';
import { mapSubjectValuesToIds } from 'src/features/subjects/SubjectPicker';
import type { DatasourceModalProps } from '../types';
import { setDatasetCertification } from '../components/DatasourceEditor/datasetCertification';
const DatasourceEditor = AsyncEsmComponent(
() => import('../components/DatasourceEditor'),
@@ -189,12 +188,7 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
datasource.cache_timeout === '' ? null : datasource.cache_timeout,
is_sqllab_view: datasource.is_sqllab_view,
template_params: datasource.template_params,
extra: datasource.dataset_certification_changed
? setDatasetCertification(datasource.extra, {
certified_by: datasource.certified_by,
certification_details: datasource.certification_details,
})
: datasource.extra,
extra: datasource.extra,
is_managed_externally: datasource.is_managed_externally,
external_url: datasource.external_url,
metrics: datasource?.metrics?.map(
@@ -36,7 +36,6 @@ import {
SupersetClient,
getClientErrorObject,
getExtensionsRegistry,
formatSpecifier,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { t } from '@apache-superset/core/translation';
@@ -109,10 +108,6 @@ import {
} from '../../FoldersEditor/treeUtils';
import FoldersEditor from '../../FoldersEditor';
import { DatasourceFolder } from 'src/explore/components/DatasourcePanel/types';
import {
getDatasetCertification,
isDatasetExtraValid,
} from './datasetCertification';
const extensionsRegistry = getExtensionsRegistry();
@@ -189,9 +184,6 @@ interface DatasourceObject {
description?: string;
default_endpoint?: string;
extra?: string;
certified_by?: string;
certification_details?: string;
dataset_certification_changed?: boolean;
datasource_type?: string;
type?: string;
offset?: number;
@@ -835,78 +827,6 @@ function EditorsSelector({
const ResultTable =
extensionsRegistry.get('sqleditor.extension.resultTable') ?? FilterableTable;
// D3's '%' and 'p' types both multiply by 100; parsed via d3-format's own
// grammar so garbage like "foo%" is rejected rather than matched by suffix.
// The stored value is trimmed before parsing because
// NumberFormatterRegistry.get() trims it the same way before rendering, so
// this check agrees with what the renderer actually sees.
export const isPercentD3Format = (d3format?: string): boolean => {
if (!d3format) {
return false;
}
try {
const { type } = formatSpecifier(d3format.trim());
return type === '%' || type === 'p';
} catch {
return false;
}
};
// Matches the outermost COUNT(...) call's parens by depth, so a ratio like
// `COUNT(*) / COUNT(*)` isn't misclassified but a nested call like
// `COUNT(DISTINCT COALESCE(a, b))` is still recognized. Parens inside a
// quoted string literal (single- or double-quoted, with a doubled quote as
// an escaped quote) are ignored so they don't desync the depth count.
export const isCountExpression = (expression?: string): boolean => {
const trimmed = expression?.trim();
if (!trimmed || !/^count\s*\(/i.test(trimmed) || !trimmed.endsWith(')')) {
return false;
}
let depth = 0;
let stringDelimiter: string | null = null;
for (let i = trimmed.indexOf('('); i < trimmed.length; i += 1) {
const char = trimmed[i];
if (stringDelimiter) {
if (char === stringDelimiter && trimmed[i + 1] === stringDelimiter) {
i += 1;
} else if (char === stringDelimiter) {
stringDelimiter = null;
}
} else if (char === "'" || char === '"') {
stringDelimiter = char;
} else if (char === '(') {
depth += 1;
} else if (char === ')') {
depth -= 1;
if (depth === 0) {
return i === trimmed.length - 1;
}
}
}
return false;
};
function renderMetricFormatWarning(item: Record<string, any>): ReactNode {
if (
!isCountExpression(item.expression) ||
!isPercentD3Format(item.d3format)
) {
return null;
}
return (
<Alert
css={themeParam => ({ marginBottom: themeParam.sizeUnit * 4 })}
type="warning"
showIcon
message={t(
'This metric is a count, but its D3 format is a percentage. ' +
'Percent formats multiply the value by 100, which will make a ' +
'raw count render as a misleadingly large number.',
)}
/>
);
}
// Redux connector types
interface QueryPayload {
client_id?: string;
@@ -979,7 +899,6 @@ function DatasourceEditor({
// Initialize datasource state with transformed editors and metrics
const [datasource, setDatasource] = useState<DatasourceObject>(() => ({
...propsDatasource,
...getDatasetCertification(propsDatasource.extra),
editors: normalizeSubjectsToPickerValues(propsDatasource.editors || []),
metrics: propsDatasource.metrics?.map(hydrateMetricExtra),
}));
@@ -1712,67 +1631,12 @@ function DatasourceEditor({
onDatasourceChange,
]);
const renderCertificationFieldset = useCallback(() => {
const certificationError = !isDatasetExtraValid(datasource.extra)
? t('Fix the Extra JSON to edit certification')
: undefined;
return isSqla ? (
<Fieldset
title={t('Certification')}
item={datasource}
onFieldChange={(fieldKey, value) => {
if (
fieldKey !== 'certified_by' &&
fieldKey !== 'certification_details'
) {
return;
}
setDatasource(previousDatasource => ({
...previousDatasource,
[fieldKey]: typeof value === 'string' ? value : undefined,
dataset_certification_changed: true,
}));
}}
>
<Field
fieldKey="certified_by"
label={t('Certified by')}
description={t('Person or group that has certified this dataset')}
errorMessage={certificationError}
control={
<TextControl
controlId="dataset_certified_by"
placeholder={t('Certified by')}
disabled={Boolean(certificationError)}
/>
}
/>
<Field
fieldKey="certification_details"
label={t('Certification details')}
description={t('Details of the dataset certification')}
errorMessage={certificationError}
control={
<TextControl
controlId="dataset_certification_details"
placeholder={t('Certification details')}
disabled={Boolean(certificationError)}
/>
}
/>
</Fieldset>
) : null;
}, [datasource, isSqla]);
const renderSettingsFieldset = useCallback(
() => (
<Fieldset
title={t('Basic')}
item={datasource}
onFieldChange={(fieldKey, value) =>
onDatasourcePropChange(String(fieldKey), value)
}
onChange={onDatasourceChange}
>
<Field
fieldKey="description"
@@ -1826,20 +1690,15 @@ function DatasourceEditor({
}
/>
)}
<EditorsSelector
datasource={datasource}
onChange={newEditors => {
onDatasourcePropChange('editors', newEditors);
}}
/>
{isSqla && (
<Field
fieldKey="extra"
label={t('Extra')}
description={t(
'Extra data to specify table metadata, such as ' +
'`{ "warning_markdown": "This is a warning." }`. ' +
'Use the Certification fields below for certification metadata.',
'Extra data to specify table metadata. Currently supports ' +
'metadata of the format: `{ "certification": { "certified_by": ' +
'"Data Platform Team", "details": "This table is the source of truth." ' +
'}, "warning_markdown": "This is a warning." }`.',
)}
control={
<TextAreaControl
@@ -1851,9 +1710,15 @@ function DatasourceEditor({
}
/>
)}
<EditorsSelector
datasource={datasource}
onChange={newEditors => {
onDatasourceChange({ ...datasource, editors: newEditors });
}}
/>
</Fieldset>
),
[datasource, onDatasourcePropChange, isSqla],
[datasource, onDatasourceChange, isSqla],
);
const renderAdvancedFieldset = useCallback(
@@ -1861,9 +1726,7 @@ function DatasourceEditor({
<Fieldset
title={t('Advanced')}
item={datasource}
onFieldChange={(fieldKey, value) =>
onDatasourcePropChange(String(fieldKey), value)
}
onChange={onDatasourceChange}
>
<Field
fieldKey="cache_timeout"
@@ -1911,7 +1774,7 @@ function DatasourceEditor({
/>
</Fieldset>
),
[datasource, onDatasourcePropChange, isSqla],
[datasource, onDatasourceChange, isSqla],
);
const renderSourceFieldset = useCallback(
@@ -2307,7 +2170,7 @@ function DatasourceEditor({
}}
expandFieldset={
<FormContainer>
<Fieldset compact renderWarning={renderMetricFormatWarning}>
<Fieldset compact>
<Field
fieldKey="expression"
label={t('SQL expression')}
@@ -2685,10 +2548,7 @@ function DatasourceEditor({
children: (
<Row gutter={16}>
<Col xs={24} md={12}>
<FormContainer>
{renderSettingsFieldset()}
{renderCertificationFieldset()}
</FormContainer>
<FormContainer>{renderSettingsFieldset()}</FormContainer>
</Col>
<Col xs={24} md={12}>
<FormContainer>{renderAdvancedFieldset()}</FormContainer>
@@ -2718,7 +2578,6 @@ function DatasourceEditor({
folders,
folderCount,
handleFoldersChange,
renderCertificationFieldset,
renderSettingsFieldset,
renderAdvancedFieldset,
// `renderSpatialTab` is intentionally retained (see its definition above)
@@ -1,111 +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.
*/
export type DatasetCertification = {
certified_by?: string;
certification_details?: string;
};
type JsonObject = Record<string, unknown>;
const isJsonObject = (value: unknown): value is JsonObject =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const parseExtra = (extra?: string): JsonObject | undefined => {
if (!extra?.trim()) {
return {};
}
try {
const parsed: unknown = JSON.parse(extra);
return isJsonObject(parsed) ? parsed : undefined;
} catch {
return undefined;
}
};
export const isDatasetExtraValid = (extra?: string): boolean =>
parseExtra(extra) !== undefined;
export const getDatasetCertification = (
extra?: string,
): DatasetCertification => {
const certification = parseExtra(extra)?.certification;
if (!isJsonObject(certification)) {
return {};
}
return {
certified_by:
typeof certification.certified_by === 'string'
? certification.certified_by
: undefined,
certification_details:
typeof certification.details === 'string'
? certification.details
: undefined,
};
};
export const setDatasetCertification = (
extra: string | undefined,
{ certified_by, certification_details }: DatasetCertification,
): string | undefined => {
const parsedExtra = parseExtra(extra);
// Do not replace malformed raw metadata while the user is correcting it in
// the adjacent Extra editor.
if (!parsedExtra) {
return extra;
}
const normalizedCertifiedBy = certified_by || undefined;
const normalizedDetails = certification_details || undefined;
const existing = getDatasetCertification(extra);
// Avoid reformatting raw Extra JSON when the certification did not change.
if (
existing.certified_by === normalizedCertifiedBy &&
existing.certification_details === normalizedDetails
) {
return extra;
}
const existingCertification = parsedExtra.certification;
const certification = isJsonObject(existingCertification)
? { ...existingCertification }
: {};
delete certification.certified_by;
delete certification.details;
if (normalizedCertifiedBy) {
certification.certified_by = normalizedCertifiedBy;
}
if (normalizedDetails) {
certification.details = normalizedDetails;
}
if (Object.keys(certification).length > 0) {
parsedExtra.certification = certification;
} else {
delete parsedExtra.certification;
}
return JSON.stringify(parsedExtra);
};
@@ -1,109 +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.
*/
import fetchMock from 'fetch-mock';
import { Constants } from '@superset-ui/core/components';
import {
act,
fireEvent,
screen,
userEvent,
waitFor,
} from 'spec/helpers/testing-library';
import {
cleanupAsyncOperations,
createProps,
DATASOURCE_ENDPOINT,
dismissDatasourceWarning,
fastRender,
setupDatasourceEditorMocks,
} from './DatasourceEditor.test.utils';
beforeEach(() => {
fetchMock.get(DATASOURCE_ENDPOINT, [], { name: DATASOURCE_ENDPOINT });
setupDatasourceEditorMocks();
});
afterEach(async () => {
jest.useRealTimers();
await cleanupAsyncOperations();
fetchMock.clearHistory().removeRoutes();
});
test('a trailing Basic edit keeps pending Certification fields', async () => {
const testProps = createProps();
const extra = '{ "custom_key": true }';
testProps.datasource.extra = extra;
fastRender(testProps);
await dismissDatasourceWarning();
await userEvent.click(await screen.findByRole('tab', { name: 'Settings' }));
const defaultUrlLabel = await screen.findByText('Default URL');
const defaultUrl = defaultUrlLabel
.closest('.ant-form-item')
?.querySelector('input');
expect(defaultUrl).not.toBeNull();
const certifiedBy = await screen.findByPlaceholderText('Certified by');
const certificationDetails = screen.getByPlaceholderText(
'Certification details',
);
jest.useFakeTimers();
fireEvent.change(certifiedBy, {
target: { value: 'Data Team' },
});
fireEvent.change(certificationDetails, {
target: { value: 'Reviewed for production' },
});
fireEvent.change(defaultUrl as HTMLInputElement, {
target: { value: '/dashboard/7/' },
});
act(() => {
jest.advanceTimersByTime(Constants.FAST_DEBOUNCE);
});
jest.useRealTimers();
await waitFor(() => {
const { calls } = testProps.onChange.mock;
expect(calls[calls.length - 1]?.[0]).toEqual(
expect.objectContaining({
default_endpoint: '/dashboard/7/',
certified_by: 'Data Team',
certification_details: 'Reviewed for production',
dataset_certification_changed: true,
extra,
}),
);
});
});
test('malformed Extra disables dataset certification controls', async () => {
const testProps = createProps();
testProps.datasource.extra = '{"custom_key":';
fastRender(testProps);
await dismissDatasourceWarning();
await userEvent.click(await screen.findByRole('tab', { name: 'Settings' }));
expect(await screen.findByPlaceholderText('Certified by')).toBeDisabled();
expect(screen.getByPlaceholderText('Certification details')).toBeDisabled();
expect(
screen.getAllByText('Fix the Extra JSON to edit certification'),
).toHaveLength(2);
});
@@ -1,210 +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.
*/
import fetchMock from 'fetch-mock';
import {
screen,
userEvent,
waitFor,
within,
} from 'spec/helpers/testing-library';
import { isCountExpression, isPercentD3Format } from '../DatasourceEditor';
import {
createProps,
DATASOURCE_ENDPOINT,
setupDatasourceEditorMocks,
cleanupAsyncOperations,
fastRender,
dismissDatasourceWarning,
DatasourceEditorProps,
} from './DatasourceEditor.test.utils';
beforeEach(() => {
fetchMock.get(DATASOURCE_ENDPOINT, [], { name: DATASOURCE_ENDPOINT });
setupDatasourceEditorMocks();
});
afterEach(async () => {
await cleanupAsyncOperations();
fetchMock.clearHistory().removeRoutes();
});
const WARNING_TEXT = /D3 format is a percentage/i;
// Selecting the expand toggle by its position in the list is brittle: any
// change to the fixture or the table's sort order would expand a different
// row and negative assertions would keep passing against the wrong metric.
// Look up the toggle via the row that actually contains the metric name.
const expandMetricRow = async (metricName: string) => {
const nameCell = await screen.findByText(metricName);
const row = nameCell.closest('tr');
if (!row) {
throw new Error(`Could not find a table row for metric "${metricName}"`);
}
await userEvent.click(within(row).getByLabelText(/expand row/i));
};
// A fixed-time sleep can't prove the debounced value actually committed, so
// negative assertions would pass vacuously if the commit landed late on a
// loaded runner. Instead, wait for the real signal of a commit: the metric's
// d3format reaching the top-level onChange the editor calls after every
// datasource state update.
const waitForD3FormatCommit = (
onChange: DatasourceEditorProps['onChange'],
metricName: string,
d3format: string,
) =>
waitFor(() => {
const [datasource] = onChange.mock.calls.at(-1) ?? [];
const metric = datasource?.metrics?.find(
(m: { metric_name?: string }) => m.metric_name === metricName,
);
expect(metric?.d3format).toBe(d3format);
});
test('isCountExpression matches a COUNT(...) call, including nested calls', () => {
expect(isCountExpression('COUNT(*)')).toBe(true);
expect(isCountExpression('count( * )')).toBe(true);
expect(isCountExpression('COUNT (*)')).toBe(true);
expect(isCountExpression('COUNT(DISTINCT name)')).toBe(true);
expect(isCountExpression('COUNT(DISTINCT COALESCE(a, b))')).toBe(true);
expect(isCountExpression('COUNT(*) / COUNT(*)')).toBe(false);
expect(isCountExpression('COUNT(*) * 100')).toBe(false);
expect(isCountExpression('SUM(num)')).toBe(false);
expect(isCountExpression(undefined)).toBe(false);
});
test('isCountExpression ignores parens inside string literals', () => {
expect(isCountExpression("COUNT(CASE WHEN x = '(' THEN 1 END)")).toBe(true);
expect(isCountExpression("COUNT(CASE WHEN x = ')' THEN 1 END)")).toBe(true);
expect(isCountExpression("COUNT(CASE WHEN x = '''(' THEN 1 END)")).toBe(true);
});
test('isCountExpression ignores parens inside double-quoted identifiers', () => {
expect(isCountExpression('COUNT("x\'")')).toBe(true);
expect(isCountExpression('COUNT("y\'")')).toBe(true);
expect(isCountExpression('COUNT(CASE WHEN "a""b" = 1 THEN 1 END)')).toBe(
true,
);
});
test('isPercentD3Format accepts only a valid D3 percent/p spec', () => {
expect(isPercentD3Format('.0%')).toBe(true);
expect(isPercentD3Format(',.2%')).toBe(true);
expect(isPercentD3Format('.1p')).toBe(true);
expect(isPercentD3Format('foo%')).toBe(false);
expect(isPercentD3Format('.0%garbage%')).toBe(false);
expect(isPercentD3Format(',.0f')).toBe(false);
expect(isPercentD3Format(undefined)).toBe(false);
});
// NumberFormatterRegistry.get() trims the stored value before parsing it at
// render time, so this must trim too rather than reject a format the
// renderer accepts.
test('isPercentD3Format trims, matching render-time parsing', () => {
expect(isPercentD3Format('.0% ')).toBe(true);
});
// A '%' format is valid syntax, so it never hits the "Invalid format" fallback.
test('warns when a percent D3 format is set on a COUNT metric', async () => {
const testProps = createProps();
fastRender(testProps);
await dismissDatasourceWarning();
await userEvent.click(await screen.findByTestId('collection-tab-Metrics'));
await expandMetricRow('count');
expect(screen.queryByText(WARNING_TEXT)).not.toBeInTheDocument();
await userEvent.type(await screen.findByPlaceholderText('%y/%m/%d'), '.0%');
expect(await screen.findByText(WARNING_TEXT)).toBeInTheDocument();
});
test('does not warn for a non-percent format on a COUNT metric', async () => {
const testProps = createProps();
fastRender(testProps);
await dismissDatasourceWarning();
await userEvent.click(await screen.findByTestId('collection-tab-Metrics'));
await expandMetricRow('count');
await userEvent.type(await screen.findByPlaceholderText('%y/%m/%d'), ',.0f');
await waitForD3FormatCommit(testProps.onChange, 'count', ',.0f');
expect(screen.queryByText(WARNING_TEXT)).not.toBeInTheDocument();
});
test('does not warn for a percent format on a non-COUNT metric', async () => {
const testProps = createProps();
fastRender(testProps);
await dismissDatasourceWarning();
await userEvent.click(await screen.findByTestId('collection-tab-Metrics'));
await expandMetricRow('sum__num');
await userEvent.type(await screen.findByPlaceholderText('%y/%m/%d'), '.0%');
await waitForD3FormatCommit(testProps.onChange, 'sum__num', '.0%');
expect(screen.queryByText(WARNING_TEXT)).not.toBeInTheDocument();
});
test('does not warn for a ratio built from COUNT, e.g. COUNT(*) / COUNT(*)', async () => {
const baseProps = createProps();
const testProps = {
...baseProps,
datasource: {
...baseProps.datasource,
metrics: [
...baseProps.datasource.metrics,
{
id: 99,
uuid: 'metric-99-uuid',
expression: 'COUNT(*) / COUNT(*)',
verbose_name: 'ratio',
metric_name: 'ratio',
metric_type: 'count',
},
],
},
};
fastRender(testProps);
await dismissDatasourceWarning();
await userEvent.click(await screen.findByTestId('collection-tab-Metrics'));
await expandMetricRow('ratio');
await userEvent.type(await screen.findByPlaceholderText('%y/%m/%d'), '.0%');
await waitForD3FormatCommit(testProps.onChange, 'ratio', '.0%');
expect(screen.queryByText(WARNING_TEXT)).not.toBeInTheDocument();
});
test('does not warn for a garbage format string that merely ends in %', async () => {
const testProps = createProps();
fastRender(testProps);
await dismissDatasourceWarning();
await userEvent.click(await screen.findByTestId('collection-tab-Metrics'));
await expandMetricRow('count');
await userEvent.type(await screen.findByPlaceholderText('%y/%m/%d'), 'foo%');
await waitForD3FormatCommit(testProps.onChange, 'count', 'foo%');
expect(screen.queryByText(WARNING_TEXT)).not.toBeInTheDocument();
});
@@ -1,169 +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.
*/
import {
getDatasetCertification,
isDatasetExtraValid,
setDatasetCertification,
} from '../datasetCertification';
test('reads dataset certification from Extra JSON', () => {
expect(
getDatasetCertification(
JSON.stringify({
certification: {
certified_by: 'Data Platform Team',
details: 'Source of truth',
},
}),
),
).toEqual({
certified_by: 'Data Platform Team',
certification_details: 'Source of truth',
});
});
test('writes dataset certification without discarding other Extra metadata', () => {
const result = setDatasetCertification(
JSON.stringify({
custom_key: { enabled: true },
warning_markdown: 'Use only finalized records',
}),
{
certified_by: 'E2E Team',
certification_details: 'Reviewed for production',
},
);
expect(JSON.parse(result ?? '')).toEqual({
custom_key: { enabled: true },
warning_markdown: 'Use only finalized records',
certification: {
certified_by: 'E2E Team',
details: 'Reviewed for production',
},
});
});
test('writes certification details without requiring a certifier', () => {
const result = setDatasetCertification('{}', {
certification_details: 'Reviewed for production',
});
expect(JSON.parse(result ?? '')).toEqual({
certification: { details: 'Reviewed for production' },
});
});
test('editing certification preserves unknown certification metadata', () => {
const result = setDatasetCertification(
JSON.stringify({
certification: {
certified_by: 'Data Platform Team',
details: 'Source of truth',
expires_at: '2030-01-01',
},
}),
{
certified_by: 'E2E Team',
certification_details: 'Reviewed for production',
},
);
expect(JSON.parse(result ?? '')).toEqual({
certification: {
certified_by: 'E2E Team',
details: 'Reviewed for production',
expires_at: '2030-01-01',
},
});
});
test('clearing dataset certification preserves other Extra metadata', () => {
const result = setDatasetCertification(
JSON.stringify({
certification: {
certified_by: 'Data Platform Team',
details: 'Source of truth',
},
warning_markdown: 'Use only finalized records',
}),
{ certified_by: '', certification_details: '' },
);
expect(JSON.parse(result ?? '')).toEqual({
warning_markdown: 'Use only finalized records',
});
});
test('clearing certification preserves unknown certification metadata', () => {
const result = setDatasetCertification(
JSON.stringify({
certification: {
certified_by: 'Data Platform Team',
details: 'Source of truth',
expires_at: '2030-01-01',
},
}),
{ certified_by: '', certification_details: '' },
);
expect(JSON.parse(result ?? '')).toEqual({
certification: { expires_at: '2030-01-01' },
});
});
test('an unchanged certification leaves Extra formatting untouched', () => {
const extra = '{\n "certification": { "certified_by": "Data Team" }\n}';
expect(setDatasetCertification(extra, { certified_by: 'Data Team' })).toBe(
extra,
);
expect(
setDatasetCertification(undefined, {
certified_by: '',
certification_details: '',
}),
).toBeUndefined();
});
test('handles non-object Extra and certification values', () => {
expect(getDatasetCertification('[]')).toEqual({});
expect(getDatasetCertification('{"certification":true}')).toEqual({});
expect(
setDatasetCertification('{"certification":true}', {
certified_by: 'Data Team',
}),
).toBe('{"certification":{"certified_by":"Data Team"}}');
});
test('identifies malformed and non-object Extra JSON', () => {
expect(isDatasetExtraValid()).toBe(true);
expect(isDatasetExtraValid('{}')).toBe(true);
expect(isDatasetExtraValid('{"custom_key":')).toBe(false);
expect(isDatasetExtraValid('[]')).toBe(false);
});
test('editing certification does not overwrite malformed Extra JSON', () => {
expect(
setDatasetCertification('{"custom_key":', {
certified_by: 'Data Platform Team',
}),
).toBe('{"custom_key":');
});

Some files were not shown because too many files have changed in this diff Show More