diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index abbe5c1000a..9c5b3312c86 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,7 +1,5 @@ # Notify all committers of DB migration changes, per SIP-59 -# https://github.com/apache/superset/issues/13351 - /superset/migrations/ @mistercrunch @michael-s-molina @betodealmeida @eschutho @sadpandajoe # Notify some committers of changes in the components @@ -12,28 +10,30 @@ # Notify Helm Chart maintainers about changes in it -/helm/superset/ @craig-rueda @dpgaspar @villebro @nytai @michael-s-molina @mistercrunch @rusackas @Antonio-RiveroMartnez +/helm/superset/ @dpgaspar @villebro @nytai @michael-s-molina @mistercrunch @rusackas @Antonio-RiveroMartnez @hainenber # Notify E2E test maintainers of changes -/superset-frontend/cypress-base/ @sadpandajoe @geido @eschutho @rusackas @betodealmeida @mistercrunch +/superset-frontend/playwright/ @sadpandajoe @geido @eschutho @rusackas @mistercrunch +/superset-frontend/cypress-base/ @sadpandajoe @geido @eschutho @rusackas @mistercrunch # Notify PMC members of changes to GitHub Actions -/.github/ @villebro @geido @eschutho @rusackas @betodealmeida @nytai @mistercrunch @craig-rueda @kgabryje @dpgaspar @sadpandajoe @hainenber +/.github/ @villebro @geido @eschutho @rusackas @betodealmeida @nytai @mistercrunch @kgabryje @sha174n @dpgaspar @sadpandajoe @hainenber # Notify PMC members of changes to CI-executed scripts (supply-chain risk: # scripts/ files run directly in CI workflows and can execute arbitrary code) -/scripts/ @villebro @geido @eschutho @rusackas @betodealmeida @nytai @mistercrunch @craig-rueda @kgabryje @dpgaspar @sadpandajoe @hainenber +/scripts/ @villebro @geido @eschutho @rusackas @betodealmeida @nytai @mistercrunch @kgabryje @dpgaspar @sha174n @sadpandajoe @hainenber # Notify PMC members of changes to required GitHub Actions -/.asf.yaml @villebro @geido @eschutho @rusackas @betodealmeida @nytai @mistercrunch @craig-rueda @kgabryje @dpgaspar @Antonio-RiveroMartnez +/.asf.yaml @villebro @geido @eschutho @rusackas @betodealmeida @nytai @mistercrunch @kgabryje @dpgaspar @sha174n @Antonio-RiveroMartnez # Maps are a finicky contribution process we care about **/*.geojson @villebro @rusackas +**/*.ipynb @villebro @rusackas /superset-frontend/plugins/legacy-plugin-chart-country-map/ @villebro @rusackas # Notify translation maintainers of changes to translations diff --git a/.github/actions/setup-supersetbot/action.yml b/.github/actions/setup-supersetbot/action.yml index b6aca8c91ba..f1a32b8b78d 100644 --- a/.github/actions/setup-supersetbot/action.yml +++ b/.github/actions/setup-supersetbot/action.yml @@ -17,6 +17,7 @@ runs: - name: Install supersetbot from npm if: ${{ inputs.from-npm == 'true' }} shell: bash + # zizmor: ignore[adhoc-packages] - supersetbot is a first-party Apache CLI (apache-superset/supersetbot) installed globally as a tool; a global CLI install has no application manifest/lockfile context run: npm install -g supersetbot - name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )" @@ -31,6 +32,7 @@ runs: if: ${{ inputs.from-npm == 'false' }} shell: bash working-directory: supersetbot + # zizmor: ignore[adhoc-packages] - installs the locally packed supersetbot tarball built from the trusted apache-superset/supersetbot checkout; no lockfile applies to a global CLI install run: | # simple trick to install globally with dependencies npm pack diff --git a/.github/workflows/bump-python-package.yml b/.github/workflows/bump-python-package.yml index 1dac5bb9923..305e38d99a4 100644 --- a/.github/workflows/bump-python-package.yml +++ b/.github/workflows/bump-python-package.yml @@ -40,7 +40,7 @@ jobs: uses: ./.github/actions/setup-supersetbot/ - name: Set up Python ${{ inputs.python-version }} - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.10" diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 8fd50f4278a..3899a8582e8 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -64,7 +64,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -75,6 +75,6 @@ jobs: # queries: security-extended,security-and-quality - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/generate-FOSSA-report.yml b/.github/workflows/generate-FOSSA-report.yml index e740d56b967..125a0b1d696 100644 --- a/.github/workflows/generate-FOSSA-report.yml +++ b/.github/workflows/generate-FOSSA-report.yml @@ -37,7 +37,7 @@ jobs: persist-credentials: false submodules: recursive - name: Setup Java - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 with: distribution: "temurin" java-version: "11" diff --git a/.github/workflows/github-action-validator.yml b/.github/workflows/github-action-validator.yml index 2673354da55..e2dfaef806a 100644 --- a/.github/workflows/github-action-validator.yml +++ b/.github/workflows/github-action-validator.yml @@ -37,7 +37,10 @@ jobs: node-version: "20" - name: Install Dependencies - run: npm install -g @action-validator/core @action-validator/cli --save-dev + # Versions are pinned to avoid ad-hoc, unpinned package installs. + # Bump deliberately when upgrading. + # zizmor: ignore[adhoc-packages] - @action-validator is a global CLI tool installed to validate the repo's workflows; a global CLI install has no application manifest/lockfile context, and the versions are pinned above + run: npm install -g @action-validator/core@0.6.0 @action-validator/cli@0.6.0 - name: Run Script run: bash .github/workflows/github-action-validator.sh diff --git a/.github/workflows/license-check.yml b/.github/workflows/license-check.yml index b1839c2a6e3..95668258611 100644 --- a/.github/workflows/license-check.yml +++ b/.github/workflows/license-check.yml @@ -23,7 +23,7 @@ jobs: persist-credentials: false submodules: recursive - name: Setup Java - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 with: distribution: "temurin" java-version: "11" diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 416115793b7..07069e5faa1 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -32,19 +32,18 @@ jobs: with: persist-credentials: false submodules: recursive + - name: Setup Python uses: ./.github/actions/setup-backend/ with: python-version: ${{ matrix.python-version }} - - name: Enable brew and helm-docs - # Add brew to the path - see https://github.com/actions/runner-images/issues/6283 - run: | - echo "/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin" >> $GITHUB_PATH - eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" - echo "HOMEBREW_PREFIX=$HOMEBREW_PREFIX" >>"${GITHUB_ENV}" - echo "HOMEBREW_CELLAR=$HOMEBREW_CELLAR" >>"${GITHUB_ENV}" - echo "HOMEBREW_REPOSITORY=$HOMEBREW_REPOSITORY" >>"${GITHUB_ENV}" - brew install norwoodj/tap/helm-docs + + - name: Setup Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + + - name: Install helm-docs + run: go install github.com/norwoodj/helm-docs/cmd/helm-docs@v1.14.2 + - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: @@ -63,7 +62,7 @@ jobs: yarn install --immutable - name: Cache pre-commit environments - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/pre-commit key: pre-commit-v2-${{ runner.os }}-py${{ matrix.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c65074ae28e..b60b8a8d725 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,7 +56,7 @@ jobs: - name: Cache npm if: env.HAS_TAGS - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.npm # npm cache files are stored in `~/.npm` on Linux/macOS key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }} @@ -70,7 +70,7 @@ jobs: run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT - name: Cache npm if: env.HAS_TAGS - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 id: npm-cache # use this to check for `cache-hit` (`steps.npm-cache.outputs.cache-hit != 'true'`) with: path: ${{ steps.npm-cache-dir-path.outputs.dir }} diff --git a/.github/workflows/showtime-trigger.yml b/.github/workflows/showtime-trigger.yml index e880476cf09..f8220e8de8b 100644 --- a/.github/workflows/showtime-trigger.yml +++ b/.github/workflows/showtime-trigger.yml @@ -2,7 +2,8 @@ name: 🎪 Superset Showtime # Ultra-simple: just sync on any PR state change on: - # zizmor: ignore[dangerous-triggers] - required to react to PR label changes; this workflow does not check out or execute PR-provided code + # zizmor: ignore[dangerous-triggers] - required to react to PR label changes; PR code is + # only checked out and built after the maintainer-authorization gate (write/admin actors) pull_request_target: types: [labeled, unlabeled, synchronize, closed] @@ -156,6 +157,10 @@ jobs: with: ref: ${{ steps.check.outputs.target_sha }} persist-credentials: false + # Building fork PR code is Showtime's purpose: deploys are gated on the + # maintainer-authorization step above (write/admin actors only), so this + # checkout is an explicit, authorized opt-in rather than an automatic one. + allow-unsafe-pr-checkout: true - name: Setup Docker Environment (only if build needed) if: steps.auth.outputs.authorized == 'true' && steps.check.outputs.build_needed == 'true' diff --git a/.github/workflows/superset-docs-deploy.yml b/.github/workflows/superset-docs-deploy.yml index 7fbb9c33f0e..9f6268451da 100644 --- a/.github/workflows/superset-docs-deploy.yml +++ b/.github/workflows/superset-docs-deploy.yml @@ -71,7 +71,7 @@ jobs: node-version-file: "./docs/.nvmrc" - name: Setup Python uses: ./.github/actions/setup-backend/ - - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 + - uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 with: distribution: "zulu" java-version: "21" diff --git a/.github/workflows/superset-helm-lint.yml b/.github/workflows/superset-helm-lint.yml index 5f1d653a572..dcb1d6f233b 100644 --- a/.github/workflows/superset-helm-lint.yml +++ b/.github/workflows/superset-helm-lint.yml @@ -26,7 +26,7 @@ jobs: fetch-depth: 0 - name: Set up Helm - uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0 + uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1 with: version: v3.16.4 diff --git a/.github/workflows/superset-helm-release.yml b/.github/workflows/superset-helm-release.yml index 74859c60e3f..705bc7b16a2 100644 --- a/.github/workflows/superset-helm-release.yml +++ b/.github/workflows/superset-helm-release.yml @@ -42,7 +42,7 @@ jobs: git config user.email "$GITHUB_ACTOR@users.noreply.github.com" - name: Install Helm - uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0 + uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1 with: version: v3.5.4 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml old mode 100644 new mode 100755 diff --git a/Dockerfile b/Dockerfile index ecdbb97c751..12497e6543a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -120,7 +120,7 @@ RUN useradd --user-group -d ${SUPERSET_HOME} -m --no-log-init --shell /bin/bash # Some bash scripts needed throughout the layers COPY --chmod=755 docker/*.sh /app/docker/ -COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv +RUN pip install --no-cache-dir --upgrade uv # Using uv as it's faster/simpler than pip RUN uv venv /app/.venv @@ -141,7 +141,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \ COPY superset/translations/ /app/translations_mo/ RUN if [ "${BUILD_TRANSLATIONS}" = "true" ]; then \ - pybabel compile -d /app/translations_mo | true; \ + pybabel compile --use-fuzzy -d /app/translations_mo || true; \ fi; \ rm -f /app/translations_mo/*/*/*.[po,json] diff --git a/README.md b/README.md index d328add67ca..52cfb004a3d 100644 --- a/README.md +++ b/README.md @@ -247,16 +247,13 @@ Understanding the Superset Points of View - [Superset API](https://superset.apache.org/docs/rest-api) -## Repo Activity - - - - - Performance Stats of apache/superset - Last 28 days - - - - + diff --git a/UPDATING.md b/UPDATING.md index 2d2703f0825..4a057a7fe73 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -24,6 +24,45 @@ assists people when migrating to a new version. ## Next +- [39925](https://github.com/apache/superset/pull/39925): URL prefixing for `SUPERSET_APP_ROOT` subdirectory deployments is now handled automatically by helpers in `src/utils/navigationUtils` (`openInNewTab`, `redirect`, `getShareableUrl`, ``). Direct imports of `ensureAppRoot` / `makeUrl` from `src/utils/pathUtils` are forbidden outside `navigationUtils.ts` (enforced by a static-invariant test); contributors writing new code should use the focused helpers instead. No runtime behaviour change for existing callers — all 19 prior call sites have been migrated and four pre-existing double-prefix and missing-prefix bugs are fixed as part of the migration. + +- [39925](https://github.com/apache/superset/pull/39925): `SupersetClient.getUrl()` now strips a single leading application-root segment from the supplied `endpoint` before building the request URL, so a caller that accidentally pre-prefixes its endpoint (for example by wrapping it with `ensureAppRoot` before passing it to the client) no longer produces a doubled `/superset/superset/...` URL under subdirectory deployment. The strip is **single-pass** — a genuine `/superset/superset/` route is preserved, not collapsed — and **silent** (no console warning); the static-invariant test remains the primary signal for pre-prefixing at the call site, and this runtime strip is a safety net beneath it. Code that intentionally targeted a literal `///...` endpoint through `getUrl` (a configuration that has no legitimate use under the prefixing model) would have its first redundant segment removed. + +- **Breaking — `Superset` view class route prefix removed.** The `Superset` view in `superset/views/core.py` now declares `route_base = ""`, overriding Flask-AppBuilder's auto-derived `/superset` prefix. Routes that previously lived at `/superset/welcome/`, `/superset/dashboard//`, `/superset/dashboard/p//`, `/superset/explore/`, etc. now respond at `/welcome/`, `/dashboard//`, `/dashboard/p//`, `/explore/`, etc. Under subdirectory deployment (`SUPERSET_APP_ROOT=/superset`) the URLs are unchanged from end-user perspective — `AppRootMiddleware` re-applies the prefix via `SCRIPT_NAME`. Under root deployments, any external integration or bookmark that hard-codes `/superset//` paths must be updated to drop the prefix. This fixes the doubled `/superset/superset/...` URLs that `url_for` emitted for these endpoints under subdirectory deployment and the related 404s on the routes themselves. + +- **Breaking — Three sibling view classes route prefix removed.** Following the same rationale as the `Superset` class above, `ExplorePermalinkView` (`superset/views/explore.py`), `TagModelView`, and `TaggedObjectsModelView` (`superset/views/tags.py`, `superset/views/all_entities.py`) now mount at the application root rather than a hard-coded `/superset/...`. The user-visible URLs `/superset/explore/p//`, `/superset/tags/`, and `/superset/all_entities/` are unchanged under subdirectory deployment; under root deployments these views now serve `/explore/p//`, `/tags/`, and `/all_entities/`, so any external integration or bookmark must drop the `/superset/` prefix. `Dashboard.url` and `Dashboard.get_url` likewise return `/dashboard//` instead of the prior `/superset/dashboard//` literal so downstream consumers (DashboardList row hrefs, MCP service `dashboard_url`) emit a single, deployment-correct prefix. + +- **Legacy `/superset/*` path support.** A new outermost WSGI middleware `LegacyPrefixRedirectMiddleware` (`superset/middleware/legacy_prefix_redirect.py`) 308-redirects every enumerated legacy `/superset/` path to its post-`route_base=""` canonical location (e.g. `/superset/welcome/` → `/welcome/` under root; → `/superset/welcome/` under `SUPERSET_APP_ROOT=/superset`, because the canonical resolves through `AppRootMiddleware`). Bookmarks, email links, and external integrations survive the route-base collapse for one release cycle. POST against a GET-only canonical returns 410 Gone instead of 308 (308 would 405 on retry). The shim is removed at EOL `5.0.0`, matching the `@deprecated(eol_version="5.0.0")` gate on `Superset.explore` and `Superset.explore_json`. + +- **PWA web app manifest served dynamically.** The PWA manifest is now served at `/pwa-manifest.json` (under `APPLICATION_ROOT`) by a new `PwaManifestView` (`superset/views/pwa_manifest.py`) instead of the static file at `/static/assets/pwa-manifest.json`. The legacy static source at `superset-frontend/src/pwa-manifest.json` has been removed (along with its `webpack.config.js` `CopyPlugin` rule). The new endpoint resolves `APPLICATION_ROOT` and `STATIC_ASSETS_PREFIX` at request time so PWA install works under subdirectory deployments and split static-prefix / app-root deployments (where `STATIC_ASSETS_PREFIX` points to a CDN host while the Superset backend stays under `APPLICATION_ROOT`). The `` href in `superset/templates/superset/spa.html` was updated correspondingly (using a new `application_root_rstrip` template global). Operators with a forked `spa.html` should switch any manifest `` to `{{ application_root_rstrip }}/pwa-manifest.json`. + +- **Hard re-bookmark break — `/superset/sql//`.** SQL Lab moved to its own blueprint at `/sqllab/`. The legacy `/superset/sql//` shape changed to a query-string form (`/sqllab/?dbid=`); no 1:1 path mapping exists, so `LegacyPrefixRedirectMiddleware` does **not** redirect this route — it passes through and surfaces a 404. Users with bookmarks to `/superset/sql//` must update them to `/sqllab/?dbid=`. + +- **`SqlaTable.sql_url` query-string format.** `SqlaTable.sql_url` now URL-encodes `table_name` and joins it as a query parameter rather than concatenating a second `?`. Previously, with `Database.sql_url` returning `/sqllab/?dbid=`, the concatenation produced `/sqllab/?dbid=?table_name=` — a malformed second `?` that broke the query parser. External code that parsed the legacy `?table_name=` shape now sees properly percent-encoded values (e.g. `/` → `%2F`, ` ` → `+` or `%20`); decode with `urllib.parse.parse_qsl`. + +- **New config flag `EMBEDDED_DISABLE_PERMALINK_ORIGIN_REWRITE` (default `False`).** Share/permalink URLs now substitute `window.location.origin` for the backend-supplied origin so a proxied or subdirectory-deployed Superset never hands the user an unreachable internal hostname. Operators whose reverse proxy correctly forwards `X-Forwarded-Host` *and* who want permalinks to carry the backend's literal origin can opt out by setting `EMBEDDED_DISABLE_PERMALINK_ORIGIN_REWRITE = True` in `superset_config.py`. Default `False` (rewrite is on); flipping the default would regress the dominant proxied/subdir deployment to an unreachable host. + +### SQL Lab denies large-object and information_schema access by default + +`DISALLOWED_SQL_FUNCTIONS` and `DISALLOWED_SQL_TABLES` now ship with additional default entries, so SQL Lab and chart-data queries that reference them are rejected where they were previously allowed: + +- PostgreSQL large-object routines (`lo_from_bytea`, `lo_export`, `lo_import`, `lo_put`, `lo_create`, `lo_creat`, `lowrite`, `lo_get`, `loread`, `lo_unlink`), which read and write bytes on the database server's filesystem. +- The SQL-standard `information_schema` views (`tables`, `columns`, `routines`, `views`, the privilege/grant views, etc.), which expose table, column, privilege, and view-definition metadata across the whole database. + +Deployments that legitimately query these (for example tooling that introspects `information_schema`) can restore the previous behavior by overriding `DISALLOWED_SQL_FUNCTIONS` / `DISALLOWED_SQL_TABLES` in `superset_config.py` to drop the entries they need. + +Because the denylist now resolves the effective schema through the query-aware path, PostgreSQL queries that change the `search_path` (e.g. `SET search_path = ...`) are rejected on the SQL Lab execution and cost-estimate paths whenever any `DISALLOWED_SQL_TABLES` entry is configured (the default for PostgreSQL), matching the behavior previously applied only when `RLS_IN_SQLLAB` was enabled. + +### SQL parser input length cap (SQL_MAX_PARSE_LENGTH) + +The SQL parser now rejects scripts whose UTF-8 byte length exceeds the new +`SQL_MAX_PARSE_LENGTH` config option (default `1_000_000` bytes) before they are +handed to sqlglot, which bounds parser memory and CPU usage. A single query +larger than the cap (for example a very large `IN (...)` list or a big +virtual-dataset SQL) raises a parse error in SQL Lab and dashboard-generated +queries. Deployments that legitimately run queries above this size should raise +the value, and `SQL_MAX_PARSE_LENGTH = None` disables the check entirely. + ### Ant Design upgraded from v5 to v6 The frontend now builds against Ant Design 6, and `@superset-ui/core` / `@apache-superset/core` peer-depend on `antd ^6`. Custom plugins, extensions, and themes that interact with Ant Design need review: @@ -50,10 +89,27 @@ When the MCP service has JWT auth enabled (`MCP_AUTH_ENABLED = True`), an audien The git SHA and build number surfaced in the "About" section, the bootstrap payload, and the public `/version` endpoint are now only included for admin users by default; the release version string is still shown to everyone. To expose the build details to all users (the previous behavior), set the `SUPERSET_EXPOSE_BUILD_DETAILS` environment variable (or `EXPOSE_BUILD_DETAILS_TO_USERS = True` in `superset_config.py`). +### Helm chart adopts Kubernetes recommended labels (breaking upgrade) + +The Helm chart now labels and selects workloads using the [Kubernetes recommended labels](https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/) (`app.kubernetes.io/*`) instead of the legacy `app`/`release` labels. Because a Deployment's `spec.selector.matchLabels` is immutable, `helm upgrade` against an existing release will fail with a `field is immutable` error. + +To upgrade, delete the affected workloads (which selector labels changed) before upgrading, then run the upgrade so they are recreated with the new labels: + +```bash +kubectl delete deployment,statefulset -l release= -n +helm upgrade superset/superset +``` + +Alternatively, perform a fresh install. This is a one-time migration; subsequent upgrades are unaffected. + ### Pivot table First/Last aggregations follow data order The pivot table chart's `First` and `Last` aggregations now return the first and last value in data (query result) order, instead of effectively returning the minimum and maximum. Existing pivot tables that use these aggregations for totals/subtotals may show different values after upgrading. For deterministic results, ensure the underlying query has a stable sort order. +### `FetchRetryOptions` callback parameters widened to allow `null` + +The `error` and `response` parameters of the `retryDelay` and `retryOn` callbacks in `FetchRetryOptions` (exported from `@superset-ui/core`) are now typed `Error | null` and `Response | null` to match the actual call-site signature provided by `fetch-retry`. Because these parameter types are contravariant, consumers who typed their callbacks with the non-nullable `(attempt: number, error: Error, response: Response) => number` will get a TypeScript compile error. Widen your callback signatures to accept `Error | null` / `Response | null`. + ### `thumbnail_url` removed from dashboard list API response The `thumbnail_url` field has been removed from `GET /api/v1/dashboard/` list responses. External consumers relying on this field must now construct the thumbnail URL client-side using `id` and `changed_on_utc`: @@ -87,6 +143,7 @@ Deployments that intentionally point webhooks at internal targets (chatops bridg ### Impala cancel_query blocks private/internal hosts by default The Impala engine spec's `cancel_query` issues an HTTP request from the Superset backend to the host configured on the Impala database connection. That host is now validated before the request: if it resolves to a private/internal IP range, the cancel call is refused and a warning is logged. Operators whose Impala cluster runs on an internal network can opt out by setting `IMPALA_CANCEL_QUERY_ALLOW_INTERNAL_HOSTS = True` in `superset_config.py`. This mirrors the dataset-import and webhook opt-out flags. + ### Map chart renderer and OpenStreetMap migration behavior The MapLibre migration for deck.gl charts preserves saved non-Mapbox styles on @@ -124,6 +181,11 @@ Operators can tune or disable the policy via config: ### Data uploads bounded by UPLOAD_MAX_FILE_SIZE_BYTES Single data-file uploads (CSV, Excel, columnar) are now bounded by the `UPLOAD_MAX_FILE_SIZE_BYTES` config option, which defaults to `100 * 1024 * 1024` (100 MB). Files larger than this are rejected with a `413` before their contents are buffered into memory. Set `UPLOAD_MAX_FILE_SIZE_BYTES = None` to disable the check and restore unbounded uploads. +### Currency symbol position follows the locale when unset + +When a chart's currency control leaves the **Prefix or suffix** field empty, the currency symbol position is now derived from the deployment locale's own convention via `Intl.NumberFormat` instead of always defaulting to a suffix. For example, under the default `en-US` locale `USD`, `GBP`, and `EUR` render as a prefix (`$ 1,000`), while eurozone locales such as `fr-FR` render `EUR` as a suffix (`1 000 €`). An explicit Prefix/Suffix selection is always honored and is unaffected. + +Charts that relied on the previous always-suffix default for an unset position will render the symbol on the locale-appropriate side instead; set the position explicitly on the metric's currency control to pin it. ### Duration formatter precision @@ -189,6 +251,18 @@ Runbook to adopt: 2. Set that value on the tunnel's `server_host_key` (via the database/SSH tunnel API or UI payload). 3. Optionally set `SSH_TUNNEL_STRICT_HOST_KEY_CHECKING = True` in `superset_config.py` to require host-key verification on all tunnels. +### SMTP server certificate validation enabled by default + +`SMTP_SSL_SERVER_AUTH` now defaults to `True` (previously `False`). With this default, STARTTLS/SSL connections to the configured SMTP server validate the server's TLS certificate against the system trusted CA store. This makes outbound email (alerts and reports) verify the mail server's identity out of the box. + +If your SMTP server presents a self-signed certificate, or a certificate that is not trusted by the system CA store, email delivery may now fail with a certificate verification error. To restore the previous behavior of skipping certificate validation, set the following in `superset_config.py`: + +```python +SMTP_SSL_SERVER_AUTH = False +``` + +The recommended fix is to add the SMTP server's certificate (or its issuing CA) to the system trust store rather than disabling validation. + ### Dataset import validates catalog against the target connection Importing a dataset now validates the `catalog` field against the target database connection. When the connection has multi-catalog disabled (`allow_multi_catalog` off) and the dataset's catalog is not the connection's default catalog, the import fails instead of silently persisting the non-default catalog. This matches the validation already enforced on the dataset update path and prevents imported datasets from querying an unintended database. @@ -238,6 +312,28 @@ Schedule the cutover in a quiet window. Runtime reads use only the single config The migration is transactional (all-or-nothing) and idempotent — it can be safely re-run or resumed. Note that AES-GCM, unlike AES-CBC, does not support querying directly over encrypted columns; audit any code that filters on an encrypted column before switching. See the SIP at `docs/sip/authenticated-encryption-at-rest.md` for details. +### Soft delete and restore for dashboards + +**Everything in this section applies only when the `SOFT_DELETE` feature flag is enabled. The flag defaults to `False`** (`@lifecycle: development`), so on a default deployment `DELETE /api/v1/dashboard/` continues to **hard-delete permanently** — nothing is recoverable. Enable `SOFT_DELETE` to get the behavior described below. + +**Flag-toggle caveat:** the soft-delete visibility filter is evaluated per query while the flag is on. If dashboards are soft-deleted during a flag-on window and the flag is later turned **off**, those rows reappear as live dashboards in all lists and lookups (including slug lookups — if a soft-deleted dashboard's slug was reused while the flag was on, both rows become visible with the same slug). The `POST //restore` endpoint and the `dashboard_deleted_state` list filter remain functional regardless of the flag, deliberately, so rows soft-deleted during a flag-on window stay discoverable and restorable after a rollback of the flag. + +With the flag enabled: `DELETE /api/v1/dashboard/` no longer hard-deletes the dashboard (the bulk-delete endpoint behaves the same way). The row is marked with a `deleted_at` timestamp and hidden from the dashboard API's list, detail, and lookup endpoints, which return 404 for soft-deleted dashboards. The embedded-dashboard iframe URL (`/embedded/`) keeps rendering because it reads only `embedded.allowed_domains` and `embedded.dashboard_id` (the FK column) without dereferencing the parent dashboard; the frontend's subsequent dashboard-API fetch is what sees the 404 and surfaces "dashboard not found" to the user. + +**New endpoint** — `POST /api/v1/dashboard//restore` clears `deleted_at` and returns the dashboard to active state. Requires `can_write on Dashboard` and ownership of the row (or admin). Soft-deleted dashboards can also be surfaced in the list endpoint via the new `dashboard_deleted_state` rison filter: `include` returns both live and soft-deleted rows, `only` returns just the soft-deleted ones. Any other value is ignored. For non-admin users, soft-deleted rows are limited to dashboards they own — the same audience that can restore them. + +**Permissions migration:** existing role grants of `can_write on Dashboard` cover the new restore endpoint automatically; no role migration is required. + +**Schema migration:** the migration adds a nullable `deleted_at` column and an index on it (`ix_dashboards_deleted_at`) to the `dashboards` table, and **replaces the full unique constraint on `slug`** with a partial unique index (`ix_dashboards_active_slug`) enforcing slug uniqueness only among active (non-soft-deleted) rows. The column add is instant. On Postgres the constraint swap briefly blocks reads and writes during `ALTER TABLE ... DROP CONSTRAINT` (acquires `ACCESS EXCLUSIVE`), then blocks writes only during `CREATE UNIQUE INDEX` (acquires `ShareLock`); reads pass through during the index build. Both windows are sub-second on a typical `dashboards` table. MySQL InnoDB builds the functional index online (no blocking). + +**Rollback note:** the downgrade restores the original full unique constraint on `slug`. If the partial-index window allowed slug reuse (a soft-deleted row and an active row holding the same slug), `ALTER TABLE ... ADD CONSTRAINT idx_unique_slug UNIQUE (slug)` will abort with a unique-constraint violation. Before downgrading, hard-delete the soft-deleted duplicates (or rename one side) so each slug appears at most once across all rows. Rolling back the application code while leaving the new migration in place is also possible but exposes soft-deleted rows to the older code path; pair the rollback with a data decision (restore, hard-delete, or migrate-down). + +The partial-index replacement is dialect-dependent: PostgreSQL uses a native `WHERE deleted_at IS NULL` partial index; MySQL 8.0.13+ uses a functional index over `(CASE WHEN deleted_at IS NULL THEN slug END)` (8.0.13 is the first release with functional key parts). **MySQL <8.0.13, MariaDB, and SQLite keep the original full unique constraint** (functional indexes / column-level UNIQUE recreation aren't supported cleanly — MariaDB is excluded even at 10.x because its `CASE`-expression index semantics differ), so on those backends a soft-deleted dashboard continues to reserve its slug for the lifetime of the row. + +**Slug semantics:** on PostgreSQL and MySQL 8.0.13+, the slug of a soft-deleted dashboard is **free for reuse**. A new active dashboard can claim it immediately. Restoring a soft-deleted dashboard whose slug has since been claimed returns **422 with a clean error** (`DashboardSlugConflictError`) — rename one of the dashboards and retry; the restore is not silently rejected by a database-level constraint violation. + +**Importer behavior:** importing a dashboard YAML whose UUID matches an existing **soft-deleted** dashboard is treated as an implicit restore-with-update — **and this happens even when `overwrite` is not set**. This is a deliberate asymmetry with active rows: an active dashboard imported without `overwrite=true` is returned unchanged (the import never mutates it), but a soft-deleted UUID match is restored *and* has the upload's contents applied regardless of the `overwrite` argument, on the reasoning that re-importing a deleted dashboard's exact UUID is an explicit request to bring it back. The restore preserves the original PK and all pre-deletion relationship rows (`dashboard_slices` junctions, role grants, owners, tags) — including role grants that were implicitly revoked by the deletion. Callers whose imports must never mutate existing state should treat bundles that may contain previously deleted UUIDs accordingly. The operation is permission-gated: it requires `can_write` and ownership of the deleted row (or admin) — non-owners get `ImportFailedError`, and callers without `can_write` get `ImportFailedError` instead of silently receiving the soft-deleted row. + ### Granular Export Controls A new feature flag `GRANULAR_EXPORT_CONTROLS` introduces three fine-grained permissions that replace the legacy `can_csv` permission: @@ -527,6 +623,29 @@ See `superset/mcp_service/PRODUCTION.md` for deployment guides. } ``` +### Composite primary keys on many-to-many association tables + +Eight M:N association tables move from a synthetic `id INTEGER PRIMARY KEY` to a composite `PRIMARY KEY (fk1, fk2)` on their two foreign-key columns. The surrogate `id` is dropped, and the redundant `UNIQUE (fk1, fk2)` on the two tables that carried one is removed (now subsumed by the PK). + +| Table | Composite PK | +|---|---| +| `dashboard_roles` | `(dashboard_id, role_id)` | +| `dashboard_slices` | `(dashboard_id, slice_id)` | +| `dashboard_user` | `(user_id, dashboard_id)` | +| `report_schedule_user` | `(user_id, report_schedule_id)` | +| `rls_filter_roles` | `(role_id, rls_filter_id)` | +| `rls_filter_tables` | `(table_id, rls_filter_id)` | +| `slice_user` | `(user_id, slice_id)` | +| `sqlatable_user` | `(user_id, table_id)` | + +**Before upgrading:** + +- The migration **deletes** two classes of pre-existing rows the composite PK cannot accommodate: duplicate `(fk1, fk2)` pairs (it keeps the lowest `id` and removes the rest) and rows with `NULL` in either FK column. Both are meaningless for `secondary=` association tables, but export the affected rows first if you need an audit record. +- External tooling (BI tools, backup scripts) that references the surrogate `id` on these tables will break; no application code references it. +- Downgrade restores the `id` column (and the original `UNIQUE` on the two tables that had it) but leaves the FK columns `NOT NULL` (intentional — a `NULL` FK in a junction row is meaningless). + +For large `dashboard_slices` / `report_schedule_user` tables, see the operator runbook in [#39859](https://github.com/apache/superset/pull/39859) — pre-flight inventory queries, per-dialect lock-window sizing, and the duplicate / NULL-FK roll-up — to plan the maintenance window. + ## 6.0.0 - [33055](https://github.com/apache/superset/pull/33055): Upgrades Flask-AppBuilder to 5.0.0. The AUTH_OID authentication type has been deprecated and is no longer available as an option in Flask-AppBuilder. OpenID (OID) is considered a deprecated authentication protocol - if you are using AUTH_OID, you will need to migrate to an alternative authentication method such as OAuth, LDAP, or database authentication before upgrading. - [34871](https://github.com/apache/superset/pull/34871): Fixed Jest test hanging issue from Ant Design v5 upgrade. MessageChannel is now mocked in test environment to prevent rc-overflow from causing Jest to hang. Test environment only - no production impact. diff --git a/docs/admin_docs/configuration/configuring-superset.mdx b/docs/admin_docs/configuration/configuring-superset.mdx index 9ef6f075022..1ae2e1ba50b 100644 --- a/docs/admin_docs/configuration/configuring-superset.mdx +++ b/docs/admin_docs/configuration/configuring-superset.mdx @@ -549,6 +549,24 @@ CELERY_BEAT_SCHEDULE = { Adjust `retention_period_days` to control how long query rows are kept. Companion opt-in tasks (`prune_logs`, `prune_tasks`) exist for pruning the logs and tasks tables; see the commented-out examples in `superset/config.py`. Without enabling these tasks, the metadata database will grow unbounded over time. +## Dashboard Layout Size Limit + +Each dashboard stores its layout (the position, size, and nesting of every chart, row, and tab) as a JSON blob in the metadata database. Superset caps the length of this serialized blob with `SUPERSET_DASHBOARD_POSITION_DATA_LIMIT`, which defaults to `65535`: + +```python +SUPERSET_DASHBOARD_POSITION_DATA_LIMIT = 65535 +``` + +This is a Python-level cap (65535 is 2¹⁶ − 1), independent of the database column capacity — the `position_json` column is a `MEDIUMTEXT`, which holds far more. When the serialized layout reaches this limit, the editor blocks the save and reports the current length, the limit, and this setting's name. A warning is shown once the layout passes 90% of the limit. + +Large dashboards — for example, many charts spread across nested tabs — can exceed the default. Because the underlying column comfortably stores larger values, you can safely raise the limit: + +```python +SUPERSET_DASHBOARD_POSITION_DATA_LIMIT = 131072 # double the default +``` + +Alternatively, split a very large dashboard into several smaller ones. Note that this check is enforced when saving layout edits in the UI; a dashboard imported from a ZIP with an oversized layout will load and render, but cannot be edited and re-saved until the limit is raised. + :::resources - [Blog: Feature Flags in Apache Superset](https://preset.io/blog/feature-flags-in-apache-superset-and-preset/) ::: diff --git a/docs/developer_docs/contributing/howtos.md b/docs/developer_docs/contributing/howtos.md index 405a6311c35..452f705ae57 100644 --- a/docs/developer_docs/contributing/howtos.md +++ b/docs/developer_docs/contributing/howtos.md @@ -332,15 +332,28 @@ cd superset-frontend npm run build-translation # Backend -pybabel compile -d superset/translations +pybabel compile --use-fuzzy -d superset/translations ``` +`--use-fuzzy` includes `#, fuzzy` entries in the compiled `.mo` files. Superset +serves fuzzy translations on purpose: the frontend build (`po2json --fuzzy`) +already includes them, `flask fab babel-compile` (used by the release images) +compiles with `-f`, and the production `Dockerfile` compiles with `--use-fuzzy` +as well. This keeps machine-generated (and other draft) translations visible in +the UI rather than falling back to English while they await review. + ### Backfilling missing translations with AI For languages with many untranslated strings, the repo includes a script that uses Claude AI to generate draft translations for any missing entries. All AI-generated strings are marked `#, fuzzy` and tagged with an attribution -comment so that human reviewers know they need to be checked before merging. +comment so that human reviewers know they need to be checked. + +Note that `#, fuzzy` marks a translation as *needing review*, not as *withheld*: +both the frontend and backend builds serve fuzzy entries (see [Applying +translations](#applying-translations) above), so an AI-generated string is shown +in the UI as soon as it is built and deployed. Reviewers should verify each +entry and remove the `#, fuzzy` flag to promote it to a confirmed translation. #### Prerequisites diff --git a/docs/docs/index.mdx b/docs/docs/index.mdx index 681acd6d2d5..77ba3a1a82c 100644 --- a/docs/docs/index.mdx +++ b/docs/docs/index.mdx @@ -254,16 +254,13 @@ Understanding the Superset Points of View - [Superset API](/developer-docs/api) -## Repo Activity - - - - - Performance Stats of apache/superset - Last 28 days - - - - + diff --git a/docs/docusaurus.config.ts b/docs/docusaurus.config.ts index 7c7f920187b..3c3237fa2d2 100644 --- a/docs/docusaurus.config.ts +++ b/docs/docusaurus.config.ts @@ -749,7 +749,7 @@ const config: Config = { showReadingTime: true, // Please change this to your repo. editUrl: - 'https://github.com/facebook/docusaurus/edit/main/website/blog/', + 'https://github.com/apache/superset/tree/master/docs', }, theme: { customCss: require.resolve('./src/styles/custom.css'), diff --git a/docs/package.json b/docs/package.json index 341a0433dd8..ec1047c78a8 100644 --- a/docs/package.json +++ b/docs/package.json @@ -58,25 +58,15 @@ "@fontsource/inter": "^5.2.8", "@mdx-js/react": "^3.1.1", "@saucelabs/theme-github-codeblock": "^0.3.0", - "@storybook/addon-docs": "^8.6.18", - "@storybook/blocks": "^8.6.15", - "@storybook/channels": "^8.6.18", - "@storybook/client-logger": "^8.6.18", - "@storybook/components": "^8.6.18", - "@storybook/core": "^8.6.18", - "@storybook/core-events": "^8.6.18", - "@storybook/csf": "^0.1.13", - "@storybook/docs-tools": "^8.6.18", - "@storybook/preview-api": "^8.6.18", - "@storybook/theming": "^8.6.15", + "@storybook/addon-docs": "^10.4.5", "@superset-ui/core": "^0.20.4", - "@swc/core": "^1.15.41", + "@swc/core": "^1.15.43", "antd": "^6.4.5", - "baseline-browser-mapping": "^2.10.38", + "baseline-browser-mapping": "^2.10.40", "caniuse-lite": "^1.0.30001799", - "docusaurus-plugin-openapi-docs": "^5.0.2", - "docusaurus-theme-openapi-docs": "^5.0.2", - "js-yaml": "^4.2.0", + "docusaurus-plugin-openapi-docs": "^5.1.0", + "docusaurus-theme-openapi-docs": "^5.1.0", + "js-yaml": "^5.1.0", "js-yaml-loader": "^1.2.2", "json-bigint": "^1.0.0", "prism-react-renderer": "^2.4.1", @@ -88,8 +78,8 @@ "react-table": "^7.8.0", "remark-import-partial": "^0.0.2", "reselect": "^5.2.0", - "storybook": "^8.6.18", - "swagger-ui-react": "^5.32.7", + "storybook": "^10.4.5", + "swagger-ui-react": "^5.32.8", "swc-loader": "^0.2.7", "tinycolor2": "^1.4.2", "unist-util-visit": "^5.1.0" @@ -106,11 +96,11 @@ "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.6", "eslint-plugin-react": "^7.37.5", - "globals": "^17.6.0", + "globals": "^17.7.0", "prettier": "^3.8.4", "typescript": "~6.0.3", - "typescript-eslint": "^8.61.1", - "webpack": "^5.107.2" + "typescript-eslint": "^8.62.0", + "webpack": "^5.108.0" }, "browserslist": { "production": [ diff --git a/docs/src/webpack.extend.ts b/docs/src/webpack.extend.ts index c86498f09c0..32a91552d05 100644 --- a/docs/src/webpack.extend.ts +++ b/docs/src/webpack.extend.ts @@ -168,60 +168,6 @@ export default function webpackExtendPlugin(): Plugin { __dirname, '../../superset-frontend/packages/superset-core/src', ), - // Add proper Storybook aliases - '@storybook/blocks': path.resolve( - __dirname, - '../node_modules/@storybook/blocks', - ), - '@storybook/components': path.resolve( - __dirname, - '../node_modules/@storybook/components', - ), - '@storybook/theming': path.resolve( - __dirname, - '../node_modules/@storybook/theming', - ), - '@storybook/client-logger': path.resolve( - __dirname, - '../node_modules/@storybook/client-logger', - ), - '@storybook/core-events': path.resolve( - __dirname, - '../node_modules/@storybook/core-events', - ), - // Add internal Storybook aliases - 'storybook/internal/components': path.resolve( - __dirname, - '../node_modules/@storybook/components', - ), - 'storybook/internal/theming': path.resolve( - __dirname, - '../node_modules/@storybook/theming', - ), - 'storybook/internal/client-logger': path.resolve( - __dirname, - '../node_modules/@storybook/client-logger', - ), - 'storybook/internal/csf': path.resolve( - __dirname, - '../node_modules/@storybook/csf', - ), - 'storybook/internal/preview-api': path.resolve( - __dirname, - '../node_modules/@storybook/preview-api', - ), - 'storybook/internal/docs-tools': path.resolve( - __dirname, - '../node_modules/@storybook/docs-tools', - ), - 'storybook/internal/core-events': path.resolve( - __dirname, - '../node_modules/@storybook/core-events', - ), - 'storybook/internal/channels': path.resolve( - __dirname, - '../node_modules/@storybook/channels', - ), }, }, }; diff --git a/docs/static/.htaccess b/docs/static/.htaccess index f056e4423e5..9d282c0bc66 100644 --- a/docs/static/.htaccess +++ b/docs/static/.htaccess @@ -22,7 +22,14 @@ RewriteRule ^(.*)$ https://superset.apache.org/$1 [R,L] RewriteCond %{HTTP_HOST} ^superset.incubator.apache.org$ [NC] RewriteRule ^(.*)$ https://superset.apache.org/$1 [R=301,L] -Header set Content-Security-Policy "default-src data: blob: 'self' *.apache.org widget.kapa.ai *.githubusercontent.com *.scarf.sh *.googleapis.com *.google.com *.run.app *.gstatic.com *.github.com *.algolia.net *.algolianet.com 'unsafe-inline' 'unsafe-eval'; frame-src *; frame-ancestors 'self' *.google.com https://sidebar.bugherd.com; form-action 'self'; worker-src blob:; img-src 'self' blob: data: https:; font-src 'self'; object-src 'none'" +# CSP permissions for superset.apache.org +# Additional domains required for docs site functionality: +# - widget.kapa.ai: AI chatbot widget (uses Google reCAPTCHA). Approval here: https://privacy.apache.org/faq/committers.html +# - *.googleapis.com, *.google.com, *.gstatic.com: Google Calendar embed, kapa.ai reCAPTCHA - all of these loaded with user consent, following policy laid out in https://privacy.apache.org/faq/committers.html +# - github.com, *.github.com, *.githubusercontent.com: GitHub user-attachment images in docs (apex github.com serves user-attachments/* assets). Discussed/resolved in this thread: https://issues.apache.org/jira/browse/INFRA-25701?filter=-2 (DPA in place with GitHub) +# - *.algolia.net, *.algolianet.com: Algolia DocSearch. Approved here: https://privacy.apache.org/faq/committers.html +# See: https://infra.apache.org/tools/csp.html +SetEnv CSP_PROJECT_DOMAINS "widget.kapa.ai https://*.googleapis.com/ https://*.google.com/ https://*.gstatic.com/ https://github.com/ https://*.github.com/ https://*.githubusercontent.com/ https://*.algolia.net/ https://*.algolianet.com/" # REDIRECTS diff --git a/docs/static/feature-flags.json b/docs/static/feature-flags.json index 8f7623e471d..241412bc637 100644 --- a/docs/static/feature-flags.json +++ b/docs/static/feature-flags.json @@ -87,6 +87,12 @@ "lifecycle": "development", "description": "Enable semantic layers and show semantic views alongside datasets" }, + { + "name": "SOFT_DELETE", + "default": false, + "lifecycle": "development", + "description": "Temporary rollout / kill-switch gate for soft delete (default off = legacy hard delete). An emergency stop, not a clean rollback: flipping ON->OFF resurrects already-soft-deleted rows. Removed (along with its two gate points \u2014 BaseDAO.delete routing and the do_orm_execute visibility listener) once soft delete is stable." + }, { "name": "TABLE_V2_TIME_COMPARISON_ENABLED", "default": false, diff --git a/docs/user_docs_versioned_docs/version-6.0.0/configuration/databases.mdx b/docs/user_docs_versioned_docs/version-6.0.0/configuration/databases.mdx index 779c33b05d1..3c4816f5826 100644 --- a/docs/user_docs_versioned_docs/version-6.0.0/configuration/databases.mdx +++ b/docs/user_docs_versioned_docs/version-6.0.0/configuration/databases.mdx @@ -519,6 +519,80 @@ For a connection to a SQL endpoint you need to use the HTTP path from the endpoi {"connect_args": {"http_path": "/sql/1.0/endpoints/****", "driver_path": "/path/to/odbc/driver"}} ``` +##### OAuth2 Authentication + +Superset supports OAuth2 authentication for Databricks, allowing users to authenticate with their personal Databricks accounts instead of using shared access tokens. This provides better security and audit capabilities. + +###### Prerequisites + +1. Create an OAuth2 application in your Databricks account: + - Go to your Databricks account console + - Navigate to **Settings** → **Developer** → **OAuth apps** + - Create a new OAuth app with the redirect URI: `http://your-superset-host:port/api/v1/database/oauth2/` + +2. Configure OAuth2 in your `superset_config.py`: + +```python +from datetime import timedelta + +# OAuth2 configuration for Databricks +# The authorization endpoint is derived from your Databricks workspace host; the +# token endpoint must be set explicitly (see notes below). +DATABASE_OAUTH2_CLIENTS = { + "Databricks (legacy)": { + "id": "your-databricks-client-id", + "secret": "your-databricks-client-secret", + "scope": "sql", + "token_request_uri": "https://your-workspace-host/oidc/v1/token", + }, + "Databricks": { + "id": "your-databricks-client-id", + "secret": "your-databricks-client-secret", + "scope": "sql", + "token_request_uri": "https://your-workspace-host/oidc/v1/token", + }, +} + +# OAuth2 redirect URI (adjust hostname/port for your setup) +DATABASE_OAUTH2_REDIRECT_URI = "http://your-superset-host:port/api/v1/database/oauth2/" + +# Optional: OAuth2 timeout +DATABASE_OAUTH2_TIMEOUT = timedelta(seconds=30) +``` + +Replace the following placeholders: +- `your-databricks-client-id`: Your Databricks OAuth2 application client ID +- `your-databricks-client-secret`: Your Databricks OAuth2 application client secret +- `your-superset-host:port`: Your Superset instance hostname and port + +**Multi-Cloud Provider Support** + +Databricks fronts the user-to-machine (U2M) OAuth2 flow on every workspace at +`https:///oidc/v1/authorize` and +`https:///oidc/v1/token`, regardless of whether the workspace +runs on AWS, Azure, or GCP. Superset derives the **authorization** endpoint +directly from your connection's host, so no cloud provider or account/tenant +identifier needs to be configured. + +The **token** endpoint cannot be auto-derived (token exchange has no database +context to read the host), so you must supply `token_request_uri` in +`DATABASE_OAUTH2_CLIENTS`, set to `https:///oidc/v1/token` for +your workspace. + +If you supply a fully-resolved `authorization_request_uri` (and/or +`token_request_uri`), those values take precedence over the host-derived +defaults. + +###### Usage + +Once configured, users can: + +1. Connect to Databricks databases normally using access tokens +2. When querying data, Superset will automatically redirect users to authenticate with Databricks if needed +3. User-specific OAuth2 tokens will be used for database connections, providing better security and audit trails + +This feature works with both "Databricks (legacy)" and "Databricks" engine types and automatically supports all major cloud providers (AWS, Azure, GCP). + #### Denodo The recommended connector library for Denodo is diff --git a/docs/user_docs_versioned_docs/version-6.0.0/intro.md b/docs/user_docs_versioned_docs/version-6.0.0/intro.md index 31bd74aeb49..c6a159b10fc 100644 --- a/docs/user_docs_versioned_docs/version-6.0.0/intro.md +++ b/docs/user_docs_versioned_docs/version-6.0.0/intro.md @@ -212,16 +212,13 @@ Understanding the Superset Points of View - [Superset API](https://superset.apache.org/docs/rest-api) -## Repo Activity - - - - - Performance Stats of apache/superset - Last 28 days - - - - + diff --git a/docs/user_docs_versioned_docs/version-6.1.0/index.mdx b/docs/user_docs_versioned_docs/version-6.1.0/index.mdx index 681acd6d2d5..77ba3a1a82c 100644 --- a/docs/user_docs_versioned_docs/version-6.1.0/index.mdx +++ b/docs/user_docs_versioned_docs/version-6.1.0/index.mdx @@ -254,16 +254,13 @@ Understanding the Superset Points of View - [Superset API](/developer-docs/api) -## Repo Activity - - - - - Performance Stats of apache/superset - Last 28 days - - - - + diff --git a/docs/user_docs_versioned_docs/version-6.1.0/intro.md b/docs/user_docs_versioned_docs/version-6.1.0/intro.md index 9388518130e..67459ad42ea 100644 --- a/docs/user_docs_versioned_docs/version-6.1.0/intro.md +++ b/docs/user_docs_versioned_docs/version-6.1.0/intro.md @@ -246,16 +246,13 @@ Understanding the Superset Points of View - [Superset API](https://superset.apache.org/docs/rest-api) -## Repo Activity - - - - - Performance Stats of apache/superset - Last 28 days - - - - + diff --git a/docs/yarn.lock b/docs/yarn.lock index e8d4862aec1..d14de067685 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -2,6 +2,11 @@ # yarn lockfile v1 +"@adobe/css-tools@^4.4.0": + version "4.5.0" + resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.5.0.tgz#b5b71a25a4d16afa2482592ddfa62fccc60bc7d1" + integrity sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q== + "@algolia/abtesting@1.6.0": version "1.6.0" resolved "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.6.0.tgz" @@ -2232,6 +2237,22 @@ utility-types "^3.10.0" webpack "^5.88.1" +"@emnapi/core@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.11.1.tgz#b9e1064f3a6b1631e241e638eb48d736bfd372a6" + integrity sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ== + dependencies: + "@emnapi/wasi-threads" "1.2.2" + tslib "^2.4.0" + +"@emnapi/core@1.9.2": + version "1.9.2" + resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.9.2.tgz#3870265ecffc7352d01ead62d8d83d8358a2d034" + integrity sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA== + dependencies: + "@emnapi/wasi-threads" "1.2.1" + tslib "^2.4.0" + "@emnapi/core@^1.5.0": version "1.8.1" resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.8.1.tgz#fd9efe721a616288345ffee17a1f26ac5dd01349" @@ -2240,6 +2261,20 @@ "@emnapi/wasi-threads" "1.1.0" tslib "^2.4.0" +"@emnapi/runtime@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.11.1.tgz#58f1f3d5d81a9b12f793ab688c96371901027c24" + integrity sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw== + dependencies: + tslib "^2.4.0" + +"@emnapi/runtime@1.9.2": + version "1.9.2" + resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.9.2.tgz#8b469a3db160817cadb1de9050211a9d1ea84fa2" + integrity sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw== + dependencies: + tslib "^2.4.0" + "@emnapi/runtime@^1.5.0": version "1.8.1" resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.8.1.tgz#550fa7e3c0d49c5fb175a116e8cd70614f9a22a5" @@ -2254,6 +2289,20 @@ dependencies: tslib "^2.4.0" +"@emnapi/wasi-threads@1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz#28fed21a1ba1ce797c44a070abc94d42f3ae8548" + integrity sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w== + dependencies: + tslib "^2.4.0" + +"@emnapi/wasi-threads@1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz#4c93becf5bfa3b13d1bbdcc06aee38321ad8139a" + integrity sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA== + dependencies: + tslib "^2.4.0" + "@emotion/babel-plugin@^11.13.5": version "11.13.5" resolved "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz" @@ -2376,135 +2425,135 @@ resolved "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz" integrity sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg== -"@esbuild/aix-ppc64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz#bef96351f16520055c947aba28802eede3c9e9a9" - integrity sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA== +"@esbuild/aix-ppc64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz#7a01a8d2ec2fbb2dac78adad09b0fa781e4082be" + integrity sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ== -"@esbuild/android-arm64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz#d2e70be7d51a529425422091e0dcb90374c1546c" - integrity sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg== +"@esbuild/android-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz#b540a27d14e4afd058496a4dbec4d3f414db110a" + integrity sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg== -"@esbuild/android-arm@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.9.tgz#d2a753fe2a4c73b79437d0ba1480e2d760097419" - integrity sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ== +"@esbuild/android-arm@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.28.1.tgz#704bd297de6d762de54eabbeafbf55f6756abe2f" + integrity sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ== -"@esbuild/android-x64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.9.tgz#5278836e3c7ae75761626962f902a0d55352e683" - integrity sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw== +"@esbuild/android-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.28.1.tgz#d1cb166d34b0fbf0fe8ab460a5594f24a378701e" + integrity sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng== -"@esbuild/darwin-arm64@0.25.9": - version "0.25.9" - resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz" - integrity sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg== +"@esbuild/darwin-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz#1034b26457fc886368fe61bbd09f653f6afa8e54" + integrity sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q== -"@esbuild/darwin-x64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz#e27dbc3b507b3a1cea3b9280a04b8b6b725f82be" - integrity sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ== +"@esbuild/darwin-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz#65556a432a1e4d72032d8218c1932fcca1a49772" + integrity sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ== -"@esbuild/freebsd-arm64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz#364e3e5b7a1fd45d92be08c6cc5d890ca75908ca" - integrity sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q== +"@esbuild/freebsd-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz#2e61e0592f9030d7e3dae18ee25ebc535918aef6" + integrity sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw== -"@esbuild/freebsd-x64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz#7c869b45faeb3df668e19ace07335a0711ec56ab" - integrity sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg== +"@esbuild/freebsd-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz#c95ec289959ef8079c4dca817a1e2c4be66b9bd3" + integrity sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ== -"@esbuild/linux-arm64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz#48d42861758c940b61abea43ba9a29b186d6cb8b" - integrity sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw== +"@esbuild/linux-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz#40b22175dda06182f3ee8141186c5ff304c4a717" + integrity sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g== -"@esbuild/linux-arm@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz#6ce4b9cabf148274101701d112b89dc67cc52f37" - integrity sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw== +"@esbuild/linux-arm@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz#c09a0f67917592ac0de892a9be4d3814debd2a6c" + integrity sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ== -"@esbuild/linux-ia32@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz#207e54899b79cac9c26c323fc1caa32e3143f1c4" - integrity sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A== +"@esbuild/linux-ia32@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz#a580f9c676797833891e519fc7a1337c8afd8db3" + integrity sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w== -"@esbuild/linux-loong64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz#0ba48a127159a8f6abb5827f21198b999ffd1fc0" - integrity sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ== +"@esbuild/linux-loong64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz#46452cf321dc7f9e91c2fa780a56bb56e79cd68b" + integrity sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg== -"@esbuild/linux-mips64el@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz#a4d4cc693d185f66a6afde94f772b38ce5d64eb5" - integrity sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA== +"@esbuild/linux-mips64el@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz#4211b3184dd6608f53dcb22e39f5d34ee08852c8" + integrity sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ== -"@esbuild/linux-ppc64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz#0f5805c1c6d6435a1dafdc043cb07a19050357db" - integrity sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w== +"@esbuild/linux-ppc64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz#697857c2a61cb9b0b6bb6652e40c1dc5e1ca8e5d" + integrity sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ== -"@esbuild/linux-riscv64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz#6776edece0f8fca79f3386398b5183ff2a827547" - integrity sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg== +"@esbuild/linux-riscv64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz#d192943eb146a40ac4c6497d0cf7be35b986bf08" + integrity sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ== -"@esbuild/linux-s390x@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz#3f6f29ef036938447c2218d309dc875225861830" - integrity sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA== +"@esbuild/linux-s390x@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz#acea0356da0e0ebc08f97cf7b9c2e401e1e648dc" + integrity sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag== -"@esbuild/linux-x64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz#831fe0b0e1a80a8b8391224ea2377d5520e1527f" - integrity sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg== +"@esbuild/linux-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz#6f0c3ce0cb64c534b70c4c45ecb2c16d34e35dfd" + integrity sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA== -"@esbuild/netbsd-arm64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz#06f99d7eebe035fbbe43de01c9d7e98d2a0aa548" - integrity sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q== +"@esbuild/netbsd-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz#8bcd77077a0dce3378b574fedb26d2a253b73d36" + integrity sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw== -"@esbuild/netbsd-x64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz#db99858e6bed6e73911f92a88e4edd3a8c429a52" - integrity sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g== +"@esbuild/netbsd-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz#e7fb2a01e99c830c94e6623cd9fefb4c8fb58347" + integrity sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg== -"@esbuild/openbsd-arm64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz#afb886c867e36f9d86bb21e878e1185f5d5a0935" - integrity sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ== +"@esbuild/openbsd-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz#c52909372db8b86e2c55e05a8940033b5660a3b2" + integrity sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q== -"@esbuild/openbsd-x64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz#30855c9f8381fac6a0ef5b5f31ac6e7108a66ecf" - integrity sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA== +"@esbuild/openbsd-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz#c427b9be5a64c262ff9a7eb70b5fbbaadf446c6c" + integrity sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw== -"@esbuild/openharmony-arm64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz#2f2144af31e67adc2a8e3705c20c2bd97bd88314" - integrity sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg== +"@esbuild/openharmony-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz#dc9b147baca2e6c4b3c85571741ef4860a489097" + integrity sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg== -"@esbuild/sunos-x64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz#69b99a9b5bd226c9eb9c6a73f990fddd497d732e" - integrity sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw== +"@esbuild/sunos-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz#ce866d12df13c15e4c99f073a3d466f6e0649b3a" + integrity sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ== -"@esbuild/win32-arm64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz#d789330a712af916c88325f4ffe465f885719c6b" - integrity sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ== +"@esbuild/win32-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz#7468e3692d01d629d5941e5d83817bb80f9e39b4" + integrity sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA== -"@esbuild/win32-ia32@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz#52fc735406bd49688253e74e4e837ac2ba0789e3" - integrity sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww== +"@esbuild/win32-ia32@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz#a5bc0063fb2bcab6d0ed63f2a1537958bc269ec6" + integrity sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg== -"@esbuild/win32-x64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz#585624dc829cfb6e7c0aa6c3ca7d7e6daa87e34f" - integrity sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ== +"@esbuild/win32-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz#10064ee44f4347b90c9a02b446bbf80a91632b12" + integrity sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A== "@eslint-community/eslint-utils@^4.8.0": version "4.9.0" @@ -2871,6 +2920,13 @@ "@emnapi/runtime" "^1.5.0" "@tybys/wasm-util" "^0.10.1" +"@napi-rs/wasm-runtime@^1.1.4", "@napi-rs/wasm-runtime@^1.1.6": + version "1.1.6" + resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz#ed33806d0f9be98dc76d0c3d4fd872fda701b5d5" + integrity sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg== + dependencies: + "@tybys/wasm-util" "^0.10.3" + "@noble/hashes@1.4.0": version "1.4.0" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.4.0.tgz#45814aa329f30e4fe0ba49426f49dfccdd066426" @@ -2897,6 +2953,214 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@oxc-parser/binding-android-arm-eabi@0.127.0": + version "0.127.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.127.0.tgz#b75e796249ee22f632e40e942746c4bf648cee92" + integrity sha512-0LC7ye4hvqbIKxAzThzvswgHLFu2AURKzYLeSVvLdu2TBOYWQDmHnTqPLeA597BcUCxiLqLsS4CJ5uoI5WYWCQ== + +"@oxc-parser/binding-android-arm64@0.127.0": + version "0.127.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.127.0.tgz#e264467fe39f80018f62fa0dae82db0b80260444" + integrity sha512-b5jtVTH6AU5CJXHNdj7Jj9IEiR9yVjjnwHzPJhGyHGPdcsZSzBCkS9GBbV33niRMvKthDwQRFRJfI4a+k4PvYg== + +"@oxc-parser/binding-darwin-arm64@0.127.0": + version "0.127.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.127.0.tgz#0576d35109c00dcc6277200ba2eca7b47e07f1b1" + integrity sha512-obCE8B7ISKkJidjlhv9xRGJPOSDG2Yu6PRga9Ruaz35uintHxbp1Ki/Yc71wx4rj3Edrm0a1kzG1TAwit0wFpg== + +"@oxc-parser/binding-darwin-x64@0.127.0": + version "0.127.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.127.0.tgz#efa1ba49075aa318ff540a1c2f8a442017417206" + integrity sha512-JL6Xb5IwPQT8rUzlpsX7E+AgfcdNklXNPFp8pjCQQ5MQOQo5rtEB2ui+3Hgg9Sn7Y9Egj6YOLLiHhLpdAe12Aw== + +"@oxc-parser/binding-freebsd-x64@0.127.0": + version "0.127.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.127.0.tgz#817ba3c508d751d94d6e6fd86af69ddaa27da531" + integrity sha512-SDQ/3MQFw58fqQz3Z1PhSKFF3JoCF4gmlNjziDm8X02tTahCw0qJbd7FGPDKw1i4VTBZene9JPyC3mHtSvi+wA== + +"@oxc-parser/binding-linux-arm-gnueabihf@0.127.0": + version "0.127.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.127.0.tgz#b1c3096c654771998480316ef10d1e5d29edc79b" + integrity sha512-Av+D1MIqzV0YMGPT9we2SIZaMKD7Cxs4CvXSx/yxaWHewZjYEjScpOf5igc8IILASViw4WTnjlwUdI1KzVtDHQ== + +"@oxc-parser/binding-linux-arm-musleabihf@0.127.0": + version "0.127.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.127.0.tgz#c44a8f10e6c903685825aebf1289fc2086aed61e" + integrity sha512-Cs2fdJ8cPpFdeebj6p4dag8A4+56hPvZ0AhQQzlaLswGz1tz7bXt1nETLeorrM9+AMcWFFkqxcXwDGfTVidY8g== + +"@oxc-parser/binding-linux-arm64-gnu@0.127.0": + version "0.127.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.127.0.tgz#61c245abfab6f63045915b5c9cfa7d335ad7c440" + integrity sha512-qdOfTcT6SY8gsJrrV92uyEUyjqMGPpIB5JZUG6QN5dukYd+7/j0kX6MwK1DgQj39jtUYixxPiaRUiEN1+0CXgQ== + +"@oxc-parser/binding-linux-arm64-musl@0.127.0": + version "0.127.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.127.0.tgz#358bbd90e5c85b6c35125f5a6ff084e09b694c04" + integrity sha512-EoTCZneNFU/P2qrpEM+RHmQwt+CvDkyGESG6qhr7KaegXLZwePfbrkCDfAk8/rhxbDUVGsZILX+2tqPzFtoFWA== + +"@oxc-parser/binding-linux-ppc64-gnu@0.127.0": + version "0.127.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.127.0.tgz#b7ea7b51bf54db4c42819187f760e069d433dac3" + integrity sha512-zALjmZYgxFLHjXeudcDF0xFGNydTAtkAeXAr2EuC17ywCyFxcmQra4w0BMde0Yi/re4Bi4iwEoEXtYN7l6eBLQ== + +"@oxc-parser/binding-linux-riscv64-gnu@0.127.0": + version "0.127.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.127.0.tgz#3a3b10d160988df50bbbcd631c6af39de3dd451d" + integrity sha512-fPP8M6zQLS7Jz7o9d5ArUSuAuSK3e+WCYVrCpdzeCOejidtZExJ9tjhDrAd3HEPqARBCPmdpqxESPFqy44vkBQ== + +"@oxc-parser/binding-linux-riscv64-musl@0.127.0": + version "0.127.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.127.0.tgz#3787d37e1d0a15ee239f51610298500321b31730" + integrity sha512-7IcC4Ao02oGpfnjt+X/oF4U2mllo2qoSkw5xxiXNKL9MCTsTiAC6616beOuehdxGcnz1bRoPC1RQ2f1GQDdN+g== + +"@oxc-parser/binding-linux-s390x-gnu@0.127.0": + version "0.127.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.127.0.tgz#b71a16cbba115a4696498f9149bc54cc4e1df9cd" + integrity sha512-pbXIhiNFHoqWeqDNLiJ9JkpHz1IM9k4DXa66x+1GTWMG7iLxtkXgE53iiuKSXwmk3zIYmaPVfBvgcAhS583K4Q== + +"@oxc-parser/binding-linux-x64-gnu@0.127.0": + version "0.127.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.127.0.tgz#71527dd0284ba727d35a93c841c91192af3ebdec" + integrity sha512-MYCguB9RvBvlSd6gbuNI7QwiLoCCAlGnlRJFPrzLI6U1/9wkC/WK6LtBAUln55H1Ctqw45PWmqrobKoMhsYQzQ== + +"@oxc-parser/binding-linux-x64-musl@0.127.0": + version "0.127.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.127.0.tgz#16830afa4b001f349cebb93e12b278e72601cb3f" + integrity sha512-5eY0B/bxf1xIUxb4NOTvOI3KWtBQfPWYyKAzgcrCt0mDibSZygVpO1Pz8bkeiSZ5Jj9+M09dkggG3H8I5d0Uyg== + +"@oxc-parser/binding-openharmony-arm64@0.127.0": + version "0.127.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.127.0.tgz#a41c71d249cb597dc357038eb1cbe3ce732453f8" + integrity sha512-Gld0ajrFTUXNtdw20fVBuTQx66FA75nIVg+//pPfR3sXkuABB4mTBhl3r9JNzrJpgW//qiwxf0nWXUWGJSL3UQ== + +"@oxc-parser/binding-wasm32-wasi@0.127.0": + version "0.127.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.127.0.tgz#b1efcdb433b30ed4a3ad912fa03da3834bd4845d" + integrity sha512-T6KVD7rhLzFlwGRXMnxUFfkCZD8FHnb968wVXW1mXzgRFc5RNXOBY2mPPDZ77x5Ln76ltLMgtPg0cOkU1NSrEQ== + dependencies: + "@emnapi/core" "1.9.2" + "@emnapi/runtime" "1.9.2" + "@napi-rs/wasm-runtime" "^1.1.4" + +"@oxc-parser/binding-win32-arm64-msvc@0.127.0": + version "0.127.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.127.0.tgz#b62b5e328126323d41ae1ee7adc95537c4c4423a" + integrity sha512-Ujvw4X+LD1CCGULcsQcvb4YNVoBGqt+JHgNNzGGaCImELiZLk477ifUH53gIbE7EKd933NdTi25JWEr9K2HwXw== + +"@oxc-parser/binding-win32-ia32-msvc@0.127.0": + version "0.127.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.127.0.tgz#dac30de6971dbe63aa5722be9a4cc070fd3c650e" + integrity sha512-0cwxKO7KHQQQfo4Uf4B2SQrhgm+cJaP9OvFFhx52Tkg4bezsacu83GB2/In5bC415Ueeym+kXdnge/57rbSfTw== + +"@oxc-parser/binding-win32-x64-msvc@0.127.0": + version "0.127.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.127.0.tgz#a2df879b0803f72b350a7567365cee5b8978edf0" + integrity sha512-rOrnSQSCbhI2kowr9XxE7m9a8oQXnBHjnS6j95LxxAnEZ0+Fz20WlRXG4ondQb+ejjt2KOsa65sE6++L6kUd+w== + +"@oxc-project/types@^0.127.0": + version "0.127.0" + resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.127.0.tgz#8374fcdfb4a641861218daa5700c447c00b66663" + integrity sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ== + +"@oxc-resolver/binding-android-arm-eabi@11.22.0": + version "11.22.0" + resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.22.0.tgz#aaa63b5603beafe469b2600e0f888d37c70b85c6" + integrity sha512-il+0FB7BBUfuQaE0Lgd9zlgSjzu88ErN8vr4hintuTt1qRDcPtmzLyurail1gJZpJ1ljo7zA0cid/a/PaWMyZg== + +"@oxc-resolver/binding-android-arm64@11.22.0": + version "11.22.0" + resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.22.0.tgz#9715e981b385576f8ab52429e6bcaf584f0a82c0" + integrity sha512-rWCyvcoiMxb5JRsGMXI22DFAlsddZHOTBWp/zz48E85Yh/KWQRFko18Gf5xsRdcN0pz65VFnJoisz/B2LWoaGg== + +"@oxc-resolver/binding-darwin-arm64@11.22.0": + version "11.22.0" + resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.22.0.tgz#74c7781e90a75b173b44e2b1b1ef69ea60830b25" + integrity sha512-eDx1up8xhb6OH58RfcADHAKWQY3yatNAbrOF2QEqDN2ml0DsOlHBNgj7E5NB3kU62yam2VEYnFTMO8meOYEe1w== + +"@oxc-resolver/binding-darwin-x64@11.22.0": + version "11.22.0" + resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.22.0.tgz#d0fe6275665ee503cb04a446b90b3eb2b51bfa66" + integrity sha512-4YUMAsVqqQGzkq7eDWEZXUvzm1L7eZFd4jghnoDv76fPF2IisedcBjkJY3iwcAlWQNtZLgc5Od/cL0Z2ogEwaQ== + +"@oxc-resolver/binding-freebsd-x64@11.22.0": + version "11.22.0" + resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.22.0.tgz#fb2d83dbe2c67ac43dcbc4deebb346e9f0ca730f" + integrity sha512-I7qjjmCzrqPme94B9b9deHID6YiggKQRy3s9mTjnUuYlpgDx5YgC1G00W2S/Cchrjz5I8VOik17/3uO4joPULw== + +"@oxc-resolver/binding-linux-arm-gnueabihf@11.22.0": + version "11.22.0" + resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.22.0.tgz#5b5bcba035e92423c323aed8efcc0495ef06ec99" + integrity sha512-VsI6Vnsyg9O5jLv+bkYP15yHv924i63fLbROZAZfwAAUJ611FF8OE4aCX2KzsG70yRlcn4n7Zh0fyHT5L4myGA== + +"@oxc-resolver/binding-linux-arm-musleabihf@11.22.0": + version "11.22.0" + resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.22.0.tgz#99b2d6a2c51795c06288abaf7a46010d3a3bb19d" + integrity sha512-Imedx3sbderR0w8HHZ+vH7PqrY7eL3H7cj666Yrg+erelaRCVzXlJjQD5w0vNk+RtGDqhmnP5R18WIowFCI+uA== + +"@oxc-resolver/binding-linux-arm64-gnu@11.22.0": + version "11.22.0" + resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.22.0.tgz#9054470057f6c20ca5c4a499e28af738017b06fc" + integrity sha512-a9L5IxPBpiSXcvEPGNWOpD5pKbcE0VgC5smcaYn0t/oMaJxHwejrJ1qRoXZLj767HAo+nq9FNEpZ9WFW91lP8g== + +"@oxc-resolver/binding-linux-arm64-musl@11.22.0": + version "11.22.0" + resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.22.0.tgz#08ad9dcee84991bede3e3dab9f10417388629664" + integrity sha512-4AFo9hX0AbA/o7qWrsrAHbRGsZpthcUEZiuMHlxqZsR4JNgvFmeuLtMXUV+KPHWo+gfefFmiQ0UdUx8GPBCrXQ== + +"@oxc-resolver/binding-linux-ppc64-gnu@11.22.0": + version "11.22.0" + resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.22.0.tgz#5690f4c624d297c01cf44b9da6dfc2fc990d16ff" + integrity sha512-QVPpxFDkLxWAnfAqN0DA1TH4agOPL1bxg7dwUZ7goQKU5IfaPoL/ZcPClMol4+Dwb30g2nPNxbr0BPyFmcVV3A== + +"@oxc-resolver/binding-linux-riscv64-gnu@11.22.0": + version "11.22.0" + resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.22.0.tgz#40d3ca2f3a755a8094b5d5e36fee7b16e2a1e147" + integrity sha512-saQJeKGMCrtW5DH8uY9N9pPE4/8Hs+DpZ4hJg1+SzvISSKhTf3V6/jOROxluU14ftz5KNd8G/NXRgj9vTS0Emg== + +"@oxc-resolver/binding-linux-riscv64-musl@11.22.0": + version "11.22.0" + resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.22.0.tgz#e14c0ff46b8ed05473da0aae5e924f3daf313f66" + integrity sha512-/p6aCGRKot+Je44l+WoL+zkizRXY4ApQcvRXlLw8lRM305tmmEqNtAyekDLMCzn8DUt81lS3ZsiUpdn0bL533A== + +"@oxc-resolver/binding-linux-s390x-gnu@11.22.0": + version "11.22.0" + resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.22.0.tgz#27030d6d33e76e5109bbfdc10d3e05a0586dc5cc" + integrity sha512-ZYfI5CG/W1C+HXDWkJ5+JPjiuwVQw6HBD1jUTneAzJVWImRDjstQPKmixCa1fTkthCM1OCkn2D8fdX+q37kMXQ== + +"@oxc-resolver/binding-linux-x64-gnu@11.22.0": + version "11.22.0" + resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.22.0.tgz#0af76104d588447af0f63305835c4e6a1b5f5c0f" + integrity sha512-IR2juRKWbR6TmFZTn6plHFm5iXWD8Szw/fGeKhaGWzwTPN/Oq4CCV6ZVp8Bq8ih2easVh7Mwom2A5CGkB+QVxg== + +"@oxc-resolver/binding-linux-x64-musl@11.22.0": + version "11.22.0" + resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.22.0.tgz#c96c73129a3798908420531f543a2b42468d62d7" + integrity sha512-TCE/wgDr3EaxQrwQrU9MbRK35cFsYAVwMT2Du0lbyjmlaXV03uPLnCKIDDmxUPyQUdPgZirM+k26GDR3LNs+hw== + +"@oxc-resolver/binding-openharmony-arm64@11.22.0": + version "11.22.0" + resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.22.0.tgz#17da0dc393e005c3b147eea451c9eef2a58442e8" + integrity sha512-uhNpwQzWnYVBZ6ZZomIQN2X/jUDLp3HYjLSVbdsZqA4hNpYSFENSF8JV6I6gdvvV9TLQr1rC/viDsxhvE/5/Ww== + +"@oxc-resolver/binding-wasm32-wasi@11.22.0": + version "11.22.0" + resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.22.0.tgz#c63fdecb16d1d15252546b058394cfb6d3b85814" + integrity sha512-nn8NCiQhh3rgwrGMf8msky7MjPd0W8Vwmo7oZr5cs7TOJby2IRMZYPPgb+NTz8vVG6VjjjcLPriIMxHE1aAx1A== + dependencies: + "@emnapi/core" "1.11.1" + "@emnapi/runtime" "1.11.1" + "@napi-rs/wasm-runtime" "^1.1.6" + +"@oxc-resolver/binding-win32-arm64-msvc@11.22.0": + version "11.22.0" + resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.22.0.tgz#281fce9107ddcccaee6536e989b9894d5b0d6c36" + integrity sha512-kHx9KiAKQKeSW/yVmt5tUa7oAHUd9OsoB5fdpQjSBDaKtHSQpPIXxNJ1E9q8cnaxS9NNWANR6w3osEHBD+5lSg== + +"@oxc-resolver/binding-win32-x64-msvc@11.22.0": + version "11.22.0" + resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.22.0.tgz#00d45727f9f7dea1dc31902143ae68e1fc765552" + integrity sha512-gZKq7BTQBdAmvFYGQfqkuz3zeoytmAkluFjajToSkWUTed0DsJ3GdCoXpPdpGSElBLLIeAgKM3ju6XdPvKOr0A== + "@parcel/watcher-android-arm64@2.5.6": version "2.5.6" resolved "https://registry.yarnpkg.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz#5f32e0dba356f4ac9a11068d2a5c134ca3ba6564" @@ -3741,102 +4005,40 @@ resolved "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz" integrity sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g== -"@storybook/addon-docs@^8.6.18": - version "8.6.18" - resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-8.6.18.tgz#1910942ecdff4e5cda6352d22bc483f0c2058f61" - integrity sha512-55ADer0yNmmeR928Y3UAv3r4i7bJSd9LwywsQ+lRol/FNe0ZcwLEz31xL+jVsqQFNnDh/imsDIp8aYapGMtfEQ== +"@storybook/addon-docs@^10.4.5": + version "10.4.6" + resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-10.4.6.tgz#921d982183444e43c3df32811f5f760ae2935ad4" + integrity sha512-aWAfP5JMiT5a3zBJizwroCRzOCqZwDTJmvsYvwMD3ilIEa/kT1vhf6Xrbk4XIPhDwbh8Hpb/Gfnka1xBYEISWg== dependencies: "@mdx-js/react" "^3.0.0" - "@storybook/blocks" "8.6.18" - "@storybook/csf-plugin" "8.6.18" - "@storybook/react-dom-shim" "8.6.18" + "@storybook/csf-plugin" "10.4.6" + "@storybook/icons" "^2.0.2" + "@storybook/react-dom-shim" "10.4.6" react "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" react-dom "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" ts-dedent "^2.0.0" -"@storybook/blocks@8.6.18", "@storybook/blocks@^8.6.15": - version "8.6.18" - resolved "https://registry.yarnpkg.com/@storybook/blocks/-/blocks-8.6.18.tgz#d1bf7e9639a86cdf690bea1c53028be725afb1e8" - integrity sha512-esZv4msPQ9LxgTb8YUIZhhxVMuI6BPi5bkXtk8c7w7sWuAsqsCe/RnVInn7ooUry2gjnD4hd9+8Eqj0b8oTVoA== +"@storybook/csf-plugin@10.4.6": + version "10.4.6" + resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-10.4.6.tgz#cd3362ada67417f1ce9e8e31d2dc403f05b65d91" + integrity sha512-NILLxDqpA/JR/AazGWpsz+4fadJwRU4uhHephGtYpVOWnQA/DkJfKT6zpcJVq8+QA8A2zKMLX3GVKsXIrxjuDA== dependencies: - "@storybook/icons" "^1.2.12" - ts-dedent "^2.0.0" + unplugin "^2.3.5" -"@storybook/channels@^8.6.18": - version "8.6.18" - resolved "https://registry.yarnpkg.com/@storybook/channels/-/channels-8.6.18.tgz#21bf4624badc41f343ac7e182ba7a88c5d682bff" - integrity sha512-J/xabOEHfMYEWpdm4gR6HD5IdC0e7OsNvgUEspQjcUMhjMwtGm/EaahwNpRUIxO2tgzKj4zHnflGfPCfTd4PgQ== +"@storybook/global@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@storybook/global/-/global-5.0.0.tgz#b793d34b94f572c1d7d9e0f44fac4e0dbc9572ed" + integrity sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ== -"@storybook/client-logger@^8.6.18": - version "8.6.18" - resolved "https://registry.yarnpkg.com/@storybook/client-logger/-/client-logger-8.6.18.tgz#21b95c5ecb30475ad5a1fa68c0af603a4199c01b" - integrity sha512-l7x3KkumMcTN+R1ozAqEyAkHpNBonIvicYoTgha/3Dh/tKiBYLLum2AEXbiu0TBJ7EEUfi4AG7eOBBfVdfWqvQ== +"@storybook/icons@^2.0.2": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@storybook/icons/-/icons-2.1.0.tgz#edfc2450a39c5e780f28c6cbc49acd7bff59b41a" + integrity sha512-Fxh9vYpX9bQqFeHRiY8h2ApeRGDzRSMLwJwNZ/AIRqnyOKHxRKL+yFe+ctEkVJmuptRE9u1Hrn8ZZNHyfDKKNg== -"@storybook/components@^8.6.18": - version "8.6.18" - resolved "https://registry.yarnpkg.com/@storybook/components/-/components-8.6.18.tgz#0e5431f9d84cae29a8b8a406c9ad99406bf2ccb4" - integrity sha512-55yViiZzPS/cPBuOeW4QGxGqrusjXVyxuknmbYCIwDtFyyvI/CgbjXRHdxNBaIjz+IlftxvBmmSaOqFG5+/dkA== - -"@storybook/core-events@^8.6.18": - version "8.6.18" - resolved "https://registry.yarnpkg.com/@storybook/core-events/-/core-events-8.6.18.tgz#aaaf2a544fdb07036a08200692bb88a96d9df651" - integrity sha512-eUVwrcppny/ZYyke/SPVZVuco8wxkQ/0K20nlevSiDkgWZSELii5Ju0/l9Ubnopr9dshoFCYbC7q6liTSpok7A== - -"@storybook/core@8.6.18", "@storybook/core@^8.6.18": - version "8.6.18" - resolved "https://registry.yarnpkg.com/@storybook/core/-/core-8.6.18.tgz#0ddbec8421715b372419ae5dfefef3df5848386c" - integrity sha512-dRBP2TnX6fGdS0T2mXBHjkS/3Nlu1ra1huovZVFuM67CYMzrhM/3hX/zru1vWSC5rqY93ZaAhjMciPW4pK5mMQ== - dependencies: - "@storybook/theming" "8.6.18" - better-opn "^3.0.2" - browser-assert "^1.2.1" - esbuild "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0" - esbuild-register "^3.5.0" - jsdoc-type-pratt-parser "^4.0.0" - process "^0.11.10" - recast "^0.23.5" - semver "^7.6.2" - util "^0.12.5" - ws "^8.2.3" - -"@storybook/csf-plugin@8.6.18": - version "8.6.18" - resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-8.6.18.tgz#f92cede49c71d4381187884d72e41ee44d324d3b" - integrity sha512-x1ioz/L0CwaelCkHci3P31YtvwayN3FBftvwQOPbvRh9qeb4Cpz5IdVDmyvSxxYwXN66uAORNoqgjTi7B4/y5Q== - dependencies: - unplugin "^1.3.1" - -"@storybook/csf@^0.1.13": - version "0.1.13" - resolved "https://registry.npmjs.org/@storybook/csf/-/csf-0.1.13.tgz" - integrity sha512-7xOOwCLGB3ebM87eemep89MYRFTko+D8qE7EdAAq74lgdqRR5cOUtYWJLjO2dLtP94nqoOdHJo6MdLLKzg412Q== - dependencies: - type-fest "^2.19.0" - -"@storybook/docs-tools@^8.6.18": - version "8.6.18" - resolved "https://registry.yarnpkg.com/@storybook/docs-tools/-/docs-tools-8.6.18.tgz#ba79b08a41131f97d9c6970c48651552763acbcf" - integrity sha512-43ggjDA1ZV0FWjMlNBkKC1VWQ6zDQmSj0WWWqivGQdnBt4dufYQFXnbQeFr9Og+3OjZYmr3KTrLCjDiyCGOgjg== - -"@storybook/icons@^1.2.12": - version "1.4.0" - resolved "https://registry.npmjs.org/@storybook/icons/-/icons-1.4.0.tgz" - integrity sha512-Td73IeJxOyalzvjQL+JXx72jlIYHgs+REaHiREOqfpo3A2AYYG71AUbcv+lg7mEDIweKVCxsMQ0UKo634c8XeA== - -"@storybook/preview-api@^8.6.18": - version "8.6.18" - resolved "https://registry.yarnpkg.com/@storybook/preview-api/-/preview-api-8.6.18.tgz#2f5eb75c7587035a07670457c09b67208aa16735" - integrity sha512-joXRXh3GdVvzhbfIgmix1xs90p8Q/nja7AhEAC2egn5Pl7SKsIYZUCYI6UdrQANb2myg9P552LKXfPect8llKg== - -"@storybook/react-dom-shim@8.6.18": - version "8.6.18" - resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-8.6.18.tgz#34bdc010d3c3572fc74fa149f754d185df85044e" - integrity sha512-N4xULcAWZQTUv4jy1/d346Tyb4gufuC3UaLCuU/iVSZ1brYF4OW3ANr+096btbMxY8pR/65lmtoqr5CTGwnBvA== - -"@storybook/theming@8.6.18", "@storybook/theming@^8.6.15": - version "8.6.18" - resolved "https://registry.yarnpkg.com/@storybook/theming/-/theming-8.6.18.tgz#18c66263868bfb00a419772b5460a5714c5e1181" - integrity sha512-n6OEjEtHupa2PdTwWzRepr7cO8NkDd4rgF6BKLitRbujOspLxzMBEqdphs+QLcuiCIgf33SqmEA64QWnbSMhPw== +"@storybook/react-dom-shim@10.4.6": + version "10.4.6" + resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-10.4.6.tgz#9c9921ead173f854b71395eb646e02c28c1257a8" + integrity sha512-iGNmKzrq9vgl2PDrYAnZKI+yvac3Ym+lJXXuQaqlFRS23zA5MNm4EBX+rAG7WulqchoK6NaZ0KQOs2mAgEpTMg== "@superset-ui/core@^0.20.4": version "0.20.4" @@ -3986,6 +4188,18 @@ ramda-adjunct "^5.0.0" unraw "^3.0.0" +"@swagger-api/apidom-ast@^1.11.3": + version "1.11.3" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ast/-/apidom-ast-1.11.3.tgz#aeda3142e895ea1798a8da8293a5abdd62cd9a33" + integrity sha512-Nfi/0vy+cIHClX7raXamtHnCMBbwI1PEg+yroIzyy8LcCH7zcS0Xi4ARG3CkDQswOnWO2gLDAUAFjDvkQWdZ+A== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-error" "^1.11.3" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + unraw "^3.0.0" + "@swagger-api/apidom-core@^1.11.0": version "1.11.0" resolved "https://registry.yarnpkg.com/@swagger-api/apidom-core/-/apidom-core-1.11.0.tgz#7a1d60ad121bd32af46685c4888e64414dff5f80" @@ -4001,6 +4215,21 @@ short-unique-id "^5.3.2" ts-mixer "^6.0.3" +"@swagger-api/apidom-core@^1.11.3": + version "1.11.3" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-core/-/apidom-core-1.11.3.tgz#90d7a8e6b5bd974d8d30aa842bceac1b12a50196" + integrity sha512-21/PXEqCzsWkiwKWHt0TPJE7GUtog3BhMlvYLUEPbteXOrk+PNazbjYDPl4zfZGRLaoGhTqpFBY5pXafdzaHWQ== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-ast" "^1.11.3" + "@swagger-api/apidom-error" "^1.11.3" + "@types/ramda" "~0.30.0" + minim "~0.23.8" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + short-unique-id "^5.3.2" + ts-mixer "^6.0.3" + "@swagger-api/apidom-error@^1.11.0": version "1.11.0" resolved "https://registry.yarnpkg.com/@swagger-api/apidom-error/-/apidom-error-1.11.0.tgz#7f2235e9068e8e1b3c87a43835623c623d0f7392" @@ -4008,6 +4237,13 @@ dependencies: "@babel/runtime-corejs3" "^7.20.7" +"@swagger-api/apidom-error@^1.11.3": + version "1.11.3" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-error/-/apidom-error-1.11.3.tgz#36dea235c93803ccb751eab62b0a7cb439a769b2" + integrity sha512-Z4mIDyZUF2kDFHBzKsxkYSaHpGSvGDc7gAkdzLKxcQk4849iCUXxs0PpQLxZxfYcmUSwkw7AZmIP/OKGpsR8JA== + dependencies: + "@babel/runtime-corejs3" "^7.20.7" + "@swagger-api/apidom-json-pointer@^1.11.0": version "1.11.0" resolved "https://registry.yarnpkg.com/@swagger-api/apidom-json-pointer/-/apidom-json-pointer-1.11.0.tgz#f129dec39b749a09d03ad84909033b998a886339" @@ -4018,6 +4254,69 @@ "@swagger-api/apidom-error" "^1.11.0" "@swaggerexpert/json-pointer" "^2.10.1" +"@swagger-api/apidom-json-pointer@^1.11.3": + version "1.11.3" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-json-pointer/-/apidom-json-pointer-1.11.3.tgz#a9a93ca628899929a141e8e90cb96e68e67c96e3" + integrity sha512-z6eEvJ5HIb0aFqVldHyU5Guut+bv/opu28i+rrfDb1LEG3IWk5T3DbW9d/nWpAfNze/bjwiZvPphmkuycijb2w== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-core" "^1.11.3" + "@swagger-api/apidom-error" "^1.11.3" + "@swaggerexpert/json-pointer" "^2.10.1" + +"@swagger-api/apidom-ns-api-design-systems@^1.11.3": + version "1.11.3" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-api-design-systems/-/apidom-ns-api-design-systems-1.11.3.tgz#da43781d1d73fda7428f80cde33306e46fa84d89" + integrity sha512-HTISScqScdnUc2BKqMaWM+ISU79AlHlr+XHUR9g0R8QiO4KzCkWUi6TncP0gvGoEY+BvfR0OkztfxYrelkksmg== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-core" "^1.11.3" + "@swagger-api/apidom-error" "^1.11.3" + "@swagger-api/apidom-ns-openapi-3-1" "^1.11.3" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + ts-mixer "^6.0.3" + +"@swagger-api/apidom-ns-arazzo-1@^1.11.0", "@swagger-api/apidom-ns-arazzo-1@^1.11.3": + version "1.11.3" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-arazzo-1/-/apidom-ns-arazzo-1-1.11.3.tgz#8712c930533df7313df2af2012daab999f4abd1a" + integrity sha512-AHRSbuYy5oA8c/7j7nahxMASjt0cuOWyUk4yu+cKG+KS85ho2f2NiFThqQjtIJLisvrp+qNniJ1EwTfcCTivTQ== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-core" "^1.11.3" + "@swagger-api/apidom-ns-json-schema-2020-12" "^1.11.3" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + ts-mixer "^6.0.3" + +"@swagger-api/apidom-ns-asyncapi-2@^1.11.0", "@swagger-api/apidom-ns-asyncapi-2@^1.11.3": + version "1.11.3" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-asyncapi-2/-/apidom-ns-asyncapi-2-1.11.3.tgz#8a798610871242f69588243a0bd1e7c7139eecf6" + integrity sha512-bn/Pf52SCsSGcw7qO3gAje+KjhhpWJxWjjWQe5Jv9oPoXCnvGoSspSXMY3zIrhz9XfrP94AdWBUpOlWBoaaDBQ== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-core" "^1.11.3" + "@swagger-api/apidom-ns-json-schema-draft-7" "^1.11.3" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + ts-mixer "^6.0.3" + +"@swagger-api/apidom-ns-asyncapi-3@^1.11.3": + version "1.11.3" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-asyncapi-3/-/apidom-ns-asyncapi-3-1.11.3.tgz#44d85d1fac51cbecdfd64a5041790fb30fac22f9" + integrity sha512-QhZtf8YeMRVtgVioJj0qKdNG0LEzi9RPSrC9KWfCdwEBlkhNDhHFh6FIJWayYAwR6DPlOXjTYQf97V9QwIuRmQ== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-core" "^1.11.3" + "@swagger-api/apidom-ns-asyncapi-2" "^1.11.3" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + ts-mixer "^6.0.3" + "@swagger-api/apidom-ns-json-schema-2019-09@^1.11.0": version "1.11.0" resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-json-schema-2019-09/-/apidom-ns-json-schema-2019-09-1.11.0.tgz#18531aa5a09192d2f296474f458b493e64f40d5c" @@ -4032,6 +4331,20 @@ ramda-adjunct "^5.0.0" ts-mixer "^6.0.4" +"@swagger-api/apidom-ns-json-schema-2019-09@^1.11.3": + version "1.11.3" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-json-schema-2019-09/-/apidom-ns-json-schema-2019-09-1.11.3.tgz#5fd1ee5fc509a6ab842473dcaa3719bc9dd99791" + integrity sha512-zHA4/oin6Fg6VN0bWmMRu1nt4Xnd05mF5T+Od6vEmid1bxDXpfSSMaPpdu+WCBJ5dDX19EeBVTJoAI0U4psxBg== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-core" "^1.11.3" + "@swagger-api/apidom-error" "^1.11.3" + "@swagger-api/apidom-ns-json-schema-draft-7" "^1.11.3" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + ts-mixer "^6.0.4" + "@swagger-api/apidom-ns-json-schema-2020-12@^1.11.0": version "1.11.0" resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-json-schema-2020-12/-/apidom-ns-json-schema-2020-12-1.11.0.tgz#deb31661039e6a0be281173ab959644ed75ebe69" @@ -4046,6 +4359,20 @@ ramda-adjunct "^5.0.0" ts-mixer "^6.0.4" +"@swagger-api/apidom-ns-json-schema-2020-12@^1.11.3": + version "1.11.3" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-json-schema-2020-12/-/apidom-ns-json-schema-2020-12-1.11.3.tgz#3f8f0c158e4a8e0616e96b70635cf3a4961b6e05" + integrity sha512-zUWRb5DlvlZXtf2JmiNqUwwgGc4eUAsUQPU59kP140vkPT98HDhNJx9UXoHLgP5F/Zk8f2rzeG237u+JnCT9/g== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-core" "^1.11.3" + "@swagger-api/apidom-error" "^1.11.3" + "@swagger-api/apidom-ns-json-schema-2019-09" "^1.11.3" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + ts-mixer "^6.0.4" + "@swagger-api/apidom-ns-json-schema-draft-4@^1.11.0": version "1.11.0" resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-json-schema-draft-4/-/apidom-ns-json-schema-draft-4-1.11.0.tgz#45cd21eb80541c80cb503c332aeda8eac2f245fd" @@ -4059,6 +4386,19 @@ ramda-adjunct "^5.0.0" ts-mixer "^6.0.4" +"@swagger-api/apidom-ns-json-schema-draft-4@^1.11.3": + version "1.11.3" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-json-schema-draft-4/-/apidom-ns-json-schema-draft-4-1.11.3.tgz#790732f9c9dcc548453d7c0396be4e316a70e052" + integrity sha512-udOM8ZQT7BzsyT9012R7/VjeazjOlyLwKpmpbgHZDG6uGHqsnWn9lN+F8/5Eox9qqBnQNqDNtDhBEMYjE6RYNQ== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-ast" "^1.11.3" + "@swagger-api/apidom-core" "^1.11.3" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + ts-mixer "^6.0.4" + "@swagger-api/apidom-ns-json-schema-draft-6@^1.11.0": version "1.11.0" resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-json-schema-draft-6/-/apidom-ns-json-schema-draft-6-1.11.0.tgz#c251d9dfe26a39f9cfa7d316251112f3cb541ffd" @@ -4073,6 +4413,20 @@ ramda-adjunct "^5.0.0" ts-mixer "^6.0.4" +"@swagger-api/apidom-ns-json-schema-draft-6@^1.11.3": + version "1.11.3" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-json-schema-draft-6/-/apidom-ns-json-schema-draft-6-1.11.3.tgz#188aba6798189b4c22b1050dc9184a77336b3c81" + integrity sha512-ZOgo5OtpQ6e5XQ5R1H8CYTYXEtnD0l+FyN065A6c2O1NxJXFKS2a5SZTvx+udA4mugtkTSmr7hAQe2ae+KAXQw== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-core" "^1.11.3" + "@swagger-api/apidom-error" "^1.11.3" + "@swagger-api/apidom-ns-json-schema-draft-4" "^1.11.3" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + ts-mixer "^6.0.4" + "@swagger-api/apidom-ns-json-schema-draft-7@^1.11.0": version "1.11.0" resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-json-schema-draft-7/-/apidom-ns-json-schema-draft-7-1.11.0.tgz#3d8416635d60914e0699becb0681370b1b2aeb2c" @@ -4087,6 +4441,34 @@ ramda-adjunct "^5.0.0" ts-mixer "^6.0.4" +"@swagger-api/apidom-ns-json-schema-draft-7@^1.11.3": + version "1.11.3" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-json-schema-draft-7/-/apidom-ns-json-schema-draft-7-1.11.3.tgz#44eefe84c09b15842bf6bdebd93e80bded7e3378" + integrity sha512-L5Sc0qMUrUbgVex3fN+tnNHjed/JCAwpwyzdHY8KgUfXHkfM5aLUyyAGirm+ObwZvGDEejsTT2Otp6B5S29eqw== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-core" "^1.11.3" + "@swagger-api/apidom-error" "^1.11.3" + "@swagger-api/apidom-ns-json-schema-draft-6" "^1.11.3" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + ts-mixer "^6.0.4" + +"@swagger-api/apidom-ns-openapi-2@^1.11.0", "@swagger-api/apidom-ns-openapi-2@^1.11.3": + version "1.11.3" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-openapi-2/-/apidom-ns-openapi-2-1.11.3.tgz#54719b81f47e387bbc26e557cbc81580e1bf014f" + integrity sha512-T241UD+1My1hVJHayTCz9f7rznmeM8rxQW4NtUnD8k677SShpnNkrfeoc1elrpJXdTsnEPIOLCK0EEIyEplHgA== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-core" "^1.11.3" + "@swagger-api/apidom-error" "^1.11.3" + "@swagger-api/apidom-ns-json-schema-draft-4" "^1.11.3" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + ts-mixer "^6.0.3" + "@swagger-api/apidom-ns-openapi-3-0@^1.11.0": version "1.11.0" resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-openapi-3-0/-/apidom-ns-openapi-3-0-1.11.0.tgz#4b44fb8a292439f4966bedef7d0c484926ece0e5" @@ -4101,6 +4483,20 @@ ramda-adjunct "^5.0.0" ts-mixer "^6.0.3" +"@swagger-api/apidom-ns-openapi-3-0@^1.11.3": + version "1.11.3" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-openapi-3-0/-/apidom-ns-openapi-3-0-1.11.3.tgz#2c4192c1ac8c95009e0d5401c25c271c75eabf60" + integrity sha512-FZvBqWpZFciemMgWBGY89RIH5uLl6ZYtmMAhWzDcmEi1JSjmWKkoqZno//KlqSgbI09gbkzipdMjGK825givPA== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-core" "^1.11.3" + "@swagger-api/apidom-error" "^1.11.3" + "@swagger-api/apidom-ns-json-schema-draft-4" "^1.11.3" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + ts-mixer "^6.0.3" + "@swagger-api/apidom-ns-openapi-3-1@^1.11.0": version "1.11.0" resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-openapi-3-1/-/apidom-ns-openapi-3-1-1.11.0.tgz#51ebf009a2e1afc4704a665dbed993516560375e" @@ -4117,6 +4513,22 @@ ramda-adjunct "^5.0.0" ts-mixer "^6.0.3" +"@swagger-api/apidom-ns-openapi-3-1@^1.11.3": + version "1.11.3" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-openapi-3-1/-/apidom-ns-openapi-3-1-1.11.3.tgz#a24564b597bbaecaf8ca7416b2b18f6ed11fd0f1" + integrity sha512-ObCP+l3/ZhuPaSqXqmSnCpEOIMalQns0c4IZgX6h3o7Jc6K6BcqDMeq4s17dwcQja9fFUtkoIIFuquJBKvK86Q== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-ast" "^1.11.3" + "@swagger-api/apidom-core" "^1.11.3" + "@swagger-api/apidom-json-pointer" "^1.11.3" + "@swagger-api/apidom-ns-json-schema-2020-12" "^1.11.3" + "@swagger-api/apidom-ns-openapi-3-0" "^1.11.3" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + ts-mixer "^6.0.3" + "@swagger-api/apidom-ns-openapi-3-2@^1.11.0": version "1.11.0" resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-openapi-3-2/-/apidom-ns-openapi-3-2-1.11.0.tgz#7486802e6168b00814425c01093b74d948a7f65c" @@ -4134,10 +4546,302 @@ ramda-adjunct "^5.0.0" ts-mixer "^6.0.3" +"@swagger-api/apidom-ns-openapi-3-2@^1.11.3": + version "1.11.3" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-openapi-3-2/-/apidom-ns-openapi-3-2-1.11.3.tgz#024f402868cb88e997e4bd97ac33ae5864169167" + integrity sha512-Ri7KLrfrpJeteghUdzbozxKve2NG5Z1aPWX91DI14j75+v/xBAu91AIZt7K+LV8JWUNk+VbhUlhgSH1JAIVa0g== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-ast" "^1.11.3" + "@swagger-api/apidom-core" "^1.11.3" + "@swagger-api/apidom-json-pointer" "^1.11.3" + "@swagger-api/apidom-ns-json-schema-2020-12" "^1.11.3" + "@swagger-api/apidom-ns-openapi-3-0" "^1.11.3" + "@swagger-api/apidom-ns-openapi-3-1" "^1.11.3" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + ts-mixer "^6.0.3" + +"@swagger-api/apidom-parser-adapter-api-design-systems-json@^1.11.0": + version "1.11.3" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-parser-adapter-api-design-systems-json/-/apidom-parser-adapter-api-design-systems-json-1.11.3.tgz#34849034b52c36d0cf1d0d394f724df9295b1c69" + integrity sha512-6LcwiY2Ce1qmsq8CSxCoRMJ3q2quFnEsNgI48LpM9/PYK/idjsB/WrnU9xSl9fXm/aTo/bOO0ckc8KadxVykfA== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-core" "^1.11.3" + "@swagger-api/apidom-ns-api-design-systems" "^1.11.3" + "@swagger-api/apidom-parser-adapter-json" "^1.11.3" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + +"@swagger-api/apidom-parser-adapter-api-design-systems-yaml@^1.11.0": + version "1.11.3" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-parser-adapter-api-design-systems-yaml/-/apidom-parser-adapter-api-design-systems-yaml-1.11.3.tgz#2835667bf65464dbc1bf809be3b407d61077c21e" + integrity sha512-zk2NuWAOvjEHYQ3lZ43VhAqJk+9KK452HeFfZFne4a4iALhCQ/wceK7tCG81jBMMoWnR0f1JS/vxz7/Y2CNwLA== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-core" "^1.11.3" + "@swagger-api/apidom-ns-api-design-systems" "^1.11.3" + "@swagger-api/apidom-parser-adapter-yaml-1-2" "^1.11.3" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + +"@swagger-api/apidom-parser-adapter-arazzo-json-1@^1.11.0": + version "1.11.3" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-parser-adapter-arazzo-json-1/-/apidom-parser-adapter-arazzo-json-1-1.11.3.tgz#dae663448bbb534bfa4aaca43282750a64799d2e" + integrity sha512-vXP6lYp6Q5/5nLkMJyBRTWYIZYxL6GnOsJsdnN0KdTHuoPXNZWsv5p2oPYlVE/W7gRAZDylUmYZQ1L9rr7BGRw== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-core" "^1.11.3" + "@swagger-api/apidom-ns-arazzo-1" "^1.11.3" + "@swagger-api/apidom-parser-adapter-json" "^1.11.3" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + +"@swagger-api/apidom-parser-adapter-arazzo-yaml-1@^1.11.0": + version "1.11.3" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-parser-adapter-arazzo-yaml-1/-/apidom-parser-adapter-arazzo-yaml-1-1.11.3.tgz#49e4114be22485ed98b1090c7ab5aa050762af1d" + integrity sha512-zI57Q5DIf7TGv1A2gIk3umbPWMEFWYO3tEmfP0xJGDBn6gZRHgJvvVjn0wQrljigEEw1V3hRDUXk6Zzg3oVNvA== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-core" "^1.11.3" + "@swagger-api/apidom-ns-arazzo-1" "^1.11.3" + "@swagger-api/apidom-parser-adapter-yaml-1-2" "^1.11.3" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + +"@swagger-api/apidom-parser-adapter-asyncapi-json-2@^1.11.0": + version "1.11.3" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-parser-adapter-asyncapi-json-2/-/apidom-parser-adapter-asyncapi-json-2-1.11.3.tgz#43f7c50534352a2da2374209990284fb3fcda3d8" + integrity sha512-VGl15mpHaL+SlsXaRvcVaWXir+pCLrj99QN//Kuir3cpPW6P9IdBztj11bjoIG3aX1bOIrU16/uOhV4G5J4Kag== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-core" "^1.11.3" + "@swagger-api/apidom-ns-asyncapi-2" "^1.11.3" + "@swagger-api/apidom-parser-adapter-json" "^1.11.3" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + +"@swagger-api/apidom-parser-adapter-asyncapi-json-3@^1.11.0": + version "1.11.3" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-parser-adapter-asyncapi-json-3/-/apidom-parser-adapter-asyncapi-json-3-1.11.3.tgz#dd36230ed7cf8c86847dfb2079865edb3fa10a6b" + integrity sha512-37dNwdLAcAd7TZW8YqSbpuBoujfoM2AvXBoT1BUxzbYetwNkxP19Q1VtCEAzMxUEQ/9niBANSzuo0zClUEc2kw== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-core" "^1.11.3" + "@swagger-api/apidom-ns-asyncapi-3" "^1.11.3" + "@swagger-api/apidom-parser-adapter-json" "^1.11.3" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + +"@swagger-api/apidom-parser-adapter-asyncapi-yaml-2@^1.11.0": + version "1.11.3" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-parser-adapter-asyncapi-yaml-2/-/apidom-parser-adapter-asyncapi-yaml-2-1.11.3.tgz#30ac71ef28e1fd36922d1352aad52da99dab1d04" + integrity sha512-MEZHn+qROKehrXLhDhl7kSQADXjwU2NGSeNuBTr1/nINShpu4Tkxrk7xT0LY8GauKrZR9MyAnueATDQgK8NWhQ== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-core" "^1.11.3" + "@swagger-api/apidom-ns-asyncapi-2" "^1.11.3" + "@swagger-api/apidom-parser-adapter-yaml-1-2" "^1.11.3" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + +"@swagger-api/apidom-parser-adapter-asyncapi-yaml-3@^1.11.0": + version "1.11.3" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-parser-adapter-asyncapi-yaml-3/-/apidom-parser-adapter-asyncapi-yaml-3-1.11.3.tgz#e77c8b1e7d9f7c03f151df4148bd13628dca3f31" + integrity sha512-qHOK0NXnG7t5Gx0xCIbyfrM5FXLgK6krlZRPlrlT6K853MWWWWmjAieuayPtu72Ak1hd/8U2/oWgbONnPqj76w== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-core" "^1.11.3" + "@swagger-api/apidom-ns-asyncapi-3" "^1.11.3" + "@swagger-api/apidom-parser-adapter-yaml-1-2" "^1.11.3" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + +"@swagger-api/apidom-parser-adapter-json@^1.11.0", "@swagger-api/apidom-parser-adapter-json@^1.11.3": + version "1.11.3" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-parser-adapter-json/-/apidom-parser-adapter-json-1.11.3.tgz#9f555deccc80306ca97499dacc739f4adaa55c9d" + integrity sha512-Wj/DTb6mblsaxKfye9kXLwS+KmWWLAM2M2oSs4muOclhAOtcffr2H+STtiT3Hwrzb+cW8RpX6Htvey4rvbj/Jg== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-ast" "^1.11.3" + "@swagger-api/apidom-core" "^1.11.3" + "@swagger-api/apidom-error" "^1.11.3" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + tree-sitter "=0.21.1" + tree-sitter-json "=0.24.8" + web-tree-sitter "=0.24.5" + +"@swagger-api/apidom-parser-adapter-openapi-json-2@^1.11.0": + version "1.11.3" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-parser-adapter-openapi-json-2/-/apidom-parser-adapter-openapi-json-2-1.11.3.tgz#b0806d0f74a34656b3e5ad7d97fdcc3ddcb712f4" + integrity sha512-aDR5vKSApqQgDkvJNJZqvp8slEWHGByz5bzDbw6ZHRPSIZcA2IEHdJPgWJVS5gNtJ+YAnA7veM39oqeoF2yy8w== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-core" "^1.11.3" + "@swagger-api/apidom-ns-openapi-2" "^1.11.3" + "@swagger-api/apidom-parser-adapter-json" "^1.11.3" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + +"@swagger-api/apidom-parser-adapter-openapi-json-3-0@^1.11.0": + version "1.11.3" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-parser-adapter-openapi-json-3-0/-/apidom-parser-adapter-openapi-json-3-0-1.11.3.tgz#c1e5c1ec021530198d0a1f857bb5ec52b2a5a43a" + integrity sha512-hpSBiaVG7qbyJy25/Pl48NCT9uvvsOPAAj+xCAb9TwmIPBcFkVYvj6ZWJbYZsOmzUzcNLOZEOBjRNPN3G31zmQ== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-core" "^1.11.3" + "@swagger-api/apidom-ns-openapi-3-0" "^1.11.3" + "@swagger-api/apidom-parser-adapter-json" "^1.11.3" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + +"@swagger-api/apidom-parser-adapter-openapi-json-3-1@^1.11.0": + version "1.11.3" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-parser-adapter-openapi-json-3-1/-/apidom-parser-adapter-openapi-json-3-1-1.11.3.tgz#90ad9b7140351a1350190b51ad305f40615f29c5" + integrity sha512-EP7U25s5We8Q/2olymVrJL2nKT+skTea6SnR8F3OETsBoef3i1XgQx7AHqUc9S83hp+bOxZ+oPyI6dNwO+J+MA== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-core" "^1.11.3" + "@swagger-api/apidom-ns-openapi-3-1" "^1.11.3" + "@swagger-api/apidom-parser-adapter-json" "^1.11.3" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + +"@swagger-api/apidom-parser-adapter-openapi-json-3-2@^1.11.0": + version "1.11.3" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-parser-adapter-openapi-json-3-2/-/apidom-parser-adapter-openapi-json-3-2-1.11.3.tgz#fd6159147c03122064a3353425f212e99e4c4eb6" + integrity sha512-WLuMb0pwH+5hjosRHGvUFLm9iWP0/XNnEdrwdUio4y0eodjDwrPGb2LSdH3zaR8p7mKrqe007jqpjaLLsStzrQ== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-core" "^1.11.3" + "@swagger-api/apidom-ns-openapi-3-2" "^1.11.3" + "@swagger-api/apidom-parser-adapter-json" "^1.11.3" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + +"@swagger-api/apidom-parser-adapter-openapi-yaml-2@^1.11.0": + version "1.11.3" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-parser-adapter-openapi-yaml-2/-/apidom-parser-adapter-openapi-yaml-2-1.11.3.tgz#dffbde9ced3187a81613b73ebf18437591479224" + integrity sha512-PyD0E4fwOhOCcP/Aqa9HlvZYuw825E6N0IRC69dxxt6Fy6w6Fv2KYRnRw8OINoog8I6/8uSZOLdeDla3FCqhFA== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-core" "^1.11.3" + "@swagger-api/apidom-ns-openapi-2" "^1.11.3" + "@swagger-api/apidom-parser-adapter-yaml-1-2" "^1.11.3" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + +"@swagger-api/apidom-parser-adapter-openapi-yaml-3-0@^1.11.0": + version "1.11.3" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-parser-adapter-openapi-yaml-3-0/-/apidom-parser-adapter-openapi-yaml-3-0-1.11.3.tgz#bf6ff2fc7200c9d1b0f5c088abb3c950e9046491" + integrity sha512-7ULkLaEAAVKRNWT525PdKOaUjrZ7G2ulH9VBsMJ9niaZN/n6hnSW8IR4Iaf0cLZ1MjsZsdWovq5slPz2hpqR4A== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-core" "^1.11.3" + "@swagger-api/apidom-ns-openapi-3-0" "^1.11.3" + "@swagger-api/apidom-parser-adapter-yaml-1-2" "^1.11.3" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + +"@swagger-api/apidom-parser-adapter-openapi-yaml-3-1@^1.11.0": + version "1.11.3" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-parser-adapter-openapi-yaml-3-1/-/apidom-parser-adapter-openapi-yaml-3-1-1.11.3.tgz#d079f9ec371463200a8d8c7d4878e91fce76d6d2" + integrity sha512-2VqxGS0PaNrYeLgGyOl9m4m6EbJjrtxOswzWK2Rk3/M3gEDfnFdyOVffKWlcdEv6XFJ+wgLy5MPlODrzyOOKCA== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-core" "^1.11.3" + "@swagger-api/apidom-ns-openapi-3-1" "^1.11.3" + "@swagger-api/apidom-parser-adapter-yaml-1-2" "^1.11.3" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + +"@swagger-api/apidom-parser-adapter-openapi-yaml-3-2@^1.11.0": + version "1.11.3" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-parser-adapter-openapi-yaml-3-2/-/apidom-parser-adapter-openapi-yaml-3-2-1.11.3.tgz#f1cf0026708980bf1029021fa781b0f7adbc0678" + integrity sha512-fRJYMozAyIJP46kKbUPQX2wIzfX9FSC7ZxUn+VWOa587f3zZC3XmydGO46GA/TGuc8cWs7AFD/EfSKWDbsB3Tg== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-core" "^1.11.3" + "@swagger-api/apidom-ns-openapi-3-2" "^1.11.3" + "@swagger-api/apidom-parser-adapter-yaml-1-2" "^1.11.3" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + +"@swagger-api/apidom-parser-adapter-yaml-1-2@^1.11.0", "@swagger-api/apidom-parser-adapter-yaml-1-2@^1.11.3": + version "1.11.3" + resolved "https://registry.yarnpkg.com/@swagger-api/apidom-parser-adapter-yaml-1-2/-/apidom-parser-adapter-yaml-1-2-1.11.3.tgz#959bfd16fb3664e39bf24b92c19e5f4cea0fe08c" + integrity sha512-mSPMfVzDkLC+HPDI/6ho8TxI1PEcDJl/Y53gZdmfCU1xYzUdOF4s0iHalHHt0ipxYpt0EQXD+oGBnpp/TTx4IQ== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-ast" "^1.11.3" + "@swagger-api/apidom-core" "^1.11.3" + "@swagger-api/apidom-error" "^1.11.3" + "@tree-sitter-grammars/tree-sitter-yaml" "=0.7.1" + "@types/ramda" "~0.30.0" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + tree-sitter "=0.22.4" + web-tree-sitter "=0.24.5" + "@swagger-api/apidom-reference@^1.11.0": version "1.11.0" resolved "https://registry.yarnpkg.com/@swagger-api/apidom-reference/-/apidom-reference-1.11.0.tgz#9deaff0a93e46058c090946394dbb83975a86a51" integrity sha512-ftqegYrxxl9UwQFbdVOtXIqNolVd25M5u53X8fP96Wx6lEVr5Ed7B6+dzch8ttCUmKeoLIeagvt76b6BoYtnLw== + dependencies: + "@babel/runtime-corejs3" "^7.26.10" + "@swagger-api/apidom-core" "^1.11.0" + "@swagger-api/apidom-error" "^1.11.0" + "@types/ramda" "~0.30.0" + axios "^1.15.0" + minimatch "^10.2.1" + ramda "~0.30.0" + ramda-adjunct "^5.0.0" + optionalDependencies: + "@swagger-api/apidom-json-pointer" "^1.11.0" + "@swagger-api/apidom-ns-arazzo-1" "^1.11.0" + "@swagger-api/apidom-ns-asyncapi-2" "^1.11.0" + "@swagger-api/apidom-ns-openapi-2" "^1.11.0" + "@swagger-api/apidom-ns-openapi-3-0" "^1.11.0" + "@swagger-api/apidom-ns-openapi-3-1" "^1.11.0" + "@swagger-api/apidom-ns-openapi-3-2" "^1.11.0" + "@swagger-api/apidom-parser-adapter-api-design-systems-json" "^1.11.0" + "@swagger-api/apidom-parser-adapter-api-design-systems-yaml" "^1.11.0" + "@swagger-api/apidom-parser-adapter-arazzo-json-1" "^1.11.0" + "@swagger-api/apidom-parser-adapter-arazzo-yaml-1" "^1.11.0" + "@swagger-api/apidom-parser-adapter-asyncapi-json-2" "^1.11.0" + "@swagger-api/apidom-parser-adapter-asyncapi-json-3" "^1.11.0" + "@swagger-api/apidom-parser-adapter-asyncapi-yaml-2" "^1.11.0" + "@swagger-api/apidom-parser-adapter-asyncapi-yaml-3" "^1.11.0" + "@swagger-api/apidom-parser-adapter-json" "^1.11.0" + "@swagger-api/apidom-parser-adapter-openapi-json-2" "^1.11.0" + "@swagger-api/apidom-parser-adapter-openapi-json-3-0" "^1.11.0" + "@swagger-api/apidom-parser-adapter-openapi-json-3-1" "^1.11.0" + "@swagger-api/apidom-parser-adapter-openapi-json-3-2" "^1.11.0" + "@swagger-api/apidom-parser-adapter-openapi-yaml-2" "^1.11.0" + "@swagger-api/apidom-parser-adapter-openapi-yaml-3-0" "^1.11.0" + "@swagger-api/apidom-parser-adapter-openapi-yaml-3-1" "^1.11.0" + "@swagger-api/apidom-parser-adapter-openapi-yaml-3-2" "^1.11.0" + "@swagger-api/apidom-parser-adapter-yaml-1-2" "^1.11.0" "@swaggerexpert/cookie@^2.0.2": version "2.0.2" @@ -4153,86 +4857,86 @@ dependencies: apg-lite "^1.0.4" -"@swc/core-darwin-arm64@1.15.41": - version "1.15.41" - resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.41.tgz#4fcbc9cbb9dfc9027d66e2b23b8d1d0315d164bd" - integrity sha512-kREh6J5paQFvP3i7f/4FbqRNOJREutVFVOkder4GVyCBQ39YmER55cW/y1NNjwrchzFqgYswFn0mMDCqbqKzrw== +"@swc/core-darwin-arm64@1.15.43": + version "1.15.43" + resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.43.tgz#386294f8427dde2df1a70dd0a5826d67af70e996" + integrity sha512-v1aVuvXdo/BHxJzco9V2xpHrvwWmhfS8t6gziY5wJxd+Z2h8AeJRnAwPD8itCDaGXVBwJ/CaKfxEzTkG0Va0OA== -"@swc/core-darwin-x64@1.15.41": - version "1.15.41" - resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.15.41.tgz#726c60a893e2f1a07bee28f79b519b8e6489415b" - integrity sha512-N8B56ESFazZAWZyIkecADSPCwlLEinW7QLMEeotCpv4J7VXwfH+OLkmRL8o96UZ+1355fwHxDTS6/wK7yucvkA== +"@swc/core-darwin-x64@1.15.43": + version "1.15.43" + resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.15.43.tgz#c4823529c424e2ae25b7eb786438474741521fcb" + integrity sha512-lp3d4Lamc8dt5huYdGLSR+9hLxmfr1jb0l+4XXG2zPqZwYWRN9R0U2qYoTrggiU2RWW0oV9VbWM3kBnqIc2kdQ== -"@swc/core-linux-arm-gnueabihf@1.15.41": - version "1.15.41" - resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.41.tgz#08930e8015ca2fadc729546d5bd4b758a3999dda" - integrity sha512-6XrId2fyle0mS5xxON8rU84mPd2Cq1kDJRj+4BnQKTd7u+2kSA6Ww+JkOP0iTNqOqt9OXhPOEAjBHAuonWcdCg== +"@swc/core-linux-arm-gnueabihf@1.15.43": + version "1.15.43" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.43.tgz#c0a0ed17cffc5d4af192935667f12f05feeb39f9" + integrity sha512-JWTQQELtsG5GgphDrr/XqqmM2pDN3cZqbMS0Mrg+iTiXL3F74sn/S2IyYE/5u4h2KLkTf9qQ7dXyxsbx7YzkeA== -"@swc/core-linux-arm64-gnu@1.15.41": - version "1.15.41" - resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.41.tgz#6c27490a4013647a09ff64cea1d6b1169394602f" - integrity sha512-ynLIarxlkVnqHn1D0fKOVht6mNU5ks6lrH+MY3kkS+XFaGGgDxFZVjWKJlkYTKm3RCvBTfA8Ng5fLufXheMRKQ== +"@swc/core-linux-arm64-gnu@1.15.43": + version "1.15.43" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.43.tgz#1eb2d9c5eeee5bb9d00599b475ddc31dc2870d22" + integrity sha512-B4otJRdPWIsmiSBf0uG7Z/+vMWmkufjz5MmYxubwKuZazDW14Zd3symga1N62QR4RT+kEFeHEgsXfZGyn/w0hw== -"@swc/core-linux-arm64-musl@1.15.41": - version "1.15.41" - resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.41.tgz#4cce52fbbbe78b1f99c2a4e3f9ad2629f6eae494" - integrity sha512-dXu/5vd4gh8symyhRF+4G7gOPkjmb4pONhh7sl+6GSiW0LOKZlfu5kXmyFbTz9smOT7jgr002qY9b1nujjXt2A== +"@swc/core-linux-arm64-musl@1.15.43": + version "1.15.43" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.43.tgz#ea6b5c38088f3921a57922d3931b2d74fd23a9fd" + integrity sha512-6zB6OnpViBxYy4tgY3v2i6AZY9fwkcHZ032UOwtwUuW1d19sdT07qF0kZe6/3UR1tUaK6jjg2rmVcUIBCEYVjQ== -"@swc/core-linux-ppc64-gnu@1.15.41": - version "1.15.41" - resolved "https://registry.yarnpkg.com/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.41.tgz#3d1fadd8d320e7250a6b2a2d9c0b0d4dac162f97" - integrity sha512-XGO6zVPXoPE0gf/XnI4jBbafNT13AYgoh6ns0JCSdOetI/kqVf0vhpz7NuNgAzZrMVCsmieqjPoTwViDgh4mOQ== +"@swc/core-linux-ppc64-gnu@1.15.43": + version "1.15.43" + resolved "https://registry.yarnpkg.com/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.43.tgz#538fac30bbd5f1e678bb7bac9ccc62246a6f6d7a" + integrity sha512-coxE1ZWdB3uSDVNoEtYNrRi/1epvckZx9cTJ8ICUxTMTxGk+yvQ/Twacp3ruZSaMPGCriUjP86C37VhaT6nyRg== -"@swc/core-linux-s390x-gnu@1.15.41": - version "1.15.41" - resolved "https://registry.yarnpkg.com/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.41.tgz#6e4c54168d4a8d7852ef797437bd25e6fb5d7a50" - integrity sha512-0WUglRwyZtW+iMi7J3iFdrCxreZZIKf4egTwEQfIYRsqFax69A0OrFj+NIoFSE03xBT/IFRrg+S8K6f9Ky+4hA== +"@swc/core-linux-s390x-gnu@1.15.43": + version "1.15.43" + resolved "https://registry.yarnpkg.com/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.43.tgz#ee564b45f3f578b1fc82136c4dab163189316641" + integrity sha512-lXfLhs+LpBsD5inuYx+YDH5WsPPBQ95KPUiy8P5wq9ob9xKDZFqwNfU2QW6bGO8NqRO/H9JQomTSt5Yyh+FGfA== -"@swc/core-linux-x64-gnu@1.15.41": - version "1.15.41" - resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.41.tgz#5f947698786e15e2f696e0c6b3afd25138bae86b" - integrity sha512-VxkuQK59c0tHm6uJZCUrS3cyA2JhGGfdU6e41SZz0x/JS+4Sm7C1mIc97In14vkZJopEt7yXA2TouCqZDSygEA== +"@swc/core-linux-x64-gnu@1.15.43": + version "1.15.43" + resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.43.tgz#e6e3bfea76921c7f5e16d50a126615f2e04ce1c8" + integrity sha512-07XnKwTmKy8TGOZG3D9fRnLWGynxPjwQnZLVmBFbo6F+7vHYzBIOuwXEhemrChBWb6yDNZsVCcMWCPX6FDD2xg== -"@swc/core-linux-x64-musl@1.15.41": - version "1.15.41" - resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.41.tgz#f4a0910cb273e39bcc09d572a08f62a355a93628" - integrity sha512-/0qXIu1ZxggLuovLb22vFfKHq2AA4n6Whw5UwmVCHk4pkw7KWnPIQpMCEqUMPsNkFJig7PPp/TSYFu8ZEb2rtQ== +"@swc/core-linux-x64-musl@1.15.43": + version "1.15.43" + resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.43.tgz#539f6f2721c0cc32e5db5cf0d453c82045f6662d" + integrity sha512-TJc+bsSIaBh+hZvZ5GRtW/K1bw66TJ9vsUwvVIsZdiWxU5ObLwZvfcnZ3UpgVfMnFibRes9uriJrQNBHEEogRQ== -"@swc/core-win32-arm64-msvc@1.15.41": - version "1.15.41" - resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.41.tgz#a55334b1b7c23a962d4219f332b6422f3c3374e4" - integrity sha512-Y481sMNZM6rECh9VO4+y26N1lWEDAyxnBZskUf37fl90uHE946VHfmiVQWT0uMFOhyJJFovGTRuF4W82dwewUg== +"@swc/core-win32-arm64-msvc@1.15.43": + version "1.15.43" + resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.43.tgz#b7bb6b611d484ac19d0ee21469e7012d646c28b5" + integrity sha512-jfd7s2/bUQYkOHLs+LWQNKZdmDa8+sufKLllhpWAhVQ2GDCwsHe3vR/j+OSiItZNtkzFuaawa3+SAKz9y5gYfw== -"@swc/core-win32-ia32-msvc@1.15.41": - version "1.15.41" - resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.41.tgz#e1135f8d6857f6c48e4bfb6105568b37b3f88dc5" - integrity sha512-BAchBD5qeUzy3hiPSLJtaaoSm4blCLyYffOF1bGE4ETcV+OisqjUAwDQMJj++4bTpvMCDzwC+Bj3PmQyBCtscw== +"@swc/core-win32-ia32-msvc@1.15.43": + version "1.15.43" + resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.43.tgz#e5b25722a7d27bb0c9a9bdee7863f29c8674364e" + integrity sha512-rLAE8JvucqEW1ZGohxPQrQWPBQeJG4+ypKbWfdlU/qmKScvCkxf9/Jxnzki1dkUQCQ7P5Enp13RlvqOlvx/32g== -"@swc/core-win32-x64-msvc@1.15.41": - version "1.15.41" - resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.41.tgz#52d241e2bf4c6154675c0ad447b29cbdb0ccb547" - integrity sha512-WOkA+fJ/ViVBQDsSV9JC52NACTe5PhlurA6viASDZGb7HR3KS01ZG7RZ+Bg6SVQFIoq3gSbTsskQVe6EbHFAYw== +"@swc/core-win32-x64-msvc@1.15.43": + version "1.15.43" + resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.43.tgz#d28842621201c345383d468d40c09648b6cd6e68" + integrity sha512-h8MLDHZcfIukwQWj03rIJZx1I0E81AYj2X7J/nGErG4nz+QAv6G1Z+peotvinL3lqpbo32tLYSMFo32/ySzxKg== -"@swc/core@^1.15.41", "@swc/core@^1.7.39": - version "1.15.41" - resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.15.41.tgz#a212c5040abd1ffd2ad6caf140f0d586ffcfaa6e" - integrity sha512-03nQq/082QRJJiOvp3FGbgxTGyyxMxohPTjhk/W9bD2J0tk4ukITI7goOhOO2WbaHn/lsPmo/zf8+DIXhwpgYQ== +"@swc/core@^1.15.43", "@swc/core@^1.7.39": + version "1.15.43" + resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.15.43.tgz#653e6573968fd5c74163b9885ea0a933012c9f22" + integrity sha512-1CuKjFkPxIgGdeHVuNbkxmBxkcbdc08u0aiI43pFq6yY1tTVKmXT9hFEooyyKs/sJ3xf1GPHyEwTtk9Xl8dvQw== dependencies: "@swc/counter" "^0.1.3" - "@swc/types" "^0.1.26" + "@swc/types" "^0.1.27" optionalDependencies: - "@swc/core-darwin-arm64" "1.15.41" - "@swc/core-darwin-x64" "1.15.41" - "@swc/core-linux-arm-gnueabihf" "1.15.41" - "@swc/core-linux-arm64-gnu" "1.15.41" - "@swc/core-linux-arm64-musl" "1.15.41" - "@swc/core-linux-ppc64-gnu" "1.15.41" - "@swc/core-linux-s390x-gnu" "1.15.41" - "@swc/core-linux-x64-gnu" "1.15.41" - "@swc/core-linux-x64-musl" "1.15.41" - "@swc/core-win32-arm64-msvc" "1.15.41" - "@swc/core-win32-ia32-msvc" "1.15.41" - "@swc/core-win32-x64-msvc" "1.15.41" + "@swc/core-darwin-arm64" "1.15.43" + "@swc/core-darwin-x64" "1.15.43" + "@swc/core-linux-arm-gnueabihf" "1.15.43" + "@swc/core-linux-arm64-gnu" "1.15.43" + "@swc/core-linux-arm64-musl" "1.15.43" + "@swc/core-linux-ppc64-gnu" "1.15.43" + "@swc/core-linux-s390x-gnu" "1.15.43" + "@swc/core-linux-x64-gnu" "1.15.43" + "@swc/core-linux-x64-musl" "1.15.43" + "@swc/core-win32-arm64-msvc" "1.15.43" + "@swc/core-win32-ia32-msvc" "1.15.43" + "@swc/core-win32-x64-msvc" "1.15.43" "@swc/counter@^0.1.3": version "0.1.3" @@ -4307,10 +5011,10 @@ "@swc/html-win32-ia32-msvc" "1.15.13" "@swc/html-win32-x64-msvc" "1.15.13" -"@swc/types@^0.1.26": - version "0.1.26" - resolved "https://registry.yarnpkg.com/@swc/types/-/types-0.1.26.tgz#2a976a1870caef1992316dda1464150ee36968b5" - integrity sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw== +"@swc/types@^0.1.27": + version "0.1.27" + resolved "https://registry.yarnpkg.com/@swc/types/-/types-0.1.27.tgz#12080b0c426dea450634f202d9a3c82ac396e793" + integrity sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg== dependencies: "@swc/counter" "^0.1.3" @@ -4321,6 +5025,31 @@ dependencies: defer-to-connect "^2.0.1" +"@testing-library/jest-dom@^6.9.1": + version "6.9.1" + resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz#7613a04e146dd2976d24ddf019730d57a89d56c2" + integrity sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA== + dependencies: + "@adobe/css-tools" "^4.4.0" + aria-query "^5.0.0" + css.escape "^1.5.1" + dom-accessibility-api "^0.6.3" + picocolors "^1.1.1" + redent "^3.0.0" + +"@testing-library/user-event@^14.6.1": + version "14.6.1" + resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.6.1.tgz#13e09a32d7a8b7060fe38304788ebf4197cd2149" + integrity sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw== + +"@tree-sitter-grammars/tree-sitter-yaml@=0.7.1": + version "0.7.1" + resolved "https://registry.yarnpkg.com/@tree-sitter-grammars/tree-sitter-yaml/-/tree-sitter-yaml-0.7.1.tgz#9fcf9c56c7b4adb19097f869ada29c2d7a62c93d" + integrity sha512-AynBwkIoQCTgjDR33bDUp9Mqq+YTco0is3n5hRApMqG9of/6A4eQsfC1/uSEeHSUyMQSYawcAWamsexnVpIP4Q== + dependencies: + node-addon-api "^8.3.1" + node-gyp-build "^4.8.4" + "@tybys/wasm-util@^0.10.1": version "0.10.1" resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414" @@ -4328,6 +5057,13 @@ dependencies: tslib "^2.4.0" +"@tybys/wasm-util@^0.10.3": + version "0.10.3" + resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.3.tgz#015cba9e9dd47ce14d03d2a8c5d547bfb169665d" + integrity sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg== + dependencies: + tslib "^2.4.0" + "@types/body-parser@*": version "1.19.6" resolved "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz" @@ -4343,6 +5079,14 @@ dependencies: "@types/node" "*" +"@types/chai@^5.2.2": + version "5.2.3" + resolved "https://registry.yarnpkg.com/@types/chai/-/chai-5.2.3.tgz#8e9cd9e1c3581fa6b341a5aed5588eb285be0b4a" + integrity sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA== + dependencies: + "@types/deep-eql" "*" + assertion-error "^2.0.1" + "@types/connect-history-api-fallback@^1.5.4": version "1.5.4" resolved "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.4.tgz" @@ -4575,6 +5319,11 @@ dependencies: "@types/ms" "*" +"@types/deep-eql@*": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@types/deep-eql/-/deep-eql-4.0.2.tgz#334311971d3a07121e7eb91b684a605e7eea9cbd" + integrity sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw== + "@types/estree-jsx@^1.0.0": version "1.0.5" resolved "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz" @@ -4932,110 +5681,110 @@ dependencies: "@types/yargs-parser" "*" -"@typescript-eslint/eslint-plugin@8.61.1", "@typescript-eslint/eslint-plugin@^8.59.3": - version "8.61.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz#6e4b7fee21f1983308e9e9b634ecbaf702c86006" - integrity sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ== +"@typescript-eslint/eslint-plugin@8.62.0", "@typescript-eslint/eslint-plugin@^8.59.3": + version "8.62.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.0.tgz#ef482aab65b9b2c0abf92d36d670a0d270bcef4c" + integrity sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw== dependencies: "@eslint-community/regexpp" "^4.12.2" - "@typescript-eslint/scope-manager" "8.61.1" - "@typescript-eslint/type-utils" "8.61.1" - "@typescript-eslint/utils" "8.61.1" - "@typescript-eslint/visitor-keys" "8.61.1" + "@typescript-eslint/scope-manager" "8.62.0" + "@typescript-eslint/type-utils" "8.62.0" + "@typescript-eslint/utils" "8.62.0" + "@typescript-eslint/visitor-keys" "8.62.0" ignore "^7.0.5" natural-compare "^1.4.0" ts-api-utils "^2.5.0" -"@typescript-eslint/parser@8.61.1", "@typescript-eslint/parser@^8.61.0": - version "8.61.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.61.1.tgz#881fba60b50636249cdeea2e547bf75715254c72" - integrity sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg== +"@typescript-eslint/parser@8.62.0", "@typescript-eslint/parser@^8.61.0": + version "8.62.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.62.0.tgz#8533094fb44427f50b82813c6d3876782f20dc3e" + integrity sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA== dependencies: - "@typescript-eslint/scope-manager" "8.61.1" - "@typescript-eslint/types" "8.61.1" - "@typescript-eslint/typescript-estree" "8.61.1" - "@typescript-eslint/visitor-keys" "8.61.1" + "@typescript-eslint/scope-manager" "8.62.0" + "@typescript-eslint/types" "8.62.0" + "@typescript-eslint/typescript-estree" "8.62.0" + "@typescript-eslint/visitor-keys" "8.62.0" debug "^4.4.3" -"@typescript-eslint/project-service@8.61.1": - version "8.61.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.61.1.tgz#fcd9739964a40867eed55f1ac318d3909f24b4af" - integrity sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA== +"@typescript-eslint/project-service@8.62.0": + version "8.62.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.62.0.tgz#ab74c1abb4959fb4c3ba7d7edc6554ee245db990" + integrity sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ== dependencies: - "@typescript-eslint/tsconfig-utils" "^8.61.1" - "@typescript-eslint/types" "^8.61.1" + "@typescript-eslint/tsconfig-utils" "^8.62.0" + "@typescript-eslint/types" "^8.62.0" debug "^4.4.3" -"@typescript-eslint/scope-manager@8.61.1": - version "8.61.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.61.1.tgz#2479921a40fdb0afa18f5838fae6167264b417b2" - integrity sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w== +"@typescript-eslint/scope-manager@8.62.0": + version "8.62.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.62.0.tgz#a7a7b428d32444bc9a4fe16f24a78fc124283fd4" + integrity sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA== dependencies: - "@typescript-eslint/types" "8.61.1" - "@typescript-eslint/visitor-keys" "8.61.1" + "@typescript-eslint/types" "8.62.0" + "@typescript-eslint/visitor-keys" "8.62.0" -"@typescript-eslint/tsconfig-utils@8.61.1": - version "8.61.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz#ca88080e0cf191d49516d7f300b67aa090d2254f" - integrity sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg== - -"@typescript-eslint/tsconfig-utils@^8.61.1": +"@typescript-eslint/tsconfig-utils@8.62.0": version "8.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.0.tgz#9440a673581c6d9de308c4d5803dd52ed5d71729" integrity sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g== -"@typescript-eslint/type-utils@8.61.1": - version "8.61.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.61.1.tgz#8fa18f453ee140893b47d339d1a6b64cac9b08a1" - integrity sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw== +"@typescript-eslint/tsconfig-utils@^8.62.0": + version "8.62.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.1.tgz#e2b5f24fe721044189cb7e81117c96d75979d627" + integrity sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g== + +"@typescript-eslint/type-utils@8.62.0": + version "8.62.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.62.0.tgz#6f64d813ed9f340d796baed40cdab86b8e9a491a" + integrity sha512-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w== dependencies: - "@typescript-eslint/types" "8.61.1" - "@typescript-eslint/typescript-estree" "8.61.1" - "@typescript-eslint/utils" "8.61.1" + "@typescript-eslint/types" "8.62.0" + "@typescript-eslint/typescript-estree" "8.62.0" + "@typescript-eslint/utils" "8.62.0" debug "^4.4.3" ts-api-utils "^2.5.0" -"@typescript-eslint/types@8.61.1": - version "8.61.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.61.1.tgz#0c51f518e4e6848371a1c988e859d59eb7522d5a" - integrity sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA== - -"@typescript-eslint/types@^8.61.1": +"@typescript-eslint/types@8.62.0": version "8.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.62.0.tgz#601427c10203d9f0f34f0b3e474df735eb12b593" integrity sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg== -"@typescript-eslint/typescript-estree@8.61.1": - version "8.61.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz#febbe70365ac0bf7611262b61b338fc8797965c7" - integrity sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg== +"@typescript-eslint/types@^8.62.0": + version "8.62.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.62.1.tgz#c58be954e483b2fc98275374d5bcb40b99842dc1" + integrity sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q== + +"@typescript-eslint/typescript-estree@8.62.0": + version "8.62.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.0.tgz#b96b55d02e26aa09434421c3fa678e525ca09a4c" + integrity sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A== dependencies: - "@typescript-eslint/project-service" "8.61.1" - "@typescript-eslint/tsconfig-utils" "8.61.1" - "@typescript-eslint/types" "8.61.1" - "@typescript-eslint/visitor-keys" "8.61.1" + "@typescript-eslint/project-service" "8.62.0" + "@typescript-eslint/tsconfig-utils" "8.62.0" + "@typescript-eslint/types" "8.62.0" + "@typescript-eslint/visitor-keys" "8.62.0" debug "^4.4.3" minimatch "^10.2.2" semver "^7.7.3" tinyglobby "^0.2.15" ts-api-utils "^2.5.0" -"@typescript-eslint/utils@8.61.1": - version "8.61.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.61.1.tgz#ffd1054de7dd33b7873cd6c6713ec6b0366316d3" - integrity sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA== +"@typescript-eslint/utils@8.62.0": + version "8.62.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.62.0.tgz#b5228524ca1ee51af40e156c82d425dec3e01cfe" + integrity sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g== dependencies: "@eslint-community/eslint-utils" "^4.9.1" - "@typescript-eslint/scope-manager" "8.61.1" - "@typescript-eslint/types" "8.61.1" - "@typescript-eslint/typescript-estree" "8.61.1" + "@typescript-eslint/scope-manager" "8.62.0" + "@typescript-eslint/types" "8.62.0" + "@typescript-eslint/typescript-estree" "8.62.0" -"@typescript-eslint/visitor-keys@8.61.1": - version "8.61.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.1.tgz#546cf102b4efdb72a9a08e63a1b0d7d745eb66eb" - integrity sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w== +"@typescript-eslint/visitor-keys@8.62.0": + version "8.62.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.0.tgz#b6daab190bf8f18612f5b86323469a12288c6b31" + integrity sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ== dependencies: - "@typescript-eslint/types" "8.61.1" + "@typescript-eslint/types" "8.62.0" eslint-visitor-keys "^5.0.0" "@ungap/structured-clone@^1.0.0": @@ -5051,6 +5800,40 @@ d3-selection "^3.0.0" d3-transition "^3.0.1" +"@vitest/expect@3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-3.2.4.tgz#8362124cd811a5ee11c5768207b9df53d34f2433" + integrity sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig== + dependencies: + "@types/chai" "^5.2.2" + "@vitest/spy" "3.2.4" + "@vitest/utils" "3.2.4" + chai "^5.2.0" + tinyrainbow "^2.0.0" + +"@vitest/pretty-format@3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-3.2.4.tgz#3c102f79e82b204a26c7a5921bf47d534919d3b4" + integrity sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA== + dependencies: + tinyrainbow "^2.0.0" + +"@vitest/spy@3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-3.2.4.tgz#cc18f26f40f3f028da6620046881f4e4518c2599" + integrity sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw== + dependencies: + tinyspy "^4.0.3" + +"@vitest/utils@3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-3.2.4.tgz#c0813bc42d99527fb8c5b138c7a88516bca46fea" + integrity sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA== + dependencies: + "@vitest/pretty-format" "3.2.4" + loupe "^3.1.4" + tinyrainbow "^2.0.0" + "@vx/responsive@^0.0.199": version "0.0.199" resolved "https://registry.npmjs.org/@vx/responsive/-/responsive-0.0.199.tgz" @@ -5183,6 +5966,11 @@ "@webassemblyjs/ast" "1.14.1" "@xtuc/long" "4.2.2" +"@webcontainer/env@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@webcontainer/env/-/env-1.1.1.tgz#23021b2bb24befeeef53dba8996d1886b7016515" + integrity sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng== + "@xtuc/ieee754@^1.2.0": version "1.2.0" resolved "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz" @@ -5243,6 +6031,13 @@ address@^1.0.1: resolved "https://registry.npmjs.org/address/-/address-1.2.2.tgz" integrity sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA== +agent-base@6: + version "6.0.2" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" + integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== + dependencies: + debug "4" + aggregate-error@^3.0.0: version "3.1.0" resolved "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz" @@ -5480,6 +6275,11 @@ argparse@^2.0.1: resolved "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== +aria-query@^5.0.0: + version "5.3.2" + resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.2.tgz#93f81a43480e33a338f19163a3d10a50c01dcd59" + integrity sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw== + array-buffer-byte-length@^1.0.1, array-buffer-byte-length@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz" @@ -5577,6 +6377,11 @@ asn1js@^3.0.6: pvutils "^1.1.5" tslib "^2.8.1" +assertion-error@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" + integrity sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA== + ast-types@^0.16.1: version "0.16.1" resolved "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz" @@ -5604,6 +6409,11 @@ async@3.2.6: resolved "https://registry.npmjs.org/async/-/async-3.2.6.tgz" integrity sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA== +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + autolinker@^3.11.0: version "3.16.2" resolved "https://registry.npmjs.org/autolinker/-/autolinker-3.16.2.tgz" @@ -5630,6 +6440,16 @@ available-typed-arrays@^1.0.7: dependencies: possible-typed-array-names "^1.0.0" +axios@^1.15.0: + version "1.18.1" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.18.1.tgz#d63f9863bcd8938815c86f9e2abd380189d96dfe" + integrity sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g== + dependencies: + follow-redirects "^1.16.0" + form-data "^4.0.5" + https-proxy-agent "^5.0.1" + proxy-from-env "^2.1.0" + babel-loader@^9.2.1: version "9.2.1" resolved "https://registry.npmjs.org/babel-loader/-/babel-loader-9.2.1.tgz" @@ -5698,23 +6518,16 @@ base64-js@^1.3.1, base64-js@^1.5.1: resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== -baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19: - version "2.10.38" - resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz#c84d093c4bf7325c5053c279d90f153c66526042" - integrity sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw== +baseline-browser-mapping@^2.10.40, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19: + version "2.10.40" + resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz#f372c8eb36ff4ad0b5e7ae467014abef124554ba" + integrity sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw== batch@0.6.1: version "0.6.1" resolved "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz" integrity sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw== -better-opn@^3.0.2: - version "3.0.2" - resolved "https://registry.npmjs.org/better-opn/-/better-opn-3.0.2.tgz" - integrity sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ== - dependencies: - open "^8.0.4" - big.js@^5.2.2: version "5.2.2" resolved "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz" @@ -5811,11 +6624,6 @@ braces@^3.0.3, braces@~3.0.2: dependencies: fill-range "^7.1.1" -browser-assert@^1.2.1: - version "1.2.1" - resolved "https://registry.npmjs.org/browser-assert/-/browser-assert-1.2.1.tgz" - integrity sha512-nfulgvOR6S4gt9UKCeGJOuSGBPGiFT6oQ/2UBnvTY/5aQ1PnksW72fhZkM30DzoRRv2WpwZf1vHHEr3mtuXIWQ== - browserslist@^4.0.0, browserslist@^4.23.0, browserslist@^4.24.0, browserslist@^4.24.2, browserslist@^4.24.4, browserslist@^4.25.0, browserslist@^4.25.3, browserslist@^4.28.1: version "4.28.1" resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz" @@ -5954,6 +6762,17 @@ ccount@^2.0.0: resolved "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz" integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg== +chai@^5.2.0: + version "5.3.3" + resolved "https://registry.yarnpkg.com/chai/-/chai-5.3.3.tgz#dd3da955e270916a4bd3f625f4b919996ada7e06" + integrity sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw== + dependencies: + assertion-error "^2.0.1" + check-error "^2.1.1" + deep-eql "^5.0.1" + loupe "^3.1.0" + pathval "^2.0.0" + chalk@^2.4.2: version "2.4.2" resolved "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz" @@ -6006,6 +6825,11 @@ charset@^1.0.0: resolved "https://registry.npmjs.org/charset/-/charset-1.0.1.tgz" integrity sha512-6dVyOOYjpfFcL1Y4qChrAoQLRHvj2ziyhcm0QJlhOcAhykL/k1kTUPbeo+87MNRTRdk2OIIsIXbuF3x2wi5EXg== +check-error@^2.1.1: + version "2.1.3" + resolved "https://registry.yarnpkg.com/check-error/-/check-error-2.1.3.tgz#2427361117b70cca8dc89680ead32b157019caf5" + integrity sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA== + cheerio-select@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz" @@ -6166,6 +6990,13 @@ combine-promises@^1.1.0: resolved "https://registry.npmjs.org/combine-promises/-/combine-promises-1.2.0.tgz" integrity sha512-VcQB1ziGD0NXrhKxiwyNbCDmRzs/OShMs2GqW2DlU2A/Sd0nQxE1oWDAE5O0ygSx5mgQOn9eIFh7yKPgFRVkPQ== +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + comma-separated-tokens@^2.0.0: version "2.0.3" resolved "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz" @@ -6518,9 +7349,9 @@ css-what@^6.0.1, css-what@^6.1.0: resolved "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz" integrity sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA== -css.escape@1.5.1: +css.escape@1.5.1, css.escape@^1.5.1: version "1.5.1" - resolved "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz" + resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb" integrity sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg== cssdb@^8.3.0: @@ -6996,7 +7827,7 @@ debug@2.6.9: dependencies: ms "2.0.0" -debug@4, debug@^4.0.0, debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.4.1, debug@^4.4.3: +debug@4, debug@^4.0.0, debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.4.1, debug@^4.4.3: version "4.4.3" resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz" integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== @@ -7017,6 +7848,11 @@ decompress-response@^6.0.0: dependencies: mimic-response "^3.1.0" +deep-eql@^5.0.1: + version "5.0.2" + resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-5.0.2.tgz#4b756d8d770a9257300825d52a2c2cff99c3a341" + integrity sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q== + deep-extend@0.6.0, deep-extend@^0.6.0: version "0.6.0" resolved "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz" @@ -7085,6 +7921,11 @@ delaunator@5: dependencies: robust-predicates "^3.0.2" +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== + depd@2.0.0, depd@~2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz" @@ -7163,10 +8004,10 @@ doctrine@^2.1.0: dependencies: esutils "^2.0.2" -docusaurus-plugin-openapi-docs@^5.0.2: - version "5.0.2" - resolved "https://registry.yarnpkg.com/docusaurus-plugin-openapi-docs/-/docusaurus-plugin-openapi-docs-5.0.2.tgz#f00028621deb9179065fe7d6a541256692ef941b" - integrity sha512-WCC2m6PpylXZfNga+ScelTG0a7jUGtbB9+AmbR9lUj93FPryTs8VHTMJ3fKtO0senJTWgOU3MDvZw0v+mE3ztA== +docusaurus-plugin-openapi-docs@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/docusaurus-plugin-openapi-docs/-/docusaurus-plugin-openapi-docs-5.1.0.tgz#9732f81f45a5bc126bcafcb150332b7623ddece7" + integrity sha512-ocRemE3KmdhhPKaow5hja1m1NLIPfNlfRYFt7pja+nG26Wlp0MEC9ERS99gSWEWaxU+txDFBpUXsxo7nGlk8ZA== dependencies: "@apidevtools/json-schema-ref-parser" "^15.3.3" "@redocly/openapi-core" "^2.25.2" @@ -7184,10 +8025,10 @@ docusaurus-plugin-openapi-docs@^5.0.2: swagger2openapi "^7.0.8" xml-formatter "^3.6.6" -docusaurus-theme-openapi-docs@^5.0.2: - version "5.0.2" - resolved "https://registry.yarnpkg.com/docusaurus-theme-openapi-docs/-/docusaurus-theme-openapi-docs-5.0.2.tgz#2ab6f6b04fc2e494e24971d31432a9187c84a2fe" - integrity sha512-BD6WhbunR6kXqtoUUDlhxO4HlCNM2nYENGr/TbiTEknkgXYKQz+FEIhY4Hyz5GSLpuhPih0CDuNl7Xkfpcz0Yw== +docusaurus-theme-openapi-docs@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/docusaurus-theme-openapi-docs/-/docusaurus-theme-openapi-docs-5.1.0.tgz#4b9b7024130bd299a56bffe238c584280851012f" + integrity sha512-SLHg1lodIM5AEGo0EJx4pJ+9szDDrN8Q+hNk5qqTVJ60k/zKjHAnwGCzVDT25K1CCMDPaUzuuruj43d7TRoQKw== dependencies: "@hookform/error-message" "^2.0.1" "@reduxjs/toolkit" "^2.8.2" @@ -7218,6 +8059,11 @@ docusaurus-theme-openapi-docs@^5.0.2: url "^0.11.4" xml-formatter "^3.6.6" +dom-accessibility-api@^0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz#993e925cc1d73f2c662e7d75dd5a5445259a8fd8" + integrity sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w== + dom-converter@^0.2.0: version "0.2.0" resolved "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz" @@ -7371,10 +8217,10 @@ encodeurl@~2.0.0: resolved "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz" integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== -enhanced-resolve@^5.22.0: - version "5.22.1" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.22.1.tgz#c34bc3f414298496fc244b21bbe316440782da17" - integrity sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww== +enhanced-resolve@^5.22.2: + version "5.24.1" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.24.1.tgz#b2439adf5d31d7e4764de1f9ecf942d6cd3fc874" + integrity sha512-7DdUaTjmNwMcH2gLr1qycesKII3BK4RLy/mdAb7x10Lq7bR4aNKHt1BR1ZALSv0rPM/hF5wYF0PhGop/rJm8vw== dependencies: graceful-fs "^4.2.4" tapable "^2.3.3" @@ -7561,44 +8407,37 @@ esast-util-from-js@^2.0.0: esast-util-from-estree "^2.0.0" vfile-message "^4.0.0" -esbuild-register@^3.5.0: - version "3.6.0" - resolved "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz" - integrity sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg== - dependencies: - debug "^4.3.4" - -"esbuild@^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0": - version "0.25.9" - resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz" - integrity sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g== +"esbuild@^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0 || ^0.28.0": + version "0.28.1" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.1.tgz#ef45b4634c9c9d97a296aea4114a5f9840f95578" + integrity sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw== optionalDependencies: - "@esbuild/aix-ppc64" "0.25.9" - "@esbuild/android-arm" "0.25.9" - "@esbuild/android-arm64" "0.25.9" - "@esbuild/android-x64" "0.25.9" - "@esbuild/darwin-arm64" "0.25.9" - "@esbuild/darwin-x64" "0.25.9" - "@esbuild/freebsd-arm64" "0.25.9" - "@esbuild/freebsd-x64" "0.25.9" - "@esbuild/linux-arm" "0.25.9" - "@esbuild/linux-arm64" "0.25.9" - "@esbuild/linux-ia32" "0.25.9" - "@esbuild/linux-loong64" "0.25.9" - "@esbuild/linux-mips64el" "0.25.9" - "@esbuild/linux-ppc64" "0.25.9" - "@esbuild/linux-riscv64" "0.25.9" - "@esbuild/linux-s390x" "0.25.9" - "@esbuild/linux-x64" "0.25.9" - "@esbuild/netbsd-arm64" "0.25.9" - "@esbuild/netbsd-x64" "0.25.9" - "@esbuild/openbsd-arm64" "0.25.9" - "@esbuild/openbsd-x64" "0.25.9" - "@esbuild/openharmony-arm64" "0.25.9" - "@esbuild/sunos-x64" "0.25.9" - "@esbuild/win32-arm64" "0.25.9" - "@esbuild/win32-ia32" "0.25.9" - "@esbuild/win32-x64" "0.25.9" + "@esbuild/aix-ppc64" "0.28.1" + "@esbuild/android-arm" "0.28.1" + "@esbuild/android-arm64" "0.28.1" + "@esbuild/android-x64" "0.28.1" + "@esbuild/darwin-arm64" "0.28.1" + "@esbuild/darwin-x64" "0.28.1" + "@esbuild/freebsd-arm64" "0.28.1" + "@esbuild/freebsd-x64" "0.28.1" + "@esbuild/linux-arm" "0.28.1" + "@esbuild/linux-arm64" "0.28.1" + "@esbuild/linux-ia32" "0.28.1" + "@esbuild/linux-loong64" "0.28.1" + "@esbuild/linux-mips64el" "0.28.1" + "@esbuild/linux-ppc64" "0.28.1" + "@esbuild/linux-riscv64" "0.28.1" + "@esbuild/linux-s390x" "0.28.1" + "@esbuild/linux-x64" "0.28.1" + "@esbuild/netbsd-arm64" "0.28.1" + "@esbuild/netbsd-x64" "0.28.1" + "@esbuild/openbsd-arm64" "0.28.1" + "@esbuild/openbsd-x64" "0.28.1" + "@esbuild/openharmony-arm64" "0.28.1" + "@esbuild/sunos-x64" "0.28.1" + "@esbuild/win32-arm64" "0.28.1" + "@esbuild/win32-ia32" "0.28.1" + "@esbuild/win32-x64" "0.28.1" escalade@^3.1.1, escalade@^3.2.0: version "3.2.0" @@ -8122,7 +8961,7 @@ flatted@^3.2.9: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.2.tgz#f5c23c107f0f37de8dbdf24f13722b3b98d52726" integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA== -follow-redirects@^1.0.0: +follow-redirects@^1.0.0, follow-redirects@^1.16.0: version "1.16.0" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.16.0.tgz#28474a159d3b9d11ef62050a14ed60e4df6d61bc" integrity sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw== @@ -8144,6 +8983,17 @@ form-data-encoder@^2.1.2: resolved "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz" integrity sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw== +form-data@^4.0.5: + version "4.0.6" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.6.tgz#28e864e1b786dbebb68db1f452f9635278665827" + integrity sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + es-set-tostringtag "^2.1.0" + hasown "^2.0.4" + mime-types "^2.1.35" + format@^0.2.0: version "0.2.2" resolved "https://registry.npmjs.org/format/-/format-0.2.2.tgz" @@ -8287,11 +9137,6 @@ glob-to-regex.js@^1.0.1: resolved "https://registry.npmjs.org/glob-to-regex.js/-/glob-to-regex.js-1.2.0.tgz" integrity sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ== -glob-to-regexp@^0.4.1: - version "0.4.1" - resolved "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz" - integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== - glob@^7.0.0: version "7.2.3" resolved "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz" @@ -8316,10 +9161,10 @@ globals@^14.0.0: resolved "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz" integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== -globals@^17.6.0: - version "17.6.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-17.6.0.tgz#0f0be018d5cca8690e6375ead1f65c4bb96191fc" - integrity sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA== +globals@^17.7.0: + version "17.7.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-17.7.0.tgz#553d55090b4dde8209ec2da42580d6e7e7d8b10d" + integrity sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg== globalthis@^1.0.4: version "1.0.4" @@ -8471,6 +9316,13 @@ hasown@^2.0.2: dependencies: function-bind "^1.1.2" +hasown@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003" + integrity sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A== + dependencies: + function-bind "^1.1.2" + hast-util-from-parse5@^8.0.0: version "8.0.3" resolved "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz" @@ -8805,6 +9657,14 @@ http2-wrapper@^2.1.10: quick-lru "^5.1.1" resolve-alpn "^1.2.0" +https-proxy-agent@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" + integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== + dependencies: + agent-base "6" + debug "4" + human-signals@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz" @@ -8994,14 +9854,6 @@ is-alphanumerical@^2.0.0: is-alphabetical "^2.0.0" is-decimal "^2.0.0" -is-arguments@^1.0.4: - version "1.2.0" - resolved "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz" - integrity sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA== - dependencies: - call-bound "^1.0.2" - has-tostringtag "^1.0.2" - is-array-buffer@^3.0.4, is-array-buffer@^3.0.5: version "3.0.5" resolved "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz" @@ -9127,7 +9979,7 @@ is-fullwidth-code-point@^3.0.0: resolved "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz" integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== -is-generator-function@^1.0.10, is-generator-function@^1.0.7: +is-generator-function@^1.0.10: version "1.1.0" resolved "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz" integrity sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ== @@ -9283,7 +10135,7 @@ is-symbol@^1.0.4, is-symbol@^1.1.1: has-symbols "^1.1.0" safe-regex-test "^1.1.0" -is-typed-array@^1.1.13, is-typed-array@^1.1.14, is-typed-array@^1.1.15, is-typed-array@^1.1.3: +is-typed-array@^1.1.13, is-typed-array@^1.1.14, is-typed-array@^1.1.15: version "1.1.15" resolved "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz" integrity sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ== @@ -9454,7 +10306,7 @@ js-yaml@4.1.0: dependencies: argparse "^2.0.1" -js-yaml@=4.2.0, js-yaml@^4.1.0, js-yaml@^4.1.1, js-yaml@^4.2.0: +js-yaml@=4.2.0, js-yaml@^4.1.0, js-yaml@^4.1.1: version "4.2.0" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.2.0.tgz#2bd9e85682dd91bd469afb809d816043b3d49524" integrity sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw== @@ -9469,10 +10321,12 @@ js-yaml@^3.13.1: argparse "^1.0.7" esprima "^4.0.0" -jsdoc-type-pratt-parser@^4.0.0: - version "4.8.0" - resolved "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.8.0.tgz" - integrity sha512-iZ8Bdb84lWRuGHamRXFyML07r21pcwBrLkHEuHgEY5UbCouBwv7ECknDRKzsQIXMiqpPymqtIf8TC/shYKB5rw== +js-yaml@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-5.1.0.tgz#c084ac880197833810a69e9c7e51eae12ff35448" + integrity sha512-s8VA5jkR8f22S3NAXmhKPFqGUduqZGlsufabVOgN14iTdw/RXcym7bKkbwjxLK9Yw2lEvvmJjFp119+KPeo8Kg== + dependencies: + argparse "^2.0.1" jsesc@^3.0.2: version "3.1.0" @@ -9836,6 +10690,11 @@ loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3 dependencies: js-tokens "^3.0.0 || ^4.0.0" +loupe@^3.1.0, loupe@^3.1.4: + version "3.2.1" + resolved "https://registry.yarnpkg.com/loupe/-/loupe-3.2.1.tgz#0095cf56dc5b7a9a7c08ff5b1a8796ec8ad17e76" + integrity sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ== + lower-case@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz" @@ -10856,9 +11715,9 @@ mime-types@2.1.18: dependencies: mime-db "~1.33.0" -mime-types@^2.1.27, mime-types@~2.1.17, mime-types@~2.1.24, mime-types@~2.1.34: +mime-types@^2.1.27, mime-types@^2.1.35, mime-types@~2.1.17, mime-types@~2.1.24, mime-types@~2.1.34: version "2.1.35" - resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== dependencies: mime-db "1.52.0" @@ -10895,6 +11754,11 @@ mimic-response@^4.0.0: resolved "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz" integrity sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg== +min-indent@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869" + integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg== + mini-css-extract-plugin@^2.9.2: version "2.9.4" resolved "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.4.tgz" @@ -10922,7 +11786,7 @@ minimatch@3.1.5, minimatch@^3.1.1, minimatch@^3.1.2: dependencies: brace-expansion "^1.1.7" -minimatch@^10.2.2: +minimatch@^10.2.1, minimatch@^10.2.2: version "10.2.5" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.5.tgz#bd48687a0be38ed2961399105600f832095861d1" integrity sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg== @@ -10934,6 +11798,16 @@ minimist@^1.2.0: resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== +minimizer-webpack-plugin@^5.6.1: + version "5.6.1" + resolved "https://registry.yarnpkg.com/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.6.1.tgz#289922a4c96c4ed1ddb76b8a00bd8074e89a2f7f" + integrity sha512-DoeAZz8Q1C1znwsUzej1fdoi4jCf7/+Em27ouLqfK/+3m8G+D7yDhUwrc3CNhjSzGUN1kn7Iv4sWmjflQHenpw== + dependencies: + "@jridgewell/trace-mapping" "^0.3.25" + jest-worker "^27.4.5" + schema-utils "^4.3.0" + terser "^5.31.1" + mri@^1.1.0: version "1.2.0" resolved "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz" @@ -11029,6 +11903,11 @@ node-addon-api@^7.0.0: resolved "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz" integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ== +node-addon-api@^8.0.0, node-addon-api@^8.2.2, node-addon-api@^8.3.0, node-addon-api@^8.3.1: + version "8.9.0" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-8.9.0.tgz#d2467090e6195c428ccd510dfd604f01c027f0a0" + integrity sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q== + node-domexception@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" @@ -11066,6 +11945,11 @@ node-fetch@^2.6.1: dependencies: whatwg-url "^5.0.0" +node-gyp-build@^4.8.0, node-gyp-build@^4.8.2, node-gyp-build@^4.8.4: + version "4.8.4" + resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.8.4.tgz#8a70ee85464ae52327772a90d66c6077a900cfc8" + integrity sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ== + node-readfiles@^0.2.0: version "0.2.0" resolved "https://registry.npmjs.org/node-readfiles/-/node-readfiles-0.2.0.tgz" @@ -11271,7 +12155,7 @@ onetime@^5.1.2: dependencies: mimic-fn "^2.1.0" -open@^10.0.3: +open@^10.0.3, open@^10.2.0: version "10.2.0" resolved "https://registry.npmjs.org/open/-/open-10.2.0.tgz" integrity sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA== @@ -11281,7 +12165,7 @@ open@^10.0.3: is-inside-container "^1.0.0" wsl-utils "^0.1.0" -open@^8.0.4, open@^8.4.0: +open@^8.4.0: version "8.4.2" resolved "https://registry.npmjs.org/open/-/open-8.4.2.tgz" integrity sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ== @@ -11364,6 +12248,59 @@ own-keys@^1.0.1: object-keys "^1.1.1" safe-push-apply "^1.0.0" +oxc-parser@^0.127.0: + version "0.127.0" + resolved "https://registry.yarnpkg.com/oxc-parser/-/oxc-parser-0.127.0.tgz#bb14600f5c59fb6b1fbac0ab6ff2cd3495a6df1d" + integrity sha512-bkgD4qHlN7WxLdX8bLXdaU54TtQtAIg/ZBAfm0aje/mo3MRDo3P0hZSgr4U7O3xfX+fQmR5AP04JS/TGcZLcFA== + dependencies: + "@oxc-project/types" "^0.127.0" + optionalDependencies: + "@oxc-parser/binding-android-arm-eabi" "0.127.0" + "@oxc-parser/binding-android-arm64" "0.127.0" + "@oxc-parser/binding-darwin-arm64" "0.127.0" + "@oxc-parser/binding-darwin-x64" "0.127.0" + "@oxc-parser/binding-freebsd-x64" "0.127.0" + "@oxc-parser/binding-linux-arm-gnueabihf" "0.127.0" + "@oxc-parser/binding-linux-arm-musleabihf" "0.127.0" + "@oxc-parser/binding-linux-arm64-gnu" "0.127.0" + "@oxc-parser/binding-linux-arm64-musl" "0.127.0" + "@oxc-parser/binding-linux-ppc64-gnu" "0.127.0" + "@oxc-parser/binding-linux-riscv64-gnu" "0.127.0" + "@oxc-parser/binding-linux-riscv64-musl" "0.127.0" + "@oxc-parser/binding-linux-s390x-gnu" "0.127.0" + "@oxc-parser/binding-linux-x64-gnu" "0.127.0" + "@oxc-parser/binding-linux-x64-musl" "0.127.0" + "@oxc-parser/binding-openharmony-arm64" "0.127.0" + "@oxc-parser/binding-wasm32-wasi" "0.127.0" + "@oxc-parser/binding-win32-arm64-msvc" "0.127.0" + "@oxc-parser/binding-win32-ia32-msvc" "0.127.0" + "@oxc-parser/binding-win32-x64-msvc" "0.127.0" + +oxc-resolver@^11.19.1: + version "11.22.0" + resolved "https://registry.yarnpkg.com/oxc-resolver/-/oxc-resolver-11.22.0.tgz#9848cc8978d0e4d48958db064b0b57f874f7323e" + integrity sha512-F3VuQRlu5uaMN9ffo2ufEa8D/SKykx1a2KaLtBa2JEmbtulRaTZKwQrrLsNLGfvBeLW8M/J0CE47KN3s0VdcmQ== + optionalDependencies: + "@oxc-resolver/binding-android-arm-eabi" "11.22.0" + "@oxc-resolver/binding-android-arm64" "11.22.0" + "@oxc-resolver/binding-darwin-arm64" "11.22.0" + "@oxc-resolver/binding-darwin-x64" "11.22.0" + "@oxc-resolver/binding-freebsd-x64" "11.22.0" + "@oxc-resolver/binding-linux-arm-gnueabihf" "11.22.0" + "@oxc-resolver/binding-linux-arm-musleabihf" "11.22.0" + "@oxc-resolver/binding-linux-arm64-gnu" "11.22.0" + "@oxc-resolver/binding-linux-arm64-musl" "11.22.0" + "@oxc-resolver/binding-linux-ppc64-gnu" "11.22.0" + "@oxc-resolver/binding-linux-riscv64-gnu" "11.22.0" + "@oxc-resolver/binding-linux-riscv64-musl" "11.22.0" + "@oxc-resolver/binding-linux-s390x-gnu" "11.22.0" + "@oxc-resolver/binding-linux-x64-gnu" "11.22.0" + "@oxc-resolver/binding-linux-x64-musl" "11.22.0" + "@oxc-resolver/binding-openharmony-arm64" "11.22.0" + "@oxc-resolver/binding-wasm32-wasi" "11.22.0" + "@oxc-resolver/binding-win32-arm64-msvc" "11.22.0" + "@oxc-resolver/binding-win32-x64-msvc" "11.22.0" + p-cancelable@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz" @@ -11599,6 +12536,11 @@ path@0.12.7: process "^0.11.1" util "^0.10.3" +pathval@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/pathval/-/pathval-2.0.1.tgz#8855c5a2899af072d6ac05d11e46045ad0dc605d" + integrity sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ== + picocolors@^1.0.0, picocolors@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz" @@ -12354,6 +13296,11 @@ proxy-addr@~2.0.7: forwarded "0.2.0" ipaddr.js "1.9.1" +proxy-from-env@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz#a7487568adad577cfaaa7e88c49cab3ab3081aba" + integrity sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA== + punycode@^1.4.1: version "1.4.1" resolved "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz" @@ -12816,6 +13763,14 @@ recma-stringify@^1.0.0: unified "^11.0.0" vfile "^6.0.0" +redent@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f" + integrity sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg== + dependencies: + indent-string "^4.0.0" + strip-indent "^3.0.0" + redux-immutable@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/redux-immutable/-/redux-immutable-4.0.0.tgz" @@ -13430,7 +14385,7 @@ semver@^6.3.1: resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.3.5, semver@^7.3.7, semver@^7.5.4, semver@^7.6.2, semver@^7.7.3: +semver@^7.3.5, semver@^7.3.7, semver@^7.5.4, semver@^7.7.3: version "7.7.3" resolved "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz" integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== @@ -13870,12 +14825,26 @@ stop-iteration-iterator@^1.1.0: es-errors "^1.3.0" internal-slot "^1.1.0" -storybook@^8.6.18: - version "8.6.18" - resolved "https://registry.yarnpkg.com/storybook/-/storybook-8.6.18.tgz#2a635a4b0c99693f43ba21b8eb511c5cc513a807" - integrity sha512-p8seiSI6FiVY6P3V0pG+5v7c8pDMehMAFRWEhG5XqIBSQszzOjDnW2rNvm3odoLKfo3V3P6Cs6Hv9ILzymULyQ== +storybook@^10.4.5: + version "10.4.6" + resolved "https://registry.yarnpkg.com/storybook/-/storybook-10.4.6.tgz#c28a2edb9bd01388d567f0c48f56c0386811016b" + integrity sha512-6wkA6LxfDSSilloITsrFOJfsnw0mDUP2h8Ls+lRt8oRsudtz2RWFhLv+Toiwg6NW7hUpdTDc2hzR7DztJid6+A== dependencies: - "@storybook/core" "8.6.18" + "@storybook/global" "^5.0.0" + "@storybook/icons" "^2.0.2" + "@testing-library/jest-dom" "^6.9.1" + "@testing-library/user-event" "^14.6.1" + "@vitest/expect" "3.2.4" + "@vitest/spy" "3.2.4" + "@webcontainer/env" "^1.1.1" + esbuild "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0 || ^0.28.0" + open "^10.2.0" + oxc-parser "^0.127.0" + oxc-resolver "^11.19.1" + recast "^0.23.5" + semver "^7.7.3" + use-sync-external-store "^1.5.0" + ws "^8.18.0" string-convert@^0.2.0: version "0.2.1" @@ -14014,6 +14983,13 @@ strip-final-newline@^2.0.0: resolved "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz" integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== +strip-indent@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-3.0.0.tgz#c32e1cee940b6b3432c771bc2c54bcce73cd3001" + integrity sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ== + dependencies: + min-indent "^1.0.0" + strip-json-comments@^3.1.1: version "3.1.1" resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz" @@ -14145,10 +15121,10 @@ swagger-client@3.37.3, swagger-client@^3.37.4: ramda "^0.30.1" ramda-adjunct "^5.1.0" -swagger-ui-react@^5.32.7: - version "5.32.7" - resolved "https://registry.yarnpkg.com/swagger-ui-react/-/swagger-ui-react-5.32.7.tgz#f4e94c8ee9ace175f43696f051594591caa0f530" - integrity sha512-lnT1A7wlj493InhPjdlnFe32cXO7LMEFIfB0frHBSpYK/r9VGVE8+fRGhOI9AIwLXgVRz6M/TO3+OrIOEwz2rw== +swagger-ui-react@^5.32.8: + version "5.32.8" + resolved "https://registry.yarnpkg.com/swagger-ui-react/-/swagger-ui-react-5.32.8.tgz#0608b45cf552f33fcc9b3fc5e07740c9a854861f" + integrity sha512-Cstx4Tq8fT5l2TBxHxts8pG+ks0qKSkuO1pwUwgrQQiZ241Mqs+KUODLVIonsYXL/gqX143rkcipUa4d0Rid7w== dependencies: "@babel/runtime-corejs3" "^7.27.1" "@scarf/scarf" "=1.4.0" @@ -14221,7 +15197,7 @@ tapable@^2.0.0, tapable@^2.2.1, tapable@^2.3.0, tapable@^2.3.3: resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.3.3.tgz#5da7c9992c46038221267985ab28421a8879f160" integrity sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A== -terser-webpack-plugin@^5.3.9, terser-webpack-plugin@^5.5.0: +terser-webpack-plugin@^5.3.9: version "5.6.0" resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.6.0.tgz#8e7caad248183ab9e91ff08a83b0fc9f0439c3c3" integrity sha512-Eum+5ajkaOhf5KbM26osvv21kLD7BaGqQ1UA4Ami4arYwylmGUQTgHFpHDdmJod1q4QXa66p0to/FBKID+J1vA== @@ -14303,6 +15279,16 @@ tinypool@^1.0.2: resolved "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz" integrity sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg== +tinyrainbow@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/tinyrainbow/-/tinyrainbow-2.0.0.tgz#9509b2162436315e80e3eee0fcce4474d2444294" + integrity sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw== + +tinyspy@^4.0.3: + version "4.0.4" + resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-4.0.4.tgz#d77a002fb53a88aa1429b419c1c92492e0c81f78" + integrity sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q== + to-buffer@^1.2.0: version "1.2.1" resolved "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.1.tgz" @@ -14349,6 +15335,30 @@ tree-dump@^1.0.3: resolved "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz" integrity sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA== +tree-sitter-json@=0.24.8: + version "0.24.8" + resolved "https://registry.yarnpkg.com/tree-sitter-json/-/tree-sitter-json-0.24.8.tgz#72bfa26942691f2bf59d973b6794923c033f04c2" + integrity sha512-Tc9ZZYwHyWZ3Tt1VEw7Pa2scu1YO7/d2BCBbKTx5hXwig3UfdQjsOPkPyLpDJOn/m1UBEWYAtSdGAwCSyagBqQ== + dependencies: + node-addon-api "^8.2.2" + node-gyp-build "^4.8.2" + +tree-sitter@=0.21.1: + version "0.21.1" + resolved "https://registry.yarnpkg.com/tree-sitter/-/tree-sitter-0.21.1.tgz#fbb34c09056700814af0e1e37688e06463ba04c4" + integrity sha512-7dxoA6kYvtgWw80265MyqJlkRl4yawIjO7S5MigytjELkX43fV2WsAXzsNfO7sBpPPCF5Gp0+XzHk0DwLCq3xQ== + dependencies: + node-addon-api "^8.0.0" + node-gyp-build "^4.8.0" + +tree-sitter@=0.22.4: + version "0.22.4" + resolved "https://registry.yarnpkg.com/tree-sitter/-/tree-sitter-0.22.4.tgz#7d29547f663ff6f49ee8d1ac444e6fa169c72b77" + integrity sha512-usbHZP9/oxNsUY65MQUsduGRqDHQOou1cagUSwjhoSYAmSahjQDAVsh9s+SlZkn8X8+O1FULRGwHu7AFP3kjzg== + dependencies: + node-addon-api "^8.3.0" + node-gyp-build "^4.8.4" + trim-lines@^3.0.0: version "3.0.1" resolved "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz" @@ -14423,7 +15433,7 @@ type-fest@^1.0.1: resolved "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz" integrity sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA== -type-fest@^2.13.0, type-fest@^2.19.0, type-fest@^2.5.0: +type-fest@^2.13.0, type-fest@^2.5.0: version "2.19.0" resolved "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz" integrity sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA== @@ -14495,15 +15505,15 @@ types-ramda@^0.30.1: dependencies: ts-toolbelt "^9.6.0" -typescript-eslint@^8.61.1: - version "8.61.1" - resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.61.1.tgz#7c224a9a643b7f42d295c67a75c1e30fee8c3eaa" - integrity sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw== +typescript-eslint@^8.62.0: + version "8.62.0" + resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.62.0.tgz#7252c3c931637cda28794c0518f321ee89621d67" + integrity sha512-8QxXi+ZACKX0kaqO4gY8kn0RSD9gFfaHDWwjqtEN48aWCBkX4MJaufWN+c3BzlrXLOxfywDL8CaoqUwcRq4j4Q== dependencies: - "@typescript-eslint/eslint-plugin" "8.61.1" - "@typescript-eslint/parser" "8.61.1" - "@typescript-eslint/typescript-estree" "8.61.1" - "@typescript-eslint/utils" "8.61.1" + "@typescript-eslint/eslint-plugin" "8.62.0" + "@typescript-eslint/parser" "8.62.0" + "@typescript-eslint/typescript-estree" "8.62.0" + "@typescript-eslint/utils" "8.62.0" typescript@~6.0.3: version "6.0.3" @@ -14716,12 +15726,14 @@ unpipe@~1.0.0: resolved "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz" integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== -unplugin@^1.3.1: - version "1.16.1" - resolved "https://registry.npmjs.org/unplugin/-/unplugin-1.16.1.tgz" - integrity sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w== +unplugin@^2.3.5: + version "2.3.11" + resolved "https://registry.yarnpkg.com/unplugin/-/unplugin-2.3.11.tgz#411e020dd2ba90e2fbe1e7bd63a5a399e6ee3b54" + integrity sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww== dependencies: - acorn "^8.14.0" + "@jridgewell/remapping" "^2.3.5" + acorn "^8.15.0" + picomatch "^4.0.3" webpack-virtual-modules "^0.6.2" unraw@^3.0.0: @@ -14799,6 +15811,11 @@ use-sync-external-store@^1.4.0: resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz" integrity sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A== +use-sync-external-store@^1.5.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d" + integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w== + util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz" @@ -14811,17 +15828,6 @@ util@^0.10.3: dependencies: inherits "2.0.3" -util@^0.12.5: - version "0.12.5" - resolved "https://registry.npmjs.org/util/-/util-0.12.5.tgz" - integrity sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA== - dependencies: - inherits "^2.0.3" - is-arguments "^1.0.4" - is-generator-function "^1.0.7" - is-typed-array "^1.1.3" - which-typed-array "^1.1.2" - utila@~0.4: version "0.4.0" resolved "https://registry.npmjs.org/utila/-/utila-0.4.0.tgz" @@ -14941,12 +15947,11 @@ warning@^4.0.3: dependencies: loose-envify "^1.0.0" -watchpack@^2.5.1: - version "2.5.1" - resolved "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz" - integrity sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg== +watchpack@^2.5.2: + version "2.5.2" + resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.5.2.tgz#e12e82d84674266fc1c6dbfe38891b92ff0522ec" + integrity sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg== dependencies: - glob-to-regexp "^0.4.1" graceful-fs "^4.1.2" wbuf@^1.1.0, wbuf@^1.7.3: @@ -14966,6 +15971,11 @@ web-streams-polyfill@^3.0.3: resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b" integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw== +web-tree-sitter@=0.24.5: + version "0.24.5" + resolved "https://registry.yarnpkg.com/web-tree-sitter/-/web-tree-sitter-0.24.5.tgz#16cea449da63012f23ca7b83bd32817dd0520400" + integrity sha512-+J/2VSHN8J47gQUAvF8KDadrfz6uFYVjxoxbKWDoXVsH2u7yLdarCnIURnrMA6uSRkgX3SdmqM5BOoQjPdSh5w== + webidl-conversions@^3.0.0: version "3.0.1" resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz" @@ -15063,10 +16073,10 @@ webpack-virtual-modules@^0.6.2: resolved "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz" integrity sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ== -webpack@^5.107.2, webpack@^5.88.1, webpack@^5.95.0: - version "5.107.2" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.107.2.tgz#dea14dcb177b46b29de15f952f7303691ee2b596" - integrity sha512-v7RhXaJbpMlV0D7hC7lb2EbnxkoeUqf9qhKr6lozx3Q48pmFrqqNRmZFUEGmi7pSwm6fCQ2H1IjvCkHqdpVdjQ== +webpack@^5.108.0, webpack@^5.88.1, webpack@^5.95.0: + version "5.108.0" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.108.0.tgz#f8bb9b554f25374e8dfa3e1b137b0791a56d7614" + integrity sha512-Ln1JuYGPRTXcHECapSFSvACtHmWEN5sQqFJeLLGQ0057S7qzT2eXUz0MZUedtmIrNy3nJgnITSubIYKGED9jSQ== dependencies: "@types/estree" "^1.0.8" "@types/json-schema" "^7.0.15" @@ -15077,19 +16087,18 @@ webpack@^5.107.2, webpack@^5.88.1, webpack@^5.95.0: acorn-import-phases "^1.0.3" browserslist "^4.28.1" chrome-trace-event "^1.0.2" - enhanced-resolve "^5.22.0" + enhanced-resolve "^5.22.2" es-module-lexer "^2.1.0" eslint-scope "5.1.1" events "^3.2.0" - glob-to-regexp "^0.4.1" graceful-fs "^4.2.11" loader-runner "^4.3.2" mime-db "^1.54.0" + minimizer-webpack-plugin "^5.6.1" neo-async "^2.6.2" schema-utils "^4.3.3" tapable "^2.3.0" - terser-webpack-plugin "^5.5.0" - watchpack "^2.5.1" + watchpack "^2.5.2" webpack-sources "^3.5.0" webpackbar@^7.0.0: @@ -15169,7 +16178,7 @@ which-collection@^1.0.2: is-weakmap "^2.0.2" is-weakset "^2.0.3" -which-typed-array@^1.1.16, which-typed-array@^1.1.19, which-typed-array@^1.1.2: +which-typed-array@^1.1.16, which-typed-array@^1.1.19: version "1.1.19" resolved "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz" integrity sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw== @@ -15239,7 +16248,7 @@ write-file-atomic@^3.0.3: signal-exit "^3.0.2" typedarray-to-buffer "^3.1.5" -ws@^7.3.1, ws@^8.18.0, ws@^8.2.3, ws@^8.21.0: +ws@^7.3.1, ws@^8.18.0, ws@^8.21.0: version "8.21.0" resolved "https://registry.yarnpkg.com/ws/-/ws-8.21.0.tgz#012e413fc07429945121b0c153158c4343086951" integrity sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g== diff --git a/helm/superset/Chart.yaml b/helm/superset/Chart.yaml index 8c0f5bc072f..4e5a823e86d 100644 --- a/helm/superset/Chart.yaml +++ b/helm/superset/Chart.yaml @@ -29,7 +29,7 @@ maintainers: - name: craig-rueda email: craig@craigrueda.com url: https://github.com/craig-rueda -version: 0.18.0 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details. +version: 0.19.0 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details. dependencies: - name: postgresql version: 16.7.27 diff --git a/helm/superset/README.md b/helm/superset/README.md index c3dc7070525..59835dabc35 100644 --- a/helm/superset/README.md +++ b/helm/superset/README.md @@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs # superset -![Version: 0.18.0](https://img.shields.io/badge/Version-0.18.0-informational?style=flat-square) +![Version: 0.19.0](https://img.shields.io/badge/Version-0.19.0-informational?style=flat-square) Apache Superset is a modern, enterprise-ready business intelligence web application @@ -46,6 +46,21 @@ It should be a long random bytes or str. On helm this can be set on `extraSecretEnv.SUPERSET_SECRET_KEY` or `configOverrides.secrets` +## Upgrade Notes + +### Kubernetes recommended labels (breaking) + +This chart labels and selects workloads using the [Kubernetes recommended labels](https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/) (`app.kubernetes.io/*`) instead of the legacy `app`/`release` labels. A Deployment's `spec.selector.matchLabels` is immutable, so `helm upgrade` against a release created before this change fails with a `field is immutable` error. + +To upgrade an existing release, delete the affected workloads first (their selector labels changed), then upgrade so they are recreated: + +```console +kubectl delete deployment,statefulset -l release= -n +helm upgrade superset/superset +``` + +Alternatively, perform a fresh install. This is a one-time migration; subsequent upgrades are unaffected. + ## Requirements | Repository | Name | Version | diff --git a/helm/superset/README.md.gotmpl b/helm/superset/README.md.gotmpl index a2e3261af09..3047f8c9d5f 100644 --- a/helm/superset/README.md.gotmpl +++ b/helm/superset/README.md.gotmpl @@ -45,6 +45,21 @@ It should be a long random bytes or str. On helm this can be set on `extraSecretEnv.SUPERSET_SECRET_KEY` or `configOverrides.secrets` +## Upgrade Notes + +### Kubernetes recommended labels (breaking) + +This chart labels and selects workloads using the [Kubernetes recommended labels](https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/) (`app.kubernetes.io/*`) instead of the legacy `app`/`release` labels. A Deployment's `spec.selector.matchLabels` is immutable, so `helm upgrade` against a release created before this change fails with a `field is immutable` error. + +To upgrade an existing release, delete the affected workloads first (their selector labels changed), then upgrade so they are recreated: + +```console +kubectl delete deployment,statefulset -l release= -n +helm upgrade superset/superset +``` + +Alternatively, perform a fresh install. This is a one-time migration; subsequent upgrades are unaffected. + {{ template "chart.requirementsSection" . }} {{ template "chart.valuesSection" . }} diff --git a/helm/superset/templates/_helpers.tpl b/helm/superset/templates/_helpers.tpl index 221ac2f35e5..4533798eb7f 100644 --- a/helm/superset/templates/_helpers.tpl +++ b/helm/superset/templates/_helpers.tpl @@ -61,6 +61,49 @@ Create chart name and version as used by the chart label. {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} {{- end -}} +{{/* +Common labels for all resources - follows Kubernetes recommended labels +https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/ +*/}} +{{- define "superset.labels" -}} +helm.sh/chart: {{ include "superset.chart" . }} +{{ include "superset.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +app.kubernetes.io/part-of: superset +{{- if .Values.extraLabels }} +{{ toYaml .Values.extraLabels }} +{{- end }} +{{- end -}} + +{{/* +Selector labels - used by selectors and matchLabels +*/}} +{{- define "superset.selectorLabels" -}} +app.kubernetes.io/name: {{ include "superset.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end -}} + +{{/* +Component labels - extends superset.labels with component-specific labels +Usage: {{ include "superset.componentLabels" (dict "component" "web" "root" .) }} +*/}} +{{- define "superset.componentLabels" -}} +{{ include "superset.labels" .root }} +app.kubernetes.io/component: {{ .component }} +{{- end -}} + +{{/* +Component selector labels - for matchLabels with component +Usage: {{ include "superset.componentSelectorLabels" (dict "component" "web" "root" .) }} +*/}} +{{- define "superset.componentSelectorLabels" -}} +{{ include "superset.selectorLabels" .root }} +app.kubernetes.io/component: {{ .component }} +{{- end -}} + {{- define "superset-config" }} import os @@ -146,27 +189,32 @@ RESULTS_BACKEND = RedisCache( {{- end }} +{{- define "supersetNode.selectorLabels" -}} +app.kubernetes.io/name: {{ include "superset.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/component: web +{{- end }} + {{- define "supersetCeleryBeat.selectorLabels" -}} -app: {{ include "superset.name" . }}-celerybeat -release: {{ .Release.Name }} +app.kubernetes.io/name: {{ include "superset.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/component: celerybeat {{- end }} {{- define "supersetCeleryFlower.selectorLabels" -}} -app: {{ include "superset.name" . }}-flower -release: {{ .Release.Name }} -{{- end }} - -{{- define "supersetNode.selectorLabels" -}} -app: {{ include "superset.name" . }} -release: {{ .Release.Name }} +app.kubernetes.io/name: {{ include "superset.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/component: flower {{- end }} {{- define "supersetWebsockets.selectorLabels" -}} -app: {{ include "superset.name" . }}-ws -release: {{ .Release.Name }} +app.kubernetes.io/name: {{ include "superset.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/component: websocket {{- end }} {{- define "supersetWorker.selectorLabels" -}} -app: {{ include "superset.name" . }}-worker -release: {{ .Release.Name }} +app.kubernetes.io/name: {{ include "superset.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/component: worker {{- end }} diff --git a/helm/superset/templates/configmap-superset.yaml b/helm/superset/templates/configmap-superset.yaml index fa0928c1fbc..767bb5c811c 100644 --- a/helm/superset/templates/configmap-superset.yaml +++ b/helm/superset/templates/configmap-superset.yaml @@ -24,13 +24,7 @@ metadata: name: {{ template "superset.fullname" . }}-extra-config namespace: {{ .Release.Namespace }} labels: - app: {{ template "superset.name" . }} - chart: {{ template "superset.chart" . }} - release: {{ .Release.Name }} - heritage: {{ .Release.Service }} - {{- if .Values.extraLabels }} - {{- toYaml .Values.extraLabels | nindent 4 }} - {{- end }} + {{- include "superset.labels" . | nindent 4 }} data: {{- range $path, $config := .Values.extraConfigs }} {{ $path }}: | diff --git a/helm/superset/templates/deployment-beat.yaml b/helm/superset/templates/deployment-beat.yaml index ceba9d5c5ae..be285659450 100644 --- a/helm/superset/templates/deployment-beat.yaml +++ b/helm/superset/templates/deployment-beat.yaml @@ -24,13 +24,7 @@ metadata: name: {{ template "superset.fullname" . }}-celerybeat namespace: {{ .Release.Namespace }} labels: - app: {{ template "superset.name" . }}-celerybeat - chart: {{ template "superset.chart" . }} - release: {{ .Release.Name }} - heritage: {{ .Release.Service }} - {{- if .Values.extraLabels }} - {{- toYaml .Values.extraLabels | nindent 4 }} - {{- end }} + {{- include "superset.componentLabels" (dict "component" "celerybeat" "root" .) | nindent 4 }} {{- if .Values.supersetCeleryBeat.deploymentAnnotations }} annotations: {{- toYaml .Values.supersetCeleryBeat.deploymentAnnotations | nindent 4 }} {{- end }} @@ -59,11 +53,7 @@ spec: {{- toYaml .Values.supersetCeleryBeat.podAnnotations | nindent 8 }} {{- end }} labels: - app: "{{ template "superset.name" . }}-celerybeat" - release: {{ .Release.Name }} - {{- if .Values.extraLabels }} - {{- toYaml .Values.extraLabels | nindent 8 }} - {{- end }} + {{- include "supersetCeleryBeat.selectorLabels" . | nindent 8 }} {{- if .Values.supersetCeleryBeat.podLabels }} {{- toYaml .Values.supersetCeleryBeat.podLabels | nindent 8 }} {{- end }} diff --git a/helm/superset/templates/deployment-flower.yaml b/helm/superset/templates/deployment-flower.yaml index 67dcc34e9a4..27e3080eed8 100644 --- a/helm/superset/templates/deployment-flower.yaml +++ b/helm/superset/templates/deployment-flower.yaml @@ -24,13 +24,7 @@ metadata: name: {{ template "superset.fullname" . }}-flower namespace: {{ .Release.Namespace }} labels: - app: {{ template "superset.name" . }}-flower - chart: {{ template "superset.chart" . }} - release: {{ .Release.Name }} - heritage: {{ .Release.Service }} - {{- if .Values.extraLabels }} - {{- toYaml .Values.extraLabels | nindent 4 }} - {{- end }} + {{- include "superset.componentLabels" (dict "component" "flower" "root" .) | nindent 4 }} {{- if .Values.supersetCeleryFlower.deploymentAnnotations }} annotations: {{- toYaml .Values.supersetCeleryFlower.deploymentAnnotations | nindent 4 }} {{- end }} @@ -48,11 +42,7 @@ spec: {{- toYaml .Values.supersetCeleryFlower.podAnnotations | nindent 8 }} {{- end }} labels: - app: "{{ template "superset.name" . }}-flower" - release: {{ .Release.Name }} - {{- if .Values.extraLabels }} - {{- toYaml .Values.extraLabels | nindent 8 }} - {{- end }} + {{- include "supersetCeleryFlower.selectorLabels" . | nindent 8 }} {{- if .Values.supersetCeleryFlower.podLabels }} {{- toYaml .Values.supersetCeleryFlower.podLabels | nindent 8 }} {{- end }} diff --git a/helm/superset/templates/deployment-worker.yaml b/helm/superset/templates/deployment-worker.yaml index 0bab3e3be56..60034b7c721 100644 --- a/helm/superset/templates/deployment-worker.yaml +++ b/helm/superset/templates/deployment-worker.yaml @@ -23,15 +23,9 @@ metadata: name: {{ template "superset.fullname" . }}-worker namespace: {{ .Release.Namespace }} labels: - app: {{ template "superset.name" . }}-worker - chart: {{ template "superset.chart" . }} - release: {{ .Release.Name }} - heritage: {{ .Release.Service }} - {{- if .Values.extraLabels }} - {{- toYaml .Values.extraLabels | nindent 4 }} - {{- end }} - {{- if .Values.supersetWorker.deploymentLabels }} - {{- toYaml .Values.supersetWorker.deploymentLabels | nindent 4 }} + {{- include "superset.componentLabels" (dict "component" "worker" "root" .) | nindent 4 }} + {{- with .Values.supersetWorker.deploymentLabels }} + {{- toYaml . | nindent 4 }} {{- end }} {{- if .Values.supersetWorker.deploymentAnnotations }} annotations: {{- toYaml .Values.supersetWorker.deploymentAnnotations | nindent 4 }} @@ -65,11 +59,7 @@ spec: {{- toYaml .Values.supersetWorker.podAnnotations | nindent 8 }} {{- end }} labels: - app: {{ template "superset.name" . }}-worker - release: {{ .Release.Name }} - {{- if .Values.extraLabels }} - {{- toYaml .Values.extraLabels | nindent 8 }} - {{- end }} + {{- include "supersetWorker.selectorLabels" . | nindent 8 }} {{- if .Values.supersetWorker.podLabels }} {{- toYaml .Values.supersetWorker.podLabels | nindent 8 }} {{- end }} diff --git a/helm/superset/templates/deployment-ws.yaml b/helm/superset/templates/deployment-ws.yaml index 6281e34771b..d7b15de3e2b 100644 --- a/helm/superset/templates/deployment-ws.yaml +++ b/helm/superset/templates/deployment-ws.yaml @@ -24,13 +24,7 @@ metadata: name: "{{ template "superset.fullname" . }}-ws" namespace: {{ .Release.Namespace }} labels: - app: "{{ template "superset.name" . }}-ws" - chart: {{ template "superset.chart" . }} - release: {{ .Release.Name }} - heritage: {{ .Release.Service }} - {{- if .Values.extraLabels }} - {{- toYaml .Values.extraLabels | nindent 4 }} - {{- end }} + {{- include "superset.componentLabels" (dict "component" "websocket" "root" .) | nindent 4 }} {{- if .Values.supersetWebsockets.deploymentAnnotations }} annotations: {{- toYaml .Values.supersetWebsockets.deploymentAnnotations | nindent 4 }} {{- end }} @@ -51,11 +45,7 @@ spec: {{- toYaml .Values.supersetWebsockets.podAnnotations | nindent 8 }} {{- end }} labels: - app: "{{ template "superset.name" . }}-ws" - release: {{ .Release.Name }} - {{- if .Values.extraLabels }} - {{- toYaml .Values.extraLabels | nindent 8 }} - {{- end }} + {{- include "supersetWebsockets.selectorLabels" . | nindent 8 }} {{- if .Values.supersetWebsockets.podLabels }} {{- toYaml .Values.supersetWebsockets.podLabels | nindent 8 }} {{- end }} diff --git a/helm/superset/templates/deployment.yaml b/helm/superset/templates/deployment.yaml index aab15bef207..1686af945a3 100644 --- a/helm/superset/templates/deployment.yaml +++ b/helm/superset/templates/deployment.yaml @@ -23,15 +23,9 @@ metadata: name: {{ template "superset.fullname" . }} namespace: {{ .Release.Namespace }} labels: - app: {{ template "superset.name" . }} - chart: {{ template "superset.chart" . }} - release: {{ .Release.Name }} - heritage: {{ .Release.Service }} - {{- if .Values.extraLabels }} - {{- toYaml .Values.extraLabels | nindent 4 }} - {{- end }} - {{- if .Values.supersetNode.deploymentLabels }} - {{- toYaml .Values.supersetNode.deploymentLabels | nindent 4 }} + {{- include "superset.componentLabels" (dict "component" "web" "root" .) | nindent 4 }} + {{- with .Values.supersetNode.deploymentLabels }} + {{- toYaml . | nindent 4 }} {{- end }} {{- if .Values.supersetNode.deploymentAnnotations }} annotations: {{- toYaml .Values.supersetNode.deploymentAnnotations | nindent 4 }} @@ -67,11 +61,7 @@ spec: {{- toYaml .Values.supersetNode.podAnnotations | nindent 8 }} {{- end }} labels: - app: {{ template "superset.name" . }} - release: {{ .Release.Name }} - {{- if .Values.extraLabels }} - {{- toYaml .Values.extraLabels | nindent 8 }} - {{- end }} + {{- include "supersetNode.selectorLabels" . | nindent 8 }} {{- if .Values.supersetNode.podLabels }} {{- toYaml .Values.supersetNode.podLabels | nindent 8 }} {{- end }} diff --git a/helm/superset/templates/hpa-node.yaml b/helm/superset/templates/hpa-node.yaml index 843620ea15e..7b830ebbded 100644 --- a/helm/superset/templates/hpa-node.yaml +++ b/helm/superset/templates/hpa-node.yaml @@ -23,13 +23,7 @@ kind: HorizontalPodAutoscaler metadata: name: {{ include "superset.fullname" . }}-hpa labels: - app: {{ template "superset.name" . }} - chart: {{ template "superset.chart" . }} - release: {{ .Release.Name }} - heritage: {{ .Release.Service }} - {{- if .Values.extraLabels }} - {{- toYaml .Values.extraLabels | nindent 4 }} - {{- end }} + {{- include "superset.labels" . | nindent 4 }} spec: scaleTargetRef: apiVersion: apps/v1 diff --git a/helm/superset/templates/hpa-worker.yaml b/helm/superset/templates/hpa-worker.yaml index fd4f027cc6b..9d9664a23d0 100644 --- a/helm/superset/templates/hpa-worker.yaml +++ b/helm/superset/templates/hpa-worker.yaml @@ -23,13 +23,7 @@ kind: HorizontalPodAutoscaler metadata: name: {{ include "superset.fullname" . }}-hpa-worker labels: - app: {{ template "superset.name" . }} - chart: {{ template "superset.chart" . }} - release: {{ .Release.Name }} - heritage: {{ .Release.Service }} - {{- if .Values.extraLabels }} - {{- toYaml .Values.extraLabels | nindent 4 }} - {{- end }} + {{- include "superset.labels" . | nindent 4 }} spec: scaleTargetRef: apiVersion: apps/v1 diff --git a/helm/superset/templates/ingress.yaml b/helm/superset/templates/ingress.yaml index 05acb20d6ff..f7ffdbe05e3 100644 --- a/helm/superset/templates/ingress.yaml +++ b/helm/superset/templates/ingress.yaml @@ -25,13 +25,7 @@ metadata: name: {{ $fullName }} namespace: {{ .Release.Namespace }} labels: - app: {{ template "superset.name" . }} - chart: {{ template "superset.chart" . }} - release: {{ .Release.Name }} - heritage: {{ .Release.Service }} - {{- if .Values.extraLabels }} - {{- toYaml .Values.extraLabels | nindent 4 }} - {{- end }} + {{- include "superset.componentLabels" (dict "component" "ingress" "root" .) | nindent 4 }} {{- with .Values.ingress.annotations }} annotations: {{- toYaml . | nindent 4 }} {{- end }} diff --git a/helm/superset/templates/init-job.yaml b/helm/superset/templates/init-job.yaml index 048d5834fde..24b119f434f 100644 --- a/helm/superset/templates/init-job.yaml +++ b/helm/superset/templates/init-job.yaml @@ -24,13 +24,7 @@ metadata: name: {{ template "superset.fullname" . }}-init-db namespace: {{ .Release.Namespace }} labels: - app: {{ template "superset.name" . }} - chart: {{ template "superset.chart" . }} - release: {{ .Release.Name }} - heritage: {{ .Release.Service }} - {{- if .Values.extraLabels }} - {{- toYaml .Values.extraLabels | nindent 4 }} - {{- end }} + {{- include "superset.componentLabels" (dict "component" "init" "root" .) | nindent 4 }} {{- if .Values.init.jobAnnotations }} annotations: {{- toYaml .Values.init.jobAnnotations | nindent 4 }} {{- end }} @@ -42,9 +36,7 @@ spec: annotations: {{- toYaml .Values.init.podAnnotations | nindent 8 }} {{- end }} labels: - app: {{ template "superset.name" . }} - chart: {{ template "superset.chart" . }} - release: {{ .Release.Name }} + {{- include "superset.componentSelectorLabels" (dict "component" "init" "root" .) | nindent 8 }} job: {{ template "superset.fullname" . }}-init-db {{- if .Values.extraLabels }} {{- toYaml .Values.extraLabels | nindent 8 }} diff --git a/helm/superset/templates/pdb-beat.yaml b/helm/superset/templates/pdb-beat.yaml index 0190d226986..1650977464a 100644 --- a/helm/superset/templates/pdb-beat.yaml +++ b/helm/superset/templates/pdb-beat.yaml @@ -27,13 +27,7 @@ kind: PodDisruptionBudget metadata: name: {{ include "superset.fullname" $ }}-celerybeat-pdb labels: - app: {{ template "superset.name" $ }}-celerybeat - chart: {{ template "superset.chart" $ }} - release: {{ $.Release.Name }} - heritage: {{ $.Release.Service }} - {{- if $.Values.extraLabels }} - {{- toYaml $.Values.extraLabels | nindent 4 }} - {{- end }} + {{- include "superset.componentLabels" (dict "component" "celerybeat" "root" $) | nindent 4 }} spec: {{- if .minAvailable }} minAvailable: {{ .minAvailable }} diff --git a/helm/superset/templates/pdb-flower.yaml b/helm/superset/templates/pdb-flower.yaml index 8a8c530abec..373e24971fa 100644 --- a/helm/superset/templates/pdb-flower.yaml +++ b/helm/superset/templates/pdb-flower.yaml @@ -27,13 +27,7 @@ kind: PodDisruptionBudget metadata: name: {{ include "superset.fullname" $ }}-flower-pdb labels: - app: {{ template "superset.name" $ }}-flower - chart: {{ template "superset.chart" $ }} - release: {{ $.Release.Name }} - heritage: {{ $.Release.Service }} - {{- if $.Values.extraLabels }} - {{- toYaml $.Values.extraLabels | nindent 4 }} - {{- end }} + {{- include "superset.componentLabels" (dict "component" "flower" "root" $) | nindent 4 }} spec: {{- if .minAvailable }} minAvailable: {{ .minAvailable }} diff --git a/helm/superset/templates/pdb-worker.yaml b/helm/superset/templates/pdb-worker.yaml index 3f7dfcd3735..205caf2dac9 100644 --- a/helm/superset/templates/pdb-worker.yaml +++ b/helm/superset/templates/pdb-worker.yaml @@ -27,13 +27,7 @@ kind: PodDisruptionBudget metadata: name: {{ include "superset.fullname" $ }}-worker-pdb labels: - app: {{ template "superset.name" $ }}-worker - chart: {{ template "superset.chart" $ }} - release: {{ $.Release.Name }} - heritage: {{ $.Release.Service }} - {{- if $.Values.extraLabels }} - {{- toYaml $.Values.extraLabels | nindent 4 }} - {{- end }} + {{- include "superset.componentLabels" (dict "component" "worker" "root" $) | nindent 4 }} spec: {{- if .minAvailable }} minAvailable: {{ .minAvailable }} diff --git a/helm/superset/templates/pdb-ws.yaml b/helm/superset/templates/pdb-ws.yaml index 6cbefc933c4..8191a1545d2 100644 --- a/helm/superset/templates/pdb-ws.yaml +++ b/helm/superset/templates/pdb-ws.yaml @@ -27,13 +27,7 @@ kind: PodDisruptionBudget metadata: name: {{ include "superset.fullname" $ }}-ws-pdb labels: - app: {{ template "superset.name" $ }}-ws - chart: {{ template "superset.chart" $ }} - release: {{ $.Release.Name }} - heritage: {{ $.Release.Service }} - {{- if $.Values.extraLabels }} - {{- toYaml $.Values.extraLabels | nindent 4 }} - {{- end }} + {{- include "superset.componentLabels" (dict "component" "websocket" "root" $) | nindent 4 }} spec: {{- if .minAvailable }} minAvailable: {{ .minAvailable }} diff --git a/helm/superset/templates/pdb.yaml b/helm/superset/templates/pdb.yaml index a7f16d3a785..ab2e271e717 100644 --- a/helm/superset/templates/pdb.yaml +++ b/helm/superset/templates/pdb.yaml @@ -27,13 +27,7 @@ kind: PodDisruptionBudget metadata: name: {{ include "superset.fullname" $ }}-pdb labels: - app: {{ template "superset.name" $ }} - chart: {{ template "superset.chart" $ }} - release: {{ $.Release.Name }} - heritage: {{ $.Release.Service }} - {{- if $.Values.extraLabels }} - {{- toYaml $.Values.extraLabels | nindent 4 }} - {{- end }} + {{- include "superset.componentLabels" (dict "component" "web" "root" $) | nindent 4 }} spec: {{- if .minAvailable }} minAvailable: {{ .minAvailable }} diff --git a/helm/superset/templates/secret-env.yaml b/helm/superset/templates/secret-env.yaml index c78c860c3e2..b16cd755030 100644 --- a/helm/superset/templates/secret-env.yaml +++ b/helm/superset/templates/secret-env.yaml @@ -23,13 +23,7 @@ metadata: name: {{ template "superset.fullname" . }}-env namespace: {{ .Release.Namespace }} labels: - app: {{ template "superset.fullname" . }} - chart: {{ template "superset.chart" . }} - release: "{{ .Release.Name }}" - heritage: "{{ .Release.Service }}" - {{- if .Values.extraLabels }} - {{- toYaml .Values.extraLabels | nindent 4 }} - {{- end }} + {{- include "superset.labels" . | nindent 4 }} type: Opaque stringData: REDIS_HOST: {{ tpl .Values.supersetNode.connections.redis_host . | quote }} diff --git a/helm/superset/templates/secret-superset-config.yaml b/helm/superset/templates/secret-superset-config.yaml index c672fe900b0..4a3d5d0ee85 100644 --- a/helm/superset/templates/secret-superset-config.yaml +++ b/helm/superset/templates/secret-superset-config.yaml @@ -23,13 +23,7 @@ metadata: name: {{ template "superset.fullname" . }}-config namespace: {{ .Release.Namespace }} labels: - app: {{ template "superset.fullname" . }} - chart: {{ template "superset.chart" . }} - release: "{{ .Release.Name }}" - heritage: "{{ .Release.Service }}" - {{- if .Values.extraLabels }} - {{- toYaml .Values.extraLabels | nindent 4 }} - {{- end }} + {{- include "superset.labels" . | nindent 4 }} type: Opaque stringData: superset_config.py: | diff --git a/helm/superset/templates/secret-ws.yaml b/helm/superset/templates/secret-ws.yaml index a559ac0056a..b37ba4600bd 100644 --- a/helm/superset/templates/secret-ws.yaml +++ b/helm/superset/templates/secret-ws.yaml @@ -24,13 +24,7 @@ metadata: name: "{{ template "superset.fullname" . }}-ws-config" namespace: {{ .Release.Namespace }} labels: - app: {{ template "superset.fullname" . }} - chart: {{ template "superset.chart" . }} - release: "{{ .Release.Name }}" - heritage: "{{ .Release.Service }}" - {{- if .Values.extraLabels }} - {{- toYaml .Values.extraLabels | nindent 4 }} - {{- end }} + {{- include "superset.labels" . | nindent 4 }} type: Opaque stringData: config.json: | diff --git a/helm/superset/templates/service-flower.yaml b/helm/superset/templates/service-flower.yaml index c2bda0fb86b..0ab98496118 100644 --- a/helm/superset/templates/service-flower.yaml +++ b/helm/superset/templates/service-flower.yaml @@ -24,13 +24,7 @@ metadata: name: "{{ template "superset.fullname" . }}-flower" namespace: {{ .Release.Namespace }} labels: - app: {{ template "superset.name" . }} - chart: {{ template "superset.chart" . }} - release: {{ .Release.Name }} - heritage: {{ .Release.Service }} - {{- if .Values.extraLabels }} - {{- toYaml .Values.extraLabels | nindent 4 }} - {{- end }} + {{- include "superset.componentLabels" (dict "component" "flower" "root" .) | nindent 4 }} {{- with .Values.supersetCeleryFlower.service.annotations }} annotations: {{- toYaml . | nindent 4 }} {{- end }} @@ -45,8 +39,7 @@ spec: nodePort: {{ .Values.supersetCeleryFlower.service.nodePort.http }} {{- end }} selector: - app: {{ template "superset.name" . }}-flower - release: {{ .Release.Name }} + {{- include "supersetCeleryFlower.selectorLabels" . | nindent 4 }} {{- if .Values.supersetCeleryFlower.service.loadBalancerIP }} loadBalancerIP: {{ .Values.supersetCeleryFlower.service.loadBalancerIP }} {{- end }} diff --git a/helm/superset/templates/service-ws.yaml b/helm/superset/templates/service-ws.yaml index 1517e2e5772..02f45fa4c3c 100644 --- a/helm/superset/templates/service-ws.yaml +++ b/helm/superset/templates/service-ws.yaml @@ -24,13 +24,7 @@ metadata: name: "{{ template "superset.fullname" . }}-ws" namespace: {{ .Release.Namespace }} labels: - app: {{ template "superset.name" . }} - chart: {{ template "superset.chart" . }} - release: {{ .Release.Name }} - heritage: {{ .Release.Service }} - {{- if .Values.extraLabels }} - {{- toYaml .Values.extraLabels | nindent 4 }} - {{- end }} + {{- include "superset.componentLabels" (dict "component" "websocket" "root" .) | nindent 4 }} {{- with .Values.supersetWebsockets.service.annotations }} annotations: {{- toYaml . | nindent 4 }} {{- end }} @@ -45,8 +39,7 @@ spec: nodePort: {{ .Values.supersetWebsockets.service.nodePort.http }} {{- end }} selector: - app: "{{ template "superset.name" . }}-ws" - release: {{ .Release.Name }} + {{- include "supersetWebsockets.selectorLabels" . | nindent 4 }} {{- if .Values.supersetWebsockets.service.loadBalancerIP }} loadBalancerIP: {{ .Values.supersetWebsockets.service.loadBalancerIP }} {{- end }} diff --git a/helm/superset/templates/service.yaml b/helm/superset/templates/service.yaml index e435961ba9b..76b96c6d735 100644 --- a/helm/superset/templates/service.yaml +++ b/helm/superset/templates/service.yaml @@ -23,13 +23,7 @@ metadata: name: {{ template "superset.fullname" . }} namespace: {{ .Release.Namespace }} labels: - app: {{ template "superset.name" . }} - chart: {{ template "superset.chart" . }} - release: {{ .Release.Name }} - heritage: {{ .Release.Service }} - {{- if .Values.extraLabels }} - {{- toYaml .Values.extraLabels | nindent 4 }} - {{- end }} + {{- include "superset.componentLabels" (dict "component" "web" "root" .) | nindent 4 }} {{- with .Values.service.annotations }} annotations: {{- toYaml . | nindent 4 }} {{- end }} @@ -44,8 +38,7 @@ spec: nodePort: {{ .Values.service.nodePort.http }} {{- end }} selector: - app: {{ template "superset.name" . }} - release: {{ .Release.Name }} + {{- include "supersetNode.selectorLabels" . | nindent 4 }} {{- if .Values.service.loadBalancerIP }} loadBalancerIP: {{ .Values.service.loadBalancerIP }} {{- end }} diff --git a/helm/superset/templates/serviceaccount.yaml b/helm/superset/templates/serviceaccount.yaml index e92fc6202fa..2a3bfdb2c83 100644 --- a/helm/superset/templates/serviceaccount.yaml +++ b/helm/superset/templates/serviceaccount.yaml @@ -24,17 +24,11 @@ metadata: name: {{ include "superset.serviceAccountName" . }} namespace: {{ .Release.Namespace }} labels: - app.kubernetes.io/name: {{ include "superset.name" . }} - helm.sh/chart: {{ include "superset.chart" . }} - app.kubernetes.io/instance: {{ .Release.Name }} - app.kubernetes.io/managed-by: {{ .Release.Service }} + {{- include "superset.labels" . | nindent 4 }} {{- if semverCompare "> 1.6" .Capabilities.KubeVersion.GitVersion }} kubernetes.io/cluster-service: "true" {{- end }} addonmanager.kubernetes.io/mode: Reconcile - {{- if .Values.extraLabels }} - {{- toYaml .Values.extraLabels | nindent 4 }} - {{- end }} {{- if .Values.serviceAccount.annotations }} annotations: {{- toYaml .Values.serviceAccount.annotations | nindent 4 }} {{- end }} diff --git a/helm/superset/tests/labels_test.yaml b/helm/superset/tests/labels_test.yaml new file mode 100644 index 00000000000..54c4685f196 --- /dev/null +++ b/helm/superset/tests/labels_test.yaml @@ -0,0 +1,520 @@ +# +# 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. +# +suite: Label Consistency Tests +templates: + - deployment.yaml + - deployment-worker.yaml + - deployment-beat.yaml + - deployment-flower.yaml + - deployment-ws.yaml + - service.yaml + - service-ws.yaml + - service-flower.yaml + - init-job.yaml + - ingress.yaml + - configmap-superset.yaml + - secret-superset-config.yaml + - secret-ws.yaml + - pdb.yaml + - pdb-worker.yaml + - pdb-beat.yaml + - pdb-flower.yaml + - pdb-ws.yaml + +# These tests validate that Kubernetes recommended labels are consistently applied +# across all chart resources per https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/ +# +# Required Labels (app.kubernetes.io/): +# - name: The name of the application +# - instance: A unique name identifying the instance of an application +# - version: The current version of the application +# - component: The component within the architecture +# - part-of: The name of a higher level application this one is part of +# - managed-by: The tool being used to manage the operation of an application +# +# Helm-specific Labels: +# - helm.sh/chart: The chart name and version + +tests: + # ============================================================================= + # Main Deployment Labels + # ============================================================================= + - it: should have all recommended labels on main deployment + template: deployment.yaml + asserts: + - isNotNull: + path: metadata.labels["app.kubernetes.io/name"] + - isNotNull: + path: metadata.labels["app.kubernetes.io/instance"] + - isNotNull: + path: metadata.labels["app.kubernetes.io/version"] + - isNotNull: + path: metadata.labels["app.kubernetes.io/managed-by"] + - isNotNull: + path: metadata.labels["app.kubernetes.io/part-of"] + - isNotNull: + path: metadata.labels["app.kubernetes.io/component"] + - isNotNull: + path: metadata.labels["helm.sh/chart"] + + - it: should have correct component label on main deployment + template: deployment.yaml + asserts: + - equal: + path: metadata.labels["app.kubernetes.io/component"] + value: web + + - it: should have part-of label set to superset on main deployment + template: deployment.yaml + asserts: + - equal: + path: metadata.labels["app.kubernetes.io/part-of"] + value: superset + + # ============================================================================= + # Worker Deployment Labels + # ============================================================================= + - it: should have all recommended labels on worker deployment + template: deployment-worker.yaml + asserts: + - isNotNull: + path: metadata.labels["app.kubernetes.io/name"] + - isNotNull: + path: metadata.labels["app.kubernetes.io/instance"] + - isNotNull: + path: metadata.labels["app.kubernetes.io/version"] + - isNotNull: + path: metadata.labels["app.kubernetes.io/managed-by"] + - isNotNull: + path: metadata.labels["app.kubernetes.io/part-of"] + - isNotNull: + path: metadata.labels["app.kubernetes.io/component"] + + - it: should have correct component label on worker deployment + template: deployment-worker.yaml + asserts: + - equal: + path: metadata.labels["app.kubernetes.io/component"] + value: worker + + # ============================================================================= + # Celery Beat Deployment Labels + # ============================================================================= + - it: should have all recommended labels on celerybeat deployment + template: deployment-beat.yaml + set: + supersetCeleryBeat.enabled: true + asserts: + - isNotNull: + path: metadata.labels["app.kubernetes.io/name"] + - isNotNull: + path: metadata.labels["app.kubernetes.io/instance"] + - isNotNull: + path: metadata.labels["app.kubernetes.io/component"] + + - it: should have correct component label on celerybeat deployment + template: deployment-beat.yaml + set: + supersetCeleryBeat.enabled: true + asserts: + - equal: + path: metadata.labels["app.kubernetes.io/component"] + value: celerybeat + + # ============================================================================= + # Flower Deployment Labels + # ============================================================================= + - it: should have all recommended labels on flower deployment + template: deployment-flower.yaml + set: + supersetCeleryFlower.enabled: true + asserts: + - isNotNull: + path: metadata.labels["app.kubernetes.io/name"] + - isNotNull: + path: metadata.labels["app.kubernetes.io/instance"] + - isNotNull: + path: metadata.labels["app.kubernetes.io/component"] + + - it: should have correct component label on flower deployment + template: deployment-flower.yaml + set: + supersetCeleryFlower.enabled: true + asserts: + - equal: + path: metadata.labels["app.kubernetes.io/component"] + value: flower + + # ============================================================================= + # WebSocket Deployment Labels + # ============================================================================= + - it: should have all recommended labels on websocket deployment + template: deployment-ws.yaml + set: + supersetWebsockets.enabled: true + supersetWebsockets.config.jwtSecret: "test-secret-for-unit-test" + asserts: + - isNotNull: + path: metadata.labels["app.kubernetes.io/name"] + - isNotNull: + path: metadata.labels["app.kubernetes.io/instance"] + - isNotNull: + path: metadata.labels["app.kubernetes.io/component"] + + - it: should have correct component label on websocket deployment + template: deployment-ws.yaml + set: + supersetWebsockets.enabled: true + supersetWebsockets.config.jwtSecret: "test-secret-for-unit-test" + asserts: + - equal: + path: metadata.labels["app.kubernetes.io/component"] + value: websocket + + # ============================================================================= + # Service Labels + # ============================================================================= + - it: should have all recommended labels on main service + template: service.yaml + asserts: + - isNotNull: + path: metadata.labels["app.kubernetes.io/name"] + - isNotNull: + path: metadata.labels["app.kubernetes.io/instance"] + - isNotNull: + path: metadata.labels["app.kubernetes.io/component"] + + - it: should have correct component label on main service + template: service.yaml + asserts: + - equal: + path: metadata.labels["app.kubernetes.io/component"] + value: web + + - it: should have all recommended labels on websocket service + template: service-ws.yaml + set: + supersetWebsockets.enabled: true + supersetWebsockets.config.jwtSecret: "test-secret-for-unit-test" + asserts: + - isNotNull: + path: metadata.labels["app.kubernetes.io/name"] + - isNotNull: + path: metadata.labels["app.kubernetes.io/instance"] + - isNotNull: + path: metadata.labels["app.kubernetes.io/component"] + + - it: should have correct component label on websocket service + template: service-ws.yaml + set: + supersetWebsockets.enabled: true + supersetWebsockets.config.jwtSecret: "test-secret-for-unit-test" + asserts: + - equal: + path: metadata.labels["app.kubernetes.io/component"] + value: websocket + + - it: should have all recommended labels on flower service + template: service-flower.yaml + set: + supersetCeleryFlower.enabled: true + asserts: + - isNotNull: + path: metadata.labels["app.kubernetes.io/name"] + - isNotNull: + path: metadata.labels["app.kubernetes.io/instance"] + - isNotNull: + path: metadata.labels["app.kubernetes.io/component"] + + - it: should have correct component label on flower service + template: service-flower.yaml + set: + supersetCeleryFlower.enabled: true + asserts: + - equal: + path: metadata.labels["app.kubernetes.io/component"] + value: flower + + # ============================================================================= + # Init Job Labels + # ============================================================================= + - it: should have all recommended labels on init job + template: init-job.yaml + set: + init.enabled: true + init.createAdmin: true + init.adminUser.password: "test-password" + asserts: + - isNotNull: + path: metadata.labels["app.kubernetes.io/name"] + - isNotNull: + path: metadata.labels["app.kubernetes.io/instance"] + - isNotNull: + path: metadata.labels["app.kubernetes.io/component"] + + - it: should have correct component label on init job + template: init-job.yaml + set: + init.enabled: true + init.createAdmin: true + init.adminUser.password: "test-password" + asserts: + - equal: + path: metadata.labels["app.kubernetes.io/component"] + value: init + + # ============================================================================= + # Ingress Labels + # ============================================================================= + - it: should have all recommended labels on ingress + template: ingress.yaml + set: + ingress.enabled: true + ingress.hosts: + - host: superset.example.com + paths: + - path: / + pathType: Prefix + asserts: + - isNotNull: + path: metadata.labels["app.kubernetes.io/name"] + - isNotNull: + path: metadata.labels["app.kubernetes.io/instance"] + - isNotNull: + path: metadata.labels["app.kubernetes.io/component"] + + - it: should have correct component label on ingress + template: ingress.yaml + set: + ingress.enabled: true + ingress.hosts: + - host: superset.example.com + paths: + - path: / + pathType: Prefix + asserts: + - equal: + path: metadata.labels["app.kubernetes.io/component"] + value: ingress + + # ============================================================================= + # Selector Label Consistency + # + # These use value assertions (not isNotNull) on purpose: a missing/misscoped + # release name renders as the string "", which is non-null and would + # silently pass isNotNull. Asserting the concrete value catches that class of + # bug, and asserting the pod template labels equal the selector guards the + # immutable spec.selector.matchLabels <-> pod label invariant. + # ============================================================================= + - it: should set selector matchLabels to concrete values on main deployment + template: deployment.yaml + asserts: + - equal: + path: spec.selector.matchLabels["app.kubernetes.io/name"] + value: superset + - equal: + path: spec.selector.matchLabels["app.kubernetes.io/instance"] + value: RELEASE-NAME + - equal: + path: spec.selector.matchLabels["app.kubernetes.io/component"] + value: web + + - it: should match pod template labels to the selector on main deployment + template: deployment.yaml + asserts: + - equal: + path: spec.template.metadata.labels["app.kubernetes.io/name"] + value: superset + - equal: + path: spec.template.metadata.labels["app.kubernetes.io/instance"] + value: RELEASE-NAME + - equal: + path: spec.template.metadata.labels["app.kubernetes.io/component"] + value: web + + - it: should set selector matchLabels to concrete values on worker deployment + template: deployment-worker.yaml + asserts: + - equal: + path: spec.selector.matchLabels["app.kubernetes.io/instance"] + value: RELEASE-NAME + - equal: + path: spec.selector.matchLabels["app.kubernetes.io/component"] + value: worker + + - it: should match pod template labels to the selector on worker deployment + template: deployment-worker.yaml + asserts: + - equal: + path: spec.template.metadata.labels["app.kubernetes.io/instance"] + value: RELEASE-NAME + - equal: + path: spec.template.metadata.labels["app.kubernetes.io/component"] + value: worker + + # ============================================================================= + # Extra Labels Support + # ============================================================================= + - it: should include extraLabels when specified + template: deployment.yaml + set: + extraLabels: + custom-label: custom-value + environment: production + asserts: + - equal: + path: metadata.labels.custom-label + value: custom-value + - equal: + path: metadata.labels.environment + value: production + + - it: should include extraLabels in service + template: service.yaml + set: + extraLabels: + custom-label: custom-value + asserts: + - equal: + path: metadata.labels.custom-label + value: custom-value + + # ============================================================================= + # ConfigMap / Secret Labels + # ============================================================================= + - it: should have recommended labels on extra-config configmap + template: configmap-superset.yaml + set: + extraConfigs: + custom.py: "FOO = 1" + asserts: + - isNotNull: + path: metadata.labels["app.kubernetes.io/name"] + - equal: + path: metadata.labels["app.kubernetes.io/instance"] + value: RELEASE-NAME + - isNotNull: + path: metadata.labels["app.kubernetes.io/managed-by"] + - equal: + path: metadata.labels["app.kubernetes.io/part-of"] + value: superset + + - it: should have recommended labels on superset config secret + template: secret-superset-config.yaml + asserts: + - isNotNull: + path: metadata.labels["app.kubernetes.io/name"] + - equal: + path: metadata.labels["app.kubernetes.io/instance"] + value: RELEASE-NAME + - equal: + path: metadata.labels["app.kubernetes.io/part-of"] + value: superset + + - it: should have recommended labels on websocket config secret + template: secret-ws.yaml + set: + supersetWebsockets.enabled: true + asserts: + - isNotNull: + path: metadata.labels["app.kubernetes.io/name"] + - equal: + path: metadata.labels["app.kubernetes.io/instance"] + value: RELEASE-NAME + + # ============================================================================= + # PodDisruptionBudget Labels (metadata must match the selector) + # ============================================================================= + - it: should have recommended labels and matching selector on main pdb + template: pdb.yaml + set: + supersetNode.podDisruptionBudget.enabled: true + supersetNode.podDisruptionBudget.maxUnavailable: null + asserts: + - equal: + path: metadata.labels["app.kubernetes.io/instance"] + value: RELEASE-NAME + - equal: + path: metadata.labels["app.kubernetes.io/component"] + value: web + - equal: + path: spec.selector.matchLabels["app.kubernetes.io/instance"] + value: RELEASE-NAME + - equal: + path: spec.selector.matchLabels["app.kubernetes.io/component"] + value: web + + - it: should set correct component on worker pdb + template: pdb-worker.yaml + set: + supersetWorker.podDisruptionBudget.enabled: true + supersetWorker.podDisruptionBudget.maxUnavailable: null + asserts: + - equal: + path: metadata.labels["app.kubernetes.io/component"] + value: worker + - equal: + path: spec.selector.matchLabels["app.kubernetes.io/component"] + value: worker + + - it: should set correct component on celerybeat pdb + template: pdb-beat.yaml + set: + supersetCeleryBeat.podDisruptionBudget.enabled: true + supersetCeleryBeat.podDisruptionBudget.maxUnavailable: null + asserts: + - equal: + path: metadata.labels["app.kubernetes.io/component"] + value: celerybeat + + - it: should set correct component on flower pdb + template: pdb-flower.yaml + set: + supersetCeleryFlower.podDisruptionBudget.enabled: true + supersetCeleryFlower.podDisruptionBudget.maxUnavailable: null + asserts: + - equal: + path: metadata.labels["app.kubernetes.io/component"] + value: flower + + - it: should set correct component on websocket pdb + template: pdb-ws.yaml + set: + supersetWebsockets.enabled: true + supersetWebsockets.podDisruptionBudget.enabled: true + supersetWebsockets.podDisruptionBudget.maxUnavailable: null + asserts: + - equal: + path: metadata.labels["app.kubernetes.io/component"] + value: websocket + - equal: + path: spec.selector.matchLabels["app.kubernetes.io/component"] + value: websocket + + - it: should use recommended labels on init job pod template + template: init-job.yaml + set: + init.enabled: true + asserts: + - equal: + path: spec.template.metadata.labels["app.kubernetes.io/instance"] + value: RELEASE-NAME + - equal: + path: spec.template.metadata.labels["app.kubernetes.io/component"] + value: init + - isNotNull: + path: spec.template.metadata.labels.job diff --git a/pyproject.toml b/pyproject.toml index 9e7621905bb..ff8b7dec74f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,7 +77,7 @@ dependencies = [ # Flask-AppBuilder workaround. Tracking issue: # https://github.com/apache/superset/issues/33162 "marshmallow>=3.0, <5", - "marshmallow-union>=0.1", + "marshmallow-union>=0.1.15.post1", "msgpack>=1.2.0, <1.3", "nh3>=0.3.5, <0.4", "numpy>1.23.5, <2.3", @@ -154,7 +154,7 @@ elasticsearch = ["elasticsearch-dbapi>=0.2.13, <0.3.0"] exasol = ["sqlalchemy-exasol>=2.4.0, <8.0"] excel = ["xlrd>=2.0.2, <2.1"] fastmcp = [ - "fastmcp>=3.2.4,<4.0", + "fastmcp>=3.4.2,<4.0", # tiktoken backs the response-size-guard token estimator. Without # it, the middleware falls back to a coarser character-based # heuristic that under-counts JSON-heavy MCP responses. diff --git a/requirements/base.txt b/requirements/base.txt index 98da47f59c6..6160b34bf1d 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -236,7 +236,7 @@ marshmallow-sqlalchemy==1.5.0 # via # -r requirements/base.in # flask-appbuilder -marshmallow-union==0.1.15 +marshmallow-union==0.1.15.post1 # via apache-superset (pyproject.toml) mdurl==0.1.2 # via markdown-it-py diff --git a/requirements/development.txt b/requirements/development.txt index 6e5ab0d1ecb..16a662942c0 100644 --- a/requirements/development.txt +++ b/requirements/development.txt @@ -53,7 +53,7 @@ attrs==25.3.0 # requests-cache # trio authlib==1.6.12 - # via fastmcp + # via fastmcp-slim babel==2.17.0 # via # -c requirements/base-constraint.txt @@ -184,6 +184,7 @@ cryptography==48.0.1 # apache-superset # authlib # google-auth + # joserfc # paramiko # pyjwt # pyopenssl @@ -191,7 +192,7 @@ cryptography==48.0.1 cycler==0.12.1 # via matplotlib cyclopts==4.2.4 - # via fastmcp + # via fastmcp-slim db-dtypes==1.3.1 # via pandas-gbq defusedxml==0.7.1 @@ -236,9 +237,11 @@ et-xmlfile==2.0.0 # -c requirements/base-constraint.txt # openpyxl exceptiongroup==1.3.0 - # via fastmcp -fastmcp==3.2.4 + # via fastmcp-slim +fastmcp==3.4.2 # via apache-superset +fastmcp-slim==3.4.2 + # via fastmcp filelock==3.20.3 # via # -c requirements/base-constraint.txt @@ -382,7 +385,7 @@ greenlet==3.5.1 # shillelagh # sqlalchemy griffelib==2.0.2 - # via fastmcp + # via fastmcp-slim grpcio==1.81.1 # via # apache-superset @@ -413,7 +416,7 @@ httpcore==1.0.9 # via httpx httpx==0.28.1 # via - # fastmcp + # fastmcp-slim # mcp httpx-sse==0.4.1 # via mcp @@ -472,12 +475,14 @@ jmespath==1.1.0 # via # boto3 # botocore +joserfc==1.7.2 + # via fastmcp-slim jsonpath-ng==1.8.0 # via # -c requirements/base-constraint.txt # apache-superset jsonref==1.1.0 - # via fastmcp + # via fastmcp-slim jsonschema==4.23.0 # via # -c requirements/base-constraint.txt @@ -487,7 +492,7 @@ jsonschema==4.23.0 # openapi-spec-validator jsonschema-path==0.3.4 # via - # fastmcp + # fastmcp-slim # openapi-spec-validator jsonschema-specifications==2025.4.1 # via @@ -541,7 +546,7 @@ marshmallow-sqlalchemy==1.5.0 # via # -c requirements/base-constraint.txt # flask-appbuilder -marshmallow-union==0.1.15 +marshmallow-union==0.1.15.post1 # via # -c requirements/base-constraint.txt # apache-superset @@ -550,7 +555,7 @@ matplotlib==3.9.0 mccabe==0.7.0 # via pylint mcp==1.24.0 - # via fastmcp + # via fastmcp-slim mdurl==0.1.2 # via # -c requirements/base-constraint.txt @@ -594,7 +599,7 @@ odfpy==1.4.1 # -c requirements/base-constraint.txt # pandas openapi-pydantic==0.5.1 - # via fastmcp + # via fastmcp-slim openapi-schema-validator==0.6.3 # via # -c requirements/base-constraint.txt @@ -606,7 +611,7 @@ openpyxl==3.1.5 # -c requirements/base-constraint.txt # pandas opentelemetry-api==1.39.1 - # via fastmcp + # via fastmcp-slim ordered-set==4.1.0 # via # -c requirements/base-constraint.txt @@ -627,7 +632,7 @@ packaging==25.0 # deprecation # docker # duckdb-engine - # fastmcp + # fastmcp-slim # google-cloud-bigquery # gunicorn # limits @@ -672,7 +677,7 @@ pip==25.1.1 platformdirs==4.3.8 # via # -c requirements/base-constraint.txt - # fastmcp + # fastmcp-slim # pylint # requests-cache # virtualenv @@ -714,7 +719,7 @@ psutil==6.1.0 psycopg2-binary==2.9.12 # via apache-superset py-key-value-aio==0.4.4 - # via fastmcp + # via fastmcp-slim pyarrow==24.0.0 # via # -c requirements/base-constraint.txt @@ -741,7 +746,7 @@ pydantic==2.11.7 # -c requirements/base-constraint.txt # apache-superset # apache-superset-core - # fastmcp + # fastmcp-slim # mcp # openapi-pydantic # pydantic-settings @@ -750,7 +755,9 @@ pydantic-core==2.33.2 # -c requirements/base-constraint.txt # pydantic pydantic-settings==2.10.1 - # via mcp + # via + # fastmcp-slim + # mcp pydata-google-auth==1.9.0 # via pandas-gbq pydruid==0.6.9 @@ -793,7 +800,7 @@ pyparsing==3.2.3 # apache-superset # matplotlib pyperclip==1.10.0 - # via fastmcp + # via fastmcp-slim pysocks==1.7.1 # via # -c requirements/base-constraint.txt @@ -835,12 +842,14 @@ python-dotenv==1.2.2 # via # -c requirements/base-constraint.txt # apache-superset - # fastmcp + # fastmcp-slim # pydantic-settings python-ldap==3.4.7 # via apache-superset python-multipart==0.0.29 - # via mcp + # via + # fastmcp-slim + # mcp pytz==2025.2 # via # -c requirements/base-constraint.txt @@ -856,7 +865,7 @@ pyyaml==6.0.3 # -c requirements/base-constraint.txt # apache-superset # apispec - # fastmcp + # fastmcp-slim # jsonschema-path # pre-commit redis==5.3.1 @@ -899,7 +908,7 @@ rich==13.9.4 # via # -c requirements/base-constraint.txt # cyclopts - # fastmcp + # fastmcp-slim # flask-limiter # rich-rst rich-rst==1.3.1 @@ -995,8 +1004,10 @@ sshtunnel==0.4.0 # via # -c requirements/base-constraint.txt # apache-superset -starlette==0.49.1 - # via mcp +starlette==1.3.1 + # via + # fastmcp-slim + # mcp statsd==4.0.1 # via apache-superset syntaqlite==0.4.2 @@ -1035,6 +1046,7 @@ typing-extensions==4.15.0 # apache-superset-core # cattrs # exceptiongroup + # fastmcp-slim # grpcio # limits # mcp @@ -1062,7 +1074,7 @@ tzdata==2025.2 tzlocal==5.2 # via trino uncalled-for==0.2.0 - # via fastmcp + # via fastmcp-slim url-normalize==2.2.1 # via # -c requirements/base-constraint.txt @@ -1077,7 +1089,7 @@ urllib3==2.7.0 # selenium uvicorn==0.37.0 # via - # fastmcp + # fastmcp-slim # mcp vine==5.1.0 # via @@ -1093,7 +1105,7 @@ watchdog==6.0.0 # apache-superset # apache-superset-extensions-cli watchfiles==1.1.1 - # via fastmcp + # via fastmcp-slim wcwidth==0.2.13 # via # -c requirements/base-constraint.txt @@ -1103,7 +1115,7 @@ websocket-client==1.8.0 # -c requirements/base-constraint.txt # selenium websockets==15.0.1 - # via fastmcp + # via fastmcp-slim werkzeug==3.1.6 # via # -c requirements/base-constraint.txt diff --git a/scripts/translations/backfill_po.py b/scripts/translations/backfill_po.py index 284af4a99af..25eed9ed444 100644 --- a/scripts/translations/backfill_po.py +++ b/scripts/translations/backfill_po.py @@ -91,22 +91,28 @@ _ASF_LICENSE_HEADER = """\ LANGUAGE_NAMES: dict[str, str] = { "ar": "Arabic", "ca": "Catalan", + "cs": "Czech", "de": "German", "es": "Spanish", "fa": "Persian (Farsi)", + "fi": "Finnish", "fr": "French", "it": "Italian", "ja": "Japanese", "ko": "Korean", + "lv": "Latvian", "mi": "Māori", "nl": "Dutch", "pl": "Polish", "pt": "Portuguese", "pt_BR": "Brazilian Portuguese", + "ro": "Romanian", "ru": "Russian", "sk": "Slovak", "sl": "Slovenian", "sr": "Serbian", + "sr_Latn": "Serbian (Latin script)", + "th": "Thai", "tr": "Turkish", "uk": "Ukrainian", "zh": "Chinese (Simplified)", @@ -346,6 +352,97 @@ def translate_batch( return parse_response(result.stdout.strip(), len(batch)) +def _translate_single_plaintext( + model: str, + target_lang: str, + item: dict[str, Any], + index: dict[str, Any], +) -> str | None: + """Translate a single entry with a plain-text prompt (no JSON envelope). + + Fallback for an entry whose JSON batch response cannot be parsed — typically + because the source string contains literal double-quotes that the model + echoes back unescaped, corrupting the surrounding JSON. Asking for a bare + string sidesteps the JSON contract entirely. Returns the translation text, + or None if the CLI call fails. + """ + claude_bin = shutil.which("claude") + if not claude_bin: + raise RuntimeError( + "claude CLI not found. Install Claude Code or add it to PATH." + ) + lines = [ + "You are a professional translator specializing in software UI strings.", + f"Translate the following English string into {_lang_name(target_lang)} " + f"({target_lang}).", + "Return ONLY the translation as plain text — no surrounding quotes, no " + "JSON, no markdown fences, no explanation.", + "Preserve all format placeholders exactly (%(name)s, {name}, %s, %d), any " + "HTML tags, and any inner quotation marks.", + "", + f"English: {item['msgid']}", + ] + if item.get("msgid_plural"): + lines.append(f"English plural: {item['msgid_plural']}") + refs = index.get(item["index_key"], {}) + ref_lines = [ + f"{_lang_name(lang)}: {val}" + for lang, val in sorted(refs.items()) + if lang != target_lang and isinstance(val, str) and val + ] + if ref_lines: + lines.append("") + lines.append("Reference translations in other languages:") + lines.extend(ref_lines) + prompt = "\n".join(lines) + # claude_bin is resolved via shutil.which — not user-controlled input + result = subprocess.run( # noqa: S603 + [claude_bin, "--model", model, "-p"], + input=prompt, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + return None + text = result.stdout.strip() + # Strip accidental markdown fences or wrapping quotes the model may add. + text = re.sub(r"^```[^\n]*\n?", "", text) + text = re.sub(r"\n?```$", "", text).strip() + if len(text) >= 2 and text[0] == '"' and text[-1] == '"': + text = text[1:-1] + return text or None + + +def _resilient_translate( + model: str, + target_lang: str, + batch: list[dict[str, Any]], + index: dict[str, Any], +) -> dict[int, str]: + """Translate a batch, isolating entries that break the JSON response contract. + + ``translate_batch`` sends the whole batch in one request and parses a single + JSON object back. A source string containing literal double-quotes can make + the model emit unescaped quotes, so ``json.loads`` fails and the ENTIRE batch + would be lost. To salvage the rest, on a parse failure (ValueError) we bisect + the batch and recurse; a lone entry that still fails falls back to a + plain-text prompt via ``_translate_single_plaintext``. Returned keys are + positions within ``batch``. RuntimeError (CLI failure) is left to propagate + to the caller, preserving the existing per-batch failure handling. + """ + try: + return translate_batch(model, target_lang, batch, index) + except ValueError: + if len(batch) == 1: + text = _translate_single_plaintext(model, target_lang, batch[0], index) + return {0: text} if text else {} + mid = len(batch) // 2 + left = _resilient_translate(model, target_lang, batch[:mid], index) + right = _resilient_translate(model, target_lang, batch[mid:], index) + return {**left, **{k + mid: v for k, v in right.items()}} + + def _apply_plural_translation(entry: polib.POEntry, translation: str) -> None: """Distribute a model response across the entry's plural forms. @@ -462,7 +559,7 @@ def _process_batches( file=sys.stderr, ) try: - translations = translate_batch(model, lang, batch_items, index) + translations = _resilient_translate(model, lang, batch_items, index) except (ValueError, RuntimeError) as exc: print(f" ERROR in batch starting at {batch_start}: {exc}", file=sys.stderr) failed_count += len(batch_entries) diff --git a/scripts/translations/check_translation_regression.py b/scripts/translations/check_translation_regression.py index 51aa496a6d1..1f0995d6f88 100755 --- a/scripts/translations/check_translation_regression.py +++ b/scripts/translations/check_translation_regression.py @@ -20,21 +20,31 @@ Check that source-code changes don't cause translation regressions. What counts as a regression --------------------------- -A regression is an *existing translation that a source change invalidated*. -The check keys on the **increase in fuzzy entries** rather than a drop in the -translated count, because a count drop happens identically for a benign -*deletion* and a real *rename*, so it cannot distinguish the two — whereas a -``#, fuzzy`` marker unambiguously flags a stranded translation. +A regression is an *existing translation that a source change invalidated*: +a message that was a **confirmed, non-fuzzy translation** in the baseline and +is **fuzzy** after the PR. The check keys on this per-``msgid`` transition +rather than on the aggregate count of fuzzy entries, because a bare count +cannot tell apart two changes that move the fuzzy total by the same amount: + +* ``translated -> fuzzy`` — a reworded source string stranded a real + translation. **This is the regression.** +* ``untranslated -> fuzzy`` — an empty ``msgstr`` was filled with a fuzzy + (unconfirmed) guess, e.g. an AI backfill committed as ``#, fuzzy``. No + existing translation was lost, so this is **not** a regression and must + pass. + +Keying on the per-entry transition lets a backfill PR commit fuzzy guesses for +previously-untranslated strings (the ja/fi catalog backfills) without tripping +the check, while still catching a genuine invalidation even when the same PR +also adds new strings (which a count-delta heuristic would let mask it). Note ``babel_update.sh`` runs ``pybabel update`` with ``--no-fuzzy-matching``, so *adding* (or renaming) a source string does **not** auto-generate a fuzzy guess against an unrelated existing translation — new strings land as cleanly -untranslated (empty ``msgstr``). This deliberately avoids the prior behaviour -where *every* PR that merely added a translatable string tripped this check on -spurious fuzzies. As a result the check now guards against ``#, fuzzy`` entries -that arrive another way — e.g. a committed ``.po`` edit — rather than ones the -update step synthesises. *Deleting* a string is still not a regression: with -``--ignore-obsolete`` it is simply dropped and no fuzzy is created. +untranslated (empty ``msgstr``). The fuzzies this check sees therefore arrive +another way — typically a committed ``.po`` edit. *Deleting* a string is still +not a regression: with ``--ignore-obsolete`` it is simply dropped and no fuzzy +is created. Usage ----- @@ -127,28 +137,72 @@ def count_stats(po_file: Path) -> dict[str, int]: } +def entry_keys(po_file: Path) -> dict[str, list[str]]: + """Return per-``msgid`` key sets for a .po file. + + ``translated_keys`` lists the non-fuzzy, non-obsolete entries with a + populated ``msgstr`` — confirmed translations a source reword could strand. + ``fuzzy_keys`` lists the non-obsolete entries carrying the ``fuzzy`` flag + (however they arrived — a committed backfill guess or a real invalidation). + + A key combines ``msgctxt`` and ``msgid`` (gettext's own identity rule) so + context-disambiguated entries stay distinct. The header entry (empty + ``msgid``) is ignored. The regression check compares the baseline's + ``translated_keys`` against the PR's ``fuzzy_keys``: their intersection is + exactly the set of confirmed translations the PR turned fuzzy. + + Raises: + OSError: if ``polib`` cannot read or parse the file. As with a msgfmt + failure, a catalog we cannot parse is surfaced rather than silently + counted as empty. + """ + import polib # type: ignore[import-untyped] # noqa: PLC0415 + + translated_keys: list[str] = [] + fuzzy_keys: list[str] = [] + for entry in polib.pofile(str(po_file)): + if entry.obsolete or not entry.msgid: + continue + key = f"{entry.msgctxt}\x04{entry.msgid}" if entry.msgctxt else entry.msgid + if "fuzzy" in entry.flags: + fuzzy_keys.append(key) + elif ( + all(entry.msgstr_plural.values()) + if entry.msgid_plural + else bool(entry.msgstr) + ): + translated_keys.append(key) + return {"translated_keys": translated_keys, "fuzzy_keys": fuzzy_keys} + + def get_counts( translations_dir: Path, failures: Optional[set[str]] = None, -) -> dict[str, dict[str, int]]: +) -> dict[str, dict[str, object]]: """Count translated/fuzzy entries for every ``.po`` file in a directory. + Each language maps to ``{"translated", "fuzzy", "translated_keys", + "fuzzy_keys"}`` — aggregate counts (for the human-readable summary) plus the + per-``msgid`` key sets the regression check actually keys on. + If ``failures`` is provided, the name of each language whose ``.po`` file - is present on disk but could not be counted (msgfmt non-zero exit, or - unparseable output) is added to it. Such a language is deliberately absent - from the returned mapping — but, unlike a language whose catalog was simply - deleted, it must not be mistaken for an intentional removal: a caller that - cares about the distinction (see :func:`cmd_compare`) can inspect - ``failures`` and treat it as a hard error. + is present on disk but could not be counted (msgfmt non-zero exit, + unparseable output, or a polib parse error) is added to it. Such a language + is deliberately absent from the returned mapping — but, unlike a language + whose catalog was simply deleted, it must not be mistaken for an intentional + removal: a caller that cares about the distinction (see :func:`cmd_compare`) + can inspect ``failures`` and treat it as a hard error. """ - counts: dict[str, dict[str, int]] = {} + counts: dict[str, dict[str, object]] = {} for po_file in sorted(translations_dir.glob("*/LC_MESSAGES/messages.po")): lang = po_file.parent.parent.name if lang in SKIP_LANGS: continue try: - counts[lang] = count_stats(po_file) - except (subprocess.CalledProcessError, RuntimeError) as exc: + stats: dict[str, object] = dict(count_stats(po_file)) + stats.update(entry_keys(po_file)) + counts[lang] = stats + except (subprocess.CalledProcessError, RuntimeError, OSError) as exc: # A malformed .po file (msgfmt non-zero exit, or stderr we # can't parse) is a real problem worth seeing, but it shouldn't # take the whole regression check down with it — that would @@ -164,42 +218,73 @@ def get_counts( return counts -def _normalize(entry: object) -> dict[str, int]: - """Coerce a baseline entry into ``{"translated", "fuzzy"}``. +def _normalize(entry: object) -> dict[str, object]: + """Coerce a baseline entry into ``{"translated", "fuzzy", *_keys}``. - Tolerates the legacy baseline format where each language mapped directly to - an integer translated count (no fuzzy data); such entries contribute a - fuzzy baseline of 0. + ``translated_keys``/``fuzzy_keys`` are the per-``msgid`` sets the check + keys on. They are ``None`` (not ``[]``) when the baseline predates the + per-entry format — an absent set means "unknown", which routes + :func:`cmd_compare` to the coarse aggregate fallback, whereas an empty list + is a known-empty set. Legacy formats — a ``{"translated", "fuzzy"}`` dict + with no key sets, or a bare integer translated count — are both tolerated. """ if isinstance(entry, dict): return { "translated": int(entry.get("translated", 0)), "fuzzy": int(entry.get("fuzzy", 0)), + "translated_keys": ( + list(entry["translated_keys"]) if "translated_keys" in entry else None + ), + "fuzzy_keys": ( + list(entry["fuzzy_keys"]) if "fuzzy_keys" in entry else None + ), } if isinstance(entry, int): - return {"translated": entry, "fuzzy": 0} + return { + "translated": entry, + "fuzzy": 0, + "translated_keys": None, + "fuzzy_keys": None, + } raise TypeError(f"Unsupported baseline entry: {entry!r}") -def build_regression_report(regressions: list[tuple[str, int, int]]) -> str: +def _key_list(stats: dict[str, object], field: str) -> Optional[list[str]]: + """Return ``stats[field]`` as a list of keys, or ``None`` if unavailable. + + A missing or non-list value reads as "unknown" so the caller can fall back + to the aggregate comparison instead of treating it as an empty key set. + """ + value = stats.get(field) + return list(value) if isinstance(value, list) else None + + +def _count(stats: dict[str, object], field: str) -> int: + """Return ``stats[field]`` as an int count, defaulting to 0.""" + value = stats.get(field, 0) + return value if isinstance(value, int) else 0 + + +def build_regression_report(regressions: list[tuple[str, int, int, int]]) -> str: """Build a markdown report for posting as a PR comment. - Each regression tuple is ``(lang, before_fuzzy, after_fuzzy)``. + Each regression tuple is ``(lang, before_fuzzy, after_fuzzy, invalidated)`` + where ``invalidated`` is the number of confirmed translations the PR turned + fuzzy. """ - rows = "\n".join( - f"| `{lang}` | {b} | {a} | +{a - b} |" for lang, b, a in regressions - ) - affected = ", ".join(f"`{lang}`" for lang, _, _ in regressions) + rows = "\n".join(f"| `{lang}` | {n} |" for lang, _b, _a, n in regressions) + affected = ", ".join(f"`{lang}`" for lang, *_ in regressions) return ( "## ⚠️ Translation Regression Detected\n\n" f"A source change in this PR renamed or reworded strings, invalidating " f"existing translations (they are now `#, fuzzy`) in {affected}. Please " f"resolve the affected `.po` files before merging.\n\n" - "_Note: intentionally **deleting** a translatable string is not a " - "regression and is not flagged here — only translations invalidated by " - "a renamed/reworded source string are._\n\n" - "| Language | Fuzzy before | Fuzzy after | New |\n" - "|----------|-------------:|------------:|----:|\n" + "_Note: neither intentionally **deleting** a translatable string nor " + "filling a previously-**untranslated** entry with a fuzzy guess (e.g. an " + "AI backfill) is a regression — only a confirmed translation that a " + "renamed/reworded source string turned fuzzy is flagged here._\n\n" + "| Language | Invalidated translations |\n" + "|----------|-------------------------:|\n" f"{rows}\n\n" "### How to fix\n\n" "**1. Install dependencies** (if not already set up):\n\n" @@ -231,6 +316,41 @@ def cmd_count(translations_dir: Path) -> None: print(json.dumps(counts, indent=2)) +def _detect_regressions( + before: dict[str, dict[str, object]], + after: dict[str, dict[str, object]], +) -> list[tuple[str, int, int, int]]: + """Return ``(lang, before_fuzzy, after_fuzzy, invalidated)`` per regressed lang. + + A regression is a key in the baseline's ``translated_keys`` that is fuzzy + after the PR — a confirmed translation a source reword stranded. Filling a + previously-untranslated entry with a fuzzy guess (backfill) is therefore not + flagged (its key was absent from the baseline's translated set), and neither + is deleting a string (with ``--ignore-obsolete`` it drops, creating no + fuzzy). When per-entry key data is unavailable (a legacy baseline, or a + catalog whose key set could not be read), fall back to the coarse rule: any + net increase in the aggregate fuzzy count. + """ + regressions: list[tuple[str, int, int, int]] = [] + for lang, before_stats in sorted(before.items()): + after_stats = after.get(lang) + if after_stats is None: + # Catalog absent from `after`: an intentional deletion (a + # present-but-uncountable catalog was already caught by the caller). + continue + b_fuzzy = _count(before_stats, "fuzzy") + a_fuzzy = _count(after_stats, "fuzzy") + before_translated = before_stats.get("translated_keys") + after_fuzzy = _key_list(after_stats, "fuzzy_keys") + if isinstance(before_translated, list) and after_fuzzy is not None: + invalidated = set(after_fuzzy) & set(before_translated) + if invalidated: + regressions.append((lang, b_fuzzy, a_fuzzy, len(invalidated))) + elif a_fuzzy > b_fuzzy: + regressions.append((lang, b_fuzzy, a_fuzzy, a_fuzzy - b_fuzzy)) + return regressions + + def cmd_compare( before_path: str, translations_dir: Path, @@ -259,23 +379,12 @@ def cmd_compare( ) sys.exit(1) - # A regression is an *increase* in fuzzy entries: the PR's source diff - # renamed/reworded strings, leaving their committed translations stranded. - # A plain drop in the translated count is NOT used — deleting a string - # lowers it identically to a rename but is a legitimate change, and with - # `pybabel update --ignore-obsolete` a deletion creates no fuzzy entry. - regressions: list[tuple[str, int, int]] = [] - for lang, before_stats in sorted(before.items()): - after_stats = after.get(lang, {"translated": 0, "fuzzy": 0}) - if after_stats["fuzzy"] > before_stats["fuzzy"]: - regressions.append((lang, before_stats["fuzzy"], after_stats["fuzzy"])) - - if regressions: + if regressions := _detect_regressions(before, after): print("Translation regression detected!\n") - for lang, b, a in regressions: + for lang, _b, _a, n in regressions: print( - f" {lang}: {a - b} translation(s) invalidated " - f"(fuzzy {b} -> {a}) by a renamed/reworded source string" + f" {lang}: {n} confirmed translation(s) invalidated " + f"(now fuzzy) by a renamed/reworded source string" ) print( "\nResolve the newly-fuzzy entries in the affected .po files " @@ -290,14 +399,16 @@ def cmd_compare( # All good — print a summary so it's easy to read in CI logs. print("No translation regressions.\n") for lang in sorted(after): - before_stats = before.get(lang, {"translated": 0, "fuzzy": 0}) + before_stats: dict[str, object] = before.get(lang, {}) after_stats = after[lang] - t_delta = after_stats["translated"] - before_stats["translated"] - f_delta = after_stats["fuzzy"] - before_stats["fuzzy"] + b_translated = _count(before_stats, "translated") + a_translated = _count(after_stats, "translated") + b_fuzzy = _count(before_stats, "fuzzy") + a_fuzzy = _count(after_stats, "fuzzy") print( - f" {lang}: translated {before_stats['translated']} -> " - f"{after_stats['translated']} ({t_delta:+d}), fuzzy " - f"{before_stats['fuzzy']} -> {after_stats['fuzzy']} ({f_delta:+d})" + f" {lang}: translated {b_translated} -> {a_translated} " + f"({a_translated - b_translated:+d}), fuzzy " + f"{b_fuzzy} -> {a_fuzzy} ({a_fuzzy - b_fuzzy:+d})" ) diff --git a/superset-embedded-sdk/babel.config.js b/superset-embedded-sdk/babel.config.js index 63f0c0b1562..1c5f2757625 100644 --- a/superset-embedded-sdk/babel.config.js +++ b/superset-embedded-sdk/babel.config.js @@ -20,4 +20,5 @@ module.exports = { presets: ["@babel/preset-typescript", "@babel/preset-env"], sourceMaps: true, + ignore: ["**/*.test.ts"], }; diff --git a/superset-embedded-sdk/package.json b/superset-embedded-sdk/package.json index c62bc194fd6..591204d4cc1 100644 --- a/superset-embedded-sdk/package.json +++ b/superset-embedded-sdk/package.json @@ -22,7 +22,7 @@ "module": "lib/index.js", "types": "dist/index.d.ts", "scripts": { - "build": "tsc && babel src --out-dir lib --extensions '.ts,.tsx' && webpack --mode production", + "build": "tsc && babel src --out-dir lib --extensions '.ts' && webpack --mode production", "ci:release": "node ./release-if-necessary.js", "test": "vitest --run --dir src" }, diff --git a/superset-embedded-sdk/tsconfig.json b/superset-embedded-sdk/tsconfig.json index 8a8e31a18d3..599acf62ce3 100644 --- a/superset-embedded-sdk/tsconfig.json +++ b/superset-embedded-sdk/tsconfig.json @@ -23,6 +23,7 @@ ], "exclude": [ + "src/**/*.test.ts", "dist", "lib", "node_modules" diff --git a/superset-frontend/cypress-base/cypress/utils/urls.ts b/superset-frontend/cypress-base/cypress/utils/urls.ts index 66110bcc64b..ce8eecaa992 100644 --- a/superset-frontend/cypress-base/cypress/utils/urls.ts +++ b/superset-frontend/cypress-base/cypress/utils/urls.ts @@ -19,9 +19,8 @@ export const DASHBOARD_LIST = '/dashboard/list/'; export const CHART_LIST = '/chart/list/'; -export const WORLD_HEALTH_DASHBOARD = '/superset/dashboard/world_health/'; -export const SAMPLE_DASHBOARD_1 = '/superset/dashboard/1-sample-dashboard/'; -export const SUPPORTED_CHARTS_DASHBOARD = - '/superset/dashboard/supported_charts_dash/'; -export const TABBED_DASHBOARD = '/superset/dashboard/tabbed_dash/'; +export const WORLD_HEALTH_DASHBOARD = '/dashboard/world_health/'; +export const SAMPLE_DASHBOARD_1 = '/dashboard/1-sample-dashboard/'; +export const SUPPORTED_CHARTS_DASHBOARD = '/dashboard/supported_charts_dash/'; +export const TABBED_DASHBOARD = '/dashboard/tabbed_dash/'; export const DATABASE_LIST = '/databaseview/list'; diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index acb7e11bd27..dfbf2f67a5b 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -90,17 +90,17 @@ "dayjs": "^1.11.21", "dom-to-image-more": "^3.10.0", "dom-to-pdf": "^0.3.2", - "echarts": "^5.6.0", + "echarts": "^6.1.0", "fast-glob": "^3.3.2", "fs-extra": "^11.3.5", "fuse.js": "^7.4.2", "geolib": "^3.3.14", "geostyler": "^18.6.0", "geostyler-data": "^1.1.0", - "geostyler-openlayers-parser": "^5.7.0", + "geostyler-openlayers-parser": "^5.7.1", "geostyler-style": "11.0.2", "geostyler-wfs-parser": "^3.0.1", - "google-auth-library": "^10.7.0", + "google-auth-library": "^10.9.0", "immer": "^11.1.8", "interweave": "^13.1.1", "jquery": "^4.0.0", @@ -115,7 +115,7 @@ "memoize-one": "^6.0.0", "mousetrap": "^1.6.5", "mustache": "^4.2.0", - "nanoid": "^5.1.15", + "nanoid": "^5.1.16", "ol": "^10.9.0", "query-string": "9.4.0", "re-resizable": "^6.11.2", @@ -179,14 +179,14 @@ "@emotion/jest": "^11.14.2", "@formatjs/intl-durationformat": "^0.10.15", "@istanbuljs/nyc-config-typescript": "^1.0.1", - "@playwright/test": "^1.61.0", + "@playwright/test": "^1.61.1", "@pmmmwh/react-refresh-webpack-plugin": "^0.6.2", - "@storybook/addon-docs": "10.4.5", - "@storybook/addon-links": "10.4.4", - "@storybook/react-webpack5": "10.4.4", + "@storybook/addon-docs": "10.4.6", + "@storybook/addon-links": "10.4.6", + "@storybook/react-webpack5": "10.4.6", "@storybook/test-runner": "0.24.4", "@svgr/webpack": "^8.1.0", - "@swc/core": "^1.15.41", + "@swc/core": "^1.15.43", "@swc/plugin-emotion": "^14.14.0", "@swc/plugin-transform-imports": "^12.5.0", "@testing-library/dom": "^9.3.4", @@ -201,7 +201,7 @@ "@types/json-bigint": "^1.0.4", "@types/lodash-es": "^4.17.12", "@types/mousetrap": "^1.6.15", - "@types/node": "^26.0.0", + "@types/node": "^26.0.1", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@types/react-loadable": "^5.5.11", @@ -220,7 +220,7 @@ "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.10.38", + "baseline-browser-mapping": "^2.10.40", "cheerio": "1.2.0", "concurrently": "^10.0.3", "copy-webpack-plugin": "^14.0.0", @@ -283,7 +283,7 @@ "unzipper": "^0.12.5", "vm-browserify": "^1.1.2", "wait-on": "^9.0.10", - "webpack": "^5.107.2", + "webpack": "^5.108.3", "webpack-bundle-analyzer": "^5.3.0", "webpack-cli": "^7.0.3", "webpack-dev-server": "^5.2.5", @@ -8438,9 +8438,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -8458,9 +8455,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -8478,9 +8472,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -8498,9 +8489,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -8518,9 +8506,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -8538,9 +8523,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -8558,9 +8540,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -8578,9 +8557,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -8848,13 +8824,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz", - "integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.61.0" + "playwright": "1.61.1" }, "bin": { "playwright": "cli.js" @@ -9946,9 +9922,9 @@ } }, "node_modules/@sigstore/core": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-3.2.0.tgz", - "integrity": "sha512-kxHrDQ9YgfrWUSXU0cjsQGv8JykOFZQ9ErNKbFPWzk3Hgpwu8x2hHrQ9IdA8yl+j9RTLTC3sAF3Tdq1IQCP4oA==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-3.2.1.tgz", + "integrity": "sha512-qRsxPnCrbC/puegGxKuynfnxgLiHqWStrSjxkoB4YKqq3Z3s4cyZyj42ZdWFAEblNP65C+rBH8EuREHIXoi83g==", "dev": true, "license": "Apache-2.0", "engines": { @@ -10114,14 +10090,14 @@ } }, "node_modules/@sigstore/verify": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-3.1.0.tgz", - "integrity": "sha512-mNe0Iigql08YupSOGv197YdHpPPr+EzDZmfCgMc7RPNaZTw5aLN01nBl6CHJOh3BGtnMIj83EeN4butBchc8Ag==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-3.1.1.tgz", + "integrity": "sha512-qv7+G3J2cc6wwFj3yKvXOamzqhMwSk1ogPGmhpS8iXllcPrJaIIBA+4HbttlHVu1pqWTdmaCH/WE7UOC51kdoA==", "dev": true, "license": "Apache-2.0", "dependencies": { "@sigstore/bundle": "^4.0.0", - "@sigstore/core": "^3.1.0", + "@sigstore/core": "^3.2.1", "@sigstore/protobuf-specs": "^0.5.0" }, "engines": { @@ -10190,16 +10166,16 @@ "license": "MIT" }, "node_modules/@storybook/addon-docs": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.4.5.tgz", - "integrity": "sha512-9mIV0maIxixfuvdpNhr3QMeU/gbJKeaBcWhPYuf176cqDZAG9EUhZ50TIinxeFRbyEGRJqaLPoiYwIu4GJu3jA==", + "version": "10.4.6", + "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.4.6.tgz", + "integrity": "sha512-aWAfP5JMiT5a3zBJizwroCRzOCqZwDTJmvsYvwMD3ilIEa/kT1vhf6Xrbk4XIPhDwbh8Hpb/Gfnka1xBYEISWg==", "dev": true, "license": "MIT", "dependencies": { "@mdx-js/react": "^3.0.0", - "@storybook/csf-plugin": "10.4.5", + "@storybook/csf-plugin": "10.4.6", "@storybook/icons": "^2.0.2", - "@storybook/react-dom-shim": "10.4.5", + "@storybook/react-dom-shim": "10.4.6", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" @@ -10210,7 +10186,7 @@ }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.4.5" + "storybook": "^10.4.6" }, "peerDependenciesMeta": { "@types/react": { @@ -10218,36 +10194,10 @@ } } }, - "node_modules/@storybook/addon-docs/node_modules/@storybook/react-dom-shim": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.4.5.tgz", - "integrity": "sha512-fKdikHC7cDgSuaBirPwvgFBmfO//3cln0y3GmDEQchUV2VFDrZ7ZL1/iH7dA21XuiFFhQcDRRkArJmvMAGG5Cg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.4.5" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@storybook/addon-links": { - "version": "10.4.4", - "resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.4.4.tgz", - "integrity": "sha512-sWydPWLgduT24p/NJ/hXHcHsPlAyzQP+cOtCGliSI989K9yBP/TOL3A8sz7LIDfukI9DVAsylPhJ1jDSiAEI1w==", + "version": "10.4.6", + "resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.4.6.tgz", + "integrity": "sha512-VGfERTsGRFmfvNP3SKprFWkC6Od5kXzSutT5PSZjQ/O9NnCdHhd/RILxFDN2TzZn9ywDc7t5b4AldKmSYCv3EQ==", "dev": true, "license": "MIT", "dependencies": { @@ -10260,7 +10210,7 @@ "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.4.4" + "storybook": "^10.4.6" }, "peerDependenciesMeta": { "@types/react": { @@ -10271,10 +10221,63 @@ } } }, + "node_modules/@storybook/builder-webpack5": { + "version": "10.4.6", + "resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-10.4.6.tgz", + "integrity": "sha512-klJUCBlkr1mFqXxsyYc7akIBhT5VOBTRcHChSFXq6Kbh+qQYOvfmgBpY0Tv+f7ffBmzp6gxpTorg7eGxlklxeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/core-webpack": "10.4.6", + "case-sensitive-paths-webpack-plugin": "^2.4.0", + "cjs-module-lexer": "^1.2.3", + "css-loader": "^7.1.2", + "es-module-lexer": "^1.5.0", + "fork-ts-checker-webpack-plugin": "^9.1.0", + "html-webpack-plugin": "^5.5.0", + "magic-string": "^0.30.5", + "style-loader": "^4.0.0", + "terser-webpack-plugin": "^5.3.17", + "ts-dedent": "^2.0.0", + "webpack": "5", + "webpack-dev-middleware": "^6.1.2", + "webpack-hot-middleware": "^2.25.1", + "webpack-virtual-modules": "^0.6.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^10.4.6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@storybook/core-webpack": { + "version": "10.4.6", + "resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.4.6.tgz", + "integrity": "sha512-DDhpgaFGb1AC0lzfR2LOozCj+uT14tsr2R9551PHgcC3ru1yfhL2DNbIjdiMuQP8nVm+2HKeXWhybJGYtk9z9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^10.4.6" + } + }, "node_modules/@storybook/csf-plugin": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.4.5.tgz", - "integrity": "sha512-OsSsSLulBmdKTz7MIKLgoWADZB8bjYaAjZZy/THdI50G/TTd6FVSXQMCM7GO7xQZ/EguRY1PmjOVCLbgcnXsDA==", + "version": "10.4.6", + "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.4.6.tgz", + "integrity": "sha512-NILLxDqpA/JR/AazGWpsz+4fadJwRU4uhHephGtYpVOWnQA/DkJfKT6zpcJVq8+QA8A2zKMLX3GVKsXIrxjuDA==", "dev": true, "license": "MIT", "dependencies": { @@ -10287,7 +10290,7 @@ "peerDependencies": { "esbuild": "*", "rollup": "*", - "storybook": "^10.4.5", + "storybook": "^10.4.6", "vite": "*", "webpack": "*" }, @@ -10324,6 +10327,74 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@storybook/preset-react-webpack": { + "version": "10.4.6", + "resolved": "https://registry.npmjs.org/@storybook/preset-react-webpack/-/preset-react-webpack-10.4.6.tgz", + "integrity": "sha512-D6EjK1sknC/1GmdWQjV38HEP+tBxJ5ObNuCUWQzGsJqsiq45HaOemZbIyJqxp3dYx2/YpFd0bawn7PlOP4yNOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/core-webpack": "10.4.6", + "@storybook/react-docgen-typescript-plugin": "1.0.6--canary.9.0c3f3b7.0", + "@types/semver": "^7.7.1", + "magic-string": "^0.30.5", + "react-docgen": "^7.1.1", + "resolve": "^1.22.8", + "semver": "^7.7.3", + "tsconfig-paths": "^4.2.0", + "webpack": "5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "storybook": "^10.4.6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@storybook/react": { + "version": "10.4.6", + "resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.4.6.tgz", + "integrity": "sha512-9Y7YecrVFe1/01KYjfOLxVqTg2Aq+IO6TEv6sC2U0PfD0AWCSCmQ91QqgBpN/XW4aFFWoiZNinyXMUlU8zxy2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "@storybook/react-dom-shim": "10.4.6", + "react-docgen": "^8.0.2", + "react-docgen-typescript": "^2.2.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "storybook": "^10.4.6", + "typescript": ">= 4.9.x" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/@storybook/react-docgen-typescript-plugin": { "version": "1.0.6--canary.9.0c3f3b7.0", "resolved": "https://registry.npmjs.org/@storybook/react-docgen-typescript-plugin/-/react-docgen-typescript-plugin-1.0.6--canary.9.0c3f3b7.0.tgz", @@ -10388,16 +10459,42 @@ "semver": "bin/semver.js" } }, + "node_modules/@storybook/react-dom-shim": { + "version": "10.4.6", + "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.4.6.tgz", + "integrity": "sha512-iGNmKzrq9vgl2PDrYAnZKI+yvac3Ym+lJXXuQaqlFRS23zA5MNm4EBX+rAG7WulqchoK6NaZ0KQOs2mAgEpTMg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "storybook": "^10.4.6" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@storybook/react-webpack5": { - "version": "10.4.4", - "resolved": "https://registry.npmjs.org/@storybook/react-webpack5/-/react-webpack5-10.4.4.tgz", - "integrity": "sha512-LAYz4BlyOKNHhD/DR7f7eRFevJdl9lvoT1QsKEiT3ngFNxn9wMI2o8IMx7fOQYz0v1UTExZFYtwV2Ur/psJMEw==", + "version": "10.4.6", + "resolved": "https://registry.npmjs.org/@storybook/react-webpack5/-/react-webpack5-10.4.6.tgz", + "integrity": "sha512-34rOHFRa4NrBnMKsQMq8QTvw65QQJUtasYfEOw1/Pa9+CFWfFQZ3kfDLRxatphUyc0rbKxmVrVE4SFEMY/eMsA==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/builder-webpack5": "10.4.4", - "@storybook/preset-react-webpack": "10.4.4", - "@storybook/react": "10.4.4" + "@storybook/builder-webpack5": "10.4.6", + "@storybook/preset-react-webpack": "10.4.6", + "@storybook/react": "10.4.6" }, "funding": { "type": "opencollective", @@ -10406,7 +10503,7 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.4.4", + "storybook": "^10.4.6", "typescript": ">= 4.9.x" }, "peerDependenciesMeta": { @@ -10415,171 +10512,7 @@ } } }, - "node_modules/@storybook/react-webpack5/node_modules/@storybook/builder-webpack5": { - "version": "10.4.4", - "resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-10.4.4.tgz", - "integrity": "sha512-R/hUX+uPFXjh3CqRtwMTG4PXywpSn0Qb08GIHOrNihb2bmszbC3nZ89vReZsBYL1lm4IpgBQbz+XzmY8LOHaVA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/core-webpack": "10.4.4", - "case-sensitive-paths-webpack-plugin": "^2.4.0", - "cjs-module-lexer": "^1.2.3", - "css-loader": "^7.1.2", - "es-module-lexer": "^1.5.0", - "fork-ts-checker-webpack-plugin": "^9.1.0", - "html-webpack-plugin": "^5.5.0", - "magic-string": "^0.30.5", - "style-loader": "^4.0.0", - "terser-webpack-plugin": "^5.3.17", - "ts-dedent": "^2.0.0", - "webpack": "5", - "webpack-dev-middleware": "^6.1.2", - "webpack-hot-middleware": "^2.25.1", - "webpack-virtual-modules": "^0.6.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "storybook": "^10.4.4" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@storybook/react-webpack5/node_modules/@storybook/builder-webpack5/node_modules/@storybook/core-webpack": { - "version": "10.4.4", - "resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.4.4.tgz", - "integrity": "sha512-tZ3cMhMunC/84KStq1vGhWanhNiFL99M0KFyKToEeULYx2WOyd/dC3oFIaWSNns3jRvykwR4Z11CwZzW6khjrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ts-dedent": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "storybook": "^10.4.4" - } - }, - "node_modules/@storybook/react-webpack5/node_modules/@storybook/preset-react-webpack": { - "version": "10.4.4", - "resolved": "https://registry.npmjs.org/@storybook/preset-react-webpack/-/preset-react-webpack-10.4.4.tgz", - "integrity": "sha512-n7JDUfY2mYW/5VqkfkHePWUWmUATCG1lF278pX+gaahvbMACJyM/bbZFcL56mlc1dEQIg6czC8VkA9y2Xm5nTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/core-webpack": "10.4.4", - "@storybook/react-docgen-typescript-plugin": "1.0.6--canary.9.0c3f3b7.0", - "@types/semver": "^7.7.1", - "magic-string": "^0.30.5", - "react-docgen": "^7.1.1", - "resolve": "^1.22.8", - "semver": "^7.7.3", - "tsconfig-paths": "^4.2.0", - "webpack": "5" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.4.4" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@storybook/react-webpack5/node_modules/@storybook/preset-react-webpack/node_modules/@storybook/core-webpack": { - "version": "10.4.4", - "resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.4.4.tgz", - "integrity": "sha512-tZ3cMhMunC/84KStq1vGhWanhNiFL99M0KFyKToEeULYx2WOyd/dC3oFIaWSNns3jRvykwR4Z11CwZzW6khjrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ts-dedent": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "storybook": "^10.4.4" - } - }, - "node_modules/@storybook/react-webpack5/node_modules/@storybook/react": { - "version": "10.4.4", - "resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.4.4.tgz", - "integrity": "sha512-6K5/uHrvjswrueyVpUt6IWGuSgYCMtMOYyVs86XJZYqKBV3Pv7nGsGNH7YSMLAVQBZW4CQqm2etd5Op0GHY9Kg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/global": "^5.0.0", - "@storybook/react-dom-shim": "10.4.4", - "react-docgen": "^8.0.2", - "react-docgen-typescript": "^2.2.2" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.4.4", - "typescript": ">= 4.9.x" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - }, - "typescript": { - "optional": true - } - } - }, - "node_modules/@storybook/react-webpack5/node_modules/@storybook/react/node_modules/@storybook/react-dom-shim": { - "version": "10.4.4", - "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.4.4.tgz", - "integrity": "sha512-y6SObmoW78AydE6VfKQSUmCkuqiaMPy9LgMpMdMEyWfJ/pSxBDMIKycr9dlRMJP1cvNgByaJgrusWtA46ndSQw==", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.4.4" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@storybook/react-webpack5/node_modules/@storybook/react/node_modules/react-docgen": { + "node_modules/@storybook/react/node_modules/react-docgen": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-8.0.3.tgz", "integrity": "sha512-aEZ9qP+/M+58x2qgfSFEWH1BxLyHe5+qkLNJOZQb5iGS017jpbRnoKhNRrXPeA6RfBrZO5wZrT9DMC1UqE1f1w==", @@ -11004,15 +10937,15 @@ } }, "node_modules/@swc/core": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.41.tgz", - "integrity": "sha512-03nQq/082QRJJiOvp3FGbgxTGyyxMxohPTjhk/W9bD2J0tk4ukITI7goOhOO2WbaHn/lsPmo/zf8+DIXhwpgYQ==", + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.43.tgz", + "integrity": "sha512-1CuKjFkPxIgGdeHVuNbkxmBxkcbdc08u0aiI43pFq6yY1tTVKmXT9hFEooyyKs/sJ3xf1GPHyEwTtk9Xl8dvQw==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "@swc/counter": "^0.1.3", - "@swc/types": "^0.1.26" + "@swc/types": "^0.1.27" }, "engines": { "node": ">=10" @@ -11022,18 +10955,18 @@ "url": "https://opencollective.com/swc" }, "optionalDependencies": { - "@swc/core-darwin-arm64": "1.15.41", - "@swc/core-darwin-x64": "1.15.41", - "@swc/core-linux-arm-gnueabihf": "1.15.41", - "@swc/core-linux-arm64-gnu": "1.15.41", - "@swc/core-linux-arm64-musl": "1.15.41", - "@swc/core-linux-ppc64-gnu": "1.15.41", - "@swc/core-linux-s390x-gnu": "1.15.41", - "@swc/core-linux-x64-gnu": "1.15.41", - "@swc/core-linux-x64-musl": "1.15.41", - "@swc/core-win32-arm64-msvc": "1.15.41", - "@swc/core-win32-ia32-msvc": "1.15.41", - "@swc/core-win32-x64-msvc": "1.15.41" + "@swc/core-darwin-arm64": "1.15.43", + "@swc/core-darwin-x64": "1.15.43", + "@swc/core-linux-arm-gnueabihf": "1.15.43", + "@swc/core-linux-arm64-gnu": "1.15.43", + "@swc/core-linux-arm64-musl": "1.15.43", + "@swc/core-linux-ppc64-gnu": "1.15.43", + "@swc/core-linux-s390x-gnu": "1.15.43", + "@swc/core-linux-x64-gnu": "1.15.43", + "@swc/core-linux-x64-musl": "1.15.43", + "@swc/core-win32-arm64-msvc": "1.15.43", + "@swc/core-win32-ia32-msvc": "1.15.43", + "@swc/core-win32-x64-msvc": "1.15.43" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" @@ -11045,9 +10978,9 @@ } }, "node_modules/@swc/core-darwin-arm64": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.41.tgz", - "integrity": "sha512-kREh6J5paQFvP3i7f/4FbqRNOJREutVFVOkder4GVyCBQ39YmER55cW/y1NNjwrchzFqgYswFn0mMDCqbqKzrw==", + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.43.tgz", + "integrity": "sha512-v1aVuvXdo/BHxJzco9V2xpHrvwWmhfS8t6gziY5wJxd+Z2h8AeJRnAwPD8itCDaGXVBwJ/CaKfxEzTkG0Va0OA==", "cpu": [ "arm64" ], @@ -11061,9 +10994,9 @@ } }, "node_modules/@swc/core-darwin-x64": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.41.tgz", - "integrity": "sha512-N8B56ESFazZAWZyIkecADSPCwlLEinW7QLMEeotCpv4J7VXwfH+OLkmRL8o96UZ+1355fwHxDTS6/wK7yucvkA==", + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.43.tgz", + "integrity": "sha512-lp3d4Lamc8dt5huYdGLSR+9hLxmfr1jb0l+4XXG2zPqZwYWRN9R0U2qYoTrggiU2RWW0oV9VbWM3kBnqIc2kdQ==", "cpu": [ "x64" ], @@ -11077,9 +11010,9 @@ } }, "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.41.tgz", - "integrity": "sha512-6XrId2fyle0mS5xxON8rU84mPd2Cq1kDJRj+4BnQKTd7u+2kSA6Ww+JkOP0iTNqOqt9OXhPOEAjBHAuonWcdCg==", + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.43.tgz", + "integrity": "sha512-JWTQQELtsG5GgphDrr/XqqmM2pDN3cZqbMS0Mrg+iTiXL3F74sn/S2IyYE/5u4h2KLkTf9qQ7dXyxsbx7YzkeA==", "cpu": [ "arm" ], @@ -11093,9 +11026,9 @@ } }, "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.41.tgz", - "integrity": "sha512-ynLIarxlkVnqHn1D0fKOVht6mNU5ks6lrH+MY3kkS+XFaGGgDxFZVjWKJlkYTKm3RCvBTfA8Ng5fLufXheMRKQ==", + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.43.tgz", + "integrity": "sha512-B4otJRdPWIsmiSBf0uG7Z/+vMWmkufjz5MmYxubwKuZazDW14Zd3symga1N62QR4RT+kEFeHEgsXfZGyn/w0hw==", "cpu": [ "arm64" ], @@ -11109,9 +11042,9 @@ } }, "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.41.tgz", - "integrity": "sha512-dXu/5vd4gh8symyhRF+4G7gOPkjmb4pONhh7sl+6GSiW0LOKZlfu5kXmyFbTz9smOT7jgr002qY9b1nujjXt2A==", + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.43.tgz", + "integrity": "sha512-6zB6OnpViBxYy4tgY3v2i6AZY9fwkcHZ032UOwtwUuW1d19sdT07qF0kZe6/3UR1tUaK6jjg2rmVcUIBCEYVjQ==", "cpu": [ "arm64" ], @@ -11125,9 +11058,9 @@ } }, "node_modules/@swc/core-linux-ppc64-gnu": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.41.tgz", - "integrity": "sha512-XGO6zVPXoPE0gf/XnI4jBbafNT13AYgoh6ns0JCSdOetI/kqVf0vhpz7NuNgAzZrMVCsmieqjPoTwViDgh4mOQ==", + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.43.tgz", + "integrity": "sha512-coxE1ZWdB3uSDVNoEtYNrRi/1epvckZx9cTJ8ICUxTMTxGk+yvQ/Twacp3ruZSaMPGCriUjP86C37VhaT6nyRg==", "cpu": [ "ppc64" ], @@ -11141,9 +11074,9 @@ } }, "node_modules/@swc/core-linux-s390x-gnu": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.41.tgz", - "integrity": "sha512-0WUglRwyZtW+iMi7J3iFdrCxreZZIKf4egTwEQfIYRsqFax69A0OrFj+NIoFSE03xBT/IFRrg+S8K6f9Ky+4hA==", + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.43.tgz", + "integrity": "sha512-lXfLhs+LpBsD5inuYx+YDH5WsPPBQ95KPUiy8P5wq9ob9xKDZFqwNfU2QW6bGO8NqRO/H9JQomTSt5Yyh+FGfA==", "cpu": [ "s390x" ], @@ -11157,9 +11090,9 @@ } }, "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.41.tgz", - "integrity": "sha512-VxkuQK59c0tHm6uJZCUrS3cyA2JhGGfdU6e41SZz0x/JS+4Sm7C1mIc97In14vkZJopEt7yXA2TouCqZDSygEA==", + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.43.tgz", + "integrity": "sha512-07XnKwTmKy8TGOZG3D9fRnLWGynxPjwQnZLVmBFbo6F+7vHYzBIOuwXEhemrChBWb6yDNZsVCcMWCPX6FDD2xg==", "cpu": [ "x64" ], @@ -11173,9 +11106,9 @@ } }, "node_modules/@swc/core-linux-x64-musl": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.41.tgz", - "integrity": "sha512-/0qXIu1ZxggLuovLb22vFfKHq2AA4n6Whw5UwmVCHk4pkw7KWnPIQpMCEqUMPsNkFJig7PPp/TSYFu8ZEb2rtQ==", + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.43.tgz", + "integrity": "sha512-TJc+bsSIaBh+hZvZ5GRtW/K1bw66TJ9vsUwvVIsZdiWxU5ObLwZvfcnZ3UpgVfMnFibRes9uriJrQNBHEEogRQ==", "cpu": [ "x64" ], @@ -11189,9 +11122,9 @@ } }, "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.41.tgz", - "integrity": "sha512-Y481sMNZM6rECh9VO4+y26N1lWEDAyxnBZskUf37fl90uHE946VHfmiVQWT0uMFOhyJJFovGTRuF4W82dwewUg==", + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.43.tgz", + "integrity": "sha512-jfd7s2/bUQYkOHLs+LWQNKZdmDa8+sufKLllhpWAhVQ2GDCwsHe3vR/j+OSiItZNtkzFuaawa3+SAKz9y5gYfw==", "cpu": [ "arm64" ], @@ -11205,9 +11138,9 @@ } }, "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.41.tgz", - "integrity": "sha512-BAchBD5qeUzy3hiPSLJtaaoSm4blCLyYffOF1bGE4ETcV+OisqjUAwDQMJj++4bTpvMCDzwC+Bj3PmQyBCtscw==", + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.43.tgz", + "integrity": "sha512-rLAE8JvucqEW1ZGohxPQrQWPBQeJG4+ypKbWfdlU/qmKScvCkxf9/Jxnzki1dkUQCQ7P5Enp13RlvqOlvx/32g==", "cpu": [ "ia32" ], @@ -11221,9 +11154,9 @@ } }, "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.41.tgz", - "integrity": "sha512-WOkA+fJ/ViVBQDsSV9JC52NACTe5PhlurA6viASDZGb7HR3KS01ZG7RZ+Bg6SVQFIoq3gSbTsskQVe6EbHFAYw==", + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.43.tgz", + "integrity": "sha512-h8MLDHZcfIukwQWj03rIJZx1I0E81AYj2X7J/nGErG4nz+QAv6G1Z+peotvinL3lqpbo32tLYSMFo32/ySzxKg==", "cpu": [ "x64" ], @@ -11282,9 +11215,9 @@ } }, "node_modules/@swc/types": { - "version": "0.1.26", - "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz", - "integrity": "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==", + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.27.tgz", + "integrity": "sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==", "devOptional": true, "license": "Apache-2.0", "dependencies": { @@ -12178,9 +12111,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.0.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.0.tgz", - "integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==", + "version": "26.0.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.1.tgz", + "integrity": "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==", "license": "MIT", "dependencies": { "undici-types": "~8.3.0" @@ -15420,9 +15353,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.38", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", - "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", + "version": "2.10.40", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", + "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -19184,13 +19117,13 @@ } }, "node_modules/echarts": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz", - "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz", + "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==", "license": "Apache-2.0", "dependencies": { "tslib": "2.3.0", - "zrender": "5.6.1" + "zrender": "6.1.0" } }, "node_modules/echarts/node_modules/tslib": { @@ -20756,9 +20689,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", "funding": [ { "type": "github", @@ -21964,9 +21897,9 @@ } }, "node_modules/geostyler-openlayers-parser": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/geostyler-openlayers-parser/-/geostyler-openlayers-parser-5.7.0.tgz", - "integrity": "sha512-FRNTNPLoKJzKYnWas+E4hb4h38SGaK3KeNPZmLUqO5EcTootJjAJyTbCy/Cuv9afk56HYIBpM2gHh6q/fLwqsg==", + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/geostyler-openlayers-parser/-/geostyler-openlayers-parser-5.7.1.tgz", + "integrity": "sha512-GKkFdki1XbNIWS8onAU2CatGCJ/BB3QzknligxTXtTuLOa6Gqp2RshgExt3BzVyQOlXMmb8zmhtd5Z0CbvrrgA==", "license": "BSD-2-Clause", "dependencies": { "css-font-parser": "^2.0.0", @@ -22918,9 +22851,9 @@ } }, "node_modules/google-auth-library": { - "version": "10.7.0", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.7.0.tgz", - "integrity": "sha512-QpTAbNJ36TliZLx3TTtahR8HG0hN9RllL1e3FymOvQSIKK8JmgV58H924ub2wa2DsS3ANjjP1Aw1N+Ramc8hqQ==", + "version": "10.9.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.0.tgz", + "integrity": "sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg==", "license": "Apache-2.0", "dependencies": { "base64-js": "^1.3.0", @@ -31734,6 +31667,98 @@ "node": ">= 6" } }, + "node_modules/minimizer-webpack-plugin": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-DoeAZz8Q1C1znwsUzej1fdoi4jCf7/+Em27ouLqfK/+3m8G+D7yDhUwrc3CNhjSzGUN1kn7Iv4sWmjflQHenpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/minimizer-webpack-plugin/node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/minimizer-webpack-plugin/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -32052,9 +32077,9 @@ } }, "node_modules/nanoid": { - "version": "5.1.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.15.tgz", - "integrity": "sha512-kBg3RpGtIe+RpTbyXwoI6pk5yD7KUiI3sygUqgeBMRst42KmhB4RZC7eiO9Wa1HIpaCCtpE2DJ6OI4Wi5ebwFw==", + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", "funding": [ { "type": "github", @@ -34387,13 +34412,13 @@ } }, "node_modules/playwright": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", - "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.61.0" + "playwright-core": "1.61.1" }, "bin": { "playwright": "cli.js" @@ -34406,9 +34431,9 @@ } }, "node_modules/playwright-core": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", - "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -39463,18 +39488,18 @@ "license": "ISC" }, "node_modules/sigstore": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-4.1.0.tgz", - "integrity": "sha512-/fUgUhYghuLzVT/gaJoeVehLCgZiUxPCPMcyVNY0lIf/cTCz58K/WTI7PefDarXxp9nUKpEwg1yyz3eSBMTtgA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-4.1.1.tgz", + "integrity": "sha512-endqECJkfhozrXMK5ngu/UAA0xVcVEFdnHJCElGaExypjW+HK5i6zu3NteLoaX/iFbRUbC3+DjttQs0GARr+5w==", "dev": true, "license": "Apache-2.0", "dependencies": { "@sigstore/bundle": "^4.0.0", - "@sigstore/core": "^3.1.0", + "@sigstore/core": "^3.2.1", "@sigstore/protobuf-specs": "^0.5.0", - "@sigstore/sign": "^4.1.0", - "@sigstore/tuf": "^4.0.1", - "@sigstore/verify": "^3.1.0" + "@sigstore/sign": "^4.1.1", + "@sigstore/tuf": "^4.0.2", + "@sigstore/verify": "^3.1.1" }, "engines": { "node": "^20.17.0 || >=22.9.0" @@ -43301,13 +43326,12 @@ } }, "node_modules/watchpack": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", - "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", + "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", "dev": true, "license": "MIT", "dependencies": { - "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" }, "engines": { @@ -43370,9 +43394,9 @@ } }, "node_modules/webpack": { - "version": "5.107.2", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.107.2.tgz", - "integrity": "sha512-v7RhXaJbpMlV0D7hC7lb2EbnxkoeUqf9qhKr6lozx3Q48pmFrqqNRmZFUEGmi7pSwm6fCQ2H1IjvCkHqdpVdjQ==", + "version": "5.108.3", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.108.3.tgz", + "integrity": "sha512-hOpaCHmQVVY66IVTjofnH14IgSdmod2aquSGHGuYig/OIdWge01Hk2Wt988DZcwXumFUT4+FvJY5N+ikl8o/ww==", "dev": true, "license": "MIT", "dependencies": { @@ -43385,19 +43409,18 @@ "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.22.0", + "enhanced-resolve": "^5.22.2", "es-module-lexer": "^2.1.0", "eslint-scope": "5.1.1", "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "loader-runner": "^4.3.2", "mime-db": "^1.54.0", + "minimizer-webpack-plugin": "^5.6.1", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.5.0", - "watchpack": "^2.5.1", + "watchpack": "^2.5.2", "webpack-sources": "^3.5.0" }, "bin": { @@ -45130,9 +45153,9 @@ } }, "node_modules/zrender": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz", - "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz", + "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==", "license": "BSD-3-Clause", "dependencies": { "tslib": "2.3.0" @@ -45312,7 +45335,7 @@ "handlebars": "^4.7.9", "jed": "^1.1.1", "lodash": "^4.18.1", - "lodash-es": "^4.17.21", + "lodash-es": "^4.18.1", "math-expression-evaluator": "^2.0.7", "parse-ms": "^4.0.0", "re-resizable": "^6.11.2", @@ -45342,7 +45365,7 @@ "@types/d3-time-format": "^4.0.3", "@types/jquery": "^4.0.1", "@types/lodash": "^4.17.24", - "@types/node": "^26.0.0", + "@types/node": "^26.0.1", "@types/prop-types": "^15.7.15", "@types/react-syntax-highlighter": "^15.5.13", "@types/react-table": "^7.7.20", @@ -46051,7 +46074,7 @@ "@deck.gl/extensions": "~9.2.9", "@deck.gl/geo-layers": "~9.2.5", "@deck.gl/layers": "~9.2.5", - "@deck.gl/mapbox": "~9.3.4", + "@deck.gl/mapbox": "~9.3.5", "@deck.gl/mesh-layers": "~9.2.5", "@luma.gl/constants": "~9.2.5", "@luma.gl/core": "~9.2.5", @@ -46100,9 +46123,9 @@ } }, "plugins/preset-chart-deckgl/node_modules/@deck.gl/mapbox": { - "version": "9.3.4", - "resolved": "https://registry.npmjs.org/@deck.gl/mapbox/-/mapbox-9.3.4.tgz", - "integrity": "sha512-chAEyVc8qD5I/tvy+VO2b/W01LP19LJTQLamR0gVCMkCm58hnKh83nQtj/XGqrHk61LOpnN/ASfksXa7XNc0hA==", + "version": "9.3.5", + "resolved": "https://registry.npmjs.org/@deck.gl/mapbox/-/mapbox-9.3.5.tgz", + "integrity": "sha512-odm+QfVslgJOI0c0NwzmWyKVtYy7yPVyZ8mz/xuDmA4QvdskpfcDaczaEpFEf4wT2senF6AOnnRcw5MGTu+FHw==", "license": "MIT", "dependencies": { "@math.gl/web-mercator": "^4.1.0" diff --git a/superset-frontend/package.json b/superset-frontend/package.json index 99fc1c107fe..a968a056f87 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -173,17 +173,17 @@ "dayjs": "^1.11.21", "dom-to-image-more": "^3.10.0", "dom-to-pdf": "^0.3.2", - "echarts": "^5.6.0", + "echarts": "^6.1.0", "fast-glob": "^3.3.2", "fs-extra": "^11.3.5", "fuse.js": "^7.4.2", "geolib": "^3.3.14", "geostyler": "^18.6.0", "geostyler-data": "^1.1.0", - "geostyler-openlayers-parser": "^5.7.0", + "geostyler-openlayers-parser": "^5.7.1", "geostyler-style": "11.0.2", "geostyler-wfs-parser": "^3.0.1", - "google-auth-library": "^10.7.0", + "google-auth-library": "^10.9.0", "immer": "^11.1.8", "interweave": "^13.1.1", "jquery": "^4.0.0", @@ -197,7 +197,7 @@ "memoize-one": "^6.0.0", "mousetrap": "^1.6.5", "mustache": "^4.2.0", - "nanoid": "^5.1.15", + "nanoid": "^5.1.16", "ol": "^10.9.0", "query-string": "9.4.0", "re-resizable": "^6.11.2", @@ -262,14 +262,14 @@ "@emotion/jest": "^11.14.2", "@formatjs/intl-durationformat": "^0.10.15", "@istanbuljs/nyc-config-typescript": "^1.0.1", - "@playwright/test": "^1.61.0", + "@playwright/test": "^1.61.1", "@pmmmwh/react-refresh-webpack-plugin": "^0.6.2", - "@storybook/addon-docs": "10.4.5", - "@storybook/addon-links": "10.4.4", - "@storybook/react-webpack5": "10.4.4", + "@storybook/addon-docs": "10.4.6", + "@storybook/addon-links": "10.4.6", + "@storybook/react-webpack5": "10.4.6", "@storybook/test-runner": "0.24.4", "@svgr/webpack": "^8.1.0", - "@swc/core": "^1.15.41", + "@swc/core": "^1.15.43", "@swc/plugin-emotion": "^14.14.0", "@swc/plugin-transform-imports": "^12.5.0", "@testing-library/dom": "^9.3.4", @@ -283,7 +283,7 @@ "@types/js-levenshtein": "^1.1.3", "@types/json-bigint": "^1.0.4", "@types/mousetrap": "^1.6.15", - "@types/node": "^26.0.0", + "@types/node": "^26.0.1", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@types/react-loadable": "^5.5.11", @@ -302,7 +302,7 @@ "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.10.38", + "baseline-browser-mapping": "^2.10.40", "cheerio": "1.2.0", "concurrently": "^10.0.3", "copy-webpack-plugin": "^14.0.0", @@ -365,7 +365,7 @@ "unzipper": "^0.12.5", "vm-browserify": "^1.1.2", "wait-on": "^9.0.10", - "webpack": "^5.107.2", + "webpack": "^5.108.3", "webpack-bundle-analyzer": "^5.3.0", "webpack-cli": "^7.0.3", "webpack-dev-server": "^5.2.5", @@ -415,6 +415,7 @@ "@luma.gl/shadertools": "~9.2.5", "@luma.gl/webgl": "~9.2.5", "fast-xml-parser": "^5.8.0", + "fast-uri": "^3.1.3", "jest-mock": "^30.4.0", "jest-runtime": "^30.4.0", "@jest/globals": "^30.4.0", diff --git a/superset-frontend/packages/superset-core/src/theme/types.ts b/superset-frontend/packages/superset-core/src/theme/types.ts index e0e782d6497..bc1f9ac8409 100644 --- a/superset-frontend/packages/superset-core/src/theme/types.ts +++ b/superset-frontend/packages/superset-core/src/theme/types.ts @@ -237,6 +237,27 @@ export interface SupersetSpecificTokens { * Fallback: transparent */ buttonSecondaryActiveBorderColor?: string; + + // Component flexibility tokens (sizing, radius, behavior) + selectOptionActiveOutline?: boolean; + labelBorderRadius?: number; + buttonControlHeight?: number; + buttonControlHeightSM?: number; + buttonControlHeightXS?: number; + buttonPaddingInline?: number; + buttonPaddingInlineSM?: number; + buttonFontSize?: number; + buttonBorderRadius?: number; + buttonStyleMap?: Record< + string, + { type?: string; variant?: string; color?: string } + >; + + // Dashboard tile tokens (opt-in, fallbacks: colorBgContainer bg, no border, borderRadius, hairline box-shadow) + dashboardTileBg?: string; + dashboardTileBorder?: string; + dashboardTileBorderRadius?: number; + dashboardTileBoxShadow?: string; } /** diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/customControls.tsx b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/customControls.tsx index fd355ff1167..3604b099359 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/customControls.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/customControls.tsx @@ -238,11 +238,16 @@ export const xAxisForceCategoricalControl = { return state?.form_data?.x_axis_sort !== undefined || control.value; }, renderTrigger: true, + // Expose the toggle for numeric and temporal x-axes. Temporal columns + // default to a continuous time scale, where ECharts places ticks at "nice" + // intervals that don't align with the actual buckets (e.g. weekly grain + // markers landing between month ticks). Treating the axis as categorical + // lets each bucket map to a discrete, tick-aligned category. visibility: ({ controls }: { controls: ControlStateMapping }) => checkColumnType( getColumnLabel(controls?.x_axis?.value as QueryFormColumn), controls?.datasource?.datasource, - [GenericDataType.Numeric], + [GenericDataType.Numeric, GenericDataType.Temporal], ), shouldMapStateToProps: () => true, }, diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/types.ts b/superset-frontend/packages/superset-ui-chart-controls/src/types.ts index aed80f3d357..9f63e423309 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/types.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/types.ts @@ -713,4 +713,5 @@ export interface DataColumnMeta { isChildColumn?: boolean; description?: string; currencyCodeColumn?: string; + isFilterable?: boolean; } diff --git a/superset-frontend/packages/superset-ui-chart-controls/test/shared-controls/customControls.test.tsx b/superset-frontend/packages/superset-ui-chart-controls/test/shared-controls/customControls.test.tsx index 38681840a77..029b19dd6d6 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/test/shared-controls/customControls.test.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/test/shared-controls/customControls.test.tsx @@ -20,7 +20,11 @@ import { GenericDataType } from '@apache-superset/core/common'; import { xAxisForceCategoricalControl } from '../../src/shared-controls/customControls'; import { checkColumnType } from '../../src/utils/checkColumnType'; -import type { ControlState } from '@superset-ui/chart-controls'; +import type { + ControlPanelState, + ControlState, + ControlStateMapping, +} from '@superset-ui/chart-controls'; jest.mock('../../src/utils/checkColumnType'); jest.mock('@superset-ui/core', () => ({ @@ -39,12 +43,12 @@ test('xAxisForceCategoricalControl should not treat temporal columns as categori controls: { x_axis: { value: 'date_column' }, datasource: { datasource: {} }, - }, - }; + } as unknown as ControlStateMapping, + } as unknown as ControlPanelState; const result = xAxisForceCategoricalControl.config.initialValue!( control, - state as any, + state, ); // Verify: should return control value (false) for non-numeric columns @@ -55,3 +59,27 @@ test('xAxisForceCategoricalControl should not treat temporal columns as categori mockCheckColumnType.mockClear(); }); + +test('xAxisForceCategoricalControl is visible for numeric and temporal x-axes', () => { + const mockCheckColumnType = jest.mocked(checkColumnType); + mockCheckColumnType.mockReturnValue(true); + + const controls = { + x_axis: { value: 'date_column' }, + datasource: { datasource: {} }, + } as unknown as ControlStateMapping; + + const visible = xAxisForceCategoricalControl.config.visibility!({ + controls, + }); + + expect(visible).toBe(true); + // Temporal columns must be included so the toggle is exposed for time-grain + // charts (e.g. weekly grain), where the time scale misaligns ticks/markers. + expect(mockCheckColumnType).toHaveBeenCalledWith('date_column', {}, [ + GenericDataType.Numeric, + GenericDataType.Temporal, + ]); + + mockCheckColumnType.mockClear(); +}); diff --git a/superset-frontend/packages/superset-ui-core/package.json b/superset-frontend/packages/superset-ui-core/package.json index 84be234d797..f5f3261be1c 100644 --- a/superset-frontend/packages/superset-ui-core/package.json +++ b/superset-frontend/packages/superset-ui-core/package.json @@ -67,7 +67,7 @@ "rison": "^0.1.1", "seedrandom": "^3.0.5", "xss": "^1.0.15", - "lodash-es": "^4.17.21" + "lodash-es": "^4.18.1" }, "devDependencies": { "@emotion/styled": "^11.14.1", @@ -78,7 +78,7 @@ "@types/d3-time-format": "^4.0.3", "@types/jquery": "^4.0.1", "@types/lodash": "^4.17.24", - "@types/node": "^26.0.0", + "@types/node": "^26.0.1", "@types/prop-types": "^15.7.15", "@types/react-syntax-highlighter": "^15.5.13", "@types/react-table": "^7.7.20", diff --git a/superset-frontend/packages/superset-ui-core/src/chart/clients/ChartClient.ts b/superset-frontend/packages/superset-ui-core/src/chart/clients/ChartClient.ts index 033c5dd9195..397a854a2a5 100644 --- a/superset-frontend/packages/superset-ui-core/src/chart/clients/ChartClient.ts +++ b/superset-frontend/packages/superset-ui-core/src/chart/clients/ChartClient.ts @@ -109,7 +109,7 @@ export default class ChartClient { (await buildQueryRegistry.get(visType)) ?? (() => formData); const requestConfig: RequestConfig = useLegacyApi ? { - endpoint: '/superset/explore_json/', + endpoint: '/explore_json/', postPayload: { form_data: buildQuery(formData), }, @@ -139,7 +139,7 @@ export default class ChartClient { ): Promise { return this.client .get({ - endpoint: `/superset/fetch_datasource_metadata?datasourceKey=${datasourceKey}`, + endpoint: `/fetch_datasource_metadata?datasourceKey=${datasourceKey}`, ...options, } as RequestConfig) .then(response => response.json as Datasource); diff --git a/superset-frontend/packages/superset-ui-core/src/chart/components/StatefulChart.tsx b/superset-frontend/packages/superset-ui-core/src/chart/components/StatefulChart.tsx index 2e9a4903410..baae1ae2ce4 100644 --- a/superset-frontend/packages/superset-ui-core/src/chart/components/StatefulChart.tsx +++ b/superset-frontend/packages/superset-ui-core/src/chart/components/StatefulChart.tsx @@ -28,6 +28,7 @@ import { RequestConfig, getClientErrorObject, } from '../..'; +import type { HandlerFunction } from '../types/Base'; import { Loading } from '../../components/Loading'; import ChartClient from '../clients/ChartClient'; import getChartBuildQueryRegistry from '../registries/ChartBuildQueryRegistrySingleton'; @@ -262,9 +263,7 @@ export default function StatefulChart(props: StatefulChartProps) { if (!useLegacyApi && !queryContext.queries) { queryContext = { queries: [queryContext] }; } - const endpoint = useLegacyApi - ? '/superset/explore_json/' - : '/api/v1/chart/data'; + const endpoint = useLegacyApi ? '/explore_json/' : '/api/v1/chart/data'; const requestConfig: RequestConfig = { endpoint, @@ -482,7 +481,7 @@ export default function StatefulChart(props: StatefulChartProps) { enableNoResults={enableNoResults} noResults={NoDataComponent && } onRenderSuccess={onRenderSuccess} - onRenderFailure={onRenderFailure} + onRenderFailure={onRenderFailure as HandlerFunction | undefined} hooks={hooks} /> ); diff --git a/superset-frontend/packages/superset-ui-core/src/components/ActionButton/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/ActionButton/index.tsx index d0473812915..336b8ca3d48 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/ActionButton/index.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/ActionButton/index.tsx @@ -41,6 +41,7 @@ export const ActionButton = ({ >, ) { const [loaded, setLoaded] = useState(component !== undefined); useEffect(() => { diff --git a/superset-frontend/packages/superset-ui-core/src/components/Button/Button.stories.tsx b/superset-frontend/packages/superset-ui-core/src/components/Button/Button.stories.tsx index 808a16f45b1..b5d9f1087d2 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Button/Button.stories.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Button/Button.stories.tsx @@ -125,6 +125,11 @@ InteractiveButton.argTypes = { options: buttonSizes, control: { type: 'select' }, }, + styleConfig: { + description: + 'Optional visual overrides (controlHeight, paddingInline, fontSize, fontWeight, borderRadius, ctaMinWidth, ctaMinHeight, iconGap).', + control: { type: 'object' }, + }, target: { name: TARGETS.label, control: { type: 'select' }, diff --git a/superset-frontend/packages/superset-ui-core/src/components/Button/Button.test.tsx b/superset-frontend/packages/superset-ui-core/src/components/Button/Button.test.tsx index a01dee81a79..ddc749fa213 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Button/Button.test.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Button/Button.test.tsx @@ -186,6 +186,68 @@ test('getSecondaryButtonHoverStyles supports partial token overrides', () => { expect(hoverStyles['&:active'].backgroundColor).toBe('#99d3df !important'); }); +test('styleConfig overrides theme defaults', () => { + const { getByRole } = render( + , + ); + expect(getByRole('button')).toBeInTheDocument(); +}); + +test('styleConfig partial override merges with defaults', () => { + const { getByRole } = render( + , + ); + expect(getByRole('button')).toBeInTheDocument(); +}); + +test('xsmall size resolves with theme tokens', () => { + const { getByRole } = render( + , + ); + expect(getByRole('button')).toBeInTheDocument(); + expect(getByRole('button')).toHaveClass('superset-button'); +}); + +test('small size resolves with theme tokens', () => { + const { getByRole } = render( + , + ); + expect(getByRole('button')).toBeInTheDocument(); + expect(getByRole('button')).toHaveClass('superset-button'); +}); + +test('primary buttonStyle applies ant-btn-primary class', () => { + const { getByRole } = render(); + expect(getByRole('button')).toHaveClass('ant-btn-primary'); +}); + +test('danger buttonStyle applies ant-btn-dangerous class', () => { + const { getByRole } = render(); + expect(getByRole('button')).toHaveClass('ant-btn-dangerous'); +}); + +test('link buttonStyle applies ant-btn-link class', () => { + const { getByRole } = render(); + expect(getByRole('button')).toHaveClass('ant-btn-link'); +}); + test('getSecondaryButtonStyle falls back when tokens are empty strings', () => { const mockTheme = { colorPrimary: '#2893B3', diff --git a/superset-frontend/packages/superset-ui-core/src/components/Button/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/Button/index.tsx index 8f7a3d9aa33..064d58687ac 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Button/index.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Button/index.tsx @@ -25,6 +25,7 @@ import type { SupersetTheme } from '@apache-superset/core/theme'; import type { ButtonColorType, ButtonProps, + ButtonStyleConfig, ButtonStyle, ButtonType, ButtonVariantType, @@ -84,14 +85,13 @@ export const getSecondaryButtonHoverStyles = (theme: SupersetTheme) => ({ }, }); -const BUTTON_STYLE_MAP: Record< - ButtonStyle, - { - type?: ButtonType; - variant?: ButtonVariantType; - color?: ButtonColorType; - } -> = { +type ButtonStyleMapping = { + type?: ButtonType; + variant?: ButtonVariantType; + color?: ButtonColorType; +}; + +const BUTTON_STYLE_MAP: Record = { primary: { type: 'primary', variant: 'solid', color: 'primary' }, secondary: { variant: 'filled', color: 'primary' }, tertiary: { variant: 'outlined', color: 'default' }, @@ -113,30 +113,56 @@ function ButtonInner(props: ButtonProps, ref: Ref) { href, showMarginRight = true, icon, + styleConfig, ...restProps } = props; const theme = useTheme(); - const { fontSizeSM, fontWeightStrong } = theme; + const { fontWeightStrong } = theme; + const btnFontSize = theme.buttonFontSize ?? theme.fontSizeSM; - let height = 32; - let padding = 18; + const resolvedStyleMap: Record = + theme.buttonStyleMap + ? (Object.fromEntries( + Object.entries(BUTTON_STYLE_MAP).map(([key, value]) => [ + key, + { ...value, ...theme.buttonStyleMap?.[key as ButtonStyle] }, + ]), + ) as Record) + : BUTTON_STYLE_MAP; + + let defaultHeight = theme.buttonControlHeight ?? 32; + let defaultPaddingInline = theme.buttonPaddingInline ?? 18; + let defaultBorderRadius = theme.buttonBorderRadius ?? theme.borderRadius; if (buttonSize === 'xsmall') { - height = 22; - padding = 5; + defaultHeight = theme.buttonControlHeightXS ?? 22; + defaultPaddingInline = 5; + defaultBorderRadius = theme.buttonBorderRadius ?? theme.borderRadiusSM; } else if (buttonSize === 'small') { - height = 30; - padding = 10; + defaultHeight = theme.buttonControlHeightSM ?? 30; + defaultPaddingInline = theme.buttonPaddingInlineSM ?? 10; + defaultBorderRadius = theme.buttonBorderRadius ?? theme.borderRadiusSM; } if (buttonStyle === 'link') { - padding = 4; + defaultPaddingInline = 4; } + const resolvedStyleConfig: Required = { + controlHeight: styleConfig?.controlHeight ?? defaultHeight, + paddingInline: styleConfig?.paddingInline ?? defaultPaddingInline, + fontSize: styleConfig?.fontSize ?? btnFontSize, + fontWeight: styleConfig?.fontWeight ?? fontWeightStrong, + ctaMinWidth: styleConfig?.ctaMinWidth ?? theme.sizeUnit * 36, + ctaMinHeight: styleConfig?.ctaMinHeight ?? theme.sizeUnit * 8, + iconGap: styleConfig?.iconGap ?? theme.sizeUnit * 2, + borderRadius: styleConfig?.borderRadius ?? defaultBorderRadius, + }; + const { type: antdType = 'default', variant, color, - } = BUTTON_STYLE_MAP[buttonStyle ?? 'primary']; + } = resolvedStyleMap[buttonStyle ?? 'primary'] ?? BUTTON_STYLE_MAP.primary; const element = children as ReactElement; @@ -148,7 +174,9 @@ function ButtonInner(props: ButtonProps, ref: Ref) { renderedChildren = Children.toArray(children); } const firstChildMargin = - showMarginRight && renderedChildren.length > 1 ? theme.sizeUnit * 2 : 0; + showMarginRight && renderedChildren.length > 1 + ? resolvedStyleConfig.iconGap + : 0; const effectiveButtonStyle: ButtonStyle = buttonStyle ?? 'primary'; @@ -167,11 +195,10 @@ function ButtonInner(props: ButtonProps, ref: Ref) { variant={variant} danger={effectiveButtonStyle === 'danger'} color={color} + // Static class names for embedded-dashboard CSS targeting className={cx( className, 'superset-button', - // A static class name containing the button style is available to - // support customizing button styles in embedded dashboards. `superset-button-${buttonStyle}`, { cta: !!cta }, )} @@ -180,12 +207,13 @@ function ButtonInner(props: ButtonProps, ref: Ref) { alignItems: 'center', justifyContent: 'center', lineHeight: 1, - fontSize: fontSizeSM, - fontWeight: fontWeightStrong, - height, - padding: `0px ${padding}px`, - minWidth: cta ? theme.sizeUnit * 36 : undefined, - minHeight: cta ? theme.sizeUnit * 8 : undefined, + fontSize: resolvedStyleConfig.fontSize, + fontWeight: resolvedStyleConfig.fontWeight, + height: resolvedStyleConfig.controlHeight, + padding: `0px ${resolvedStyleConfig.paddingInline}px`, + borderRadius: resolvedStyleConfig.borderRadius, + minWidth: cta ? resolvedStyleConfig.ctaMinWidth : undefined, + minHeight: cta ? resolvedStyleConfig.ctaMinHeight : undefined, marginLeft: 0, '& + .superset-button:not(.ant-btn-compact-item)': { marginLeft: theme.sizeUnit * 2, diff --git a/superset-frontend/packages/superset-ui-core/src/components/Button/types.ts b/superset-frontend/packages/superset-ui-core/src/components/Button/types.ts index d53e90e64e0..4bdee16d9c6 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Button/types.ts +++ b/superset-frontend/packages/superset-ui-core/src/components/Button/types.ts @@ -40,6 +40,17 @@ export type ButtonStyle = export type ButtonSize = 'default' | 'small' | 'xsmall'; +export type ButtonStyleConfig = { + controlHeight?: number; + paddingInline?: number; + fontSize?: number; + fontWeight?: number; + ctaMinWidth?: number; + ctaMinHeight?: number; + iconGap?: number; + borderRadius?: number; +}; + export type ButtonProps = Omit & { placement?: TooltipPlacement; tooltip?: ReactNode; @@ -49,4 +60,5 @@ export type ButtonProps = Omit & { cta?: boolean; showMarginRight?: boolean; icon?: ReactNode; + styleConfig?: ButtonStyleConfig; }; diff --git a/superset-frontend/packages/superset-ui-core/src/components/DropdownButton/DropdownButton.stories.tsx b/superset-frontend/packages/superset-ui-core/src/components/DropdownButton/DropdownButton.stories.tsx index 2ebb77f948f..f0cd956a6ee 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/DropdownButton/DropdownButton.stories.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/DropdownButton/DropdownButton.stories.tsx @@ -60,6 +60,11 @@ InteractiveDropdownButton.args = { }; InteractiveDropdownButton.argTypes = { + styleConfig: { + description: + 'Optional visual overrides (controlHeight, fontSize, fontWeight, boxShadow).', + control: { type: 'object' }, + }, placement: { defaultValue: 'top', control: { type: 'select' }, diff --git a/superset-frontend/packages/superset-ui-core/src/components/DropdownButton/DropdownButton.test.tsx b/superset-frontend/packages/superset-ui-core/src/components/DropdownButton/DropdownButton.test.tsx new file mode 100644 index 00000000000..e0d3719b803 --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/src/components/DropdownButton/DropdownButton.test.tsx @@ -0,0 +1,76 @@ +/** + * 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 { fireEvent, render, waitFor } from '@superset-ui/core/spec'; +import { DropdownButton } from '.'; + +const menuProps = { items: [{ key: '1', label: 'Item 1' }] }; + +test('renders without crashing when no styleConfig is provided', () => { + const { container } = render( + Click, + ); + expect(container.querySelector('.ant-btn')).toBeInTheDocument(); +}); + +test('renders without crashing with full styleConfig', () => { + const { container } = render( + + Click + , + ); + expect(container.querySelector('.ant-btn')).toBeInTheDocument(); +}); + +test('renders tooltip when tooltip prop is provided', async () => { + const { getByText } = render( + + Click + , + ); + fireEvent.mouseEnter(getByText('Click')); + await waitFor(() => { + expect(document.querySelector('[role="tooltip"]')).toHaveTextContent( + 'My Tooltip', + ); + }); +}); + +test('does not render tooltip wrapper when tooltip is not provided', () => { + const { container } = render( + Click, + ); + expect(container.querySelector('[id$="-tooltip"]')).not.toBeInTheDocument(); +}); + +test('passes button type to underlying Dropdown.Button', () => { + const { container } = render( + + Click + , + ); + expect(container.querySelector('.ant-btn-primary')).toBeInTheDocument(); +}); diff --git a/superset-frontend/packages/superset-ui-core/src/components/DropdownButton/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/DropdownButton/index.tsx index e51ddf6ed70..63c556995e1 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/DropdownButton/index.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/DropdownButton/index.tsx @@ -27,6 +27,7 @@ export const DropdownButton = ({ tooltip, tooltipPlacement, children, + styleConfig, ...rest }: DropdownButtonProps) => { const theme = useTheme(); @@ -57,10 +58,14 @@ export const DropdownButton = ({ defaultBtnCss, css` .ant-btn { - height: 30px; - box-shadow: none; - font-size: ${theme.fontSizeSM}px; - font-weight: ${theme.fontWeightStrong}; + height: ${styleConfig?.controlHeight ?? + theme.buttonControlHeightSM ?? + 30}px; + box-shadow: ${styleConfig?.boxShadow ?? 'none'}; + font-size: ${styleConfig?.fontSize ?? + theme.buttonFontSize ?? + theme.fontSizeSM}px; + font-weight: ${styleConfig?.fontWeight ?? theme.fontWeightStrong}; } `, ]} @@ -85,4 +90,4 @@ export const DropdownButton = ({ return button; }; -export type { DropdownButtonProps }; +export type { DropdownButtonProps, DropdownButtonStyleConfig } from './types'; diff --git a/superset-frontend/packages/superset-ui-core/src/components/DropdownButton/types.ts b/superset-frontend/packages/superset-ui-core/src/components/DropdownButton/types.ts index 3789d677336..fa31c4f1df0 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/DropdownButton/types.ts +++ b/superset-frontend/packages/superset-ui-core/src/components/DropdownButton/types.ts @@ -21,7 +21,15 @@ import { type ComponentProps } from 'react'; import { Dropdown } from 'antd'; import type { TooltipPlacement } from '../Tooltip/types'; +export type DropdownButtonStyleConfig = { + controlHeight?: number; + fontSize?: number; + fontWeight?: number; + boxShadow?: string; +}; + export type DropdownButtonProps = ComponentProps & { tooltip?: string; tooltipPlacement?: TooltipPlacement; + styleConfig?: DropdownButtonStyleConfig; }; diff --git a/superset-frontend/packages/superset-ui-core/src/components/DropdownContainer/DropdownContainer.tsx b/superset-frontend/packages/superset-ui-core/src/components/DropdownContainer/DropdownContainer.tsx index b8543b58a6c..02c0152a8a0 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/DropdownContainer/DropdownContainer.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/DropdownContainer/DropdownContainer.tsx @@ -19,7 +19,7 @@ import { cloneElement, forwardRef, - RefObject, + ForwardedRef, useEffect, useImperativeHandle, useLayoutEffect, @@ -54,7 +54,7 @@ export const DropdownContainer = forwardRef( forceRender, style, }: DropdownContainerProps, - outerRef: RefObject, + outerRef: ForwardedRef, ) => { const theme = useTheme(); const { ref, width = 0 } = useResizeDetector(); diff --git a/superset-frontend/packages/superset-ui-core/src/components/Form/LabeledErrorBoundInput.tsx b/superset-frontend/packages/superset-ui-core/src/components/Form/LabeledErrorBoundInput.tsx index 882fabf8b41..2095cca2f03 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Form/LabeledErrorBoundInput.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Form/LabeledErrorBoundInput.tsx @@ -79,7 +79,7 @@ export const LabeledErrorBoundInput = ({ isValidating ? 'validating' : hasError ? 'error' : 'success' } help={errorMessage || helpText} - hasFeedback={!!hasError} + hasFeedback={isValidating || !!hasError} > {visibilityToggle || props.name === 'password' ? ( { acc[key as AntdIconNames] = forwardRef( - (props, ref) => ( + ( + { + // Forward-compat: TS 6.0 treats IconComponentProps.component as a + // different shape than BaseIconProps.component; strip it from spread + // props so our own component binding is authoritative. + component: _ignoredComponent, + ...rest + }, + ref, + ) => ( ), ); diff --git a/superset-frontend/packages/superset-ui-core/src/components/Label/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/Label/index.tsx index 05371b75235..e57b6d52bf2 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Label/index.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Label/index.tsx @@ -53,7 +53,7 @@ export const Label = forwardRef((props, ref) => { overflow: hidden; text-overflow: ellipsis; background-color: ${backgroundColor}; - border-radius: 8px; + border-radius: ${theme.labelBorderRadius ?? 8}px; border-color: ${borderColor}; padding: 0.35em 0.8em; line-height: 1; diff --git a/superset-frontend/packages/superset-ui-core/src/components/ModalTrigger/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/ModalTrigger/index.tsx index 465346cb1d7..f344293d096 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/ModalTrigger/index.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/ModalTrigger/index.tsx @@ -16,7 +16,13 @@ * specific language governing permissions and limitations * under the License. */ -import { forwardRef, useState, ReactNode, MouseEvent } from 'react'; +import { + forwardRef, + ForwardedRef, + useState, + ReactNode, + MouseEvent, +} from 'react'; import { Button } from '../Button'; import { Modal } from '../Modal'; @@ -54,7 +60,7 @@ export interface ModalTriggerRef { } export const ModalTrigger = forwardRef( - (props: ModalTriggerProps, ref: ModalTriggerRef | null) => { + (props: ModalTriggerProps, ref: ForwardedRef) => { const [showModal, setShowModal] = useState(false); const { beforeOpen = () => {}, @@ -87,8 +93,14 @@ export const ModalTrigger = forwardRef( setShowModal(true); }; - if (ref) { - ref.current = { close, open, showModal }; // eslint-disable-line + // Forward both callback refs (e.g. `(value) => setRef(value)`) and + // object refs. Without the callback-ref branch, parents that pass a + // function ref get silently no-op'd and can't call close/open/showModal. + const refValue = { close, open, showModal }; + if (typeof ref === 'function') { + ref(refValue); + } else if (ref) { + ref.current = refValue; // eslint-disable-line } /* eslint-disable jsx-a11y/interactive-supports-focus */ diff --git a/superset-frontend/packages/superset-ui-core/src/components/PageHeaderWithActions/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/PageHeaderWithActions/index.tsx index a8dd727064d..d536f292bea 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/PageHeaderWithActions/index.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/PageHeaderWithActions/index.tsx @@ -32,10 +32,11 @@ import { CertifiedBadge } from '../CertifiedBadge'; import { Button } from '../Button'; export const menuTriggerStyles = (theme: SupersetTheme) => css` - width: ${theme.sizeUnit * 8}px; - height: ${theme.sizeUnit * 8}px; + width: ${theme.buttonControlHeight ?? theme.sizeUnit * 8}px; + height: ${theme.buttonControlHeight ?? theme.sizeUnit * 8}px; padding: 0; border: 1px solid ${theme.colorPrimary}; + border-radius: ${theme.buttonBorderRadius ?? theme.borderRadius}px; &.ant-btn > span.anticon { line-height: 0; diff --git a/superset-frontend/packages/superset-ui-core/src/components/Select/AsyncSelect.tsx b/superset-frontend/packages/superset-ui-core/src/components/Select/AsyncSelect.tsx index 1b1936c072b..cf51a786a51 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Select/AsyncSelect.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Select/AsyncSelect.tsx @@ -18,6 +18,7 @@ */ import { forwardRef, + ForwardedRef, FocusEvent, ReactElement, RefObject, @@ -38,6 +39,8 @@ import { getClientErrorObject, } from '@superset-ui/core'; import { + BaseOptionType, + DefaultOptionType, LabeledValue as AntdLabeledValue, RefSelectProps, } from 'antd/es/select'; @@ -146,7 +149,7 @@ const AsyncSelect = forwardRef( maxTagCount: propsMaxTagCount, ...props }: AsyncSelectProps, - ref: RefObject, + ref: ForwardedRef, ) => { const isSingleMode = mode === 'single'; const shouldShowSearch = allowNewOptions ? true : Boolean(showSearch); @@ -325,7 +328,14 @@ const AsyncSelect = forwardRef( mergedData = prevOptions .filter(previousOption => !dataValues.has(previousOption.value)) .concat(data) - .sort(sortComparatorForNoSearch); + // Forward-compat: TS 6.0 infers stricter antd option types; widen + // the comparator to accept the broader DefaultOptionType shape. + .sort( + sortComparatorForNoSearch as unknown as ( + a: BaseOptionType | DefaultOptionType, + b: BaseOptionType | DefaultOptionType, + ) => number, + ); return mergedData; }); } @@ -510,7 +520,13 @@ const AsyncSelect = forwardRef( if (isDropdownVisible && !inputValue && selectOptions.length > 1) { const sortedOptions = selectOptions .slice() - .sort(sortComparatorForNoSearch); + // Forward-compat: see note in mergeData above. + .sort( + sortComparatorForNoSearch as unknown as ( + a: BaseOptionType | DefaultOptionType, + b: BaseOptionType | DefaultOptionType, + ) => number, + ); if (!isEqual(sortedOptions, selectOptions)) { setSelectOptions(sortedOptions); } @@ -633,14 +649,16 @@ const AsyncSelect = forwardRef( setAllValuesLoaded(false); }; - useImperativeHandle( - ref, - () => ({ - ...(ref.current as RefSelectProps), + useImperativeHandle(ref, () => { + const current = + ref && typeof ref !== 'function' && ref.current + ? (ref.current as RefSelectProps) + : ({} as RefSelectProps); + return { + ...current, clearCache, - }), - [ref], - ); + }; + }, [ref]); const getPastedTextValue = useCallback( async (text: string) => { @@ -713,8 +731,21 @@ const AsyncSelect = forwardRef( data-test={ariaLabel || name} autoClearSearchValue={autoClearSearchValue} popupRender={popupRender} - filterOption={handleFilterOption} - filterSort={sortComparatorWithSearch} + // Forward-compat: TS 6.0 infers stricter antd option types; local + // helpers typed against AntdLabeledValue are behaviorally compatible + // with the broader BaseOptionType/DefaultOptionType antd expects. + filterOption={ + handleFilterOption as unknown as ( + search: string, + option?: BaseOptionType | DefaultOptionType, + ) => boolean + } + filterSort={ + sortComparatorWithSearch as unknown as ( + a: BaseOptionType | DefaultOptionType, + b: BaseOptionType | DefaultOptionType, + ) => number + } getPopupContainer={ getPopupContainer || (triggerNode => triggerNode.parentNode) } @@ -724,13 +755,26 @@ const AsyncSelect = forwardRef( mode={mappedMode} notFoundContent={isLoading ? t('Loading...') : notFoundContent} onBlur={handleOnBlur} - onDeselect={handleOnDeselect} + // Forward-compat: TS 6.0 narrows the Select value type handed to + // SelectHandler; our local handlers already accept the broader union. + onDeselect={ + handleOnDeselect as unknown as ( + value: unknown, + option: BaseOptionType | DefaultOptionType, + ) => void + } onOpenChange={handleOnDropdownVisibleChange} - // @ts-expect-error + // @ts-expect-error antd Select does not declare onPaste on its prop + // surface, but the underlying input accepts it and we rely on that. onPaste={onPaste} onPopupScroll={handlePagination} onSearch={shouldShowSearch ? handleOnSearch : undefined} - onSelect={handleOnSelect} + onSelect={ + handleOnSelect as unknown as ( + value: unknown, + option: BaseOptionType | DefaultOptionType, + ) => void + } onClear={handleClear} options={fullSelectOptions} optionRender={option => {option.label || option.value}} diff --git a/superset-frontend/packages/superset-ui-core/src/components/Select/Select.tsx b/superset-frontend/packages/superset-ui-core/src/components/Select/Select.tsx index 1deccafd737..38f50d0e028 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Select/Select.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Select/Select.tsx @@ -34,6 +34,8 @@ import { t } from '@apache-superset/core/translation'; import { ensureIsArray, formatNumber, usePrevious } from '@superset-ui/core'; import { Constants } from '@superset-ui/core/components'; import { + BaseOptionType, + DefaultOptionType, LabeledValue as AntdLabeledValue, RefSelectProps, } from 'antd/es/select'; @@ -214,7 +216,17 @@ const Select = forwardRef( ); const initialOptionsSorted = useMemo( - () => initialOptions.slice().sort(sortSelectedFirst), + () => + initialOptions + .slice() + // Forward-compat: TS 6.0 infers stricter antd option types; widen the + // comparator to accept the broader DefaultOptionType shape. + .sort( + sortSelectedFirst as unknown as ( + a: BaseOptionType | DefaultOptionType, + b: BaseOptionType | DefaultOptionType, + ) => number, + ), [initialOptions, sortSelectedFirst], ); @@ -242,7 +254,17 @@ const Select = forwardRef( missingValues.length > 0 ? missingValues.concat(selectOptions) : selectOptions; - return result.slice().sort(sortSelectedFirst); + return ( + result + .slice() + // Forward-compat: see note on initialOptionsSorted. + .sort( + sortSelectedFirst as unknown as ( + a: BaseOptionType | DefaultOptionType, + b: BaseOptionType | DefaultOptionType, + ) => number, + ) + ); }, [selectOptions, selectValue, sortSelectedFirst]); const enabledOptions = useMemo( @@ -782,8 +804,21 @@ const Select = forwardRef( data-test={ariaLabel || name} autoClearSearchValue={autoClearSearchValue} popupRender={popupRender} - filterOption={handleFilterOption} - filterSort={sortComparatorWithSearch} + // Forward-compat: TS 6.0 infers stricter antd option types; local + // helpers typed against AntdLabeledValue are behaviorally compatible + // with the broader BaseOptionType/DefaultOptionType antd expects. + filterOption={ + handleFilterOption as unknown as ( + search: string, + option?: BaseOptionType | DefaultOptionType, + ) => boolean + } + filterSort={ + sortComparatorWithSearch as unknown as ( + a: BaseOptionType | DefaultOptionType, + b: BaseOptionType | DefaultOptionType, + ) => number + } getPopupContainer={ getPopupContainer || (triggerNode => triggerNode.parentNode) } @@ -794,13 +829,26 @@ const Select = forwardRef( mode={mappedMode} notFoundContent={isLoading ? t('Loading...') : notFoundContent} onBlur={handleOnBlur} - onDeselect={handleOnDeselect} + // Forward-compat: TS 6.0 narrows the Select value type handed to + // SelectHandler; our local handlers already accept the broader union. + onDeselect={ + handleOnDeselect as unknown as ( + value: unknown, + option: BaseOptionType | DefaultOptionType, + ) => void + } onOpenChange={handleOnDropdownVisibleChange} - // @ts-expect-error + // @ts-expect-error antd Select does not declare onPaste on its prop + // surface, but the underlying input accepts it and we rely on that. onPaste={onPaste} onPopupScroll={undefined} onSearch={shouldShowSearch ? handleOnSearch : undefined} - onSelect={handleOnSelect} + onSelect={ + handleOnSelect as unknown as ( + value: unknown, + option: BaseOptionType | DefaultOptionType, + ) => void + } onClear={handleClear} placeholder={placeholder} tokenSeparators={tokenSeparators} diff --git a/superset-frontend/packages/superset-ui-core/src/components/Select/styles.tsx b/superset-frontend/packages/superset-ui-core/src/components/Select/styles.tsx index c7e0dc942b2..2d0e5c54668 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Select/styles.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Select/styles.tsx @@ -45,10 +45,11 @@ export const StyledContainer = styled.div<{ headerPosition: string }>` export const StyledSelect = styled(Select, { shouldForwardProp: prop => prop !== 'headerPosition' && prop !== 'oneLine', })<{ headerPosition?: string; oneLine?: boolean }>` - ${({ theme, headerPosition, oneLine }) => ` + ${({ theme, headerPosition, oneLine }) => { + const useSubtleOptionHover = theme.selectOptionActiveOutline === false; + return ` .ant-select-item-option-active:not(.ant-select-item-option-disabled) { - outline: 2px solid ${theme.colorPrimary}; - outline-offset: -2px; + ${useSubtleOptionHover ? 'outline: none;' : `outline: 2px solid ${theme.colorPrimary}; outline-offset: -2px;`} } flex: ${headerPosition === 'left' ? 1 : 0}; line-height: ${theme.sizeXL}px; @@ -82,7 +83,8 @@ export const StyledSelect = styled(Select, { } ` }; - `} + `; + }} `; export const NoElement = styled.span` diff --git a/superset-frontend/packages/superset-ui-core/src/components/Table/VirtualTable.tsx b/superset-frontend/packages/superset-ui-core/src/components/Table/VirtualTable.tsx index b9bc81715a0..f5c6967b0e0 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Table/VirtualTable.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Table/VirtualTable.tsx @@ -84,8 +84,8 @@ const VirtualTable = ( allowHTML = false, } = props; const [tableWidth, setTableWidth] = useState(0); - const onResize = useCallback((width: number) => { - setTableWidth(width); + const onResize = useCallback((width?: number) => { + setTableWidth(width ?? 0); }, []); const { ref } = useResizeDetector({ onResize }); const theme = useTheme(); diff --git a/superset-frontend/packages/superset-ui-core/src/components/Table/utils/InteractiveTableUtils.ts b/superset-frontend/packages/superset-ui-core/src/components/Table/utils/InteractiveTableUtils.ts index 021ef81ff30..82dda4000aa 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Table/utils/InteractiveTableUtils.ts +++ b/superset-frontend/packages/superset-ui-core/src/components/Table/utils/InteractiveTableUtils.ts @@ -29,7 +29,7 @@ interface IInteractiveColumn extends HTMLElement { export default class InteractiveTableUtils { tableRef: HTMLTableElement | null; - columnRef: IInteractiveColumn | null; + columnRef: IInteractiveColumn | null = null; setDerivedColumns: Function; diff --git a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/index.tsx index 9e24e051746..8184dfe85ce 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/index.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/index.tsx @@ -27,7 +27,12 @@ import { } from 'react-table'; import { styled } from '@apache-superset/core/theme'; import { Table, TableSize } from '@superset-ui/core/components/Table'; -import { TableRowSelection, SorterResult } from 'antd/es/table/interface'; +import { + ColumnsType, + TableRowSelection, + SorterResult, +} from 'antd/es/table/interface'; +import type { TableProps } from 'antd/es/table'; import { mapColumns, mapRows } from './utils'; export interface TableCollectionProps { @@ -303,7 +308,10 @@ function TableCollection({ surface the Table expects here. + columns={mappedColumns as unknown as ColumnsType} data={mappedRows} size={size} data-test="listview-table" @@ -316,7 +324,9 @@ function TableCollection({ sortDirections={['ascend', 'descend', 'ascend']} isPaginationSticky={isPaginationSticky} showRowCount={showRowCount} - rowClassName={getRowClassName} + rowClassName={ + getRowClassName as unknown as TableProps['rowClassName'] + } expandable={expandable} components={{ header: { @@ -342,7 +352,7 @@ function TableCollection({ ), }, }} - onChange={handleTableChange} + onChange={handleTableChange as unknown as TableProps['onChange']} /> ); } diff --git a/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/index.tsx index a790963e333..e2fb7e55d60 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/index.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/index.tsx @@ -20,6 +20,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { t } from '@apache-superset/core/translation'; import { Select } from '@superset-ui/core/components'; +import type { LabeledValue } from '@superset-ui/core/components'; import { extendedDayjs } from '../../utils/dates'; import { timezoneOptionsCache, @@ -156,7 +157,16 @@ export default function TimezoneSelector({ onOpenChange={handleOpenChange} value={selectValue} options={timezoneOptions || []} - sortComparator={sortComparator} + // Forward-compat: TS 6.0 resolves sortComparator against antd's + // LabeledValue; our comparator only reads properties that always exist + // on TimezoneOption, so the broader shape is safe at runtime. + sortComparator={ + sortComparator as unknown as ( + a: LabeledValue, + b: LabeledValue, + search?: string, + ) => number + } loading={isLoadingOptions} placeholder={isLoadingOptions ? t('Loading timezones...') : placeholder} {...{ placement: 'topLeft', ...rest }} diff --git a/superset-frontend/packages/superset-ui-core/src/connection/SupersetClientClass.ts b/superset-frontend/packages/superset-ui-core/src/connection/SupersetClientClass.ts index 5bd2d3d64bf..dbc41065e48 100644 --- a/superset-frontend/packages/superset-ui-core/src/connection/SupersetClientClass.ts +++ b/superset-frontend/packages/superset-ui-core/src/connection/SupersetClientClass.ts @@ -82,7 +82,11 @@ export default class SupersetClientClass { unauthorizedHandler = undefined, }: ClientConfig = {}) { const url = new URL(`${protocol || 'https:'}//${host || 'localhost'}`); - this.appRoot = appRoot; + // Strip a trailing slash so the getUrl dedupe comparisons and the final + // `${this.appRoot}/${...}` build stay correct regardless of how the root + // was supplied. Mirrors normalizeBackendUrlString / AppRootMiddleware / + // LegacyPrefixRedirectMiddleware, which all rstrip the root. + this.appRoot = appRoot.replace(/\/$/, ''); this.host = url.host; this.protocol = url.protocol as Protocol; this.headers = { Accept: 'application/json', ...headers }; // defaulting accept to json @@ -296,8 +300,26 @@ export default class SupersetClientClass { const host = inputHost ?? this.host; const cleanHost = host.slice(-1) === '/' ? host.slice(0, -1) : host; // no backslash + // Strip a single leading appRoot segment so callers that accidentally + // pre-prefix their endpoint (e.g. by wrapping with ensureAppRoot before + // passing to the client) do not produce a doubled `/superset/superset/...` + // URL. Single-pass strip mirrors + // `stripAppRoot` in `src/utils/pathUtils` and `normalizeBackendUrlString` + // exactly: a genuine `/superset/superset/` is a legitimate route, not + // a double-prefix bug. The L2 static invariant still flags pre-prefixing as + // a migration issue; this is the runtime safety net. + let cleanEndpoint = endpoint; + const root = this.appRoot; + if (root) { + if (cleanEndpoint === root) { + cleanEndpoint = ''; + } else if (cleanEndpoint.startsWith(`${root}/`)) { + cleanEndpoint = cleanEndpoint.slice(root.length); + } + } + return `${this.protocol}//${cleanHost}${this.appRoot}/${ - endpoint[0] === '/' ? endpoint.slice(1) : endpoint + cleanEndpoint[0] === '/' ? cleanEndpoint.slice(1) : cleanEndpoint }`; } } diff --git a/superset-frontend/packages/superset-ui-core/src/connection/callApi/parseResponse.ts b/superset-frontend/packages/superset-ui-core/src/connection/callApi/parseResponse.ts index 0716f16a8ac..38c76b60ffd 100644 --- a/superset-frontend/packages/superset-ui-core/src/connection/callApi/parseResponse.ts +++ b/superset-frontend/packages/superset-ui-core/src/connection/callApi/parseResponse.ts @@ -55,24 +55,25 @@ export default async function parseResponse( if (parseMethod === 'json-bigint') { const rawData = await response.text(); const json = JSONbig.parse(rawData); + const decoded = cloneDeepWith(json, (value: any) => { + if ( + value?.isInteger?.() === true && + (value?.isGreaterThan?.(Number.MAX_SAFE_INTEGER) || + value?.isLessThan?.(Number.MIN_SAFE_INTEGER)) + ) { + // toFixed() avoids scientific notation, which BigInt() rejects. + return BigInt(value.toFixed()); + } + // // `json-bigint` could not handle floats well, see sidorares/json-bigint#62 + // // TODO: clean up after json-bigint>1.0.1 is released + if (value?.isNaN?.() === false) { + return value?.toNumber?.(); + } + return undefined; + }); const result: JsonResponse = { response, - json: cloneDeepWith(json, (value: any) => { - if ( - value?.isInteger?.() === true && - (value?.isGreaterThan?.(Number.MAX_SAFE_INTEGER) || - value?.isLessThan?.(Number.MIN_SAFE_INTEGER)) - ) { - // toFixed() avoids scientific notation, which BigInt() rejects. - return BigInt(value.toFixed()); - } - // // `json-bigint` could not handle floats well, see sidorares/json-bigint#62 - // // TODO: clean up after json-bigint>1.0.1 is released - if (value?.isNaN?.() === false) { - return value?.toNumber?.(); - } - return undefined; - }), + json: decoded, }; return result as ReturnType; } diff --git a/superset-frontend/packages/superset-ui-core/src/connection/index.ts b/superset-frontend/packages/superset-ui-core/src/connection/index.ts index 3ac9806a055..4fdf85e3f9b 100644 --- a/superset-frontend/packages/superset-ui-core/src/connection/index.ts +++ b/superset-frontend/packages/superset-ui-core/src/connection/index.ts @@ -21,6 +21,9 @@ export { default as callApi } from './callApi'; export { default as SupersetClient } from './SupersetClient'; export { default as SupersetClientClass } from './SupersetClientClass'; +export { normalizeBackendUrlString } from './normalizeBackendUrls'; +export type { NormalizeOptions } from './normalizeBackendUrls'; + export * from './types'; export * from './constants'; export { default as __hack_reexport_connection } from './types'; diff --git a/superset-frontend/packages/superset-ui-core/src/connection/normalizeBackendUrls.ts b/superset-frontend/packages/superset-ui-core/src/connection/normalizeBackendUrls.ts new file mode 100644 index 00000000000..31f2731e7fc --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/src/connection/normalizeBackendUrls.ts @@ -0,0 +1,59 @@ +/** + * 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. + */ + +/** + * Strips the configured application root from a single backend-supplied URL + * string so the frontend speaks router-relative paths. Apply it at the few call + * sites that surface a router-relative URL from an API response (e.g. a + * dataset's `explore_url`) before handing the value to a consumer that + * re-prefixes the root — `SupersetClient.getUrl`, `makeUrl`, or a react-router + * `` resolving against the Router `basename`. Without it those consumers + * would re-prefix an already-rooted path into `/superset/superset/...`. + * + * Absolute (`https:`, `ftp:`, `mailto:`, `tel:`) and protocol-relative (`//`) + * URLs pass through untouched, so an operator-configured external + * `default_endpoint` on a dataset is left alone. + */ + +export interface NormalizeOptions { + /** Application root to strip. Empty string disables normalisation. */ + applicationRoot: string; +} + +const SAFE_ABSOLUTE_URL_RE = /^(?:https?|ftp|mailto|tel):/i; + +function stripTrailingSlash(root: string): string { + return root.endsWith('/') ? root.slice(0, -1) : root; +} + +/** Normalise a single router-relative URL string. */ +export function normalizeBackendUrlString( + value: string, + options: NormalizeOptions, +): string { + const root = stripTrailingSlash(options.applicationRoot); + if (!root) return value; + if (SAFE_ABSOLUTE_URL_RE.test(value)) return value; + if (value.startsWith('//')) return value; + if (value === root) return '/'; + if (value.startsWith(`${root}/`)) { + return value.slice(root.length); + } + return value; +} diff --git a/superset-frontend/packages/superset-ui-core/src/connection/types.ts b/superset-frontend/packages/superset-ui-core/src/connection/types.ts index 3379edb4a9c..1893444fe2d 100644 --- a/superset-frontend/packages/superset-ui-core/src/connection/types.ts +++ b/superset-frontend/packages/superset-ui-core/src/connection/types.ts @@ -26,10 +26,18 @@ export type FetchRetryOptions = { retries?: number; retryDelay?: | number - | ((attempt: number, error: Error, response: Response) => number); + | (( + attempt: number, + error: Error | null, + response: Response | null, + ) => number); retryOn?: | number[] - | ((attempt: number, error: Error, response: Response) => boolean); + | (( + attempt: number, + error: Error | null, + response: Response | null, + ) => boolean); }; export type Headers = { [k: string]: string }; export type Host = string; diff --git a/superset-frontend/packages/superset-ui-core/src/currency-format/CurrencyFormatter.ts b/superset-frontend/packages/superset-ui-core/src/currency-format/CurrencyFormatter.ts index 28acc5a950e..01961a2ce0f 100644 --- a/superset-frontend/packages/superset-ui-core/src/currency-format/CurrencyFormatter.ts +++ b/superset-frontend/packages/superset-ui-core/src/currency-format/CurrencyFormatter.ts @@ -26,6 +26,11 @@ import NumberFormats from '../number-format/NumberFormats'; import { Currency } from '../query'; import { RowData, RowDataValue } from './types'; import { AUTO_CURRENCY_SYMBOL, ISO_4217_REGEX } from './CurrencyFormats'; +import { getCurrencyLocale } from './currencyLocale'; +import { + resolveSymbolPosition, + formatWithSymbolPosition, +} from './symbolPosition'; /* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ @@ -90,7 +95,7 @@ class CurrencyFormatter extends ExtensibleFunction { ); this.d3Format = config.d3Format || NumberFormats.SMART_NUMBER; this.currency = config.currency; - this.locale = config.locale || 'en-US'; + this.locale = config.locale || getCurrencyLocale(); } hasValidCurrency() { @@ -128,13 +133,16 @@ class CurrencyFormatter extends ExtensibleFunction { try { const symbol = getCurrencySymbol({ symbol: normalizedCurrency }); if (symbol) { - if (this.currency.symbolPosition === 'prefix') { - return `${symbol} ${normalizedValue}`; - } else if (this.currency.symbolPosition === 'suffix') { - return `${normalizedValue} ${symbol}`; - } - // Unknown symbolPosition - default to suffix - return `${normalizedValue} ${symbol}`; + const position = resolveSymbolPosition( + normalizedCurrency, + this.currency.symbolPosition, + this.locale, + ); + return formatWithSymbolPosition( + symbol, + normalizedValue, + position, + ); } } catch { // Invalid currency code - return value without currency symbol @@ -147,13 +155,15 @@ class CurrencyFormatter extends ExtensibleFunction { try { const symbol = getCurrencySymbol(this.currency); - if (this.currency.symbolPosition === 'prefix') { - return `${symbol} ${normalizedValue}`; - } else if (this.currency.symbolPosition === 'suffix') { - return `${normalizedValue} ${symbol}`; + if (!symbol) { + return formattedValue; } - // Unknown symbolPosition - default to suffix - return `${normalizedValue} ${symbol}`; + const position = resolveSymbolPosition( + this.currency.symbol, + this.currency.symbolPosition, + this.locale, + ); + return formatWithSymbolPosition(symbol, normalizedValue, position); } catch { // Invalid currency code - return value without currency symbol return formattedValue; diff --git a/superset-frontend/packages/superset-ui-core/src/currency-format/currencyLocale.ts b/superset-frontend/packages/superset-ui-core/src/currency-format/currencyLocale.ts new file mode 100644 index 00000000000..c136fd007e6 --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/src/currency-format/currencyLocale.ts @@ -0,0 +1,52 @@ +/** + * 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 DEFAULT_CURRENCY_LOCALE = 'en-US'; + +let currencyLocale: string = DEFAULT_CURRENCY_LOCALE; + +/** + * Set the locale used to resolve the default currency symbol position. + * + * Called once at application bootstrap with the deployment locale so that + * currency formatting follows the conventions of that locale (e.g. EUR is a + * suffix in `fr-FR`/`de-DE` but a prefix in `en-US`). + * + * Superset's bootstrap locale can be underscore-formatted (e.g. `zh_TW`, + * `pt_BR`), but `Intl.NumberFormat` expects BCP-47 tags with hyphens. The + * value is canonicalized before storing so symbol resolution does not throw + * and silently fall back. Empty or invalid tags leave the locale unchanged. + */ +export function setCurrencyLocale(locale?: string): void { + if (!locale) { + return; + } + try { + // getCanonicalLocales throws on a malformed tag and otherwise returns a + // non-empty list, so the first entry is always a valid canonical tag here. + [currencyLocale] = Intl.getCanonicalLocales(locale.replace(/_/g, '-')); + } catch { + // Invalid locale tag — keep the previously configured locale. + } +} + +/** Get the locale used to resolve the default currency symbol position. */ +export function getCurrencyLocale(): string { + return currencyLocale; +} diff --git a/superset-frontend/packages/superset-ui-core/src/currency-format/index.ts b/superset-frontend/packages/superset-ui-core/src/currency-format/index.ts index 8f696f40cb9..bee1d3b9c9a 100644 --- a/superset-frontend/packages/superset-ui-core/src/currency-format/index.ts +++ b/superset-frontend/packages/superset-ui-core/src/currency-format/index.ts @@ -24,5 +24,11 @@ export { hasMixedCurrencies, } from './CurrencyFormatter'; export { AUTO_CURRENCY_SYMBOL, ISO_4217_REGEX } from './CurrencyFormats'; +export { getCurrencyLocale, setCurrencyLocale } from './currencyLocale'; +export { + resolveSymbolPosition, + formatWithSymbolPosition, + type SymbolPosition, +} from './symbolPosition'; export * from './types'; export * from './utils'; diff --git a/superset-frontend/packages/superset-ui-core/src/currency-format/symbolPosition.ts b/superset-frontend/packages/superset-ui-core/src/currency-format/symbolPosition.ts new file mode 100644 index 00000000000..4be5a4c588d --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/src/currency-format/symbolPosition.ts @@ -0,0 +1,93 @@ +/** + * 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 { getCurrencyLocale } from './currencyLocale'; + +export type SymbolPosition = 'prefix' | 'suffix'; + +const NUMERIC_PART_TYPES = new Set([ + 'integer', + 'group', + 'decimal', + 'fraction', +]); + +/** + * Memoize resolved positions by `(locale, currencyCode)`. `format` runs on a + * hot per-value path (every currency cell of every chart), so avoid rebuilding + * an `Intl.NumberFormat` and re-parsing the locale for repeated values. + */ +const positionCache = new Map(); + +/** + * Resolve where the currency symbol should be placed relative to the value. + * + * An explicit `prefix`/`suffix` is always honored. When the position is unset, + * it is derived from the locale's own convention for that currency via + * `Intl.NumberFormat` (e.g. `$1` in `en-US` is a prefix, `1 €` in `fr-FR` is a + * suffix). Unknown currency codes fall back to `prefix`, the most common + * convention worldwide. + */ +export function resolveSymbolPosition( + currencyCode: string | undefined, + symbolPosition?: string, + locale: string = getCurrencyLocale(), +): SymbolPosition { + if (symbolPosition === 'prefix' || symbolPosition === 'suffix') { + return symbolPosition; + } + + if (currencyCode) { + const cacheKey = `${locale}|${currencyCode}`; + const cached = positionCache.get(cacheKey); + if (cached) { + return cached; + } + try { + const parts = new Intl.NumberFormat(locale, { + style: 'currency', + currency: currencyCode, + }).formatToParts(1); + const currencyIndex = parts.findIndex(part => part.type === 'currency'); + const valueIndex = parts.findIndex(part => + NUMERIC_PART_TYPES.has(part.type), + ); + if (currencyIndex !== -1 && valueIndex !== -1) { + const position = currencyIndex < valueIndex ? 'prefix' : 'suffix'; + positionCache.set(cacheKey, position); + return position; + } + } catch { + // Unknown currency or locale — fall back to the default below. + } + } + + return 'prefix'; +} + +/** + * Combine a symbol and a formatted value according to the resolved position. + */ +export function formatWithSymbolPosition( + symbol: string, + value: string, + position: SymbolPosition, +): string { + return position === 'prefix' ? `${symbol} ${value}` : `${value} ${symbol}`; +} diff --git a/superset-frontend/packages/superset-ui-core/src/query/api/legacy/getDatasourceMetadata.ts b/superset-frontend/packages/superset-ui-core/src/query/api/legacy/getDatasourceMetadata.ts index 344d5891cae..c4a5c0f26fb 100644 --- a/superset-frontend/packages/superset-ui-core/src/query/api/legacy/getDatasourceMetadata.ts +++ b/superset-frontend/packages/superset-ui-core/src/query/api/legacy/getDatasourceMetadata.ts @@ -32,7 +32,7 @@ export default function getDatasourceMetadata({ }: Params) { return client .get({ - endpoint: `/superset/fetch_datasource_metadata?datasourceKey=${datasourceKey}`, + endpoint: `/fetch_datasource_metadata?datasourceKey=${datasourceKey}`, ...requestConfig, }) .then(response => response.json as Datasource); diff --git a/superset-frontend/packages/superset-ui-core/src/query/types/Dashboard.ts b/superset-frontend/packages/superset-ui-core/src/query/types/Dashboard.ts index c8c9c4a3775..d1de2b70b06 100644 --- a/superset-frontend/packages/superset-ui-core/src/query/types/Dashboard.ts +++ b/superset-frontend/packages/superset-ui-core/src/query/types/Dashboard.ts @@ -106,6 +106,7 @@ export type ChartCustomization = { }; description?: string; removed?: boolean; + time_grains?: string[]; }; export type ChartCustomizationDivider = Partial< diff --git a/superset-frontend/packages/superset-ui-core/src/time-comparison/fetchTimeRange.ts b/superset-frontend/packages/superset-ui-core/src/time-comparison/fetchTimeRange.ts index 4389f271001..638c7ec719a 100644 --- a/superset-frontend/packages/superset-ui-core/src/time-comparison/fetchTimeRange.ts +++ b/superset-frontend/packages/superset-ui-core/src/time-comparison/fetchTimeRange.ts @@ -103,10 +103,16 @@ export const fetchTimeRange = async ( ), ), }; - } catch (response) { + } catch (caught) { + // Forward-compat: TS 6.0 types caught values as `unknown`; cast to the + // shape getClientErrorObject accepts and narrow for statusText access. + const response = caught as Parameters[0]; const clientError = await getClientErrorObject(response); return { - error: clientError.message || clientError.error || response.statusText, + error: + clientError.message || + clientError.error || + (response as { statusText?: string }).statusText, }; } }; diff --git a/superset-frontend/packages/superset-ui-core/src/utils/lruCache.ts b/superset-frontend/packages/superset-ui-core/src/utils/lruCache.ts index e92005986aa..7ac417e24df 100644 --- a/superset-frontend/packages/superset-ui-core/src/utils/lruCache.ts +++ b/superset-frontend/packages/superset-ui-core/src/utils/lruCache.ts @@ -55,7 +55,12 @@ class LRUCache { throw new TypeError('The LRUCache key must be string.'); } if (this.cache.size >= this.capacity) { - this.cache.delete(this.cache.keys().next().value); + // Forward-compat: TS 6.0 types IteratorResult.value as `string | undefined` + // when not explicitly checked; guard before passing to Map#delete. + const oldestKey = this.cache.keys().next().value; + if (oldestKey !== undefined) { + this.cache.delete(oldestKey); + } } this.cache.set(key, value); } diff --git a/superset-frontend/packages/superset-ui-core/src/utils/mapStyles.test.ts b/superset-frontend/packages/superset-ui-core/src/utils/mapStyles.test.ts index 5313a9c1c2e..170c04370ff 100644 --- a/superset-frontend/packages/superset-ui-core/src/utils/mapStyles.test.ts +++ b/superset-frontend/packages/superset-ui-core/src/utils/mapStyles.test.ts @@ -283,6 +283,22 @@ test('lookalike OpenStreetMap hostnames do not receive OSM attribution', () => { } }); +test('relative raster tile templates do not receive OSM attribution', () => { + // A host-relative template cannot be parsed by `new URL`, so the OSM + // hostname check must fall through to "not OSM" rather than throw. + const relativeTileUrl = '/local-tiles/{z}/{x}/{y}.png'; + const style = resolveMapStyle( + `tile://${relativeTileUrl}`, + 'default-style.json', + ); + + expect(typeof style).toBe('object'); + if (typeof style !== 'string') { + expect(style.sources['osm-raster-tiles'].tiles).toEqual([relativeTileUrl]); + expect(style.sources['osm-raster-tiles']).not.toHaveProperty('attribution'); + } +}); + test('style JSON URLs pass through without raster wrapping', () => { const styleUrl = 'https://example.com/styles/custom-style.json'; diff --git a/superset-frontend/packages/superset-ui-core/src/utils/mapStyles.ts b/superset-frontend/packages/superset-ui-core/src/utils/mapStyles.ts index 56c4ca1714c..901cb00a9fd 100644 --- a/superset-frontend/packages/superset-ui-core/src/utils/mapStyles.ts +++ b/superset-frontend/packages/superset-ui-core/src/utils/mapStyles.ts @@ -88,6 +88,9 @@ type BootstrapData = { }; export function getBootstrapDataFromDocument(): unknown { + /* istanbul ignore if -- a missing document only occurs in SSR/worker + contexts, which Jest cannot simulate: jsdom pins `document` as a + non-configurable global */ if (typeof document === 'undefined') { return undefined; } diff --git a/superset-frontend/packages/superset-ui-core/test/chart/clients/ChartClient.test.ts b/superset-frontend/packages/superset-ui-core/test/chart/clients/ChartClient.test.ts index 5bc0b6a9f1a..8d11b98d175 100644 --- a/superset-frontend/packages/superset-ui-core/test/chart/clients/ChartClient.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/chart/clients/ChartClient.test.ts @@ -176,7 +176,9 @@ describe('ChartClient', () => { Promise.reject(new Error('Unexpected all to v1 API')), ); - fetchMock.post('glob:*/superset/explore_json/', { + // post `Superset.route_base = ""`, the legacy endpoint + // collapsed from `/superset/explore_json/` to `/explore_json/`. + fetchMock.post('glob:*/explore_json/', { field1: 'abc', field2: 'def', }); @@ -198,13 +200,10 @@ describe('ChartClient', () => { describe('.loadDatasource(datasourceKey, options)', () => { test('fetches datasource', () => { - fetchMock.get( - 'glob:*/superset/fetch_datasource_metadata?datasourceKey=1__table', - { - field1: 'abc', - field2: 'def', - }, - ); + fetchMock.get('glob:*/fetch_datasource_metadata?datasourceKey=1__table', { + field1: 'abc', + field2: 'def', + }); return expect(chartClient.loadDatasource('1__table')).resolves.toEqual({ field1: 'abc', @@ -264,13 +263,10 @@ describe('ChartClient', () => { color: 'living-coral', }); - fetchMock.get( - 'glob:*/superset/fetch_datasource_metadata?datasourceKey=1__table', - { - name: 'transactions', - schema: 'staging', - }, - ); + fetchMock.get('glob:*/fetch_datasource_metadata?datasourceKey=1__table', { + name: 'transactions', + schema: 'staging', + }); fetchMock.post('glob:*/api/v1/chart/data', { lorem: 'ipsum', diff --git a/superset-frontend/packages/superset-ui-core/test/connection/SupersetClientAppRootContract.test.ts b/superset-frontend/packages/superset-ui-core/test/connection/SupersetClientAppRootContract.test.ts new file mode 100644 index 00000000000..ff8cf65863a --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/test/connection/SupersetClientAppRootContract.test.ts @@ -0,0 +1,113 @@ +/** + * 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 { SupersetClientClass } from '@superset-ui/core'; + +// SupersetClient is expected to apply the configured appRoot exactly once. +// Callers must pass router-relative endpoints; pre-prefixing causes the +// double-prefix bug documented below. + +describe('SupersetClient applies the application root exactly once', () => { + const buildClient = () => + new SupersetClientClass({ + protocol: 'https:', + host: 'config_host', + appRoot: '/superset', + }); + + test('endpoint without leading slash is concatenated correctly', () => { + expect(buildClient().getUrl({ endpoint: 'api/v1/chart' })).toBe( + 'https://config_host/superset/api/v1/chart', + ); + }); + + test('endpoint with leading slash is normalised to a single root segment', () => { + expect(buildClient().getUrl({ endpoint: '/api/v1/chart' })).toBe( + 'https://config_host/superset/api/v1/chart', + ); + }); + + // A trailing slash on the configured appRoot is stripped at construction + // (SupersetClientClass `appRoot.replace(/\/$/, '')`). Without it, a root of + // '/superset/' produced 'https://host/superset//foo', and the dedupe block's + // `startsWith('/superset//')` check silently failed to dedupe a pre-prefixed + // endpoint. This pins both behaviours against regression. + test('trailing-slash appRoot is normalised to a single root segment', () => { + const client = new SupersetClientClass({ + protocol: 'https:', + host: 'config_host', + appRoot: '/superset/', + }); + expect(client.getUrl({ endpoint: '/api/v1/chart' })).toBe( + 'https://config_host/superset/api/v1/chart', + ); + // and a pre-prefixed endpoint is still deduped, not doubled + expect(client.getUrl({ endpoint: '/superset/api/v1/chart' })).toBe( + 'https://config_host/superset/api/v1/chart', + ); + }); + + // Runtime safety net: if a caller pre-prefixes the endpoint (e.g. by wrapping + // with ensureAppRoot before calling), getUrl strips the duplicate. The L2 + // static invariant still flags the pattern at the call site — this guards + // against the bug reaching production if the static check is bypassed. + test('dedupes a leading application-root segment from a pre-prefixed endpoint', () => { + expect(buildClient().getUrl({ endpoint: '/superset/api/v1/chart' })).toBe( + 'https://config_host/superset/api/v1/chart', + ); + }); + + // Single-pass strip preserves a legitimate `/superset/superset/` + // route. Backend-supplied router-relative URLs are stripped of the root at + // the call sites that surface them (via `normalizeBackendUrlString`) before + // any re-prefixing helper sees them, so a doubled leading segment reaching + // `getUrl` is a real route, not a double-prefix bug. This pin guards against + // silent regression to a greedy strip. + test('strips exactly one application-root segment (single-pass)', () => { + expect( + buildClient().getUrl({ endpoint: '/superset/superset/api/v1/chart' }), + ).toBe('https://config_host/superset/superset/api/v1/chart'); + expect( + buildClient().getUrl({ + endpoint: '/superset/superset/superset/api/v1/chart', + }), + ).toBe('https://config_host/superset/superset/superset/api/v1/chart'); + }); + + test('dedupe is segment-boundary aware — `/supersetfoo` is not a prefix match', () => { + expect(buildClient().getUrl({ endpoint: '/supersetfoo/x' })).toBe( + 'https://config_host/superset/supersetfoo/x', + ); + }); + + test('dedupes the bare application root to an empty endpoint', () => { + expect(buildClient().getUrl({ endpoint: '/superset' })).toBe( + 'https://config_host/superset/', + ); + }); + + test('empty application root produces no prefix segment', () => { + const client = new SupersetClientClass({ + protocol: 'https:', + host: 'config_host', + }); + expect(client.getUrl({ endpoint: '/api/v1/chart' })).toBe( + 'https://config_host/api/v1/chart', + ); + }); +}); diff --git a/superset-frontend/packages/superset-ui-core/test/connection/normalizeBackendUrls.test.ts b/superset-frontend/packages/superset-ui-core/test/connection/normalizeBackendUrls.test.ts new file mode 100644 index 00000000000..2100cbdedd7 --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/test/connection/normalizeBackendUrls.test.ts @@ -0,0 +1,89 @@ +/** + * 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 { normalizeBackendUrlString } from '../../src/connection/normalizeBackendUrls'; + +const PREFIX = '/superset'; + +describe('normalizeBackendUrlString', () => { + test('strips application root from a router-relative path', () => { + expect( + normalizeBackendUrlString('/superset/explore/?slice_id=1', { + applicationRoot: PREFIX, + }), + ).toBe('/explore/?slice_id=1'); + }); + + test('strips a value that equals the application root exactly', () => { + expect( + normalizeBackendUrlString('/superset', { applicationRoot: PREFIX }), + ).toBe('/'); + }); + + test('tolerates a trailing slash on applicationRoot', () => { + expect( + normalizeBackendUrlString('/superset/foo', { + applicationRoot: '/superset/', + }), + ).toBe('/foo'); + }); + + // The negative cases below prove the helper is conservative: it doesn't + // mutate external URLs or path segments that merely share text with the root. + test('passes absolute URLs through unchanged', () => { + expect( + normalizeBackendUrlString('https://external.example.com/superset/foo', { + applicationRoot: PREFIX, + }), + ).toBe('https://external.example.com/superset/foo'); + }); + + test('passes protocol-relative URLs through unchanged', () => { + expect( + normalizeBackendUrlString('//cdn.example.com/superset/foo', { + applicationRoot: PREFIX, + }), + ).toBe('//cdn.example.com/superset/foo'); + }); + + test('does not strip a similar-but-different prefix segment', () => { + // /superset-public/... shares text with /superset but is a different path + // segment. Only /superset followed by / or end-of-string counts. + expect( + normalizeBackendUrlString('/superset-public/explore/?slice_id=1', { + applicationRoot: PREFIX, + }), + ).toBe('/superset-public/explore/?slice_id=1'); + }); + + test('is a no-op when application root is empty', () => { + expect( + normalizeBackendUrlString('/superset/explore/?slice_id=1', { + applicationRoot: '', + }), + ).toBe('/superset/explore/?slice_id=1'); + }); + + test('is idempotent: normalize(normalize(x)) === normalize(x)', () => { + const once = normalizeBackendUrlString('/superset/explore/?id=1', { + applicationRoot: PREFIX, + }); + const twice = normalizeBackendUrlString(once, { applicationRoot: PREFIX }); + expect(twice).toBe(once); + }); +}); diff --git a/superset-frontend/packages/superset-ui-core/test/currency-format/CurrencyFormatter.test.ts b/superset-frontend/packages/superset-ui-core/test/currency-format/CurrencyFormatter.test.ts index 1b27d0d0d7a..ff9350a3912 100644 --- a/superset-frontend/packages/superset-ui-core/test/currency-format/CurrencyFormatter.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/currency-format/CurrencyFormatter.test.ts @@ -21,8 +21,14 @@ import { CurrencyFormatter, getCurrencySymbol, NumberFormats, + setCurrencyLocale, } from '@superset-ui/core'; +afterEach(() => { + // Guard against any test mutating the shared currency locale singleton. + setCurrencyLocale('en-US'); +}); + test('getCurrencySymbol', () => { expect( getCurrencySymbol({ symbol: 'PLN', symbolPosition: 'prefix' }), @@ -132,7 +138,9 @@ test('CurrencyFormatter:format', () => { // @ts-expect-error currency: { symbol: 'USD' }, }); - expect(currencyFormatterWithoutPosition(VALUE)).toEqual('56.1M $'); + // With no explicit position, placement follows the locale convention. + // USD is a prefix in the default en-US locale. + expect(currencyFormatterWithoutPosition(VALUE)).toEqual('$ 56.1M'); // @ts-expect-error const currencyFormatterWithoutCurrency = new CurrencyFormatter({}); @@ -200,17 +208,29 @@ test('CurrencyFormatter AUTO mode uses suffix position from row context', () => expect(result).toMatch(/1,000\.00.*€/); }); -test('CurrencyFormatter AUTO mode uses default suffix when symbolPosition is unknown', () => { - const formatter = new CurrencyFormatter({ +test('CurrencyFormatter AUTO mode resolves position from locale when symbolPosition is unset', () => { + // Default en-US locale: EUR symbol is a prefix. + const enFormatter = new CurrencyFormatter({ // @ts-expect-error currency: { symbol: 'AUTO' }, d3Format: ',.2f', }); const row = { currency: 'EUR' }; - const result = formatter.format(1000, row, 'currency'); - expect(result).toContain('€'); - expect(result).toMatch(/1,000\.00.*€/); + const enResult = enFormatter.format(1000, row, 'currency'); + expect(enResult).toContain('€'); + expect(enResult).toMatch(/€.*1,000\.00/); + + // fr-FR locale: EUR symbol is a suffix. + const frFormatter = new CurrencyFormatter({ + // @ts-expect-error + currency: { symbol: 'AUTO' }, + d3Format: ',.2f', + locale: 'fr-FR', + }); + const frResult = frFormatter.format(1000, row, 'currency'); + expect(frResult).toContain('€'); + expect(frResult).toMatch(/1,000\.00.*€/); }); test('CurrencyFormatter AUTO mode returns plain value when row currency is not a string (line 52)', () => { @@ -265,3 +285,23 @@ test('CurrencyFormatter AUTO mode falls back to plain value when getCurrencySymb expect(result).toBe('1,000.00'); }); + +test('CurrencyFormatter static mode returns plain value when getCurrencySymbol returns undefined', () => { + const formatter = new CurrencyFormatter({ + currency: { symbol: 'USD', symbolPosition: 'prefix' }, + d3Format: ',.2f', + }); + + const OrigNumberFormat = Intl.NumberFormat; + // formatToParts without a 'currency' entry → getCurrencySymbol returns + // undefined, exercising the `if (!symbol)` guard in the static branch. + Intl.NumberFormat = jest.fn().mockImplementation(() => ({ + formatToParts: () => [{ type: 'integer', value: '1' }], + })) as unknown as typeof Intl.NumberFormat; + + const result = formatter.format(1000); + + Intl.NumberFormat = OrigNumberFormat; + + expect(result).toBe('1,000.00'); +}); diff --git a/superset-frontend/packages/superset-ui-core/test/currency-format/currencyLocale.test.ts b/superset-frontend/packages/superset-ui-core/test/currency-format/currencyLocale.test.ts new file mode 100644 index 00000000000..b93a95ad9da --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/test/currency-format/currencyLocale.test.ts @@ -0,0 +1,65 @@ +/* + * 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 { + getCurrencyLocale, + setCurrencyLocale, + resolveSymbolPosition, +} from '@superset-ui/core'; + +afterEach(() => { + // Restore the default so other tests are not affected by the global locale. + setCurrencyLocale('en-US'); +}); + +test('currency locale defaults to en-US', () => { + expect(getCurrencyLocale()).toEqual('en-US'); +}); + +test('setCurrencyLocale updates the locale used to resolve unset positions', () => { + setCurrencyLocale('fr-FR'); + expect(getCurrencyLocale()).toEqual('fr-FR'); + // EUR is a suffix in fr-FR. + expect(resolveSymbolPosition('EUR')).toEqual('suffix'); +}); + +test('setCurrencyLocale ignores empty values', () => { + setCurrencyLocale('de-DE'); + setCurrencyLocale(undefined); + setCurrencyLocale(''); + expect(getCurrencyLocale()).toEqual('de-DE'); +}); + +test('setCurrencyLocale canonicalizes underscore-formatted locales to BCP-47', () => { + // Superset bootstrap can emit underscore tags like `pt_BR`/`zh_TW`. + setCurrencyLocale('pt_BR'); + expect(getCurrencyLocale()).toEqual('pt-BR'); + // BRL is a prefix in pt-BR; the placement must resolve instead of throwing + // and falling back. + expect(resolveSymbolPosition('BRL')).toEqual('prefix'); + + setCurrencyLocale('zh_TW'); + expect(getCurrencyLocale()).toEqual('zh-TW'); +}); + +test('setCurrencyLocale keeps the previous locale for invalid tags', () => { + setCurrencyLocale('fr-FR'); + setCurrencyLocale('not a locale!'); + expect(getCurrencyLocale()).toEqual('fr-FR'); +}); diff --git a/superset-frontend/packages/superset-ui-core/test/currency-format/symbolPosition.test.ts b/superset-frontend/packages/superset-ui-core/test/currency-format/symbolPosition.test.ts new file mode 100644 index 00000000000..b30846b7b80 --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/test/currency-format/symbolPosition.test.ts @@ -0,0 +1,74 @@ +/* + * 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 { + resolveSymbolPosition, + formatWithSymbolPosition, +} from '@superset-ui/core'; + +test('resolveSymbolPosition honors an explicit position regardless of locale', () => { + expect(resolveSymbolPosition('EUR', 'prefix', 'fr-FR')).toEqual('prefix'); + expect(resolveSymbolPosition('USD', 'suffix', 'en-US')).toEqual('suffix'); +}); + +test('resolveSymbolPosition derives the position from the locale when unset', () => { + // en-US places the symbol before the value for these currencies. + expect(resolveSymbolPosition('USD', undefined, 'en-US')).toEqual('prefix'); + expect(resolveSymbolPosition('GBP', undefined, 'en-US')).toEqual('prefix'); + expect(resolveSymbolPosition('EUR', undefined, 'en-US')).toEqual('prefix'); + + // Eurozone locales place the EUR symbol after the value. + expect(resolveSymbolPosition('EUR', undefined, 'fr-FR')).toEqual('suffix'); + expect(resolveSymbolPosition('EUR', undefined, 'de-DE')).toEqual('suffix'); +}); + +test('resolveSymbolPosition returns the same result on repeated calls (cached)', () => { + // The second call hits the memoized (locale, currencyCode) entry. + expect(resolveSymbolPosition('EUR', undefined, 'fr-FR')).toEqual('suffix'); + expect(resolveSymbolPosition('EUR', undefined, 'fr-FR')).toEqual('suffix'); +}); + +test('resolveSymbolPosition falls back to prefix for unknown currencies', () => { + expect(resolveSymbolPosition('INVALID_CODE', undefined, 'en-US')).toEqual( + 'prefix', + ); + expect(resolveSymbolPosition(undefined, undefined, 'en-US')).toEqual( + 'prefix', + ); +}); + +test('resolveSymbolPosition falls back to prefix when locale parts lack a currency', () => { + const OrigNumberFormat = Intl.NumberFormat; + // formatToParts without a 'currency' part → currencyIndex is -1, so the + // position cannot be derived and the default prefix is returned. Use a + // locale/currency pair not exercised elsewhere so the memoization cache + // does not short-circuit this call. + Intl.NumberFormat = jest.fn().mockImplementation(() => ({ + formatToParts: () => [{ type: 'integer', value: '1' }], + })) as unknown as typeof Intl.NumberFormat; + + expect(resolveSymbolPosition('USD', undefined, 'zz-mock')).toEqual('prefix'); + + Intl.NumberFormat = OrigNumberFormat; +}); + +test('formatWithSymbolPosition places the symbol according to the position', () => { + expect(formatWithSymbolPosition('$', '1,000', 'prefix')).toEqual('$ 1,000'); + expect(formatWithSymbolPosition('€', '1,000', 'suffix')).toEqual('1,000 €'); +}); diff --git a/superset-frontend/packages/superset-ui-core/test/query/api/legacy/getDatasourceMetadata.test.ts b/superset-frontend/packages/superset-ui-core/test/query/api/legacy/getDatasourceMetadata.test.ts index be94bfa9f68..5ae788d10ad 100644 --- a/superset-frontend/packages/superset-ui-core/test/query/api/legacy/getDatasourceMetadata.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/query/api/legacy/getDatasourceMetadata.test.ts @@ -35,8 +35,10 @@ describe('getFormData()', () => { field2: 'def', }; + // post-`route_base=""`, the legacy endpoint collapsed + // from `/superset/fetch_datasource_metadata` to `/fetch_datasource_metadata`. fetchMock.get( - 'glob:*/superset/fetch_datasource_metadata?datasourceKey=1__table', + 'glob:*/fetch_datasource_metadata?datasourceKey=1__table', mockData, ); diff --git a/superset-frontend/packages/superset-ui-core/types/assets.d.ts b/superset-frontend/packages/superset-ui-core/types/assets.d.ts index b95944d3dba..b75c58d40f5 100644 --- a/superset-frontend/packages/superset-ui-core/types/assets.d.ts +++ b/superset-frontend/packages/superset-ui-core/types/assets.d.ts @@ -21,3 +21,4 @@ declare module '*.svg'; declare module '*.png'; declare module '*.jpg'; declare module '*.jpeg'; +declare module '*.css'; diff --git a/superset-frontend/playwright/pages/DashboardPage.ts b/superset-frontend/playwright/pages/DashboardPage.ts index a61ff3415c1..9b81d493d94 100644 --- a/superset-frontend/playwright/pages/DashboardPage.ts +++ b/superset-frontend/playwright/pages/DashboardPage.ts @@ -44,7 +44,7 @@ export class DashboardPage { * @param slug - The dashboard slug (e.g., 'world_health') */ async gotoBySlug(slug: string): Promise { - await gotoWithRetry(this.page, `superset/dashboard/${slug}/`); + await gotoWithRetry(this.page, `dashboard/${slug}/`); } /** @@ -52,7 +52,7 @@ export class DashboardPage { * @param id - The dashboard ID */ async gotoById(id: number): Promise { - await gotoWithRetry(this.page, `superset/dashboard/${id}/`); + await gotoWithRetry(this.page, `dashboard/${id}/`); } /** diff --git a/superset-frontend/playwright/tests/dataset/create-dataset.spec.ts b/superset-frontend/playwright/tests/dataset/create-dataset.spec.ts index 7dcfa81dd61..52368478d5f 100644 --- a/superset-frontend/playwright/tests/dataset/create-dataset.spec.ts +++ b/superset-frontend/playwright/tests/dataset/create-dataset.spec.ts @@ -29,6 +29,7 @@ import { waitForPost } from '../../helpers/api/intercepts'; import { expectStatusOneOf } from '../../helpers/api/assertions'; import { getDatabaseByName } from '../../helpers/api/database'; import { apiExecuteSql } from '../../helpers/api/sqllab'; +import { TIMEOUT } from '../../utils/constants'; interface ExamplesSetupResult { tableName: string; @@ -116,7 +117,7 @@ async function dropTempTable( // Uses test.describe only because Playwright's serial mode API requires it - // (Deviation from "avoid describe" guideline is necessary for functional reasons) test.describe('create dataset wizard', () => { - test.describe.configure({ mode: 'serial' }); + test.describe.configure({ mode: 'serial', timeout: TIMEOUT.SLOW_TEST }); test('should create a dataset via wizard', async ({ page, testAssets }) => { const { tableName, dbId, createDatasetPage } = await setupExamplesDataset( diff --git a/superset-frontend/playwright/utils/urls.ts b/superset-frontend/playwright/utils/urls.ts index ed78c0b1c53..e35eddd2249 100644 --- a/superset-frontend/playwright/utils/urls.ts +++ b/superset-frontend/playwright/utils/urls.ts @@ -35,5 +35,5 @@ export const URL = { LOGIN: 'login/', SAVED_QUERIES_LIST: 'savedqueryview/list/', SQLLAB: 'sqllab', - WELCOME: 'superset/welcome/', + WELCOME: 'welcome/', } as const; diff --git a/superset-frontend/plugins/plugin-chart-cartodiagram/src/plugin/index.ts b/superset-frontend/plugins/plugin-chart-cartodiagram/src/plugin/index.ts index 7bf99854318..6f3cd1ff429 100644 --- a/superset-frontend/plugins/plugin-chart-cartodiagram/src/plugin/index.ts +++ b/superset-frontend/plugins/plugin-chart-cartodiagram/src/plugin/index.ts @@ -33,8 +33,9 @@ import { getLayerConfig } from '../util/controlPanelUtil'; export default class CartodiagramPlugin extends ChartPlugin { constructor(opts: CartodiagramPluginConstructorOpts) { const metadata = new ChartMetadata({ - description: + description: t( 'Display charts on a map. For using this plugin, users first have to create any other chart that can then be placed on the map.', + ), name: t('Cartodiagram'), thumbnail, thumbnailDark, diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/index.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/index.ts index 77872ac1bc6..fc4555c554d 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/index.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/index.ts @@ -28,8 +28,9 @@ export default class PopKPIPlugin extends ChartPlugin { constructor() { const metadata = new ChartMetadata({ category: t('KPI'), - description: + description: t( 'Showcases a metric along with a comparison of value, change, and percent change for a selected time period.', + ), name: t('Big Number with Time Period Comparison'), tags: [ t('Comparison'), diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/transformProps.ts index 23708f7a41c..fa57d2a8b71 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/transformProps.ts @@ -282,6 +282,11 @@ export default function transformProps( ? formatTime : numberFormatter; + const lineWidth = 2; + // Pad the grid by half the stroke width so the trendline isn't clipped at + // the edges of the chart area (the stroke extends beyond the data point). + const strokePad = lineWidth / 2; + const echartOptions: EChartsCoreOption = trendLineData ? { series: [ @@ -293,6 +298,9 @@ export default function transformProps( symbolSize: 10, showSymbol: false, color: mainColor ?? BRAND_COLOR, + lineStyle: { + width: lineWidth, + }, areaStyle: { color: new graphic.LinearGradient(0, 0, 0, 1, [ { @@ -346,10 +354,10 @@ export default function transformProps( top: TIMESERIES_CONSTANTS.gridOffsetTop, } : { - bottom: 0, - left: 0, - right: 0, - top: 0, + bottom: strokePad, + left: strokePad, + right: strokePad, + top: strokePad, }, tooltip: { ...getDefaultTooltip(refs), diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/transformProps.test.ts index 08912e4cbfb..7bd49f2c36f 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/transformProps.test.ts @@ -17,6 +17,7 @@ * under the License. */ import { DatasourceType, TimeGranularity, VizType } from '@superset-ui/core'; +import type { LineSeriesOption } from 'echarts'; import { supersetTheme } from '@apache-superset/core/theme'; import transformProps from '../../src/BigNumber/BigNumberWithTrendline/transformProps'; import { @@ -269,11 +270,18 @@ describe('BigNumberWithTrendline', () => { }, }); + const series = ( + transformed.echartOptions?.series as LineSeriesOption[] + )?.[0]; + const lineWidth = series?.lineStyle?.width; + expect(lineWidth).toBe(2); + + const expectedPad = (lineWidth as number) / 2; expect(transformed.echartOptions?.grid).toEqual({ - bottom: 0, - left: 0, - right: 0, - top: 0, + bottom: expectedPad, + left: expectedPad, + right: expectedPad, + top: expectedPad, }); }); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts index f4c0ecc4ed3..1c2a09b5d50 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts @@ -1573,6 +1573,47 @@ test('xAxisForceCategorical forces Category axis regardless of Numeric coltype', expect(xAxis.triggerEvent).toBe(true); }); +test('temporal x coltype forced categorical yields a Category axis with date labels', () => { + // Issue #28204: with a temporal x-axis (e.g. weekly grain) the default Time + // scale places ticks at "nice" intervals that don't line up with the buckets. + // Forcing categorical maps each bucket to a discrete, tick-aligned category + // while still formatting the labels as dates rather than raw timestamps. + const ts1 = 1745784000000; + const ts2 = 1745870400000; + const chartProps = createTestChartProps({ + formData: { + metrics: ['metric'], + granularity_sqla: 'ds', + x_axis: '__timestamp', + xAxisForceCategorical: true, + }, + queriesData: [ + createTestQueryData( + [ + { __timestamp: ts1, metric: 10 }, + { __timestamp: ts2, metric: 20 }, + ], + { + colnames: ['__timestamp', 'metric'], + coltypes: [GenericDataType.Temporal, GenericDataType.Numeric], + }, + ), + ], + }); + + const { echartOptions } = transformProps(chartProps); + const xAxis = echartOptions.xAxis as { + type: string; + axisLabel: { formatter: (v: Date) => string }; + }; + + expect(xAxis.type).toBe(AxisType.Category); + const label = xAxis.axisLabel.formatter(new Date(ts1)); + expect(typeof label).toBe('string'); + expect(label).not.toMatch(/NaN/); + expect(label).not.toBe(String(ts1)); +}); + test('temporal x coltype wires the time formatter and Time axis', () => { // Regression guard: the happy path for time-series charts. Ensures that // Temporal coltype keeps routing through the TimeFormatter so a refactor diff --git a/superset-frontend/plugins/plugin-chart-point-cluster-map/src/components/ScatterPlotOverlay.tsx b/superset-frontend/plugins/plugin-chart-point-cluster-map/src/components/ScatterPlotOverlay.tsx index 5c94a69b514..b14da848e45 100644 --- a/superset-frontend/plugins/plugin-chart-point-cluster-map/src/components/ScatterPlotOverlay.tsx +++ b/superset-frontend/plugins/plugin-chart-point-cluster-map/src/components/ScatterPlotOverlay.tsx @@ -26,6 +26,9 @@ import luminanceFromRGB from '../utils/luminanceFromRGB'; export const MIN_CLUSTER_RADIUS_RATIO = 1 / 6; export const MAX_POINT_RADIUS_RATIO = 1 / 3; +export const isValidCanvasRadius = (value: number) => + Number.isFinite(value) && value > 0; + interface GeoJSONLocation { geometry: { coordinates: [number, number]; @@ -352,8 +355,11 @@ function ScatterPlotOverlay({ : String(pointMetric); } - if (!pointRadius) { + if (!isValidCanvasRadius(pointRadius)) { pointRadius = defaultRadius; + if (pointMetric === null) { + pointLabel = undefined; + } } ctx.arc( diff --git a/superset-frontend/plugins/plugin-chart-point-cluster-map/test/ScatterPlotOverlay.test.tsx b/superset-frontend/plugins/plugin-chart-point-cluster-map/test/ScatterPlotOverlay.test.tsx index cafa91aaeba..634fc8e208b 100644 --- a/superset-frontend/plugins/plugin-chart-point-cluster-map/test/ScatterPlotOverlay.test.tsx +++ b/superset-frontend/plugins/plugin-chart-point-cluster-map/test/ScatterPlotOverlay.test.tsx @@ -18,8 +18,8 @@ */ import { render } from '@testing-library/react'; -import ScatterPlotOverlay from '../src/components/ScatterPlotOverlay'; -import { +import ScatterPlotOverlay, { + isValidCanvasRadius, MIN_CLUSTER_RADIUS_RATIO, MAX_POINT_RADIUS_RATIO, } from '../src/components/ScatterPlotOverlay'; @@ -158,6 +158,18 @@ const MIN_VISIBLE_POINT_RADIUS = const MAX_VISIBLE_POINT_RADIUS = defaultProps.dotRadius * MAX_POINT_RADIUS_RATIO; +test.each([ + [1, true], + [0.1, true], + [0, false], + [-1, false], + [NaN, false], + [Infinity, false], + [-Infinity, false], +])('validates canvas radius value %p', (value, expected) => { + expect(isValidCanvasRadius(value)).toBe(expected); +}); + test('renders map with varying radius values in Pixels mode', () => { const locations = [ createLocation([100, 100], { radius: 10, cluster: false }), @@ -373,6 +385,42 @@ test('renders map with Miles mode', () => { }).not.toThrow(); }); +test.each(['Kilometers', 'Miles'])( + 'falls back to default radius for non-positive %s values', + pointRadiusUnit => { + const locations = [ + createLocation([100, 50], { radius: -5, cluster: false }), + createLocation([200, 50], { radius: 0, cluster: false }), + createLocation([300, 50], { radius: 10, cluster: false }), + ]; + + render( + , + ); + const redrawParams = triggerRedraw(); + + const arcCalls = redrawParams.ctx.arc.mock.calls; + + arcCalls.forEach(call => { + expect(Number.isFinite(call[2])).toBe(true); + expect(call[2]).toBeGreaterThan(0); + }); + expect(arcCalls[0][2]).toBe(MIN_VISIBLE_POINT_RADIUS); + expect(arcCalls[1][2]).toBe(MIN_VISIBLE_POINT_RADIUS); + expect(arcCalls[2][2]).toBeGreaterThan(MIN_VISIBLE_POINT_RADIUS); + + const expectedLabel = pointRadiusUnit === 'Miles' ? '10mi' : '10km'; + expect(redrawParams.ctx.fillText.mock.calls.map(call => call[0])).toEqual([ + expectedLabel, + ]); + }, +); + test('displays metric property labels on points', () => { const locations = [ createLocation([100, 100], { radius: 50, metric: 123.456, cluster: false }), diff --git a/superset-frontend/plugins/plugin-chart-table/src/DataTable/DataTable.tsx b/superset-frontend/plugins/plugin-chart-table/src/DataTable/DataTable.tsx index a40d91b0d7d..374609b6bb9 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/DataTable/DataTable.tsx +++ b/superset-frontend/plugins/plugin-chart-table/src/DataTable/DataTable.tsx @@ -56,6 +56,7 @@ import { PAGE_SIZE_OPTIONS } from '../consts'; import { sortAlphanumericCaseInsensitive } from './utils/sortAlphanumericCaseInsensitive'; import { SearchOption, SortByItem } from '../types'; import SearchSelectDropdown from './components/SearchSelectDropdown'; +import { SupersetTheme, css } from '@apache-superset/core/theme'; export interface DataTableProps extends TableOptions { tableClassName?: string; @@ -561,6 +562,9 @@ export default typedMemo(function DataTable({ align="center" justify="space-between" gap="middle" + css={(theme: SupersetTheme) => css` + font-size: ${theme.fontSizeSM}px; + `} > {hasPagination ? ( ({ /> ) : null} - {serverPagination && searchInput && ( - - {t('Search by')}: - - - )} {searchInput && ( - - searchInput={ - typeof searchInput === 'boolean' ? undefined : searchInput - } - preGlobalFilteredRows={preGlobalFilteredRows} - setGlobalFilter={ - manualSearch ? handleSearchChange : setGlobalFilter - } - filterValue={manualSearch ? initialSearchText : filterValue} - id={searchInputId} - serverPagination={!!serverPagination} - rowCount={rowCount} - /> + <> + {serverPagination && ( + + {t('Search by')} + + + )} + + searchInput={ + typeof searchInput === 'boolean' ? undefined : searchInput + } + preGlobalFilteredRows={preGlobalFilteredRows} + setGlobalFilter={ + manualSearch ? handleSearchChange : setGlobalFilter + } + filterValue={manualSearch ? initialSearchText : filterValue} + id={searchInputId} + serverPagination={!!serverPagination} + rowCount={rowCount} + /> + )} {renderTimeComparisonDropdown ? renderTimeComparisonDropdown() diff --git a/superset-frontend/plugins/plugin-chart-table/src/DataTable/components/SearchSelectDropdown.tsx b/superset-frontend/plugins/plugin-chart-table/src/DataTable/components/SearchSelectDropdown.tsx index e612742b1fa..a9f67fa5b50 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/DataTable/components/SearchSelectDropdown.tsx +++ b/superset-frontend/plugins/plugin-chart-table/src/DataTable/components/SearchSelectDropdown.tsx @@ -17,14 +17,9 @@ * under the License. */ /* eslint-disable import/no-extraneous-dependencies */ -import { styled } from '@apache-superset/core/theme'; -import { RawAntdSelect } from '@superset-ui/core/components'; +import { RawAntdSelect as Select } from '@superset-ui/core/components'; import { SearchOption } from '../../types'; - -const StyledSelect = styled(RawAntdSelect)` - width: 120px; - margin-right: 8px; -`; +import { SupersetTheme, css } from '@apache-superset/core/theme'; interface SearchSelectDropdownProps { /** The currently selected search column value */ @@ -41,10 +36,14 @@ function SearchSelectDropdown({ searchOptions, }: SearchSelectDropdownProps) { return ( - css` + width: ${theme.sizeUnit * 30}px; + `} + value={value ?? searchOptions?.[0]?.value} options={searchOptions} + size="small" onChange={onChange} /> ); diff --git a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx index ce533a13c51..159031e5caf 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx +++ b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx @@ -58,7 +58,7 @@ import { useTheme, SupersetTheme, } from '@apache-superset/core/theme'; -import { t, tn } from '@apache-superset/core/translation'; +import { t } from '@apache-superset/core/translation'; import { GenericDataType } from '@apache-superset/core/common'; import { Input, @@ -254,12 +254,13 @@ function SearchInput({ inputRef, }: SearchInputProps) { return ( - - {t('Search')} + + + {t('Select page size')} - {t('Show')}{' '} + {t('Entries per page')} id="pageSizeSelect" value={current} onChange={value => onChange(value)} size="small" css={(theme: SupersetTheme) => css` - width: ${theme.sizeUnit * 18}px; + width: ${theme.sizeUnit * 30}px; `} aria-label={t('Show entries per page')} > @@ -301,9 +302,8 @@ function SelectPageSize({ ); })} - {' '} - {t('entries per page')} - + + ); } @@ -1204,8 +1204,11 @@ export default function TableChart( onClick: emitCrossFilters && !valueRange && !isMetric ? () => { + const isFilterable = columnsMeta.find( + (cm: DataColumnMeta) => cm.key === key, + )?.isFilterable; // allow selecting text in a cell - if (!getSelectedText()) { + if (!getSelectedText() && isFilterable !== false) { toggleFilter(key, value); } } diff --git a/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts b/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts index a1bef459def..ee3ee09bab7 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts @@ -232,6 +232,9 @@ const processColumns = memoizeOne(function processColumns( const metricsSet = new Set(metrics); const percentMetricsSet = new Set(percentMetrics); const rawPercentMetricsSet = new Set(rawPercentMetrics); + const columnsByName = new Map( + (props.datasource.columns ?? []).map(col => [col.column_name, col]), + ); const columns: DataColumnMeta[] = (colnames || []) .filter( @@ -244,6 +247,7 @@ const processColumns = memoizeOne(function processColumns( const config = columnConfig[key] || {}; // for the purpose of presentation, only numeric values are treated as metrics // because users can also add things like `MAX(str_col)` as a metric. + const isFilterable = columnsByName.get(key)?.filterable; const isMetric = metricsSet.has(key) && isNumeric(key, records); const isPercentMetric = percentMetricsSet.has(key); const label = @@ -326,6 +330,7 @@ const processColumns = memoizeOne(function processColumns( isPercentMetric, formatter, config, + isFilterable, description, currencyCodeColumn, }; diff --git a/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx b/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx index e5b5d3278e3..f24c0a7a4d8 100644 --- a/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx +++ b/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx @@ -2106,7 +2106,7 @@ describe('plugin-chart-table', () => { await waitFor(() => { expect(screen.getByRole('textbox')).toHaveValue('Michael'); - expect(screen.getByLabelText('Search 0 records')).toHaveValue( + expect(screen.getByLabelText('Search records')).toHaveValue( 'Michael', ); }); @@ -2534,3 +2534,33 @@ test('sorts genuinely string columns alphanumerically', () => { const values = Array.from(cells).map(td => td.textContent); expect(values).toEqual(['apple', 'banana', 'cherry']); }); + +test('TableChart should NOT emit cross-filter when clicking a cell in a not-filterable column', () => { + const setDataMask = jest.fn(); + const props = transformProps({ + ...testData.basic, + datasource: { + ...testData.basic.datasource, + columns: [{ column_name: 'name', filterable: false } as any], + }, + hooks: { setDataMask }, + emitCrossFilters: true, + }); + render( + + + , + ); + + fireEvent.click(screen.getByText('Michael')); + + const crossFilterCall = setDataMask.mock.calls.find( + (call: any[]) => call[0]?.filterState?.filters, + ); + expect(crossFilterCall).toBeUndefined(); +}); diff --git a/superset-frontend/plugins/preset-chart-deckgl/package.json b/superset-frontend/plugins/preset-chart-deckgl/package.json index 75273b191a7..94c97819ea6 100644 --- a/superset-frontend/plugins/preset-chart-deckgl/package.json +++ b/superset-frontend/plugins/preset-chart-deckgl/package.json @@ -29,7 +29,7 @@ "@deck.gl/extensions": "~9.2.9", "@deck.gl/geo-layers": "~9.2.5", "@deck.gl/layers": "~9.2.5", - "@deck.gl/mapbox": "~9.3.4", + "@deck.gl/mapbox": "~9.3.5", "@deck.gl/mesh-layers": "~9.2.5", "@luma.gl/constants": "~9.2.5", "@luma.gl/core": "~9.2.5", diff --git a/superset-frontend/plugins/preset-chart-deckgl/src/CategoricalDeckGLContainer.test.tsx b/superset-frontend/plugins/preset-chart-deckgl/src/CategoricalDeckGLContainer.test.tsx new file mode 100644 index 00000000000..199f8a9be5a --- /dev/null +++ b/superset-frontend/plugins/preset-chart-deckgl/src/CategoricalDeckGLContainer.test.tsx @@ -0,0 +1,68 @@ +/** + * 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 { QueryFormData } from '@superset-ui/core'; +import { getCategories } from './CategoricalDeckGLContainer'; +import { addColorToFeatures } from './utils/addColor'; +import { COLOR_SCHEME_TYPES } from './utilities/utils'; + +// Record every (label, sliceId) pair the categorical color scale is asked to +// resolve, so we can assert the legend and point-color paths key the scale on +// the same slice id. +const scaleCalls: [string, number | undefined][] = []; +jest.mock('@superset-ui/core', () => { + const actual = jest.requireActual('@superset-ui/core'); + return { + ...actual, + CategoricalColorNamespace: { + ...actual.CategoricalColorNamespace, + getScale: () => (value: string, sliceId?: number) => { + scaleCalls.push([value, sliceId]); + return value === 'A' ? '#ff0000' : '#00ff00'; + }, + }, + }; +}); + +test('legend and point colors resolve from the same slice_id', () => { + const fd = { + datasource: '1__table', + viz_type: 'deck_scatter', + color_scheme_type: COLOR_SCHEME_TYPES.categorical_palette, + color_scheme: 'supersetColors', + dimension: 'category', + slice_id: 42, + color_picker: { r: 0, g: 0, b: 0, a: 1 }, + } as unknown as QueryFormData; + const data = [{ cat_color: 'A' }, { cat_color: 'B' }]; + + const categories = getCategories(fd, data); + const features = addColorToFeatures(data, fd); + + // Both the legend path (getCategories) and the point-color path + // (addColorToFeatures) key the color scale on the same slice id. + expect(scaleCalls.length).toBeGreaterThan(0); + scaleCalls.forEach(([, sliceId]) => { + expect(sliceId).toBe(42); + }); + + // The legend swatch for each category matches the resolved point color. + expect(categories.A.color).toEqual(features[0].color); + expect(categories.B.color).toEqual(features[1].color); + expect(features[0].color).not.toEqual(features[1].color); +}); diff --git a/superset-frontend/plugins/preset-chart-deckgl/src/CategoricalDeckGLContainer.tsx b/superset-frontend/plugins/preset-chart-deckgl/src/CategoricalDeckGLContainer.tsx index bbcde5c0acd..5650ff73d7d 100644 --- a/superset-frontend/plugins/preset-chart-deckgl/src/CategoricalDeckGLContainer.tsx +++ b/superset-frontend/plugins/preset-chart-deckgl/src/CategoricalDeckGLContainer.tsx @@ -47,15 +47,15 @@ import { DeckGLContainerStyledWrapper, } from './DeckGLContainer'; import { GetLayerType } from './factory'; -import { ColorBreakpointType, ColorType, Point } from './types'; +import { Point } from './types'; import { TooltipProps } from './components/Tooltip'; import { COLOR_SCHEME_TYPES, ColorSchemeType } from './utilities/utils'; import { getColorBreakpointsBuckets } from './utils'; -import { DEFAULT_DECKGL_COLOR } from './utilities/Shared_DeckGL'; +import { addColorToFeatures } from './utils/addColor'; const { getScale } = CategoricalColorNamespace; -function getCategories(fd: QueryFormData, data: JsonObject[]) { +export function getCategories(fd: QueryFormData, data: JsonObject[]) { const c = fd.color_picker || { r: 0, g: 0, b: 0, a: 1 }; const fixedColor = [c.r, c.g, c.b, 255 * c.a]; const appliedScheme = fd.color_scheme; @@ -70,7 +70,7 @@ function getCategories(fd: QueryFormData, data: JsonObject[]) { if (d.cat_color != null && !categories.hasOwnProperty(d.cat_color)) { let color; if (fd.dimension) { - color = hexToRGB(colorFn(d.cat_color, fd.sliceId), c.a * 255); + color = hexToRGB(colorFn(d.cat_color, fd.slice_id), c.a * 255); } else { color = fixedColor; } @@ -150,80 +150,7 @@ const CategoricalDeckGLContainer = (props: CategoricalDeckGLContainerProps) => { data: JsonObject[], fd: QueryFormData, selectedColorScheme: ColorSchemeType, - ) => { - const appliedScheme = fd.color_scheme; - const colorFn = getScale(appliedScheme); - let color: ColorType; - - switch (selectedColorScheme) { - case COLOR_SCHEME_TYPES.fixed_color: { - color = fd.color_picker || { r: 0, g: 0, b: 0, a: 1 }; - const colorArray = [color.r, color.g, color.b, color.a * 255]; - - return data.map(d => ({ ...d, color: colorArray })); - } - case COLOR_SCHEME_TYPES.categorical_palette: { - if (!fd.dimension) { - const fallbackColor = fd.color_picker || { - r: 0, - g: 0, - b: 0, - a: 1, - }; - const colorArray = [ - fallbackColor.r, - fallbackColor.g, - fallbackColor.b, - fallbackColor.a * 255, - ]; - return data.map(d => ({ ...d, color: colorArray })); - } - - return data.map(d => ({ - ...d, - color: hexToRGB(colorFn(d.cat_color, fd.slice_id)), - })); - } - case COLOR_SCHEME_TYPES.color_breakpoints: { - const defaultBreakpointColor = fd.default_breakpoint_color - ? [ - fd.default_breakpoint_color.r, - fd.default_breakpoint_color.g, - fd.default_breakpoint_color.b, - fd.default_breakpoint_color.a * 255, - ] - : [ - DEFAULT_DECKGL_COLOR.r, - DEFAULT_DECKGL_COLOR.g, - DEFAULT_DECKGL_COLOR.b, - DEFAULT_DECKGL_COLOR.a * 255, - ]; - return data.map(d => { - const breakpointForPoint: ColorBreakpointType = - fd.color_breakpoints?.find( - (breakpoint: ColorBreakpointType) => - d.metric >= breakpoint.minValue && - d.metric <= breakpoint.maxValue, - ); - - if (breakpointForPoint) { - const pointColor = [ - breakpointForPoint.color.r, - breakpointForPoint.color.g, - breakpointForPoint.color.b, - breakpointForPoint.color.a * 255, - ]; - return { ...d, color: pointColor }; - } - - return { ...d, color: defaultBreakpointColor }; - }); - } - default: { - return []; - } - } - }, + ) => addColorToFeatures(data, fd, selectedColorScheme), [], ); diff --git a/superset-frontend/plugins/preset-chart-deckgl/src/Multi/Multi.color.test.tsx b/superset-frontend/plugins/preset-chart-deckgl/src/Multi/Multi.color.test.tsx new file mode 100644 index 00000000000..52daea45156 --- /dev/null +++ b/superset-frontend/plugins/preset-chart-deckgl/src/Multi/Multi.color.test.tsx @@ -0,0 +1,245 @@ +/** + * 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, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { supersetTheme, ThemeProvider } from '@apache-superset/core/theme'; +import { Provider } from 'react-redux'; +import { configureStore } from '@reduxjs/toolkit'; +import { DatasourceType, SupersetClient } from '@superset-ui/core'; +import DeckMulti from './Multi'; + +// Capture the layers handed to the DeckGL container so we can inspect the +// per-feature colors that were resolved for each sublayer. +interface CapturedDataPoint { + color: number[]; +} +interface CapturedLayer { + id?: string; + props: { + data: CapturedDataPoint[]; + getSourceColor?: (d: Record) => number[]; + getTargetColor?: (d: Record) => number[]; + }; +} +const mockLayerCapture: { layers: CapturedLayer[] } = { layers: [] }; +jest.mock('../DeckGLContainer', () => ({ + DeckGLContainerStyledWrapper: ({ layers }: { layers?: CapturedLayer[] }) => { + mockLayerCapture.layers = layers || []; + return
DeckGL Container Mock
; + }, +})); + +jest.mock('@superset-ui/core', () => ({ + ...jest.requireActual('@superset-ui/core'), + SupersetClient: { + get: jest.fn(), + }, +})); + +const mockStore = configureStore({ + reducer: { + dataMask: () => ({}), + }, +}); + +const renderWithProviders = (component: React.ReactElement) => + render( + + {component} + , + ); + +const SCATTER_SLICE_ID = 1; + +const props = { + formData: { + datasource: '1__table', + viz_type: 'deck_multi', + deck_slices: [SCATTER_SLICE_ID], + autozoom: false, + map_style: 'mapbox://styles/mapbox/light-v9', + }, + payload: { + data: { + slices: [ + { + slice_id: SCATTER_SLICE_ID, + form_data: { + viz_type: 'deck_scatter', + datasource: '1__table', + slice_id: SCATTER_SLICE_ID, + // categorical color configuration coming from the saved scatter chart + color_scheme_type: 'categorical_palette', + color_scheme: 'supersetColors', + dimension: 'category', + }, + }, + ], + features: { + deck_scatter: [], + }, + mapboxApiKey: 'test-key', + }, + }, + setControlValue: jest.fn(), + viewport: { longitude: 0, latitude: 0, zoom: 1 }, + onAddFilter: jest.fn(), + height: 600, + width: 800, + datasource: { + id: 1, + type: DatasourceType.Table, + name: 'test_datasource', + columns: [], + metrics: [], + columnFormats: {}, + currencyFormats: {}, + verboseMap: {}, + }, + onSelect: jest.fn(), +}; + +beforeEach(() => { + jest.clearAllMocks(); + mockLayerCapture.layers = []; + // The scatter sublayer query returns features tagged with a category column. + (SupersetClient.get as jest.Mock).mockResolvedValue({ + json: { + data: { + features: [ + { position: [0, 0], radius: 1, cat_color: 'A' }, + { position: [1, 1], radius: 1, cat_color: 'B' }, + ], + }, + }, + }); +}); + +const expectDistinctCategoricalColors = async () => { + await waitFor(() => { + expect(mockLayerCapture.layers.length).toBeGreaterThan(0); + }); + + const scatterLayer = mockLayerCapture.layers.find((layer: CapturedLayer) => + layer?.id?.startsWith('scatter-layer-'), + ); + expect(scatterLayer).toBeDefined(); + + const { data } = (scatterLayer as CapturedLayer).props; + expect(data).toHaveLength(2); + + // Both points must carry a resolved RGBA color... + data.forEach((d: CapturedDataPoint) => { + expect(Array.isArray(d.color)).toBe(true); + expect(d.color).toHaveLength(4); + }); + + // ...and the two distinct categories must NOT share the same color. Before + // the fix, categorical colors were dropped in the Multiple Layers chart and + // every point fell back to the same default color. + expect(data[0].color).not.toEqual(data[1].color); +}; + +test('applies categorical scatterplot colors to sublayers in the multi chart', async () => { + renderWithProviders(); + + await expectDistinctCategoricalColors(); +}); + +test('applies categorical colors to scatter subslices saved before the color_scheme_type control existed', async () => { + // Charts saved before the color_scheme_type control existed lack the key in + // stored params; the scatter default (categorical_palette) must be resolved + // so they keep per-category colors. + const legacyProps = { + ...props, + payload: { + ...props.payload, + data: { + ...props.payload.data, + slices: [ + { + slice_id: SCATTER_SLICE_ID, + form_data: { + viz_type: 'deck_scatter', + datasource: '1__table', + slice_id: SCATTER_SLICE_ID, + color_scheme: 'supersetColors', + dimension: 'category', + }, + }, + ], + }, + }, + }; + + renderWithProviders(); + + await expectDistinctCategoricalColors(); +}); + +test('keeps fixed source and target colors for arc subslices saved before the color_scheme_type control existed', async () => { + // Legacy arcs default to fixed_color, where the layer reads the source and + // target pickers directly; resolving the default must not stamp a single + // per-feature color over the target color. + const ARC_SLICE_ID = 2; + const arcProps = { + ...props, + formData: { ...props.formData, deck_slices: [ARC_SLICE_ID] }, + payload: { + ...props.payload, + data: { + ...props.payload.data, + slices: [ + { + slice_id: ARC_SLICE_ID, + form_data: { + viz_type: 'deck_arc', + datasource: '1__table', + slice_id: ARC_SLICE_ID, + color_picker: { r: 10, g: 20, b: 30, a: 1 }, + target_color_picker: { r: 40, g: 50, b: 60, a: 1 }, + }, + }, + ], + features: { deck_arc: [] }, + }, + }, + }; + (SupersetClient.get as jest.Mock).mockResolvedValue({ + json: { + data: { + features: [{ sourcePosition: [0, 0], targetPosition: [1, 1] }], + }, + }, + }); + + renderWithProviders(); + + await waitFor(() => { + expect(mockLayerCapture.layers.length).toBeGreaterThan(0); + }); + + const arcLayer = mockLayerCapture.layers.find( + (layer: CapturedLayer) => layer?.id === `path-layer-${ARC_SLICE_ID}`, + ); + expect(arcLayer).toBeDefined(); + + expect(arcLayer?.props.getSourceColor?.({})).toEqual([10, 20, 30, 255]); + expect(arcLayer?.props.getTargetColor?.({})).toEqual([40, 50, 60, 255]); +}); diff --git a/superset-frontend/plugins/preset-chart-deckgl/src/Multi/Multi.tsx b/superset-frontend/plugins/preset-chart-deckgl/src/Multi/Multi.tsx index f2d221b09c5..7fa89ba1ef0 100644 --- a/superset-frontend/plugins/preset-chart-deckgl/src/Multi/Multi.tsx +++ b/superset-frontend/plugins/preset-chart-deckgl/src/Multi/Multi.tsx @@ -49,6 +49,8 @@ import { DeckGLContainerStyledWrapper, } from '../DeckGLContainer'; import { getExploreLongUrl } from '../utils/explore'; +import { addColorToFeatures } from '../utils/addColor'; +import { COLOR_SCHEME_TYPES, ColorSchemeType } from '../utilities/utils'; import layerGenerators from '../layers'; import fitViewport, { Viewport } from '../utils/fitViewport'; import { getMapboxApiKey } from '../utils/mapbox'; @@ -98,6 +100,16 @@ const MultiWrapper = styled.div<{ height: number; width: number }>` width: ${({ width }) => width}px; `; +// Default color_scheme_type per color-aware layer type, matching each control +// panel. Sub-slices arrive as raw saved form data without control-default +// hydration, so charts saved before this control existed need the default +// resolved here to keep their configured colors. +const COLOR_AWARE_LAYER_DEFAULTS: Record = { + deck_scatter: COLOR_SCHEME_TYPES.categorical_palette, + deck_path: COLOR_SCHEME_TYPES.fixed_color, + deck_arc: COLOR_SCHEME_TYPES.fixed_color, +}; + const selectDataMask = createSelector( (state: { dataMask?: DataMaskState }) => state.dataMask, dataMask => dataMask || {}, @@ -225,15 +237,43 @@ const DeckMulti = (props: DeckMultiProps) => { ); const createLayerFromData = useCallback( - (subslice: JsonObject, json: JsonObject): Layer => - // @ts-expect-error TODO(hainenber): define proper type for `form_data.viz_type` and call signature for functions in layerGenerators. - layerGenerators[subslice.form_data.viz_type]({ - formData: subslice.form_data, - payload: json, - setTooltip, - datasource: props.datasource, - onSelect: props.onSelect, - }), + (subslice: JsonObject, json: JsonObject): Layer => { + const { form_data: subsliceFormData } = subslice; + const defaultColorSchemeType = + COLOR_AWARE_LAYER_DEFAULTS[subsliceFormData.viz_type]; + let layerFormData = subsliceFormData; + let payload = json; + + // Resolve per-feature colors as CategoricalDeckGLContainer does when + // the layer renders standalone. + if (defaultColorSchemeType) { + layerFormData = { + ...subsliceFormData, + color_scheme_type: + subsliceFormData.color_scheme_type ?? defaultColorSchemeType, + }; + if (Array.isArray(json?.data?.features)) { + payload = { + ...json, + data: { + ...json.data, + features: addColorToFeatures(json.data.features, layerFormData), + }, + }; + } + } + + return ( + // @ts-expect-error TODO(hainenber): define proper type for `form_data.viz_type` and call signature for functions in layerGenerators. + layerGenerators[layerFormData.viz_type]({ + formData: layerFormData, + payload, + setTooltip, + datasource: props.datasource, + onSelect: props.onSelect, + }) + ); + }, [props.onSelect, props.datasource, setTooltip], ); diff --git a/superset-frontend/plugins/preset-chart-deckgl/src/utils/addColor.test.ts b/superset-frontend/plugins/preset-chart-deckgl/src/utils/addColor.test.ts new file mode 100644 index 00000000000..7f2bdfb3875 --- /dev/null +++ b/superset-frontend/plugins/preset-chart-deckgl/src/utils/addColor.test.ts @@ -0,0 +1,103 @@ +/** + * 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 { QueryFormData } from '@superset-ui/core'; +import { addColorToFeatures } from './addColor'; +import { COLOR_SCHEME_TYPES } from '../utilities/utils'; + +const baseFormData = { + datasource: '1__table', + viz_type: 'deck_scatter', +} as unknown as QueryFormData; + +test('assigns distinct colors per category for a categorical palette', () => { + const features = [{ cat_color: 'A' }, { cat_color: 'B' }, { cat_color: 'A' }]; + const result = addColorToFeatures(features, { + ...baseFormData, + color_scheme_type: COLOR_SCHEME_TYPES.categorical_palette, + color_scheme: 'supersetColors', + dimension: 'category', + slice_id: 1, + } as unknown as QueryFormData); + + // Each feature gets a resolved RGBA color + result.forEach(d => { + expect(Array.isArray(d.color)).toBe(true); + expect(d.color).toHaveLength(4); + }); + // Same category resolves to the same color, different categories differ + expect(result[0].color).toEqual(result[2].color); + expect(result[0].color).not.toEqual(result[1].color); +}); + +test('falls back to the fixed color picker when no dimension is set', () => { + const features = [{ cat_color: 'A' }, { cat_color: 'B' }]; + const result = addColorToFeatures(features, { + ...baseFormData, + color_scheme_type: COLOR_SCHEME_TYPES.categorical_palette, + color_picker: { r: 10, g: 20, b: 30, a: 1 }, + } as unknown as QueryFormData); + + result.forEach(d => { + expect(d.color).toEqual([10, 20, 30, 255]); + }); +}); + +test('applies the fixed color scheme to every feature', () => { + const features = [{ cat_color: 'A' }, { cat_color: 'B' }]; + const result = addColorToFeatures(features, { + ...baseFormData, + color_scheme_type: COLOR_SCHEME_TYPES.fixed_color, + color_picker: { r: 1, g: 2, b: 3, a: 0.5 }, + } as unknown as QueryFormData); + + result.forEach(d => { + expect(d.color).toEqual([1, 2, 3, 127.5]); + }); +}); + +test('assigns breakpoint colors by metric and falls back to the default', () => { + const features = [{ metric: 5 }, { metric: 50 }, { metric: 500 }]; + const result = addColorToFeatures(features, { + ...baseFormData, + color_scheme_type: COLOR_SCHEME_TYPES.color_breakpoints, + color_breakpoints: [ + { minValue: 0, maxValue: 10, color: { r: 1, g: 2, b: 3, a: 1 } }, + { minValue: 11, maxValue: 100, color: { r: 4, g: 5, b: 6, a: 0.5 } }, + ], + default_breakpoint_color: { r: 7, g: 8, b: 9, a: 1 }, + } as unknown as QueryFormData); + + // Metric inside the first breakpoint range + expect(result[0].color).toEqual([1, 2, 3, 255]); + // Metric inside the second breakpoint range (alpha scaled to 0-255) + expect(result[1].color).toEqual([4, 5, 6, 127.5]); + // Metric outside every range falls back to the default breakpoint color + expect(result[2].color).toEqual([7, 8, 9, 255]); +}); + +test('returns features unchanged for an unrecognized color scheme', () => { + const features = [{ cat_color: 'A' }]; + const result = addColorToFeatures(features, { + ...baseFormData, + color_scheme_type: 'something_else', + } as unknown as QueryFormData); + + expect(result).toEqual(features); + expect(result[0].color).toBeUndefined(); +}); diff --git a/superset-frontend/plugins/preset-chart-deckgl/src/utils/addColor.ts b/superset-frontend/plugins/preset-chart-deckgl/src/utils/addColor.ts new file mode 100644 index 00000000000..23e4f98227c --- /dev/null +++ b/superset-frontend/plugins/preset-chart-deckgl/src/utils/addColor.ts @@ -0,0 +1,111 @@ +/** + * 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 { + CategoricalColorNamespace, + JsonObject, + QueryFormData, +} from '@superset-ui/core'; +import { hexToRGB } from './colors'; +import { ColorBreakpointType } from '../types'; +import { COLOR_SCHEME_TYPES, ColorSchemeType } from '../utilities/utils'; +import { DEFAULT_DECKGL_COLOR } from '../utilities/Shared_DeckGL'; + +const { getScale } = CategoricalColorNamespace; + +/** + * Resolve the per-feature color for a deck.gl layer based on the form data's + * color scheme configuration. This mirrors the categorical/fixed/breakpoint + * color logic that `CategoricalDeckGLContainer` applies when a layer is + * rendered on its own, so that it can be reused when layers are composed + * inside the deck.gl Multiple Layers chart. + * + * Features whose color scheme is not recognized are returned unchanged so the + * layer's own fallback color logic can take over. + */ +export function addColorToFeatures( + data: JsonObject[], + fd: QueryFormData, + selectedColorScheme: ColorSchemeType = fd.color_scheme_type, +): JsonObject[] { + const appliedScheme = fd.color_scheme; + const colorFn = getScale(appliedScheme); + + switch (selectedColorScheme) { + case COLOR_SCHEME_TYPES.fixed_color: { + const color = fd.color_picker || { r: 0, g: 0, b: 0, a: 1 }; + const colorArray = [color.r, color.g, color.b, color.a * 255]; + + return data.map(d => ({ ...d, color: colorArray })); + } + case COLOR_SCHEME_TYPES.categorical_palette: { + if (!fd.dimension) { + const fallbackColor = fd.color_picker || { r: 0, g: 0, b: 0, a: 1 }; + const colorArray = [ + fallbackColor.r, + fallbackColor.g, + fallbackColor.b, + fallbackColor.a * 255, + ]; + return data.map(d => ({ ...d, color: colorArray })); + } + + return data.map(d => ({ + ...d, + color: hexToRGB(colorFn(d.cat_color, fd.slice_id)), + })); + } + case COLOR_SCHEME_TYPES.color_breakpoints: { + const defaultBreakpointColor = fd.default_breakpoint_color + ? [ + fd.default_breakpoint_color.r, + fd.default_breakpoint_color.g, + fd.default_breakpoint_color.b, + fd.default_breakpoint_color.a * 255, + ] + : [ + DEFAULT_DECKGL_COLOR.r, + DEFAULT_DECKGL_COLOR.g, + DEFAULT_DECKGL_COLOR.b, + DEFAULT_DECKGL_COLOR.a * 255, + ]; + return data.map(d => { + const breakpointForPoint: ColorBreakpointType = + fd.color_breakpoints?.find( + (breakpoint: ColorBreakpointType) => + d.metric >= breakpoint.minValue && + d.metric <= breakpoint.maxValue, + ); + + if (breakpointForPoint) { + const pointColor = [ + breakpointForPoint.color.r, + breakpointForPoint.color.g, + breakpointForPoint.color.b, + breakpointForPoint.color.a * 255, + ]; + return { ...d, color: pointColor }; + } + + return { ...d, color: defaultBreakpointColor }; + }); + } + default: + return data; + } +} diff --git a/superset-frontend/spec/helpers/sourceTreeScanner.ts b/superset-frontend/spec/helpers/sourceTreeScanner.ts new file mode 100644 index 00000000000..c5798592d0c --- /dev/null +++ b/superset-frontend/spec/helpers/sourceTreeScanner.ts @@ -0,0 +1,183 @@ +/** + * 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 { readdirSync, readFileSync, statSync } from 'fs'; +import { join, relative, resolve, sep } from 'path'; + +const DEFAULT_ROOTS = ['src', 'packages/superset-ui-core/src']; + +const ALWAYS_SKIP_SEGMENTS = new Set([ + 'node_modules', + 'dist', + 'build', + 'coverage', + '__mocks__', + 'cypress-base', + 'playwright', +]); + +const ALWAYS_SKIP_SUFFIXES = [ + '.test.ts', + '.test.tsx', + '.stories.ts', + '.stories.tsx', +]; + +const SOURCE_EXTENSIONS = ['.ts', '.tsx']; + +export interface ScanOptions { + /** Workspace-relative directories to scan. Defaults to the source tree. */ + roots?: string[]; + /** Extra path segments to skip on top of {@link ALWAYS_SKIP_SEGMENTS}. */ + ignoreSegments?: string[]; + /** Regex run against each line of each file. */ + pattern: RegExp; + /** Workspace-relative paths (forward slashes) exempt from this scan. */ + allowlist?: string[]; +} + +export interface ScanHit { + /** Workspace-relative path with forward slashes. */ + file: string; + /** 1-based line number. */ + line: number; + /** The text of the matching line, trimmed. */ + text: string; + /** The substring captured by `pattern`. */ + match: string; +} + +// __dirname resolves to /spec/helpers regardless of cwd. +const WORKSPACE_ROOT = resolve(__dirname, '..', '..'); + +function isSourceFile(name: string): boolean { + return ( + SOURCE_EXTENSIONS.some(ext => name.endsWith(ext)) && + !ALWAYS_SKIP_SUFFIXES.some(suffix => name.endsWith(suffix)) + ); +} + +function walk(directory: string, ignoreSegments: Set): string[] { + const found: string[] = []; + + let entries; + try { + entries = readdirSync(directory, { withFileTypes: true }); + } catch { + return found; + } + + for (const entry of entries) { + if (ignoreSegments.has(entry.name)) continue; + const absolute = join(directory, entry.name); + + if (entry.isDirectory()) { + found.push(...walk(absolute, ignoreSegments)); + } else if (entry.isFile() && isSourceFile(entry.name)) { + found.push(absolute); + } + } + + return found; +} + +function toForwardSlashes(path: string): string { + return sep === '/' ? path : path.split(sep).join('/'); +} + +/** + * Line-by-line regex scan over the source tree. Returns one {@link ScanHit} + * per matching line. Textual (not AST-based) — false positives on string + * literals should be fixed by tightening the regex. + */ +export function scanSource(options: ScanOptions): ScanHit[] { + const { + roots = DEFAULT_ROOTS, + ignoreSegments = [], + pattern, + allowlist = [], + } = options; + + const ignoreSet = new Set([...ALWAYS_SKIP_SEGMENTS, ...ignoreSegments]); + const allowSet = new Set(allowlist); + const hits: ScanHit[] = []; + + const seen = new Set(); + for (const root of roots) { + const absoluteRoot = resolve(WORKSPACE_ROOT, root); + let stat; + try { + stat = statSync(absoluteRoot); + } catch { + continue; + } + if (!stat.isDirectory()) continue; + + for (const absoluteFile of walk(absoluteRoot, ignoreSet)) { + if (seen.has(absoluteFile)) continue; + seen.add(absoluteFile); + + const relativePath = toForwardSlashes( + relative(WORKSPACE_ROOT, absoluteFile), + ); + if (allowSet.has(relativePath)) continue; + + const contents = readFileSync(absoluteFile, 'utf8'); + const lines = contents.split('\n'); + + // Reuse the regex per file. Without the `g` flag, `.exec` ignores + // lastIndex, so recompiling per-line was wasted allocation. + const lineRegex = pattern.flags.includes('g') + ? new RegExp(pattern.source, pattern.flags.replace('g', '')) + : pattern; + + for (let index = 0; index < lines.length; index += 1) { + const lineText = lines[index]; + const match = lineRegex.exec(lineText); + if (match) { + hits.push({ + file: relativePath, + line: index + 1, + text: lineText.trim(), + match: match[0], + }); + } + } + } + } + + return hits; +} + +/** Format hits as a multi-line failure message: ` file:line — text`. */ +export function formatHits(hits: ScanHit[], header: string): string { + if (hits.length === 0) return header; + const lines = hits + .slice(0, 50) + .map(hit => ` ${hit.file}:${hit.line} — ${hit.text}`); + const overflow = + hits.length > 50 ? `\n ... and ${hits.length - 50} more` : ''; + return `${header}\n${lines.join('\n')}${overflow}`; +} + +/** Throw with a formatted message if `hits` is non-empty. */ +export function expectNoHits(hits: ScanHit[], header: string): void { + if (hits.length > 0) { + throw new Error(formatHits(hits, header)); + } +} diff --git a/superset-frontend/spec/helpers/withApplicationRoot.ts b/superset-frontend/spec/helpers/withApplicationRoot.ts new file mode 100644 index 00000000000..a8453572356 --- /dev/null +++ b/superset-frontend/spec/helpers/withApplicationRoot.ts @@ -0,0 +1,53 @@ +/** + * 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. + */ + +/** + * Run `callback` with `getBootstrapData().common.application_root` set to + * `applicationRoot`. Resets modules so any imports inside the callback see + * the configured value, then restores the prior DOM and module cache on exit. + * Pass `''` to simulate the default root-of-domain deployment. + */ +export async function withApplicationRoot( + applicationRoot: string, + callback: () => Promise | T, +): Promise { + const previousBody = document.body.innerHTML; + + try { + const bootstrapData = { common: { application_root: applicationRoot } }; + document.body.innerHTML = `
`; + jest.resetModules(); + await import('src/utils/getBootstrapData'); + return await callback(); + } finally { + document.body.innerHTML = previousBody; + jest.resetModules(); + } +} + +/** Run `body` once per scenario, each under a different application root. */ +export async function applicationRootScenarios( + scenarios: S[], + body: (scenario: S) => Promise | void, +): Promise { + for (const scenario of scenarios) { + // eslint-disable-next-line no-await-in-loop -- intentional: scenarios share document state. + await withApplicationRoot(scenario.root, () => body(scenario)); + } +} diff --git a/superset-frontend/src/SqlLab/SqlLabGlobalStyles.tsx b/superset-frontend/src/SqlLab/SqlLabGlobalStyles.tsx index 6eb17fefccf..a8d33009829 100644 --- a/superset-frontend/src/SqlLab/SqlLabGlobalStyles.tsx +++ b/superset-frontend/src/SqlLab/SqlLabGlobalStyles.tsx @@ -20,6 +20,11 @@ import { Global } from '@emotion/react'; import { css } from '@apache-superset/core/theme'; +// Class applied to the SQL Lab tab bar's overflow ("...") dropdown so its menu +// items truncate long tab names. The dropdown is portaled to the body, outside +// the tabs' emotion scope, so it is styled here via a global rule. +export const SQLLAB_TAB_OVERFLOW_POPUP_CLASS = 'sqllab-tab-overflow-popup'; + export const SqlLabGlobalStyles = () => ( css` @@ -30,6 +35,31 @@ export const SqlLabGlobalStyles = () => ( ); // Set a min height so the gutter is always visible when resizing overflow: hidden; } + + // The tab label is a flex node (icon menu + title + status icon). antd's + // overflow dropdown styles each menu item for a plain-text label, so the + // nested flex defeats its ellipsis and very long names render blank. Cap + // the item width and let the title truncate inside it. + .${SQLLAB_TAB_OVERFLOW_POPUP_CLASS} { + .ant-tabs-dropdown-menu-item { + max-width: ${theme.sizeUnit * 80}px; + } + .ant-tabs-dropdown-menu-item > span { + min-width: 0; + overflow: hidden; + } + [data-test='sql-editor-tab-header'] { + min-width: 0; + width: 100%; + } + [data-test='sql-editor-tab-title'] { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } `} /> ); diff --git a/superset-frontend/src/SqlLab/components/QueryTable/index.tsx b/superset-frontend/src/SqlLab/components/QueryTable/index.tsx index 18a3d73235b..fda383430d4 100644 --- a/superset-frontend/src/SqlLab/components/QueryTable/index.tsx +++ b/superset-frontend/src/SqlLab/components/QueryTable/index.tsx @@ -44,7 +44,7 @@ import { import { fDuration, extendedDayjs } from '@superset-ui/core/utils/dates'; import { SqlLabRootState } from 'src/SqlLab/types'; import { UserWithPermissionsAndRoles as User } from 'src/types/bootstrapTypes'; -import { makeUrl } from 'src/utils/pathUtils'; +import { openInNewTab } from 'src/utils/navigationUtils'; import ResultSet from '../ResultSet'; import HighlightedSql from '../HighlightedSql'; import { StaticPosition, StyledTooltip, ModalResultSetWrapper } from './styles'; @@ -80,8 +80,7 @@ interface QueryTableProps { } const openQuery = (id: number) => { - const url = makeUrl(`/sqllab?queryId=${id}`); - window.open(url); + openInNewTab(`/sqllab?queryId=${id}`); }; const QueryTable = ({ diff --git a/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx b/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx index 3885f195532..99388fa7ca3 100644 --- a/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx +++ b/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx @@ -53,7 +53,28 @@ jest.mock('@superset-ui/core', () => ({ isFeatureEnabled: jest.fn().mockReturnValue(false), })); +// Mock openInNewTab so the Create-chart "new window" branch can be asserted +// without spawning a real window. The rest of navigationUtils stays real so +// existing CSV-download tests keep using the genuine `redirect`/`makeUrl`. +jest.mock('src/utils/navigationUtils', () => ({ + ...jest.requireActual('src/utils/navigationUtils'), + openInNewTab: jest.fn(), +})); +// eslint-disable-next-line import/order, import/first +import { openInNewTab } from 'src/utils/navigationUtils'; + +// Stub postFormData so the Create-chart click resolves quickly; this lets +// the test focus on the URL composition that happens after the resolve. +jest.mock('src/explore/exploreUtils/formData', () => ({ + ...jest.requireActual('src/explore/exploreUtils/formData'), + postFormData: jest.fn(), +})); +// eslint-disable-next-line import/order, import/first +import { postFormData } from 'src/explore/exploreUtils/formData'; + const mockIsFeatureEnabled = isFeatureEnabled as jest.Mock; +const mockOpenInNewTab = openInNewTab as jest.Mock; +const mockPostFormData = postFormData as jest.Mock; jest.mock('src/components/ErrorMessage', () => ({ ErrorMessageWithStackTrace: () =>
Error
, @@ -160,6 +181,9 @@ describe('ResultSet', () => { beforeEach(() => { applicationRootMock.mockReturnValue(''); mockStartExport.mockClear(); + mockOpenInNewTab.mockClear(); + mockPostFormData.mockReset(); + mockPostFormData.mockResolvedValue('test-form-data-key'); }); // Add cleanup after each test @@ -1009,4 +1033,148 @@ describe('ResultSet', () => { screen.getByRole('button', { name: 'Results Action' }), ).toBeInTheDocument(); }); + + test('Create chart in new window opens single-prefixed explore URL under subdirectory deployment', async () => { + // When the user metaKey-clicks "Create chart", the SQL-Lab result handoff + // composes an explore URL via mountExploreUrl(..., includeAppRoot=true). + // Under SUPERSET_APP_ROOT=/superset, the resulting URL must contain the + // prefix exactly once. A doubled prefix (/superset/superset/explore/…) + // produces a blank Explore page. + const appRoot = '/superset'; + applicationRootMock.mockReturnValue(appRoot); + + const queryWithId = { + ...queries[0], + results: { + ...queries[0].results, + query_id: 42, + }, + }; + + const { getByTestId } = setup( + { + ...mockedProps, + queryId: queryWithId.id, + database: { allows_subquery: true, allows_virtual_table_explore: true }, + }, + mockStore({ + ...initialState, + user, + sqlLab: { + ...initialState.sqlLab, + queries: { + [queryWithId.id]: queryWithId, + }, + }, + }), + ); + + const exploreButton = await waitFor(() => + getByTestId('explore-results-button'), + ); + fireEvent.click(exploreButton, { metaKey: true }); + + await waitFor(() => { + expect(mockOpenInNewTab).toHaveBeenCalledTimes(1); + }); + const url = mockOpenInNewTab.mock.calls[0][0] as string; + expect(url).toMatch(/^\/superset\/explore\/\?.*form_data_key=/); + expect(url).not.toMatch(/\/superset\/superset\//); + }); + + test('Create chart in same window pushes router-relative explore URL under subdirectory deployment', async () => { + // Same-tab click (no metaKey) goes through history.push under the SPA + // basename Router, so mountExploreUrl is called with includeAppRoot=false. + // The composed URL must NOT carry an app-root prefix — the router applies + // it once via . A premature prefix + // here would compound with the basename and yield /superset/superset/… + const appRoot = '/superset'; + applicationRootMock.mockReturnValue(appRoot); + + const queryWithId = { + ...queries[0], + results: { + ...queries[0].results, + query_id: 99, + }, + }; + + const store = mockStore({ + ...initialState, + user, + sqlLab: { + ...initialState.sqlLab, + queries: { + [queryWithId.id]: queryWithId, + }, + }, + }); + + const { getByTestId } = render( + , + { useRedux: true, store, useRouter: true }, + ); + + const exploreButton = await waitFor(() => + getByTestId('explore-results-button'), + ); + fireEvent.click(exploreButton); + + await waitFor(() => { + expect(mockPostFormData).toHaveBeenCalledTimes(1); + }); + expect(mockOpenInNewTab).not.toHaveBeenCalled(); + }); + + test('filters the results table when typing in the search box', async () => { + const filterableQuery = { + ...queries[0], + id: 'filterableQueryId', + cached: false, + results: { + columns: [{ is_dttm: false, column_name: 'fruit', type: 'STRING' }], + selected_columns: [ + { is_dttm: false, column_name: 'fruit', type: 'STRING' }, + ], + data: [{ fruit: 'apple' }, { fruit: 'banana' }], + }, + }; + const { getByTestId, getByPlaceholderText, queryByText } = setup( + { ...mockedProps, cache: false, queryId: filterableQuery.id }, + mockStore({ + ...initialState, + user, + sqlLab: { + ...initialState.sqlLab, + queries: { + [filterableQuery.id]: filterableQuery, + }, + }, + }), + ); + + await waitFor(() => { + expect(getByTestId('table-container')).toBeInTheDocument(); + }); + // Both rows are visible before filtering + expect(queryByText('apple')).toBeInTheDocument(); + expect(queryByText('banana')).toBeInTheDocument(); + + // Typing in the search box filters the rows (case-insensitive substring) + fireEvent.change(getByPlaceholderText('Filter results'), { + target: { value: 'APP' }, + }); + + await waitFor(() => { + expect(queryByText('banana')).not.toBeInTheDocument(); + }); + expect(queryByText('apple')).toBeInTheDocument(); + }); }); diff --git a/superset-frontend/src/SqlLab/components/ResultSet/index.tsx b/superset-frontend/src/SqlLab/components/ResultSet/index.tsx index 732d1dec8f9..cb6ee6883ac 100644 --- a/superset-frontend/src/SqlLab/components/ResultSet/index.tsx +++ b/superset-frontend/src/SqlLab/components/ResultSet/index.tsx @@ -16,7 +16,6 @@ * specific language governing permissions and limitations * under the License. */ -import { sanitizeUrl } from '@braintree/sanitize-url'; import { useCallback, useEffect, @@ -88,7 +87,7 @@ import { usePermissions } from 'src/hooks/usePermissions'; import { StreamingExportModal } from 'src/components/StreamingExportModal'; import { useStreamingExport } from 'src/components/StreamingExportModal/useStreamingExport'; import { useConfirmModal } from 'src/hooks/useConfirmModal'; -import { makeUrl } from 'src/utils/pathUtils'; +import { makeUrl, openInNewTab, redirect } from 'src/utils/navigationUtils'; import ExploreCtasResultsButton from '../ExploreCtasResultsButton'; import ExploreResultsButton from '../ExploreResultsButton'; import HighlightedSql from '../HighlightedSql'; @@ -312,7 +311,9 @@ const ResultSet = ({ includeAppRoot, ); if (openInNewWindow) { - window.open(url, '_blank', 'noreferrer'); + // `url` is from `mountExploreUrl(..., includeAppRoot=true)`; the + // helper re-applies `ensureAppRoot` idempotently. + openInNewTab(url); } else { history.push(url); } @@ -379,7 +380,13 @@ const ResultSet = ({ { rows: rowsCount.toLocaleString() }, ), onConfirm: () => { - window.location.href = sanitizeUrl(getExportCsvUrl(query.id)); + // `getExportCsvUrl` already runs the path through `makeUrl`; + // `redirect` re-applies `ensureAppRoot` idempotently and routes + // the sink through navigationUtils' barriers (scheme allowlist, + // userinfo rejection, backslash rejection), which is a + // strict superset of what `sanitizeUrl` from master PR #40546 + // provides. + redirect(getExportCsvUrl(query.id)); }, confirmText: t('OK'), cancelText: t('Close'), diff --git a/superset-frontend/src/SqlLab/components/SaveDatasetModal/SaveDatasetModal.test.tsx b/superset-frontend/src/SqlLab/components/SaveDatasetModal/SaveDatasetModal.test.tsx index 861a4772858..62ba4aea4c5 100644 --- a/superset-frontend/src/SqlLab/components/SaveDatasetModal/SaveDatasetModal.test.tsx +++ b/superset-frontend/src/SqlLab/components/SaveDatasetModal/SaveDatasetModal.test.tsx @@ -127,6 +127,28 @@ describe('SaveDatasetModal', () => { expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument(); }); + test('disables the save button when the dataset name is empty or whitespace-only', async () => { + renderModal(); + + const nameInput = screen.getByRole('textbox'); + const saveBtn = screen.getByRole('button', { name: /save/i }); + + // Default name is present, so save starts enabled + expect(saveBtn).toBeEnabled(); + + // Clearing the name disables save + await userEvent.clear(nameInput); + await waitFor(() => expect(saveBtn).toBeDisabled()); + + // Whitespace-only name keeps save disabled + await userEvent.type(nameInput, ' '); + await waitFor(() => expect(saveBtn).toBeDisabled()); + + // A non-empty name re-enables save + await userEvent.type(nameInput, 'My dataset'); + await waitFor(() => expect(saveBtn).toBeEnabled()); + }); + test('renders an overwrite button when "Overwrite existing" is selected', () => { renderModal(); @@ -245,6 +267,22 @@ describe('SaveDatasetModal', () => { }); }); + test('trims surrounding whitespace from the dataset name on save', async () => { + renderModal(); + + const inputFieldText = screen.getByDisplayValue(/unimportant/i); + fireEvent.change(inputFieldText, { target: { value: ' my dataset ' } }); + + const saveConfirmationBtn = screen.getByRole('button', { + name: /save/i, + }); + userEvent.click(saveConfirmationBtn); + + expect(createDatasource).toHaveBeenCalledWith( + expect.objectContaining({ datasourceName: 'my dataset' }), + ); + }); + test('sends the catalog when creating the dataset', async () => { renderModal({ datasource: { ...mockedProps.datasource, catalog: 'public' }, diff --git a/superset-frontend/src/SqlLab/components/SaveDatasetModal/index.tsx b/superset-frontend/src/SqlLab/components/SaveDatasetModal/index.tsx index e1de7b46634..dd3b98e9e20 100644 --- a/superset-frontend/src/SqlLab/components/SaveDatasetModal/index.tsx +++ b/superset-frontend/src/SqlLab/components/SaveDatasetModal/index.tsx @@ -16,7 +16,6 @@ * specific language governing permissions and limitations * under the License. */ -import { sanitizeUrl } from '@braintree/sanitize-url'; import { useCallback, useState, FormEvent } from 'react'; import { ModalTitleWithIcon } from 'src/components/ModalTitleWithIcon'; import { Radio, RadioChangeEvent } from '@superset-ui/core/components/Radio'; @@ -58,6 +57,7 @@ import { postFormData } from 'src/explore/exploreUtils/formData'; import { URL_PARAMS } from 'src/constants'; import { isEmpty } from 'lodash-es'; import { clearDatasetCache } from 'src/utils/cachedSupersetGet'; +import { openInNewTab, redirect } from 'src/utils/navigationUtils'; interface QueryDatabase { id?: number; @@ -244,10 +244,16 @@ export const SaveDatasetModal = ({ useState(false); const createWindow = (url: string) => { + // `url` is from `mountExploreUrl(..., includeAppRoot=true)`; the + // navigationUtils helpers re-apply `ensureAppRoot` idempotently. if (openWindow) { - window.open(sanitizeUrl(url), '_blank', 'noreferrer'); + // `openInNewTab` / `redirect` route the sink through navigationUtils' + // barriers (scheme allowlist, userinfo rejection, backslash + // rejection) — strictly stronger than master PR #40546's `sanitizeUrl` + // wrap, which only rejects `javascript:` / `data:` / `vbscript:`. + openInNewTab(url); } else { - window.location.href = sanitizeUrl(url); + redirect(url); } }; const formDataWithDefaults = { @@ -365,7 +371,7 @@ export const SaveDatasetModal = ({ catalog: datasource?.catalog ?? null, schema: datasource?.schema ?? '', templateParams, - datasourceName: datasetName, + datasourceName: datasetName.trim(), }), ) .then((data: { id: number }) => { @@ -411,7 +417,7 @@ export const SaveDatasetModal = ({ const disableSaveAndExploreBtn = (newOrOverwrite === DatasetRadioState.SaveNew && - datasetName.length === 0) || + datasetName.trim().length === 0) || (newOrOverwrite === DatasetRadioState.OverwriteDataset && isEmpty(selectedDatasetToOverwrite)); diff --git a/superset-frontend/src/SqlLab/components/SaveQuery/SaveQuery.test.tsx b/superset-frontend/src/SqlLab/components/SaveQuery/SaveQuery.test.tsx index eb13d6acf53..0a60c9c7ee3 100644 --- a/superset-frontend/src/SqlLab/components/SaveQuery/SaveQuery.test.tsx +++ b/superset-frontend/src/SqlLab/components/SaveQuery/SaveQuery.test.tsx @@ -337,4 +337,32 @@ describe('SavedQuery', () => { ).not.toBeInTheDocument(); }); }); + + test('disables the save button when the query name is empty or whitespace-only', async () => { + render(, { + useRedux: true, + store: mockStore(mockState), + }); + + userEvent.click(screen.getByRole('button', { name: /save/i })); + + const nameInput = screen.getAllByRole('textbox')[0] as HTMLInputElement; + const modalSaveBtn = () => + screen.getAllByRole('button', { name: /save/i })[1]; + + // Default label is present, so the save button starts enabled + expect(modalSaveBtn()).toBeEnabled(); + + // Clearing the name disables the save button + userEvent.clear(nameInput); + await waitFor(() => expect(modalSaveBtn()).toBeDisabled()); + + // A whitespace-only name keeps the save button disabled + userEvent.type(nameInput, ' '); + await waitFor(() => expect(modalSaveBtn()).toBeDisabled()); + + // A non-empty name re-enables the save button + userEvent.type(nameInput, 'My query'); + await waitFor(() => expect(modalSaveBtn()).toBeEnabled()); + }); }); diff --git a/superset-frontend/src/SqlLab/components/SaveQuery/index.tsx b/superset-frontend/src/SqlLab/components/SaveQuery/index.tsx index dc5e05b862c..b0647155a16 100644 --- a/superset-frontend/src/SqlLab/components/SaveQuery/index.tsx +++ b/superset-frontend/src/SqlLab/components/SaveQuery/index.tsx @@ -112,6 +112,7 @@ const SaveQuery = ({ const [showSave, setShowSave] = useState(false); const [showSaveDatasetModal, setShowSaveDatasetModal] = useState(false); const isSaved = !!query.remoteId; + const isLabelEmpty = label.trim().length === 0; const canExploreDatabase = !!database?.allows_virtual_table_explore; const shouldShowSaveButton = database?.allows_virtual_table_explore !== undefined; @@ -235,12 +236,18 @@ const SaveQuery = ({ {isSaved && ( - )} diff --git a/superset-frontend/src/SqlLab/components/SqlEditorTabHeader/SqlEditorTabHeader.test.tsx b/superset-frontend/src/SqlLab/components/SqlEditorTabHeader/SqlEditorTabHeader.test.tsx index 4a331e6b894..0452f642310 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditorTabHeader/SqlEditorTabHeader.test.tsx +++ b/superset-frontend/src/SqlLab/components/SqlEditorTabHeader/SqlEditorTabHeader.test.tsx @@ -55,306 +55,303 @@ const setup = (queryEditor: QueryEditor, store?: Store) => ...(store && { store }), }); -// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks -describe('SqlEditorTabHeader', () => { - test('renders name', () => { - const { queryByText } = setup(defaultQueryEditor, mockStore(initialState)); - expect(queryByText(defaultQueryEditor.name)).toBeInTheDocument(); - expect(queryByText(extraQueryEditor1.name)).not.toBeInTheDocument(); - expect(queryByText(extraQueryEditor2.name)).not.toBeInTheDocument(); - }); +// Renders the header and opens its "..." dropdown menu, returning the store so +// each test can assert on the actions it dispatches. +const openTabDropdown = () => { + const store = mockStore(initialState); + const { getByTestId } = setup(defaultQueryEditor, store); + userEvent.click(getByTestId('dropdown-trigger')); + return store; +}; - test('renders name from unsaved changes', () => { - const expectedTitle = 'updated title'; - const { queryByText } = setup( - defaultQueryEditor, - mockStore({ - ...initialState, - sqlLab: { - ...initialState.sqlLab, - unsavedQueryEditor: { - id: defaultQueryEditor.id, - name: expectedTitle, - }, - }, - }), - ); - expect(queryByText(expectedTitle)).toBeInTheDocument(); - expect(queryByText(defaultQueryEditor.name)).not.toBeInTheDocument(); - expect(queryByText(extraQueryEditor1.name)).not.toBeInTheDocument(); - expect(queryByText(extraQueryEditor2.name)).not.toBeInTheDocument(); - }); - - test('renders current name for unrelated unsaved changes', () => { - const unrelatedTitle = 'updated title'; - const { queryByText } = setup( - defaultQueryEditor, - mockStore({ - ...initialState, - sqlLab: { - ...initialState.sqlLab, - unsavedQueryEditor: { - id: `${defaultQueryEditor.id}-other`, - name: unrelatedTitle, - }, - }, - }), - ); - expect(queryByText(defaultQueryEditor.name)).toBeInTheDocument(); - expect(queryByText(unrelatedTitle)).not.toBeInTheDocument(); - expect(queryByText(extraQueryEditor1.name)).not.toBeInTheDocument(); - expect(queryByText(extraQueryEditor2.name)).not.toBeInTheDocument(); - }); - - // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks - describe('with dropdown menus', () => { - let store = mockStore(); - beforeEach(async () => { - store = mockStore(initialState); - const { getByTestId } = setup(defaultQueryEditor, store); - const dropdown = getByTestId('dropdown-trigger'); - - userEvent.click(dropdown); - }); - - test('should dispatch removeQueryEditor action', async () => { - await waitFor(() => - expect(screen.getByTestId('close-tab-menu-option')).toBeInTheDocument(), - ); - - fireEvent.click(screen.getByTestId('close-tab-menu-option')); - - const actions = store.getActions(); - await waitFor(() => - expect(actions[0]).toEqual({ - type: REMOVE_QUERY_EDITOR, - queryEditor: defaultQueryEditor, - }), - ); - }); - - test('should dispatch queryEditorSetTitle action', async () => { - await waitFor(() => - expect( - screen.getByTestId('rename-tab-menu-option'), - ).toBeInTheDocument(), - ); - const expectedTitle = 'typed text'; - fireEvent.click(screen.getByTestId('rename-tab-menu-option')); - - const input = await screen.findByTestId('rename-tab-input'); - fireEvent.change(input, { target: { value: expectedTitle } }); - fireEvent.click(screen.getByRole('button', { name: 'Save' })); - - const actions = store.getActions(); - await waitFor(() => - expect(actions[0]).toEqual({ - type: QUERY_EDITOR_SET_TITLE, - name: expectedTitle, - queryEditor: expect.objectContaining({ - id: defaultQueryEditor.id, - }), - }), - ); - }); - - test('prefills the rename input with the current tab name', async () => { - await waitFor(() => - expect( - screen.getByTestId('rename-tab-menu-option'), - ).toBeInTheDocument(), - ); - fireEvent.click(screen.getByTestId('rename-tab-menu-option')); - - const input = await screen.findByTestId('rename-tab-input'); - expect(input).toHaveValue(defaultQueryEditor.name); - }); - - test('focuses the rename input when the modal opens', async () => { - await waitFor(() => - expect( - screen.getByTestId('rename-tab-menu-option'), - ).toBeInTheDocument(), - ); - fireEvent.click(screen.getByTestId('rename-tab-menu-option')); - - const input = await screen.findByTestId('rename-tab-input'); - await waitFor(() => expect(input).toHaveFocus()); - }); - - test('disables Save when the input is empty or whitespace', async () => { - await waitFor(() => - expect( - screen.getByTestId('rename-tab-menu-option'), - ).toBeInTheDocument(), - ); - fireEvent.click(screen.getByTestId('rename-tab-menu-option')); - - const input = await screen.findByTestId('rename-tab-input'); - fireEvent.change(input, { target: { value: ' ' } }); - expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); - }); - - test('does not dispatch or dismiss on Enter when the input is empty', async () => { - await waitFor(() => - expect( - screen.getByTestId('rename-tab-menu-option'), - ).toBeInTheDocument(), - ); - fireEvent.click(screen.getByTestId('rename-tab-menu-option')); - - const input = await screen.findByTestId('rename-tab-input'); - fireEvent.change(input, { target: { value: ' ' } }); - fireEvent.keyDown(input, { key: 'Enter', keyCode: 13, charCode: 13 }); - - const dispatchedTitleChange = store - .getActions() - .some(action => action.type === QUERY_EDITOR_SET_TITLE); - expect(dispatchedTitleChange).toBe(false); - // the modal must stay open so the user can correct the name, - // mirroring the disabled Save button rather than dismissing like Escape - expect(screen.queryByRole('dialog')).toBeInTheDocument(); - }); - - test('does not dispatch a title change when the modal is cancelled', async () => { - await waitFor(() => - expect( - screen.getByTestId('rename-tab-menu-option'), - ).toBeInTheDocument(), - ); - fireEvent.click(screen.getByTestId('rename-tab-menu-option')); - - const input = await screen.findByTestId('rename-tab-input'); - fireEvent.change(input, { target: { value: 'discarded text' } }); - fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); - - expect(store.getActions()).toEqual([]); - }); - - test('does not dispatch a title change when dismissed with the close button', async () => { - await waitFor(() => - expect( - screen.getByTestId('rename-tab-menu-option'), - ).toBeInTheDocument(), - ); - fireEvent.click(screen.getByTestId('rename-tab-menu-option')); - - const input = await screen.findByTestId('rename-tab-input'); - fireEvent.change(input, { target: { value: 'discarded text' } }); - fireEvent.click(screen.getByTestId('close-modal-btn')); - - expect(store.getActions()).toEqual([]); - }); - - test('returns focus to the tab header after the modal is cancelled', async () => { - await waitFor(() => - expect( - screen.getByTestId('rename-tab-menu-option'), - ).toBeInTheDocument(), - ); - fireEvent.click(screen.getByTestId('rename-tab-menu-option')); - - await screen.findByTestId('rename-tab-input'); - fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); - - await waitFor(() => - expect(screen.getByTestId('sql-editor-tab-header')).toHaveFocus(), - ); - }); - - test('returns focus to the tab header after a successful rename', async () => { - await waitFor(() => - expect( - screen.getByTestId('rename-tab-menu-option'), - ).toBeInTheDocument(), - ); - fireEvent.click(screen.getByTestId('rename-tab-menu-option')); - - const input = await screen.findByTestId('rename-tab-input'); - fireEvent.change(input, { target: { value: 'renamed tab' } }); - fireEvent.click(screen.getByRole('button', { name: 'Save' })); - - await waitFor(() => - expect(screen.getByTestId('sql-editor-tab-header')).toHaveFocus(), - ); - }); - - test('should dispatch removeAllOtherQueryEditors action', async () => { - await waitFor(() => - expect(screen.getByTestId('close-tab-menu-option')).toBeInTheDocument(), - ); - fireEvent.click(screen.getByTestId('close-all-other-menu-option')); - - const actions = store.getActions(); - await waitFor(() => - expect(actions).toEqual([ - { - type: REMOVE_QUERY_EDITOR, - queryEditor: initialState.sqlLab.queryEditors[1], - }, - { - type: REMOVE_QUERY_EDITOR, - queryEditor: initialState.sqlLab.queryEditors[2], - }, - ]), - ); - }); - - test('should dispatch cloneQueryToNewTab action', async () => { - await waitFor(() => - expect(screen.getByTestId('close-tab-menu-option')).toBeInTheDocument(), - ); - fireEvent.click(screen.getByTestId('clone-tab-menu-option')); - - const actions = store.getActions(); - await waitFor(() => - expect(actions[0]).toEqual({ - type: ADD_QUERY_EDITOR, - queryEditor: expect.objectContaining({ - name: `Copy of ${defaultQueryEditor.name}`, - sql: defaultQueryEditor.sql, - autorun: false, - }), - }), - ); - }); - }); - - test('does not leak tab-editing keystrokes from the rename input to the surrounding tabs', async () => { - const onContainerKeyDown = jest.fn(); - const store = mockStore(initialState); - render( -
- -
, - { useRedux: true, store }, - ); - - userEvent.click(screen.getByTestId('dropdown-trigger')); - await waitFor(() => - expect(screen.getByTestId('rename-tab-menu-option')).toBeInTheDocument(), - ); - fireEvent.click(screen.getByTestId('rename-tab-menu-option')); - const input = await screen.findByTestId('rename-tab-input'); - - // The modal portals over the editable-card tabs, whose keyboard handler would - // otherwise remove, navigate, or activate a tab (and swallow Space). None of - // these keys should escape the modal to the surrounding container. - [ - 'Delete', - 'Backspace', - 'ArrowLeft', - 'ArrowRight', - 'Home', - 'End', - ' ', - ].forEach(key => fireEvent.keyDown(input, { key })); - expect(onContainerKeyDown).not.toHaveBeenCalled(); - - // Escape (close) and Tab (focus trap) must still reach the Modal. - fireEvent.keyDown(input, { key: 'Tab' }); - fireEvent.keyDown(input, { key: 'Escape' }); - const reached = onContainerKeyDown.mock.calls.map(call => call[0].key); - expect(reached).toEqual(expect.arrayContaining(['Tab', 'Escape'])); - }); +test('renders name', () => { + const { queryByText } = setup(defaultQueryEditor, mockStore(initialState)); + expect(queryByText(defaultQueryEditor.name)).toBeInTheDocument(); + expect(queryByText(extraQueryEditor1.name)).not.toBeInTheDocument(); + expect(queryByText(extraQueryEditor2.name)).not.toBeInTheDocument(); +}); + +test('exposes the name on a dedicated node the overflow dropdown can truncate', () => { + // The overflow ("...") menu reuses this label and styles the title node by + // its data-test to keep very long names from rendering blank. + const { getByTestId } = setup(defaultQueryEditor, mockStore(initialState)); + expect(getByTestId('sql-editor-tab-title')).toHaveTextContent( + defaultQueryEditor.name, + ); +}); + +test('renders name from unsaved changes', () => { + const expectedTitle = 'updated title'; + const { queryByText } = setup( + defaultQueryEditor, + mockStore({ + ...initialState, + sqlLab: { + ...initialState.sqlLab, + unsavedQueryEditor: { + id: defaultQueryEditor.id, + name: expectedTitle, + }, + }, + }), + ); + expect(queryByText(expectedTitle)).toBeInTheDocument(); + expect(queryByText(defaultQueryEditor.name)).not.toBeInTheDocument(); + expect(queryByText(extraQueryEditor1.name)).not.toBeInTheDocument(); + expect(queryByText(extraQueryEditor2.name)).not.toBeInTheDocument(); +}); + +test('renders current name for unrelated unsaved changes', () => { + const unrelatedTitle = 'updated title'; + const { queryByText } = setup( + defaultQueryEditor, + mockStore({ + ...initialState, + sqlLab: { + ...initialState.sqlLab, + unsavedQueryEditor: { + id: `${defaultQueryEditor.id}-other`, + name: unrelatedTitle, + }, + }, + }), + ); + expect(queryByText(defaultQueryEditor.name)).toBeInTheDocument(); + expect(queryByText(unrelatedTitle)).not.toBeInTheDocument(); + expect(queryByText(extraQueryEditor1.name)).not.toBeInTheDocument(); + expect(queryByText(extraQueryEditor2.name)).not.toBeInTheDocument(); +}); + +test('should dispatch removeQueryEditor action', async () => { + const store = openTabDropdown(); + await waitFor(() => + expect(screen.getByTestId('close-tab-menu-option')).toBeInTheDocument(), + ); + + fireEvent.click(screen.getByTestId('close-tab-menu-option')); + + const actions = store.getActions(); + await waitFor(() => + expect(actions[0]).toEqual({ + type: REMOVE_QUERY_EDITOR, + queryEditor: defaultQueryEditor, + }), + ); +}); + +test('should dispatch queryEditorSetTitle action', async () => { + const store = openTabDropdown(); + await waitFor(() => + expect(screen.getByTestId('rename-tab-menu-option')).toBeInTheDocument(), + ); + const expectedTitle = 'typed text'; + fireEvent.click(screen.getByTestId('rename-tab-menu-option')); + + const input = await screen.findByTestId('rename-tab-input'); + fireEvent.change(input, { target: { value: expectedTitle } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + const actions = store.getActions(); + await waitFor(() => + expect(actions[0]).toEqual({ + type: QUERY_EDITOR_SET_TITLE, + name: expectedTitle, + queryEditor: expect.objectContaining({ + id: defaultQueryEditor.id, + }), + }), + ); +}); + +test('prefills the rename input with the current tab name', async () => { + openTabDropdown(); + await waitFor(() => + expect(screen.getByTestId('rename-tab-menu-option')).toBeInTheDocument(), + ); + fireEvent.click(screen.getByTestId('rename-tab-menu-option')); + + const input = await screen.findByTestId('rename-tab-input'); + expect(input).toHaveValue(defaultQueryEditor.name); +}); + +test('focuses the rename input when the modal opens', async () => { + openTabDropdown(); + await waitFor(() => + expect(screen.getByTestId('rename-tab-menu-option')).toBeInTheDocument(), + ); + fireEvent.click(screen.getByTestId('rename-tab-menu-option')); + + const input = await screen.findByTestId('rename-tab-input'); + await waitFor(() => expect(input).toHaveFocus()); +}); + +test('disables Save when the input is empty or whitespace', async () => { + openTabDropdown(); + await waitFor(() => + expect(screen.getByTestId('rename-tab-menu-option')).toBeInTheDocument(), + ); + fireEvent.click(screen.getByTestId('rename-tab-menu-option')); + + const input = await screen.findByTestId('rename-tab-input'); + fireEvent.change(input, { target: { value: ' ' } }); + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); +}); + +test('does not dispatch or dismiss on Enter when the input is empty', async () => { + const store = openTabDropdown(); + await waitFor(() => + expect(screen.getByTestId('rename-tab-menu-option')).toBeInTheDocument(), + ); + fireEvent.click(screen.getByTestId('rename-tab-menu-option')); + + const input = await screen.findByTestId('rename-tab-input'); + fireEvent.change(input, { target: { value: ' ' } }); + fireEvent.keyDown(input, { key: 'Enter', keyCode: 13, charCode: 13 }); + + const dispatchedTitleChange = store + .getActions() + .some(action => action.type === QUERY_EDITOR_SET_TITLE); + expect(dispatchedTitleChange).toBe(false); + // the modal must stay open so the user can correct the name, + // mirroring the disabled Save button rather than dismissing like Escape + expect(screen.queryByRole('dialog')).toBeInTheDocument(); +}); + +test('does not dispatch a title change when the modal is cancelled', async () => { + const store = openTabDropdown(); + await waitFor(() => + expect(screen.getByTestId('rename-tab-menu-option')).toBeInTheDocument(), + ); + fireEvent.click(screen.getByTestId('rename-tab-menu-option')); + + const input = await screen.findByTestId('rename-tab-input'); + fireEvent.change(input, { target: { value: 'discarded text' } }); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(store.getActions()).toEqual([]); +}); + +test('does not dispatch a title change when dismissed with the close button', async () => { + const store = openTabDropdown(); + await waitFor(() => + expect(screen.getByTestId('rename-tab-menu-option')).toBeInTheDocument(), + ); + fireEvent.click(screen.getByTestId('rename-tab-menu-option')); + + const input = await screen.findByTestId('rename-tab-input'); + fireEvent.change(input, { target: { value: 'discarded text' } }); + fireEvent.click(screen.getByTestId('close-modal-btn')); + + expect(store.getActions()).toEqual([]); +}); + +test('returns focus to the tab header after the modal is cancelled', async () => { + openTabDropdown(); + await waitFor(() => + expect(screen.getByTestId('rename-tab-menu-option')).toBeInTheDocument(), + ); + fireEvent.click(screen.getByTestId('rename-tab-menu-option')); + + await screen.findByTestId('rename-tab-input'); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + + await waitFor(() => + expect(screen.getByTestId('sql-editor-tab-header')).toHaveFocus(), + ); +}); + +test('returns focus to the tab header after a successful rename', async () => { + openTabDropdown(); + await waitFor(() => + expect(screen.getByTestId('rename-tab-menu-option')).toBeInTheDocument(), + ); + fireEvent.click(screen.getByTestId('rename-tab-menu-option')); + + const input = await screen.findByTestId('rename-tab-input'); + fireEvent.change(input, { target: { value: 'renamed tab' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => + expect(screen.getByTestId('sql-editor-tab-header')).toHaveFocus(), + ); +}); + +test('should dispatch removeAllOtherQueryEditors action', async () => { + const store = openTabDropdown(); + await waitFor(() => + expect(screen.getByTestId('close-tab-menu-option')).toBeInTheDocument(), + ); + fireEvent.click(screen.getByTestId('close-all-other-menu-option')); + + const actions = store.getActions(); + await waitFor(() => + expect(actions).toEqual([ + { + type: REMOVE_QUERY_EDITOR, + queryEditor: initialState.sqlLab.queryEditors[1], + }, + { + type: REMOVE_QUERY_EDITOR, + queryEditor: initialState.sqlLab.queryEditors[2], + }, + ]), + ); +}); + +test('should dispatch cloneQueryToNewTab action', async () => { + const store = openTabDropdown(); + await waitFor(() => + expect(screen.getByTestId('close-tab-menu-option')).toBeInTheDocument(), + ); + fireEvent.click(screen.getByTestId('clone-tab-menu-option')); + + const actions = store.getActions(); + await waitFor(() => + expect(actions[0]).toEqual({ + type: ADD_QUERY_EDITOR, + queryEditor: expect.objectContaining({ + name: `Copy of ${defaultQueryEditor.name}`, + sql: defaultQueryEditor.sql, + autorun: false, + }), + }), + ); +}); + +test('does not leak tab-editing keystrokes from the rename input to the surrounding tabs', async () => { + const onContainerKeyDown = jest.fn(); + const store = mockStore(initialState); + render( +
+ +
, + { useRedux: true, store }, + ); + + userEvent.click(screen.getByTestId('dropdown-trigger')); + await waitFor(() => + expect(screen.getByTestId('rename-tab-menu-option')).toBeInTheDocument(), + ); + fireEvent.click(screen.getByTestId('rename-tab-menu-option')); + const input = await screen.findByTestId('rename-tab-input'); + + // The modal portals over the editable-card tabs, whose keyboard handler would + // otherwise remove, navigate, or activate a tab (and swallow Space). None of + // these keys should escape the modal to the surrounding container. + [ + 'Delete', + 'Backspace', + 'ArrowLeft', + 'ArrowRight', + 'Home', + 'End', + ' ', + ].forEach(key => fireEvent.keyDown(input, { key })); + expect(onContainerKeyDown).not.toHaveBeenCalled(); + + // Escape (close) and Tab (focus trap) must still reach the Modal. + fireEvent.keyDown(input, { key: 'Tab' }); + fireEvent.keyDown(input, { key: 'Escape' }); + const reached = onContainerKeyDown.mock.calls.map(call => call[0].key); + expect(reached).toEqual(expect.arrayContaining(['Tab', 'Escape'])); }); diff --git a/superset-frontend/src/SqlLab/components/SqlEditorTabHeader/index.tsx b/superset-frontend/src/SqlLab/components/SqlEditorTabHeader/index.tsx index d11326f2010..c4aaaa1f512 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditorTabHeader/index.tsx +++ b/superset-frontend/src/SqlLab/components/SqlEditorTabHeader/index.tsx @@ -245,7 +245,7 @@ const SqlEditorTabHeader: FC = ({ queryEditor }) => { /> } /> - {qe.name}{' '} + {qe.name}{' '} { fetchMock.callHistory.calls(getTableMetadataEndpoint), ).toHaveLength(1), ); - const refreshButton = getByRole('button', { name: 'sync' }); + const refreshButton = getByRole('button', { name: 'Refresh table schema' }); fireEvent.click(refreshButton); await waitFor(() => expect( @@ -170,7 +170,9 @@ describe('table actions', () => { fetchMock.callHistory.calls(getTableMetadataEndpoint), ).toHaveLength(1), ); - const viewButton = getByRole('button', { name: 'eye' }); + const viewButton = getByRole('button', { + name: 'Show CREATE VIEW statement', + }); fireEvent.click(viewButton); await waitFor(() => expect( diff --git a/superset-frontend/src/chartCustomizations/components/TimeGrain/CustomizationTimeGrainsTransformer.integration.test.ts b/superset-frontend/src/chartCustomizations/components/TimeGrain/CustomizationTimeGrainsTransformer.integration.test.ts new file mode 100644 index 00000000000..e85a7b719b9 --- /dev/null +++ b/superset-frontend/src/chartCustomizations/components/TimeGrain/CustomizationTimeGrainsTransformer.integration.test.ts @@ -0,0 +1,153 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ChartCustomization, ChartCustomizationType } from '@superset-ui/core'; +import { getInitialDataMask } from 'src/dataMask/reducer'; +import type { ChartCustomizationsFormItem } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/types'; +import { transformCustomizationForSave } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/transformers'; + +const createTimeGrainCustomizationInput = ( + timeGrains?: string[], +): Omit => ({ + scope: { + rootPath: ['ROOT_ID'], + excluded: [], + }, + name: 'Time Grain Display Control', + filterType: 'chart_customization_timegrain', + dataset: { + value: 10, + label: 'main.dataset', + }, + column: 'dttm', + controlValues: {}, + requiredFirst: {}, + defaultValue: null, + defaultDataMask: getInitialDataMask(), + sortMetric: null, + time_grains: timeGrains, + description: '', +}); + +test('transformCustomizationForSave persists time_grains when a subset is selected', () => { + const transformed = transformCustomizationForSave( + 'CHART_CUSTOMIZATION-subset', + createTimeGrainCustomizationInput([ + 'PT1H', + 'P1D', + 'P1W', + ]) as ChartCustomizationsFormItem, + ); + + expect(transformed).toBeDefined(); + expect(transformed?.type).toBe(ChartCustomizationType.ChartCustomization); + expect(transformed && 'time_grains' in transformed).toBe(true); + expect( + transformed && 'time_grains' in transformed + ? transformed.time_grains + : undefined, + ).toEqual(['PT1H', 'P1D', 'P1W']); +}); + +test('transformCustomizationForSave omits time_grains from API payload when all are selected', () => { + const transformed = transformCustomizationForSave( + 'CHART_CUSTOMIZATION-all', + createTimeGrainCustomizationInput(undefined) as ChartCustomizationsFormItem, + ); + + expect(transformed).toBeDefined(); + expect( + transformed && 'time_grains' in transformed + ? transformed.time_grains + : undefined, + ).toBeUndefined(); + + // API boundary: undefined keys are omitted from JSON payloads. + const serialized = JSON.parse(JSON.stringify(transformed)); + expect(serialized).not.toHaveProperty('time_grains'); +}); + +test('transformCustomizationForSave remains backward compatible when time_grains is missing', () => { + const formInput = createTimeGrainCustomizationInput(); + delete (formInput as Partial).time_grains; + + const transformed = transformCustomizationForSave( + 'CHART_CUSTOMIZATION-legacy', + formInput as ChartCustomizationsFormItem, + ); + + expect(transformed).toBeDefined(); + expect( + transformed && 'time_grains' in transformed + ? transformed.time_grains + : undefined, + ).toBeUndefined(); + + const serialized = JSON.parse(JSON.stringify(transformed)); + expect(serialized).not.toHaveProperty('time_grains'); +}); + +test('transformCustomizationForSave omits time_grains when an empty array is provided', () => { + const transformed = transformCustomizationForSave( + 'CHART_CUSTOMIZATION-empty-array', + createTimeGrainCustomizationInput([]) as ChartCustomizationsFormItem, + ); + + expect(transformed).toBeDefined(); + expect( + transformed && 'time_grains' in transformed + ? transformed.time_grains + : undefined, + ).toBeUndefined(); + + // API boundary: empty allowlist should behave like unrestricted and be omitted. + const serialized = JSON.parse(JSON.stringify(transformed)); + expect(serialized).not.toHaveProperty('time_grains'); +}); + +test('transformCustomizationForSave preserves saved time_grains when reloading an existing customization', () => { + // Simulates the edit round-trip: a previously saved ChartCustomization is + // passed back through the transformer. The allowlist must survive intact so + // the edit modal can restore it. + const savedCustomization: ChartCustomization = { + id: 'CHART_CUSTOMIZATION-existing', + type: ChartCustomizationType.ChartCustomization, + name: 'Time Grain Display Control', + filterType: 'chart_customization_timegrain', + description: '', + targets: [{ datasetId: 10, column: { name: 'dttm' } }], + scope: { rootPath: ['ROOT_ID'], excluded: [] }, + controlValues: {}, + defaultDataMask: getInitialDataMask(), + removed: false, + time_grains: ['PT1H', 'P1D'], + }; + + const transformed = transformCustomizationForSave( + savedCustomization.id, + savedCustomization, + ); + + expect(transformed).toBeDefined(); + expect( + transformed && 'time_grains' in transformed + ? transformed.time_grains + : undefined, + ).toEqual(['PT1H', 'P1D']); +}); diff --git a/superset-frontend/src/chartCustomizations/components/TimeGrain/TimeGrainFilterPlugin.tsx b/superset-frontend/src/chartCustomizations/components/TimeGrain/TimeGrainFilterPlugin.tsx index 5ff909e8375..366d375cf03 100644 --- a/superset-frontend/src/chartCustomizations/components/TimeGrain/TimeGrainFilterPlugin.tsx +++ b/superset-frontend/src/chartCustomizations/components/TimeGrain/TimeGrainFilterPlugin.tsx @@ -97,11 +97,6 @@ export default function PluginFilterTimegrain( handleChange(filterState.value ?? []); }, [JSON.stringify(filterState.value)]); - const placeholderText = - (data || []).length === 0 - ? t('No data') - : tn('%s option', '%s options', data.length, data.length); - const formItemData: FormItemProps = {}; if (filterState.validateMessage) { formItemData.extra = ( @@ -111,15 +106,27 @@ export default function PluginFilterTimegrain( ); } - const options = (data || []).map( - (row: { name: string; duration: string }) => { + const options = (data || []) + .map((row: { name: string; duration: string }) => { const { name, duration } = row; return { label: name, value: duration, }; - }, - ); + }) + // Apply allowlist filter if timeGrains is configured, but keep current selection visible + .filter(option => { + const allowlist = formData.timeGrains; + if (!allowlist || allowlist.length === 0) { + return true; + } + return allowlist.includes(option.value) || value.includes(option.value); + }); + + const placeholderText = + options.length === 0 + ? t('No data') + : tn('%s option', '%s options', options.length, options.length); const sortComparator = useCallback( (a: LabeledValue, b: LabeledValue) => { diff --git a/superset-frontend/src/chartCustomizations/components/TimeGrain/TimeGrainPreFilter.integration.test.tsx b/superset-frontend/src/chartCustomizations/components/TimeGrain/TimeGrainPreFilter.integration.test.tsx new file mode 100644 index 00000000000..ca5c81d0d6c --- /dev/null +++ b/superset-frontend/src/chartCustomizations/components/TimeGrain/TimeGrainPreFilter.integration.test.tsx @@ -0,0 +1,181 @@ +/** + * 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. + */ + +/** + * Integration Test: Time Grain Pre-filter Feature (Customization plugin) + * + * Mirrors src/filters/components/TimeGrain/TimeGrainPreFilter.integration.test.tsx + * but targets the customization plugin at + * src/chartCustomizations/components/TimeGrain/TimeGrainFilterPlugin and adds + * a case for the escape hatch the customization version introduces. + */ + +import { + render, + screen, + userEvent, + waitFor, +} from 'spec/helpers/testing-library'; +import PluginFilterTimegrain from 'src/chartCustomizations/components/TimeGrain/TimeGrainFilterPlugin'; +import type { PluginFilterTimeGrainProps } from 'src/chartCustomizations/components/TimeGrain/types'; + +const data = [ + { duration: 'PT1M', name: 'Minute' }, + { duration: 'PT1H', name: 'Hour' }, + { duration: 'P1D', name: 'Day' }, + { duration: 'P1W', name: 'Week' }, + { duration: 'P1M', name: 'Month' }, +]; + +const baseConfig = { + height: 100, + width: 300, + setFilterActive: jest.fn(), + setHoveredFilter: jest.fn(), + unsetHoveredFilter: jest.fn(), + setFocusedFilter: jest.fn(), + unsetFocusedFilter: jest.fn(), + inputRef: { current: null }, +}; + +/** + * Scenario: Dashboard owner configures a time grain filter to show only Hour, Day, Week. + * End-user opens the dashboard and can only select from those three options. + */ +test('time grain pre-filter restricts dashboard filter options', async () => { + const setDataMask = jest.fn(); + const dashboardConfig: PluginFilterTimeGrainProps = { + ...baseConfig, + data, + formData: { + datasource: '3__table', + height: 100, + width: 300, + nativeFilterId: 'time_grain_1', + defaultValue: null, + viz_type: 'filter_timegrain', + timeGrains: ['PT1H', 'P1D', 'P1W'], + }, + filterState: { + value: null, + validateStatus: undefined, + validateMessage: undefined, + }, + setDataMask, + }; + + render(); + + expect(screen.getByText('3 options')).toBeInTheDocument(); + + setDataMask.mockClear(); + + await userEvent.click(screen.getByRole('combobox')); + + await waitFor(() => { + const labels = screen.getAllByRole('option').map(o => o.textContent); + expect(labels).toEqual(['Hour', 'Day', 'Week']); + expect(labels).not.toContain('Minute'); + expect(labels).not.toContain('Month'); + }); + + await userEvent.click(screen.getByText('Day')); + + await waitFor(() => { + expect(setDataMask).toHaveBeenCalledWith({ + extraFormData: { time_grain_sqla: 'P1D' }, + filterState: { label: 'Day', value: ['P1D'] }, + }); + }); +}); + +/** + * Scenario: Dashboard owner disables pre-filter (unchecks the CollapsibleControl). + * No restrictions: all time grains appear in the runtime filter. + */ +test('all time grains appear when pre-filter is unchecked', async () => { + const dashboardConfig: PluginFilterTimeGrainProps = { + ...baseConfig, + data, + formData: { + datasource: '3__table', + height: 100, + width: 300, + nativeFilterId: 'time_grain_1', + defaultValue: null, + viz_type: 'filter_timegrain', + timeGrains: undefined, + }, + filterState: { + value: null, + validateStatus: undefined, + validateMessage: undefined, + }, + setDataMask: jest.fn(), + }; + + render(); + + await userEvent.click(screen.getByRole('combobox')); + + await waitFor(() => { + const labels = screen.getAllByRole('option').map(o => o.textContent); + expect(labels).toEqual(['Minute', 'Hour', 'Day', 'Week', 'Month']); + }); +}); + +/** + * Scenario: Dashboard owner narrowed the pre-filter after an end-user had + * already selected a value that now falls outside the allowlist. + * The current selection stays visible so the filter does not silently drop it. + */ +test('current selection stays visible when it is outside the pre-filter allowlist', async () => { + const dashboardConfig: PluginFilterTimeGrainProps = { + ...baseConfig, + data, + formData: { + datasource: '3__table', + height: 100, + width: 300, + nativeFilterId: 'time_grain_1', + defaultValue: null, + viz_type: 'filter_timegrain', + timeGrains: ['PT1H', 'P1D', 'P1W'], + }, + filterState: { + value: ['P1M'], + validateStatus: undefined, + validateMessage: undefined, + }, + setDataMask: jest.fn(), + }; + + render(); + + await userEvent.click(screen.getByRole('combobox')); + + await waitFor(() => { + const labels = screen.getAllByRole('option').map(o => o.textContent); + expect(labels).toHaveLength(4); + expect(labels).toEqual( + expect.arrayContaining(['Hour', 'Day', 'Week', 'Month']), + ); + expect(labels).not.toContain('Minute'); + }); +}); diff --git a/superset-frontend/src/chartCustomizations/components/TimeGrain/types.ts b/superset-frontend/src/chartCustomizations/components/TimeGrain/types.ts index 611566f0d1d..3ae7b8efb0e 100644 --- a/superset-frontend/src/chartCustomizations/components/TimeGrain/types.ts +++ b/superset-frontend/src/chartCustomizations/components/TimeGrain/types.ts @@ -24,6 +24,7 @@ import { PluginFilterHooks, PluginFilterStylesProps } from '../types'; interface PluginFilterTimeGrainCustomizeProps { defaultValue?: string[] | null; inputRef?: RefObject; + timeGrains?: string[]; } export type PluginFilterTimeGrainQueryFormData = QueryFormData & diff --git a/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx b/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx index c2297674dba..2f4c8245fdf 100644 --- a/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx +++ b/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx @@ -84,7 +84,7 @@ import { } from 'src/database/actions'; import Mousetrap from 'mousetrap'; import { clearDatasetCache } from 'src/utils/cachedSupersetGet'; -import { makeUrl } from 'src/utils/pathUtils'; +import { makeUrl, openInNewTab } from 'src/utils/navigationUtils'; import { OwnerSelectLabel, OWNER_TEXT_LABEL_PROP, @@ -1181,7 +1181,9 @@ function DatasourceEditor({ }, [datasource]); const openOnSqlLab = useCallback(() => { - window.open(getSQLLabUrl(), '_blank', 'noopener,noreferrer'); + // `getSQLLabUrl()` already runs the path through `makeUrl`; `openInNewTab` + // re-applies `ensureAppRoot`, which is idempotent on already-prefixed paths. + openInNewTab(getSQLLabUrl()); }, [getSQLLabUrl]); const onQueryRun = useCallback(async () => { diff --git a/superset-frontend/src/components/Datasource/components/DatasourceEditor/components/DashboardLinksExternal/DashboardLinksExternal.test.tsx b/superset-frontend/src/components/Datasource/components/DatasourceEditor/components/DashboardLinksExternal/DashboardLinksExternal.test.tsx index 28cac0d17a1..853e8e7d5f5 100644 --- a/superset-frontend/src/components/Datasource/components/DatasourceEditor/components/DashboardLinksExternal/DashboardLinksExternal.test.tsx +++ b/superset-frontend/src/components/Datasource/components/DatasourceEditor/components/DashboardLinksExternal/DashboardLinksExternal.test.tsx @@ -66,7 +66,7 @@ test('renders single dashboard link correctly', () => { const link = screen.getByText('Sales Dashboard'); expect(link).toBeInTheDocument(); - expect(link.closest('a')).toHaveAttribute('href', '/superset/dashboard/1/'); + expect(link.closest('a')).toHaveAttribute('href', '/dashboard/1/'); expect(link.closest('a')).toHaveAttribute('target', '_blank'); }); @@ -98,9 +98,9 @@ test('links have correct href attributes', () => { .getByText(', Very Long Dashboard Name That Should Be Truncated') .closest('a'); - expect(salesLink).toHaveAttribute('href', '/superset/dashboard/1/'); - expect(analyticsLink).toHaveAttribute('href', '/superset/dashboard/2/'); - expect(longNameLink).toHaveAttribute('href', '/superset/dashboard/3/'); + expect(salesLink).toHaveAttribute('href', '/dashboard/1/'); + expect(analyticsLink).toHaveAttribute('href', '/dashboard/2/'); + expect(longNameLink).toHaveAttribute('href', '/dashboard/3/'); }); test('applies correct styling classes', () => { @@ -124,5 +124,5 @@ test('handles dashboard with empty title', () => { const link = screen.getByRole('link'); expect(link).toHaveTextContent(''); - expect(link).toHaveAttribute('href', '/superset/dashboard/1/'); + expect(link).toHaveAttribute('href', '/dashboard/1/'); }); diff --git a/superset-frontend/src/components/Datasource/components/DatasourceEditor/components/DashboardLinksExternal/index.tsx b/superset-frontend/src/components/Datasource/components/DatasourceEditor/components/DashboardLinksExternal/index.tsx index d9b25673dae..0ac14ebbffc 100644 --- a/superset-frontend/src/components/Datasource/components/DatasourceEditor/components/DashboardLinksExternal/index.tsx +++ b/superset-frontend/src/components/Datasource/components/DatasourceEditor/components/DashboardLinksExternal/index.tsx @@ -62,7 +62,7 @@ const DashboardLinksExternal = ({ {dashboards.map((dashboard, index) => ( {index === 0 diff --git a/superset-frontend/src/components/Datasource/components/DatasourceEditor/tests/DatasourceEditor.test.tsx b/superset-frontend/src/components/Datasource/components/DatasourceEditor/tests/DatasourceEditor.test.tsx index 39c68a042b3..64b9d42b8f1 100644 --- a/superset-frontend/src/components/Datasource/components/DatasourceEditor/tests/DatasourceEditor.test.tsx +++ b/superset-frontend/src/components/Datasource/components/DatasourceEditor/tests/DatasourceEditor.test.tsx @@ -25,6 +25,7 @@ import { within, } from 'spec/helpers/testing-library'; import { DatasourceType, isFeatureEnabled } from '@superset-ui/core'; +import * as getBootstrapData from 'src/utils/getBootstrapData'; import { createProps, DATASOURCE_ENDPOINT, @@ -822,3 +823,57 @@ test('calculated column search is case-insensitive', async () => { expect(screen.getByDisplayValue('upper_name')).toBeInTheDocument(); }); }); + +test('Open in SQL lab href is single-prefixed under subdirectory deployment', () => { + // The Open-in-SQL-Lab link's href is produced by `getSQLLabUrl()`: + // return makeUrl(`/sqllab/?${queryParams.toString()}`); + // `makeUrl` is the idempotent app-root prefix helper from + // `src/utils/navigationUtils`. Rendering the link requires both the + // virtual datasourceType state AND a populated Redux `database.queryResult` + // slice (which is not part of the default test reducer tree). Calling + // `makeUrl` directly with a `/superset` mock exercises the exact path the + // component takes and pins the dedupe invariant for the underlying helper. + const applicationRootSpy = jest + .spyOn(getBootstrapData, 'applicationRoot') + .mockReturnValue('/superset'); + try { + const { makeUrl } = jest.requireActual('src/utils/navigationUtils'); + const queryParams = new URLSearchParams({ + dbid: '1', + sql: 'SELECT * FROM users', + name: 'Vehicle Sales', + schema: 'public', + autorun: 'true', + isDataset: 'true', + }); + const url = makeUrl(`/sqllab/?${queryParams.toString()}`); + expect(url).toMatch(/^\/superset\/sqllab\/\?/); + expect(url).not.toMatch(/\/superset\/superset\//); + } finally { + applicationRootSpy.mockRestore(); + } +}); + +test('DatasourceEditor source pins getSQLLabUrl/openOnSqlLab to the makeUrl + openInNewTab helpers', () => { + // Source-pin: lock the exact two-line shape the runtime behaviour depends + // on. `getSQLLabUrl` MUST wrap its `/sqllab/?...` path in `makeUrl` so the + // Layer-2 idempotent prefix runs at the click boundary; `openOnSqlLab` + // MUST delegate to `openInNewTab` so `ensureAppRoot` runs again (idempotent + // dedupe, see `navigationUtils.appRoot.test.tsx`). A refactor that drops + // either layer would let a doubled-prefix URL escape into a new tab. + // eslint-disable-next-line global-require + const { readFileSync } = require('fs'); + // eslint-disable-next-line global-require + const { join } = require('path'); + const src = readFileSync( + join(__dirname, '..', 'DatasourceEditor.tsx'), + 'utf8', + ); + expect(src).toMatch( + /return makeUrl\(`\/sqllab\/\?\$\{queryParams\.toString\(\)\}`\);/, + ); + expect(src).toMatch(/openInNewTab\(getSQLLabUrl\(\)\);/); + expect(src).toMatch( + /import \{ makeUrl, openInNewTab \} from 'src\/utils\/navigationUtils';/, + ); +}); diff --git a/superset-frontend/src/components/FacePile/index.tsx b/superset-frontend/src/components/FacePile/index.tsx index 220209afc2d..5fefd307d25 100644 --- a/superset-frontend/src/components/FacePile/index.tsx +++ b/superset-frontend/src/components/FacePile/index.tsx @@ -24,7 +24,7 @@ import { } from '@superset-ui/core'; import getOwnerName from 'src/utils/getOwnerName'; import { Avatar, AvatarGroup, Tooltip } from '@superset-ui/core/components'; -import { ensureAppRoot } from 'src/utils/pathUtils'; +import { ensureAppRoot } from 'src/utils/navigationUtils'; import { getRandomColor } from './utils'; import type { FacePileProps } from './types'; diff --git a/superset-frontend/src/components/FilterableTable/index.tsx b/superset-frontend/src/components/FilterableTable/index.tsx index dc2f2624da8..d6db0915d9b 100644 --- a/superset-frontend/src/components/FilterableTable/index.tsx +++ b/superset-frontend/src/components/FilterableTable/index.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { useMemo, useCallback, useRef, memo } from 'react'; +import { useMemo, useCallback, memo } from 'react'; import { GridSize } from 'src/components/GridTable/constants'; import { GridTable } from 'src/components/GridTable'; import { type ColDef } from 'src/components/GridTable/types'; @@ -109,15 +109,15 @@ export const FilterableTable = ({ [orderedColumnKeys, allowHTML, getCellContent], ); - const keyword = useRef(filterText); - keyword.current = filterText; - - const keywordFilter = useCallback((node: { data: Datum }) => { - if (keyword.current && node.data) { - return hasMatch(keyword.current, node.data); - } - return true; - }, []); + const keywordFilter = useCallback( + (node: { data: Datum }) => { + if (filterText && node.data) { + return hasMatch(filterText, node.data); + } + return true; + }, + [filterText], + ); return (
diff --git a/superset-frontend/src/components/GridTable/index.tsx b/superset-frontend/src/components/GridTable/index.tsx index cafe7c67736..83acd1fe815 100644 --- a/superset-frontend/src/components/GridTable/index.tsx +++ b/superset-frontend/src/components/GridTable/index.tsx @@ -167,6 +167,24 @@ export function GridTable({ overflow: hidden; } + /* Preserve significant whitespace within cell values (e.g. option + symbols and other whitespace-sensitive data). ag-Grid's default + collapses runs of spaces, which can misrepresent the underlying + value. + + 'pre' is a deliberate trade-off over 'pre-wrap': it keeps values on a + single line so row heights and column sizing stay unchanged. CSS has no + value that preserves spaces while collapsing newlines, so an embedded + newline renders on multiple lines and is clipped by the fixed row + height; truncating such values to the visible row is acceptable here. + overflow/text-overflow keep over-long single-line values clipped with + an ellipsis rather than overflowing the cell. */ + .ag-cell-value { + white-space: pre; + overflow: hidden; + text-overflow: ellipsis; + } + & [role='columnheader']:hover .customHeaderAction { display: flex; } diff --git a/superset-frontend/src/components/ListView/CardCollection.tsx b/superset-frontend/src/components/ListView/CardCollection.tsx index d3e8999713a..b77e9f475f4 100644 --- a/superset-frontend/src/components/ListView/CardCollection.tsx +++ b/superset-frontend/src/components/ListView/CardCollection.tsx @@ -35,7 +35,7 @@ const CardContainer = styled.div<{ showThumbnails?: boolean }>` display: grid; justify-content: start; grid-gap: ${theme.sizeUnit * 12}px ${theme.sizeUnit * 4}px; - grid-template-columns: repeat(auto-fit, 300px); + grid-template-columns: repeat(auto-fit, ${theme.sizeUnit * 75}px); margin-top: ${theme.sizeUnit * -6}px; padding: ${ showThumbnails diff --git a/superset-frontend/src/components/ListView/CrossLinks.test.tsx b/superset-frontend/src/components/ListView/CrossLinks.test.tsx index 403b94a084c..ae2b5f0ec8f 100644 --- a/superset-frontend/src/components/ListView/CrossLinks.test.tsx +++ b/superset-frontend/src/components/ListView/CrossLinks.test.tsx @@ -68,10 +68,9 @@ test('should render the link with just one item', () => { ], }); expect(screen.getByText('Test dashboard')).toBeInTheDocument(); - expect(screen.getByRole('link')).toHaveAttribute( - 'href', - `/superset/dashboard/1`, - ); + // default `linkPrefix` is now `/dashboard/` (post-route_base); + // legacy `/superset/dashboard/...` was the pre-collapse route. + expect(screen.getByRole('link')).toHaveAttribute('href', `/dashboard/1`); }); test('should render a custom prefix link', () => { diff --git a/superset-frontend/src/components/ListView/CrossLinks.tsx b/superset-frontend/src/components/ListView/CrossLinks.tsx index 6f4cd6d262b..d3f6f0a474c 100644 --- a/superset-frontend/src/components/ListView/CrossLinks.tsx +++ b/superset-frontend/src/components/ListView/CrossLinks.tsx @@ -65,7 +65,7 @@ const StyledCrossLinks = styled.div` function CrossLinks({ crossLinks, maxLinks = 20, - linkPrefix = '/superset/dashboard/', + linkPrefix = '/dashboard/', external = false, }: CrossLinksProps) { const [crossLinksRef, plusRef, elementsTruncated, hasHiddenElements] = diff --git a/superset-frontend/src/components/StreamingExportModal/useStreamingExport.ts b/superset-frontend/src/components/StreamingExportModal/useStreamingExport.ts index 4a8aa0fe0c3..617e93b9288 100644 --- a/superset-frontend/src/components/StreamingExportModal/useStreamingExport.ts +++ b/superset-frontend/src/components/StreamingExportModal/useStreamingExport.ts @@ -19,7 +19,7 @@ import { useState, useCallback, useRef, useEffect } from 'react'; import { SupersetClient } from '@superset-ui/core'; import { ExportStatus, StreamingProgress } from './StreamingExportModal'; -import { makeUrl } from 'src/utils/pathUtils'; +import { makeUrl } from 'src/utils/navigationUtils'; import { applicationRoot } from 'src/utils/getBootstrapData'; interface UseStreamingExportOptions { @@ -38,8 +38,8 @@ interface StreamingExportParams { * The API endpoint URL for the export request. * * URLs should be prefixed with the application root at the call site using - * `makeUrl()` from 'src/utils/pathUtils'. This ensures proper handling for - * subdirectory deployments (e.g., /superset/api/v1/...). + * `makeUrl()` from `src/utils/navigationUtils`. This ensures proper handling + * for subdirectory deployments (e.g., /superset/api/v1/...). * * A defensive guard (`ensureUrlPrefix`) will apply the prefix if missing, * but callers should not rely on this fallback behavior. diff --git a/superset-frontend/src/components/Tag/index.tsx b/superset-frontend/src/components/Tag/index.tsx index d09334e175b..0605977bc08 100644 --- a/superset-frontend/src/components/Tag/index.tsx +++ b/superset-frontend/src/components/Tag/index.tsx @@ -82,7 +82,7 @@ const SupersetTag = ({ {' '} {id ? ( diff --git a/superset-frontend/src/core/navigation/index.test.ts b/superset-frontend/src/core/navigation/index.test.ts index e7c2fbe183d..c717d3779ef 100644 --- a/superset-frontend/src/core/navigation/index.test.ts +++ b/superset-frontend/src/core/navigation/index.test.ts @@ -35,12 +35,12 @@ test('getPage falls back to "home" for the welcome page and unknown pathnames', const { navigation, notifyLocationChanged } = await importNavigation(); // The default pathname ('/') is not enumerated and falls back to home. expect(navigation.getPage()).toBe('home'); - notifyLocationChanged('/superset/welcome/'); + notifyLocationChanged('/welcome/'); expect(navigation.getPage()).toBe('home'); }); test('getPage derives the page from window.location.pathname', async () => { - window.location.pathname = '/superset/dashboard/42/'; + window.location.pathname = '/dashboard/42/'; const { navigation } = await importNavigation(); expect(navigation.getPage()).toBe('dashboard'); }); @@ -56,19 +56,19 @@ test('notifyLocationChanged fires listeners on page type change', async () => { const listener = jest.fn(); const disposable = navigation.onDidChangePage(listener); - notifyLocationChanged('/superset/dashboard/1/'); + notifyLocationChanged('/dashboard/1/'); expect(listener).toHaveBeenCalledWith('dashboard'); disposable.dispose(); }); test('notifyLocationChanged does not fire listeners when page type is unchanged', async () => { - window.location.pathname = '/superset/dashboard/1/'; + window.location.pathname = '/dashboard/1/'; const { navigation, notifyLocationChanged } = await importNavigation(); const listener = jest.fn(); navigation.onDidChangePage(listener); - notifyLocationChanged('/superset/dashboard/2/'); + notifyLocationChanged('/dashboard/2/'); expect(listener).not.toHaveBeenCalled(); }); @@ -78,7 +78,7 @@ test('onDidChangePage listener is removed after dispose', async () => { const disposable = navigation.onDidChangePage(listener); disposable.dispose(); - notifyLocationChanged('/superset/dashboard/1/'); + notifyLocationChanged('/dashboard/1/'); expect(listener).not.toHaveBeenCalled(); }); diff --git a/superset-frontend/src/core/navigation/index.ts b/superset-frontend/src/core/navigation/index.ts index d41e76e50bf..2e2a61d3fb4 100644 --- a/superset-frontend/src/core/navigation/index.ts +++ b/superset-frontend/src/core/navigation/index.ts @@ -36,8 +36,12 @@ type Page = navigationApi.Page; /** Maps route path patterns to their corresponding Page type. */ const PAGE_ROUTES: { path: string; page: Page }[] = [ - { path: RoutePaths.DASHBOARD, page: 'dashboard' }, + // List routes must precede their parameterised detail counterparts: with + // prefix-free paths, `matchPath(exact: false)` lets `/dashboard/:idOrSlug/` + // greedily capture `/dashboard/list/` (idOrSlug='list'), so the more specific + // list route has to win first — mirroring the `routes.tsx` Switch precedence. { path: RoutePaths.DASHBOARD_LIST, page: 'dashboard_list' }, + { path: RoutePaths.DASHBOARD, page: 'dashboard' }, { path: RoutePaths.QUERY_HISTORY, page: 'query_history' }, { path: RoutePaths.SAVED_QUERIES, page: 'saved_queries' }, { path: RoutePaths.SQLLAB, page: 'sqllab' }, diff --git a/superset-frontend/src/dashboard/actions/dashboardState.test.ts b/superset-frontend/src/dashboard/actions/dashboardState.test.ts index 34736df6513..89af8b335f5 100644 --- a/superset-frontend/src/dashboard/actions/dashboardState.test.ts +++ b/superset-frontend/src/dashboard/actions/dashboardState.test.ts @@ -54,7 +54,7 @@ import { } from 'spec/fixtures/mockSliceEntities'; import { emptyFilters } from 'spec/fixtures/mockDashboardFilters'; import mockDashboardData from 'spec/fixtures/mockDashboardData'; -import { navigateTo } from 'src/utils/navigationUtils'; +import { navigateTo, navigateWithState } from 'src/utils/navigationUtils'; jest.mock('@superset-ui/core', () => ({ ...jest.requireActual('@superset-ui/core'), @@ -72,6 +72,7 @@ jest.mock('src/utils/navigationUtils', () => ({ const mockIsFeatureEnabled = isFeatureEnabled as jest.Mock; const mockNavigateTo = navigateTo as jest.Mock; +const mockNavigateWithState = navigateWithState as jest.Mock; // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('dashboardState actions', () => { @@ -253,7 +254,48 @@ describe('dashboardState actions', () => { await waitFor(() => expect(postStub.mock.calls.length).toBe(1)); expect(mockNavigateTo).toHaveBeenCalledWith( - `/superset/dashboard/${newDashboardId}/`, + `/dashboard/${newDashboardId}/`, + ); + }); + + // `navigateWithState` regression for the + // dashboard-properties-changed save path. Two assertions in one shape: + // (a) the emitted path is router-relative (`/dashboard//`), not + // the pre-migration `/superset/dashboard//` literal that under + // subdirectory deployment would double-prefix to + // `/superset/superset/dashboard//`; + // (b) the `event: 'dashboard_properties_changed'` history-state arg is + // preserved verbatim. A previous attempt to swap `navigateWithState` + // for a plain `navigateTo` would silently drop this state object and + // the dashboard would lose its post-save UX cue. + test('saves dashboard properties via navigateWithState with state preserved', async () => { + const updatedId = 777; + const { getState, dispatch } = setup({ + dashboardState: { hasUnsavedChanges: true }, + }); + + mockNavigateWithState.mockClear(); + putStub.mockRestore(); + putStub = jest.spyOn(SupersetClient, 'put').mockResolvedValue({ + json: { + result: { ...mockDashboardData, id: updatedId, slug: null }, + last_modified_time: 0, + }, + } as any); + + const thunk = saveDashboardRequest( + newDashboardData, + updatedId, + SAVE_TYPE_OVERWRITE, + ); + await thunk(dispatch, getState); + + await waitFor(() => expect(putStub.mock.calls.length).toBe(1)); + await waitFor(() => expect(mockNavigateWithState).toHaveBeenCalled()); + + expect(mockNavigateWithState).toHaveBeenCalledWith( + `/dashboard/${updatedId}/`, + { event: 'dashboard_properties_changed' }, ); }); }); @@ -513,13 +555,11 @@ describe('dashboardState actions', () => { }); getStub.mockRestore(); - getStub = jest - .spyOn(SupersetClient, 'get') - .mockRejectedValue( - new Response(JSON.stringify({ message: 'Not found' }), { - status: 404, - }), - ); + getStub = jest.spyOn(SupersetClient, 'get').mockRejectedValue( + new Response(JSON.stringify({ message: 'Not found' }), { + status: 404, + }), + ); await fetchFaveStar(id)(dispatch, getState); @@ -536,13 +576,11 @@ describe('dashboardState actions', () => { }); getStub.mockRestore(); - getStub = jest - .spyOn(SupersetClient, 'get') - .mockRejectedValue( - new Response(JSON.stringify({ message: 'Server error' }), { - status: 500, - }), - ); + getStub = jest.spyOn(SupersetClient, 'get').mockRejectedValue( + new Response(JSON.stringify({ message: 'Server error' }), { + status: 500, + }), + ); await fetchFaveStar(id)(dispatch, getState); diff --git a/superset-frontend/src/dashboard/actions/dashboardState.ts b/superset-frontend/src/dashboard/actions/dashboardState.ts index a5af637cce1..397e29a2d7f 100644 --- a/superset-frontend/src/dashboard/actions/dashboardState.ts +++ b/superset-frontend/src/dashboard/actions/dashboardState.ts @@ -584,9 +584,7 @@ export function saveDashboardRequest( }), ); dispatch(saveDashboardFinished()); - navigateTo( - `/superset/dashboard/${(response.json as JsonObject).result?.id}/`, - ); + navigateTo(`/dashboard/${(response.json as JsonObject).result?.id}/`); dispatch(addSuccessToast(t('This dashboard was saved successfully.'))); return response; }; @@ -639,7 +637,7 @@ export function saveDashboardRequest( } dispatch(saveDashboardFinished()); // redirect to the new slug or id - navigateWithState(`/superset/dashboard/${slug || id}/`, { + navigateWithState(`/dashboard/${slug || id}/`, { event: 'dashboard_properties_changed', }); diff --git a/superset-frontend/src/dashboard/actions/datasources.ts b/superset-frontend/src/dashboard/actions/datasources.ts index f48c1242845..adb5d057be2 100644 --- a/superset-frontend/src/dashboard/actions/datasources.ts +++ b/superset-frontend/src/dashboard/actions/datasources.ts @@ -62,7 +62,7 @@ export function fetchDatasourceMetadata(key: string) { } return SupersetClient.get({ - endpoint: `/superset/fetch_datasource_metadata?datasourceKey=${key}`, + endpoint: `/fetch_datasource_metadata?datasourceKey=${key}`, }).then(({ json }) => dispatch(setDatasource(json as Datasource, key))); }; } diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.tsx index 0a934a37e2f..a3be526a82d 100644 --- a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.tsx +++ b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.tsx @@ -48,7 +48,7 @@ import * as useNativeFiltersModule from './state'; fetchMock.get('glob:*/csstemplateasyncmodelview/api/read', {}); fetchMock.put('glob:*/api/v1/dashboard/*', {}); // Add mock for logging endpoint -fetchMock.post('glob:*/superset/log/?*', {}); +fetchMock.post('glob:*/log/?*', {}); jest.mock('src/dashboard/actions/dashboardState', () => ({ ...jest.requireActual('src/dashboard/actions/dashboardState'), diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx index 3fbfc5d10a5..a813999fc77 100644 --- a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx +++ b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx @@ -316,7 +316,7 @@ const StyledDashboardContent = styled.div<{ .dashboard-component-chart-holder { width: 100%; height: 100%; - background-color: ${theme.colorBgContainer}; + background-color: ${theme.dashboardTileBg ?? theme.colorBgContainer}; position: relative; padding: ${theme.sizeUnit * 4}px; box-sizing: border-box; @@ -336,8 +336,11 @@ const StyledDashboardContent = styled.div<{ } &.fade-out { - border-radius: ${theme.borderRadius}px; - box-shadow: 0 0 0 1px ${addAlpha(theme.colorBorder, 0.5)}; + border: ${theme.dashboardTileBorder ?? 'none'}; + border-radius: ${theme.dashboardTileBorderRadius ?? + theme.borderRadius}px; + box-shadow: ${theme.dashboardTileBoxShadow ?? + `0 0 0 1px ${addAlpha(theme.colorBorder, 0.5)}`}; } & .missing-chart-container { diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.test.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.test.tsx index 5f70f3319e6..d941b9f2324 100644 --- a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.test.tsx +++ b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.test.tsx @@ -29,7 +29,7 @@ import * as chartCustomizationActions from '../../actions/chartCustomizationActi fetchMock.get('glob:*/csstemplateasyncmodelview/api/read', {}); fetchMock.put('glob:*/api/v1/dashboard/*/colors*', {}); -fetchMock.post('glob:*/superset/log/?*', {}); +fetchMock.post('glob:*/log/?*', {}); jest.mock('@visx/responsive', () => ({ useParentSize: () => ({ parentRef: { current: null }, width: 800 }), diff --git a/superset-frontend/src/dashboard/components/EmbeddedModal/EmbeddedModal.test.tsx b/superset-frontend/src/dashboard/components/EmbeddedModal/EmbeddedModal.test.tsx index 492654ede34..7c0fc78384f 100644 --- a/superset-frontend/src/dashboard/components/EmbeddedModal/EmbeddedModal.test.tsx +++ b/superset-frontend/src/dashboard/components/EmbeddedModal/EmbeddedModal.test.tsx @@ -139,6 +139,18 @@ test('shows and hides confirmation alert when deactivating', async () => { }); }); +test('populates the allowed-domains input from the API response', async () => { + setup(); + + const allowedDomainsInput = (await screen.findByRole('textbox', { + name: /Allowed Domains/i, + })) as HTMLInputElement; + + await waitFor(() => { + expect(allowedDomainsInput.value).toBe('example.com'); + }); +}); + test('enables Save Changes button when allowed domains are modified', async () => { setup(); diff --git a/superset-frontend/src/dashboard/components/EmbeddedModal/index.tsx b/superset-frontend/src/dashboard/components/EmbeddedModal/index.tsx index 6ff78d794c0..4c9c7c7d9a0 100644 --- a/superset-frontend/src/dashboard/components/EmbeddedModal/index.tsx +++ b/superset-frontend/src/dashboard/components/EmbeddedModal/index.tsx @@ -197,7 +197,7 @@ export const DashboardEmbedControls = ({ dashboardId, onHide }: Props) => {

{t('Settings')}

{t('Allowed Domains (comma separated)')}{' '} diff --git a/superset-frontend/src/dashboard/components/FiltersBadge/DetailsPanel/DetailsPanel.test.tsx b/superset-frontend/src/dashboard/components/FiltersBadge/DetailsPanel/DetailsPanel.test.tsx index d1090b0ed97..b20ac55f74e 100644 --- a/superset-frontend/src/dashboard/components/FiltersBadge/DetailsPanel/DetailsPanel.test.tsx +++ b/superset-frontend/src/dashboard/components/FiltersBadge/DetailsPanel/DetailsPanel.test.tsx @@ -16,6 +16,7 @@ * specific language governing permissions and limitations * under the License. */ +/// import { RefObject } from 'react'; import { fireEvent, @@ -216,6 +217,22 @@ test('Close popover with ESC or ENTER', async () => { expect(props.setPopoverVisible).toHaveBeenCalledWith(false); }); +test('Popover container suppresses default browser focus outline', () => { + const props = createProps(); + render( + +
Content
+
, + { useRedux: true }, + ); + + const menu = screen.getByRole('menu'); + expect(menu).toHaveStyleRule('outline', 'none', { target: ':focus' }); + expect(menu).toHaveStyleRule('outline', 'none', { + target: ':focus-visible', + }); +}); + test('Arrow key navigation switches focus between indicators', () => { // Prepare props with two indicators const props = createProps(); diff --git a/superset-frontend/src/dashboard/components/FiltersBadge/Styles.tsx b/superset-frontend/src/dashboard/components/FiltersBadge/Styles.tsx index 8009520cb42..6366d51f9b4 100644 --- a/superset-frontend/src/dashboard/components/FiltersBadge/Styles.tsx +++ b/superset-frontend/src/dashboard/components/FiltersBadge/Styles.tsx @@ -118,6 +118,18 @@ export const FiltersDetailsContainer = styled.div` overflow-x: hidden; color: ${theme.colorText}; + + /* + * The container is a non-interactive wrapper that receives focus + * programmatically only to capture keyboard navigation events. Suppress the + * default browser focus outline so the popover does not show a blue ring. + * Focusable items inside (FilterItem) provide their own :focus-visible + * styles for keyboard accessibility. + */ + &:focus, + &:focus-visible { + outline: none; + } `} `; diff --git a/superset-frontend/src/dashboard/components/Header/Header.test.tsx b/superset-frontend/src/dashboard/components/Header/Header.test.tsx index df0a4bc8799..78d6dd71f76 100644 --- a/superset-frontend/src/dashboard/components/Header/Header.test.tsx +++ b/superset-frontend/src/dashboard/components/Header/Header.test.tsx @@ -547,6 +547,34 @@ test('should save', () => { expect(onSave).toHaveBeenCalledTimes(1); }); +test('should block saving and surface the size, limit, and config key when the layout exceeds the limit', () => { + const oversizedState = { + ...editableState, + dashboardState: { + ...editableState.dashboardState, + hasUnsavedChanges: true, + }, + dashboardInfo: { + ...editableState.dashboardInfo, + common: { + conf: { + ...editableState.dashboardInfo.common.conf, + // any non-empty layout serializes to more than 1 character + SUPERSET_DASHBOARD_POSITION_DATA_LIMIT: 1, + }, + }, + }, + }; + setup(oversizedState); + userEvent.click(screen.getByText('Save')); + expect(onSave).not.toHaveBeenCalled(); + expect(addDangerToast).toHaveBeenCalledTimes(1); + const message = addDangerToast.mock.calls[0][0]; + expect(message).toContain('too large to save'); + expect(message).toContain('the limit is 1'); + expect(message).toContain('SUPERSET_DASHBOARD_POSITION_DATA_LIMIT'); +}); + test('should NOT render the "Draft" status', () => { const publishedState = { ...initialState, diff --git a/superset-frontend/src/dashboard/components/Header/index.tsx b/superset-frontend/src/dashboard/components/Header/index.tsx index 6131b5b31ab..1f0e4ff8982 100644 --- a/superset-frontend/src/dashboard/components/Header/index.tsx +++ b/superset-frontend/src/dashboard/components/Header/index.tsx @@ -468,7 +468,9 @@ const Header = (): JSX.Element => { if (positionJSONLength >= limit) { boundActionCreators.addDangerToast( t( - 'Your dashboard is too large. Please reduce its size before saving it.', + 'Your dashboard is too large to save: the serialized layout length is %s but the limit is %s. Reduce the dashboard size (for example, split it into multiple dashboards) or raise the SUPERSET_DASHBOARD_POSITION_DATA_LIMIT config setting.', + positionJSONLength.toLocaleString(), + limit.toLocaleString(), ), ); } else { diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.subdirectory.test.tsx b/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.subdirectory.test.tsx new file mode 100644 index 00000000000..e58f036b3cc --- /dev/null +++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.subdirectory.test.tsx @@ -0,0 +1,172 @@ +/** + * 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, screen, userEvent } from 'spec/helpers/testing-library'; +import { VizType } from '@superset-ui/core'; +import mockState from 'spec/fixtures/mockState'; +import SliceHeaderControls, { SliceHeaderControlsProps } from '.'; + +// Subdirectory-specific regressions live here so the existing 676-line +// SliceHeaderControls.test.tsx doesn't need to mock getBootstrapData. + +// DO NOT switch this file to +// `spec/helpers/withApplicationRoot.ts`. The fixture does +// `jest.resetModules()` + dynamic `import('src/utils/getBootstrapData')` to +// install a fixture-configured applicationRoot. But `SliceHeaderControls` is +// imported statically at the top of this file; its transitive dependency +// chain (`SliceHeaderControls` → `navigationUtils` → `pathUtils` → +// `getBootstrapData::applicationRoot`) is bound to the pre-reset module +// instance. After `withApplicationRoot('/superset')` resets modules and +// re-imports getBootstrapData on the test side, the statically-imported +// component continues to reach the OLD module whose `application_root` was +// empty at first evaluation — so the rendered tree resolves +// `applicationRoot()` to `''`, NOT `/superset`. Gate (a) of the M7 go/no-go +// fails ("the rendered SliceHeaderControls tree must resolve +// applicationRoot() to the fixture-configured value"). The hand-rolled +// `jest.mock('src/utils/getBootstrapData', ...)` below remains until a +// later slice either (i) defers the SliceHeaderControls import into the +// withApplicationRoot callback or (ii) plumbs application_root through +// React context rather than a module-scoped cache. + +// Name must start with `mock` so Jest's hoisted jest.mock() factory may +// reference it. `default` returns a static shape (not mockApplicationRoot) +// because consumers like setupClient.ts call getBootstrapData() at import +// time — calling mockApplicationRoot inside `default` hits TDZ. +const mockApplicationRoot = jest.fn(() => ''); + +jest.mock('src/utils/getBootstrapData', () => ({ + __esModule: true, + default: () => ({ + common: { application_root: '', static_assets_prefix: '' }, + }), + applicationRoot: () => mockApplicationRoot(), + staticAssetsPrefix: () => '', +})); + +const SLICE_ID = 371; + +const buildProps = (): SliceHeaderControlsProps => + ({ + addDangerToast: jest.fn(), + addSuccessToast: jest.fn(), + exploreChart: jest.fn(), + exportCSV: jest.fn(), + exportFullCSV: jest.fn(), + exportXLSX: jest.fn(), + exportFullXLSX: jest.fn(), + exportPivotExcel: jest.fn(), + forceRefresh: jest.fn(), + handleToggleFullSize: jest.fn(), + toggleExpandSlice: jest.fn(), + logEvent: jest.fn(), + logExploreChart: jest.fn(), + slice: { + slice_id: SLICE_ID, + slice_url: '/explore/?form_data=%7B%22slice_id%22%3A%20371%7D', + slice_name: 'Subdirectory regression chart', + slice_description: '', + form_data: { + slice_id: SLICE_ID, + datasource: '58__table', + viz_type: VizType.Sunburst, + }, + viz_type: VizType.Sunburst, + datasource: '58__table', + description: '', + description_markeddown: '', + owners: [], + modified: '', + changed_on: 0, + }, + isCached: [false], + isExpanded: false, + cachedDttm: [''], + updatedDttm: 0, + supersetCanExplore: true, + supersetCanCSV: true, + componentId: 'CHART-subdir', + dashboardId: 26, + isFullSize: false, + chartStatus: 'rendered', + showControls: true, + supersetCanShare: true, + formData: { + slice_id: SLICE_ID, + datasource: '58__table', + viz_type: VizType.Sunburst, + }, + exploreUrl: '/explore/?dashboard_page_id=abc&slice_id=371', + defaultOpen: true, + }) as unknown as SliceHeaderControlsProps; + +const renderControls = (): void => { + render(, { + useRedux: true, + useRouter: true, + initialState: { + ...mockState, + user: { + ...mockState.user, + roles: { Admin: [['can_samples', 'Datasource']] }, + }, + }, + }); +}; + +describe('SliceHeaderControls — Cmd-click "Edit chart" under subdirectory deployment', () => { + let openSpy: jest.SpyInstance; + + beforeEach(() => { + mockApplicationRoot.mockReturnValue(''); + openSpy = jest.spyOn(window, 'open').mockImplementation(() => null); + }); + + afterEach(() => { + openSpy.mockRestore(); + }); + + test('opens the unprefixed exploreUrl when application root is empty', async () => { + mockApplicationRoot.mockReturnValue(''); + renderControls(); + + userEvent.click(screen.getByRole('button', { name: 'More Options' })); + const editChart = await screen.findByText('Edit chart'); + userEvent.click(editChart, { metaKey: true }); + + expect(openSpy).toHaveBeenCalledWith( + '/explore/?dashboard_page_id=abc&slice_id=371', + '_blank', + 'noopener noreferrer', + ); + }); + + test('opens the prefixed exploreUrl when deployed under a subdirectory', async () => { + mockApplicationRoot.mockReturnValue('/superset'); + renderControls(); + + userEvent.click(screen.getByRole('button', { name: 'More Options' })); + const editChart = await screen.findByText('Edit chart'); + userEvent.click(editChart, { metaKey: true }); + + expect(openSpy).toHaveBeenCalledWith( + '/superset/explore/?dashboard_page_id=abc&slice_id=371', + '_blank', + 'noopener noreferrer', + ); + }); +}); diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.test.tsx b/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.test.tsx index bc3adeb1a33..571f0f695e1 100644 --- a/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.test.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.test.tsx @@ -674,3 +674,115 @@ test('Should pass formData to Share menu for embed code feature', () => { openMenu(); expect(screen.getByText('Share')).toBeInTheDocument(); }); + +test('Should show single fetched query tooltip with timestamp', async () => { + const updatedDttm = Date.parse('2024-01-28T10:00:00.000Z'); + const props = createProps(); + props.isCached = [false]; + props.cachedDttm = ['']; + props.updatedDttm = updatedDttm; + + renderWrapper(props); + openMenu(); + + const refreshButton = screen.getByText('Force refresh'); + expect(refreshButton).toBeInTheDocument(); + + userEvent.hover(refreshButton); + expect(await screen.findByText(/Fetched/)).toBeInTheDocument(); +}); + +test('Should show single cached query tooltip with timestamp', async () => { + const cachedDttm = '2024-01-28T10:00:00.000Z'; + const props = createProps(); + props.isCached = [true]; + props.cachedDttm = [cachedDttm]; + props.updatedDttm = null; + + renderWrapper(props); + openMenu(); + + const refreshButton = screen.getByText('Force refresh'); + expect(refreshButton).toBeInTheDocument(); + + userEvent.hover(refreshButton); + expect(await screen.findByText(/Cached/)).toBeInTheDocument(); +}); + +test('Should show multiple per-query tooltips when all queries are fetched', async () => { + const cachedDttm1 = ''; + const cachedDttm2 = ''; + const updatedDttm = Date.parse('2024-01-28T10:10:00.000Z'); + const props = createProps(VizType.Table); + props.isCached = [false, false]; + props.cachedDttm = [cachedDttm1, cachedDttm2]; + props.updatedDttm = updatedDttm; + + renderWrapper(props); + openMenu(); + + const refreshButton = screen.getByText('Force refresh'); + expect(refreshButton).toBeInTheDocument(); + + userEvent.hover(refreshButton); + expect(await screen.findByText(/Fetched/)).toBeInTheDocument(); +}); + +test('Should show multiple per-query tooltips when all queries are cached', async () => { + const cachedDttm1 = '2025-01-28T10:00:00.000Z'; + const cachedDttm2 = '2024-01-28T10:05:00.000Z'; + const props = createProps(VizType.Table); + props.isCached = [true, true]; + props.cachedDttm = [cachedDttm1, cachedDttm2]; + props.updatedDttm = null; + + renderWrapper(props); + openMenu(); + + const refreshButton = screen.getByText('Force refresh'); + expect(refreshButton).toBeInTheDocument(); + + userEvent.hover(refreshButton); + expect(await screen.findByText(/Query 1: Cached/)).toBeInTheDocument(); + expect(await screen.findByText(/Query 2: Cached/)).toBeInTheDocument(); +}); + +test('Should deduplicate identical cache times in tooltip', async () => { + const sameCachedDttm = '2024-01-28T10:00:00.000Z'; + const props = createProps(VizType.Table); + props.isCached = [true, true]; + props.cachedDttm = [sameCachedDttm, sameCachedDttm]; + props.updatedDttm = null; + + renderWrapper(props); + openMenu(); + + const refreshButton = screen.getByText('Force refresh'); + expect(refreshButton).toBeInTheDocument(); + + userEvent.hover(refreshButton); + expect(await screen.findByText(/Cached/)).toBeInTheDocument(); +}); + +test('Should handle three or more queries with different cache states', async () => { + const cachedDttm1 = '2024-01-28T10:00:00.000Z'; + const cachedDttm2 = '2024-01-28T10:05:00.000Z'; + const cachedDttm3 = ''; + const updatedDttm = Date.parse('2024-01-28T10:15:00.000Z'); + const props = createProps(VizType.Table); + props.isCached = [true, false, true]; + props.cachedDttm = [cachedDttm1, cachedDttm2, cachedDttm3]; + props.updatedDttm = updatedDttm; + + renderWrapper(props); + openMenu(); + + const refreshButton = screen.getByText('Force refresh'); + expect(refreshButton).toBeInTheDocument(); + + userEvent.hover(refreshButton); + + expect(await screen.findByText(/Query 1:/)).toBeInTheDocument(); + expect(await screen.findByText(/Query 2:/)).toBeInTheDocument(); + expect(await screen.findByText(/Query 3:/)).toBeInTheDocument(); +}); diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx index 508b83c0bc1..f4ca1656b92 100644 --- a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx @@ -57,6 +57,7 @@ import { useDrillDetailMenuItems } from 'src/components/Chart/useDrillDetailMenu import { LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE } from 'src/logger/LogUtils'; import { MenuKeys, RootState } from 'src/dashboard/types'; import DrillDetailModal from 'src/components/Chart/DrillDetail/DrillDetailModal'; +import { openInNewTab } from 'src/utils/navigationUtils'; import { usePermissions } from 'src/hooks/usePermissions'; import { useDatasetDrillInfo } from 'src/hooks/apiResources/datasets'; import { ResourceStatus } from 'src/hooks/apiResources/apiResources'; @@ -263,7 +264,7 @@ const SliceHeaderControls = ( props.logExploreChart?.(props.slice.slice_id); if (domEvent.metaKey || domEvent.ctrlKey) { domEvent.preventDefault(); - window.open(props.exploreUrl, '_blank'); + openInNewTab(props.exploreUrl); } else { history.push(props.exploreUrl); } @@ -380,17 +381,24 @@ const SliceHeaderControls = ( const updatedWhen = updatedDttm ? (extendedDayjs.utc(updatedDttm) as any).fromNow() : ''; - const getCachedTitle = (itemCached: boolean) => { + const getCachedTitle = (itemCached: boolean, index: number) => { if (itemCached) { - return t('Cached %s', cachedWhen); + return t('Cached %s', cachedWhen[index]); } if (updatedWhen) { return t('Fetched %s', updatedWhen); } return ''; }; - const refreshTooltipData = [...new Set(isCached.map(getCachedTitle) || '')]; - // If all queries have same cache time we can unit them to one + const refreshTooltipData = (() => { + const titles = isCached.map((itemCached, index) => + getCachedTitle(itemCached, index), + ); + // Collapse to a single entry only when every query shares the same + // cache/fetch time; otherwise keep the per-query list so the "Query N" + // numbering stays aligned with the original query order. + return new Set(titles).size === 1 ? [titles[0]] : titles; + })(); const refreshTooltip = refreshTooltipData.map((item, index) => (
{refreshTooltipData.length > 1 @@ -663,8 +671,10 @@ const SliceHeaderControls = ( aria-label={t('More Options')} aria-haspopup="true" css={theme => css` - padding: ${theme.sizeUnit * 2}px; - padding-right: 0px; + width: ${theme.sizeUnit * 8}px; + height: ${theme.sizeUnit * 8}px; + padding: 0; + margin-right: -${theme.sizeUnit * 2}px; `} > diff --git a/superset-frontend/src/dashboard/components/URLShortLinkButton/URLShortLinkButton.test.tsx b/superset-frontend/src/dashboard/components/URLShortLinkButton/URLShortLinkButton.test.tsx index fe4d92cea0d..326a3bd3aa5 100644 --- a/superset-frontend/src/dashboard/components/URLShortLinkButton/URLShortLinkButton.test.tsx +++ b/superset-frontend/src/dashboard/components/URLShortLinkButton/URLShortLinkButton.test.tsx @@ -26,6 +26,9 @@ const PERMALINK_PAYLOAD = { key: '123', url: 'http://fakeurl.com/123', }; +// rewritePermalinkOrigin substitutes window.location.origin (jsdom: http://localhost) +// for the permalink's origin while preserving the path. See urlUtils.ts. +const REWRITTEN_URL = `${window.location.origin}/123`; const FILTER_STATE_PAYLOAD = { value: '{}', }; @@ -58,9 +61,7 @@ test('renders overlay on click', async () => { test('obtains short url', async () => { render(, { useRedux: true }); userEvent.click(screen.getByRole('button')); - expect(await screen.findByRole('tooltip')).toHaveTextContent( - PERMALINK_PAYLOAD.url, - ); + expect(await screen.findByRole('tooltip')).toHaveTextContent(REWRITTEN_URL); }); test('creates email anchor', async () => { @@ -78,7 +79,7 @@ test('creates email anchor', async () => { }, ); - const href = `mailto:?Subject=${subject}%20&Body=${content}${PERMALINK_PAYLOAD.url}`; + const href = `mailto:?Subject=${subject}%20&Body=${content}${REWRITTEN_URL}`; userEvent.click(screen.getByRole('button')); expect(await screen.findByRole('link')).toHaveAttribute('href', href); }); diff --git a/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.subdirectory.test.tsx b/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.subdirectory.test.tsx new file mode 100644 index 00000000000..c26c807dd5e --- /dev/null +++ b/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.subdirectory.test.tsx @@ -0,0 +1,146 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import type { FC } from 'react'; +import { render, screen } from 'spec/helpers/testing-library'; + +// Tab.tsx is statically imported below; the mock pattern intercepts +// applicationRoot() rather than relying on withApplicationRoot (which is for +// dynamic-import unit tests). + +const mockApplicationRoot = jest.fn(() => ''); + +jest.mock('src/utils/getBootstrapData', () => { + const actual = jest.requireActual< + typeof import('src/utils/getBootstrapData') + >('src/utils/getBootstrapData'); + return { + __esModule: true, + default: actual.default, + applicationRoot: () => mockApplicationRoot(), + staticAssetsPrefix: actual.staticAssetsPrefix, + }; +}); + +jest.mock('src/dashboard/util/getChartIdsFromComponent', () => + jest.fn(() => []), +); +jest.mock('src/dashboard/containers/DashboardComponent', () => + jest.fn(() =>
), +); +jest.mock('@superset-ui/core/components/EditableTitle', () => ({ + __esModule: true, + EditableTitle: jest.fn(() =>
), +})); +jest.mock('src/dashboard/components/dnd/DragDroppable', () => ({ + ...jest.requireActual('src/dashboard/components/dnd/DragDroppable'), + Droppable: jest.fn(props => ( +
{props.children ? props.children({}) : null}
+ )), +})); + +// eslint-disable-next-line import/first +import ActualTab from './Tab'; + +const Tab = ActualTab as unknown as FC>; + +const DASHBOARD_ID = 23; + +const buildProps = () => ({ + id: 'TAB-empty-', + parentId: 'TABS-empty-', + depth: 2, + index: 0, + renderType: 'RENDER_TAB_CONTENT', + availableColumnCount: 12, + columnWidth: 120, + isFocused: false, + component: { + children: [], + id: 'TAB-empty-', + meta: { text: 'Empty Tab' }, + parents: ['ROOT_ID', 'GRID_ID', 'TABS-empty-'], + type: 'TAB', + }, + parentComponent: { + children: ['TAB-empty-'], + id: 'TABS-empty-', + meta: {}, + parents: ['ROOT_ID', 'GRID_ID'], + type: 'TABS', + }, + editMode: true, + embeddedMode: false, + undoLength: 0, + redoLength: 0, + filters: {}, + directPathToChild: [], + directPathLastUpdated: 0, + dashboardId: DASHBOARD_ID, + focusedFilterScope: null, + isComponentVisible: true, + onDropOnTab: jest.fn(), + handleComponentDrop: jest.fn(), + updateComponents: jest.fn(), + setDirectPathToChild: jest.fn(), + onResizeStart: jest.fn(), + onResize: jest.fn(), + onResizeStop: jest.fn(), +}); + +const renderEmptyEditModeTab = () => + render(, { + useRedux: true, + useDnd: true, + initialState: { + dashboardInfo: { dash_edit_perm: true }, + }, + }); + +beforeEach(() => { + mockApplicationRoot.mockReturnValue(''); +}); + +test('Tab — empty edit-mode "create a new chart" link is unprefixed when application root is empty', () => { + mockApplicationRoot.mockReturnValue(''); + renderEmptyEditModeTab(); + + expect( + screen.getByRole('link', { name: 'create a new chart' }), + ).toHaveAttribute('href', `/chart/add?dashboard_id=${DASHBOARD_ID}`); +}); + +test('Tab — empty edit-mode "create a new chart" link carries the application root under subdirectory deployment', () => { + mockApplicationRoot.mockReturnValue('/superset'); + renderEmptyEditModeTab(); + + // Single prefix — not /superset/superset/ — verifying ensureAppRoot's + // dedupe boundary holds against the path's leading slash. + expect( + screen.getByRole('link', { name: 'create a new chart' }), + ).toHaveAttribute('href', `/superset/chart/add?dashboard_id=${DASHBOARD_ID}`); +}); + +test('Tab — empty edit-mode "create a new chart" link prefixes correctly for nested subdirectory roots', () => { + mockApplicationRoot.mockReturnValue('/a/b/c'); + renderEmptyEditModeTab(); + + expect( + screen.getByRole('link', { name: 'create a new chart' }), + ).toHaveAttribute('href', `/a/b/c/chart/add?dashboard_id=${DASHBOARD_ID}`); +}); diff --git a/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.test.tsx b/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.test.tsx index 23e5a5deedd..2815007eee6 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.test.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.test.tsx @@ -27,6 +27,7 @@ import { import DashboardComponent from 'src/dashboard/containers/DashboardComponent'; import { EditableTitle } from '@superset-ui/core/components'; import { setEditMode, onRefresh } from 'src/dashboard/actions/dashboardState'; +import * as getBootstrapData from 'src/utils/getBootstrapData'; import type { FC } from 'react'; import ActualTab from './Tab'; @@ -488,6 +489,36 @@ test('Render tab content with no children, editMode: true, canEdit: true', () => ).toHaveAttribute('href', '/chart/add?dashboard_id=23'); }); +test('empty-tab "create a new chart" link is single-prefixed under subdirectory deployment', () => { + // The empty-tab CTA composes the chart-add URL via ensureAppRoot. Under + // SUPERSET_APP_ROOT=/superset the rendered href must be exactly + // `/superset/chart/add?dashboard_id=23` — not `/chart/add?…` (no prefix) + // and not `/superset/superset/chart/add?…` (double prefix). The link uses + // target="_blank", so basename routing does NOT re-apply the prefix. + const applicationRootSpy = jest + .spyOn(getBootstrapData, 'applicationRoot') + .mockReturnValue('/superset'); + + try { + const props = createProps(); + props.editMode = true; + props.component.children = []; + render(, { + useRedux: true, + useDnd: true, + initialState: { + dashboardInfo: { dash_edit_perm: true }, + }, + }); + + expect( + screen.getByRole('link', { name: 'create a new chart' }), + ).toHaveAttribute('href', '/superset/chart/add?dashboard_id=23'); + } finally { + applicationRootSpy.mockRestore(); + } +}); + test('Drag to empty state, editMode: true, canEdit: true', async () => { const props = createProps(); props.editMode = true; diff --git a/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.tsx b/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.tsx index f2486a80dc8..9bdbf9221c2 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.tsx @@ -36,6 +36,7 @@ import getChartIdsFromComponent from 'src/dashboard/util/getChartIdsFromComponen import DashboardComponent from 'src/dashboard/containers/DashboardComponent'; import AnchorLink from 'src/dashboard/components/AnchorLink'; import { Typography } from '@superset-ui/core/components/Typography'; +import { ensureAppRoot } from 'src/utils/navigationUtils'; import { useIsAutoRefreshing, useIsRefreshInFlight, @@ -333,7 +334,9 @@ const Tab = (props: TabProps): ReactElement => { {t('You can')}{' '} diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBar.subdirectory.test.ts b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBar.subdirectory.test.ts new file mode 100644 index 00000000000..212939941d8 --- /dev/null +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBar.subdirectory.test.ts @@ -0,0 +1,207 @@ +/** + * 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. + */ + +// `FilterBar/index.tsx::publishDataMask` is one of the five sanctioned +// `applicationRoot()` callers (memory `project_supersetclient_approot_dedupe`). +// It runs after a filter mutation to push the updated filter cache key into +// the URL via `history.replace`. Two appRoot-aware operations gate that +// replace: +// +// 1. The path-matching guard — only fire when the current pathname is a +// dashboard route under the configured appRoot. The bug class this +// catches is "filter writes pollute Explore's URL after navigation". +// +// 2. The prefix-strip — React Router applies `basename` internally, so +// `history.replace({ pathname })` must receive a path WITHOUT the +// appRoot. The bug class this catches is `/superset/superset/dashboard/...` +// in the URL bar after the first filter change. +// +// `publishDataMask` is module-private (declared as a `const debounce(...)`). +// Testing it through a rendered FilterBar requires the Redux store, the +// filter cache API, and the debounce timer — heavyweight relative to what +// the contract actually says. Instead this test does two things: +// +// A. Reads FilterBar/index.tsx as source and pins the two patterns that +// embody the contract. A future refactor that drops the guard or the +// strip fails here loudly with the exact line that drifted. +// B. Tests the *equivalent* pure logic (re-implementation of the same +// pattern) across every appRoot × pathname × Explore-vs-Dashboard +// input shape that matters in practice. If the actual code drifts +// from the documented invariant, the source-pin in (A) fires; if the +// documented invariant itself is wrong, (B) fires. + +import { readFileSync } from 'fs'; +import { join } from 'path'; + +const FILTERBAR_SRC = readFileSync(join(__dirname, 'index.tsx'), 'utf8'); + +// --------------------------------------------------------------------------- +// (A) Source-pin: the two patterns that implement the contract. +// --------------------------------------------------------------------------- + +test('FilterBar/index.tsx guards history.replace by the configured app root', () => { + // The guard short-circuits the URL mutation when the current path is not a + // dashboard route under the appRoot — e.g. when the user navigated to + // Explore (`/explore/?slice_id=...`), the FilterBar's debounced commit must + // not stomp Explore's query string with native_filters_key. + expect(FILTERBAR_SRC).toContain( + 'window.location.pathname.startsWith(`${applicationRoot()}/dashboard`)', + ); +}); + +test('FilterBar/index.tsx strips the app root before history.replace', () => { + // Both halves of the strip survive together — the appRoot != "/" check + // and the startsWith-before-substring guard. Each is load-bearing on its + // own (without the first, root deploy hits `.substring(1)` and clips off + // the leading slash; without the second, paths that diverge from the + // appRoot get incorrectly truncated). + expect(FILTERBAR_SRC).toContain( + "if (appRoot !== '/' && replacementPathname.startsWith(appRoot))", + ); + expect(FILTERBAR_SRC).toContain( + 'replacementPathname = replacementPathname.substring(appRoot.length);', + ); +}); + +test('FilterBar/index.tsx imports applicationRoot from getBootstrapData', () => { + // Centralised symbol — the static-scan invariant in + // navigationUtils.invariants.test.ts enumerates the sanctioned import + // sites. If FilterBar's import path drifts, that scan also fires; this + // one anchors the import locally so a `git blame` on FilterBar tells the + // story without needing to cross-reference the scan ledger. + expect(FILTERBAR_SRC).toMatch( + /import\s+\{\s*applicationRoot\s*\}\s+from\s+'src\/utils\/getBootstrapData'/, + ); +}); + +// --------------------------------------------------------------------------- +// (B) Characterisation: the documented invariant, exercised across the +// appRoot × pathname matrix. +// --------------------------------------------------------------------------- +// +// Re-implementation of the FilterBar guard + strip, kept here so the test +// can fail loudly if the *invariant itself* is wrong (rather than a typo in +// the implementation). The source-pin above catches the inverse case +// (implementation drifts away from the invariant). + +interface Scenario { + description: string; + appRoot: string; + pathname: string; + shouldReplace: boolean; + replacementPathname?: string; +} + +function applyFilterBarPathLogic( + appRoot: string, + pathname: string, +): { shouldReplace: boolean; replacementPathname?: string } { + if (!pathname.startsWith(`${appRoot}/dashboard`)) { + return { shouldReplace: false }; + } + let replacement = pathname; + if (appRoot !== '/' && replacement.startsWith(appRoot)) { + replacement = replacement.substring(appRoot.length); + } + return { shouldReplace: true, replacementPathname: replacement }; +} + +const SCENARIOS: ReadonlyArray = [ + // Root deploy — pathname matches `/dashboard` directly. + { + description: 'root deploy on a dashboard page', + appRoot: '', + pathname: '/dashboard/1/', + shouldReplace: true, + replacementPathname: '/dashboard/1/', + }, + { + description: 'root deploy on Explore — guard short-circuits', + appRoot: '', + pathname: '/explore/?slice_id=42', + shouldReplace: false, + }, + // Subdir deploy — appRoot is `/superset`, pathname carries the prefix. + { + description: 'subdir deploy on a dashboard page', + appRoot: '/superset', + pathname: '/superset/dashboard/2/', + shouldReplace: true, + // Stripped: React Router re-applies basename so the strip MUST happen. + replacementPathname: '/dashboard/2/', + }, + { + description: 'subdir deploy on a dashboard permalink', + appRoot: '/superset', + pathname: '/superset/dashboard/p/abc123/', + shouldReplace: true, + replacementPathname: '/dashboard/p/abc123/', + }, + { + description: 'subdir deploy on Explore — guard short-circuits', + appRoot: '/superset', + pathname: '/superset/explore/?slice_id=7', + shouldReplace: false, + }, + { + description: + 'subdir deploy on bare app root (no /dashboard) — short-circuits', + appRoot: '/superset', + pathname: '/superset/', + shouldReplace: false, + }, + // Operator deploy under a deeply nested basename. + { + description: 'deep-nested deploy on a dashboard page', + appRoot: '/tenant-a/superset', + pathname: '/tenant-a/superset/dashboard/9/', + shouldReplace: true, + replacementPathname: '/dashboard/9/', + }, + // Adversarial: appRoot `/dash` is a substring of `/dashboard`. The guard + // template is `${appRoot}/dashboard` so the prefix is `/dash/dashboard`, + // which (correctly) does NOT match a bare `/dashboard/1/` path. Pin the + // case so a maintainer doesn't "fix" the guard to also match prefix-free + // paths (which would re-introduce the Explore-stomp regression for + // operators whose root happens to share characters with `/dashboard`). + { + description: + 'appRoot is a substring prefix of /dashboard — guard does NOT match a bare /dashboard path', + appRoot: '/dash', + pathname: '/dashboard/5/', + shouldReplace: false, + }, +]; + +test.each(SCENARIOS)( + 'publishDataMask path logic: $description', + ({ appRoot, pathname, shouldReplace, replacementPathname }: Scenario) => { + const result = applyFilterBarPathLogic(appRoot, pathname); + expect(result.shouldReplace).toBe(shouldReplace); + if (shouldReplace) { + expect(result.replacementPathname).toBe(replacementPathname); + // The dedupe contract: no `/superset/superset/...` ever reaches React + // Router. Even if the source-pin drifts, this catches the user-visible + // symptom. + expect(result.replacementPathname).not.toMatch(/\/superset\/superset\//); + } else { + expect(result.replacementPathname).toBeUndefined(); + } + }, +); diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.timeGrain.test.ts b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.timeGrain.test.ts deleted file mode 100644 index 6680e8e70b8..00000000000 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.timeGrain.test.ts +++ /dev/null @@ -1,60 +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 { ChartDataResponseResult } from '@superset-ui/core'; -import { applyTimeGrainAllowlist } from './FilterValue'; - -const baseResults = [ - { - data: [ - { duration: 'PT1H', name: 'Hour' }, - { duration: 'P1D', name: 'Day' }, - { duration: 'P1W', name: 'Week' }, - { duration: 'P1M', name: 'Month' }, - ], - }, -] as unknown as ChartDataResponseResult[]; - -test('applyTimeGrainAllowlist should filter to configured durations', () => { - const filtered = applyTimeGrainAllowlist( - 'filter_timegrain', - ['PT1H', 'P1D', 'P1W'], - baseResults, - ); - - expect(filtered[0].data).toEqual([ - { duration: 'PT1H', name: 'Hour' }, - { duration: 'P1D', name: 'Day' }, - { duration: 'P1W', name: 'Week' }, - ]); -}); - -test('applyTimeGrainAllowlist should return unfiltered results for non-timegrain filters', () => { - const filtered = applyTimeGrainAllowlist( - 'filter_select', - ['PT1H'], - baseResults, - ); - expect(filtered).toEqual(baseResults); -}); - -test('applyTimeGrainAllowlist should return unfiltered results when allowlist is empty', () => { - const filtered = applyTimeGrainAllowlist('filter_timegrain', [], baseResults); - expect(filtered).toEqual(baseResults); -}); diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.tsx index e8151d24814..8f684f4a6c0 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.tsx @@ -85,35 +85,6 @@ const StyledDiv = styled.div<{ const queriesDataPlaceholder = [{ data: [{}] }]; -type TimeGrainFilterConfig = { - time_grains?: string[]; -}; - -export const applyTimeGrainAllowlist = ( - filterType: string, - allowedTimeGrains: string[] | undefined, - results: ChartDataResponseResult[], -): ChartDataResponseResult[] => { - if (filterType !== 'filter_timegrain' || !allowedTimeGrains?.length) { - return results; - } - - return results.map(result => { - if (!Array.isArray(result.data)) { - return result; - } - - return { - ...result, - data: result.data.filter(row => - allowedTimeGrains.includes( - (row as { duration?: string }).duration ?? '', - ), - ), - }; - }); -}; - const useShouldFilterRefresh = () => { const isDashboardRefreshing = useSelector( state => state.dashboardState.isRefreshing, @@ -145,9 +116,6 @@ const FilterValue: FC = ({ const theme = useTheme() as SupersetTheme; const { id, targets, filterType } = filter; const isCustomization = isChartCustomization(filter); - const allowedTimeGrains = isCustomization - ? undefined - : (filter as TimeGrainFilterConfig).time_grains; const adhocFilters = isCustomization ? undefined : filter.adhoc_filters; const timeRange = isCustomization ? undefined : filter.time_range; const granularitySqla = isCustomization ? undefined : filter.granularity_sqla; @@ -285,23 +253,13 @@ const FilterValue: FC = ({ // deal with getChartDataRequest transforming the response data const result = 'result' in json ? json.result[0] : json; if (response.status === 200) { - setState( - applyTimeGrainAllowlist(filterType, allowedTimeGrains, [ - result as ChartDataResponseResult, - ]), - ); + setState([result as ChartDataResponseResult]); setError(undefined); handleFilterLoadFinish(); } else if (response.status === 202) { waitForAsyncData(result as Parameters[0]) .then((asyncResult: ChartDataResponseResult[]) => { - setState( - applyTimeGrainAllowlist( - filterType, - allowedTimeGrains, - asyncResult, - ), - ); + setState(asyncResult); setError(undefined); handleFilterLoadFinish(); }) @@ -317,13 +275,7 @@ const FilterValue: FC = ({ ); } } else { - setState( - applyTimeGrainAllowlist( - filterType, - allowedTimeGrains, - json.result as ChartDataResponseResult[], - ), - ); + setState(json.result as ChartDataResponseResult[]); setError(undefined); handleFilterLoadFinish(); } @@ -342,7 +294,6 @@ const FilterValue: FC = ({ groupby, handleFilterLoadFinish, filter, - allowedTimeGrains, hasDataSource, isRefreshing, shouldRefresh, diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/index.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/index.tsx index ef9f3dc69a1..dcb8b0532bd 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/index.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/index.tsx @@ -142,9 +142,11 @@ const publishDataMask = debounce( // pathname could be updated somewhere else through window.history // keep react router history in sync with window history - // replace params only when current page is /superset/dashboard + // replace params only when current page is a dashboard route under the + // configured applicationRoot (e.g. `/dashboard/...` for root deploy, + // `/superset/dashboard/...` for the legacy subdir deploy). // this prevents a race condition between updating filters and navigating to Explore - if (window.location.pathname.includes('/superset/dashboard')) { + if (window.location.pathname.startsWith(`${applicationRoot()}/dashboard`)) { // The history API is part of React router and understands that a basename may exist. // Internally it treats all paths as if they are relative to the root and appends // it when necessary. We strip any prefix so that history.replace adds it back and doesn't diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterConfigPane.test.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterConfigPane.test.tsx index 56f1d7b1892..921c2c242ac 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterConfigPane.test.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterConfigPane.test.tsx @@ -82,7 +82,9 @@ test('drag and drop', () => { test('remove filter', async () => { defaultRender(); // First trash icon - const removeFilterIcon = document.querySelector("[alt='Remove filter']")!; + const removeFilterIcon = document.querySelector( + "[aria-label='Remove filter']", + )!; userEvent.click(removeFilterIcon); expect(defaultProps.onRemove).toHaveBeenCalledWith('NATIVE_FILTER-1'); }); diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTitleContainer.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTitleContainer.tsx index 6e0bbddbb5f..19d7427917b 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTitleContainer.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTitleContainer.tsx @@ -211,7 +211,7 @@ const FilterTitleContainer = forwardRef( event.stopPropagation(); onRemove(id); }} - alt={t('Remove filter')} + aria-label={t('Remove filter')} data-test="filter-remove-button" /> )} diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx index 83113aa40c9..70705dab529 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx @@ -329,9 +329,8 @@ const FiltersConfigForm = ( const formFilterWithTimeGrains = formFilter as typeof formFilter & { time_grains?: string[]; }; - const filterToEditWithTimeGrains = filterToEdit as - | (Filter & { time_grains?: string[] }) - | undefined; + const savedTimeGrains = + filterToEdit?.time_grains ?? customizationToEdit?.time_grains; const handleModifyFilter = useCallback(() => { if (onModifyFilter) { @@ -594,8 +593,7 @@ const FiltersConfigForm = ( !!filterToEdit?.time_range; const hasTimeGrainPreFilter = !!( - formFilterWithTimeGrains?.time_grains?.length || - filterToEditWithTimeGrains?.time_grains?.length + formFilterWithTimeGrains?.time_grains?.length || savedTimeGrains?.length ); const hasEnableSingleValue = @@ -1297,7 +1295,9 @@ const FiltersConfigForm = ( )} - {itemTypeField === 'filter_timegrain' && + {(itemTypeField === 'filter_timegrain' || + itemTypeField === + 'chart_customization_timegrain') && hasDataset && datasetDetails?.time_grain_sqla && datasetDetails.time_grain_sqla.length > 0 && ( @@ -1332,9 +1332,7 @@ const FiltersConfigForm = ( filterId, 'time_grains', ]} - initialValue={ - filterToEditWithTimeGrains?.time_grains - } + initialValue={savedTimeGrains} {...getFiltersConfigModalTestId( 'time-grain-allowlist', )} diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/ItemTitleContainer.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/ItemTitleContainer.tsx index 1d00f81e77d..78b9186dca6 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/ItemTitleContainer.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/ItemTitleContainer.tsx @@ -179,7 +179,7 @@ const ItemTitleContainer = forwardRef( event.stopPropagation(); onRemove(id); }} - alt={deleteAltText} + aria-label={deleteAltText} /> )}
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/customizationTransformer.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/customizationTransformer.ts index 2e0eaf3bb49..4c5c8f9f8fd 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/customizationTransformer.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/customizationTransformer.ts @@ -108,7 +108,7 @@ function transformFormInput( excluded: [], }; - return { + const result: ChartCustomization = { id, type: ChartCustomizationType.ChartCustomization, name: formInputs.name, @@ -120,6 +120,12 @@ function transformFormInput( defaultDataMask: formInputs.defaultDataMask ?? {}, removed: false, }; + + if (formInputs.time_grains?.length) { + result.time_grains = formInputs.time_grains; + } + + return result; } function transformSavedCustomization( diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/types.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/types.ts index 78461fcbf20..c77276c12fb 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/types.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/types.ts @@ -89,6 +89,7 @@ export interface ChartCustomizationsFormItem { adhoc_filters?: AdhocFilter[]; time_range?: string; granularity_sqla?: string; + time_grains?: string[]; type: typeof NativeFilterType.NativeFilter; description: string; datasetInfo?: { diff --git a/superset-frontend/src/dashboard/containers/DashboardPage.test.tsx b/superset-frontend/src/dashboard/containers/DashboardPage.test.tsx index 7795bc95836..e66d271d0a2 100644 --- a/superset-frontend/src/dashboard/containers/DashboardPage.test.tsx +++ b/superset-frontend/src/dashboard/containers/DashboardPage.test.tsx @@ -22,6 +22,7 @@ import { createStore, render, screen, + userEvent, waitFor, } from 'spec/helpers/testing-library'; import reducerIndex from 'spec/helpers/reducerIndex'; @@ -30,7 +31,7 @@ import { useDashboardCharts, useDashboardDatasets, } from 'src/hooks/apiResources'; -import { SupersetClient } from '@superset-ui/core'; +import { SupersetApiError, SupersetClient } from '@superset-ui/core'; import CrudThemeProvider from 'src/components/CrudThemeProvider'; import { hydrateDashboard } from 'src/dashboard/actions/hydrate'; import { @@ -559,6 +560,48 @@ test('does not overwrite filterState when modern native_filters URL format is us ).toBeUndefined(); }); +test('renders a not-found state instead of throwing when the dashboard 404s', async () => { + mockUseDashboard.mockReturnValue({ + result: null, + error: new SupersetApiError({ status: 404, message: 'Not found' }), + }); + mockUseDashboardCharts.mockReturnValue({ + result: null, + error: new SupersetApiError({ status: 404, message: 'Not found' }), + }); + mockUseDashboardDatasets.mockReturnValue({ + result: null, + error: new SupersetApiError({ status: 404, message: 'Not found' }), + status: 'error', + }); + + render( + + + , + { + useRedux: true, + useRouter: true, + initialState: { + dashboardInfo: {}, + dashboardState: { sliceIds: [] }, + nativeFilters: { filters: {} }, + dataMask: {}, + }, + }, + ); + + expect( + await screen.findByText('This dashboard does not exist'), + ).toBeInTheDocument(); + expect(screen.queryByTestId('dashboard-builder')).not.toBeInTheDocument(); + + await userEvent.click( + screen.getByRole('button', { name: 'See all dashboards' }), + ); + expect(window.location.pathname).toBe('/dashboard/list/'); +}); + test('clears undo history after hydrating the dashboard', async () => { render( diff --git a/superset-frontend/src/dashboard/containers/DashboardPage.tsx b/superset-frontend/src/dashboard/containers/DashboardPage.tsx index deec545931a..516b04ba786 100644 --- a/superset-frontend/src/dashboard/containers/DashboardPage.tsx +++ b/superset-frontend/src/dashboard/containers/DashboardPage.tsx @@ -24,7 +24,7 @@ import { useTheme } from '@apache-superset/core/theme'; import { useDispatch, useSelector } from 'react-redux'; import { createSelector } from '@reduxjs/toolkit'; import { useToasts } from 'src/components/MessageToasts/withToasts'; -import { Loading } from '@superset-ui/core/components'; +import { EmptyState, Loading } from '@superset-ui/core/components'; import { useDashboard, useDashboardCharts, @@ -67,7 +67,8 @@ import SyncDashboardState, { getDashboardContextLocalStorage, } from '../components/SyncDashboardState'; import { AutoRefreshProvider } from '../contexts/AutoRefreshContext'; -import { Filter, PartialFilters } from '@superset-ui/core'; +import { Filter, PartialFilters, SupersetApiError } from '@superset-ui/core'; +import { RoutePaths } from 'src/views/routePaths'; import { parseRisonFilters, risonFiltersToExtraFormDataFilters, @@ -151,6 +152,9 @@ export const DashboardPage: FC = ({ idOrSlug }: PageProps) => { const isDashboardHydrated = useRef(false); const error = dashboardApiError || chartsApiError; + // Only 404 gets a graceful not-found state; a 403 (access denied) still + // surfaces through the error boundary. + const isNotFoundError = (error as SupersetApiError | null)?.status === 404; const readyToRender = Boolean(dashboard && charts); const { dashboard_title, id = 0 } = dashboard || {}; @@ -365,18 +369,21 @@ export const DashboardPage: FC = ({ idOrSlug }: PageProps) => { useEffect(() => { if (datasetsApiError) { - addDangerToast( - t('Error loading chart datasources. Filters may not work correctly.'), - ); + // A missing dashboard also 404s its datasets; the not-found state covers it. + if (!isNotFoundError) { + addDangerToast( + t('Error loading chart datasources. Filters may not work correctly.'), + ); + } } else { dispatch(setDatasources(datasets)); } - }, [addDangerToast, datasets, datasetsApiError, dispatch]); + }, [addDangerToast, datasets, datasetsApiError, dispatch, isNotFoundError]); const relevantDataMask = useSelector(selectRelevantDatamask); const activeFilters = useSelector(selectActiveFilters); - if (error) throw error; // caught in error boundary + if (error && !isNotFoundError) throw error; // caught in error boundary const globalStyles = useMemo( () => [ @@ -389,9 +396,25 @@ export const DashboardPage: FC = ({ idOrSlug }: PageProps) => { [theme], ); - if (error) throw error; // caught in error boundary + if (error && !isNotFoundError) throw error; // caught in error boundary const DashboardBuilderComponent = useMemo(() => , []); + + if (isNotFoundError) { + return ( + history.push(RoutePaths.DASHBOARD_LIST)} + /> + ); + } + return ( <> diff --git a/superset-frontend/src/dashboard/util/risonFilters.ts b/superset-frontend/src/dashboard/util/risonFilters.ts index d9b4ead12a0..9aa1678c21e 100644 --- a/superset-frontend/src/dashboard/util/risonFilters.ts +++ b/superset-frontend/src/dashboard/util/risonFilters.ts @@ -23,6 +23,7 @@ import { DataMaskStateWithId, } from '@superset-ui/core'; import rison from 'rison'; +import { navigateWithState } from 'src/utils/navigationUtils'; /** * Synthetic dataMask key for URL Rison filters that don't match any native @@ -238,7 +239,17 @@ export function prettifyRisonFilterUrl(): void { const prettifiedUrl = `${beforeRison}${separator}f=${risonValue}${afterRison}`; if (prettifiedUrl !== currentUrl) { - window.history.replaceState(window.history.state, '', prettifiedUrl); + // Route through navigateWithState so the navigationUtils guards + // (`assertSafeNavigationUrl` scheme/userinfo barriers + the + // CodeQL-recognised inline sanitisers) apply at the + // `window.history.replaceState` sink. The URL constructor inside + // `navigateWithState` is conservative about re-encoding: sub-delims + // like `(`, `)`, `:`, `!` (the meaningful Rison glyphs) survive, + // so the prettification's visual win is preserved for every + // character the prettifier actually targets. + navigateWithState(prettifiedUrl, window.history.state ?? {}, { + replace: true, + }); } } catch (error) { console.warn('Failed to prettify Rison URL:', error); @@ -351,12 +362,11 @@ export function updateUrlWithUnmatchedFilters( // With a real `BrowserRouter`, `history.replace` would do this too — but // under a `createMemoryHistory` (used in tests, or in some embedded // contexts) it does not, and we'd leak the stale URL into the next - // `getRisonFilterParam()` call. - window.history.replaceState( - window.history.state, - '', - currentUrl.toString(), - ); + // `getRisonFilterParam()` call. Routed through navigateWithState so the + // navigationUtils scheme/userinfo barriers gate the sink. + navigateWithState(currentUrl.toString(), window.history.state ?? {}, { + replace: true, + }); if (history) { history.replace({ pathname: currentUrl.pathname, diff --git a/superset-frontend/src/explore/components/DataTableControl/FilterInput.test.tsx b/superset-frontend/src/explore/components/DataTableControl/FilterInput.test.tsx old mode 100644 new mode 100755 index 2ffb000d4a7..8d4d6188dd8 --- a/superset-frontend/src/explore/components/DataTableControl/FilterInput.test.tsx +++ b/superset-frontend/src/explore/components/DataTableControl/FilterInput.test.tsx @@ -34,3 +34,40 @@ test('Render a FilterInput', async () => { expect(onChangeHandler).toHaveBeenCalledTimes(4); }); + +test('FilterInput auto-focuses when a non-editable element (e.g. a tab) has focus', () => { + const onChangeHandler = jest.fn(); + const button = document.createElement('button'); + document.body.appendChild(button); + try { + button.focus(); + expect(document.activeElement).toBe(button); + + render(); + const filterInput = screen.getByPlaceholderText('Search'); + + // Auto-focus should fire — a button is not an editable element + expect(document.activeElement).toBe(filterInput); + } finally { + document.body.removeChild(button); + } +}); + +test('FilterInput does not steal focus when another input already has focus', () => { + const onChangeHandler = jest.fn(); + const otherInput = document.createElement('input'); + document.body.appendChild(otherInput); + try { + otherInput.focus(); + expect(document.activeElement).toBe(otherInput); + + render(); + const filterInput = screen.getByPlaceholderText('Search'); + + // FilterInput should not have stolen focus from the already-focused input + expect(document.activeElement).not.toBe(filterInput); + expect(document.activeElement).toBe(otherInput); + } finally { + document.body.removeChild(otherInput); + } +}); diff --git a/superset-frontend/src/explore/components/DataTableControl/index.tsx b/superset-frontend/src/explore/components/DataTableControl/index.tsx old mode 100644 new mode 100755 index eef3e1aedcb..d7c1e507981 --- a/superset-frontend/src/explore/components/DataTableControl/index.tsx +++ b/superset-frontend/src/explore/components/DataTableControl/index.tsx @@ -98,9 +98,20 @@ export const FilterInput = ({ const inputRef: RefObject = useRef(null); useEffect(() => { - // Focus the input element when the component mounts if (inputRef.current && shouldFocus) { - inputRef.current.focus(); + // Skip auto-focus only when an editable element already has focus (e.g. + // user is typing in a form control when this pane remounts after a data + // refresh). Non-editable focused elements like tabs/buttons still allow + // auto-focus so the search box focuses on first open. + const activeEl = document.activeElement; + const editableFocused = + activeEl instanceof HTMLElement && + (activeEl.tagName === 'INPUT' || + activeEl.tagName === 'TEXTAREA' || + activeEl.isContentEditable); + if (!editableFocused) { + inputRef.current.focus(); + } } }, []); diff --git a/superset-frontend/src/explore/components/EmbedCodeContent.subdirectory.test.tsx b/superset-frontend/src/explore/components/EmbedCodeContent.subdirectory.test.tsx new file mode 100644 index 00000000000..69674314d77 --- /dev/null +++ b/superset-frontend/src/explore/components/EmbedCodeContent.subdirectory.test.tsx @@ -0,0 +1,78 @@ +/** + * 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 { render, screen, waitFor } from 'spec/helpers/testing-library'; +import EmbedCodeContent from 'src/explore/components/EmbedCodeContent'; + +// The chart-embed iframe `src` is produced by: +// 1. Backend `url_for(_external=True)` → absolute URL whose origin is the +// backend `Host` header (often the internal docker hostname under +// `superset-light:8088` when `ENABLE_PROXY_FIX` is off). +// 2. Frontend `rewritePermalinkOrigin` swaps the origin for +// `window.location.origin` so the iframe `src` is reachable from the +// browser that pasted the embed code. +// 3. The path segment (`/superset/explore/p//`) survives unchanged — +// the application_root must therefore be applied exactly once. +// +// This composition was previously verified only via manual QA +// (memory `project_supersetclient_approot_dedupe` records the discovery). +// This test pins the iframe-src shape so a future change to the permalink +// API, the origin-rewrite helper, or the EmbedCodeContent template would +// surface in CI rather than in a user-reported broken embed. + +const SUBDIR_PERMALINK_URL = + 'http://superset-light:8088/superset/explore/p/abc123/'; + +fetchMock.post('glob:*/api/v1/explore/permalink', { + url: SUBDIR_PERMALINK_URL, +}); + +const mockFormData = { + datasource: 'table__1', + viz_type: 'table', +}; + +test('iframe src under subdir deployment uses browser origin + single prefix', async () => { + render(, { useRedux: true }); + + // The textarea `value` contains the full iframe HTML once the permalink + // promise resolves. `data-test="embed-code-textarea"` is the stable hook. + const textarea = await screen.findByTestId('embed-code-textarea'); + + // Wait for the asynchronous permalink fetch to land in the textarea. + await waitFor(() => + expect((textarea as HTMLTextAreaElement).value).toContain(' ( {t('Add an annotation layer')}{' '} ` sink. The string fed in is a + // URL-normalised path (`/seg/seg`) so encodeURI is idempotent in + // practice — it does not alter the navigation target. + href={encodeURI(ensureAppRoot('/annotationlayer/list'))} target="_blank" rel="noopener noreferrer" > diff --git a/superset-frontend/src/explore/components/controls/ColumnConfigControl/ColumnConfigConstants.test.tsx b/superset-frontend/src/explore/components/controls/ColumnConfigControl/ColumnConfigConstants.test.tsx index 3f8e40367bf..dc6407c812f 100644 --- a/superset-frontend/src/explore/components/controls/ColumnConfigControl/ColumnConfigConstants.test.tsx +++ b/superset-frontend/src/explore/components/controls/ColumnConfigControl/ColumnConfigConstants.test.tsx @@ -35,3 +35,8 @@ test('should use defaults from Select token separators', () => { ), ).toBe(false); }); + +test('d3NumberFormat and d3TimeFormat should allow free-text entry', () => { + expect(SHARED_COLUMN_CONFIG_PROPS.d3NumberFormat.allowNewOptions).toBe(true); + expect(SHARED_COLUMN_CONFIG_PROPS.d3TimeFormat.allowNewOptions).toBe(true); +}); diff --git a/superset-frontend/src/explore/components/controls/ColumnConfigControl/constants.tsx b/superset-frontend/src/explore/components/controls/ColumnConfigControl/constants.tsx index 7668cf328c2..6822021fd40 100644 --- a/superset-frontend/src/explore/components/controls/ColumnConfigControl/constants.tsx +++ b/superset-frontend/src/explore/components/controls/ColumnConfigControl/constants.tsx @@ -61,6 +61,7 @@ const d3NumberFormat: ControlFormItemSpec<'Select'> = { }; const d3TimeFormat: ControlFormItemSpec<'Select'> = { + allowNewOptions: true, controlType: 'Select', label: t('D3 format'), description: D3_TIME_FORMAT_DOCS, diff --git a/superset-frontend/src/explore/components/controls/ViewQuery.test.tsx b/superset-frontend/src/explore/components/controls/ViewQuery.test.tsx index 5fd67dafa07..e1b5c50c24b 100644 --- a/superset-frontend/src/explore/components/controls/ViewQuery.test.tsx +++ b/superset-frontend/src/explore/components/controls/ViewQuery.test.tsx @@ -185,6 +185,7 @@ test('opens SQL Lab in a new tab when View in SQL Lab button is clicked with met expect(window.open).toHaveBeenCalledWith( `/sqllab?datasourceKey=${datasource}&sql=${encodeURIComponent(sql)}`, '_blank', + 'noopener noreferrer', ); }); diff --git a/superset-frontend/src/explore/components/controls/ViewQuery.tsx b/superset-frontend/src/explore/components/controls/ViewQuery.tsx index 1d321d766e3..0befdd9cd82 100644 --- a/superset-frontend/src/explore/components/controls/ViewQuery.tsx +++ b/superset-frontend/src/explore/components/controls/ViewQuery.tsx @@ -40,7 +40,7 @@ import { import { CopyToClipboard } from 'src/components'; import { RootState } from 'src/dashboard/types'; import { findPermission } from 'src/utils/findPermission'; -import { makeUrl } from 'src/utils/pathUtils'; +import { openInNewTab } from 'src/utils/navigationUtils'; import CodeSyntaxHighlighter, { SupportedLanguage, preloadLanguages, @@ -140,11 +140,8 @@ const ViewQuery: FC = props => { }; if (domEvent.metaKey || domEvent.ctrlKey) { domEvent.preventDefault(); - window.open( - makeUrl( - `/sqllab?datasourceKey=${datasource}&sql=${encodeURIComponent(currentSQL)}`, - ), - '_blank', + openInNewTab( + `/sqllab?datasourceKey=${datasource}&sql=${encodeURIComponent(currentSQL)}`, ); } else { history.push({ pathname: '/sqllab', state: { requestedQuery } }); diff --git a/superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeGallery.tsx b/superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeGallery.tsx index e2249c5771f..03970f7680f 100644 --- a/superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeGallery.tsx +++ b/superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeGallery.tsx @@ -778,7 +778,11 @@ export default function VizTypeGallery(props: VizTypeGalleryProps) { suffix={ {searchInputValue && ( - + )} } diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/DashboardsSubMenu.test.tsx b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/DashboardsSubMenu.test.tsx index 2fdd4e29bee..d1f8ff69902 100644 --- a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/DashboardsSubMenu.test.tsx +++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/DashboardsSubMenu.test.tsx @@ -39,7 +39,7 @@ const TestDashboardsMenuItems = ({
{menuItems.map(item => (
- {typeof item.label === 'string' ? item!.label : 'Complex Label'} + {item!.label} {item!.disabled && disabled}
))} @@ -173,6 +173,35 @@ describe('DashboardsSubMenu', () => { expect(screen.getByTestId('menu-item-5')).toBeInTheDocument(); }); + test('renders SPA-relative dashboard links without the /superset/ prefix', () => { + // Regression: prior to the route_base="" alignment, this menu emitted + // `to="/superset/dashboard/"` which, combined with the React Router + // `basename={applicationRoot()}`, produced a doubled `/superset/superset/` + // path on subdirectory deployments and a backend 404. + const dashboards = [{ id: 9, dashboard_title: 'Sales Dashboard' }]; + render( + , + { useRouter: true }, + ); + + const link = screen.getByRole('link', { name: /Sales Dashboard/ }); + expect(link).toHaveAttribute('href', '/dashboard/9?focused_chart=102'); + }); + + test('omits the focused_chart query when chartId is undefined', () => { + const dashboards = [{ id: 9, dashboard_title: 'Sales Dashboard' }]; + render(, { + useRouter: true, + }); + + const link = screen.getByRole('link', { name: /Sales Dashboard/ }); + expect(link).toHaveAttribute('href', '/dashboard/9'); + }); + test('partial string search works correctly', () => { const dashboards = [ { id: 1, dashboard_title: 'Revenue Report' }, diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/DashboardsSubMenu.tsx b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/DashboardsSubMenu.tsx index fcd2454615c..00b4a8a5f4f 100644 --- a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/DashboardsSubMenu.tsx +++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/DashboardsSubMenu.tsx @@ -71,8 +71,8 @@ export const useDashboardsMenuItems = ({ label: ( { force: false, curUrl: 'http://superset.com', }); - compareURI(URI(url!), URI('/superset/explore_json/')); + compareURI(URI(url!), URI('/explore_json/')); }); test('generates proper json forced url', () => { const url = getExploreUrl({ @@ -75,10 +75,7 @@ describe('exploreUtils', () => { force: true, curUrl: 'superset.com', }); - compareURI( - URI(url!), - URI('/superset/explore_json/').search({ force: 'true' }), - ); + compareURI(URI(url!), URI('/explore_json/').search({ force: 'true' })); }); test('generates proper csv URL', () => { const url = getExploreUrl({ @@ -87,10 +84,7 @@ describe('exploreUtils', () => { force: false, curUrl: 'superset.com', }); - compareURI( - URI(url!), - URI('/superset/explore_json/').search({ csv: 'true' }), - ); + compareURI(URI(url!), URI('/explore_json/').search({ csv: 'true' })); }); test('generates proper standalone URL', () => { const url = getExploreUrl({ @@ -113,10 +107,7 @@ describe('exploreUtils', () => { force: false, curUrl: 'superset.com?foo=bar', }); - compareURI( - URI(url!), - URI('/superset/explore_json/').search({ foo: 'bar' }), - ); + compareURI(URI(url!), URI('/explore_json/').search({ foo: 'bar' })); }); test('generate proper save slice url', () => { const url = getExploreUrl({ @@ -125,10 +116,7 @@ describe('exploreUtils', () => { force: false, curUrl: 'superset.com?foo=bar', }); - compareURI( - URI(url!), - URI('/superset/explore_json/').search({ foo: 'bar' }), - ); + compareURI(URI(url!), URI('/explore_json/').search({ foo: 'bar' })); }); }); diff --git a/superset-frontend/src/explore/exploreUtils/exportChart.test.ts b/superset-frontend/src/explore/exploreUtils/exportChart.test.ts index 9ec504bdb4f..3311e92c761 100644 --- a/superset-frontend/src/explore/exploreUtils/exportChart.test.ts +++ b/superset-frontend/src/explore/exploreUtils/exportChart.test.ts @@ -201,9 +201,12 @@ test('exportChart legacy API (useLegacyApi=true) passes prefixed URL to onStartS expect(onStartStreamingExport).toHaveBeenCalledTimes(1); const callArgs = onStartStreamingExport.mock.calls[0][0]; - // The legacy blueprint path is /superset/explore_json/; with appRoot=/superset the - // full streaming URL is /superset/superset/explore_json/ (appRoot + blueprint prefix). - expect(callArgs.url).toBe('/superset/superset/explore_json/?csv=true'); + // post `Superset.route_base = ""`, the blueprint path is + // `/explore_json/`. With appRoot=/superset, the prefixed URL is + // `/superset/explore_json/?csv=true` — single-prefix, not the legacy + // doubled `/superset/superset/explore_json/...` that this test used to + // pin (which was the bug, now fixed at source). + expect(callArgs.url).toBe('/superset/explore_json/?csv=true'); expect(callArgs.exportType).toBe('csv'); }); @@ -228,7 +231,7 @@ test('exportChart legacy API builds relative URL for CSV export without app root expect(onStartStreamingExport).toHaveBeenCalledTimes(1); const callArgs = onStartStreamingExport.mock.calls[0][0]; - expect(callArgs.url).toBe('/superset/explore_json/?csv=true'); + expect(callArgs.url).toBe('/explore_json/?csv=true'); }); test('exportChart legacy API builds relative URL for xlsx export', async () => { @@ -253,7 +256,7 @@ test('exportChart legacy API builds relative URL for xlsx export', async () => { expect(onStartStreamingExport).toHaveBeenCalledTimes(1); const callArgs = onStartStreamingExport.mock.calls[0][0]; - expect(callArgs.url).toBe('/superset/explore_json/?xlsx=true'); + expect(callArgs.url).toBe('/explore_json/?xlsx=true'); }); test('exportChart legacy API calls postBlob with relative URL', async () => { @@ -278,7 +281,7 @@ test('exportChart legacy API calls postBlob with relative URL', async () => { expect(SupersetClient.postBlob).toHaveBeenCalledTimes(1); const [url] = SupersetClient.postBlob.mock.calls[0]; - expect(url).toBe('/superset/explore_json/?csv=true'); + expect(url).toBe('/explore_json/?csv=true'); expect(url).not.toMatch(/^https?:\/\//); expect(downloadBlob).toHaveBeenCalled(); }); @@ -305,7 +308,7 @@ test('exportChart legacy API includes force param when force=true', async () => expect(onStartStreamingExport).toHaveBeenCalledTimes(1); const callArgs = onStartStreamingExport.mock.calls[0][0]; - expect(callArgs.url).toBe('/superset/explore_json/?force=true&csv=true'); + expect(callArgs.url).toBe('/explore_json/?force=true&csv=true'); }); test('exportChart successfully exports chart as CSV', async () => { diff --git a/superset-frontend/src/explore/exploreUtils/getExploreUrl.test.ts b/superset-frontend/src/explore/exploreUtils/getExploreUrl.test.ts index 032133545e8..782d4412863 100644 --- a/superset-frontend/src/explore/exploreUtils/getExploreUrl.test.ts +++ b/superset-frontend/src/explore/exploreUtils/getExploreUrl.test.ts @@ -39,7 +39,7 @@ test('Get ExploreUrl with default params', () => { test('Get ExploreUrl with endpointType:full', () => { const params = createParams(); expect(getExploreUrl({ ...params, endpointType: 'full' })).toBe( - 'http://localhost/superset/explore_json/', + 'http://localhost/explore_json/', ); }); @@ -47,21 +47,21 @@ test('Get ExploreUrl with endpointType:full and method:GET', () => { const params = createParams(); expect( getExploreUrl({ ...params, endpointType: 'full', method: 'GET' }), - ).toBe('http://localhost/superset/explore_json/'); + ).toBe('http://localhost/explore_json/'); }); test('Get relative ExploreUrl with endpointType:csv', () => { const params = createParams(); expect( getExploreUrl({ ...params, endpointType: 'csv', relative: true }), - ).toBe('/superset/explore_json/?csv=true'); + ).toBe('/explore_json/?csv=true'); }); test('Get relative ExploreUrl with endpointType:xlsx', () => { const params = createParams(); expect( getExploreUrl({ ...params, endpointType: 'xlsx', relative: true }), - ).toBe('/superset/explore_json/?xlsx=true'); + ).toBe('/explore_json/?xlsx=true'); }); test('Get relative ExploreUrl with force:true', () => { @@ -73,7 +73,7 @@ test('Get relative ExploreUrl with force:true', () => { force: true, relative: true, }), - ).toBe('/superset/explore_json/?force=true&csv=true'); + ).toBe('/explore_json/?force=true&csv=true'); }); test('Get relative ExploreUrl with endpointType:base', () => { diff --git a/superset-frontend/src/explore/exploreUtils/getURIDirectory.test.ts b/superset-frontend/src/explore/exploreUtils/getURIDirectory.test.ts index 1d6ad40045a..7d3b004cd4d 100644 --- a/superset-frontend/src/explore/exploreUtils/getURIDirectory.test.ts +++ b/superset-frontend/src/explore/exploreUtils/getURIDirectory.test.ts @@ -19,8 +19,12 @@ import { getURIDirectory } from '.'; test('Cases in which the "explore_json" will be returned', () => { + // post `Superset.route_base = ""` collapse the legacy + // `/superset/explore_json/` endpoint is gone; `getURIDirectory` now + // returns the bare `/explore_json/` path (appRoot is applied at the + // outer `ensureAppRoot` layer when `includeAppRoot=true`). ['full', 'json', 'csv', 'query', 'results', 'samples'].forEach(name => { - expect(getURIDirectory(name)).toBe('/superset/explore_json/'); + expect(getURIDirectory(name)).toBe('/explore_json/'); }); }); diff --git a/superset-frontend/src/explore/exploreUtils/index.ts b/superset-frontend/src/explore/exploreUtils/index.ts index 10c70165314..3e1cd406fbe 100644 --- a/superset-frontend/src/explore/exploreUtils/index.ts +++ b/superset-frontend/src/explore/exploreUtils/index.ts @@ -33,7 +33,7 @@ import { import { availableDomains } from 'src/utils/hostNamesConfig'; import { safeStringify } from 'src/utils/safeStringify'; import { optionLabel } from 'src/utils/common'; -import { ensureAppRoot } from 'src/utils/pathUtils'; +import { ensureAppRoot } from 'src/utils/navigationUtils'; import { downloadBlob, getFilenameFromResponse } from 'src/utils/export'; import { URL_PARAMS } from 'src/constants'; import { @@ -166,7 +166,7 @@ export function getURIDirectory( 'results', 'samples', ].includes(endpointType) - ? '/superset/explore_json/' + ? '/explore_json/' : '/explore/'; return includeAppRoot ? ensureAppRoot(uri) : uri; } diff --git a/superset-frontend/src/extensions/ExtensionsLoader.test.ts b/superset-frontend/src/extensions/ExtensionsLoader.test.ts index d2debd6d69c..a02e2515e33 100644 --- a/superset-frontend/src/extensions/ExtensionsLoader.test.ts +++ b/superset-frontend/src/extensions/ExtensionsLoader.test.ts @@ -23,6 +23,19 @@ import ExtensionsLoader from './ExtensionsLoader'; type Extension = core.Extension; +const mockApplicationRoot = jest.fn(() => ''); + +jest.mock('src/utils/getBootstrapData', () => { + const actual = jest.requireActual< + typeof import('src/utils/getBootstrapData') + >('src/utils/getBootstrapData'); + return { + __esModule: true, + ...actual, + applicationRoot: () => mockApplicationRoot(), + }; +}); + function createMockExtension(overrides: Partial = {}): Extension { return { id: 'test-extension', @@ -37,8 +50,39 @@ function createMockExtension(overrides: Partial = {}): Extension { beforeEach(() => { (ExtensionsLoader as any).instance = undefined; + mockApplicationRoot.mockReturnValue(''); }); +/** + * Capture the src attribute of the remote-entry script element and trigger + * its onerror handler so loadModule short-circuits without webpack module + * federation globals. + */ +function captureRemoteEntryScript(): { + getSrc: () => string | null; + restore: () => void; +} { + let capturedSrc: string | null = null; + const appendChildSpy = jest + .spyOn(document.head, 'appendChild') + .mockImplementation((element: Node) => { + if (element instanceof HTMLScriptElement) { + capturedSrc = element.getAttribute('src'); + if (element.onerror) { + const errorHandler = element.onerror; + setTimeout(() => { + errorHandler('Script load halted by test'); + }, 0); + } + } + return element; + }); + return { + getSrc: () => capturedSrc, + restore: () => appendChildSpy.mockRestore(), + }; +} + test('creates a singleton instance', () => { const instance1 = ExtensionsLoader.getInstance(); const instance2 = ExtensionsLoader.getInstance(); @@ -126,6 +170,70 @@ test('logs success after initializeExtensions completes', async () => { infoSpy.mockRestore(); }); +// Subdirectory regression (gap review 2026-06-10): the backend emits a +// router-relative remoteEntry URL; assigning it raw to `script.src` resolved +// it against the domain root, 404ing every extension under a subdirectory +// deployment. +test('prefixes a router-relative remoteEntry with the application root', async () => { + mockApplicationRoot.mockReturnValue('/superset'); + const loader = ExtensionsLoader.getInstance(); + const errorSpy = jest.spyOn(logging, 'error').mockImplementation(); + const script = captureRemoteEntryScript(); + + await loader.initializeExtension( + createMockExtension({ + id: 'sub-ext', + remoteEntry: '/api/v1/extensions/pub/sub-ext/remoteEntry.js', + }), + ); + + expect(script.getSrc()).toBe( + '/superset/api/v1/extensions/pub/sub-ext/remoteEntry.js', + ); + + errorSpy.mockRestore(); + script.restore(); +}); + +test('leaves the remoteEntry unprefixed on root deployments', async () => { + const loader = ExtensionsLoader.getInstance(); + const errorSpy = jest.spyOn(logging, 'error').mockImplementation(); + const script = captureRemoteEntryScript(); + + await loader.initializeExtension( + createMockExtension({ + id: 'root-ext', + remoteEntry: '/api/v1/extensions/pub/root-ext/remoteEntry.js', + }), + ); + + expect(script.getSrc()).toBe( + '/api/v1/extensions/pub/root-ext/remoteEntry.js', + ); + + errorSpy.mockRestore(); + script.restore(); +}); + +test('passes an absolute remoteEntry URL through unchanged', async () => { + mockApplicationRoot.mockReturnValue('/superset'); + const loader = ExtensionsLoader.getInstance(); + const errorSpy = jest.spyOn(logging, 'error').mockImplementation(); + const script = captureRemoteEntryScript(); + + await loader.initializeExtension( + createMockExtension({ + id: 'cdn-ext', + remoteEntry: 'https://cdn.example.com/remoteEntry.js', + }), + ); + + expect(script.getSrc()).toBe('https://cdn.example.com/remoteEntry.js'); + + errorSpy.mockRestore(); + script.restore(); +}); + test('logs error when initializeExtensions fails', async () => { const loader = ExtensionsLoader.getInstance(); const errorSpy = jest.spyOn(logging, 'error').mockImplementation(); diff --git a/superset-frontend/src/extensions/ExtensionsLoader.ts b/superset-frontend/src/extensions/ExtensionsLoader.ts index 0b74ef9be86..f4abaf5fdbb 100644 --- a/superset-frontend/src/extensions/ExtensionsLoader.ts +++ b/superset-frontend/src/extensions/ExtensionsLoader.ts @@ -19,6 +19,7 @@ import { SupersetClient } from '@superset-ui/core'; import { logging } from '@apache-superset/core/utils'; import type { common as core } from '@apache-superset/core'; +import { makeUrl } from 'src/utils/navigationUtils'; type Extension = core.Extension; @@ -107,10 +108,12 @@ class ExtensionsLoader { private async loadModule(extension: Extension): Promise { const { remoteEntry, id } = extension; - // Load the remote entry script + // Load the remote entry script. The backend emits a router-relative + // remoteEntry URL; `makeUrl` applies the application root for + // subdirectory deployments (idempotent, and absolute URLs pass through). await new Promise((resolve, reject) => { const element = document.createElement('script'); - element.src = remoteEntry; + element.src = makeUrl(remoteEntry); element.type = 'text/javascript'; element.async = true; element.onload = () => resolve(); diff --git a/superset-frontend/src/features/alerts/AlertReportModal.tsx b/superset-frontend/src/features/alerts/AlertReportModal.tsx index df18f3a0e8e..67cf34e1b98 100644 --- a/superset-frontend/src/features/alerts/AlertReportModal.tsx +++ b/superset-frontend/src/features/alerts/AlertReportModal.tsx @@ -1427,7 +1427,7 @@ const AlertReportModal: FunctionComponent = ({ const openDashboardInNewTab = (dashboardId?: number | string | null) => { if (!dashboardId) return; - navigateTo(`/superset/dashboard/${dashboardId}`, { newWindow: true }); + navigateTo(`/dashboard/${dashboardId}/`, { newWindow: true }); }; const onChartChange = (chart: SelectValue) => { diff --git a/superset-frontend/src/features/allEntities/AllEntitiesTable.subdirectory.test.tsx b/superset-frontend/src/features/allEntities/AllEntitiesTable.subdirectory.test.tsx new file mode 100644 index 00000000000..8573e0b52fb --- /dev/null +++ b/superset-frontend/src/features/allEntities/AllEntitiesTable.subdirectory.test.tsx @@ -0,0 +1,152 @@ +/** + * 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 'spec/helpers/testing-library'; +import type { TaggedObjects } from 'src/types/TaggedObject'; +import AllEntitiesTable from './AllEntitiesTable'; + +// Regression for the tag list page (sc-108439): backend `.url` properties on +// Dashboard / Slice / SavedQuery are router-relative by contract +// (see `superset/models/dashboard.py` `dashboard_link` docstring: "`Dashboard.url` +// itself stays router-relative so frontend callers can apply ensureAppRoot +// exactly once"). The TagDAO emits those router-relative paths verbatim as +// `o.url` on tagged-object responses. AllEntitiesTable therefore must wrap +// `o.url` with `ensureAppRoot` exactly once; otherwise the row hrefs lack +// the `SUPERSET_APP_ROOT` prefix and clicks land on broken links. +// +// Mocking note (same as SliceHeaderControls.subdirectory.test.tsx): the +// component is imported statically, so the `withApplicationRoot` fixture's +// `jest.resetModules()` + dynamic re-import pattern can't retroactively +// change which `getBootstrapData` instance the already-bound module graph +// sees. We mock `src/utils/getBootstrapData` directly with a reconfigurable +// `mockApplicationRoot` factory and flip it per scenario. +// +// Name must start with `mock` so Jest's hoisted jest.mock() factory may +// reference it. `default` returns a STATIC shape (not mockApplicationRoot) +// because consumers like the reducer chain call getBootstrapData() at +// import time — calling mockApplicationRoot inside `default` hits TDZ. + +const mockApplicationRoot = jest.fn(() => ''); + +jest.mock('src/utils/getBootstrapData', () => ({ + __esModule: true, + default: () => ({ + common: { application_root: '', static_assets_prefix: '' }, + }), + applicationRoot: () => mockApplicationRoot(), + staticAssetsPrefix: () => '', +})); + +const BACKEND_URLS = { + dashboard: '/dashboard/sales/', + chart: '/explore/?slice_id=42', + query: '/sqllab?savedQueryId=7', +}; + +const SAMPLE_OBJECTS: TaggedObjects = { + dashboard: [ + { + id: 1, + type: 'dashboard', + name: 'Sales', + url: BACKEND_URLS.dashboard, + changed_on: '2026-06-01T00:00:00Z', + created_by: 1, + creator: 'admin', + owners: [], + tags: [], + }, + ], + chart: [ + { + id: 42, + type: 'chart', + name: 'Top Customers', + url: BACKEND_URLS.chart, + changed_on: '2026-06-01T00:00:00Z', + created_by: 1, + creator: 'admin', + owners: [], + tags: [], + }, + ], + query: [ + { + id: 7, + type: 'query', + name: 'Daily Revenue', + url: BACKEND_URLS.query, + changed_on: '2026-06-01T00:00:00Z', + created_by: 1, + creator: 'admin', + owners: [], + tags: [], + }, + ], +}; + +const renderAndCollectHrefs = (): string[] => { + const { container } = render( + {}} + canEditTag + />, + { useRedux: true, useTheme: true }, + ); + return Array.from(container.querySelectorAll('a[href]')) + .map(a => a.getAttribute('href') ?? '') + .filter(href => href !== '' && href !== '#'); +}; + +beforeEach(() => { + mockApplicationRoot.mockReset(); +}); + +test('row hrefs carry the application root under /superset', () => { + mockApplicationRoot.mockReturnValue('/superset'); + const hrefs = renderAndCollectHrefs(); + + expect(hrefs).toEqual( + expect.arrayContaining([ + `/superset${BACKEND_URLS.dashboard}`, + `/superset${BACKEND_URLS.chart}`, + `/superset${BACKEND_URLS.query}`, + ]), + ); + hrefs.forEach(href => { + expect(href).not.toMatch(/^\/superset\/superset\//); + expect(href.startsWith('/superset')).toBe(true); + }); +}); + +test('row hrefs are unchanged under the default root-of-domain deployment', () => { + mockApplicationRoot.mockReturnValue(''); + const hrefs = renderAndCollectHrefs(); + + expect(hrefs).toEqual( + expect.arrayContaining([ + BACKEND_URLS.dashboard, + BACKEND_URLS.chart, + BACKEND_URLS.query, + ]), + ); + hrefs.forEach(href => { + expect(href).not.toMatch(/^\/superset/); + }); +}); diff --git a/superset-frontend/src/features/allEntities/AllEntitiesTable.tsx b/superset-frontend/src/features/allEntities/AllEntitiesTable.tsx index 78a9f146c2f..2e02e2379f5 100644 --- a/superset-frontend/src/features/allEntities/AllEntitiesTable.tsx +++ b/superset-frontend/src/features/allEntities/AllEntitiesTable.tsx @@ -27,6 +27,7 @@ import { EmptyState } from '@superset-ui/core/components'; import { FacePile, TagsList, type TagType } from 'src/components'; import { TaggedObject, TaggedObjects } from 'src/types/TaggedObject'; import { Typography } from '@superset-ui/core/components/Typography'; +import { ensureAppRoot } from 'src/utils/navigationUtils'; const MAX_TAGS_TO_SHOW = 3; const PAGE_SIZE = 10; @@ -73,7 +74,9 @@ export default function AllEntitiesTable({ const renderTable = (type: objectType) => { const data = objects[type].map((o: TaggedObject) => ({ - [type]: {o.name}, + [type]: ( + {o.name} + ), modified: o.changed_on ? extendedDayjs.utc(o.changed_on).fromNow() : '', tags: o.tags, owners: o.owners, diff --git a/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/CommonParameters.tsx b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/CommonParameters.tsx index c25d2f0351d..c410668a460 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/CommonParameters.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/CommonParameters.tsx @@ -243,6 +243,7 @@ export const accessTokenField = ({ validationErrors, db, isEditMode, + isValidating, default_value, description, }: FieldPropTypes) => ( @@ -250,6 +251,7 @@ export const accessTokenField = ({ id="access_token" name="access_token" required={required} + isValidating={isValidating} visibilityToggle={!isEditMode} value={db?.parameters?.access_token} validationMethods={{ onBlur: getValidation }} diff --git a/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/TableCatalog.tsx b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/TableCatalog.tsx index e7510d971bf..6f4a0fc5796 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/TableCatalog.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/TableCatalog.tsx @@ -33,6 +33,7 @@ export const TableCatalog = ({ getValidation, validationErrors, db, + isValidating, isPublic = true, }: FieldPropTypes) => { const tableCatalog = db?.catalog || []; @@ -53,6 +54,7 @@ export const TableCatalog = ({ ( { + // The redirectURL helper is the single funnel for post-connection + // navigation. Both CTA buttons call it; the only safe argument shape is + // a router-relative path, because BrowserRouter's basename re-applies the + // app root. Pinning the helper body here means a future refactor that + // re-introduces `applicationRoot()` or `ensureAppRoot` at this layer + // fails loudly with the exact callsite. + expect(MODAL_SRC).toMatch( + /const redirectURL = \(url: string\) => \{\s*history\.push\(url\);\s*\};/, + ); +}); + +test('DatabaseModal "Query data in SQL Lab" pushes a router-relative /sqllab', () => { + // The exact string we want in source. If someone "fixes" subdir support + // by wrapping this in ensureAppRoot or hard-coding `/superset/sqllab`, + // this test fires before the bad change ships. + expect(MODAL_SRC).toContain("redirectURL('/sqllab?db=true')"); +}); + +test('DatabaseModal "Create dataset" pushes a router-relative /dataset/add/', () => { + // Symmetric invariant for the sibling CTA button — same risk class + // (basename double-prefix) applies. Pinning prevents drift where someone + // "fixes" one button and leaves the other inconsistent. + expect(MODAL_SRC).toContain("redirectURL('/dataset/add/')"); +}); + +test('DatabaseModal CTA buttons do NOT prefix the app root themselves', () => { + // The buttons must not call applicationRoot()/ensureAppRoot/makeUrl on + // these specific paths — basename handles the prefix, and any extra + // prefixing produces `/superset/superset/...`. Search the renderCTABtns + // block (lines ~1855-1879 at time of writing) for offending patterns. + const ctaMatch = MODAL_SRC.match( + /const renderCTABtns = \(\) =>[\s\S]*?<\/StyledBtns>\s*\);/, + ); + expect(ctaMatch).not.toBeNull(); + const ctaSrc = ctaMatch![0]; + expect(ctaSrc).not.toMatch(/applicationRoot\s*\(/); + expect(ctaSrc).not.toMatch(/ensureAppRoot\s*\(/); + expect(ctaSrc).not.toMatch(/makeUrl\s*\(/); + // And the specific double-prefixed string the original bug produced — + // pin it so a regression that hard-codes the app root never ships. + expect(ctaSrc).not.toContain('/superset/sqllab'); + expect(ctaSrc).not.toContain('/superset/dataset/add'); +}); + +// --------------------------------------------------------------------------- +// Characterisation: the documented invariant exercised across app-roots. +// --------------------------------------------------------------------------- +// +// Re-implements the behaviour the source-pin describes: a router-relative +// path pushed through history under BrowserRouter's basename produces a +// single-prefixed URL. If the documented invariant itself is wrong the +// source-pin is useless; this matrix catches that. + +interface Scenario { + description: string; + basename: string; + pushArg: string; + expected: string; +} + +// Mirror of how React Router composes basename + pushed path. React Router +// requires the pushed path to start with '/' and concatenates basename in +// front (stripping a trailing slash on basename if present). +function composeUnderBasename(basename: string, pushArg: string): string { + const normalisedBase = + basename === '/' || basename === '' ? '' : basename.replace(/\/+$/, ''); + return `${normalisedBase}${pushArg}`; +} + +const SCENARIOS: ReadonlyArray = [ + { + description: 'root deploy: bare basename + /sqllab?db=true', + basename: '', + pushArg: '/sqllab?db=true', + expected: '/sqllab?db=true', + }, + { + description: 'subdir deploy: /superset + /sqllab?db=true', + basename: '/superset', + pushArg: '/sqllab?db=true', + expected: '/superset/sqllab?db=true', + }, + { + description: 'subdir deploy: /superset + /dataset/add/', + basename: '/superset', + pushArg: '/dataset/add/', + expected: '/superset/dataset/add/', + }, + { + description: 'deep-nested deploy: /tenant-a/superset + /sqllab?db=true', + basename: '/tenant-a/superset', + pushArg: '/sqllab?db=true', + expected: '/tenant-a/superset/sqllab?db=true', + }, + { + description: 'subdir deploy: trailing slash on basename collapses cleanly', + basename: '/superset/', + pushArg: '/sqllab?db=true', + expected: '/superset/sqllab?db=true', + }, +]; + +test.each(SCENARIOS)( + 'redirectURL navigation: $description', + ({ basename, pushArg, expected }: Scenario) => { + const url = composeUnderBasename(basename, pushArg); + expect(url).toBe(expected); + // The dedupe contract: no `/superset/superset/...` ever reaches the + // browser. Even if the source-pin drifts, this catches the user-visible + // symptom for the subdir case. + expect(url).not.toMatch(/\/superset\/superset\//); + }, +); diff --git a/superset-frontend/src/features/databases/DatabaseModal/SSHTunnelForm.tsx b/superset-frontend/src/features/databases/DatabaseModal/SSHTunnelForm.tsx index ed47ed1952e..16f9058b7a2 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/SSHTunnelForm.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/SSHTunnelForm.tsx @@ -18,18 +18,19 @@ */ import { useState } from 'react'; import { t } from '@apache-superset/core/translation'; +import { JsonObject } from '@superset-ui/core'; import { styled } from '@apache-superset/core/theme'; +import { Alert } from '@apache-superset/core/components'; import { Form, FormLabel, Col, Row, - Tooltip, + LabeledErrorBoundInput, } from '@superset-ui/core/components'; import { Input } from '@superset-ui/core/components/Input'; import { Radio } from '@superset-ui/core/components/Radio'; -import { Icons } from '@superset-ui/core/components/Icons'; -import { DatabaseObject, FieldPropTypes } from '../types'; +import { DatabaseObject, CustomEventHandlerType } from '../types'; import { AuthType } from '.'; const StyledDiv = styled.div` @@ -48,50 +49,73 @@ const StyledFormItem = styled(Form.Item)` margin-bottom: 0 !important; `; -const StyledInputPassword = styled(Input.Password)` - margin: ${({ theme }) => `${theme.sizeUnit}px 0 ${theme.sizeUnit * 2}px`}; -`; +interface SSHTunnelFormProps { + db: DatabaseObject | null; + onSSHTunnelParametersChange: CustomEventHandlerType; + setSSHTunnelLoginMethod: (method: AuthType) => void; + isValidating?: boolean; + validationErrors?: JsonObject | null; + getValidation: () => void; +} const SSHTunnelForm = ({ db, onSSHTunnelParametersChange, setSSHTunnelLoginMethod, -}: { - db: DatabaseObject | null; - onSSHTunnelParametersChange: FieldPropTypes['changeMethods']['onSSHTunnelParametersChange']; - setSSHTunnelLoginMethod: (method: AuthType) => void; -}) => { + isValidating = false, + validationErrors, + getValidation, +}: SSHTunnelFormProps) => { const [usePassword, setUsePassword] = useState(AuthType.Password); + const sshErrors = validationErrors?.ssh_tunnel || {}; + const sshSectionError = sshErrors?._error; return ( + {sshSectionError && ( + + + + + + )} - - {t('SSH Host')} - - - - {t('SSH Port')} - - @@ -100,15 +124,17 @@ const SSHTunnelForm = ({ - - {t('Username')} - - @@ -148,26 +174,19 @@ const SSHTunnelForm = ({ - - {t('SSH Password')} - - - visible ? ( - - - - ) : ( - - - - ) - } role="textbox" /> @@ -182,40 +201,45 @@ const SSHTunnelForm = ({ {t('Private Key')} - + + + - - {t('Private Key Password')} - - - visible ? ( - - - - ) : ( - - - - ) - } role="textbox" /> diff --git a/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx b/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx index 296f8160b79..adf8745028e 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx @@ -1220,26 +1220,40 @@ describe('DatabaseModal', () => { 'ssh-tunnel-server_address-input', ); expect(SSHTunnelServerAddressInput).toHaveValue(''); - userEvent.type(SSHTunnelServerAddressInput, 'localhost'); - expect(SSHTunnelServerAddressInput).toHaveValue('localhost'); + fireEvent.change(SSHTunnelServerAddressInput, { + target: { value: 'localhost' }, + }); + await waitFor(() => + expect(SSHTunnelServerAddressInput).toHaveValue('localhost'), + ); const SSHTunnelServerPortInput = screen.getByTestId( 'ssh-tunnel-server_port-input', ); expect(SSHTunnelServerPortInput).toHaveValue(null); - userEvent.type(SSHTunnelServerPortInput, '22'); - expect(SSHTunnelServerPortInput).toHaveValue(22); + fireEvent.change(SSHTunnelServerPortInput, { + target: { value: '22' }, + }); + await waitFor(() => expect(SSHTunnelServerPortInput).toHaveValue(22)); const SSHTunnelUsernameInput = screen.getByTestId( 'ssh-tunnel-username-input', ); expect(SSHTunnelUsernameInput).toHaveValue(''); - userEvent.type(SSHTunnelUsernameInput, 'test'); - expect(SSHTunnelUsernameInput).toHaveValue('test'); + fireEvent.change(SSHTunnelUsernameInput, { + target: { value: 'test' }, + }); + await waitFor(() => + expect(SSHTunnelUsernameInput).toHaveValue('test'), + ); const SSHTunnelPasswordInput = screen.getByTestId( 'ssh-tunnel-password-input', ); expect(SSHTunnelPasswordInput).toHaveValue(''); - userEvent.type(SSHTunnelPasswordInput, 'pass'); - expect(SSHTunnelPasswordInput).toHaveValue('pass'); + fireEvent.change(SSHTunnelPasswordInput, { + target: { value: 'pass' }, + }); + await waitFor(() => + expect(SSHTunnelPasswordInput).toHaveValue('pass'), + ); }); test('properly interacts with SSH Tunnel form textboxes', async () => { @@ -1258,26 +1272,70 @@ describe('DatabaseModal', () => { 'ssh-tunnel-server_address-input', ); expect(SSHTunnelServerAddressInput).toHaveValue(''); - userEvent.type(SSHTunnelServerAddressInput, 'localhost'); - expect(SSHTunnelServerAddressInput).toHaveValue('localhost'); + fireEvent.change(SSHTunnelServerAddressInput, { + target: { value: 'localhost' }, + }); + await waitFor(() => + expect(SSHTunnelServerAddressInput).toHaveValue('localhost'), + ); const SSHTunnelServerPortInput = screen.getByTestId( 'ssh-tunnel-server_port-input', ); expect(SSHTunnelServerPortInput).toHaveValue(null); - userEvent.type(SSHTunnelServerPortInput, '22'); - expect(SSHTunnelServerPortInput).toHaveValue(22); + fireEvent.change(SSHTunnelServerPortInput, { + target: { value: '22' }, + }); + await waitFor(() => expect(SSHTunnelServerPortInput).toHaveValue(22)); const SSHTunnelUsernameInput = screen.getByTestId( 'ssh-tunnel-username-input', ); expect(SSHTunnelUsernameInput).toHaveValue(''); - userEvent.type(SSHTunnelUsernameInput, 'test'); - expect(SSHTunnelUsernameInput).toHaveValue('test'); + fireEvent.change(SSHTunnelUsernameInput, { + target: { value: 'test' }, + }); + await waitFor(() => + expect(SSHTunnelUsernameInput).toHaveValue('test'), + ); const SSHTunnelPasswordInput = screen.getByTestId( 'ssh-tunnel-password-input', ); expect(SSHTunnelPasswordInput).toHaveValue(''); - userEvent.type(SSHTunnelPasswordInput, 'pass'); - expect(SSHTunnelPasswordInput).toHaveValue('pass'); + fireEvent.change(SSHTunnelPasswordInput, { + target: { value: 'pass' }, + }); + await waitFor(() => + expect(SSHTunnelPasswordInput).toHaveValue('pass'), + ); + }); + + test('does not fire blur validation for SSH fields in the SQLAlchemy form', async () => { + setup(); + + userEvent.click( + await screen.findByRole('button', { + name: /sqlite/i, + }), + ); + + expect(await screen.findByText(/step 2 of 2/i)).toBeInTheDocument(); + const SSHTunnelingToggle = screen.getByTestId('ssh-tunnel-switch'); + userEvent.click(SSHTunnelingToggle); + const SSHTunnelServerAddressInput = await screen.findByTestId( + 'ssh-tunnel-server_address-input', + ); + fireEvent.change(SSHTunnelServerAddressInput, { + target: { value: 'localhost' }, + }); + fireEvent.blur(SSHTunnelServerAddressInput); + await waitFor(() => + expect(SSHTunnelServerAddressInput).toHaveValue('localhost'), + ); + + // ``validate_parameters`` expects a dynamic-form payload, so SSH + // field blur must not call it from the SQLAlchemy-URI form + expect( + fetchMock.callHistory.calls(VALIDATE_PARAMS_ENDPOINT).length, + ).toEqual(0); }); test('if the SSH Tunneling toggle is not true, no inputs are displayed', async () => { @@ -1372,7 +1430,10 @@ describe('DatabaseModal', () => { }), ); - const textboxes = screen.getAllByRole('textbox'); + // Wait for step 2 to render + expect(await screen.findByText(/step 2 of 3/i)).toBeInTheDocument(); + + const textboxes = await screen.findAllByRole('textbox'); const hostField = textboxes[0]; const portField = screen.getByRole('spinbutton'); // textboxes[1] is the connection `database` field; the engine display @@ -1390,15 +1451,20 @@ describe('DatabaseModal', () => { expect(connectButton).toBeDisabled(); - userEvent.type(hostField, 'localhost'); - userEvent.type(portField, '5432'); - userEvent.type(databaseField, 'postgres'); - userEvent.type(usernameField, 'testdb'); - userEvent.type(passwordField, 'demoPassword'); + fireEvent.change(hostField, { target: { value: 'localhost' } }); + fireEvent.blur(hostField); + fireEvent.change(portField, { target: { value: '5432' } }); + fireEvent.blur(portField); + fireEvent.change(databaseField, { target: { value: 'postgres' } }); + fireEvent.blur(databaseField); + fireEvent.change(usernameField, { target: { value: 'testdb' } }); + fireEvent.blur(usernameField); + fireEvent.change(passwordField, { target: { value: 'demoPassword' } }); + fireEvent.blur(passwordField); await waitFor(() => expect(connectButton).toBeEnabled()); - expect(await screen.findByDisplayValue(/5432/i)).toBeInTheDocument(); + await waitFor(() => expect(portField).toHaveValue(5432)); expect(hostField).toHaveValue('localhost'); expect(portField).toHaveValue(5432); expect(databaseField).toHaveValue('postgres'); @@ -1407,12 +1473,110 @@ describe('DatabaseModal', () => { expect(connectButton).toBeEnabled(); userEvent.click(connectButton); + // Verify that validation was called during the form interaction + // Note: With the optimized validation, redundant calls on the same db state are skipped await waitFor(() => { expect( fetchMock.callHistory.calls(VALIDATE_PARAMS_ENDPOINT).length, - ).toEqual(5); + ).toBeGreaterThan(0); }); }); + + test('does not fire redundant validation on blur when db has not changed', async () => { + setup(); + + userEvent.click( + await screen.findByRole('button', { + name: /postgresql/i, + }), + ); + + expect(await screen.findByText(/step 2 of 3/i)).toBeInTheDocument(); + + const textboxes = await screen.findAllByRole('textbox'); + const hostField = textboxes[0]; + + // Type a value and blur - should trigger validation + fireEvent.change(hostField, { target: { value: 'localhost' } }); + fireEvent.blur(hostField); + + await waitFor(() => { + expect( + fetchMock.callHistory.calls(VALIDATE_PARAMS_ENDPOINT).length, + ).toEqual(1); + }); + + // Blur again without changing the value - should NOT trigger another validation + fireEvent.focus(hostField); + fireEvent.blur(hostField); + + // Wait a tick to ensure no additional calls are made + await waitFor(() => { + expect( + fetchMock.callHistory.calls(VALIDATE_PARAMS_ENDPOINT).length, + ).toEqual(1); + }); + }); + + test('keeps Connect disabled when blur validation fails without a usable response', async () => { + fetchMock.modifyRoute('validate-params', { + response: { throws: new TypeError('Network request failed') }, + }); + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + setup(); + + userEvent.click( + await screen.findByRole('button', { + name: /postgresql/i, + }), + ); + + expect(await screen.findByText(/step 2 of 3/i)).toBeInTheDocument(); + + const textboxes = await screen.findAllByRole('textbox'); + const hostField = textboxes[0]; + const portField = screen.getByRole('spinbutton'); + const databaseField = textboxes[1]; + const usernameField = textboxes[2]; + const passwordField = textboxes[3]; + const connectButton = screen.getByRole('button', { name: 'Connect' }); + + fireEvent.change(hostField, { target: { value: 'localhost' } }); + fireEvent.blur(hostField); + fireEvent.change(portField, { target: { value: '5432' } }); + fireEvent.blur(portField); + fireEvent.change(databaseField, { target: { value: 'postgres' } }); + fireEvent.blur(databaseField); + fireEvent.change(usernameField, { target: { value: 'testdb' } }); + fireEvent.blur(usernameField); + fireEvent.change(passwordField, { target: { value: 'demoPassword' } }); + fireEvent.blur(passwordField); + + await waitFor(() => { + expect( + fetchMock.callHistory.calls(VALIDATE_PARAMS_ENDPOINT).length, + ).toBeGreaterThan(0); + }); + + // A request without a usable response is not a completed validation + // cycle, so the Connect button must stay disabled + expect(connectButton).toBeDisabled(); + + // Once the endpoint recovers, the next blur retries (failed attempts + // are not cached) and completes the validation cycle + fetchMock.modifyRoute('validate-params', { + response: { message: 'OK' }, + }); + fireEvent.focus(passwordField); + fireEvent.blur(passwordField); + + await waitFor(() => expect(connectButton).toBeEnabled()); + + consoleErrorSpy.mockRestore(); + }); }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks diff --git a/superset-frontend/src/features/databases/DatabaseModal/index.tsx b/superset-frontend/src/features/databases/DatabaseModal/index.tsx index 5f5465bfe78..5d94829229c 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/index.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/index.tsx @@ -668,6 +668,7 @@ const DatabaseModal: FunctionComponent = ({ hasValidated, setHasValidated, ] = useDatabaseValidation(); + const lastValidatedDbSnapshotRef = useRef(null); const [hasConnectedDb, setHasConnectedDb] = useState(false); const [showCTAbtns, setShowCTAbtns] = useState(false); const [dbName, setDbName] = useState(''); @@ -775,6 +776,7 @@ const DatabaseModal: FunctionComponent = ({ const handleClearValidationErrors = useCallback(() => { setValidationErrors(null); setHasValidated(false); + lastValidatedDbSnapshotRef.current = null; clearError(); }, [setValidationErrors, setHasValidated, clearError]); @@ -851,6 +853,16 @@ const DatabaseModal: FunctionComponent = ({ [onChange], ); + const handleTextChange = useCallback( + ({ target }: { target: HTMLInputElement }) => { + onChange(ActionType.TextChange, { + name: target.name, + value: target.value, + }); + }, + [onChange], + ); + const handleChangeWithValidation = useCallback( ( actionType: ActionType, @@ -862,6 +874,21 @@ const DatabaseModal: FunctionComponent = ({ [onChange, handleClearValidationErrors], ); + const getBlurValidation = useCallback(async () => { + const currentDbSnapshot = JSON.stringify(db); + if (currentDbSnapshot === lastValidatedDbSnapshotRef.current) { + return []; + } + const result = await getValidation(db); + // Only cache after a request that produced a usable response. ``null`` + // signals an unexpected/network failure, in which case we leave the + // snapshot untouched so the next blur retries. + if (result !== null) { + lastValidatedDbSnapshotRef.current = currentDbSnapshot; + } + return result; + }, [db, getValidation]); + const onClose = () => { setDB({ type: ActionType.Reset }); setHasConnectedDb(false); @@ -940,7 +967,17 @@ const DatabaseModal: FunctionComponent = ({ } const errors = await getValidation(dbToUpdate, true); - if (!isEmpty(validationErrors) || !isEmpty(errors)) { + // ``getValidation`` returns ``[]`` on success, a field-keyed object + // for blocking errors (e.g. the duplicate ``database_name`` check), + // and ``null`` for stale or unexpected responses. During save we + // cannot proceed without a usable result, so treat ``null`` as + // blocking too — only ``[]`` is a clean pass. The decision relies on + // this fresh result alone: the ``validationErrors`` state in this + // closure may still hold errors from before the user fixed the form. + const hasReturnedErrors = + errors === null || + (Array.isArray(errors) ? errors.length > 0 : !isEmpty(errors)); + if (hasReturnedErrors) { addDangerToast( t('Connection failed, please check your connection settings.'), ); @@ -1845,7 +1882,6 @@ const DatabaseModal: FunctionComponent = ({ name: target.name, value: target.value, }); - handleClearValidationErrors(); }} setSSHTunnelLoginMethod={(method: AuthType) => setDB({ @@ -1853,6 +1889,12 @@ const DatabaseModal: FunctionComponent = ({ payload: { login_method: method }, }) } + isValidating={isValidating} + validationErrors={validationErrors} + // ``validate_parameters`` only understands dynamic-form payloads; in + // SQLAlchemy-URI mode SSH fields are exercised via "Test connection" + // instead, so blur validation is skipped there. + getValidation={useSqlAlchemyForm ? () => {} : getBlurValidation} /> ); @@ -1928,13 +1970,8 @@ const DatabaseModal: FunctionComponent = ({ }); }} onParametersChange={handleParametersChange} - onChange={({ target }: { target: HTMLInputElement }) => - handleChangeWithValidation(ActionType.TextChange, { - name: target.name, - value: target.value, - }) - } - getValidation={() => getValidation(db)} + onChange={handleTextChange} + getValidation={getBlurValidation} validationErrors={validationErrors} getPlaceholder={getPlaceholder} clearValidationErrors={handleClearValidationErrors} diff --git a/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.subdirectory.test.tsx b/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.subdirectory.test.tsx new file mode 100644 index 00000000000..0215158a61e --- /dev/null +++ b/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.subdirectory.test.tsx @@ -0,0 +1,114 @@ +/** + * 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 userEvent from '@testing-library/user-event'; +import { render, screen } from 'spec/helpers/testing-library'; + +// DatasetPanel opens the dataset via openInNewTab -> ensureAppRoot, which reads +// applicationRoot() statically. Intercept it to simulate a subdirectory deploy. +const mockApplicationRoot = jest.fn(() => ''); + +jest.mock('src/utils/getBootstrapData', () => { + const actual = jest.requireActual< + typeof import('src/utils/getBootstrapData') + >('src/utils/getBootstrapData'); + return { + __esModule: true, + default: actual.default, + applicationRoot: () => mockApplicationRoot(), + staticAssetsPrefix: actual.staticAssetsPrefix, + }; +}); + +// eslint-disable-next-line import/first +import DatasetPanel from 'src/features/datasets/AddDataset/DatasetPanel/DatasetPanel'; +// eslint-disable-next-line import/first +import { exampleColumns } from './fixtures'; +// eslint-disable-next-line import/first +import { DatasetObject } from 'src/features/datasets/AddDataset/types'; + +const APP_ROOT = '/superset'; + +let openSpy: jest.SpyInstance; + +beforeEach(() => { + mockApplicationRoot.mockReturnValue(APP_ROOT); + openSpy = jest.spyOn(window, 'open').mockImplementation(() => null); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +const datasetWith = (exploreUrl: string): DatasetObject[] => [ + { + db: { + id: 1, + database_name: 'test_database', + owners: [1], + backend: 'test_backend', + }, + schema: 'test_schema', + dataset_name: 'example_dataset', + table_name: 'example_table', + explore_url: exploreUrl, + }, +]; + +test('View Dataset opens a single-prefixed URL under a subdirectory deployment', async () => { + // The backend emits explore_url already carrying the application root. The + // strip must run before ensureAppRoot re-prefixes, otherwise the opened tab + // would point at a doubled /superset/superset/... path. + render( + , + { useRouter: true }, + ); + + await userEvent.click(screen.getByText('View Dataset')); + + expect(openSpy).toHaveBeenCalledTimes(1); + const openedUrl = openSpy.mock.calls[0][0]; + expect(openedUrl).toBe(`${APP_ROOT}/explore/?datasource=1__table`); + expect(openedUrl).not.toContain('/superset/superset'); +}); + +test('View Dataset passes an external explore_url through unprefixed', async () => { + render( + , + { useRouter: true }, + ); + + await userEvent.click(screen.getByText('View Dataset')); + + expect(openSpy).toHaveBeenCalledTimes(1); + expect(openSpy.mock.calls[0][0]).toBe( + 'https://external.example.com/custom-endpoint', + ); +}); diff --git a/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.tsx b/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.tsx index e18cd00c6fa..6bf357a1f3b 100644 --- a/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.tsx +++ b/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.tsx @@ -26,6 +26,7 @@ import Table, { TableSize, } from '@superset-ui/core/components/Table'; import { DatasetObject } from 'src/features/datasets/AddDataset/types'; +import { openInNewTab, stripAppRoot } from 'src/utils/navigationUtils'; import { ITableColumn } from './types'; import MessageContent from './MessageContent'; @@ -227,11 +228,12 @@ const renderExistingDatasetAlert = (dataset?: DatasetObject) => ( { - window.open( - dataset?.explore_url, - '_blank', - 'noreferrer noopener popup=false', - ); + if (dataset?.explore_url) { + // `explore_url` is router-relative from the backend (rooted under + // a subdirectory deployment); strip the root so openInNewTab's + // ensureAppRoot re-prefixes it once rather than doubling it. + openInNewTab(stripAppRoot(dataset.explore_url)); + } }} tabIndex={0} className="view-dataset-button" diff --git a/superset-frontend/src/features/datasets/AddDataset/LeftPanel/index.tsx b/superset-frontend/src/features/datasets/AddDataset/LeftPanel/index.tsx index c23017aeed7..ef90f1815eb 100644 --- a/superset-frontend/src/features/datasets/AddDataset/LeftPanel/index.tsx +++ b/superset-frontend/src/features/datasets/AddDataset/LeftPanel/index.tsx @@ -29,8 +29,8 @@ import { DatasetObject, } from 'src/features/datasets/AddDataset/types'; import { Table } from 'src/hooks/apiResources'; -import { ensureAppRoot } from 'src/utils/pathUtils'; import { Typography } from '@superset-ui/core/components/Typography'; +import { ensureAppRoot } from 'src/utils/navigationUtils'; interface LeftPanelProps { setDataset: Dispatch>; diff --git a/superset-frontend/src/features/home/Menu.subdirectory.test.tsx b/superset-frontend/src/features/home/Menu.subdirectory.test.tsx new file mode 100644 index 00000000000..4d1083fad2f --- /dev/null +++ b/superset-frontend/src/features/home/Menu.subdirectory.test.tsx @@ -0,0 +1,230 @@ +/** + * 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 * as reactRedux from 'react-redux'; +import fetchMock from 'fetch-mock'; +import { BrowserRouter } from 'react-router-dom'; +import { QueryParamProvider } from 'use-query-params'; +import { ReactRouter5Adapter } from 'use-query-params/adapters/react-router-5'; +import { render, screen } from 'spec/helpers/testing-library'; +import * as CoreTheme from '@apache-superset/core/theme'; + +// Menu's brand link statically reads applicationRoot(); intercept it to +// simulate a subdirectory deployment. +const mockApplicationRoot = jest.fn(() => ''); + +jest.mock('src/utils/getBootstrapData', () => { + const actual = jest.requireActual< + typeof import('src/utils/getBootstrapData') + >('src/utils/getBootstrapData'); + return { + __esModule: true, + default: actual.default, + applicationRoot: () => mockApplicationRoot(), + staticAssetsPrefix: actual.staticAssetsPrefix, + }; +}); + +jest.mock('@apache-superset/core/theme', () => ({ + ...jest.requireActual('@apache-superset/core/theme'), + useTheme: jest.fn(), +})); + +jest.mock('antd', () => ({ + ...jest.requireActual('antd'), + Grid: { + ...jest.requireActual('antd').Grid, + useBreakpoint: () => ({ md: true }), + }, +})); + +// IMPORTANT: unlike Menu.test.tsx this file does NOT mock GenericLink. It +// renders the real react-router-dom under a real +// `` — the same seam production builds via +// `` in src/views/App.tsx. BrowserRouter +// honours basename in createHref and re-prepends the root WITHOUT deduping, so +// this exercises the actual rendered href the user clicks. The seam mock in +// Menu.test.tsx (no basename) is blind to that re-prepend; this file covers it. + +// eslint-disable-next-line import/first +import { Menu } from './Menu'; + +const APP_ROOT = '/superset'; + +const user = { + createdOn: '2021-04-27T18:12:38.952304', + email: 'admin', + firstName: 'admin', + isActive: true, + lastName: 'admin', + permissions: {}, + roles: { + Admin: [ + ['can_sqllab', 'Superset'], + ['can_write', 'Dashboard'], + ['can_write', 'Chart'], + ], + }, + userId: 1, + username: 'admin', +}; + +const mockedProps = { + user, + data: { + menu: [], + brand: { + path: '/superset/welcome/', + icon: '/static/assets/images/superset-logo-horiz.png', + alt: 'Apache Superset', + width: '126', + tooltip: '', + text: '', + }, + environment_tag: { + text: 'Production', + color: '#000', + }, + navbar_right: { + show_watermark: false, + bug_report_url: '/report/', + documentation_url: '/docs/', + languages: { + en: { flag: 'us', name: 'English', url: '/lang/en' }, + }, + show_language_picker: true, + user_is_anonymous: true, + user_info_url: '/users/userinfo/', + user_logout_url: '/logout/', + user_login_url: '/login/', + locale: 'en', + version_string: '1.0.0', + version_sha: 'randomSHA', + build_number: 'randomBuildNumber', + }, + settings: [], + }, +}; + +const useSelectorMock = jest.spyOn(reactRedux, 'useSelector'); +const useThemeMock = CoreTheme.useTheme as jest.Mock; + +fetchMock.get( + 'glob:*api/v1/database/?q=(filters:!((col:allow_file_upload,opr:upload_is_enabled,value:!t)))', + {}, +); + +const renderUnderSubdirectory = () => + render( + // Mirror production's `` + // (views/App.tsx). BrowserRouter honours basename in createHref, so a + // router-relative `to` is re-prefixed exactly once — the seam the brand + // link's strip must feed correctly. + + +
+ + , + { useRedux: true, useTheme: true }, + ); + +beforeEach(() => { + mockApplicationRoot.mockReturnValue(APP_ROOT); + useSelectorMock.mockReturnValue({ roles: user.roles }); + // Keep the page URL under the basename so BrowserRouter is consistent. + window.history.replaceState({}, '', `${APP_ROOT}/welcome/`); +}); + +afterEach(() => { + window.history.replaceState({}, '', '/'); + useSelectorMock.mockClear(); + jest.restoreAllMocks(); +}); + +test('brand logo link is single-prefixed when brandLogoHref arrives already rooted', async () => { + // The backend emits brandLogoHref already carrying the app root. The Router + // basename re-prepends the root, so the strip must run first to avoid a + // doubled /superset/superset/... href in the rendered anchor. + useThemeMock.mockReturnValue({ + ...CoreTheme.supersetTheme, + brandLogoUrl: '/static/assets/images/custom-logo.png', + brandLogoHref: '/superset/welcome/', + }); + + renderUnderSubdirectory(); + + const brandLink = await screen.findByRole('link', { + name: /apache superset/i, + }); + expect(brandLink).toHaveAttribute('href', '/superset/welcome/'); + expect(brandLink.getAttribute('href')).not.toContain('/superset/superset'); +}); + +test('brand logo link is single-prefixed when brandLogoHref is a bare path', async () => { + // A bare (un-rooted) brandLogoHref must end up prefixed exactly once after + // the Router basename re-prepend. + useThemeMock.mockReturnValue({ + ...CoreTheme.supersetTheme, + brandLogoUrl: '/static/assets/images/custom-logo.png', + brandLogoHref: '/welcome/', + }); + + renderUnderSubdirectory(); + + const brandLink = await screen.findByRole('link', { + name: /apache superset/i, + }); + expect(brandLink).toHaveAttribute('href', '/superset/welcome/'); + expect(brandLink.getAttribute('href')).not.toContain('/superset/superset'); +}); + +test('brand logo link renders without crashing when brandLogoHref is unset (partial theme override)', async () => { + // A partial theme override can set brandLogoUrl (the image) while leaving + // brandLogoHref undefined. The internal branch must stay null-safe: stripping + // the ensureAppRoot'd value (which falls back to the app root for an undefined + // input) avoids a `undefined.startsWith` TypeError that would white-screen the + // nav chrome. The rendered href resolves to the app root, never doubled. + useThemeMock.mockReturnValue({ + ...CoreTheme.supersetTheme, + brandLogoUrl: '/static/assets/images/custom-logo.png', + brandLogoHref: undefined, + }); + + renderUnderSubdirectory(); + + const brandLink = await screen.findByRole('link', { + name: /apache superset/i, + }); + expect(brandLink.getAttribute('href')).toMatch(/^\/superset\/?$/); + expect(brandLink.getAttribute('href')).not.toContain('/superset/superset'); +}); + +test('external brandLogoHref passes through without app-root prefixing', async () => { + useThemeMock.mockReturnValue({ + ...CoreTheme.supersetTheme, + brandLogoUrl: '/static/assets/images/custom-logo.png', + brandLogoHref: 'https://external.example.com', + }); + + renderUnderSubdirectory(); + + const brandLink = await screen.findByRole('link', { + name: /apache superset/i, + }); + expect(brandLink).toHaveAttribute('href', 'https://external.example.com'); +}); diff --git a/superset-frontend/src/features/home/Menu.test.tsx b/superset-frontend/src/features/home/Menu.test.tsx index 99555d14296..08f1db0f5ae 100644 --- a/superset-frontend/src/features/home/Menu.test.tsx +++ b/superset-frontend/src/features/home/Menu.test.tsx @@ -25,6 +25,35 @@ import * as CoreTheme from '@apache-superset/core/theme'; import { Menu } from './Menu'; import * as getBootstrapData from 'src/utils/getBootstrapData'; +// Capture what `` receives so the SPA-route regression +// tests can assert on the value handed to react-router-dom (which applies its +// own basename in production via `` in +// src/views/App.tsx). The test harness's `` has no basename, +// so asserting on the rendered `` wouldn't catch the double-prefix. +let observedGenericLinkTo: unknown = null; +jest.mock('src/components/GenericLink', () => ({ + __esModule: true, + GenericLink: ({ + to, + children, + ...rest + }: { + to: unknown; + children: React.ReactNode; + [k: string]: unknown; + }) => { + observedGenericLinkTo = to; + return ( + )} + > + {children} + + ); + }, +})); + jest.mock('@apache-superset/core/theme', () => ({ ...jest.requireActual('@apache-superset/core/theme'), useTheme: jest.fn(), @@ -797,6 +826,161 @@ test('brand link falls back to brand.path when theme brandLogoUrl is absent', as expect(brandLink).toHaveAttribute('href', '/superset/welcome/'); }); +// Regression: the real backend emits `brand.path` and `brand.icon` already +// carrying the app root (because they pass through `url_for`). The frontend +// must not double-prefix them — neither via +// `ensureAppRoot`/`ensureStaticPrefix` nor via React Router's `basename` +// re-prepend. +// +// In production the SPA-route branch goes through ` -> +// react-router-dom `, and the Router's `basename={applicationRoot()}` +// (src/views/App.tsx) re-prepends the app root to the rendered `href`. The +// test harness's `` has no basename, so asserting on the +// rendered `` wouldn't catch the bug. Instead we mock `GenericLink` +// at module load (top of this file) and assert the path *handed to it* +// already has the root stripped — the value the production Router will then +// safely re-prepend. + +describe('brand link single-prefix regressions (subdirectory deployment)', () => { + beforeEach(() => { + observedGenericLinkTo = null; + }); + + test('brand link hands a root-stripped path to GenericLink when brand.path arrives already rooted (SPA route)', async () => { + applicationRootMock.mockReturnValue('/superset'); + staticAssetsPrefixMock.mockReturnValue('/superset'); + useSelectorMock.mockReturnValue({ roles: user.roles }); + + const propsWithRootedBrand = { + ...mockedProps, + isFrontendRoute: () => true, + data: { + ...mockedProps.data, + brand: { + ...mockedProps.data.brand, + path: '/superset/welcome/', + icon: '/superset/static/assets/images/superset-logo-horiz.png', + }, + }, + }; + + render(, { + useRedux: true, + useQueryParams: true, + useRouter: true, + useTheme: true, + }); + + // Wait for the mocked GenericLink to render. + await screen.findByRole('link', { + name: new RegExp(propsWithRootedBrand.data.brand.alt, 'i'), + }); + expect(observedGenericLinkTo).toBe('/welcome/'); + }); + + test('brand link is single-prefix when brand.path arrives already rooted (non-SPA route)', async () => { + applicationRootMock.mockReturnValue('/superset'); + staticAssetsPrefixMock.mockReturnValue('/superset'); + useSelectorMock.mockReturnValue({ roles: user.roles }); + + const propsWithRootedBrand = { + ...mockedProps, + isFrontendRoute: () => false, + data: { + ...mockedProps.data, + brand: { + ...mockedProps.data.brand, + path: '/superset/welcome/', + icon: '/superset/static/assets/images/superset-logo-horiz.png', + }, + }, + }; + + render(, { + useRedux: true, + useQueryParams: true, + useRouter: true, + useTheme: true, + }); + + const brandLink = await screen.findByRole('link', { + name: new RegExp(propsWithRootedBrand.data.brand.alt, 'i'), + }); + expect(brandLink).toHaveAttribute('href', '/superset/welcome/'); + const brandImg = brandLink.querySelector('img'); + expect(brandImg).toHaveAttribute( + 'src', + '/superset/static/assets/images/superset-logo-horiz.png', + ); + }); + + test('brand link strips a nested application root before handing to GenericLink', async () => { + applicationRootMock.mockReturnValue('/preset/superset'); + staticAssetsPrefixMock.mockReturnValue('/preset/superset'); + useSelectorMock.mockReturnValue({ roles: user.roles }); + + const propsWithRootedBrand = { + ...mockedProps, + isFrontendRoute: () => true, + data: { + ...mockedProps.data, + brand: { + ...mockedProps.data.brand, + path: '/preset/superset/welcome/', + icon: '/preset/superset/static/assets/images/superset-logo-horiz.png', + }, + }, + }; + + render(, { + useRedux: true, + useQueryParams: true, + useRouter: true, + useTheme: true, + }); + + await screen.findByRole('link', { + name: new RegExp(propsWithRootedBrand.data.brand.alt, 'i'), + }); + expect(observedGenericLinkTo).toBe('/welcome/'); + }); + + test('brand link from theme.brandLogoHref hands a root-stripped path to GenericLink when already rooted', async () => { + applicationRootMock.mockReturnValue('/superset'); + staticAssetsPrefixMock.mockReturnValue('/superset'); + useSelectorMock.mockReturnValue({ roles: user.roles }); + + useThemeMock.mockReturnValue({ + ...CoreTheme.supersetTheme, + brandLogoUrl: '/superset/static/assets/images/custom-logo.png', + brandLogoHref: '/superset/welcome/', + }); + + render(, { + useRedux: true, + useQueryParams: true, + useRouter: true, + useTheme: true, + }); + + const brandLink = await screen.findByRole('link', { + name: /apache superset/i, + }); + // The internal brand logo link goes through StyledBrandLink -> GenericLink + // -> react-router . The production Router re-prepends the app root, so + // the value handed to GenericLink must already be stripped to avoid a + // doubled /superset/superset/... href. (Real-basename coverage of the + // resulting rendered href lives in Menu.subdirectory.test.tsx.) + expect(observedGenericLinkTo).toBe('/welcome/'); + expect(brandLink).toHaveAttribute('href', '/welcome/'); + const brandImg = brandLink.querySelector('img'); + expect(brandImg).toHaveAttribute( + 'src', + '/superset/static/assets/images/custom-logo.png', + ); + }); +}); + // --- Active tab highlighting (regression tests for issue #36403) --- // // The active top-level tab is highlighted by matching the current route to a diff --git a/superset-frontend/src/features/home/Menu.tsx b/superset-frontend/src/features/home/Menu.tsx index a7e8d2dcdf3..6b45aa7eed5 100644 --- a/superset-frontend/src/features/home/Menu.tsx +++ b/superset-frontend/src/features/home/Menu.tsx @@ -20,7 +20,7 @@ import { useState, useEffect } from 'react'; import { styled, css, useTheme } from '@apache-superset/core/theme'; import { t } from '@apache-superset/core/translation'; import { ensureStaticPrefix } from 'src/utils/assetUrl'; -import { ensureAppRoot } from 'src/utils/pathUtils'; +import { ensureAppRoot, stripAppRoot } from 'src/utils/navigationUtils'; import { getUrlParam, isUrlExternal } from 'src/utils/urlUtils'; import { MainNav, MenuItem } from '@superset-ui/core/components/Menu'; import { Tooltip, Grid, Row, Col, Image } from '@superset-ui/core/components'; @@ -258,10 +258,18 @@ export function Menu({ // of the localized label. Fall back to the label when no name is provided. const key = name ?? label; if (url && isFrontendRoute) { + // `` re-prepends the app root to + // `to`, so handing it the already-rooted `url` from bootstrap_data + // would render a doubled `/superset/superset/...` anchor. Strip the + // root first; mirrors the brand-link treatment below. return { key, label: ( - + {label} ), @@ -288,7 +296,11 @@ export function Menu({ // collide with that parent. Fall back to the label when no name. key: child.name ?? `${child.label}`, label: child.isFrontendRoute ? ( - + {child.label} ) : ( @@ -309,6 +321,9 @@ export function Menu({ }; }; const renderBrand = () => { + if (brand.hide_logo) { + return null; + } let link; if (theme.brandLogoUrl) { const brandHref = ensureAppRoot(theme.brandLogoHref); @@ -327,7 +342,18 @@ export function Menu({ {brandImage} ) : ( - {brandImage} + // StyledBrandLink wraps GenericLink -> react-router , and + // `` re-prepends the app root + // to `to`. Strip the root so the rendered anchor is single-prefixed + // rather than a doubled `/superset/superset/...`. Strip `brandHref` + // (the ensureAppRoot'd value) rather than the raw + // `theme.brandLogoHref` so an unset href (partial theme override) + // stays null-safe — `ensureAppRoot(undefined)` yields the app root, + // which `stripAppRoot` then reduces to `/`. Mirrors the brand.path + // branch's single-prefix treatment. + + {brandImage} + )} ); @@ -335,8 +361,12 @@ export function Menu({ // --------------------------------------------------------------------------------- // TODO: deprecate this once Theme is fully rolled out // Kept as is for backwards compatibility with the old theme system / superset_config.py + // + // `` re-prepends the app root to the + // `to` prop, so handing it an already-rooted `brand.path` would render a + // doubled `/superset/superset/...` href. Strip the root first. link = ( - + - - {renderBrand()} - - {brand.text && ( + {!brand.hide_logo && ( + + {renderBrand()} + + )} + {!brand.hide_logo && brand.text && ( {brand.text} diff --git a/superset-frontend/src/features/home/RightMenu.test.tsx b/superset-frontend/src/features/home/RightMenu.test.tsx index 83cdbdf6eb5..d57f676cd17 100644 --- a/superset-frontend/src/features/home/RightMenu.test.tsx +++ b/superset-frontend/src/features/home/RightMenu.test.tsx @@ -26,6 +26,7 @@ import { } from 'spec/helpers/testing-library'; import { isFeatureEnabled, FeatureFlag, CACHE_KEY } from '@superset-ui/core'; import { isEmbedded } from 'src/dashboard/util/isEmbedded'; +import * as getBootstrapData from 'src/utils/getBootstrapData'; import RightMenu from './RightMenu'; import { GlobalMenuDataOptions, RightMenuProps } from './types'; @@ -495,3 +496,66 @@ test('hides logout button when embedded and flag is enabled', async () => { userEvent.hover(await screen.findByText(/Settings/i)); expect(screen.queryByText('Logout')).not.toBeInTheDocument(); }); + +test('Info link href is single-prefixed under subdirectory deployment', async () => { + // Backend emits a bare leading-slash path (`/user_info/` or `/users/userinfo/`). + // RightMenu wraps it with ensureAppRoot, which reads applicationRoot() + // dynamically. Under SUPERSET_APP_ROOT=/superset the rendered href must + // be exactly `/superset/users/userinfo/` — not `/users/userinfo/` (no + // prefix → 404) or `/superset/superset/users/userinfo/` (double prefix). + const applicationRootSpy = jest + .spyOn(getBootstrapData, 'applicationRoot') + .mockReturnValue('/superset'); + + try { + resetUseSelectorMock(); + render(, { + useRedux: true, + useQueryParams: true, + useRouter: true, + useTheme: true, + }); + + userEvent.hover(await screen.findByText(/Settings/i)); + const infoLink = await screen.findByText('Info'); + expect(infoLink.closest('a')).toHaveAttribute( + 'href', + '/superset/users/userinfo/', + ); + } finally { + applicationRootSpy.mockRestore(); + } +}); + +test('Logout link href is single-prefixed under subdirectory deployment', async () => { + // The logout URL is built by Flask-AppBuilder's get_url_for_logout, which + // is SCRIPT_NAME-aware and returns `/superset/logout/` under app_root. + // The frontend then routes it through ensureAppRoot, whose idempotence + // contract (see pathUtils.parity.test.ts) must prevent doubling. + const applicationRootSpy = jest + .spyOn(getBootstrapData, 'applicationRoot') + .mockReturnValue('/superset'); + + try { + const props = createProps(); + // Mirror the SCRIPT_NAME-prefixed value the backend would emit under + // APPLICATION_ROOT=/superset. + props.navbarRight.user_logout_url = '/superset/logout/'; + resetUseSelectorMock(); + render(, { + useRedux: true, + useQueryParams: true, + useRouter: true, + useTheme: true, + }); + + userEvent.hover(await screen.findByText(/Settings/i)); + const logoutLink = await screen.findByText('Logout'); + expect(logoutLink.closest('a')).toHaveAttribute( + 'href', + '/superset/logout/', + ); + } finally { + applicationRootSpy.mockRestore(); + } +}); diff --git a/superset-frontend/src/features/home/RightMenu.tsx b/superset-frontend/src/features/home/RightMenu.tsx index d05abae30c9..a9296a3498d 100644 --- a/superset-frontend/src/features/home/RightMenu.tsx +++ b/superset-frontend/src/features/home/RightMenu.tsx @@ -53,7 +53,7 @@ import { TelemetryPixel, } from '@superset-ui/core/components'; import type { ItemType, MenuItem } from '@superset-ui/core/components/Menu'; -import { ensureAppRoot } from 'src/utils/pathUtils'; +import { ensureAppRoot, stripAppRoot } from 'src/utils/navigationUtils'; import { isEmbedded } from 'src/dashboard/util/isEmbedded'; import { findPermission } from 'src/utils/findPermission'; import { isUserAdmin } from 'src/dashboard/util/permissionUtils'; @@ -427,7 +427,7 @@ const RightMenu = ({ items.push({ key: menu.label, label: isFrontendRoute(menu.url) ? ( - {menu.label} + {menu.label} ) : ( {menu.label} @@ -443,7 +443,7 @@ const RightMenu = ({ items.push({ key: menu.label, label: isFrontendRoute(menu.url) ? ( - {menu.label} + {menu.label} ) : ( {menu.label} @@ -478,7 +478,9 @@ const RightMenu = ({ sectionItems.push({ key: child.label, label: isFrontendRoute(child.url) ? ( - {menuItemDisplay} + + {menuItemDisplay} + ) : ( { const mockGet = SupersetClient.get as jest.Mock; mockGet.mockImplementation(({ endpoint }) => { if ( - endpoint?.includes( - `/api/v1/security/roles/${mockRole.id}/permissions/`, - ) + endpoint?.includes(`/api/v1/security/roles/${mockRole.id}/permissions/`) ) { // Only return permission id=10, not id=20 return Promise.resolve({ @@ -298,9 +296,7 @@ describe('RoleListEditModal', () => { const mockGet = SupersetClient.get as jest.Mock; mockGet.mockImplementation(({ endpoint }) => { if ( - endpoint?.includes( - `/api/v1/security/roles/${mockRole.id}/permissions/`, - ) + endpoint?.includes(`/api/v1/security/roles/${mockRole.id}/permissions/`) ) { return Promise.reject(new Error('network error')); } @@ -371,7 +367,9 @@ describe('RoleListEditModal', () => { }; mockGet.mockImplementation(({ endpoint }) => { - if (endpoint?.includes(`/api/v1/security/roles/${roleA.id}/permissions/`)) { + if ( + endpoint?.includes(`/api/v1/security/roles/${roleA.id}/permissions/`) + ) { return Promise.resolve({ json: { result: roleA.permission_ids.map(pid => ({ @@ -382,7 +380,9 @@ describe('RoleListEditModal', () => { }, }); } - if (endpoint?.includes(`/api/v1/security/roles/${roleB.id}/permissions/`)) { + if ( + endpoint?.includes(`/api/v1/security/roles/${roleB.id}/permissions/`) + ) { return Promise.resolve({ json: { result: roleB.permission_ids.map(pid => ({ diff --git a/superset-frontend/src/filters/components/TimeGrain/TimeGrainFilterPlugin.test.tsx b/superset-frontend/src/filters/components/TimeGrain/TimeGrainFilterPlugin.test.tsx index d2a2c3bd7fa..94bd5775b68 100644 --- a/superset-frontend/src/filters/components/TimeGrain/TimeGrainFilterPlugin.test.tsx +++ b/superset-frontend/src/filters/components/TimeGrain/TimeGrainFilterPlugin.test.tsx @@ -85,12 +85,12 @@ test('renders all options when time_grains is not set', async () => { expect(options[3]).toHaveTextContent('Year'); }); -test('filters options based on time_grains allowlist', async () => { +test('filters options based on timeGrains allowlist', async () => { const propsWithAllowlist = { ...defaultProps, formData: { ...defaultProps.formData, - time_grains: ['P1D', 'P1W'], + timeGrains: ['P1D', 'P1W'], }, }; @@ -106,12 +106,12 @@ test('filters options based on time_grains allowlist', async () => { expect(options[1]).toHaveTextContent('Week'); }); -test('shows all options when time_grains is empty array', async () => { +test('shows all options when timeGrains is empty array', async () => { const propsWithEmptyAllowlist = { ...defaultProps, formData: { ...defaultProps.formData, - time_grains: [], + timeGrains: [], }, }; @@ -125,12 +125,12 @@ test('shows all options when time_grains is empty array', async () => { expect(options.length).toBe(4); }); -test('shows all options when time_grains is undefined', async () => { +test('shows all options when timeGrains is undefined', async () => { const propsWithUndefined = { ...defaultProps, formData: { ...defaultProps.formData, - time_grains: undefined, + timeGrains: undefined, }, }; diff --git a/superset-frontend/src/filters/components/TimeGrain/TimeGrainFilterPlugin.tsx b/superset-frontend/src/filters/components/TimeGrain/TimeGrainFilterPlugin.tsx index 1f0dfce91e6..c37844121c8 100644 --- a/superset-frontend/src/filters/components/TimeGrain/TimeGrainFilterPlugin.tsx +++ b/superset-frontend/src/filters/components/TimeGrain/TimeGrainFilterPlugin.tsx @@ -93,11 +93,6 @@ export default function PluginFilterTimegrain( handleChange(filterState.value ?? []); }, [JSON.stringify(filterState.value)]); - const placeholderText = - (data || []).length === 0 - ? t('No data') - : tn('%s option', '%s options', data.length, data.length); - const formItemData: FormItemProps = {}; if (filterState.validateMessage) { formItemData.extra = ( @@ -115,15 +110,20 @@ export default function PluginFilterTimegrain( value: duration, }; }) - // Apply allowlist filter if time_grains is configured, but keep current selection visible + // Apply allowlist filter if timeGrains is configured, but keep current selection visible .filter(option => { - const allowlist = formData.time_grains; + const allowlist = formData.timeGrains; if (!allowlist || allowlist.length === 0) { return true; } return allowlist.includes(option.value) || value.includes(option.value); }); + const placeholderText = + options.length === 0 + ? t('No data') + : tn('%s option', '%s options', options.length, options.length); + return ( diff --git a/superset-frontend/src/filters/components/TimeGrain/TimeGrainPreFilter.integration.test.tsx b/superset-frontend/src/filters/components/TimeGrain/TimeGrainPreFilter.integration.test.tsx index 501eafe596f..b0175a4f3f2 100644 --- a/superset-frontend/src/filters/components/TimeGrain/TimeGrainPreFilter.integration.test.tsx +++ b/superset-frontend/src/filters/components/TimeGrain/TimeGrainPreFilter.integration.test.tsx @@ -36,6 +36,7 @@ import { waitFor, } from 'spec/helpers/testing-library'; import PluginFilterTimegrain from 'src/filters/components/TimeGrain/TimeGrainFilterPlugin'; +import type { PluginFilterTimeGrainProps } from 'src/filters/components/TimeGrain/types'; /** * Scenario: Dashboard owner configures a time grain filter to show only Hour, Day, Week. @@ -45,7 +46,7 @@ test('time grain pre-filter restricts dashboard filter options', async () => { // Step 1: Simulate saved dashboard config // (User previously set pre-filter to ['PT1H', 'P1D', 'P1W']) const setDataMask = jest.fn(); - const dashboardConfig = { + const dashboardConfig: PluginFilterTimeGrainProps = { data: [ { duration: 'PT1M', name: 'Minute' }, { duration: 'PT1H', name: 'Hour' }, @@ -54,11 +55,14 @@ test('time grain pre-filter restricts dashboard filter options', async () => { { duration: 'P1M', name: 'Month' }, ], formData: { + datasource: '3__table', + height: 100, + width: 300, nativeFilterId: 'time_grain_1', defaultValue: null, viz_type: 'filter_timegrain', - // This is what was saved by the config form: - time_grains: ['PT1H', 'P1D', 'P1W'], + // This is what was saved by the config form (camelCased by ChartProps): + timeGrains: ['PT1H', 'P1D', 'P1W'], }, filterState: { value: null, @@ -77,7 +81,9 @@ test('time grain pre-filter restricts dashboard filter options', async () => { }; // Step 2: Render the dashboard filter - render(); + render(); + + expect(screen.getByText('3 options')).toBeInTheDocument(); // Ignore initialization updates and validate the explicit user-selection payload. setDataMask.mockClear(); @@ -119,7 +125,7 @@ test('time grain pre-filter restricts dashboard filter options', async () => { * No restrictions: all time grains appear in the runtime filter. */ test('all time grains appear when pre-filter is unchecked', async () => { - const dashboardConfig = { + const dashboardConfig: PluginFilterTimeGrainProps = { data: [ { duration: 'PT1M', name: 'Minute' }, { duration: 'PT1H', name: 'Hour' }, @@ -128,11 +134,14 @@ test('all time grains appear when pre-filter is unchecked', async () => { { duration: 'P1M', name: 'Month' }, ], formData: { + datasource: '3__table', + height: 100, + width: 300, nativeFilterId: 'time_grain_1', defaultValue: null, viz_type: 'filter_timegrain', // Pre-filter not set (checkbox unchecked in config) - time_grains: undefined, + timeGrains: undefined, }, filterState: { value: null, @@ -150,7 +159,7 @@ test('all time grains appear when pre-filter is unchecked', async () => { inputRef: { current: null }, }; - render(); + render(); const select = screen.getByRole('combobox'); await userEvent.click(select); diff --git a/superset-frontend/src/filters/components/TimeGrain/types.ts b/superset-frontend/src/filters/components/TimeGrain/types.ts index c1db6a57a8d..3ae7b8efb0e 100644 --- a/superset-frontend/src/filters/components/TimeGrain/types.ts +++ b/superset-frontend/src/filters/components/TimeGrain/types.ts @@ -24,7 +24,7 @@ import { PluginFilterHooks, PluginFilterStylesProps } from '../types'; interface PluginFilterTimeGrainCustomizeProps { defaultValue?: string[] | null; inputRef?: RefObject; - time_grains?: string[]; + timeGrains?: string[]; } export type PluginFilterTimeGrainQueryFormData = QueryFormData & diff --git a/superset-frontend/src/middleware/logger.test.ts b/superset-frontend/src/middleware/logger.test.ts index f54c154d54d..5b52f3d682c 100644 --- a/superset-frontend/src/middleware/logger.test.ts +++ b/superset-frontend/src/middleware/logger.test.ts @@ -88,13 +88,13 @@ describe('logger middleware', () => { expect(next.mock.calls.length).toBe(1); }); - test('should POST an event to /superset/log/ when called', () => { + test('should POST an event to /log/ when called', () => { (logger as Function)(mockStore)(next)(action); expect(next.mock.calls.length).toBe(0); jest.advanceTimersByTime(2000); expect(postStub.mock.calls.length).toBe(1); - expect(postStub.mock.calls[0][0].endpoint).toMatch('/superset/log/'); + expect(postStub.mock.calls[0][0].endpoint).toMatch('/log/'); }); test('should include ts, start_offset, event_name, impression_id, source, and source_id in every event', () => { @@ -160,7 +160,7 @@ describe('logger middleware', () => { expect(beaconMock.mock.calls.length).toBe(1); const endpoint = beaconMock.mock.calls[0][0]; - expect(endpoint).toMatch('/superset/log/'); + expect(endpoint).toMatch('/log/'); }); test('should pass a guest token to sendBeacon if present', () => { diff --git a/superset-frontend/src/middleware/loggerMiddleware.ts b/superset-frontend/src/middleware/loggerMiddleware.ts index a41d96a53e6..b16c75503df 100644 --- a/superset-frontend/src/middleware/loggerMiddleware.ts +++ b/superset-frontend/src/middleware/loggerMiddleware.ts @@ -29,11 +29,16 @@ import { LOG_ACTIONS_SPA_NAVIGATION, } from '../logger/LogUtils'; import DebouncedMessageQueue from '../utils/DebouncedMessageQueue'; -import { ensureAppRoot } from '../utils/pathUtils'; +import { ensureAppRoot } from '../utils/navigationUtils'; import type { DashboardInfo, DashboardLayoutState } from '../dashboard/types'; import type { QueryEditor } from '../SqlLab/types'; -type LogEventSource = 'dashboard' | 'embedded_dashboard' | 'explore' | 'sqlLab' | 'slice'; +type LogEventSource = + | 'dashboard' + | 'embedded_dashboard' + | 'explore' + | 'sqlLab' + | 'slice'; interface LogEventData { source?: LogEventSource; @@ -88,7 +93,7 @@ interface LoggerStore { dispatch: Dispatch; } -const LOG_ENDPOINT = '/superset/log/?explode=events'; +const LOG_ENDPOINT = '/log/?explode=events'; const sendBeacon = (events: LogEventData[]): void => { if (events.length <= 0) { diff --git a/superset-frontend/src/pages/AnnotationLayerList/index.tsx b/superset-frontend/src/pages/AnnotationLayerList/index.tsx index 02d94cea471..9dca6fe73bf 100644 --- a/superset-frontend/src/pages/AnnotationLayerList/index.tsx +++ b/superset-frontend/src/pages/AnnotationLayerList/index.tsx @@ -42,7 +42,7 @@ import AnnotationLayerModal from 'src/features/annotationLayers/AnnotationLayerM import { AnnotationLayerObject } from 'src/features/annotationLayers/types'; import { QueryObjectColumns } from 'src/views/CRUD/types'; import { Icons } from '@superset-ui/core/components/Icons'; -import { navigateTo } from 'src/utils/navigationUtils'; +import { ensureAppRoot, navigateTo } from 'src/utils/navigationUtils'; import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils'; const PAGE_SIZE = 25; @@ -154,7 +154,9 @@ function AnnotationLayersList({ } return ( - + {name} ); diff --git a/superset-frontend/src/pages/AnnotationList/index.tsx b/superset-frontend/src/pages/AnnotationList/index.tsx index 68011f38772..948b1284a81 100644 --- a/superset-frontend/src/pages/AnnotationList/index.tsx +++ b/superset-frontend/src/pages/AnnotationList/index.tsx @@ -41,6 +41,7 @@ import { AnnotationObject } from 'src/features/annotations/types'; import AnnotationModal from 'src/features/annotations/AnnotationModal'; import { Icons } from '@superset-ui/core/components/Icons'; import { Typography } from '@superset-ui/core/components/Typography'; +import { ensureAppRoot } from 'src/utils/navigationUtils'; const PAGE_SIZE = 25; @@ -280,7 +281,7 @@ function AnnotationList({ {hasHistory ? ( {t('Back to all')} ) : ( - + {t('Back to all')} )} diff --git a/superset-frontend/src/pages/ChartList/ChartList.listview.test.tsx b/superset-frontend/src/pages/ChartList/ChartList.listview.test.tsx index 281e70ade26..5d9c27b93c5 100644 --- a/superset-frontend/src/pages/ChartList/ChartList.listview.test.tsx +++ b/superset-frontend/src/pages/ChartList/ChartList.listview.test.tsx @@ -564,7 +564,7 @@ test('renders dashboard crosslinks as navigable links', async () => { within(crosslinks).getByRole('link', { name: new RegExp(dashboard.dashboard_title), }), - ).toHaveAttribute('href', `/superset/dashboard/${dashboard.id}`); + ).toHaveAttribute('href', `/dashboard/${dashboard.id}`); }); }); @@ -604,7 +604,7 @@ test('shows tag column when TAGGING_SYSTEM is enabled', async () => { // Tag should be a link to all_entities page const tagLink = within(tag).getByRole('link'); - expect(tagLink).toHaveAttribute('href', '/superset/all_entities/?id=1'); + expect(tagLink).toHaveAttribute('href', '/all_entities/?id=1'); expect(tagLink).toHaveAttribute('target', '_blank'); }); diff --git a/superset-frontend/src/pages/ChartList/ChartList.testHelpers.tsx b/superset-frontend/src/pages/ChartList/ChartList.testHelpers.tsx index 1a27d5affeb..2ef4c28bf3d 100644 --- a/superset-frontend/src/pages/ChartList/ChartList.testHelpers.tsx +++ b/superset-frontend/src/pages/ChartList/ChartList.testHelpers.tsx @@ -33,7 +33,7 @@ export const mockHandleResourceExport = export const mockCharts = [ { id: 0, - url: '/superset/slice/0/', + url: '/explore/?slice_id=0', viz_type: 'table', slice_name: 'Test Chart 0', @@ -43,7 +43,7 @@ export const mockCharts = [ tags: [{ name: 'basic', type: 1, id: 1 }], datasource_name_text: 'public.test_dataset', - datasource_url: '/superset/explore/table/1/', + datasource_url: '/explore/?datasource_type=table&datasource_id=1', datasource_id: 1, changed_by_name: 'user', @@ -72,7 +72,7 @@ export const mockCharts = [ }, { id: 1, - url: '/superset/slice/1/', + url: '/explore/?slice_id=1', viz_type: 'bar', slice_name: 'Test Chart 1', @@ -93,7 +93,7 @@ export const mockCharts = [ ], datasource_name_text: 'sales_data', - datasource_url: '/superset/explore/table/2/', + datasource_url: '/explore/?datasource_type=table&datasource_id=2', datasource_id: 2, changed_by_name: 'admin', @@ -119,7 +119,7 @@ export const mockCharts = [ }, { id: 2, - url: '/superset/slice/2/', + url: '/explore/?slice_id=2', viz_type: 'line', slice_name: 'Test Chart 2', @@ -150,7 +150,7 @@ export const mockCharts = [ }, { id: 3, - url: '/superset/slice/3/', + url: '/explore/?slice_id=3', viz_type: 'area', slice_name: 'Test Chart 3', @@ -168,7 +168,7 @@ export const mockCharts = [ tags: [{ name: 'limit-test', type: 1, id: 10 }], datasource_name_text: 'public.limits_dataset', - datasource_url: '/superset/explore/table/4/', + datasource_url: '/explore/?datasource_type=table&datasource_id=4', datasource_id: 4, changed_by_name: 'limit_user', @@ -189,7 +189,7 @@ export const mockCharts = [ }, { id: 4, - url: '/superset/slice/4/', + url: '/explore/?slice_id=4', viz_type: 'bubble', slice_name: 'Test Chart 4', @@ -208,7 +208,7 @@ export const mockCharts = [ tags: [{ name: 'overflow', type: 1, id: 11 }], datasource_name_text: 'public.overflow_dataset', - datasource_url: '/superset/explore/table/5/', + datasource_url: '/explore/?datasource_type=table&datasource_id=5', datasource_id: 5, changed_by_name: 'overflow_user', diff --git a/superset-frontend/src/pages/DashboardList/DashboardList.testHelpers.tsx b/superset-frontend/src/pages/DashboardList/DashboardList.testHelpers.tsx index b5cc1e4ea20..876e634a1b5 100644 --- a/superset-frontend/src/pages/DashboardList/DashboardList.testHelpers.tsx +++ b/superset-frontend/src/pages/DashboardList/DashboardList.testHelpers.tsx @@ -39,7 +39,7 @@ export const mockHandleResourceExport = export const mockDashboards = [ { id: 1, - url: '/superset/dashboard/1/', + url: '/dashboard/1/', dashboard_title: 'Sales Dashboard', published: true, changed_by_name: 'admin', @@ -61,7 +61,7 @@ export const mockDashboards = [ }, { id: 2, - url: '/superset/dashboard/2/', + url: '/dashboard/2/', dashboard_title: 'Analytics Dashboard', published: false, changed_by_name: 'analyst', @@ -86,7 +86,7 @@ export const mockDashboards = [ }, { id: 3, - url: '/superset/dashboard/3/', + url: '/dashboard/3/', dashboard_title: 'Executive Overview', published: true, changed_by_name: 'admin', @@ -111,7 +111,7 @@ export const mockDashboards = [ }, { id: 4, - url: '/superset/dashboard/4/', + url: '/dashboard/4/', dashboard_title: 'Marketing Metrics', published: false, changed_by_name: 'marketing', @@ -133,7 +133,7 @@ export const mockDashboards = [ }, { id: 5, - url: '/superset/dashboard/5/', + url: '/dashboard/5/', dashboard_title: 'Ops Monitor', published: true, changed_by_name: 'ops', diff --git a/superset-frontend/src/pages/DatabaseList/index.tsx b/superset-frontend/src/pages/DatabaseList/index.tsx index b78eb0dd652..08efc789987 100644 --- a/superset-frontend/src/pages/DatabaseList/index.tsx +++ b/superset-frontend/src/pages/DatabaseList/index.tsx @@ -55,6 +55,7 @@ import { } from 'src/components'; import { Typography } from '@superset-ui/core/components/Typography'; import { getUrlParam } from 'src/utils/urlUtils'; +import { ensureAppRoot } from 'src/utils/navigationUtils'; import { URL_PARAMS } from 'src/constants'; import { Icons } from '@superset-ui/core/components/Icons'; import { isUserAdmin } from 'src/dashboard/util/permissionUtils'; @@ -1032,7 +1033,7 @@ function DatabaseList({ avatar={} title={ {result.title} @@ -1075,7 +1076,9 @@ function DatabaseList({ avatar={} title={ {result.slice_name} diff --git a/superset-frontend/src/pages/DatasetList/DatasetList.subdirectory.test.tsx b/superset-frontend/src/pages/DatasetList/DatasetList.subdirectory.test.tsx new file mode 100644 index 00000000000..2c8717f35d4 --- /dev/null +++ b/superset-frontend/src/pages/DatasetList/DatasetList.subdirectory.test.tsx @@ -0,0 +1,135 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { act, screen, waitFor } from '@testing-library/react'; +import fetchMock from 'fetch-mock'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { QueryParamProvider } from 'use-query-params'; +import { ReactRouter5Adapter } from 'use-query-params/adapters/react-router-5'; +import { render } from 'spec/helpers/testing-library'; + +// DatasetList statically imports applicationRoot(); intercept it so we can +// simulate a subdirectory deployment. Mirrors the Tab.subdirectory pattern. +const mockApplicationRoot = jest.fn(() => ''); + +jest.mock('src/utils/getBootstrapData', () => { + const actual = jest.requireActual< + typeof import('src/utils/getBootstrapData') + >('src/utils/getBootstrapData'); + return { + __esModule: true, + default: actual.default, + applicationRoot: () => mockApplicationRoot(), + staticAssetsPrefix: actual.staticAssetsPrefix, + }; +}); + +// eslint-disable-next-line import/first +import DatasetList from 'src/pages/DatasetList'; +// eslint-disable-next-line import/first +import { + setupMocks, + mockDatasetListEndpoints, + mockAdminUser, + mockDatasets, + createMockStore, + createDefaultStoreState, +} from './DatasetList.testHelpers'; + +const APP_ROOT = '/superset'; + +const renderUnderSubdirectory = () => { + const store = createMockStore({ + ...createDefaultStoreState(mockAdminUser), + user: mockAdminUser, + }); + return render( + + {/* Mirror production's `` + (views/App.tsx). BrowserRouter honours basename in createHref, so a + router-relative `to` is re-prefixed once — exactly the seam the strip + must feed correctly. */} + + + + + + , + ); +}; + +beforeEach(() => { + setupMocks(); + mockApplicationRoot.mockReturnValue(APP_ROOT); + // Put the page URL under the basename so BrowserRouter is consistent. + window.history.replaceState({}, '', `${APP_ROOT}/tablemodelview/list/`); +}); + +afterEach(async () => { + jest.useRealTimers(); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + window.history.replaceState({}, '', '/'); + fetchMock.clearHistory().removeRoutes(); + jest.restoreAllMocks(); +}); + +test('explore link is single-prefixed under a subdirectory deployment', async () => { + // The backend emits explore_url already carrying the application root. The + // Router basename re-prefixes the root, so the strip must run first to avoid + // a doubled /superset/superset/... href. + const dataset = { + ...mockDatasets[0], + explore_url: `${APP_ROOT}/explore/?datasource=1__table`, + }; + mockDatasetListEndpoints({ result: [dataset], count: 1 }); + + renderUnderSubdirectory(); + + await waitFor(() => { + expect(screen.getByText(dataset.table_name)).toBeInTheDocument(); + }); + + const exploreLink = screen.getByRole('link', { name: dataset.table_name }); + expect(exploreLink).toHaveAttribute( + 'href', + `${APP_ROOT}/explore/?datasource=1__table`, + ); + expect(exploreLink.getAttribute('href')).not.toContain('/superset/superset'); +}); + +test('external default_endpoint passes through unprefixed', async () => { + const dataset = { + ...mockDatasets[0], + explore_url: 'https://external.example.com/custom-endpoint', + }; + mockDatasetListEndpoints({ result: [dataset], count: 1 }); + + renderUnderSubdirectory(); + + await waitFor(() => { + expect(screen.getByText(dataset.table_name)).toBeInTheDocument(); + }); + + const exploreLink = screen.getByRole('link', { name: dataset.table_name }); + expect(exploreLink.getAttribute('href')).toMatch( + /^https:\/\/external\.example\.com\/custom-endpoint/, + ); +}); diff --git a/superset-frontend/src/pages/DatasetList/index.tsx b/superset-frontend/src/pages/DatasetList/index.tsx index 4c655548b23..e82c5228e16 100644 --- a/superset-frontend/src/pages/DatasetList/index.tsx +++ b/superset-frontend/src/pages/DatasetList/index.tsx @@ -71,6 +71,7 @@ import { import type { SelectOption } from 'src/components/ListView/types'; import { Typography } from '@superset-ui/core/components/Typography'; import handleResourceExport from 'src/utils/export'; +import { ensureAppRoot, stripAppRoot } from 'src/utils/navigationUtils'; import SubMenu, { SubMenuProps, ButtonProps } from 'src/features/home/SubMenu'; import Owner from 'src/types/Owner'; import withToasts from 'src/components/MessageToasts/withToasts'; @@ -704,10 +705,17 @@ const DatasetList: FunctionComponent = ({ }, }, }: CellProps) => { + // `explore_url` arrives router-relative from the backend (already + // carrying the application root under a subdirectory deployment). + // react-router's / resolve `to` against the + // Router basename, which re-prefixes the root — so strip it here to + // avoid a doubled `/superset/superset/...`. External + // `default_endpoint` URLs pass through unchanged. + const exploreTo = stripAppRoot(exploreURL); let titleLink: JSX.Element; if (PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET) { titleLink = ( - + {datasetTitle} ); @@ -715,7 +723,7 @@ const DatasetList: FunctionComponent = ({ titleLink = ( // exploreUrl can be a link to Explore or an external link // in the first case use SPA routing, else use HTML anchor - {datasetTitle} + {datasetTitle} ); } try { @@ -1396,7 +1404,7 @@ const DatasetList: FunctionComponent = ({ avatar={} title={ {result.title} @@ -1439,7 +1447,9 @@ const DatasetList: FunctionComponent = ({ avatar={} title={ {result.slice_name} diff --git a/superset-frontend/src/pages/FileHandler/FileHandler.subdirectory.test.tsx b/superset-frontend/src/pages/FileHandler/FileHandler.subdirectory.test.tsx new file mode 100644 index 00000000000..f7d1942f850 --- /dev/null +++ b/superset-frontend/src/pages/FileHandler/FileHandler.subdirectory.test.tsx @@ -0,0 +1,267 @@ +/** + * 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 { ComponentType } from 'react'; +import { Route, Router } from 'react-router-dom'; +import { createMemoryHistory, MemoryHistory } from 'history'; +import { + render, + screen, + userEvent, + waitFor, +} from 'spec/helpers/testing-library'; +import FileHandler from './index'; + +// Subdirectory regression for the five `history.push('/welcome/')` emitters +// in FileHandler. The sibling `index.test.tsx` mocks `useHistory` and so can +// only assert the value the emitter passes to `push`; this module wires up +// the real react-router pathway (real `` + a `createMemoryHistory` +// the test owns) and asserts that the resulting `history.location.pathname` +// resolves to `/welcome/` regardless of whether the user arrived under the +// root deployment (`/file-handler`) or a `/superset` subdirectory deployment +// (`/superset/file-handler`). +// +// Note on basename composition: Superset's dependency tree pairs +// `react-router-dom@5.3.4` with `history@5.3.0`, but the `basename` prop on +// `` is silently dropped in that combination (history v5 has +// no basename support; react-router-dom v5 was designed for history v4). The +// production subdirectory deployment composes the prefix at the URL-emitting +// layer via `applicationRoot()` in non-router callers (see +// `SliceHeaderControls.subdirectory.test.tsx`), and the in-app router relies +// on the unprefixed routes matching whatever path the user arrived at. As a +// result, asserting `history.createHref(...)` here would not exercise any +// real composition — so this module pins only what is meaningful in this +// stack: that the emitter pushes the unprefixed route and the post-push +// location reflects it. + +const mockAddDangerToast = jest.fn(); +const mockAddSuccessToast = jest.fn(); + +jest.setTimeout(60000); + +type ToastInjectedProps = { + addDangerToast: (msg: string) => void; + addSuccessToast: (msg: string) => void; +}; + +jest.mock('src/components/MessageToasts/withToasts', () => ({ + __esModule: true, + default: (Component: ComponentType) => + function MockedWithToasts(props: Record) { + return ( + + ); + }, +})); + +interface UploadDataModalProps { + show: boolean; + onHide: () => void; + type: string; + allowedExtensions: string[]; + fileListOverride?: File[]; +} + +jest.mock('src/features/databases/UploadDataModel', () => ({ + __esModule: true, + default: ({ + show, + onHide, + type, + allowedExtensions, + fileListOverride, + }: UploadDataModalProps) => ( +
+
{show.toString()}
+
{type}
+
{allowedExtensions.join(',')}
+
{fileListOverride?.[0]?.name ?? ''}
+ +
+ ), +})); + +// NOTE: deliberately NO jest.mock('react-router-dom', ...) here — this module +// exists precisely to exercise the real useHistory() pathway, not a mock. + +type MockFileHandle = { + kind: 'file'; + name: string; + getFile: () => Promise; + isSameEntry: () => Promise; + queryPermission: () => Promise; + requestPermission: () => Promise; +}; + +const createMockFileHandle = ( + fileName: string, + opts: { throwOnGetFile?: boolean } = {}, +): MockFileHandle => ({ + kind: 'file', + name: fileName, + getFile: opts.throwOnGetFile + ? async () => { + throw new Error('File access denied'); + } + : async () => new File(['test'], fileName), + isSameEntry: async () => false, + queryPermission: async () => 'granted', + requestPermission: async () => 'granted', +}); + +type LaunchQueue = { + setConsumer: ( + consumer: (params: { files?: MockFileHandle[] }) => void, + ) => void; +}; + +const pendingTimerIds = new Set>(); +const MAX_CONSUMER_POLL_ATTEMPTS = 50; + +// Mirrors `setupLaunchQueue` in index.test.tsx: defer the consumer to a +// macrotask so it doesn't fire synchronously inside the component's useEffect +// (the MessageChannel mock in jsDomWithFetchAPI forces React to schedule via +// setTimeout, and inline consumer calls deadlock Jest). +const setupLaunchQueue = (fileHandle: MockFileHandle | null = null) => { + let savedConsumer: + | ((params: { files?: MockFileHandle[] }) => void | Promise) + | null = null; + (window as unknown as Window & { launchQueue: LaunchQueue }).launchQueue = { + setConsumer: (consumer: (params: { files?: MockFileHandle[] }) => void) => { + savedConsumer = consumer; + if (fileHandle) { + const id = setTimeout(() => { + pendingTimerIds.delete(id); + consumer({ files: [fileHandle] }); + }, 0); + pendingTimerIds.add(id); + } + }, + }; + return { + triggerConsumer: async (params: { files?: MockFileHandle[] }) => { + let attempts = 0; + while (!savedConsumer && attempts < MAX_CONSUMER_POLL_ATTEMPTS) { + // eslint-disable-next-line no-await-in-loop + await new Promise(resolve => { + setTimeout(resolve, 0); + }); + attempts += 1; + } + if (!savedConsumer) { + throw new Error( + `LaunchQueue consumer was never registered after ${MAX_CONSUMER_POLL_ATTEMPTS} polling attempts`, + ); + } + await savedConsumer(params); + }, + }; +}; + +type DeploymentLabel = 'root' | 'subdir'; + +const ENTRY_PATHS: Record = { + root: '/file-handler', + subdir: '/superset/file-handler', +}; + +const renderUnderEntry = (entryPath: string): MemoryHistory => { + const history = createMemoryHistory({ initialEntries: [entryPath] }); + render( + + + + + , + { useRedux: true }, + ); + return history; +}; + +const expectNavigatedToWelcome = async ( + history: MemoryHistory, +): Promise => { + await waitFor(() => { + expect(history.location.pathname).toBe('/welcome/'); + }); +}; + +beforeEach(() => { + jest.clearAllMocks(); + delete (window as unknown as Window & { launchQueue?: LaunchQueue }) + .launchQueue; +}); + +afterEach(() => { + pendingTimerIds.forEach(id => clearTimeout(id)); + pendingTimerIds.clear(); + delete (window as unknown as Window & { launchQueue?: LaunchQueue }) + .launchQueue; +}); + +// Run each redirect scenario under both deployment shapes. Both must end at +// `/welcome/`; the test fails if a future maintainer re-introduces the +// `/superset/` prefix into the emitter at any of the five call sites. +const DEPLOYMENTS: DeploymentLabel[] = ['root', 'subdir']; + +DEPLOYMENTS.forEach(label => { + const entryPath = ENTRY_PATHS[label]; + + test(`launchQueue unsupported → /welcome/ under ${label} (${entryPath})`, async () => { + const history = renderUnderEntry(entryPath); + await expectNavigatedToWelcome(history); + }); + + test(`no files provided → /welcome/ under ${label} (${entryPath})`, async () => { + const { triggerConsumer } = setupLaunchQueue(); + const history = renderUnderEntry(entryPath); + await triggerConsumer({ files: [] }); + await expectNavigatedToWelcome(history); + }); + + test(`unsupported file type → /welcome/ under ${label} (${entryPath})`, async () => { + const { triggerConsumer } = setupLaunchQueue(); + const history = renderUnderEntry(entryPath); + await triggerConsumer({ files: [createMockFileHandle('test.pdf')] }); + await expectNavigatedToWelcome(history); + }); + + test(`getFile() error → /welcome/ under ${label} (${entryPath})`, async () => { + const { triggerConsumer } = setupLaunchQueue(); + const history = renderUnderEntry(entryPath); + await triggerConsumer({ + files: [createMockFileHandle('test.csv', { throwOnGetFile: true })], + }); + await expectNavigatedToWelcome(history); + }); + + test(`modal close → /welcome/ under ${label} (${entryPath})`, async () => { + setupLaunchQueue(createMockFileHandle('test.csv')); + const history = renderUnderEntry(entryPath); + const modal = await screen.findByTestId('upload-modal'); + expect(modal).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Close' })); + await expectNavigatedToWelcome(history); + }); +}); diff --git a/superset-frontend/src/pages/FileHandler/index.test.tsx b/superset-frontend/src/pages/FileHandler/index.test.tsx index a4496625fa7..b51149daaa0 100644 --- a/superset-frontend/src/pages/FileHandler/index.test.tsx +++ b/superset-frontend/src/pages/FileHandler/index.test.tsx @@ -201,7 +201,7 @@ test('shows error when launchQueue is not supported', async () => { expect(mockAddDangerToast).toHaveBeenCalledWith( 'File handling is not supported in this browser. Please use a modern browser like Chrome or Edge.', ); - expect(mockHistoryPush).toHaveBeenCalledWith('/superset/welcome/'); + expect(mockHistoryPush).toHaveBeenCalledWith('/welcome/'); }); }); @@ -221,7 +221,7 @@ test('redirects when no files are provided', async () => { await triggerConsumer({ files: [] }); await waitFor(() => { - expect(mockHistoryPush).toHaveBeenCalledWith('/superset/welcome/'); + expect(mockHistoryPush).toHaveBeenCalledWith('/welcome/'); }); }); @@ -326,7 +326,7 @@ test('shows error for unsupported file type', async () => { expect(mockAddDangerToast).toHaveBeenCalledWith( 'Unsupported file type. Please use CSV, Excel, or Columnar files.', ); - expect(mockHistoryPush).toHaveBeenCalledWith('/superset/welcome/'); + expect(mockHistoryPush).toHaveBeenCalledWith('/welcome/'); }); }); @@ -378,7 +378,7 @@ test('handles errors during file processing', async () => { expect(mockAddDangerToast).toHaveBeenCalledWith( 'Failed to open file. Please try again.', ); - expect(mockHistoryPush).toHaveBeenCalledWith('/superset/welcome/'); + expect(mockHistoryPush).toHaveBeenCalledWith('/welcome/'); }); }); @@ -402,7 +402,7 @@ test('modal close redirects to welcome page', async () => { await userEvent.click(screen.getByRole('button', { name: 'Close' })); await waitFor(() => { - expect(mockHistoryPush).toHaveBeenCalledWith('/superset/welcome/'); + expect(mockHistoryPush).toHaveBeenCalledWith('/welcome/'); }); }); diff --git a/superset-frontend/src/pages/FileHandler/index.tsx b/superset-frontend/src/pages/FileHandler/index.tsx index 589db966f74..ea24ce9383d 100644 --- a/superset-frontend/src/pages/FileHandler/index.tsx +++ b/superset-frontend/src/pages/FileHandler/index.tsx @@ -59,13 +59,13 @@ const FileHandler = ({ addDangerToast, addSuccessToast }: FileHandlerProps) => { 'File handling is not supported in this browser. Please use a modern browser like Chrome or Edge.', ), ); - history.push('/superset/welcome/'); + history.push('/welcome/'); return; } launchQueue.setConsumer(async (launchParams: FileLaunchParams) => { if (!launchParams.files || launchParams.files.length === 0) { - history.push('/superset/welcome/'); + history.push('/welcome/'); return; } @@ -92,7 +92,7 @@ const FileHandler = ({ addDangerToast, addSuccessToast }: FileHandlerProps) => { 'Unsupported file type. Please use CSV, Excel, or Columnar files.', ), ); - history.push('/superset/welcome/'); + history.push('/welcome/'); return; } @@ -103,7 +103,7 @@ const FileHandler = ({ addDangerToast, addSuccessToast }: FileHandlerProps) => { } catch (error) { console.error('Error handling file launch:', error); addDangerToast(t('Failed to open file. Please try again.')); - history.push('/superset/welcome/'); + history.push('/welcome/'); } }); }; @@ -115,7 +115,7 @@ const FileHandler = ({ addDangerToast, addSuccessToast }: FileHandlerProps) => { setShowModal(false); setUploadFile(null); setUploadType(null); - history.push('/superset/welcome/'); + history.push('/welcome/'); }; if (!uploadFile || !uploadType) { diff --git a/superset-frontend/src/pages/Login/Login.test.tsx b/superset-frontend/src/pages/Login/Login.test.tsx index 03e9ad72671..3a20d9c70b8 100644 --- a/superset-frontend/src/pages/Login/Login.test.tsx +++ b/superset-frontend/src/pages/Login/Login.test.tsx @@ -31,15 +31,25 @@ const defaultBootstrapData = { }, }; -jest.mock('src/utils/getBootstrapData', () => ({ - __esModule: true, - default: jest.fn(() => defaultBootstrapData), -})); +const mockApplicationRoot = jest.fn(() => ''); + +jest.mock('src/utils/getBootstrapData', () => { + const actual = jest.requireActual< + typeof import('src/utils/getBootstrapData') + >('src/utils/getBootstrapData'); + return { + __esModule: true, + ...actual, + default: jest.fn(() => defaultBootstrapData), + applicationRoot: () => mockApplicationRoot(), + }; +}); const mockGetBootstrapData = getBootstrapData as jest.Mock; beforeEach(() => { mockGetBootstrapData.mockReturnValue(defaultBootstrapData); + mockApplicationRoot.mockReturnValue(''); }); test('should render login form elements', () => { @@ -81,3 +91,52 @@ test('should render SAML provider buttons', () => { expect(screen.getByText('Sign in with Okta')).toBeInTheDocument(); expect(screen.getByText('Sign in with Onelogin')).toBeInTheDocument(); }); + +const samlBootstrapData = { + common: { + conf: { + AUTH_TYPE: 5, + AUTH_PROVIDERS: [{ name: 'okta', icon: 'okta' }], + AUTH_USER_REGISTRATION: false, + }, + }, +}; + +test('provider login links are root-relative on root deployments', () => { + mockGetBootstrapData.mockReturnValue(samlBootstrapData); + render(, { useRedux: true }); + expect( + screen.getByRole('link', { name: /sign in with okta/i }), + ).toHaveAttribute('href', '/login/okta'); +}); + +test('provider login links carry the application root on subdirectory deployments', () => { + mockApplicationRoot.mockReturnValue('/superset'); + mockGetBootstrapData.mockReturnValue(samlBootstrapData); + render(, { useRedux: true }); + expect( + screen.getByRole('link', { name: /sign in with okta/i }), + ).toHaveAttribute('href', '/superset/login/okta'); +}); + +test('provider login links preserve the next param under a subdirectory', () => { + mockApplicationRoot.mockReturnValue('/superset'); + mockGetBootstrapData.mockReturnValue(samlBootstrapData); + const next = '/superset/dashboard/1/'; + window.history.replaceState( + {}, + '', + `/superset/login/?next=${encodeURIComponent(next)}`, + ); + try { + render(, { useRedux: true }); + expect( + screen.getByRole('link', { name: /sign in with okta/i }), + ).toHaveAttribute( + 'href', + `/superset/login/okta?next=${encodeURIComponent(next)}`, + ); + } finally { + window.history.replaceState({}, '', '/'); + } +}); diff --git a/superset-frontend/src/pages/Login/index.tsx b/superset-frontend/src/pages/Login/index.tsx index e585bf08f54..d704945d1fd 100644 --- a/superset-frontend/src/pages/Login/index.tsx +++ b/superset-frontend/src/pages/Login/index.tsx @@ -34,6 +34,7 @@ import { capitalize } from 'lodash/fp'; import { addDangerToast } from 'src/components/MessageToasts/actions'; import { useDispatch } from 'react-redux'; import getBootstrapData from 'src/utils/getBootstrapData'; +import { ensureAppRoot } from 'src/utils/navigationUtils'; type OAuthProvider = { name: string; @@ -100,10 +101,10 @@ export default function Login() { ); const buildProviderLoginUrl = (providerName: string) => { - const base = `/login/${providerName}`; - return nextUrl - ? `${base}${base.includes('?') ? '&' : '?'}next=${encodeURIComponent(nextUrl)}` - : base; + const base = `/login/${encodeURIComponent(providerName)}`; + return ensureAppRoot( + nextUrl ? `${base}?next=${encodeURIComponent(nextUrl)}` : base, + ); }; const authType: AuthType = bootstrapData.common.conf.AUTH_TYPE; @@ -256,7 +257,7 @@ export default function Login() { + + + + ); + } + return ( diff --git a/superset-frontend/src/pages/RedirectWarning/utils.test.ts b/superset-frontend/src/pages/RedirectWarning/utils.test.ts index 0cb38a2d80f..7c9907cda10 100644 --- a/superset-frontend/src/pages/RedirectWarning/utils.test.ts +++ b/superset-frontend/src/pages/RedirectWarning/utils.test.ts @@ -56,6 +56,65 @@ test('isAllowedScheme allows relative URLs (unparseable as absolute)', () => { expect(isAllowedScheme('/dashboard/1')).toBe(true); }); +test('isAllowedScheme blocks protocol-relative URLs', () => { + // `new URL('//evil.example.com')` throws standalone, so without the + // explicit guard the catch branch would let cross-origin protocol- + // relative URLs through as "relative". + expect(isAllowedScheme('//evil.example.com')).toBe(false); + expect(isAllowedScheme('//evil.example.com/phish?token=abc')).toBe(false); +}); + +test('isAllowedScheme does not block single-leading-slash absolute paths', () => { + // Guard against an over-broad fix that strips both `//` and `/foo`. + expect(isAllowedScheme('/dashboard/list/')).toBe(true); +}); + +// The up-front +// `startsWith('//')` check missed backslash variants. `new URL('/\\evil.com')` +// throws → the catch returns `true` (allow) → the interstitial UI shows +// `/\evil.com` inside an "External link warning" Card with a Continue +// button. Browsers normalise `/\` → `//` in the special-scheme authority, +// so the consented click became `https://evil.com`. The fix must reject +// **before** the `new URL` attempt so the throw cannot route through the +// allow branch. + +test('isAllowedScheme blocks /\\evil.com (backslash variant)', () => { + expect(isAllowedScheme('/\\evil.example.com')).toBe(false); +}); + +test('isAllowedScheme blocks \\/evil.com (backslash variant)', () => { + expect(isAllowedScheme('\\/evil.example.com')).toBe(false); +}); + +test('isAllowedScheme blocks \\\\evil.com (backslash variant)', () => { + expect(isAllowedScheme('\\\\evil.example.com')).toBe(false); +}); + +test('isAllowedScheme blocks /\\evil.com BEFORE the new URL attempt (not via the catch)', () => { + // Stub `URL` to throw — the result must still be `false`, proving the + // backslash rejection happens up-front and not via the catch's + // "relative URLs — allow" branch. (If the rejection were inside the + // try-block, a URL-constructor throw would route through `catch { return + // true }` and we'd see `true` here.) + const originalURL = global.URL; + global.URL = class { + constructor() { + throw new Error('stubbed URL constructor'); + } + } as unknown as typeof URL; + try { + expect(isAllowedScheme('/\\evil.example.com')).toBe(false); + } finally { + global.URL = originalURL; + } +}); + +test('isAllowedScheme still blocks the catch-branch protocol-relative case after backslash rejection', () => { + // Regression: hardening must not accidentally drop the existing `//host` + // protection. + expect(isAllowedScheme('//evil.example.com')).toBe(false); +}); + test('getTargetUrl reads the url query parameter', () => { const locationSpy = jest.spyOn(window, 'location', 'get').mockReturnValue({ search: '?url=https%3A%2F%2Fexample.com%2Fpage', diff --git a/superset-frontend/src/pages/RedirectWarning/utils.ts b/superset-frontend/src/pages/RedirectWarning/utils.ts index 43aa834789a..e9cd66299c1 100644 --- a/superset-frontend/src/pages/RedirectWarning/utils.ts +++ b/superset-frontend/src/pages/RedirectWarning/utils.ts @@ -36,8 +36,23 @@ function normalizeUrl(url: string): string { /** * Return true if the URL scheme is safe for navigation. * Blocks javascript:, data:, vbscript:, file:, etc. + * + * Protocol-relative URLs (`//host/...`) are rejected because they perform a + * cross-origin navigation despite parsing as "relative" — the standalone + * `new URL(...)` call throws on them, so without an explicit guard the catch + * branch would let them through. + * + * Backslash variants (`/\host`, `\/host`, `\\host`, and any URL containing a + * backslash) are rejected up-front, BEFORE the `new URL` attempt. Browsers + * normalise `/\` → `//` in special-scheme authorities, so a backslash + * anywhere in the input lets an attacker craft a cross-origin target that + * presents as router-relative to the eye. Without the explicit rejection, + * `new URL('/\\evil.com')` would throw and the catch branch would return + * `true`, allowing the interstitial UI to display the URL as if it were a + * safe relative path. */ export function isAllowedScheme(url: string): boolean { + if (/^[/\\][/\\]/.test(url) || url.includes('\\')) return false; try { const parsed = new URL(url); return ALLOWED_SCHEMES.includes(parsed.protocol); diff --git a/superset-frontend/src/pages/Register/index.tsx b/superset-frontend/src/pages/Register/index.tsx index cb962687de1..9c89ef41b6e 100644 --- a/superset-frontend/src/pages/Register/index.tsx +++ b/superset-frontend/src/pages/Register/index.tsx @@ -32,6 +32,7 @@ import { useState } from 'react'; import getBootstrapData from 'src/utils/getBootstrapData'; import ReactCAPTCHA from 'react-google-recaptcha'; import { useParams } from 'react-router-dom'; +import { ensureAppRoot } from 'src/utils/navigationUtils'; interface RegisterForm { username: string; @@ -91,7 +92,11 @@ export default function Login() { 'Your account is activated. You can log in with your credentials.', )} extra={[ - , ]} diff --git a/superset-frontend/src/pages/SavedQueryList/SavedQueryList.test.tsx b/superset-frontend/src/pages/SavedQueryList/SavedQueryList.test.tsx index 9fcb399e7e2..192062f2998 100644 --- a/superset-frontend/src/pages/SavedQueryList/SavedQueryList.test.tsx +++ b/superset-frontend/src/pages/SavedQueryList/SavedQueryList.test.tsx @@ -270,9 +270,9 @@ describe('SavedQueryList', () => { await screen.findByTestId('saved_query-list-view'); - const queryButton = await screen.findByRole('button', { - name: /query/i, - }); + // Scope to the create button's stable data-test: /query/i also matches the + // per-row "Query preview" and "Copy query URL" action buttons. + const queryButton = await screen.findByTestId('add-saved-query-button'); fireEvent.click(queryButton); await waitFor(() => { diff --git a/superset-frontend/src/pages/SavedQueryList/index.tsx b/superset-frontend/src/pages/SavedQueryList/index.tsx index dd72c2de4c1..8fad1202eb8 100644 --- a/superset-frontend/src/pages/SavedQueryList/index.tsx +++ b/superset-frontend/src/pages/SavedQueryList/index.tsx @@ -66,7 +66,7 @@ import type Owner from 'src/types/Owner'; import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; import SavedQueryPreviewModal from 'src/features/queries/SavedQueryPreviewModal'; import { findPermission } from 'src/utils/findPermission'; -import { makeUrl } from 'src/utils/pathUtils'; +import { getShareableUrl, openInNewTab } from 'src/utils/navigationUtils'; const PAGE_SIZE = 25; const PASSWORDS_NEEDED_MESSAGE = t( @@ -232,6 +232,7 @@ function SavedQueryList({ icon: , name: t('Query'), buttonStyle: 'primary', + 'data-test': 'add-saved-query-button', onClick: () => { // React Router's basename already includes the application root; passing // a relative path ensures correct navigation under subdirectory deployments. @@ -243,11 +244,8 @@ function SavedQueryList({ // Action methods const openInSqlLab = (id: number, openInNewWindow: boolean) => { - copyTextToClipboard(() => - Promise.resolve( - `${window.location.origin}${makeUrl(`/sqllab?savedQueryId=${id}`)}`, - ), - ) + const path = `/sqllab?savedQueryId=${id}`; + copyTextToClipboard(() => Promise.resolve(getShareableUrl(path))) .then(() => { addSuccessToast(t('Link Copied!')); }) @@ -255,11 +253,11 @@ function SavedQueryList({ addDangerToast(t('Sorry, your browser does not support copying.')); }); if (openInNewWindow) { - window.open(makeUrl(`/sqllab?savedQueryId=${id}`)); + openInNewTab(path); } else { // React Router's basename already includes the application root; passing // a relative path ensures correct navigation under subdirectory deployments. - history.push(`/sqllab?savedQueryId=${id}`); + history.push(path); } }; @@ -543,8 +541,9 @@ function SavedQueryList({ key: 'search', input: 'search', operator: FilterOperator.AllText, - toolTipDescription: + toolTipDescription: t( 'Searches all text fields: Name, Description, Database & Schema', + ), }, { Header: t('Database'), diff --git a/superset-frontend/src/pages/Tags/index.tsx b/superset-frontend/src/pages/Tags/index.tsx index f18cf0d4f29..fd791c5d3ca 100644 --- a/superset-frontend/src/pages/Tags/index.tsx +++ b/superset-frontend/src/pages/Tags/index.tsx @@ -133,8 +133,9 @@ function TagList(props: TagListProps) { const emptyState = { title: t('No Tags created'), image: 'dashboard.svg', - description: + description: t( 'Create a new tag and assign it to existing entities like charts or dashboards', + ), buttonAction: () => setShowTagModal(true), buttonIcon: , buttonText: t('Create a new Tag'), @@ -168,7 +169,7 @@ function TagList(props: TagListProps) { }, }: any) => ( - {tagName} + {tagName} ), Header: t('Name'), diff --git a/superset-frontend/src/preamble.ts b/superset-frontend/src/preamble.ts index cc304da84d3..c9f64c4160a 100644 --- a/superset-frontend/src/preamble.ts +++ b/superset-frontend/src/preamble.ts @@ -26,7 +26,7 @@ import setupFormatters from './setup/setupFormatters'; import setupDashboardComponents from './setup/setupDashboardComponents'; import { User } from './types/bootstrapTypes'; import getBootstrapData, { applicationRoot } from './utils/getBootstrapData'; -import { makeUrl } from './utils/pathUtils'; +import { makeUrl } from './utils/navigationUtils'; import './hooks/useLocale'; // Import dayjs plugin types for global TypeScript support @@ -73,7 +73,7 @@ export default function initPreamble(): Promise { }, LANGUAGE_PACK_REQUEST_TIMEOUT_MS); try { - const languagePackUrl = makeUrl(`/superset/language_pack/${lang}/`); + const languagePackUrl = makeUrl(`/language_pack/${lang}/`); const resp = await fetch(languagePackUrl, { signal: abortController.signal, }); diff --git a/superset-frontend/src/pwa-manifest.json b/superset-frontend/src/pwa-manifest.json deleted file mode 100644 index fcd8f2213eb..00000000000 --- a/superset-frontend/src/pwa-manifest.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "name": "Apache Superset", - "short_name": "Superset", - "description": "Modern data exploration and visualization platform", - "start_url": "/superset/welcome/", - "scope": "/", - "display": "standalone", - "background_color": "#ffffff", - "theme_color": "#20a7c9", - "icons": [ - { - "src": "/static/assets/images/pwa/icon-192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "any" - }, - { - "src": "/static/assets/images/pwa/icon-512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "any" - }, - { - "src": "/static/assets/images/pwa/icon-192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/static/assets/images/pwa/icon-512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ], - "screenshots": [ - { - "src": "/static/assets/images/pwa/screenshot-wide.png", - "sizes": "1280x720", - "type": "image/png", - "form_factor": "wide", - "label": "Apache Superset Dashboard" - }, - { - "src": "/static/assets/images/pwa/screenshot-narrow.png", - "sizes": "540x720", - "type": "image/png", - "form_factor": "narrow", - "label": "Apache Superset Mobile View" - } - ], - "file_handlers": [ - { - "action": "/superset/file-handler", - "accept": { - "text/csv": [".csv"], - "application/vnd.ms-excel": [".xls"], - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [ - ".xlsx" - ], - "application/vnd.apache.parquet": [".parquet"] - } - } - ] -} diff --git a/superset-frontend/src/setup/setupFormatters.test.ts b/superset-frontend/src/setup/setupFormatters.test.ts new file mode 100644 index 00000000000..5d20f8466fd --- /dev/null +++ b/superset-frontend/src/setup/setupFormatters.test.ts @@ -0,0 +1,71 @@ +/** + * 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 mockSetCurrencyLocale = jest.fn(); + +// Stub the formatter registries so the test focuses on the currency-locale +// wiring without exercising real d3/Intl registry setup. +const chainableRegistry = () => { + const registry: Record = {}; + ['setD3Format', 'registerValue', 'setDefaultKey'].forEach(method => { + registry[method] = jest.fn(() => registry); + }); + registry.d3Format = jest.fn() as unknown as jest.Mock; + return registry; +}; + +jest.mock('@superset-ui/core', () => ({ + setCurrencyLocale: mockSetCurrencyLocale, + getNumberFormatterRegistry: jest.fn(() => chainableRegistry()), + getTimeFormatterRegistry: jest.fn(() => chainableRegistry()), + getNumberFormatter: jest.fn(() => jest.fn()), + createDurationFormatter: jest.fn(() => jest.fn()), + createMemoryFormatter: jest.fn(() => jest.fn()), + createSmartDateFormatter: jest.fn(() => jest.fn()), + createSmartDateVerboseFormatter: jest.fn(() => jest.fn()), + createSmartDateDetailedFormatter: jest.fn(() => jest.fn()), + NumberFormats: { INTEGER: ',d', INTEGER_SIGNED: '+,d' }, + SMART_DATE_ID: 'smart_date', + SMART_DATE_DETAILED_ID: 'smart_date_detailed', + SMART_DATE_VERBOSE_ID: 'smart_date_verbose', +})); + +async function runSetupFormatters(locale: string) { + // Imported lazily so the jest.mock factory above is initialized first. + const { default: setupFormatters } = await import('./setupFormatters'); + setupFormatters({}, {}, locale); +} + +beforeEach(() => { + jest.clearAllMocks(); +}); + +test('setupFormatters wires the deployment locale into the currency locale', async () => { + await runSetupFormatters('fr-FR'); + + expect(mockSetCurrencyLocale).toHaveBeenCalledWith('fr-FR'); +}); + +test('setupFormatters forwards an underscore-formatted locale to setCurrencyLocale', async () => { + // currencyLocale.setCurrencyLocale is responsible for canonicalizing the tag; + // setupFormatters must forward it untouched. + await runSetupFormatters('pt_BR'); + + expect(mockSetCurrencyLocale).toHaveBeenCalledWith('pt_BR'); +}); diff --git a/superset-frontend/src/setup/setupFormatters.ts b/superset-frontend/src/setup/setupFormatters.ts index c8d5dc0ef11..4a68790b265 100644 --- a/superset-frontend/src/setup/setupFormatters.ts +++ b/superset-frontend/src/setup/setupFormatters.ts @@ -29,6 +29,7 @@ import { createSmartDateVerboseFormatter, createSmartDateDetailedFormatter, createMemoryFormatter, + setCurrencyLocale, } from '@superset-ui/core'; import { FormatLocaleDefinition } from 'd3-format'; import { TimeLocaleDefinition } from 'd3-time-format'; @@ -38,6 +39,10 @@ export default function setupFormatters( d3TimeFormat: Partial, locale: string, ) { + // Resolve the default currency symbol position (prefix/suffix) according to + // the deployment locale's conventions when a chart leaves it unset. + setCurrencyLocale(locale); + getNumberFormatterRegistry() .setD3Format(d3NumberFormat) // Add shims for format strings that are deprecated or common typos. diff --git a/superset-frontend/src/theme/utils/antdTokenNames.test.ts b/superset-frontend/src/theme/utils/antdTokenNames.test.ts index bff105ee88d..cb56d7606ea 100644 --- a/superset-frontend/src/theme/utils/antdTokenNames.test.ts +++ b/superset-frontend/src/theme/utils/antdTokenNames.test.ts @@ -107,6 +107,40 @@ test('getAllValidTokenNames includes known Ant Design tokens', () => { expect(result.antdTokens).toContain('padding'); }); +test('component flexibility tokens are recognized as valid Superset custom tokens', () => { + const flexTokens = [ + 'selectOptionActiveOutline', + 'labelBorderRadius', + 'buttonControlHeight', + 'buttonControlHeightSM', + 'buttonControlHeightXS', + 'buttonPaddingInline', + 'buttonPaddingInlineSM', + 'buttonFontSize', + 'buttonBorderRadius', + 'buttonStyleMap', + ]; + + flexTokens.forEach(token => { + expect(isValidTokenName(token)).toBe(true); + expect(isSupersetCustomToken(token)).toBe(true); + }); +}); + +test('dashboard tile tokens are recognized as valid Superset custom tokens', () => { + const tileTokens = [ + 'dashboardTileBg', + 'dashboardTileBorder', + 'dashboardTileBorderRadius', + 'dashboardTileBoxShadow', + ]; + + tileTokens.forEach(token => { + expect(isValidTokenName(token)).toBe(true); + expect(isSupersetCustomToken(token)).toBe(true); + }); +}); + test('label variant tokens are recognized as valid Superset custom tokens', () => { const labelTokens = [ // Published/Draft diff --git a/superset-frontend/src/theme/utils/antdTokenNames.ts b/superset-frontend/src/theme/utils/antdTokenNames.ts index 0349d682c4e..a3b1b9e01c9 100644 --- a/superset-frontend/src/theme/utils/antdTokenNames.ts +++ b/superset-frontend/src/theme/utils/antdTokenNames.ts @@ -31,6 +31,7 @@ const SUPERSET_CUSTOM_TOKENS: Set = new Set([ // Brand tokens 'brandIconMaxWidth', + 'brandAppName', 'brandLogoAlt', 'brandLogoUrl', 'brandLogoMargin', @@ -81,6 +82,24 @@ const SUPERSET_CUSTOM_TOKENS: Set = new Set([ 'buttonSecondaryActiveColor', 'buttonSecondaryActiveBg', 'buttonSecondaryActiveBorderColor', + + // Component flexibility tokens + 'selectOptionActiveOutline', + 'labelBorderRadius', + 'buttonControlHeight', + 'buttonControlHeightSM', + 'buttonControlHeightXS', + 'buttonPaddingInline', + 'buttonPaddingInlineSM', + 'buttonFontSize', + 'buttonBorderRadius', + 'buttonStyleMap', + + // Dashboard tile tokens + 'dashboardTileBg', + 'dashboardTileBorder', + 'dashboardTileBorderRadius', + 'dashboardTileBoxShadow', ]); /** diff --git a/superset-frontend/src/types/bootstrapTypes.ts b/superset-frontend/src/types/bootstrapTypes.ts index 9ae4c42d502..c8d7281af0c 100644 --- a/superset-frontend/src/types/bootstrapTypes.ts +++ b/superset-frontend/src/types/bootstrapTypes.ts @@ -99,6 +99,7 @@ export interface BrandProps { alt: string; tooltip: string; text: string; + hide_logo?: boolean; } export interface NavBarProps { diff --git a/superset-frontend/src/utils/assetUrl.test.ts b/superset-frontend/src/utils/assetUrl.test.ts index f0de28b2c77..3d8307f6b3b 100644 --- a/superset-frontend/src/utils/assetUrl.test.ts +++ b/superset-frontend/src/utils/assetUrl.test.ts @@ -46,3 +46,42 @@ describe('assetUrl should ignore static asset prefix for absolute URLs', () => { expect(ensureStaticPrefix(absoluteResourcePath)).toBe(absoluteResourcePath); }); }); + +describe('ensureStaticPrefix should be idempotent', () => { + test('does not double-prefix a path that already starts with the static-assets prefix', () => { + staticAssetsPrefixMock.mockReturnValue('/superset'); + const alreadyRooted = + '/superset/static/assets/images/superset-logo-horiz.png'; + expect(ensureStaticPrefix(alreadyRooted)).toBe(alreadyRooted); + }); + + test('still prefixes a relative path that does not yet carry the prefix', () => { + staticAssetsPrefixMock.mockReturnValue('/superset'); + expect(ensureStaticPrefix('/static/assets/x.png')).toBe( + '/superset/static/assets/x.png', + ); + }); + + test('treats segment boundaries — `/supersetfoo` is not the same as `/superset`', () => { + staticAssetsPrefixMock.mockReturnValue('/superset'); + expect(ensureStaticPrefix('/supersetfoo/img.png')).toBe( + '/superset/supersetfoo/img.png', + ); + }); + + test('handles nested application roots', () => { + staticAssetsPrefixMock.mockReturnValue('/preset/superset'); + const alreadyRooted = '/preset/superset/static/x.png'; + expect(ensureStaticPrefix(alreadyRooted)).toBe(alreadyRooted); + }); + + test('returns the prefix itself unchanged when passed in bare', () => { + staticAssetsPrefixMock.mockReturnValue('/superset'); + expect(ensureStaticPrefix('/superset')).toBe('/superset'); + }); + + test('is a no-op when the static-assets prefix is empty (root deployment)', () => { + staticAssetsPrefixMock.mockReturnValue(''); + expect(ensureStaticPrefix('/static/x.png')).toBe('/static/x.png'); + }); +}); diff --git a/superset-frontend/src/utils/assetUrl.ts b/superset-frontend/src/utils/assetUrl.ts index a7fc2553b50..72a07eff5bd 100644 --- a/superset-frontend/src/utils/assetUrl.ts +++ b/superset-frontend/src/utils/assetUrl.ts @@ -30,10 +30,21 @@ export function assetUrl(path: string): string { /** * Returns the path prepended with the staticAssetsPrefix if the string is a relative path else it returns * the string as is. + * + * Idempotent — a path that already begins with the static-assets prefix at a + * segment boundary is returned unchanged, mirroring the dedupe pattern used by + * `ensureAppRoot` in pathUtils.ts and `SupersetClient.getUrl`. + * * @param url_or_path A url or relative path to a resource */ export function ensureStaticPrefix(url_or_path: string): string { - if (url_or_path.startsWith('/')) return assetUrl(url_or_path); - - return url_or_path; + if (!url_or_path.startsWith('/')) return url_or_path; + const prefix = staticAssetsPrefix(); + if ( + prefix && + (url_or_path === prefix || url_or_path.startsWith(`${prefix}/`)) + ) { + return url_or_path; + } + return assetUrl(url_or_path); } diff --git a/superset-frontend/src/utils/getBootstrapData.test.ts b/superset-frontend/src/utils/getBootstrapData.test.ts index a8076bd2f4e..db2e30d2766 100644 --- a/superset-frontend/src/utils/getBootstrapData.test.ts +++ b/superset-frontend/src/utils/getBootstrapData.test.ts @@ -111,33 +111,84 @@ describe('getBootstrapData and helpers', () => { }); test.each([ - ['//evil.example.com', 'protocol-relative URL'], + ['markup payload', '">/'], + ['protocol-relative URL', '//evil.example.com/app/'], + ['absolute URL', 'https://evil.example.com/app/'], // eslint-disable-next-line no-script-url -- intentional unsafe value under test - ['javascript:alert(1)', 'javascript scheme'], - ['https://evil.example.com', 'absolute URL'], - ['/foo">', 'path with HTML meta-characters'], + ['javascript scheme', 'javascript:alert(1)'], + ['embedded quote', "/my'app/"], + ['path traversal', '/app/..'], + ['double-dot segment', '/legit/../etc'], + ['backslash separator', '/app\\admin'], + ['leading double slash', '//app'], + ['double trailing slash', '/app//'], + ['unicode segment', '/café'], + ['tab control character', '/app\tadmin'], + ['newline control character', '/app\nadmin'], + ['null byte', '/app\x00admin'], ])( - 'should fall back to the default root when application_root is %s (%s)', - async unsafeRoot => { + 'should degrade a non-path application_root to root deployment (%s)', + async (_label, applicationRootValue) => { const customData = { common: { - application_root: unsafeRoot, + application_root: applicationRootValue, static_assets_prefix: '/custom-static/', }, }; - document.body.innerHTML = `
`; + document.body.innerHTML = `
`; + document + .getElementById('app') + ?.setAttribute('data-bootstrap', JSON.stringify(customData)); jest.resetModules(); const { default: getBootstrapData, applicationRoot } = await import('./getBootstrapData'); getBootstrapData(); - const expectedAppRoot = - DEFAULT_BOOTSTRAP_DATA.common.application_root.replace(/\/$/, ''); - expect(applicationRoot()).toEqual(expectedAppRoot); + expect(applicationRoot()).toEqual(''); }, ); + // Percent-encoded traversal sequences are NOT decoded or rejected here. + // application_root is operator-controlled server-rendered configuration + // (see SECURITY.md trust-boundary 2); the sanitizer only enforces the + // documented path shape on the literal value. Encoded forms like "%2e%2e" + // are valid path-segment characters and pass through unchanged — this is + // intentional and out of scope, documented here so the behavior is pinned. + test('should preserve a percent-encoded application_root (operator-controlled, out of scope)', async () => { + const customData = { + common: { + application_root: '/app/%2e%2e', + static_assets_prefix: '/custom-static/', + }, + }; + document.body.innerHTML = `
`; + + jest.resetModules(); + const { default: getBootstrapData, applicationRoot } = + await import('./getBootstrapData'); + getBootstrapData(); + + expect(applicationRoot()).toEqual('/app/%2e%2e'); + }); + + test('should preserve a multi-segment application_root', async () => { + const customData = { + common: { + application_root: '/team-a/superset/', + static_assets_prefix: '/custom-static/', + }, + }; + document.body.innerHTML = `
`; + + jest.resetModules(); + const { default: getBootstrapData, applicationRoot } = + await import('./getBootstrapData'); + getBootstrapData(); + + expect(applicationRoot()).toEqual('/team-a/superset'); + }); + test('should defaults without trailing slashes when #app element does not include application_root or static_assets_prefix', async () => { // Set up the fake #app element const customData = { diff --git a/superset-frontend/src/utils/getBootstrapData.ts b/superset-frontend/src/utils/getBootstrapData.ts index 8413bf860d2..5cf66a944f1 100644 --- a/superset-frontend/src/utils/getBootstrapData.ts +++ b/superset-frontend/src/utils/getBootstrapData.ts @@ -38,36 +38,36 @@ const normalizePathWithFallback = ( fallback: string, ): string => (path ?? fallback).replace(/\/$/, ''); -/** - * Matches a plain absolute path prefix (e.g. "" for root deployments or - * "/analytics" for a subdirectory). The character after the leading slash must - * not be another slash, so protocol-relative URLs ("//host") and scheme-bearing - * values ("javascript:...") do not qualify. - */ -const SAFE_APPLICATION_ROOT_RE = /^(\/[\w\-.][\w\-./]*)?$/; +// The application root is server-rendered operator configuration, but it +// flows into URL sinks across the app (script.src in ExtensionsLoader, +// history.pushState in navigationUtils), so enforce the documented +// "/segment(/segment)*" shape at the source: a value that doesn't conform +// degrades to a root deployment rather than reaching those sinks. +// Each segment must begin with a non-dot character so a literal "." or ".." +// segment (e.g. "/app/..") is rejected — otherwise browser path normalization +// would collapse `ensureAppRoot('/foo')` → `/app/../foo` → `/foo`, silently +// defeating subdirectory containment. Dots are still allowed inside a +// segment (e.g. "/foo.bar"). +// +// This guards the *literal* value only. Percent-encoded dot segments +// ("/app/%2e%2e") pass through unchanged: `%` is a permitted path-segment +// character and the regex does not decode. A browser would later normalize +// "/app/%2e%2e/foo" → "/foo", so such a value silently defeats subdirectory +// prefixing — but application_root is operator-controlled, server-rendered +// configuration (SECURITY.md trust-boundary 2), so an encoded-traversal value +// is an unsupported misconfiguration, not an attacker-reachable input. It is +// intentionally neither decoded nor rejected here; the behavior is pinned by a +// test in getBootstrapData.test.ts. +const APP_ROOT_PATH_RE = /^(?:\/[\w~%-]+(?:\.[\w~%-]+)*)*$/; -/** - * The application root (SUPERSET_APP_ROOT) is reflected into links and - * navigation, so constrain it to a plain absolute path before use. Anything - * that isn't a simple "/path" prefix falls back to the default root so a - * malformed value can't be reinterpreted as HTML or redirect off-origin. This - * also keeps the bootstrap-derived value from being treated as a tainted href - * source by static analysis. - */ -const sanitizeApplicationRoot = ( - path: string | undefined, - fallback: string, -): string => { - const normalizedFallback = normalizePathWithFallback(fallback, fallback); - const normalized = normalizePathWithFallback(path, fallback); - return SAFE_APPLICATION_ROOT_RE.test(normalized) - ? normalized - : normalizedFallback; -}; +const sanitizeAppRoot = (root: string): string => + APP_ROOT_PATH_RE.test(root) ? root : ''; -const APPLICATION_ROOT_NO_TRAILING_SLASH = sanitizeApplicationRoot( - getBootstrapData().common.application_root, - DEFAULT_BOOTSTRAP_DATA.common.application_root, +const APPLICATION_ROOT_NO_TRAILING_SLASH = sanitizeAppRoot( + normalizePathWithFallback( + getBootstrapData().common.application_root, + DEFAULT_BOOTSTRAP_DATA.common.application_root, + ), ); const STATIC_ASSETS_PREFIX_NO_TRAILING_SLASH = normalizePathWithFallback( diff --git a/superset-frontend/src/utils/navigationUtils.AppLink.test.tsx b/superset-frontend/src/utils/navigationUtils.AppLink.test.tsx new file mode 100644 index 00000000000..78709b75185 --- /dev/null +++ b/superset-frontend/src/utils/navigationUtils.AppLink.test.tsx @@ -0,0 +1,95 @@ +/** + * 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 'spec/helpers/testing-library'; +import { AppLink } from 'src/utils/navigationUtils'; + +// AppLink renders a real React element via React Testing Library, which is +// incompatible with the withApplicationRoot fixture's `jest.resetModules()` +// (it corrupts the testing-library module graph). Mock applicationRoot at +// file scope and vary per test instead. Variable must start with `mock` to +// satisfy Jest's hoisted-factory out-of-scope check. +const mockApplicationRoot = jest.fn(() => ''); + +jest.mock('src/utils/getBootstrapData', () => ({ + __esModule: true, + default: () => ({ + common: { application_root: '', static_assets_prefix: '' }, + }), + applicationRoot: () => mockApplicationRoot(), + staticAssetsPrefix: () => '', +})); + +beforeEach(() => { + mockApplicationRoot.mockReturnValue(''); +}); + +test('renders an anchor with prefixed href under subdirectory deployment', () => { + mockApplicationRoot.mockReturnValue('/superset'); + const { container } = render(go); + const anchor = container.querySelector('a'); + expect(anchor).not.toBeNull(); + expect(anchor?.getAttribute('href')).toBe('/superset/foo'); +}); + +test('passes through other anchor props', () => { + const { container } = render( + + go + , + ); + const anchor = container.querySelector('a'); + expect(anchor?.getAttribute('target')).toBe('_blank'); + expect(anchor?.getAttribute('rel')).toBe('noreferrer'); +}); + +test('passes absolute URLs through without prefixing', () => { + mockApplicationRoot.mockReturnValue('/superset'); + const { container } = render( + x, + ); + expect(container.querySelector('a')?.getAttribute('href')).toBe( + 'https://external.example.com', + ); +}); + +// AppLink runs `assertSafeNavigationUrl(ensureAppRoot(href))` during render, so +// an unsafe href throws rather than emitting an anchor that points at the +// attacker-controlled target. Mirror the openInNewTab / getShareableUrl pins. +test('throws on a backslash-laden authority-spoof href', () => { + mockApplicationRoot.mockReturnValue('/superset'); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + try { + expect(() => render(x)).toThrow( + /refused unsafe URL/, + ); + } finally { + errorSpy.mockRestore(); + } +}); + +test('throws on a protocol-relative href', () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + try { + expect(() => + render(x), + ).toThrow(/refused unsafe URL/); + } finally { + errorSpy.mockRestore(); + } +}); diff --git a/superset-frontend/src/utils/navigationUtils.appRoot.test.tsx b/superset-frontend/src/utils/navigationUtils.appRoot.test.tsx new file mode 100644 index 00000000000..e623f00b1d3 --- /dev/null +++ b/superset-frontend/src/utils/navigationUtils.appRoot.test.tsx @@ -0,0 +1,136 @@ +/** + * 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 { applicationRootScenarios } from 'spec/helpers/withApplicationRoot'; + +// Subdirectory regression for the nine `ensureAppRoot(...)` value-wrap +// callsites. Each row pins one callsite's input value +// (what the migrated component now passes to `ensureAppRoot`) and asserts the +// resulting URL under both the root deployment (`''`) and the `/superset` +// subdirectory deployment. +// +// Why this shape rather than per-callsite rendered-component tests: every +// migrated callsite is a value-only wrap (plain `
`, +// antd `