diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 9fa4a628faf0..d543833b9df4 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -67,7 +67,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 + uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. diff --git a/UPDATING.md b/UPDATING.md index 98e193512553..1c582a081b4d 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -27,6 +27,10 @@ assists people when migrating to a new version. - `SAMPLES_ROW_LIMIT` is now the default for `/datasource/samples` requests without a valid explicit `per_page`, rather than a hard per-request ceiling; explicit limits are honored up to the existing global row-limit ceiling, matching `/chart/data` SAMPLES requests. - The `cockroachdb` extra (`pip install apache-superset[cockroachdb]`) now installs `sqlalchemy-cockroachdb` instead of the abandoned `cockroachdb` package, whose SQLAlchemy dialect could not be imported under SQLAlchemy 2.0. Existing environments with the old package installed should `pip uninstall cockroachdb && pip install sqlalchemy-cockroachdb` (or simply reinstall the extra) to restore CockroachDB connectivity. +### Native Value filter "Select all" always targets the whole column + +The native "Value" filter's bulk "Select all" / "Clear" controls now operate on the entire loaded set of column values regardless of any text typed into the filter's search box. Previously the "Select all (N)" count briefly flickered to the search-scoped count before settling on the full-column count, and clicking "Select all" while searching could select only the currently matching subset. Search-scoped bulk selection was never a supported feature; the count is now stable and always matches what "Select all" selects (the full column). No configuration change is required. + ### MCP tool results preserve stored string values Structured MCP tool results no longer add `` wrappers or diff --git a/docs/admin_docs/configuration/configuring-superset.mdx b/docs/admin_docs/configuration/configuring-superset.mdx index f315715b21d3..f30cdaedc7cb 100644 --- a/docs/admin_docs/configuration/configuring-superset.mdx +++ b/docs/admin_docs/configuration/configuring-superset.mdx @@ -566,6 +566,55 @@ def FLASK_APP_MUTATOR(app: Flask) -> None: app.before_request_funcs.setdefault(None, []).append(make_session_permanent) ``` +## Carrying extra data through chart and dashboard exports + +Deployments often attach their own metadata to charts and dashboards — an owning +team, a catalogue entry, a cost centre — and need it to survive an export/import +round trip between environments. `EXTRA_ASSET_EXPORT_FIELDS` and +`EXTRA_ASSET_IMPORT_HANDLER` let you do that without forking the export commands. + +The export hook receives the model and the asset type (`"chart"` or `"dashboard"`) +and returns a mapping, which is serialised under the `extra` key of the asset's +YAML. The import hook receives the model, the asset type and that same mapping, +once the asset exists and has an id: + +```python +# superset_config.py +def _export_fields(model, asset_type): + return {"owning_team": lookup_team(model)} + + +def _import_handler(model, asset_type, extra): + if team := extra.get("owning_team"): + assign_team(model, team) + + +EXTRA_ASSET_EXPORT_FIELDS = _export_fields +EXTRA_ASSET_IMPORT_HANDLER = _import_handler +``` + +The exported YAML then carries: + +```yaml +slice_name: Revenue by region +... +extra: + owning_team: analytics-platform +``` + +A few things worth knowing: + +- **Both hooks are optional and default to `None`.** With neither configured, +exported files are byte-for-byte what they were before, and imports behave +identically. +- **Everything lives under the single `extra` key.** The import schemas reject +unknown top-level fields, so namespacing under `extra` keeps that strictness +while leaving you free to change the shape of your own payload later. +- **An export hook returning `None` or an empty mapping writes nothing**, so +assets without your metadata do not gain an empty `extra` block. +- **The import handler runs after the asset is created or updated**, which means +you can rely on `model.id`. Raising from it will fail the import. + ## Customizing the landing page (index view) The page served at `/` is rendered by an index view. By default Superset registers diff --git a/docs/admin_docs/configuration/mcp-server.mdx b/docs/admin_docs/configuration/mcp-server.mdx index 73daecdc3379..1bb9c0d9685e 100644 --- a/docs/admin_docs/configuration/mcp-server.mdx +++ b/docs/admin_docs/configuration/mcp-server.mdx @@ -253,6 +253,58 @@ def my_custom_auth_factory(app): MCP_AUTH_FACTORY = my_custom_auth_factory ``` +### Embedded Guest Authentication + +Superset's [embedded dashboards](/user-docs/using-superset/embedding) feature mints short-lived **guest tokens** for anonymous/embedded viewers. The MCP server can accept these same guest tokens, so an embedded guest (e.g. an in-app chatbot next to an embedded dashboard) can call MCP tools scoped to the dashboards/resources named in its token. + +This is opt-in and reuses the existing core guest-token configuration -- there is no MCP-specific guest secret or audience. + +```python +# superset_config.py +FEATURE_FLAGS = {"EMBEDDED_SUPERSET": True} # required -- guest tokens only exist when this is on +MCP_EMBEDDED_GUEST_AUTH_ENABLED = True # opt-in for the MCP transport (default False) +``` + +Present the guest token the same way as any other bearer token: + +```bash +curl -X POST http://localhost:5008/mcp \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer YOUR_GUEST_TOKEN' \ + -d '{"jsonrpc": "2.0", "method": "tools/list", "id": 1}' +``` + +**How it works** + +- A dedicated guest-token verifier validates the token against the same `GUEST_TOKEN_JWT_SECRET` / `GUEST_TOKEN_JWT_ALGO` / `GUEST_TOKEN_JWT_AUDIENCE` config used by embedded dashboards, replays the embedded structural checks, and enforces revocation (global version bumps and per-dashboard `guest_token_revoked_before` cutoffs). It runs *before* the JWT verifier described above, since guest tokens are signed with a different key/algorithm and would otherwise be rejected at the transport. +- A verified guest resolves to a Superset guest user as the highest-priority identity, so it's never downgraded to API-key / `MCP_DEV_USERNAME` / dev-mode resolution. Data access is scoped by the same checks (dataset allowlist, dashboard access, row-level security) that apply to embedded dashboard views. +- Guests are restricted to a default-deny allow-list, `MCP_GUEST_ALLOWED_TOOLS`, regardless of `MCP_RBAC_ENABLED`. Sensitive enumeration tools like `find_users` and `get_instance_info` are denied simply by being absent from the default list. +- Setting `MCP_AUTH_FACTORY` bypasses this whole path: a configured factory is tried first, and the default factory that wires up the guest-token verifier is never reached. If you rely on a custom auth factory (e.g. your own OIDC provider) alongside guest auth, that factory must verify guest tokens itself -- otherwise they're rejected regardless of `MCP_EMBEDDED_GUEST_AUTH_ENABLED`. + +```python +# superset_config.py +MCP_GUEST_ALLOWED_TOOLS = { + "get_dashboard_info", + "get_dashboard_layout", + "list_dashboards", + "list_charts", + "get_chart_info", + "get_chart_data", + "get_chart_preview", +} # default +``` + +**Deployment requirements** + +- The MCP server and the service that mints guest tokens (the Superset web app) must share `GUEST_TOKEN_JWT_SECRET` and `GUEST_TOKEN_JWT_AUDIENCE`. Set `GUEST_TOKEN_JWT_AUDIENCE` explicitly -- if it's unset, audience validation falls back to the URL host, which can differ between the two services and cause every guest token to fail validation. +- The `GUEST_ROLE_NAME` role (default `Public`) must exist -- a guest token is rejected if it does not. +- Don't set `MCP_DEV_USERNAME` on a deployment that also serves embedded guests. +- Restart the MCP process after toggling `EMBEDDED_SUPERSET` or `MCP_EMBEDDED_GUEST_AUTH_ENABLED` -- guest auth is wired up once at startup. + +:::warning +`GUEST_TOKEN_JWT_SECRET` guards both the web embedding and MCP guest-auth surfaces. With `MCP_EMBEDDED_GUEST_AUTH_ENABLED` on, leaving it at its insecure default isn't just a forgery risk -- the MCP server refuses to start (`MCPAuthConfigError`) until you set a real secret shared with the guest-token minting service. +::: + --- ## Connecting AI Clients @@ -523,6 +575,8 @@ All MCP settings go in `superset_config.py`. Defaults are defined in `superset/m | `MCP_JWT_DEBUG_ERRORS` | `False` | Log detailed JWT errors server-side (never exposed in HTTP responses per RFC 6750) | | `MCP_AUTH_FACTORY` | `None` | Custom auth provider factory `(flask_app) -> auth_provider`. Takes precedence over built-in JWT | | `MCP_USER_RESOLVER` | `None` | Custom function `(app, access_token) -> username` to extract a Superset username from a validated JWT token. When `None`, the default resolver checks `preferred_username`, `username`, `email`, and `sub` claims in that order. | +| `MCP_EMBEDDED_GUEST_AUTH_ENABLED` | `False` | Accept embedded [guest tokens](#embedded-guest-authentication) as Bearer auth. Also requires the `EMBEDDED_SUPERSET` feature flag. | +| `MCP_GUEST_ALLOWED_TOOLS` | see [default list](#embedded-guest-authentication) | The only tool names callable by embedded guests (default-deny), regardless of `MCP_RBAC_ENABLED`. | ### Response Size Guard diff --git a/docs/admin_docs/configuration/theming.mdx b/docs/admin_docs/configuration/theming.mdx index faee522163ac..d57dc2546afb 100644 --- a/docs/admin_docs/configuration/theming.mdx +++ b/docs/admin_docs/configuration/theming.mdx @@ -240,6 +240,39 @@ Font URLs are validated against a configurable allowlist. By default, fonts from This feature works with the stock Docker image - no custom build required! +## Results Grid Configuration Overrides + +Superset exposes a handful of opt-in tokens that customize the appearance of +the results grid in SQL Lab. These tokens have no effect unless explicitly +set, since the results grid otherwise falls back to its built-in defaults. + +```python +THEME_DEFAULT = { + "token": { + "colorPrimary": "#2893B3", + # ... other Ant Design tokens + + # Results grid overrides + "resultsGridRowHeight": 32, + "resultsGridHeaderFontSize": 13, + "resultsGridHeaderFontWeight": 600, + "resultsGridBorderRadius": 4, + "resultsGridNoStriping": True, + } +} +``` + +| Token | Type | Description | +| --- | --- | --- | +| `resultsGridRowHeight` | `number` | Row and header height, in pixels. | +| `resultsGridHeaderFontSize` | `number` | Header cell font size, in pixels. | +| `resultsGridHeaderFontWeight` | `number` | Header cell font weight. | +| `resultsGridBorderRadius` | `number` | Border radius applied to the grid and its wrapper, in pixels. | +| `resultsGridNoStriping` | `boolean` | When `true`, disables alternating row background striping. | + +These tokens can also be set through the theme CRUD interface's JSON editor, +alongside any other Superset-specific tokens. + ## ECharts Configuration Overrides :::note diff --git a/scripts/check-type.js b/scripts/check-type.js index 609b0cb391d6..966e483078b9 100755 --- a/scripts/check-type.js +++ b/scripts/check-type.js @@ -289,11 +289,11 @@ function extractArgs(args, regexes) { * For example: `superset-frontend/foo/bar.ts` -> `foo/bar.ts` * * @param {string[]} args - * @param {string} package + * @param {string} packageName * @returns {string[]} */ -function removePackageSegment(args, package) { - const packageSegment = package.concat(sep); +function removePackageSegment(args, packageName) { + const packageSegment = packageName.concat(sep); return args.map((arg) => { const normalizedPath = normalize(arg); diff --git a/superset-core/src/superset_core/semantic_layers/types.py b/superset-core/src/superset_core/semantic_layers/types.py index 4fbdadbbfacc..2c6e5da70f12 100644 --- a/superset-core/src/superset_core/semantic_layers/types.py +++ b/superset-core/src/superset_core/semantic_layers/types.py @@ -146,6 +146,8 @@ class Operator(str, enum.Enum): NOT_IN = "NOT IN" LIKE = "LIKE" NOT_LIKE = "NOT LIKE" + ILIKE = "ILIKE" + NOT_ILIKE = "NOT ILIKE" IS_NULL = "IS NULL" IS_NOT_NULL = "IS NOT NULL" ADHOC = "ADHOC" diff --git a/superset-frontend/eslint-rules/eslint-plugin-i18n-strings/package.json b/superset-frontend/eslint-rules/eslint-plugin-i18n-strings/package.json index 3f390c0c858f..4c532c271750 100644 --- a/superset-frontend/eslint-rules/eslint-plugin-i18n-strings/package.json +++ b/superset-frontend/eslint-rules/eslint-plugin-i18n-strings/package.json @@ -1,5 +1,5 @@ { - "name": "eslint-plugin-i18n-strings", + "name": "@superset-ui/eslint-plugin-i18n-strings", "version": "1.0.0", "description": "Warns about translation variables", "keywords": [], diff --git a/superset-frontend/eslint-rules/eslint-plugin-icons/package.json b/superset-frontend/eslint-rules/eslint-plugin-icons/package.json index a4d3a8180246..fced5fa7e654 100644 --- a/superset-frontend/eslint-rules/eslint-plugin-icons/package.json +++ b/superset-frontend/eslint-rules/eslint-plugin-icons/package.json @@ -1,5 +1,5 @@ { - "name": "eslint-plugin-icons", + "name": "@superset-ui/eslint-plugin-icons", "version": "1.0.0", "description": "Warns about direct usage of Ant Design icons", "keywords": [], diff --git a/superset-frontend/eslint-rules/eslint-plugin-theme-colors/package.json b/superset-frontend/eslint-rules/eslint-plugin-theme-colors/package.json index 650ddff1f2f2..f42959718b25 100644 --- a/superset-frontend/eslint-rules/eslint-plugin-theme-colors/package.json +++ b/superset-frontend/eslint-rules/eslint-plugin-theme-colors/package.json @@ -1,5 +1,5 @@ { - "name": "eslint-plugin-theme-colors", + "name": "@superset-ui/eslint-plugin-theme-colors", "version": "1.0.0", "description": "Warns about rgb(a)/hex/literal colors", "keywords": [], diff --git a/superset-frontend/eslint.config.minimal.js b/superset-frontend/eslint.config.minimal.js index 7eb2d3b7273f..f183d7dae702 100644 --- a/superset-frontend/eslint.config.minimal.js +++ b/superset-frontend/eslint.config.minimal.js @@ -37,9 +37,9 @@ require('tsx/cjs'); const tsParser = require('@typescript-eslint/parser'); -const themeColorsPlugin = require('eslint-plugin-theme-colors'); -const iconsPlugin = require('eslint-plugin-icons'); -const i18nStringsPlugin = require('eslint-plugin-i18n-strings'); +const themeColorsPlugin = require('@superset-ui/eslint-plugin-theme-colors'); +const iconsPlugin = require('@superset-ui/eslint-plugin-icons'); +const i18nStringsPlugin = require('@superset-ui/eslint-plugin-i18n-strings'); module.exports = [ // Files this config applies to. Flat config has no `--ext`; globs live here. diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 6e02bf39f7fd..2554ced01c9e 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -183,6 +183,9 @@ "@storybook/addon-links": "10.5.10", "@storybook/react-webpack5": "10.5.10", "@storybook/test-runner": "0.24.4", + "@superset-ui/eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings", + "@superset-ui/eslint-plugin-icons": "file:eslint-rules/eslint-plugin-icons", + "@superset-ui/eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors", "@svgr/webpack": "^8.1.0", "@swc/core": "^1.16.1", "@swc/plugin-emotion": "^15.0.0", @@ -226,8 +229,6 @@ "eslint": "^10.9.0", "eslint-import-resolver-alias": "^1.1.2", "eslint-import-resolver-typescript": "^4.4.5", - "eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings", - "eslint-plugin-icons": "file:eslint-rules/eslint-plugin-icons", "eslint-plugin-import": "^2.32.0", "eslint-plugin-jest-dom": "^5.10.1", "eslint-plugin-lodash": "^8.0.0", @@ -236,7 +237,6 @@ "eslint-plugin-react-you-might-not-need-an-effect": "^1.0.2", "eslint-plugin-storybook": "10.5.10", "eslint-plugin-testing-library": "^7.16.2", - "eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors", "fetch-mock": "^12.6.0", "fork-ts-checker-webpack-plugin": "^9.1.0", "history": "^5.3.0", @@ -296,6 +296,7 @@ } }, "eslint-rules/eslint-plugin-i18n-strings": { + "name": "@superset-ui/eslint-plugin-i18n-strings", "version": "1.0.0", "dev": true, "license": "Apache-2.0", @@ -304,6 +305,7 @@ } }, "eslint-rules/eslint-plugin-icons": { + "name": "@superset-ui/eslint-plugin-icons", "version": "1.0.0", "dev": true, "license": "Apache-2.0", @@ -312,6 +314,7 @@ } }, "eslint-rules/eslint-plugin-theme-colors": { + "name": "@superset-ui/eslint-plugin-theme-colors", "version": "1.0.0", "dev": true, "license": "Apache-2.0" @@ -11244,6 +11247,18 @@ "resolved": "packages/superset-ui-core", "link": true }, + "node_modules/@superset-ui/eslint-plugin-i18n-strings": { + "resolved": "eslint-rules/eslint-plugin-i18n-strings", + "link": true + }, + "node_modules/@superset-ui/eslint-plugin-icons": { + "resolved": "eslint-rules/eslint-plugin-icons", + "link": true + }, + "node_modules/@superset-ui/eslint-plugin-theme-colors": { + "resolved": "eslint-rules/eslint-plugin-theme-colors", + "link": true + }, "node_modules/@superset-ui/generator-superset": { "resolved": "packages/generator-superset", "link": true @@ -20010,14 +20025,6 @@ "ms": "^2.1.1" } }, - "node_modules/eslint-plugin-i18n-strings": { - "resolved": "eslint-rules/eslint-plugin-i18n-strings", - "link": true - }, - "node_modules/eslint-plugin-icons": { - "resolved": "eslint-rules/eslint-plugin-icons", - "link": true - }, "node_modules/eslint-plugin-import": { "version": "2.32.0", "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", @@ -20241,10 +20248,6 @@ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" } }, - "node_modules/eslint-plugin-theme-colors": { - "resolved": "eslint-rules/eslint-plugin-theme-colors", - "link": true - }, "node_modules/eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", diff --git a/superset-frontend/package.json b/superset-frontend/package.json index 986502daff7c..7fac1a874f2c 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -260,6 +260,9 @@ "@storybook/addon-links": "10.5.10", "@storybook/react-webpack5": "10.5.10", "@storybook/test-runner": "0.24.4", + "@superset-ui/eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings", + "@superset-ui/eslint-plugin-icons": "file:eslint-rules/eslint-plugin-icons", + "@superset-ui/eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors", "@svgr/webpack": "^8.1.0", "@swc/core": "^1.16.1", "@swc/plugin-emotion": "^15.0.0", @@ -303,8 +306,6 @@ "eslint": "^10.9.0", "eslint-import-resolver-alias": "^1.1.2", "eslint-import-resolver-typescript": "^4.4.5", - "eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings", - "eslint-plugin-icons": "file:eslint-rules/eslint-plugin-icons", "eslint-plugin-import": "^2.32.0", "eslint-plugin-jest-dom": "^5.10.1", "eslint-plugin-lodash": "^8.0.0", @@ -313,7 +314,6 @@ "eslint-plugin-react-you-might-not-need-an-effect": "^1.0.2", "eslint-plugin-storybook": "10.5.10", "eslint-plugin-testing-library": "^7.16.2", - "eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors", "fetch-mock": "^12.6.0", "fork-ts-checker-webpack-plugin": "^9.1.0", "history": "^5.3.0", diff --git a/superset-frontend/packages/superset-core/src/theme/types.ts b/superset-frontend/packages/superset-core/src/theme/types.ts index f72ca7388fac..af58581a608f 100644 --- a/superset-frontend/packages/superset-core/src/theme/types.ts +++ b/superset-frontend/packages/superset-core/src/theme/types.ts @@ -550,6 +550,12 @@ export interface ThemeContextType { canDetectOSPreference: () => boolean; createDashboardThemeProvider: (themeId: string) => Promise; getAppliedThemeId: () => number | null; + /** + * Re-reads the persisted system default/dark themes from the server and + * re-applies them live, so changes made on the Themes admin page take effect + * without a full page reload. + */ + refreshSystemThemes: () => Promise; } /** diff --git a/superset-frontend/packages/superset-ui-core/src/components/Select/Select.test.tsx b/superset-frontend/packages/superset-ui-core/src/components/Select/Select.test.tsx index 8e71afb4b794..ebe5dc72d49a 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Select/Select.test.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Select/Select.test.tsx @@ -17,6 +17,7 @@ * under the License. */ import { + act, createEvent, fireEvent, render, @@ -26,6 +27,7 @@ import { within, } from '@superset-ui/core/spec'; import { formatNumber } from '@superset-ui/core'; +import { Constants } from '@superset-ui/core/components'; import { Select } from '.'; type Option = { @@ -69,6 +71,38 @@ const NULL_OPTION = { label: '', value: null } as unknown as { value: number; }; +// A dedicated option set for the stableSelectAll tests, kept local so it is +// isolated from tests that mutate the shared OPTIONS array (e.g. toggling +// `disabled`). A search for "Ap" matches a strict subset (Apple, Apricot). +const STABLE_OPTIONS = [ + { label: 'Apple', value: 1 }, + { label: 'Apricot', value: 2 }, + { label: 'Banana', value: 3 }, + { label: 'Blueberry', value: 4 }, + { label: 'Cherry', value: 5 }, + { label: 'Cranberry', value: 6 }, +]; + +// A grouped option list for the stableSelectAll tests: bulk "Select all" must +// target the five leaf options, not the two value-less group headers. +const GROUPED_STABLE_OPTIONS = [ + { + label: 'Citrus', + options: [ + { label: 'Orange', value: 1 }, + { label: 'Lemon', value: 2 }, + ], + }, + { + label: 'Berries', + options: [ + { label: 'Strawberry', value: 3 }, + { label: 'Blueberry', value: 4 }, + { label: 'Raspberry', value: 5 }, + ], + }, +]; + const defaultProps = { allowClear: true, ariaLabel: ARIA_LABEL, @@ -1152,6 +1186,342 @@ test('abbreviates large numbers in bulk action buttons', async () => { expect(await screen.findByText('Select all (1.5k)')).toBeInTheDocument(); }); +// The stableSelectAll tests advance fake timers past the FAST_DEBOUNCE so the +// component's own search filter narrows `visibleOptions` (and flips +// `isSearching`) before asserting — the exact point at which the un-fixed code +// drops the badge to the search-scoped count. Asserting before that debounce +// fires (as an earlier revision did) would pass against the un-fixed code too. +test('stableSelectAll pins the "Select all" count to the full option set while searching', async () => { + jest.useFakeTimers({ advanceTimers: true }); + try { + render( + , + ); + const select = getSelect(); + userEvent.click(select); + expect( + await screen.findByText(selectAllButtonText(STABLE_OPTIONS.length)), + ).toBeInTheDocument(); + + await userEvent.type(select, 'Ap'); + act(() => { + jest.advanceTimersByTime(Constants.FAST_DEBOUNCE + 50); + }); + await waitFor(() => expect(getAllSelectOptions().length).toBe(2)); + + // Generic consumers keep the search-scoped count. + expect(screen.getByText(selectAllButtonText(2))).toBeInTheDocument(); + expect( + screen.queryByText(selectAllButtonText(STABLE_OPTIONS.length)), + ).not.toBeInTheDocument(); + } finally { + jest.useRealTimers(); + } +}); + +test('stableSelectAll selects the entire option set even while a search is active', async () => { + jest.useFakeTimers({ advanceTimers: true }); + try { + const onChange = jest.fn(); + render( + , + ); + const select = getSelect(); + userEvent.click(select); + expect(await screen.findByText(selectAllButtonText(5))).toBeInTheDocument(); + + await userEvent.type(select, 'Ap'); + act(() => { + jest.advanceTimersByTime(Constants.FAST_DEBOUNCE + 50); + }); + await waitFor(() => expect(getAllSelectOptions().length).toBe(2)); + + // The count reflects the full selectable set (5), not the 2 visible. + expect(screen.getByText(selectAllButtonText(5))).toBeInTheDocument(); + expect(screen.queryByText(selectAllButtonText(2))).not.toBeInTheDocument(); + } finally { + jest.useRealTimers(); + } +}); + +test('stableSelectAll deduplicates already-selected values when selecting the full set', async () => { + jest.useFakeTimers({ advanceTimers: true }); + try { + const onChange = jest.fn(); + // Apple (1) is already selected; the "Ap" search narrows the visible list + // to a subset while "Select all" still targets the whole set. + render( + , + ); + const select = getSelect(); + userEvent.click(select); + expect( + await screen.findByText(deselectAllButtonText(2)), + ).toBeInTheDocument(); + + await userEvent.type(select, 'Ap'); + act(() => { + jest.advanceTimersByTime(Constants.FAST_DEBOUNCE + 50); + }); + await waitFor(() => expect(getAllSelectOptions().length).toBe(2)); + + // "Clear" still reflects the full selection (2), not the visible subset (0). + expect(screen.getByText(deselectAllButtonText(2))).toBeInTheDocument(); + expect( + screen.queryByText(deselectAllButtonText(0)), + ).not.toBeInTheDocument(); + + // Clicking it removes the whole selection even though those values are not + // in the search results. + await userEvent.click(screen.getByText(deselectAllButtonText(2))); + await waitFor(() => expect(onChange).toHaveBeenCalled()); + expect(onChange.mock.calls.at(-1)?.[0]).toHaveLength(0); + } finally { + jest.useRealTimers(); + } +}); + +test('stableSelectAll "Clear" count matches the action for a selected value while searching', async () => { + jest.useFakeTimers({ advanceTimers: true }); + try { + const onChange = jest.fn(); + // The option carries a falsy value, which "Select all" skips but + // "Clear" (like the un-gated path) still removes. Pre-select and + // Banana (3); "Ap" hides both. The Clear count must equal what Clear + // removes — otherwise the label overstates the action. + render( + , + ); + const select = getSelect(); + userEvent.click(select); + + await userEvent.type(select, 'erry'); + act(() => { + jest.advanceTimersByTime(Constants.FAST_DEBOUNCE + 50); + }); + // Blueberry, Cherry, Cranberry match "erry". + await waitFor(() => expect(getAllSelectOptions().length).toBe(3)); + + // Four selected, two shown → two hidden. The overflow badge must report + // "+ 2 ...", not the sentinel-undercounted "+ 1 ...". + expect(screen.getByText('+ 2 ...')).toBeInTheDocument(); + expect(screen.queryByText('+ 1 ...')).not.toBeInTheDocument(); + } finally { + jest.useRealTimers(); + } +}); + +test('stableSelectAll counts and selects grouped options by their leaf values', async () => { + const onChange = jest.fn(); + render( +