diff --git a/.env b/.env index 0029f92fa0c..e76ed994d17 100644 --- a/.env +++ b/.env @@ -1,5 +1,5 @@ # Production Build -BUILD_GRID_VERSION=36.1.0-beta.20260818.1531 +BUILD_GRID_VERSION=36.1.0-beta.20260819.1156 BUILD_CHARTS_VERSION=14.1.0-beta.20260816 ENV=local NX_BATCH_MODE=true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee83e05de66..2d7b98c9c13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -326,31 +326,34 @@ jobs: # than a legitimately-unavailable environment. Local runs without Apache still skip. HTTPD_REQUIRED: 1 steps: - - name: Checkout - id: checkout - uses: actions/checkout@v4 - with: - fetch-depth: 1 # shallow copy - - - name: Setup - id: setup - uses: ./.github/actions/setup-nx - with: - yarn_postinstall: no-install - cache_mode: ro - - - name: nx test:e2e - id: tests - run: yarn nx ${{ github.event.inputs.nx_command || 'affected' }} -t test:e2e -c - staging --exclude 'tag:module-size' --exclude all - - - name: Persist test results - if: always() && matrix.shard != 0 - uses: actions/upload-artifact@v4 - with: - name: test-results-e2e-shard-${{matrix.shard}} - path: | - reports/ + - name: Ignore e2e tests + run: echo "::warning e2e tests are currently disabled due to flakiness. Re-enable when fixed." + + # - name: Checkout + # id: checkout + # uses: actions/checkout@v4 + # with: + # fetch-depth: 1 # shallow copy + + # - name: Setup + # id: setup + # uses: ./.github/actions/setup-nx + # with: + # yarn_postinstall: no-install + # cache_mode: ro + + # - name: nx test:e2e + # id: tests + # run: yarn nx ${{ github.event.inputs.nx_command || 'affected' }} -t test:e2e -c + # staging --exclude 'tag:module-size' --exclude all + + # - name: Persist test results + # if: always() && matrix.shard != 0 + # uses: actions/upload-artifact@v4 + # with: + # name: test-results-e2e-shard-${{matrix.shard}} + # path: | + # reports/ lint: runs-on: ubuntu-latest diff --git a/community-modules/locale/package.json b/community-modules/locale/package.json index 7919b51d569..af26670344b 100644 --- a/community-modules/locale/package.json +++ b/community-modules/locale/package.json @@ -1,6 +1,6 @@ { "name": "@ag-grid-community/locale", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "description": "Localisation Module for AG Grid, providing translations in 31 languages.", "main": "./dist/package/main.cjs.js", "types": "./dist/types/src/main.d.ts", diff --git a/community-modules/styles/package.json b/community-modules/styles/package.json index 3efb25a9855..210521c516c 100644 --- a/community-modules/styles/package.json +++ b/community-modules/styles/package.json @@ -1,6 +1,6 @@ { "name": "@ag-grid-community/styles", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "description": "AG Grid Styles and Themes", "main": "_index.scss", "files": [ diff --git a/documentation/ag-grid-docs/eslint.config.mjs b/documentation/ag-grid-docs/eslint.config.mjs index e9780fc8c6e..c2736ad77ba 100644 --- a/documentation/ag-grid-docs/eslint.config.mjs +++ b/documentation/ag-grid-docs/eslint.config.mjs @@ -1,6 +1,7 @@ import reactHooksPlugin from 'eslint-plugin-react-hooks'; import rootESLint from '../../eslint.config.mjs'; +import { noRawHistoryWrites } from '../../external/ag-website-shared/eslint.history-rules.mjs'; export default [ ...rootESLint, @@ -71,4 +72,9 @@ export default [ '@typescript-eslint/no-var-requires': 'off', }, }, + { + rules: { + 'no-restricted-syntax': ['error', ...noRawHistoryWrites], + }, + }, ]; diff --git a/documentation/ag-grid-docs/package.json b/documentation/ag-grid-docs/package.json index c1f15c312bf..dd93934c6db 100644 --- a/documentation/ag-grid-docs/package.json +++ b/documentation/ag-grid-docs/package.json @@ -2,7 +2,7 @@ "name": "ag-grid-docs", "description": "Documentation for AG Grid", "type": "module", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "repository": { "type": "git", "url": "https://github.com/ag-grid/ag-grid.git" @@ -59,11 +59,11 @@ "ag-charts-types": "14.1.0-beta.20260816", "ag-charts-react": "14.1.0-beta.20260816", "ag-charts-vue3": "14.1.0-beta.20260816", - "ag-grid-angular": "36.1.0-beta.20260818.1531", - "ag-grid-community": "36.1.0-beta.20260818.1531", - "ag-grid-enterprise": "36.1.0-beta.20260818.1531", - "ag-grid-react": "36.1.0-beta.20260818.1531", - "ag-grid-vue3": "36.1.0-beta.20260818.1531", + "ag-grid-angular": "36.1.0-beta.20260819.1156", + "ag-grid-community": "36.1.0-beta.20260819.1156", + "ag-grid-enterprise": "36.1.0-beta.20260819.1156", + "ag-grid-react": "36.1.0-beta.20260819.1156", + "ag-grid-vue3": "36.1.0-beta.20260819.1156", "algoliasearch": "^5.51.0", "astro": "6.1.9", "cheerio": "^1.0.0", diff --git a/documentation/ag-grid-docs/public/scripts/beyond-the-prompt.js b/documentation/ag-grid-docs/public/scripts/beyond-the-prompt.js index 390f09956ed..9948c38e5f0 100644 --- a/documentation/ag-grid-docs/public/scripts/beyond-the-prompt.js +++ b/documentation/ag-grid-docs/public/scripts/beyond-the-prompt.js @@ -193,7 +193,20 @@ // href, so fall back to the current URL rather than pushing null // (which would corrupt the address bar and back/forward behaviour). const sessionUrl = link.getAttribute('href') || location.href; - history.pushState({ btpSession: videoId }, '', sessionUrl); + // Mirror what Astro's ClientRouter writes on its own pushes. It derives traversal + // direction from `state.index`, so the entry needs the router's other bookkeeping + // carried over and an index one past the current entry: leaving `index` off entirely + // turns its next push index into NaN, and reusing the current one makes a forward + // traversal into this entry compare equal and read as a "back". Scroll offsets ride + // along from the spread so returning here restores the position the modal opened at. + // This is a classic script served verbatim from public/, so the shared + // replaceHistoryUrl() helper is not importable here. + // eslint-disable-next-line no-restricted-syntax -- no module system in a public/ script + history.pushState( + { ...history.state, index: (history.state?.index ?? 0) + 1, btpSession: videoId }, + '', + sessionUrl + ); }); } for (const closer of document.querySelectorAll('[data-session-modal-close]')) { diff --git a/documentation/ag-grid-docs/public/scripts/license-pricing.js b/documentation/ag-grid-docs/public/scripts/license-pricing.js index f5b207910e8..821e3644ec2 100644 --- a/documentation/ag-grid-docs/public/scripts/license-pricing.js +++ b/documentation/ag-grid-docs/public/scripts/license-pricing.js @@ -14,7 +14,12 @@ if (target) { event.preventDefault(); target.scrollIntoView({ behavior: 'smooth' }); - history.replaceState(null, '', '#pricing'); + // Preserve the existing state: Astro's ClientRouter keeps its history index and + // scroll offsets there, and replacing them breaks back/forward for the whole page. + // Classic script served verbatim from public/, so the shared replaceHistoryUrl() + // helper is not importable here. + // eslint-disable-next-line no-restricted-syntax -- no module system in a public/ script + history.replaceState(history.state, '', '#pricing'); } }); })(); diff --git a/documentation/ag-grid-docs/public/styles/bryntum-demo.css b/documentation/ag-grid-docs/public/styles/bryntum-demo.css index 7a92a4c2518..13a0b1cda74 100644 --- a/documentation/ag-grid-docs/public/styles/bryntum-demo.css +++ b/documentation/ag-grid-docs/public/styles/bryntum-demo.css @@ -21,7 +21,7 @@ margin: 0 0 8px; font-size: var(--text-fs-lg); font-weight: var(--text-semibold); - line-height: var(--text-lh-base); + line-height: var(--text-lh-lg); color: var(--color-util-brand-600); } diff --git a/documentation/ag-grid-docs/src/components/campaigns-components/BryntumCampaign.module.scss b/documentation/ag-grid-docs/src/components/campaigns-components/BryntumCampaign.module.scss index a77d192800c..ce735c132eb 100644 --- a/documentation/ag-grid-docs/src/components/campaigns-components/BryntumCampaign.module.scss +++ b/documentation/ag-grid-docs/src/components/campaigns-components/BryntumCampaign.module.scss @@ -85,7 +85,7 @@ margin: 0 0 $spacing-size-2; font-size: var(--text-fs-lg); font-weight: var(--text-semibold); - line-height: var(--text-lh-base); + line-height: var(--text-lh-lg); color: var(--color-util-brand-600); #{$selector-darkmode} & { @@ -100,7 +100,8 @@ .heroHeading { font-size: 48px; - line-height: var(--text-lh-3xl); + // Ratio, not px: the font-size is fluid at the large breakpoint, so the line box must scale with it. + line-height: 1.1; color: var(--color-white); margin: 0 0 $spacing-size-4; @@ -111,7 +112,7 @@ .heroBody { font-size: var(--text-fs-lg); - line-height: var(--text-lh-base); + line-height: var(--text-lh-lg); color: var(--color-white); opacity: 0.92; margin-bottom: $spacing-size-8; @@ -248,7 +249,7 @@ .textMediaCopy { h2 { font-size: var(--text-fs-2xl); - line-height: var(--text-lh-3xl); + line-height: var(--text-lh-2xl); margin: 0 0 $spacing-size-4; } @@ -372,7 +373,7 @@ .partnershipHeading { font-size: var(--text-fs-2xl); - line-height: var(--text-lh-3xl); + line-height: var(--text-lh-2xl); margin: 0 0 $spacing-size-4; } @@ -466,7 +467,7 @@ .miniDemoCopy { h2 { font-size: var(--text-fs-2xl); - line-height: var(--text-lh-3xl); + line-height: var(--text-lh-2xl); margin: 0 0 $spacing-size-4; } @@ -618,7 +619,7 @@ .exampleCardDescription { margin: $spacing-size-1 0 0; font-size: var(--text-fs-sm); - line-height: var(--text-lh-base); + line-height: var(--text-lh-sm); color: var(--color-fg-secondary); } diff --git a/documentation/ag-grid-docs/src/components/docs/components/Header.module.scss b/documentation/ag-grid-docs/src/components/docs/components/Header.module.scss index 0f0089b4172..8c9aae9e4be 100644 --- a/documentation/ag-grid-docs/src/components/docs/components/Header.module.scss +++ b/documentation/ag-grid-docs/src/components/docs/components/Header.module.scss @@ -58,7 +58,7 @@ justify-self: start; font-size: var(--text-fs-sm); font-weight: var(--text-regular); - line-height: var(--text-lh-base); + line-height: var(--text-lh-sm); color: var(--color-text-tertiary); } diff --git a/documentation/ag-grid-docs/src/components/example-grid/Toolbar.tsx b/documentation/ag-grid-docs/src/components/example-grid/Toolbar.tsx index c0916113636..8be93cfc06d 100644 --- a/documentation/ag-grid-docs/src/components/example-grid/Toolbar.tsx +++ b/documentation/ag-grid-docs/src/components/example-grid/Toolbar.tsx @@ -1,4 +1,5 @@ import { Select } from '@ag-website-shared/components/select/Select'; +import { replaceHistoryUrl } from '@ag-website-shared/utils/historyUrl'; import { trackDemoToolbar } from '@utils/analytics'; import { useMemo } from 'react'; import type { RefObject } from 'react'; @@ -16,7 +17,7 @@ function updateUrlParam(key: string, value: string) { } const url = new URL(window.location.href); url.searchParams.set(key, value); - history.replaceState({}, '', url); + replaceHistoryUrl(url); } interface SelectOption { diff --git a/documentation/ag-grid-docs/src/components/example-runner/components/FrameworkTemplate.astro b/documentation/ag-grid-docs/src/components/example-runner/components/FrameworkTemplate.astro index 85b2f700593..0c74b5a11af 100644 --- a/documentation/ag-grid-docs/src/components/example-runner/components/FrameworkTemplate.astro +++ b/documentation/ag-grid-docs/src/components/example-runner/components/FrameworkTemplate.astro @@ -108,6 +108,7 @@ const shouldShowTemplate = (framework: InternalFramework) => { extras={extras} headFragment={headFragment} usesMathRandom={usesMathRandom} + transpileInBrowser={transpileInBrowser} nonce={nonce} > {addInitMessageScript && ( diff --git a/documentation/ag-grid-docs/src/components/example-runner/framework-templates/JavascriptTemplate.astro b/documentation/ag-grid-docs/src/components/example-runner/framework-templates/JavascriptTemplate.astro index 79569f6c2fb..061e6939f1a 100644 --- a/documentation/ag-grid-docs/src/components/example-runner/framework-templates/JavascriptTemplate.astro +++ b/documentation/ag-grid-docs/src/components/example-runner/framework-templates/JavascriptTemplate.astro @@ -15,6 +15,7 @@ interface Props { extraStyles?: string; extras?: string[]; usesMathRandom?: boolean; + transpileInBrowser?: boolean; nonce?: string; } @@ -33,6 +34,7 @@ const { extraStyles, extras, usesMathRandom, + transpileInBrowser, nonce, } = Astro.props as Props; @@ -50,7 +52,7 @@ import { getChartsEnterpriseScriptPath, getGridLocaleScriptPath, } from '@utils/gridLibraryPaths'; -import { ExampleRunnerClient } from './lib/ExampleRunnerClient'; +import { ExampleRunnerCall, ExampleRunnerClient } from './lib/ExampleRunnerClient'; import { SeedRandom } from './lib/SeedRandom'; const siteUrl = Astro.site ? pathJoin(Astro.site, SITE_BASE_URL) : '/'; @@ -88,14 +90,9 @@ const gridScriptLocalePath = getCacheBustingUrl(getGridLocaleScriptPath(siteUrl) > window.__basePath = appLocation; - { - usesMathRandom && ( - <> - - - - ) - } + + + {usesMathRandom && } {isIntegratedCharts && diff --git a/documentation/ag-grid-docs/src/components/landing-pages/sections/comparison/ComparisonTable.module.scss b/documentation/ag-grid-docs/src/components/landing-pages/sections/comparison/ComparisonTable.module.scss index 3fb887c2b38..375f1999f98 100644 --- a/documentation/ag-grid-docs/src/components/landing-pages/sections/comparison/ComparisonTable.module.scss +++ b/documentation/ag-grid-docs/src/components/landing-pages/sections/comparison/ComparisonTable.module.scss @@ -55,7 +55,7 @@ .headerTitle { font-size: var(--text-fs-lg); font-weight: var(--text-bold); - line-height: var(--text-lh-tight); + line-height: var(--text-lh-ratio-tight); color: var(--color-fg-primary); @media screen and (max-width: $breakpoint-landing-page-medium) { diff --git a/documentation/ag-grid-docs/src/components/license-setup/components/LicenseSetup.module.scss b/documentation/ag-grid-docs/src/components/license-setup/components/LicenseSetup.module.scss index 3f19cfebf79..7f6396af839 100644 --- a/documentation/ag-grid-docs/src/components/license-setup/components/LicenseSetup.module.scss +++ b/documentation/ag-grid-docs/src/components/license-setup/components/LicenseSetup.module.scss @@ -26,7 +26,7 @@ word-break: break-all; font-family: var(--text-monospace-font-family); font-size: 15px; - line-height: var(--text-lh-base); + line-height: 21px; } .license { @@ -142,7 +142,7 @@ td { padding: 0; - line-height: var(--text-lh-tight); + line-height: var(--text-lh-ratio-tight); font-weight: var(--text-bold); &:not(:last-child) { diff --git a/documentation/ag-grid-docs/src/components/license-setup/utils/updateSearchParams.ts b/documentation/ag-grid-docs/src/components/license-setup/utils/updateSearchParams.ts index c392a12c6d5..d469dc9d146 100644 --- a/documentation/ag-grid-docs/src/components/license-setup/utils/updateSearchParams.ts +++ b/documentation/ag-grid-docs/src/components/license-setup/utils/updateSearchParams.ts @@ -1,3 +1,5 @@ +import { replaceHistoryUrl } from '@ag-website-shared/utils/historyUrl'; + export const updateSearchParams = ({ integratedCharts }: { integratedCharts: boolean }) => { const url = new URL(window.location); const integratedChartsParamValue = url.searchParams.get('integratedCharts') === 'true'; @@ -10,5 +12,7 @@ export const updateSearchParams = ({ integratedCharts }: { integratedCharts: boo } } - history.pushState(null, '', url); + // A filter, not a navigation: no page-level popstate handler services these entries, so + // pushing one leaves back moving the URL with nothing reacting to it. + replaceHistoryUrl(url); }; diff --git a/documentation/ag-grid-docs/src/components/quotes/Quotes.module.scss b/documentation/ag-grid-docs/src/components/quotes/Quotes.module.scss index 21295b76cd4..ff54296934c 100644 --- a/documentation/ag-grid-docs/src/components/quotes/Quotes.module.scss +++ b/documentation/ag-grid-docs/src/components/quotes/Quotes.module.scss @@ -56,7 +56,7 @@ flex-direction: column; justify-content: space-between; font-size: var(--text-fs-base); - line-height: var(--text-lh-2xl); + line-height: var(--text-lh-base); border: 1px solid var(--color-border-secondary); margin: -1px 0 0 -1px; padding: 24px; diff --git a/documentation/ag-grid-docs/src/content/docs/filter-advanced/index.mdoc b/documentation/ag-grid-docs/src/content/docs/filter-advanced/index.mdoc index e04e74647f4..9bbae8430d5 100644 --- a/documentation/ag-grid-docs/src/content/docs/filter-advanced/index.mdoc +++ b/documentation/ag-grid-docs/src/content/docs/filter-advanced/index.mdoc @@ -144,6 +144,7 @@ For `text` and `object` Cell Data Types, `caseSensitive = true` can be set to en For `number`, `date`, `dateString`, `dateTime` and `dateTimeString` Cell Data Types, the following properties can be set to include blank values for the relevant options: - `includeBlanksInEquals = true` +- `includeBlanksInNotEqual = true` - `includeBlanksInLessThan = true` - `includeBlanksInGreaterThan = true` diff --git a/documentation/ag-grid-docs/src/pages-styles/campaigns.module.scss b/documentation/ag-grid-docs/src/pages-styles/campaigns.module.scss index a4eab679208..bf5b8a1b146 100644 --- a/documentation/ag-grid-docs/src/pages-styles/campaigns.module.scss +++ b/documentation/ag-grid-docs/src/pages-styles/campaigns.module.scss @@ -419,7 +419,7 @@ justify-content: space-between; width: 33.33%; font-size: var(--text-fs-base); - line-height: var(--text-lh-2xl); + line-height: var(--text-lh-base); padding: $spacing-size-10; @media screen and (max-width: $breakpoint-pricing-medium) { @@ -437,6 +437,7 @@ blockquote { font-size: var(--text-fs-lg); + line-height: var(--text-lh-lg); font-weight: var(--text-semibold); margin-bottom: $spacing-size-6; color: var(--color-fg-primary); diff --git a/documentation/ag-grid-docs/src/pages-styles/homepage.module.scss b/documentation/ag-grid-docs/src/pages-styles/homepage.module.scss index 62134789fc3..51aca2c2416 100644 --- a/documentation/ag-grid-docs/src/pages-styles/homepage.module.scss +++ b/documentation/ag-grid-docs/src/pages-styles/homepage.module.scss @@ -69,7 +69,7 @@ body { h1, h2 { - line-height: var(--text-lh-tight); + line-height: var(--text-lh-ratio-tight); color: var(--color-white); } @@ -343,7 +343,7 @@ body { > div { text-align: center; margin-bottom: $spacing-size-10; - list-style: var(--text-lh-tight); + list-style: var(--text-lh-ratio-tight); @media screen and (min-width: $breakpoint-sponsorship-large) { text-align: unset; diff --git a/documentation/update-algolia-indices/package.json b/documentation/update-algolia-indices/package.json index c37a0d91a27..8230324f5f3 100644 --- a/documentation/update-algolia-indices/package.json +++ b/documentation/update-algolia-indices/package.json @@ -1,6 +1,6 @@ { "name": "update-algolia-indices", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "description": "Update algolia indices", "main": "src/index.ts", "type": "module", diff --git a/external/ag-website-shared/.gitrepo b/external/ag-website-shared/.gitrepo index cdb7485f1f0..89f61683ab1 100644 --- a/external/ag-website-shared/.gitrepo +++ b/external/ag-website-shared/.gitrepo @@ -6,7 +6,7 @@ [subrepo] remote = git@github.com:ag-grid/ag-website-shared.git branch = latest - commit = e914cbd1b9149f4a849b97f9ce5c4711d0cc38cc - parent = 3aeb2db7e42fd6a7458b5179c0f581b6691ad9be + commit = 33a9edfb59b108a89ecc16aabbc395ca96118713 + parent = 3f6d6ac9e128944c8ec07f6b1c61233dc0d672c3 method = rebase cmdver = 0.4.9 diff --git a/external/ag-website-shared/eslint.config.mjs b/external/ag-website-shared/eslint.config.mjs index 5e12fe374ba..e9b23257f75 100644 --- a/external/ag-website-shared/eslint.config.mjs +++ b/external/ag-website-shared/eslint.config.mjs @@ -3,6 +3,8 @@ import pluginJs from '@eslint/js'; import globals from 'globals'; import tseslint from 'typescript-eslint'; +import { noRawHistoryWrites } from './eslint.history-rules.mjs'; + export default [ { languageOptions: { globals: { ...globals.browser, ...globals.node } } }, pluginJs.configs.recommended, @@ -45,4 +47,11 @@ export default [ 'no-undef': 'off', }, }, + { + // The helper itself is the one place a raw write belongs. + ignores: ['src/utils/historyUrl.ts'], + rules: { + 'no-restricted-syntax': ['error', ...noRawHistoryWrites], + }, + }, ]; diff --git a/external/ag-website-shared/eslint.history-rules.mjs b/external/ag-website-shared/eslint.history-rules.mjs new file mode 100644 index 00000000000..1d0f728f737 --- /dev/null +++ b/external/ag-website-shared/eslint.history-rules.mjs @@ -0,0 +1,25 @@ +/** + * Astro's ClientRouter stores its history index and scroll offsets in `history.state`. A raw + * write replaces them, after which its popstate handler either bails out or reads every + * traversal as a "back", silently breaking back/forward for the whole page. + * + * Shared by the ag-website-shared config and each site's own config, so both halves of a + * website enforce the same invariant from one definition. + */ +const message = + 'Use replaceHistoryUrl() from @ag-website-shared/utils/historyUrl - a raw history write discards the router state that back/forward depends on.'; + +const methods = '/^(pushState|replaceState)$/'; + +export const noRawHistoryWrites = [ + // `history.pushState(...)` + { + selector: `CallExpression > MemberExpression.callee[object.name='history'][property.name=${methods}]`, + message, + }, + // `window.history.pushState(...)`, `globalThis.history.pushState(...)` + { + selector: `CallExpression > MemberExpression.callee[object.property.name='history'][property.name=${methods}]`, + message, + }, +]; diff --git a/external/ag-website-shared/src/components/automated-examples/AutomatedExampleDebug.module.scss b/external/ag-website-shared/src/components/automated-examples/AutomatedExampleDebug.module.scss index 06be83b32e8..9cb483cd860 100644 --- a/external/ag-website-shared/src/components/automated-examples/AutomatedExampleDebug.module.scss +++ b/external/ag-website-shared/src/components/automated-examples/AutomatedExampleDebug.module.scss @@ -67,7 +67,7 @@ $z-index-debug-panel: $z-index-debug-canvas + 200; button:not([class^='ag-']) { padding: $spacing-size-1; - line-height: var(--text-lh-ultra-tight); + line-height: var(--text-lh-ratio-ultra-tight); } } diff --git a/external/ag-website-shared/src/components/changelog/Changelog.tsx b/external/ag-website-shared/src/components/changelog/Changelog.tsx index e5c27ac436a..920e0764b55 100644 --- a/external/ag-website-shared/src/components/changelog/Changelog.tsx +++ b/external/ag-website-shared/src/components/changelog/Changelog.tsx @@ -6,6 +6,7 @@ import { useSearchQuery } from '@ag-website-shared/components/changelog/useSearc import DetailCellRenderer from '@ag-website-shared/components/grid/DetailCellRendererComponent'; import { Grid } from '@ag-website-shared/components/grid/Grid'; import { Icon } from '@ag-website-shared/components/icon/Icon'; +import { replaceHistoryUrl } from '@ag-website-shared/utils/historyUrl'; import { IssueColDef, IssueTypeColDef } from '@ag-website-shared/utils/issueColDefs'; import ReleaseVersionNotes from '@components/release-notes/ReleaseVersionNotes.jsx'; import { urlWithBaseUrl } from '@utils/urlWithBaseUrl'; @@ -188,7 +189,7 @@ export const Changelog: FunctionComponent = ({ library }) => { } url.searchParams.set('fixVersion', fixVersion); - window.history.replaceState({}, '', url); + replaceHistoryUrl(url); }, [setFixVersion] ); diff --git a/external/ag-website-shared/src/components/changelog/changelog.module.scss b/external/ag-website-shared/src/components/changelog/changelog.module.scss index 213ff829204..69192e8ff0a 100644 --- a/external/ag-website-shared/src/components/changelog/changelog.module.scss +++ b/external/ag-website-shared/src/components/changelog/changelog.module.scss @@ -58,7 +58,7 @@ .searchExplainer, label.searchBreakingText { - line-height: var(--text-lh-tight); + line-height: var(--text-lh-ratio-tight); @media screen and (max-width: $breakpoint-changelog-pipeline-large) { display: none; diff --git a/external/ag-website-shared/src/components/changelog/useSearchQuery.ts b/external/ag-website-shared/src/components/changelog/useSearchQuery.ts index 7d428f1185a..0339f5558db 100644 --- a/external/ag-website-shared/src/components/changelog/useSearchQuery.ts +++ b/external/ag-website-shared/src/components/changelog/useSearchQuery.ts @@ -1,3 +1,4 @@ +import { replaceHistoryUrl } from '@ag-website-shared/utils/historyUrl'; import { type ChangeEvent, useCallback, useEffect, useRef, useState } from 'react'; export function useSearchQuery() { @@ -29,7 +30,7 @@ export function useSearchQuery() { } else { url.searchParams.delete('searchQuery'); } - window.history.replaceState(null, '', url.toString()); + replaceHistoryUrl(url); }, 250); return () => clearTimeout(timeoutId); diff --git a/external/ag-website-shared/src/components/contact-form/ContactResultPage.module.scss b/external/ag-website-shared/src/components/contact-form/ContactResultPage.module.scss index fb377bc59c5..1bc0543e1fb 100644 --- a/external/ag-website-shared/src/components/contact-form/ContactResultPage.module.scss +++ b/external/ag-website-shared/src/components/contact-form/ContactResultPage.module.scss @@ -43,13 +43,14 @@ .heroHeading { font-size: 36px; font-weight: var(--text-bold); - line-height: var(--text-lh-tight); + line-height: 43px; margin: 0; color: var(--color-fg-primary); letter-spacing: -1px; @media screen and (min-width: $breakpoint-docs-nav-medium) { font-size: 48px; + line-height: 58px; } } @@ -191,7 +192,7 @@ .heading { font-size: var(--text-fs-3xl); font-weight: var(--text-bold); - line-height: var(--text-lh-tight); + line-height: var(--text-lh-ratio-tight); margin: 0; color: var(--color-fg-primary); diff --git a/external/ag-website-shared/src/components/demo-page/DemoPage.astro b/external/ag-website-shared/src/components/demo-page/DemoPage.astro new file mode 100644 index 00000000000..4f8e2f8bead --- /dev/null +++ b/external/ag-website-shared/src/components/demo-page/DemoPage.astro @@ -0,0 +1,110 @@ +--- +import { Icon } from '@ag-website-shared/components/icon/Icon'; +import { urlWithBaseUrl } from '@utils/urlWithBaseUrl'; + +import styles from './DemoPage.module.scss'; +import type { DemoPageExample, DemoPageHero } from './types'; + +interface Props extends DemoPageHero { + /** Which demo is currently shown; drives the active feature-list item. */ + currentId: string; + /** Every demo the site offers, in the order the feature list shows them. */ + examples: DemoPageExample[]; +} + +const { currentId, examples, eyebrow, title, description, primaryCta, secondaryCta } = Astro.props; +--- + +
+ + + {/* Stands in for the hero and demo card, both hidden at this notice's breakpoint. */} + + +
+
+
+ +
+ + + + +
+
+
+ +{ + /* Each site consuming this component serves the script from its own public folder. + data-astro-rerun: the router swaps in a fresh, unwired stage, so the launch overlay + needs re-binding on each visit rather than only on the first document to load this. */ +} + diff --git a/external/ag-website-shared/src/components/demo-page/DemoPage.module.scss b/external/ag-website-shared/src/components/demo-page/DemoPage.module.scss new file mode 100644 index 00000000000..1975846e9c8 --- /dev/null +++ b/external/ag-website-shared/src/components/demo-page/DemoPage.module.scss @@ -0,0 +1,375 @@ +@use 'design-system' as *; + +// Wider than most screens, so the demo reads as a full-size app running off the +// edge rather than a shrunken one. +$demo-held-width: 1600px; +$hero-max-width: 480px; // Matches the .hero clamp ceiling. +$demo-column-gap: $spacing-size-8; +$demo-gutter-base: $spacing-size-12; +$demo-gutter-wide: 100px; +// Where the demo first fits between the wide gutters; derived so it tracks its parts. +$breakpoint-demo-fits: $demo-held-width + $hero-max-width + $demo-column-gap + $demo-gutter-wide * 2; + +// Deliberately uncapped, unlike every other page: until the demo fits, the card +// takes the right-hand gutter and runs off the edge of the screen. +.demoPage { + // JS overwrites --demo-height after first paint, so this fallback has to be + // right too or the demo renders taller than the window and snaps. + --demo-chrome: var(--layout-site-header-height); + --demo-height: calc(100vh - var(--demo-chrome)); + + --demo-gutter: var(--layout-horizontal-margins); + + display: flex; + flex-direction: column; + gap: $demo-column-gap; + width: 100%; + padding: $spacing-size-6 var(--demo-gutter); + box-sizing: border-box; + + // Only the slotted mobile notice shows this narrow; give it the space the demo would have filled. + min-height: var(--demo-height); + justify-content: center; + + @media screen and (min-width: $breakpoint-docs-search-medium) { + min-height: 0; + justify-content: flex-start; + } + + @media screen and (min-width: $breakpoint-hero-large) { + --demo-gutter: #{$demo-gutter-base}; + + flex-direction: row; + align-items: stretch; + height: var(--demo-height); + } + + @media screen and (min-width: $breakpoint-demo-fits) { + --demo-gutter: #{$demo-gutter-wide}; + } +} + +// The banner pushes this page down, so its single-line height comes out of the +// space available to the demo. +:global(html[data-show-announcement='true']) .demoPage { + --demo-chrome: calc(var(--layout-site-header-height) + 37px); +} + +.hero { + // Its copy sells the demo and its list switches between examples: nothing to offer + // while no demo is reachable. + display: none; + flex-shrink: 0; + + @media screen and (min-width: $breakpoint-docs-search-medium) { + display: flex; + } + + @media screen and (min-width: $breakpoint-hero-large) { + width: clamp(360px, 34vw, 480px); + align-items: center; + } +} + +.heroInner { + display: flex; + flex-direction: column; + width: 100%; + // Full width while stacked, where the hero has the whole page to itself; only + // capped once it becomes the narrow left column beside the demo. + @media screen and (min-width: $breakpoint-hero-large) { + max-width: 440px; + } +} + +.eyebrow { + margin: 0 0 $spacing-size-3; + font-size: var(--text-fs-lg); + font-weight: var(--text-semibold); + // Brand shades don't flip with the theme, so brand-600 needs a lighter + // substitute on dark backgrounds. + color: var(--color-brand-600); + + #{$selector-darkmode} & { + color: var(--color-brand-300); + } +} + +.title { + margin: 0 0 $spacing-size-5; + font-size: var(--text-fs-3xl); + line-height: var(--text-lh-3xl); + font-weight: var(--text-bold); + letter-spacing: -0.02em; + color: var(--color-fg-primary); +} + +.description { + margin: 0 0 $spacing-size-8; + font-size: var(--text-fs-base); + line-height: var(--text-lh-base); + color: var(--color-fg-secondary); +} + +// Doubles as the demo switcher. A shared rule runs down the left; each item's own +// transparent border overlays it, colouring in when active. +.featureList { + list-style: none; + margin: 0 0 $spacing-size-8; + padding: 0; + border-left: 2px solid var(--color-border-primary); +} + +.featureItem { + display: flex; + flex-direction: column; + gap: $spacing-size-1; + margin-left: -2px; + padding: $spacing-size-3 0 $spacing-size-3 $spacing-size-8; + border-left: 2px solid transparent; + text-decoration: none; + + &:hover .featureTitle { + color: var(--color-brand-500); + } + + #{$selector-darkmode} &:hover .featureTitle { + color: var(--color-brand-300); + } +} + +.featureItemActive { + border-left-color: var(--color-brand-600); + + .featureTitle { + color: var(--color-brand-600); + } + + #{$selector-darkmode} & { + border-left-color: var(--color-brand-300); + + .featureTitle { + color: var(--color-brand-300); + } + } +} + +.featureTitle { + font-size: 18px; + font-weight: var(--text-semibold); + line-height: var(--text-lh-base); + color: var(--color-fg-primary); + transition: color $transition-default-timing; +} + +.featureDescription { + font-size: var(--text-fs-base); + line-height: var(--text-lh-base); + // Undo the global `a` rule's semibold so the title carries the weight. + font-weight: var(--text-regular); + color: var(--color-fg-tertiary); +} + +.ctaButtons { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: $spacing-size-3; +} + +.contactButton { + gap: $spacing-size-2; + :global(.icon) { + --icon-size: #{$spacing-size-4}; + } +} + +.demoCard { + position: relative; + // The demo has no usable small-screen form: it is held at $demo-held-width, so a phone + // would show a sliver of it. Hide it until there is room; the page-level mobile notice + // stands in, as one inside this card would centre in the held width and land off-screen. + display: none; + flex: 1 1 auto; + min-width: 0; + height: 70vh; + min-height: 480px; + background: var(--color-bg-primary); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-2xl); + box-shadow: var(--shadow-lg); + overflow: hidden; + + // Same threshold the mobile notice uses to call an example unavailable. + @media screen and (min-width: $breakpoint-docs-search-medium) { + display: block; + } + + @media screen and (min-width: $breakpoint-hero-large) { + height: auto; + + // A clipped demo runs off the screen edge rather than stopping short of it, + // since the gutter would only be dead space beside it. updateCutOff + // (demo-page.js) measures the un-bled width, so this can't oscillate with + // the state that applies it. + &:has([data-cutoff='true']) { + margin-right: calc(-1 * var(--demo-gutter)); + } + } + + // The expanded stage covers the page opaquely, but this card's border and shadow + // would still show behind it. Must follow the bleed above to win on source order. + &:has([data-expanded='true']) { + margin-right: 0; + border-color: transparent; + box-shadow: none; + } +} + +// The demo is a non-interactive preview (viewport `inert`, overlay swallowing +// clicks) until launched, then expands to fill the viewport and becomes live. +.stage { + position: relative; + width: 100%; + height: 100%; + + &[data-expanded='true'] { + position: fixed; + // positionBelowHeader (demo-page.js) writes the sticky header's live + // bottom here, so the header stays visible. + top: var(--stage-top, 0px); + right: 0; + bottom: 0; + left: 0; + // top+bottom own the height; overrides the base height:100%, which would + // make it a full viewport tall and overflow below the header. + height: auto; + box-sizing: border-box; + // Generous top band keeps Minimise off the demo; the remaining three sides + // share one inset so the frame sits evenly, giving the demo the width the + // page gutters would otherwise take. + padding: $spacing-size-16 $spacing-size-6 $spacing-size-6; + // Below the site header (10002) and banner (10005). + z-index: 9999; + background: var(--color-bg-primary); + } +} + +.stageViewport { + width: 100%; + height: 100%; + // A floor, not a cap: the card clips the overflow, so the demo is cut off on + // the right rather than shrinking, and still grows past this when there's room. + min-width: $demo-held-width; +} + +// Expanded: take the real available width, framed like the preview card. +.stage[data-expanded='true'] .stageViewport { + min-width: 0; + background: var(--color-bg-primary); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-2xl); + box-shadow: var(--shadow-lg); + overflow: hidden; +} + +// Each demo's own script sizes itself from the viewport bottom, overshooting this +// container and clipping bottom-pinned content such as the sidebar profile +// switcher. Pin it to the container instead. +.stageViewport > :global(*) { + height: 100% !important; +} + +// Hit layer that, with the `inert` viewport, makes the preview non-interactive. +// Transparent on load so the demo reads crisply; the idle zero-blur filter is what +// lets the hover blur interpolate rather than pop. +.stageOverlay { + position: absolute; + inset: 0; + z-index: 2; + display: none; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + border: none; + cursor: pointer; + background: transparent; + backdrop-filter: blur(0) saturate(1); + -webkit-backdrop-filter: blur(0) saturate(1); + transition: + background $transition-default-timing, + backdrop-filter $transition-default-timing, + -webkit-backdrop-filter $transition-default-timing; + + &:hover, + &:focus-visible { + background: color-mix(in srgb, var(--color-bg-primary) 38%, transparent); + backdrop-filter: blur(5px) saturate(1.05); + -webkit-backdrop-filter: blur(5px) saturate(1.05); + } +} + +// Gate interaction only while the demo is cropped; when it all fits, it is live +// inline with no overlay. +.stage[data-cutoff='true']:not([data-expanded='true']) .stageOverlay { + display: flex; +} + +// Borrows the primary button's tokens so it sits in the site's design language. +.stageHint { + display: inline-flex; + align-items: center; + gap: $spacing-size-2; + padding: 0.5em 1.15em 0.6em; + border-radius: var(--radius-sm); + background-color: var(--color-button-primary-bg); + color: var(--color-button-primary-fg); + border: 1px solid var(--color-button-primary-border); + font-size: var(--text-fs-base); + font-weight: var(--text-bold); + box-shadow: var(--shadow-lg); + // Rises into view on hover/focus of the overlay. + opacity: 0; + transform: translateY($spacing-size-2) scale(0.98); + transition: + opacity $transition-default-timing, + background-color $transition-default-timing, + transform $transition-default-timing; + + .stageOverlay:hover &, + .stageOverlay:focus-visible & { + opacity: 1; + background-color: var(--color-button-primary-bg-hover); + transform: none; + } +} + +.stageHintIcon { + flex-shrink: 0; +} + +.stageMinimise { + position: absolute; + top: $spacing-size-3; + // Matches the stage's side padding so it lines up with the demo's left edge. + left: $spacing-size-6; + z-index: 4; + // Colour, border, padding and states come from the global `button-secondary` + // class in the markup; this only owns placement and icon + label layout. + align-items: center; + gap: $spacing-size-2; + + // Scoped selectors out-specify the global button `display: inline-block`. + .stage:not([data-expanded='true']) & { + display: none; + } + + .stage[data-expanded='true'] & { + display: inline-flex; + } +} + +.stageMinimiseIcon { + flex-shrink: 0; +} diff --git a/external/ag-website-shared/src/components/demo-page/types.ts b/external/ag-website-shared/src/components/demo-page/types.ts new file mode 100644 index 00000000000..bfa7800b744 --- /dev/null +++ b/external/ag-website-shared/src/components/demo-page/types.ts @@ -0,0 +1,27 @@ +/** One demo in the page's feature list, which doubles as the switcher between demos. */ +export interface DemoPageExample { + id: string; + title: string; + /** Base-relative page path, e.g. './example'. */ + path: string; + /** Short supporting copy shown beneath the title. */ + description: string; +} + +/** A call to action rendered beneath the feature list. */ +export interface DemoPageCta { + label: string; + href: string; +} + +/** The consuming site's copy for the demo page hero. */ +export interface DemoPageHero { + /** Small uppercase label above the title. */ + eyebrow: string; + /** Main hero heading. */ + title: string; + /** Supporting paragraph beneath the heading. */ + description: string; + primaryCta: DemoPageCta; + secondaryCta: DemoPageCta; +} diff --git a/external/ag-website-shared/src/components/framework-text-animation/FrameworkTextAnimation.module.scss b/external/ag-website-shared/src/components/framework-text-animation/FrameworkTextAnimation.module.scss index 2ac67cfae65..bb78d55fc10 100644 --- a/external/ag-website-shared/src/components/framework-text-animation/FrameworkTextAnimation.module.scss +++ b/external/ag-website-shared/src/components/framework-text-animation/FrameworkTextAnimation.module.scss @@ -2,6 +2,14 @@ .animatedWordsOuter { --word-height: 1.2em; + --word-duration: 340ms; + + // The incoming word waits for the outgoing one to fade rather than crossing through it. + --word-handover-delay: 140ms; + + // Not a full line: the line box is barely taller than the glyphs, so a word travelling + // that far would be sliced by `overflow: hidden` while still opaque. + --word-travel: 40%; display: inline-grid; height: var(--word-height); @@ -26,16 +34,28 @@ .animatedWord { display: inline-block; - animation: framework-word-in 0.25s cubic-bezier(0.6, 0.3, 0.5, 1.4); +} + +.wordIn { + animation: framework-word-in var(--word-duration) cubic-bezier(0.16, 1, 0.3, 1) var(--word-handover-delay) both; @media (prefers-reduced-motion: reduce) { animation: none; } } +.wordOut { + animation: framework-word-out var(--word-duration) cubic-bezier(0.4, 0, 1, 1) both; + + // Without the animation to lift it away, it would sit on top of the current word. + @media (prefers-reduced-motion: reduce) { + display: none; + } +} + @keyframes framework-word-in { from { - transform: translateY(-100%); + transform: translateY(var(--word-travel)); opacity: 0; } @@ -45,6 +65,23 @@ } } +@keyframes framework-word-out { + from { + transform: translateY(0); + opacity: 1; + } + + // Gone before the incoming word becomes legible, though still drifting upwards. + 55% { + opacity: 0; + } + + to { + transform: translateY(calc(var(--word-travel) * -1)); + opacity: 0; + } +} + .javascript { color: #f7df1e; // Yellow } diff --git a/external/ag-website-shared/src/components/framework-text-animation/FrameworkTextAnimation.tsx b/external/ag-website-shared/src/components/framework-text-animation/FrameworkTextAnimation.tsx index 3892e403425..f87b2053bee 100644 --- a/external/ag-website-shared/src/components/framework-text-animation/FrameworkTextAnimation.tsx +++ b/external/ag-website-shared/src/components/framework-text-animation/FrameworkTextAnimation.tsx @@ -27,32 +27,41 @@ const CYCLE_MS = 2500; const LONGEST_WORD = WORDS.reduce((longest, w) => (w.text.length > longest.length ? w.text : longest), ''); export const FrameworkTextAnimation: FunctionComponent = ({ prefix, suffix }) => { - const [wordIndex, setWordIndex] = useState(0); + // A monotonic count, not an index: the outgoing word stays derivable and `key` changes each swap. + const [cycle, setCycle] = useState(0); const prefixText = prefix ? `${prefix} ` : ''; const suffixText = suffix ? ` ${suffix}` : ''; useEffect(() => { const timeout = setTimeout(() => { - setWordIndex((index) => (index + 1) % WORDS.length); + setCycle((current) => current + 1); }, CYCLE_MS); return () => clearTimeout(timeout); - }, [wordIndex]); + }, [cycle]); - const word = WORDS[wordIndex]; + const word = WORDS[cycle % WORDS.length]; + // Nothing to animate away on first paint, so the server-rendered word settles without moving. + const outgoingWord = cycle > 0 ? WORDS[(cycle - 1) % WORDS.length] : undefined; - // One visible word at a time so the H1 reads as a single clean heading for crawlers - // and screen readers. The sizer is an aria-hidden copy of the widest word that stays - // in the DOM purely to reserve width — the two are stacked in the same grid cell, so - // the container width is fixed to the widest word and never shifts as words cycle. - // `key` retriggers the entry animation on swap. + // Only the current word is announced or server-rendered, so the H1 stays a single clean + // heading for crawlers and screen readers; the outgoing copy and the sizer are aria-hidden. return ( - + {outgoingWord && ( + + )} + 0 && styles.wordIn, word.className)}> {`${prefixText}${word.text}${suffixText}`} diff --git a/external/ag-website-shared/src/components/license-pricing/Licenses.module.scss b/external/ag-website-shared/src/components/license-pricing/Licenses.module.scss index 19612e72fdc..176985b4da2 100644 --- a/external/ag-website-shared/src/components/license-pricing/Licenses.module.scss +++ b/external/ag-website-shared/src/components/license-pricing/Licenses.module.scss @@ -72,7 +72,7 @@ p.name { font-size: 46px; - line-height: var(--text-lh-ultra-tight); + line-height: var(--text-lh-ratio-ultra-tight); margin-top: $spacing-size-1; margin-bottom: $spacing-size-2; @@ -136,7 +136,7 @@ p.name { height: 52px; padding-bottom: $spacing-size-3; font-size: 30px; - line-height: var(--text-lh-ultra-tight); + line-height: var(--text-lh-ratio-ultra-tight); font-weight: var(--text-bold); letter-spacing: -0.05em; @@ -164,7 +164,7 @@ p.name { margin-bottom: $spacing-size-4; p { - line-height: var(--text-lh-tight); + line-height: var(--text-lh-ratio-tight); } } diff --git a/external/ag-website-shared/src/components/license-pricing/SocialProof.module.scss b/external/ag-website-shared/src/components/license-pricing/SocialProof.module.scss index 3e7d06c44f6..f0fa4e91891 100644 --- a/external/ag-website-shared/src/components/license-pricing/SocialProof.module.scss +++ b/external/ag-website-shared/src/components/license-pricing/SocialProof.module.scss @@ -130,7 +130,7 @@ justify-content: space-between; width: 50%; font-size: var(--text-fs-base); - line-height: var(--text-lh-2xl); + line-height: var(--text-lh-base); @media screen and (max-width: $breakpoint-pricing-medium) { width: 100%; diff --git a/external/ag-website-shared/src/components/license-pricing/license-pricing.module.scss b/external/ag-website-shared/src/components/license-pricing/license-pricing.module.scss index a4105547b46..e385eac39b0 100644 --- a/external/ag-website-shared/src/components/license-pricing/license-pricing.module.scss +++ b/external/ag-website-shared/src/components/license-pricing/license-pricing.module.scss @@ -76,7 +76,7 @@ gap: $spacing-size-2 $spacing-size-4; margin: 0 auto; margin-top: $spacing-size-12; - line-height: var(--text-lh-tight); + line-height: var(--text-lh-ratio-tight); max-width: 600px; padding: $spacing-size-3; border: 1px solid var(--color-border-primary); @@ -182,7 +182,7 @@ } p { - line-height: var(--text-lh-tight); + line-height: var(--text-lh-ratio-tight); } } @@ -242,7 +242,7 @@ p { font-weight: var(--text-bold); - line-height: var(--text-lh-ultra-tight); + line-height: var(--text-lh-ratio-ultra-tight); } p:first-child { @@ -266,7 +266,7 @@ .price { font-size: 46px !important; // !important for font-size weirdness - line-height: var(--text-lh-tight); + line-height: 55px; font-weight: var(--text-bold); letter-spacing: -0.05em; } @@ -308,7 +308,7 @@ } ul { - line-height: var(--text-lh-tight); + line-height: var(--text-lh-ratio-tight); } li:not(:last-child) { @@ -465,7 +465,7 @@ } .trialLicenceHeader { - line-height: var(--text-lh-tight); + line-height: var(--text-lh-ratio-tight); } .trialLicenceCopyItem { diff --git a/external/ag-website-shared/src/components/link-icon/LinkIcon.tsx b/external/ag-website-shared/src/components/link-icon/LinkIcon.tsx index 06100bae933..41b02db4f68 100644 --- a/external/ag-website-shared/src/components/link-icon/LinkIcon.tsx +++ b/external/ag-website-shared/src/components/link-icon/LinkIcon.tsx @@ -1,6 +1,7 @@ import { $copyFrameworkAgnosticLinks } from '@ag-website-shared/components/dev-tools/stores/devToolsStore'; import { Icon } from '@ag-website-shared/components/icon/Icon'; import { getFrameworkRedirectUrl } from '@ag-website-shared/utils/getFrameworkRedirectUrl'; +import { replaceHistoryUrl } from '@ag-website-shared/utils/historyUrl'; import { useStoreSsr } from '@utils/hooks/useStoreSsr'; import classnames from 'classnames'; import { type AllHTMLAttributes, useEffect, useRef, useState } from 'react'; @@ -27,7 +28,7 @@ export function LinkIcon({ navigator.clipboard.writeText(redirectUrl ?? href); - history.replaceState({}, '', hash); + replaceHistoryUrl(hash); setLinkCopied(true); setlinkActive(true); diff --git a/external/ag-website-shared/src/components/major-table/MajorTable.module.scss b/external/ag-website-shared/src/components/major-table/MajorTable.module.scss index 24f89ec4a6a..87f4ec739bb 100644 --- a/external/ag-website-shared/src/components/major-table/MajorTable.module.scss +++ b/external/ag-website-shared/src/components/major-table/MajorTable.module.scss @@ -21,7 +21,7 @@ td { padding: $spacing-size-4 $spacing-size-2; - line-height: var(--text-lh-tight); + line-height: var(--text-lh-ratio-tight); } a span { diff --git a/external/ag-website-shared/src/components/page-styles/docs.module.scss b/external/ag-website-shared/src/components/page-styles/docs.module.scss index 0df2ed88fc8..2386baf9942 100644 --- a/external/ag-website-shared/src/components/page-styles/docs.module.scss +++ b/external/ag-website-shared/src/components/page-styles/docs.module.scss @@ -180,10 +180,11 @@ blockquote:where(:not(:last-child)) { font-size: var(--text-fs-lg); margin-bottom: $spacing-size-8; color: var(--color-text-secondary); - line-height: var(--text-lh-sm); + line-height: var(--text-lh-lg); :global(code) { - font-size: 0.8em; + font-size: var(--text-fs-base); + line-height: var(--text-lh-base); } } diff --git a/external/ag-website-shared/src/components/policies/policyPage.module.scss b/external/ag-website-shared/src/components/policies/policyPage.module.scss index 53c88a1b7c2..c6d36f19ee9 100644 --- a/external/ag-website-shared/src/components/policies/policyPage.module.scss +++ b/external/ag-website-shared/src/components/policies/policyPage.module.scss @@ -30,7 +30,7 @@ nav li:not(:last-child) { margin-bottom: $spacing-size-2; - line-height: var(--text-lh-tight); + line-height: var(--text-lh-ratio-tight); } } } diff --git a/external/ag-website-shared/src/components/roadmap/Roadmap.module.scss b/external/ag-website-shared/src/components/roadmap/Roadmap.module.scss index cd85f184856..41f8a42cb41 100644 --- a/external/ag-website-shared/src/components/roadmap/Roadmap.module.scss +++ b/external/ag-website-shared/src/components/roadmap/Roadmap.module.scss @@ -49,7 +49,7 @@ h1, h2 { - line-height: var(--text-lh-tight); + line-height: var(--text-lh-ratio-tight); color: var(--color-text-brand-primary); #{$selector-darkmode} & { diff --git a/external/ag-website-shared/src/components/search/SearchBox.module.scss b/external/ag-website-shared/src/components/search/SearchBox.module.scss index 8ebf34a7516..65050df8edc 100644 --- a/external/ag-website-shared/src/components/search/SearchBox.module.scss +++ b/external/ag-website-shared/src/components/search/SearchBox.module.scss @@ -54,7 +54,7 @@ input[type='search'].searchInput { span { text-transform: uppercase; font-weight: 600; - letter-spacing: 1.2pxs; + letter-spacing: 1.2px; color: var(--color-text-secondary); opacity: 0.6; font-size: 13px; diff --git a/external/ag-website-shared/src/components/theme-builder-grid/components/general/GetTheme.tsx b/external/ag-website-shared/src/components/theme-builder-grid/components/general/GetTheme.tsx index b819e7269c9..00e3e744144 100644 --- a/external/ag-website-shared/src/components/theme-builder-grid/components/general/GetTheme.tsx +++ b/external/ag-website-shared/src/components/theme-builder-grid/components/general/GetTheme.tsx @@ -1,4 +1,5 @@ import { UIPopupButton } from '@ag-website-shared/components/theme-builder/UIPopupButton'; +import { replaceHistoryUrl } from '@ag-website-shared/utils/historyUrl'; import styled from '@emotion/styled'; import { ThemeImportExportDialog } from './ThemeImportExportDialog'; @@ -16,7 +17,7 @@ export const GetThemeButton = () => ( initialOpen={hasImportHash()} onClose={() => { if (hasImportHash()) { - history.replaceState(null, '', window.location.pathname + window.location.search); + replaceHistoryUrl(window.location.pathname + window.location.search); } }} > diff --git a/external/ag-website-shared/src/components/theme-builder/ParamSearchSelector.tsx b/external/ag-website-shared/src/components/theme-builder/ParamSearchSelector.tsx index fe1610429c3..63f1b62e66b 100644 --- a/external/ag-website-shared/src/components/theme-builder/ParamSearchSelector.tsx +++ b/external/ag-website-shared/src/components/theme-builder/ParamSearchSelector.tsx @@ -354,11 +354,11 @@ const ItemContent = styled('div')` const ItemLabel = styled('div')` font-weight: var(--text-semibold); - line-height: var(--text-lh-tight); + line-height: var(--text-lh-ratio-tight); `; const ItemDocs = styled('div')` font-size: var(--text-fs-xs); - line-height: var(--text-lh-tight); + line-height: var(--text-lh-xs); color: var(--color-fg-secondary); `; diff --git a/external/ag-website-shared/src/design-system/_base.scss b/external/ag-website-shared/src/design-system/_base.scss index 3870dffa8e9..9a5e11b3803 100644 --- a/external/ag-website-shared/src/design-system/_base.scss +++ b/external/ag-website-shared/src/design-system/_base.scss @@ -14,7 +14,8 @@ html { font-family: var(--text-font-family); font-size: var(--text-fs-base); - line-height: var(--text-lh-base); + // Ratio: every element without its own line-height inherits this and must re-multiply per font-size. + line-height: var(--text-lh-ratio-base); font-weight: var(--text-regular); background-color: var(--color-bg-primary); color: var(--color-text-primary); diff --git a/external/ag-website-shared/src/design-system/_root.scss b/external/ag-website-shared/src/design-system/_root.scss index 2c4a385c9bf..a61337f6a60 100644 --- a/external/ag-website-shared/src/design-system/_root.scss +++ b/external/ag-website-shared/src/design-system/_root.scss @@ -61,8 +61,15 @@ --text-semibold: 600; --text-bold: 700; - --text-lh-tight: 1.2; - --text-lh-ultra-tight: 1; + // Ratios: a length is inherited as-is, so use these wherever the font-size is inherited, fluid or + // unknown. --text-lh- is paired, and valid only alongside its own --text-fs-. + --text-lh-ratio-base: 1.4; + --text-lh-ratio-tight: 1.2; + --text-lh-ratio-ultra-tight: 1; + + // Deprecated: ag-charts and ag-studio still use these names. Remove once both have migrated. + --text-lh-tight: var(--text-lh-ratio-tight); + --text-lh-ultra-tight: var(--text-lh-ratio-ultra-tight); --text-fs-2xs: 10px; --text-lh-2xs: 1.2; diff --git a/external/ag-website-shared/src/design-system/components/_containers.scss b/external/ag-website-shared/src/design-system/components/_containers.scss index c777f9894b6..767b35ca71a 100644 --- a/external/ag-website-shared/src/design-system/components/_containers.scss +++ b/external/ag-website-shared/src/design-system/components/_containers.scss @@ -66,7 +66,7 @@ position: relative; display: inline-block; padding: ($spacing-size-2 + $spacing-size-1) $spacing-size-1; - line-height: var(--text-lh-tight); + line-height: var(--text-lh-ratio-tight); color: var(--color-fg-tertiary); transition: color $transition-default-timing; cursor: pointer; diff --git a/external/ag-website-shared/src/design-system/core/_mixins.scss b/external/ag-website-shared/src/design-system/core/_mixins.scss index 2678d9be49a..379d3722fb3 100644 --- a/external/ag-website-shared/src/design-system/core/_mixins.scss +++ b/external/ag-website-shared/src/design-system/core/_mixins.scss @@ -28,7 +28,7 @@ td { padding: 0; - line-height: var(--text-lh-tight); + line-height: var(--text-lh-ratio-tight); font-weight: var(--text-bold); &:not(:last-child) { diff --git a/external/ag-website-shared/src/design-system/elements/_form-elements.scss b/external/ag-website-shared/src/design-system/elements/_form-elements.scss index ada25385045..6ec9f2be6bb 100644 --- a/external/ag-website-shared/src/design-system/elements/_form-elements.scss +++ b/external/ag-website-shared/src/design-system/elements/_form-elements.scss @@ -64,7 +64,7 @@ input[type='number'], textarea { &#{$selector-exclude-grid} { padding: math.div(6em, 16) math.div(12em, 16); - line-height: var(--text-lh-base); + line-height: var(--text-lh-ratio-base); border-radius: var(--radius-sm); border: 1px solid var(--color-input-border); background-color: var(--color-bg-primary); @@ -234,7 +234,7 @@ select#{$selector-exclude-grid} { appearance: none; padding: math.div(6em, 16) math.div(36em, 16) math.div(6em, 16) math.div(12em, 16); font-size: var(--text-fs-base); - line-height: var(--text-lh-base); + line-height: var(--text-lh-ratio-base); border-radius: var(--radius-sm); background-color: var(--color-bg-primary); background-image: var(--svg-chevron-down); diff --git a/external/ag-website-shared/src/markdown-pages/buildFrameworkRedirectMarkdown.test.ts b/external/ag-website-shared/src/markdown-pages/buildFrameworkRedirectMarkdown.test.ts new file mode 100644 index 00000000000..6dadacce5da --- /dev/null +++ b/external/ag-website-shared/src/markdown-pages/buildFrameworkRedirectMarkdown.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; + +import { buildFrameworkRedirectMarkdown } from './buildFrameworkRedirectMarkdown'; + +const SITE_ROOT = 'https://www.ag-grid.com/'; + +const DESTINATIONS = [ + { label: 'React', url: '/studio/react/licence-install/' }, + { label: 'Angular', url: '/studio/angular/licence-install/' }, +]; + +describe('buildFrameworkRedirectMarkdown', () => { + const output = buildFrameworkRedirectMarkdown({ + title: 'AG Studio: Licensing', + description: 'Installing Your Licence Key', + heading: 'AG Studio Licensing', + destinations: DESTINATIONS, + siteRoot: SITE_ROOT, + }); + + it('emits the page metadata as frontmatter, then the heading as H1', () => { + expect(output.startsWith('---\n')).toBe(true); + expect(output).toContain('title: "AG Studio: Licensing"'); + expect(output).toContain('description: "Installing Your Licence Key"'); + expect(output).toContain('# AG Studio Licensing'); + }); + + it('spells out every framework destination, since a reader cannot be redirected', () => { + for (const { label, url } of DESTINATIONS) { + expect(output).toContain(`- [${label}](https://www.ag-grid.com${url})`); + } + }); + + it('ends with a single trailing newline', () => { + expect(output.endsWith('\n')).toBe(true); + expect(output.endsWith('\n\n')).toBe(false); + }); +}); diff --git a/external/ag-website-shared/src/utils/extraCodeSnippets.ts b/external/ag-website-shared/src/utils/extraCodeSnippets.ts index 83d526325a5..1252a93a125 100644 --- a/external/ag-website-shared/src/utils/extraCodeSnippets.ts +++ b/external/ag-website-shared/src/utils/extraCodeSnippets.ts @@ -16,6 +16,9 @@ export const TEAR_DOWN_END = '/** TEAR DOWN END **/'; export const TEST_ID_START = '/** ENABLE AG-TEST-ID START **/'; export const TEST_ID_END = '/** ENABLE AG-TEST-ID END **/'; +export const AI_TELEMETRY_START = '/** AI TELEMETRY START **/'; +export const AI_TELEMETRY_END = '/** AI TELEMETRY END **/'; + export const DARK_MODE_REGEX = getSnippetRegex({ startDelimiter: DARK_MODE_START, endDelimiter: DARK_MODE_END }); export const E2E_THEME_REGEX = getSnippetRegex({ startDelimiter: E2E_THEME_START, endDelimiter: E2E_THEME_END }); export const CONSOLE_LOG_REGEX = getSnippetRegex({ startDelimiter: CONSOLE_LOG_START, endDelimiter: CONSOLE_LOG_END }); @@ -25,6 +28,10 @@ export const DARK_INTEGRATED_REGEX = getSnippetRegex({ }); export const TEAR_DOWN_REGEX = getSnippetRegex({ startDelimiter: TEAR_DOWN_START, endDelimiter: TEAR_DOWN_END }); export const TEST_ID_REGEX = getSnippetRegex({ startDelimiter: TEST_ID_START, endDelimiter: TEST_ID_END }); +export const AI_TELEMETRY_REGEX = getSnippetRegex({ + startDelimiter: AI_TELEMETRY_START, + endDelimiter: AI_TELEMETRY_END, +}); /** * Return a regex that matches a snippet of text between specified delimiters. diff --git a/external/ag-website-shared/src/utils/historyUrl.ts b/external/ag-website-shared/src/utils/historyUrl.ts new file mode 100644 index 00000000000..b00ff093a80 --- /dev/null +++ b/external/ag-website-shared/src/utils/historyUrl.ts @@ -0,0 +1,18 @@ +/** + * Rewrite the current URL, or attach page state to the current entry, without navigating. + * + * Astro's `` tracks its position in session history through `history.state`, + * and its popstate handler bails out when that state is missing. A direct + * `history.replaceState(null, '', url)` therefore disables back and forward for the whole + * page: the browser moves the URL, but the router never swaps the document. A partial state + * such as `{}` survives the null check but loses the index that distinguishes a back + * traversal from a forward one. + * + * Pass `statePatch` to store page state alongside the router's own bookkeeping - it spreads + * unknown keys through untouched, so both survive in the same entry. Omit `url` to leave the + * address bar alone and patch state only. + */ +export function replaceHistoryUrl(url?: string | URL, statePatch?: Record): void { + const state = statePatch ? { ...history.state, ...statePatch } : history.state; + history.replaceState(state, '', url); +} diff --git a/package.json b/package.json index 8aefa469df2..921810909b9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "license": "MIT", "scripts": { "compressVideo": "tsx external/ag-website-shared/scripts/compress-video", diff --git a/packages/ag-grid-angular/package.json b/packages/ag-grid-angular/package.json index 1f7070c811f..84f607e65dc 100644 --- a/packages/ag-grid-angular/package.json +++ b/packages/ag-grid-angular/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid-angular", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "description": "AG Grid Angular Component", "scripts": { "clean": "rimraf dist", @@ -15,7 +15,7 @@ "module": "./dist/ag-grid-angular/fesm2022/ag-grid-angular.mjs", "typings": "./dist/ag-grid-angular/index.d.ts", "dependencies": { - "ag-grid-community": "36.1.0-beta.20260818.1531", + "ag-grid-community": "36.1.0-beta.20260819.1156", "@angular/animations": "^20.3.25", "@angular/common": "^20.3.25", "@angular/compiler": "^20.3.25", @@ -27,7 +27,7 @@ "zone.js": "~0.15.1" }, "devDependencies": { - "ag-grid-community": "36.1.0-beta.20260818.1531", + "ag-grid-community": "36.1.0-beta.20260819.1156", "@angular/build": "^20.3.25", "@angular/cli": "^20.3.25", "@angular/forms": "^20.3.25", diff --git a/packages/ag-grid-angular/projects/ag-grid-angular/package.json b/packages/ag-grid-angular/projects/ag-grid-angular/package.json index d6b200cacfe..ee3d1dbf91c 100644 --- a/packages/ag-grid-angular/projects/ag-grid-angular/package.json +++ b/packages/ag-grid-angular/projects/ag-grid-angular/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid-angular", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "description": "AG Grid Angular Component", "license": "MIT", "peerDependencies": { @@ -8,7 +8,7 @@ "@angular/core": ">= 20.0.0" }, "dependencies": { - "ag-grid-community": "36.1.0-beta.20260818.1531", + "ag-grid-community": "36.1.0-beta.20260819.1156", "tslib": "^2.8.1" }, "repository": { diff --git a/packages/ag-grid-community/package.json b/packages/ag-grid-community/package.json index 58a821eb543..b61dd15300b 100644 --- a/packages/ag-grid-community/package.json +++ b/packages/ag-grid-community/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid-community", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "description": "Advanced Data Grid / Data Table supporting Javascript / Typescript / React / Angular / Vue", "main": "./dist/package/main.cjs.js", "types": "./dist/types/src/main.d.ts", @@ -119,7 +119,7 @@ ], "homepage": "https://www.ag-grid.com/", "dependencies": { - "ag-stack": "36.1.0-beta.20260818.1531", + "ag-stack": "36.1.0-beta.20260819.1156", "ag-charts-types": "14.1.0-beta.20260816" }, "devDependencies": { diff --git a/packages/ag-grid-community/src/version.ts b/packages/ag-grid-community/src/version.ts index e76d311e3ee..6219d4e35d3 100644 --- a/packages/ag-grid-community/src/version.ts +++ b/packages/ag-grid-community/src/version.ts @@ -1,2 +1,2 @@ // DO NOT UPDATE MANUALLY: Generated from script during build time -export const VERSION = '36.1.0-beta.20260818.1531'; +export const VERSION = '36.1.0-beta.20260819.1156'; diff --git a/packages/ag-grid-enterprise/package.json b/packages/ag-grid-enterprise/package.json index a67cd1bc779..7a3de81a7ed 100644 --- a/packages/ag-grid-enterprise/package.json +++ b/packages/ag-grid-enterprise/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid-enterprise", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "description": "Advanced Data Grid / Data Table supporting Javascript / Typescript / React / Angular / Vue", "main": "./dist/package/main.cjs.js", "types": "./dist/types/src/main.d.ts", @@ -113,8 +113,8 @@ ], "homepage": "https://www.ag-grid.com/", "dependencies": { - "ag-stack": "36.1.0-beta.20260818.1531", - "ag-grid-community": "36.1.0-beta.20260818.1531" + "ag-stack": "36.1.0-beta.20260819.1156", + "ag-grid-community": "36.1.0-beta.20260819.1156" }, "optionalDependencies": { "ag-charts-community": "14.1.0-beta.20260816", diff --git a/packages/ag-grid-enterprise/src/advancedFilter/advancedFilterExpressionService.ts b/packages/ag-grid-enterprise/src/advancedFilter/advancedFilterExpressionService.ts index 1667bce2cec..894e8b8432d 100644 --- a/packages/ag-grid-enterprise/src/advancedFilter/advancedFilterExpressionService.ts +++ b/packages/ag-grid-enterprise/src/advancedFilter/advancedFilterExpressionService.ts @@ -1,4 +1,11 @@ -import { _exists, _parseBigIntOrNull, _parseDateTimeFromString, _serialiseDate, _toStringOrNull } from 'ag-stack'; +import { + _exists, + _hasOwn, + _parseBigIntOrNull, + _parseDateTimeFromString, + _serialiseDate, + _toStringOrNull, +} from 'ag-stack'; import type { AgColumn, @@ -31,6 +38,15 @@ import { } from './filterExpressionOperators'; import { getBigIntParser } from './filterExpressionUtils'; +/** The `filterParams` an Advanced Filter evaluator honours; the rest are column-filter UI concerns. */ +const COPIED_FILTER_PARAMS: (keyof FilterExpressionEvaluatorParams)[] = [ + 'caseSensitive', + 'includeBlanksInEquals', + 'includeBlanksInNotEqual', + 'includeBlanksInLessThan', + 'includeBlanksInGreaterThan', +]; + export class AdvancedFilterExpressionService extends BeanStub implements NamedBean { beanName = 'advFilterExpSvc' as const; @@ -269,9 +285,16 @@ export class AdvancedFilterExpressionService extends BeanStub implements NamedBe return entries; } - public getOperatorAutocompleteEntries(column: AgColumn, baseCellDataType: BaseCellDataType): AutocompleteEntry[] { - const activeOperators = this.getActiveOperators(column); - return this.getDataTypeExpressionOperator(baseCellDataType)!.getEntries(activeOperators); + /** The options the column offers: those of the data type that `filterParams.filterOptions` names, or all. */ + public getOperatorAutocompleteEntries( + column: AgColumn | null | undefined, + baseCellDataType?: BaseCellDataType + ): AutocompleteEntry[] { + const operatorForType = this.getDataTypeExpressionOperator(baseCellDataType); + if (!operatorForType) { + return []; + } + return operatorForType.getEntries(column ? this.getActiveOperators(column) : undefined); } public getJoinOperatorAutocompleteEntries(): AutocompleteEntry[] { @@ -293,7 +316,9 @@ export class AdvancedFilterExpressionService extends BeanStub implements NamedBe baseCellDataType?: BaseCellDataType, operator?: string ): FilterExpressionOperator | undefined { - return this.getDataTypeExpressionOperator(baseCellDataType)?.operators?.[operator!]; + const operators = this.getDataTypeExpressionOperator(baseCellDataType)?.operators; + // A model `type` such as `toString` must not resolve to an inherited member. + return operators && _hasOwn(operators, operator!) ? operators[operator!] : undefined; } public getExpressionJoinOperators(): { AND: string; OR: string } { @@ -364,14 +389,13 @@ export class AdvancedFilterExpressionService extends BeanStub implements NamedBe } const { filterParams } = column.colDef; if (filterParams) { - ['caseSensitive', 'includeBlanksInEquals', 'includeBlanksInLessThan', 'includeBlanksInGreaterThan'].forEach( - (param: keyof FilterExpressionEvaluatorParams) => { - const paramValue = filterParams[param]; - if (paramValue) { - params[param] = paramValue; - } + for (let i = 0, len = COPIED_FILTER_PARAMS.length; i < len; ++i) { + const param = COPIED_FILTER_PARAMS[i]; + const paramValue = filterParams[param]; + if (paramValue) { + params[param] = paramValue; } - ); + } } this.expressionEvaluatorParams[colId] = params; diff --git a/packages/ag-grid-enterprise/src/advancedFilter/colFilterExpressionParser.ts b/packages/ag-grid-enterprise/src/advancedFilter/colFilterExpressionParser.ts index cda76fa88dc..f3500f28aaa 100644 --- a/packages/ag-grid-enterprise/src/advancedFilter/colFilterExpressionParser.ts +++ b/packages/ag-grid-enterprise/src/advancedFilter/colFilterExpressionParser.ts @@ -116,29 +116,32 @@ class OperatorParser implements Parser { public expectedNumOperands: number = 0; private operator: string = ''; private parsedOperator: string; + /** Last character of the resolved name; set once the region is settled. */ + private matchEndPosition: number | undefined; constructor( private readonly params: FilterExpressionParserParams, public readonly startPosition: number, - private readonly baseCellDataType: BaseCellDataType + private readonly baseCellDataType: BaseCellDataType, + private readonly column: AgColumn | null | undefined ) {} public parse(char: string, position: number): boolean | undefined { - if (char === ' ' || char === ')') { - const isMatch = this.parseOperator(false, position - 1); - if (isMatch) { - return true; - } else { + if (this.matchEndPosition == null) { + const isTerminator = char === ' ' || char === ')'; + if (!isTerminator || !this.parseOperator(false, position - 1)) { this.operator += char; + return undefined; } - } else { - this.operator += char; } - return undefined; + // A resolved name may run past the terminator that settled it, so the rest of it is consumed as read. + return position <= this.matchEndPosition! ? undefined : true; } public complete(position: number): void { - this.parseOperator(true, position); + if (this.matchEndPosition == null) { + this.parseOperator(true, position); + } } public getValidationError(): FilterExpressionValidationError | null { @@ -159,20 +162,53 @@ class OperatorParser implements Parser { return this.parsedOperator; } + /** + * Greedy, over the options the column offers: the longest name spelled here wins, so one that another name + * starts with - or one containing a terminator - resolves. + */ private parseOperator(fromComplete: boolean, endPosition: number): boolean { - const operatorForType = this.params.advFilterExpSvc.getDataTypeExpressionOperator(this.baseCellDataType)!; - const parsedOperator = operatorForType.findOperator(this.operator); + const { params, startPosition } = this; + const expression = params.expression; + const advFilterExpSvc = params.advFilterExpSvc; + const entries = advFilterExpSvc.getOperatorAutocompleteEntries(this.column, this.baseCellDataType); this.endPosition = endPosition; - if (parsedOperator) { - this.parsedOperator = parsedOperator; - const operator = operatorForType.operators[parsedOperator]; + + const minLength = endPosition - startPosition + 1; + const partialSearchValue = expression.slice(startPosition, endPosition + 1).toLocaleLowerCase() + ' '; + let matchedOperator: string | undefined; + let matchedLength = 0; + let isPartialMatch = false; + for (let i = 0, len = entries.length; i < len; ++i) { + const entry = entries[i]; + const displayValue = entry.displayValue ?? ''; + // Lengths come from the name as written: lower-casing can change them, `İ` becoming two units. + const lowerCaseDisplayValue = displayValue.toLocaleLowerCase(); + if ( + displayValue.length > matchedLength && + displayValue.length >= minLength && + isNameAt(expression, startPosition, displayValue, lowerCaseDisplayValue) + ) { + matchedOperator = entry.key; + matchedLength = displayValue.length; + } + if (lowerCaseDisplayValue.startsWith(partialSearchValue)) { + isPartialMatch = true; + } + } + + if (matchedOperator) { + const matchEndPosition = startPosition + matchedLength - 1; + this.parsedOperator = matchedOperator; + this.endPosition = matchEndPosition; + this.matchEndPosition = matchEndPosition; + const operator = advFilterExpSvc.getExpressionOperator(this.baseCellDataType, matchedOperator)!; this.expectedNumOperands = operator.numOperands; const operatorDisplayValue = operator.displayValue; - checkAndUpdateExpression(this.params, this.operator, operatorDisplayValue, endPosition); + const userValue = expression.slice(startPosition, matchEndPosition + 1); + checkAndUpdateExpression(params, userValue, operatorDisplayValue, matchEndPosition); this.operator = operatorDisplayValue; return true; } - const isPartialMatch = parsedOperator === null; if (fromComplete || !isPartialMatch) { this.valid = false; } @@ -180,6 +216,24 @@ class OperatorParser implements Parser { } } +/** + * A name only resolves where a terminator or the expression end follows it: were it allowed to run into the + * next character, the caller would drop that character and the text would name an option it does not spell. + */ +function isNameAt( + expression: string, + startPosition: number, + displayValue: string, + lowerCaseDisplayValue: string +): boolean { + const endPosition = startPosition + displayValue.length; + const nextChar = expression[endPosition]; + return ( + (nextChar === undefined || nextChar === ' ' || nextChar === ')') && + expression.slice(startPosition, endPosition).toLocaleLowerCase() === lowerCaseDisplayValue + ); +} + class OperandParser implements Parser { public readonly type = 'operand'; @@ -360,7 +414,12 @@ export class ColFilterExpressionParser { this.columnParser = new ColumnParser(this.params, i); parser = this.columnParser; } else if (!this.operatorParser) { - this.operatorParser = new OperatorParser(this.params, i, this.columnParser.baseCellDataType); + this.operatorParser = new OperatorParser( + this.params, + i, + this.columnParser.baseCellDataType, + this.columnParser.column + ); parser = this.operatorParser; } else { this.operandParser = new OperandParser( diff --git a/packages/ag-grid-enterprise/src/advancedFilter/filterExpressionOperators.ts b/packages/ag-grid-enterprise/src/advancedFilter/filterExpressionOperators.ts index cd761f78a90..fb062b5f16c 100644 --- a/packages/ag-grid-enterprise/src/advancedFilter/filterExpressionOperators.ts +++ b/packages/ag-grid-enterprise/src/advancedFilter/filterExpressionOperators.ts @@ -8,6 +8,7 @@ import type { AutocompleteEntry } from './autocomplete/autocompleteParams'; export interface FilterExpressionEvaluatorParams { caseSensitive?: boolean; includeBlanksInEquals?: boolean; + includeBlanksInNotEqual?: boolean; includeBlanksInLessThan?: boolean; includeBlanksInGreaterThan?: boolean; valueConverter: (value: TValue, node: IRowNode) => ConvertedTValue; @@ -32,7 +33,6 @@ export interface DataTypeFilterExpressionOperators; }; getEntries(activeOperators?: string[]): AutocompleteEntry[]; - findOperator(displayValue: string): string | null | undefined; } export abstract class FilterExpressionOperators implements Record< @@ -115,10 +115,6 @@ export class TextFilterExpressionOperators implements DataTypeF return getEntries(this.operators, activeOperators); } - public findOperator(displayValue: string): string | null | undefined { - return findMatch(displayValue, this.operators, ({ displayValue }) => displayValue); - } - private initOperators(): void { const { translate } = this.params; this.operators = { @@ -206,10 +202,6 @@ export class ScalarFilterExpressionOperators< return getEntries(this.operators, activeOperators); } - public findOperator(displayValue: string): string | null | undefined { - return findMatch(displayValue, this.operators, ({ displayValue }) => displayValue); - } - private initOperators(): void { const { translate, equals } = this.params; this.operators = { @@ -234,8 +226,9 @@ export class ScalarFilterExpressionOperators< node, params, operand1!, - !!params.includeBlanksInEquals, - (v, o) => !equals(v, o) + !!params.includeBlanksInNotEqual, + (v, o) => !equals(v, o), + true ), numOperands: 1, }, @@ -310,12 +303,19 @@ export class ScalarFilterExpressionOperators< params: FilterExpressionEvaluatorParams, operand: ConvertedTValue, nullsMatch: boolean, - expression: (value: ConvertedTValue, operand: ConvertedTValue) => boolean + expression: (value: ConvertedTValue, operand: ConvertedTValue) => boolean, + isNegated?: boolean ): boolean { if (value == null) { return nullsMatch; } - return expression(params.valueConverter(value, node), operand); + const convertedValue = params.valueConverter(value, node); + // A value the data type cannot read is nothing to compare against, as the column filter's own validity + // gate decides: it matches no comparison, and so matches every negation of one. + if (convertedValue == null) { + return !!isNegated; + } + return expression(convertedValue, operand); } } @@ -330,10 +330,6 @@ export class BooleanFilterExpressionOperators implements DataTypeFilterExpressio return getEntries(this.operators, activeOperators); } - public findOperator(displayValue: string): string | null | undefined { - return findMatch(displayValue, this.operators, ({ displayValue }) => displayValue); - } - private initOperators(): void { const { translate } = this.params; this.operators = { diff --git a/packages/ag-grid-enterprise/src/columnToolPanel/agPrimaryColsList.ts b/packages/ag-grid-enterprise/src/columnToolPanel/agPrimaryColsList.ts index e5d0349ada6..0695257f152 100644 --- a/packages/ag-grid-enterprise/src/columnToolPanel/agPrimaryColsList.ts +++ b/packages/ag-grid-enterprise/src/columnToolPanel/agPrimaryColsList.ts @@ -413,7 +413,7 @@ export class AgPrimaryColsList extends Component { const removeFunc = item.removeEventListener.bind(item, 'expandedChanged', columnExpandedListener); this.destroyColumnItemFuncs.push(removeFunc); }; - const colNames = this.beans.colNames; + const beans = this.beans; const recursivelyBuild = ( tree: (AgColumn | AgProvidedColumnGroup)[], @@ -445,9 +445,8 @@ export class AgPrimaryColsList extends Component { return; } - const displayName = colNames.getDisplayNameForProvidedColumnGroup(null, columnGroup, 'columnToolPanel'); const item: ColumnModelItem = new ColumnModelItem( - displayName, + beans, columnGroup, depth, true, @@ -467,9 +466,7 @@ export class AgPrimaryColsList extends Component { return; } - const displayName = colNames.getDisplayNameForColumn(column, 'columnToolPanel'); - - parentList.push(new ColumnModelItem(displayName, column, depth)); + parentList.push(new ColumnModelItem(beans, column, depth)); }; this.destroyColumnTree(); diff --git a/packages/ag-grid-enterprise/src/columnToolPanel/columnModelItem.ts b/packages/ag-grid-enterprise/src/columnToolPanel/columnModelItem.ts index 674e9986ab2..f1960d4933b 100644 --- a/packages/ag-grid-enterprise/src/columnToolPanel/columnModelItem.ts +++ b/packages/ag-grid-enterprise/src/columnToolPanel/columnModelItem.ts @@ -1,6 +1,6 @@ import { LocalEventService } from 'ag-stack'; -import type { AgColumn, AgProvidedColumnGroup, IEventEmitter, IEventListener } from 'ag-grid-community'; +import type { AgColumn, AgProvidedColumnGroup, BeanCollection, IEventEmitter, IEventListener } from 'ag-grid-community'; type ColumnModelItemEvent = 'expandedChanged'; export class ColumnModelItem implements IEventEmitter { @@ -13,8 +13,13 @@ export class ColumnModelItem implements IEventEmitter { private _expanded: boolean | undefined; public passesFilter: boolean; + private cachedName: string | null = null; + /** -1 never matches a real `colDefsVersion`, so the first read always resolves. */ + private cachedNameVersion: number = -1; + private cachedNameOverride: string | null = null; + constructor( - public readonly displayName: string | null, + private readonly beans: BeanCollection, columnOrGroup: AgColumn | AgProvidedColumnGroup, public readonly depth: number, public readonly group = false, @@ -29,6 +34,28 @@ export class ColumnModelItem implements IEventEmitter { } } + // OPTIMIZATION: resolved on read so a name change is picked up without rebuilding the tree, but + // memoised against its only two inputs so a user `headerValueGetter` runs once per change rather + // than once per read - search filtering and row recycling both read this for every item. + public get displayName(): string | null { + const { beans, group, columnGroup, column } = this; + const { colNames, colModel } = beans; + const version = colModel.colDefsVersion; + const override = group + ? (colModel.groupHeaderNameOverrides.get(columnGroup.groupId) ?? null) + : column.headerNameOverride; + + if (version !== this.cachedNameVersion || override !== this.cachedNameOverride) { + this.cachedNameVersion = version; + this.cachedNameOverride = override; + this.cachedName = group + ? colNames.getDisplayNameForProvidedColumnGroup(null, columnGroup, 'columnToolPanel') + : colNames.getDisplayNameForColumn(column, 'columnToolPanel'); + } + + return this.cachedName; + } + public get expanded(): boolean { return !!this._expanded; } diff --git a/packages/ag-grid-enterprise/src/columnToolPanel/toolPanelColumnComp.ts b/packages/ag-grid-enterprise/src/columnToolPanel/toolPanelColumnComp.ts index e7dfb721322..6586212e3f1 100644 --- a/packages/ag-grid-enterprise/src/columnToolPanel/toolPanelColumnComp.ts +++ b/packages/ag-grid-enterprise/src/columnToolPanel/toolPanelColumnComp.ts @@ -48,7 +48,6 @@ export class ToolPanelColumnComp extends Component { public readonly column: AgColumn; public readonly columnDepth: number; private eDragHandle: Element; - private displayName: string | null; private processingColumnStateChange = false; private tooltipFeature?: TooltipFeature; private labelRendererFeature?: ColumnSelectionLabelRendererFeature; @@ -63,10 +62,13 @@ export class ToolPanelColumnComp extends Component { private readonly source: ColumnSelectionPanelSource ) { super(); - const { column, depth, displayName } = modelItem; + const { column, depth } = modelItem; this.column = column; this.columnDepth = depth; - this.displayName = displayName; + } + + private get displayName(): string | null { + return this.modelItem.displayName; } public postConstruct(): void { @@ -217,12 +219,10 @@ export class ToolPanelColumnComp extends Component { } private onColDefChanged(): void { - const displayName = this.beans.colNames.getDisplayNameForColumn(this.column, 'columnToolPanel'); - this.displayName = displayName; if (this.labelRendererFeature) { this.labelRendererFeature.refresh(); } else { - this.eLabel.textContent = displayName; + this.eLabel.textContent = this.displayName; } this.refreshAriaLabel(); } diff --git a/packages/ag-grid-enterprise/src/columnToolPanel/toolPanelColumnGroupComp.ts b/packages/ag-grid-enterprise/src/columnToolPanel/toolPanelColumnGroupComp.ts index ad292ace338..258ff986818 100644 --- a/packages/ag-grid-enterprise/src/columnToolPanel/toolPanelColumnGroupComp.ts +++ b/packages/ag-grid-enterprise/src/columnToolPanel/toolPanelColumnGroupComp.ts @@ -68,7 +68,6 @@ export class ToolPanelColumnGroupComp extends Component { public readonly columnGroup: AgProvidedColumnGroup; public readonly columnDepth: number; - private displayName: string | null; private processingColumnStateChange = false; private tooltipFeature?: TooltipFeature; private labelRendererFeature?: ColumnSelectionLabelRendererFeature; @@ -82,10 +81,13 @@ export class ToolPanelColumnGroupComp extends Component { private readonly source: ColumnSelectionPanelSource ) { super(); - const { columnGroup, depth, displayName } = modelItem; + const { columnGroup, depth } = modelItem; this.columnGroup = columnGroup; this.columnDepth = depth; - this.displayName = displayName; + } + + private get displayName(): string | null { + return this.modelItem.displayName; } public postConstruct(): void { @@ -359,11 +361,6 @@ export class ToolPanelColumnGroupComp extends Component { if (event.columnGroup && event.columnGroup.getGroupId() !== this.columnGroup.groupId) { return; } - this.displayName = this.beans.colNames.getDisplayNameForProvidedColumnGroup( - null, - this.columnGroup, - 'columnToolPanel' - ); if (this.labelRendererFeature) { this.labelRendererFeature.refresh(); } else { diff --git a/packages/ag-grid-enterprise/src/version.ts b/packages/ag-grid-enterprise/src/version.ts index e76d311e3ee..6219d4e35d3 100644 --- a/packages/ag-grid-enterprise/src/version.ts +++ b/packages/ag-grid-enterprise/src/version.ts @@ -1,2 +1,2 @@ // DO NOT UPDATE MANUALLY: Generated from script during build time -export const VERSION = '36.1.0-beta.20260818.1531'; +export const VERSION = '36.1.0-beta.20260819.1156'; diff --git a/packages/ag-grid-react/package.json b/packages/ag-grid-react/package.json index 41e7b400030..ccf4baffd8e 100644 --- a/packages/ag-grid-react/package.json +++ b/packages/ag-grid-react/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid-react", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "description": "AG Grid React Component", "main": "./dist/package/index.cjs.js", "types": "./dist/types/src/index.d.ts", @@ -31,7 +31,7 @@ "devDependencies": { "@babel/runtime": "^7.29.2", "prop-types": "^15.6.2", - "ag-grid-community": "36.1.0-beta.20260818.1531", + "ag-grid-community": "36.1.0-beta.20260819.1156", "@babel/plugin-proposal-throw-expressions": "^7.27.1", "@babel/preset-typescript": "^7.28.5", "@types/react": "~18.3.26", @@ -44,7 +44,7 @@ }, "dependencies": { "prop-types": "^15.8.1", - "ag-grid-community": "36.1.0-beta.20260818.1531" + "ag-grid-community": "36.1.0-beta.20260819.1156" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", diff --git a/packages/ag-grid-vue3/package.json b/packages/ag-grid-vue3/package.json index e4dd7bec762..369101dbb9d 100644 --- a/packages/ag-grid-vue3/package.json +++ b/packages/ag-grid-vue3/package.json @@ -1,7 +1,7 @@ { "name": "ag-grid-vue3", "description": "AG Grid Vue 3 Component", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "author": "Sean Landsman ", "license": "MIT", "files": [ @@ -44,7 +44,7 @@ "build-only:watch": "vite build --watch" }, "dependencies": { - "ag-grid-community": "36.1.0-beta.20260818.1531" + "ag-grid-community": "36.1.0-beta.20260819.1156" }, "devDependencies": { "vue": "^3.5.32", diff --git a/packages/ag-stack/package.json b/packages/ag-stack/package.json index 30c38cda478..ade2bbb0208 100644 --- a/packages/ag-stack/package.json +++ b/packages/ag-stack/package.json @@ -1,6 +1,6 @@ { "name": "ag-stack", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "description": "Advanced Data Grid / Data Table supporting Javascript / Typescript / React / Angular / Vue", "main": "./dist/package/main.cjs.js", "types": "./dist/types/src/main.d.ts", diff --git a/packages/ag-stack/src/version.ts b/packages/ag-stack/src/version.ts index e76d311e3ee..6219d4e35d3 100644 --- a/packages/ag-stack/src/version.ts +++ b/packages/ag-stack/src/version.ts @@ -1,2 +1,2 @@ // DO NOT UPDATE MANUALLY: Generated from script during build time -export const VERSION = '36.1.0-beta.20260818.1531'; +export const VERSION = '36.1.0-beta.20260819.1156'; diff --git a/plugins/ag-grid-generate-code-reference-files/package.json b/plugins/ag-grid-generate-code-reference-files/package.json index 153bb562d4d..4a3e3498196 100644 --- a/plugins/ag-grid-generate-code-reference-files/package.json +++ b/plugins/ag-grid-generate-code-reference-files/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid-generate-code-reference-files", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "private": true, "dependencies": { "ag-shared": "0.0.1", diff --git a/plugins/ag-grid-generate-example-files/package.json b/plugins/ag-grid-generate-example-files/package.json index 01d7949abbe..64e27dcb00a 100644 --- a/plugins/ag-grid-generate-example-files/package.json +++ b/plugins/ag-grid-generate-example-files/package.json @@ -1,10 +1,10 @@ { "name": "ag-grid-generate-example-files", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "private": true, "dependencies": { "ag-shared": "0.0.1", - "ag-grid-community": "36.1.0-beta.20260818.1531", + "ag-grid-community": "36.1.0-beta.20260819.1156", "glob": "^11.1.0", "typescript": "~5.8.3", "cheerio": "^1.2.0", diff --git a/plugins/ag-grid-task-autogen/package.json b/plugins/ag-grid-task-autogen/package.json index d940fb59366..a351eac61dd 100644 --- a/plugins/ag-grid-task-autogen/package.json +++ b/plugins/ag-grid-task-autogen/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid-task-autogen", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "private": true, "dependencies": { "@nx/devkit": "20.8.4", diff --git a/testing/accessibility/package.json b/testing/accessibility/package.json index 895d1b6f6e9..0c04e5b0f18 100644 --- a/testing/accessibility/package.json +++ b/testing/accessibility/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid-accessibility", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "scripts": { "download-examples": "curl --retry 5 -retry-all-errors https://grid-staging.ag-grid.com/debug/all-examples.json > ./all-examples.json", "download-examples-local": "curl https://localhost:4610/debug/all-examples.json > ./all-examples.json", @@ -18,9 +18,9 @@ "@angular/platform-browser": "^20.3.25", "@angular/platform-browser-dynamic": "^20.3.25", "@angular/router": "^20.3.25", - "ag-grid-angular": "36.1.0-beta.20260818.1531", - "ag-grid-community": "36.1.0-beta.20260818.1531", - "ag-grid-enterprise": "36.1.0-beta.20260818.1531", + "ag-grid-angular": "36.1.0-beta.20260819.1156", + "ag-grid-community": "36.1.0-beta.20260819.1156", + "ag-grid-enterprise": "36.1.0-beta.20260819.1156", "ag-charts-community": "14.1.0-beta.20260816", "ag-charts-enterprise": "14.1.0-beta.20260816", "rxjs": "~7.8.2", diff --git a/testing/ag-test-utils/package.json b/testing/ag-test-utils/package.json index 5229f2aaaff..135627349e2 100644 --- a/testing/ag-test-utils/package.json +++ b/testing/ag-test-utils/package.json @@ -1,6 +1,6 @@ { "name": "ag-test-utils", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "private": true, "type": "module", "description": "Harnesses and assertions shared by the behavioural suite: grid lifecycle, GridRows/GridColumns snapshots, DOM widgets and polyfills. Test-only; never published and never imported by grid source.", diff --git a/testing/ag-test-utils/src/filters/advancedFilterBuilderHarness.ts b/testing/ag-test-utils/src/filters/advancedFilterBuilderHarness.ts index 9158021c3d3..bedf5006dbb 100644 --- a/testing/ag-test-utils/src/filters/advancedFilterBuilderHarness.ts +++ b/testing/ag-test-utils/src/filters/advancedFilterBuilderHarness.ts @@ -303,9 +303,10 @@ export class AdvancedFilterBuilderHarness { this.api.onFilterChanged(); // Recreating the rows is the whole point of this helper, so poll until the rendered rows are // new element instances — the only signal that can distinguish a rebuild from the original render. - await waitFor(() => { + await waitFor(async () => { nudgeVirtualList('.ag-advanced-filter-builder-virtual-list-viewport'); nudgeVirtualList('.ag-rich-select-virtual-list-viewport'); + await asyncSetTimeout(0); const rowsNow = Array.from(document.querySelectorAll(ITEM_WRAPPER)); if (rowsNow.length === 0 || rowsNow.some((row) => rowsBefore.includes(row))) { throw new Error('builder item rows were not recreated'); diff --git a/testing/ag-test-utils/src/widgets/dropdowns.ts b/testing/ag-test-utils/src/widgets/dropdowns.ts index e03d33a234d..bdb346d3541 100644 --- a/testing/ag-test-utils/src/widgets/dropdowns.ts +++ b/testing/ag-test-utils/src/widgets/dropdowns.ts @@ -11,7 +11,11 @@ import { firePointerLikeClick } from '../test-utils-events'; const RICH_SELECT_VIEWPORT = '.ag-rich-select-virtual-list-viewport'; -/** Nudges a VirtualList viewport so the grid re-runs drawVirtualRows against its (mocked) height. */ +/** + * Nudges a VirtualList viewport so the grid re-runs drawVirtualRows against its (mocked) height. + * A `waitFor` callback around a nudge that rebuilds rows must `await asyncSetTimeout(0)`, or it re-polls on + * that mutation as a microtask and starves its own deadline timer; a stable list re-renders nothing. + */ export function nudgeVirtualList(selector: string, root: ParentNode = document): void { const el = root.querySelector(selector); if (el) { @@ -66,10 +70,10 @@ export async function selectRichSelectRow(label: string, root: ParentNode = docu let rows: HTMLElement[] = []; let index = -1; // Polled on a time budget, not a tick count: the list mounts a macrotask after the click and a fixed - // number of ticks flakes under load. Nudging inside the callback is safe - `waitFor` arms an independent - // `setTimeout` for its deadline, so the mutations it observes only add polls. - await waitFor(() => { + // number of ticks flakes under load. + await waitFor(async () => { nudgeVirtualList(RICH_SELECT_VIEWPORT, root); + await asyncSetTimeout(0); rows = Array.from(root.querySelectorAll('.ag-rich-select-row')); index = rows.findIndex((r) => r.textContent?.trim() === label); if (index < 0) { diff --git a/testing/angular-tests/package.json b/testing/angular-tests/package.json index fb07dac7b57..defca8ebe6c 100644 --- a/testing/angular-tests/package.json +++ b/testing/angular-tests/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid-angular-tests", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "private": true, "scripts": { "test:e2e": "jest --no-cache" @@ -11,8 +11,8 @@ "@angular/core": "^21.0.0", "@angular/platform-browser": "^21.0.0", "@angular/platform-browser-dynamic": "^21.0.0", - "ag-grid-angular": "36.1.0-beta.20260818.1531", - "ag-grid-community": "36.1.0-beta.20260818.1531", + "ag-grid-angular": "36.1.0-beta.20260819.1156", + "ag-grid-community": "36.1.0-beta.20260819.1156", "rxjs": "~7.8.2", "tslib": "^2.8.1", "zone.js": "~0.15.0" diff --git a/testing/behavioural/package.json b/testing/behavioural/package.json index 94325dffa9a..6e23aab0b1b 100644 --- a/testing/behavioural/package.json +++ b/testing/behavioural/package.json @@ -1,6 +1,6 @@ { "name": "ag-behavioural-testing", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "private": true, "description": "Behavioural unit testing for ag-Grid", "dependencies": { @@ -8,9 +8,9 @@ }, "type": "module", "devDependencies": { - "ag-grid-community": "36.1.0-beta.20260818.1531", - "ag-grid-enterprise": "36.1.0-beta.20260818.1531", - "ag-grid-react": "36.1.0-beta.20260818.1531", + "ag-grid-community": "36.1.0-beta.20260819.1156", + "ag-grid-enterprise": "36.1.0-beta.20260819.1156", + "ag-grid-react": "36.1.0-beta.20260819.1156", "@types/react": "^18.3.23", "@types/react-dom": "^18.3.7", "@testing-library/dom": "^10.4.1", diff --git a/testing/behavioural/src/columnHeaderEdit/editable-header-name.test.ts b/testing/behavioural/src/columnHeaderEdit/editable-header-name.test.ts index 6cf9a88d8f1..2cf5653db42 100644 --- a/testing/behavioural/src/columnHeaderEdit/editable-header-name.test.ts +++ b/testing/behavioural/src/columnHeaderEdit/editable-header-name.test.ts @@ -94,6 +94,40 @@ describe('Editable header name', () => { ); } + /** + * The virtual list only materialises rows that are in view, and jsdom gives it no height, so a + * child row has to be brought into view (or built from its model item, as the list itself does) + * before its rendered label can be read. + */ + async function childColumnLabel(toolPanel: any, gridDiv: HTMLElement, colId: string): Promise { + const listPanel = toolPanel.primaryColsPanel.primaryColsListPanel; + const items = (listPanel.getDisplayedColsList() as any[]) ?? []; + const rowIndex = items.findIndex((item) => !item.group && item.column?.getColId() === colId); + if (rowIndex < 0) { + throw new Error(`Tool-panel column entry not found for colId="${colId}"`); + } + + listPanel['virtualList'].ensureIndexVisible(rowIndex); + await asyncSetTimeout(0); + + const rendered = listPanel['virtualList'].getComponentAt(rowIndex) as any; + if (rendered) { + return rendered.getGui().querySelector('.ag-column-select-column-label')?.textContent ?? null; + } + + const wrapper = document.createElement('div'); + wrapper.classList.add('ag-virtual-list-item'); + gridDiv.appendChild(wrapper); + const comp = listPanel['createComponentFromItem'](items[rowIndex], wrapper); + wrapper.appendChild(comp.getGui()); + const label = wrapper.querySelector('.ag-column-select-column-label')?.textContent ?? null; + // This helper is polled from `waitFor`, so the throwaway row must not outlive the read: a + // surviving copy would duplicate the labels that the other helpers select on. + listPanel.destroyBean(comp); + wrapper.remove(); + return label; + } + /** Open the "Edit Column Name" editor for a column via its tool-panel context menu. */ async function openEditor(toolPanel: any, gridDiv: HTMLElement, label: string): Promise { await openContextMenu(toolPanel, gridDiv, label); @@ -397,6 +431,149 @@ describe('Editable header name', () => { expect(api.getState().columnGroup?.headerNames).toEqual([{ groupId: 'athleteGroup', headerName: 'Renamed' }]); }); + test('an edited group label survives collapsing and expanding the group in the columns tool panel', async () => { + const { gridDiv, toolPanel } = await createGrid([ + { + groupId: 'athleteGroup', + headerName: 'Group', + headerNameEditable: true, + children: [{ field: 'athlete' }, { field: 'age' }], + } as any, + ]); + + const labels = () => + Array.from(gridDiv.querySelectorAll('.ag-column-select-column-label')).map((el) => el.textContent); + await waitFor(() => expect(labels()).toContain('Group')); + + const input = await openEditor(toolPanel, gridDiv, 'Group'); + await userEvent.clear(input); + await userEvent.type(input, 'Renamed'); + pressEnter(input); + await waitFor(() => expect(labels()).toContain('Renamed')); + + // Toggling destroys and recreates the row components from their tool-panel model items. + toolPanel.collapseColumnGroups(); + await asyncSetTimeout(0); + expect(labels()).toContain('Renamed'); + + toolPanel.expandColumnGroups(); + await asyncSetTimeout(0); + expect(labels()).toContain('Renamed'); + }); + + test('an edited child column label survives collapsing and expanding its group in the columns tool panel', async () => { + const { gridDiv, toolPanel } = await createGrid([ + { + groupId: 'athleteGroup', + headerName: 'Group', + children: [{ field: 'athlete', headerNameEditable: true }, { field: 'age' }], + } as any, + ]); + + await waitFor(async () => expect(await childColumnLabel(toolPanel, gridDiv, 'athlete')).toBe('Athlete')); + + const input = await openEditor(toolPanel, gridDiv, 'Athlete'); + await userEvent.clear(input); + await userEvent.type(input, 'Renamed'); + pressEnter(input); + await waitFor(async () => expect(await childColumnLabel(toolPanel, gridDiv, 'athlete')).toBe('Renamed')); + + toolPanel.collapseColumnGroups(); + await asyncSetTimeout(0); + toolPanel.expandColumnGroups(); + await asyncSetTimeout(0); + + expect(await childColumnLabel(toolPanel, gridDiv, 'athlete')).toBe('Renamed'); + }); + + test('the tool-panel search box matches a name edited before the search text is set', async () => { + const { api, gridDiv, toolPanel } = await createGrid([{ field: 'athlete' }, { field: 'age' }]); + + const labels = () => + Array.from(gridDiv.querySelectorAll('.ag-column-select-column-label')).map((el) => el.textContent); + await waitFor(() => expect(labels()).toContain('Athlete')); + + api.applyColumnState({ state: [{ colId: 'athlete', headerName: 'Renamed' }] }); + + const listPanel = toolPanel.primaryColsPanel.primaryColsListPanel; + listPanel.setFilterText('Renamed'); + + await waitFor(() => expect(labels()).toEqual(['Renamed'])); + }); + + test('the tool-panel search box matches a virtualised column that has never been rendered', async () => { + const manyCols = Array.from({ length: 80 }, (_, i) => ({ field: `col${i}`, headerName: `Col ${i}` })); + const { api, toolPanel } = await createGrid(manyCols); + + const listPanel = toolPanel.primaryColsPanel.primaryColsListPanel; + const targetIndex = await waitFor(() => { + const index = (listPanel.getDisplayedColsList() as any[]).findIndex( + (item) => item.column?.getColId() === 'col79' + ); + expect(index).toBeGreaterThan(-1); + return index; + }); + + // Non-vacuous: the search below has to reach an item the virtual list never materialised. + expect(listPanel['virtualList'].getComponentAt(targetIndex)).toBeFalsy(); + + api.applyColumnState({ state: [{ colId: 'col79', headerName: 'Renamed' }] }); + listPanel.setFilterText('Renamed'); + + await waitFor(() => + expect((listPanel.getDisplayedColsList() as any[]).map((item) => item.displayName)).toEqual(['Renamed']) + ); + }); + + test('the tool panel resolves an unrendered group name only when something reads it', async () => { + let innerGroupToolPanelCalls = 0; + const { gridDiv, toolPanel } = await createGrid([ + { field: 'athlete', headerNameEditable: true }, + { + groupId: 'outerGroup', + headerName: 'Outer', + children: [ + { + groupId: 'innerGroup', + headerValueGetter: (params: any) => { + // The grid header and rendered rows re-resolve names on any rename, so the + // group is collapsed out of view below and only the tool panel's own + // resolutions are counted. + if (params.location === 'columnToolPanel') { + innerGroupToolPanelCalls++; + } + return 'Inner'; + }, + children: [{ field: 'age' }], + }, + ], + } as any, + ]); + + toolPanel.collapseColumnGroups(); + await asyncSetTimeout(0); + + // Baseline before typing: `columnHeaderEdit.applyMode` defaults to `live`, so the rename + // event fires per keystroke and a baseline taken after typing would miss the extra work. + const callsBeforeEdit = innerGroupToolPanelCalls; + const input = await openEditor(toolPanel, gridDiv, 'Athlete'); + await userEvent.clear(input); + await userEvent.type(input, 'Renamed'); + pressEnter(input); + await waitForEditorClosed(); + + expect(innerGroupToolPanelCalls).toBe(callsBeforeEdit); + + // Non-vacuous: the counter does fire through the panel's own model item, so the assertion + // above is about the rename doing no work, not about the getter being unreachable. + const listPanel = toolPanel.primaryColsPanel.primaryColsListPanel; + const innerItem = listPanel['allColsTree'][1].children.find( + (item: any) => item.columnGroup?.groupId === 'innerGroup' + ); + expect(innerItem.displayName).toBe('Inner'); + expect(innerGroupToolPanelCalls).toBe(callsBeforeEdit + 1); + }); + test('renaming one group does not recompute the header name of another group', async () => { let ageGroupGetterCalls = 0; const { api, gridDiv, toolPanel } = await createGrid([ diff --git a/testing/behavioural/src/columnToolPanel/column-display-name.test.ts b/testing/behavioural/src/columnToolPanel/column-display-name.test.ts new file mode 100644 index 00000000000..eb8b4c2e458 --- /dev/null +++ b/testing/behavioural/src/columnToolPanel/column-display-name.test.ts @@ -0,0 +1,169 @@ +import { waitFor } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import { TestGridsManager } from 'ag-test-utils'; + +import type { ColDef, GridApi, HeaderValueGetterParams } from 'ag-grid-community'; +import { getGridElement } from 'ag-grid-community'; +import { AllEnterpriseModule } from 'ag-grid-enterprise'; + +/** + * The columns tool panel resolves each entry's display name through `headerValueGetter`, so the name + * has to track colDef changes that reach a live column, while a user getter must not be re-run for + * reads that cannot have changed its output (searching, and re-rendering recycled rows). + */ +describe('columns tool panel display name', () => { + const gridMgr = new TestGridsManager({ modules: [AllEnterpriseModule] }); + + afterEach(() => { + gridMgr.reset(); + }); + + const sideBar = { + toolPanels: [ + { + id: 'columns', + labelDefault: 'Columns', + labelKey: 'columns', + iconKey: 'columns', + toolPanel: 'agColumnsToolPanel', + }, + ], + defaultToolPanel: 'columns', + }; + + /** jsdom gives the virtual list no height, so the viewport needs one before it renders any row. */ + function renderVirtualList(root: ParentNode): void { + const viewport = root.querySelector('.ag-column-select-virtual-list-viewport') as HTMLElement; + Object.defineProperty(viewport, 'offsetHeight', { value: 300, configurable: true }); + viewport.dispatchEvent(new Event('scroll')); + } + + function labels(root: ParentNode): (string | null)[] { + return Array.from(root.querySelectorAll('.ag-column-select-column-label'), (el) => el.textContent); + } + + async function search(root: ParentNode, text: string): Promise { + const input = root.querySelector('.ag-column-select-header-filter-wrapper input') as HTMLInputElement; + await userEvent.clear(input); + if (text) { + await userEvent.type(input, text); + } + } + + test('an entry re-resolves its name when the colDef changes underneath it', async () => { + // Cell data type inference is the case that matters: it re-applies the colDef to the live + // column once row data arrives, without reloading columns, so the panel keeps its entries. + const api: GridApi = gridMgr.createGrid('columns-tool-panel-display-name', { + columnDefs: [ + { + field: 'athlete', + headerValueGetter: (params) => `Athlete (${params.column?.getColDef().cellDataType})`, + }, + { field: 'age' }, + ] satisfies ColDef[], + sideBar, + }); + const gridElement = getGridElement(api)!; + + await waitFor(() => { + renderVirtualList(gridElement); + expect(labels(gridElement)).toEqual(['Athlete (false)', 'Age']); + }); + // Search first, so the pre-inference name is resolved for every entry before it changes. + await search(gridElement, 'athlete'); + await waitFor(() => expect(labels(gridElement)).toEqual(['Athlete (false)'])); + + api.setGridOption('rowData', [{ athlete: 'Michael Phelps', age: 23 }]); + + await waitFor(() => expect(labels(gridElement)).toEqual(['Athlete (text)'])); + + await search(gridElement, 'athlete (text)'); + await waitFor(() => expect(labels(gridElement)).toEqual(['Athlete (text)'])); + + await search(gridElement, 'athlete (false)'); + await waitFor(() => expect(labels(gridElement)).toEqual([])); + }); + + test('searching does not re-run a header value getter whose output cannot have changed', async () => { + let toolPanelCalls = 0; + const countingHeaderValueGetter = (params: HeaderValueGetterParams) => { + if (params.location === 'columnToolPanel') { + toolPanelCalls++; + } + return `Col ${params.column?.getColId()}`; + }; + const api: GridApi = await gridMgr.createGridAndWait('columns-tool-panel-display-name', { + columnDefs: Array.from({ length: 40 }, (_, i) => ({ + field: `field${i}`, + headerValueGetter: countingHeaderValueGetter, + })) satisfies ColDef[], + rowData: [{ field0: 'a' }], + sideBar, + }); + const gridElement = getGridElement(api)!; + + await waitFor(() => { + renderVirtualList(gridElement); + expect(labels(gridElement).length).toBeGreaterThan(0); + }); + + // Every entry is resolved by this first search, which has to test all 40 names. + await search(gridElement, 'field39'); + await waitFor(() => expect(labels(gridElement)).toEqual(['Col field39'])); + const callsAfterFirstSearch = toolPanelCalls; + expect(callsAfterFirstSearch).toBeGreaterThanOrEqual(40); + + // Further searches re-test all 40 names and re-render rows, but nothing that feeds a name + // has changed, so no entry may consult the getter again. + await search(gridElement, 'field38'); + await waitFor(() => expect(labels(gridElement)).toEqual(['Col field38'])); + await search(gridElement, ''); + await waitFor(() => expect(labels(gridElement).length).toBeGreaterThan(1)); + + expect(toolPanelCalls).toBe(callsAfterFirstSearch); + }); + + test('reverting a renamed column consults its header value getter again', async () => { + let toolPanelCalls = 0; + const api: GridApi = await gridMgr.createGridAndWait('columns-tool-panel-display-name', { + columnDefs: [ + { + field: 'athlete', + headerValueGetter: (params: HeaderValueGetterParams) => { + if (params.location === 'columnToolPanel') { + toolPanelCalls++; + } + return 'From Getter'; + }, + }, + { field: 'age' }, + ] satisfies ColDef[], + rowData: [{ athlete: 'Michael Phelps', age: 23 }], + sideBar, + }); + const gridElement = getGridElement(api)!; + + await waitFor(() => { + renderVirtualList(gridElement); + expect(labels(gridElement)).toEqual(['From Getter', 'Age']); + }); + + // An override wins outright over the getter, so renaming must not consult it at all. + const callsBeforeRename = toolPanelCalls; + api.applyColumnState({ state: [{ colId: 'athlete', headerName: 'Renamed' }] }); + await waitFor(() => expect(labels(gridElement)).toEqual(['Renamed', 'Age'])); + await search(gridElement, 'renamed'); + await waitFor(() => expect(labels(gridElement)).toEqual(['Renamed'])); + expect(toolPanelCalls).toBe(callsBeforeRename); + + // Reverting drops the override, so the name has to come from the getter again. The visible + // rows refresh in place; which rows the active search admits is only recomputed on the next + // search, so the reverted entry is still listed here under the stale "renamed" text. + api.applyColumnState({ state: [{ colId: 'athlete', headerName: null }] }); + await waitFor(() => expect(labels(gridElement)).toEqual(['From Getter'])); + expect(toolPanelCalls).toBeGreaterThan(callsBeforeRename); + + await search(gridElement, 'from getter'); + await waitFor(() => expect(labels(gridElement)).toEqual(['From Getter'])); + }); +}); diff --git a/testing/behavioural/src/filters/advanced-filter-regression/af-options.test.ts b/testing/behavioural/src/filters/advanced-filter-regression/af-options.test.ts index d289820edef..7299918a015 100644 --- a/testing/behavioural/src/filters/advanced-filter-regression/af-options.test.ts +++ b/testing/behavioural/src/filters/advanced-filter-regression/af-options.test.ts @@ -11,6 +11,7 @@ import type { GridApi, GridOptions, IFilterOptionDef } from 'ag-grid-community'; import { ClientSideRowModelModule, DateFilterModule, + LocaleModule, NumberFilterModule, TextFilterModule, enableDevValidations, @@ -306,6 +307,22 @@ describe('Advanced Filter — currently-unsupported operators (baseline)', () => └── LEAF id:1 date:"2024-06-15" `); }); + + // A preset key names no AF option, so it can make none available: the list leaves only `equals`. + test('an operator absent from the preset list is not offered', async () => { + const api: GridApi = await gridsManager.createGridAndWait('grid1', OPTS); + + await AdvancedFilterHarness.get(api).applyExpression('[Date] > "2024-03-01"'); + await asyncSetTimeout(0); + + await new FilterDom(api, 'preset column greaterThan not offered').checkFilterDom(` + ADVANCED FILTER + input: "[Date] > "2024-03-01"" + valid: false — Expression has an error. Option not found - > "2024-03-01". + buttons: Apply ⊘ | Builder + model: null + `); + }); }); describe('inRange is not offered today', () => { @@ -382,42 +399,122 @@ describe('Advanced Filter — currently-unsupported operators (baseline)', () => `); }); }); +}); + +/** The docs promise `filterOptions` sets the options available to the Advanced Filter, not only its autocomplete. */ +describe('Advanced Filter — string filterOptions restrict the options', () => { + const gridsManager = new TestGridsManager({ + modules: [ + TextFilterModule, + NumberFilterModule, + DateFilterModule, + LocaleModule, + AdvancedFilterModule, + ClientSideRowModelModule, + ], + }); - describe('string filterOptions restrict autocomplete suggestions only, not the parser', () => { - const OPTS: GridOptions = { + afterEach(() => gridsManager.reset()); + + function optionsWithNotContainsNamed(notContainsName?: string): GridOptions { + return { columnDefs: [{ field: 'name', filter: 'agTextColumnFilter', filterParams: { filterOptions: ['equals'] } }], rowData: [{ name: 'Bolt' }, { name: 'Ng' }], enableAdvancedFilter: true, + localeText: notContainsName ? { advancedFilterNotContains: notContainsName } : undefined, }; + } - test('an operator outside the restricted list is still accepted by the parser', async () => { - const api: GridApi = await gridsManager.createGridAndWait('grid1', OPTS); + test('an option the list names parses', async () => { + const api: GridApi = await gridsManager.createGridAndWait('grid1', optionsWithNotContainsNamed()); - // `contains` is a valid text operator but NOT in the column's restricted `filterOptions`. - // The restriction only trims the autocomplete suggestions — the parser still accepts it, - // so the expression applies. (Baseline for AG-10819: the operator set is not enforced.) - await AdvancedFilterHarness.get(api).applyExpression('[Name] contains "o"'); - await asyncSetTimeout(0); - expect(api.getAdvancedFilterModel()).toEqual({ - filterType: 'text', - colId: 'name', - type: 'contains', - filter: 'o', - }); - await new GridRows(api, 'restricted-but-parsed operator applied').check(` - ROOT id:ROOT_NODE_ID - └── LEAF id:0 name:"Bolt" - `); + await AdvancedFilterHarness.get(api).applyExpression('[Name] equals "Bolt"'); + await asyncSetTimeout(0); - await AdvancedFilterHarness.get(api).applyExpression('[Name] equals "Bolt"'); - await asyncSetTimeout(0); - expect(api.getAdvancedFilterModel()).toEqual({ - filterType: 'text', - colId: 'name', - type: 'equals', - filter: 'Bolt', - }); + expect(api.getAdvancedFilterModel()).toEqual({ + filterType: 'text', + colId: 'name', + type: 'equals', + filter: 'Bolt', + }); + await new GridRows(api, 'a named option').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:0 name:"Bolt" + `); + }); + + test('an option the list omits is not a known option', async () => { + const api: GridApi = await gridsManager.createGridAndWait('grid1', optionsWithNotContainsNamed()); + + await AdvancedFilterHarness.get(api).applyExpression('[Name] contains "o"'); + await asyncSetTimeout(0); + + await new FilterDom(api, 'an omitted option').checkFilterDom(` + ADVANCED FILTER + input: "[Name] contains "o"" + valid: false — Expression has an error. Option not found - contains "o". + buttons: Apply ⊘ | Builder + model: null + `); + }); + + test('a model naming an option the list omits does not apply', async () => { + const api: GridApi = await gridsManager.createGridAndWait('grid1', optionsWithNotContainsNamed()); + + api.setAdvancedFilterModel({ filterType: 'text', colId: 'name', type: 'contains', filter: 'o' }); + await asyncSetTimeout(0); + + await new FilterDom(api, 'a model naming an omitted option').checkFilterDom(` + ADVANCED FILTER + input: "[Name] contains "o"" + valid: false — Expression has an error. Option not found - contains "o". + buttons: Apply ⊘ | Builder + model: null + `); + }); + + test('an omitted longer name does not beat the shorter one it starts with', async () => { + const api: GridApi = await gridsManager.createGridAndWait('grid1', optionsWithNotContainsNamed('equals not')); + + // `equals not` names the omitted `notContains`, so only `equals` is in the running: `not` becomes its + // operand and `"Bolt"` is left where a join operator belongs. + await AdvancedFilterHarness.get(api).applyExpression('[Name] equals not "Bolt"'); + await asyncSetTimeout(0); + + await new FilterDom(api, 'an omitted longer name').checkFilterDom(` + ADVANCED FILTER + input: "[Name] equals not "Bolt"" + valid: false — Expression has an error. Join operator not found - "Bolt". + buttons: Apply ⊘ | Builder + model: null + `); + }); + + // Presets have no expression form yet, so a list of only presets names nothing the parser can offer. + test('a list naming no Advanced Filter option leaves the column unfilterable', async () => { + const api: GridApi = await gridsManager.createGridAndWait('grid1', { + columnDefs: [ + { + field: 'date', + cellDataType: 'dateString', + filter: 'agDateColumnFilter', + filterParams: { filterOptions: ['today', 'yesterday'] }, + }, + ], + rowData: [{ date: '2024-01-01' }], + enableAdvancedFilter: true, }); + + await AdvancedFilterHarness.get(api).applyExpression('[Date] = "2024-01-01"'); + await asyncSetTimeout(0); + + await new FilterDom(api, 'a list naming no AF option').checkFilterDom(` + ADVANCED FILTER + input: "[Date] = "2024-01-01"" + valid: false — Expression has an error. Option not found - = "2024-01-01". + buttons: Apply ⊘ | Builder + model: null + `); }); }); diff --git a/testing/behavioural/src/filters/advanced-filter-regression/af-parser.test.ts b/testing/behavioural/src/filters/advanced-filter-regression/af-parser.test.ts index e529971631b..70df0197264 100644 --- a/testing/behavioural/src/filters/advanced-filter-regression/af-parser.test.ts +++ b/testing/behavioural/src/filters/advanced-filter-regression/af-parser.test.ts @@ -1,7 +1,13 @@ import { AdvancedFilterHarness, FilterDom, GridRows, TestGridsManager, asyncSetTimeout } from 'ag-test-utils'; import type { GridApi, GridOptions } from 'ag-grid-community'; -import { ClientSideRowModelModule, DateFilterModule, NumberFilterModule, TextFilterModule } from 'ag-grid-community'; +import { + ClientSideRowModelModule, + DateFilterModule, + LocaleModule, + NumberFilterModule, + TextFilterModule, +} from 'ag-grid-community'; import { AdvancedFilterModule } from 'ag-grid-enterprise'; /** @@ -409,6 +415,218 @@ describe('Advanced Filter — parser edge cases', () => { }); }); + describe('an operator name another one starts with', () => { + // Its own manager: `localeText` needs LocaleModule, and the names under test only exist through it. + const localeGridsManager = new TestGridsManager({ + modules: [ + TextFilterModule, + NumberFilterModule, + LocaleModule, + AdvancedFilterModule, + ClientSideRowModelModule, + ], + }); + + afterEach(() => localeGridsManager.reset()); + + function optionsWithNotContainsNamed(name: string): GridOptions { + return { ...OPTS, localeText: { advancedFilterNotContains: name } }; + } + + test('the longer name parses rather than the shorter one it starts with', async () => { + const api = await localeGridsManager.createGridAndWait( + 'grid1', + optionsWithNotContainsNamed('contains not') + ); + const af = AdvancedFilterHarness.get(api); + + await af.applyExpression('[Athlete] contains not "e"'); + await asyncSetTimeout(0); + + expect(af.value).toBe('[Athlete] contains not "e"'); + expect(api.getAdvancedFilterModel()).toEqual({ + filterType: 'text', + colId: 'athlete', + type: 'notContains', + filter: 'e', + }); + await new GridRows(api, 'the longer operator name').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Bolt" age:25 big:"10000000000000000001n" + └── LEAF id:1 athlete:"Ng" age:40 big:"20000000000000000002n" + `); + }); + + test('the shorter name still parses on its own', async () => { + const api = await localeGridsManager.createGridAndWait( + 'grid1', + optionsWithNotContainsNamed('contains not') + ); + const af = AdvancedFilterHarness.get(api); + + await af.applyExpression('[Athlete] contains "e"'); + await asyncSetTimeout(0); + + expect(af.value).toBe('[Athlete] contains "e"'); + expect(api.getAdvancedFilterModel()).toEqual({ + filterType: 'text', + colId: 'athlete', + type: 'contains', + filter: 'e', + }); + await new GridRows(api, 'the shorter operator name').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:2 athlete:"Wei" age:28 big:"10000000000000000001n" + `); + }); + + test('a model naming the longer option round-trips through the expression', async () => { + const api = await localeGridsManager.createGridAndWait( + 'grid1', + optionsWithNotContainsNamed('contains not') + ); + + api.setAdvancedFilterModel({ filterType: 'text', colId: 'athlete', type: 'notContains', filter: 'e' }); + await asyncSetTimeout(0); + + expect(AdvancedFilterHarness.get(api).value).toBe('[Athlete] contains not "e"'); + await new GridRows(api, 'a model naming the longer option').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Bolt" age:25 big:"10000000000000000001n" + └── LEAF id:1 athlete:"Ng" age:40 big:"20000000000000000002n" + `); + }); + + test('a longer name left incomplete falls back to the shorter match, and the rest is rejected', async () => { + const api = await localeGridsManager.createGridAndWait( + 'grid1', + optionsWithNotContainsNamed('contains not really') + ); + + // Greedy but not destructive: only `contains` resolves, so `not` is its operand and `"e"` is left + // where a join operator belongs - the text is reported rather than silently swallowed by the scan. + await AdvancedFilterHarness.get(api).applyExpression('[Athlete] contains not "e"'); + await asyncSetTimeout(0); + + await new FilterDom(api, 'an incomplete longer name').checkFilterDom(` + ADVANCED FILTER + input: "[Athlete] contains not "e"" + valid: false — Expression has an error. Join operator not found - "e". + buttons: Apply ⊘ | Builder + model: null + `); + }); + + test('an operand continuing the longer name falls back to the shorter one rather than failing', async () => { + const api = await localeGridsManager.createGridAndWait( + 'grid1', + optionsWithNotContainsNamed('contains not') + ); + const af = AdvancedFilterHarness.get(api); + + // `notes` spells `not` and then keeps going, so the longer name is not what the text says. + await af.applyExpression('[Athlete] contains notes'); + await asyncSetTimeout(0); + + expect(api.getAdvancedFilterModel()).toEqual({ + filterType: 'text', + colId: 'athlete', + type: 'contains', + filter: 'notes', + }); + await new GridRows(api, 'an operand continuing the longer name').check(` + ROOT id:ROOT_NODE_ID + `); + }); + + test('a name containing a closing bracket parses', async () => { + const api = await localeGridsManager.createGridAndWait( + 'grid1', + optionsWithNotContainsNamed('not (contains)') + ); + const af = AdvancedFilterHarness.get(api); + + await af.applyExpression('[Athlete] not (contains) "e"'); + await asyncSetTimeout(0); + + expect(af.value).toBe('[Athlete] not (contains) "e"'); + expect(api.getAdvancedFilterModel()).toEqual({ + filterType: 'text', + colId: 'athlete', + type: 'notContains', + filter: 'e', + }); + await new GridRows(api, 'a bracketed operator name').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Bolt" age:25 big:"10000000000000000001n" + └── LEAF id:1 athlete:"Ng" age:40 big:"20000000000000000002n" + `); + }); + + test('a bracket closing the enclosing group is not taken as part of the name', async () => { + const api = await localeGridsManager.createGridAndWait( + 'grid1', + optionsWithNotContainsNamed('not (contains)') + ); + const af = AdvancedFilterHarness.get(api); + + await af.applyExpression('([Athlete] not (contains) "e") OR [Age] = 40'); + await asyncSetTimeout(0); + + expect(api.getAdvancedFilterModel()).toEqual({ + filterType: 'join', + type: 'OR', + conditions: [ + { filterType: 'text', colId: 'athlete', type: 'notContains', filter: 'e' }, + { filterType: 'number', colId: 'age', type: 'equals', filter: 40 }, + ], + }); + await new GridRows(api, 'a bracketed name inside a group').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 athlete:"Bolt" age:25 big:"10000000000000000001n" + └── LEAF id:1 athlete:"Ng" age:40 big:"20000000000000000002n" + `); + }); + }); + + describe('an operator name whose lowercase is longer than the name', () => { + const localeGridsManager = new TestGridsManager({ + modules: [ + TextFilterModule, + NumberFilterModule, + LocaleModule, + AdvancedFilterModule, + ClientSideRowModelModule, + ], + }); + + afterEach(() => localeGridsManager.reset()); + + // `İ` lowercases to two UTF-16 units, so the name is longer lowercased than as typed: a boundary + // taken from the lowercased form overruns the name and never matches what the text spells. + test('a name containing a dotted capital I parses', async () => { + const api = await localeGridsManager.createGridAndWait('grid1', { + ...OPTS, + localeText: { advancedFilterContains: 'İçerir' }, + }); + const af = AdvancedFilterHarness.get(api); + + await af.applyExpression('[Athlete] İçerir "e"'); + await asyncSetTimeout(0); + + expect(api.getAdvancedFilterModel()).toEqual({ + filterType: 'text', + colId: 'athlete', + type: 'contains', + filter: 'e', + }); + await new GridRows(api, 'a dotted capital I in the name').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:2 athlete:"Wei" age:28 big:"10000000000000000001n" + `); + }); + }); + describe('bigint operand', () => { test('a bigint literal beyond Number.MAX_SAFE_INTEGER filters exactly', async () => { const api = await gridsManager.createGridAndWait('grid1', OPTS); diff --git a/testing/behavioural/src/filters/advanced-filter/advanced-filter-column-filter-parity.test.ts b/testing/behavioural/src/filters/advanced-filter/advanced-filter-column-filter-parity.test.ts index 1de55173f59..5e007f1537b 100644 --- a/testing/behavioural/src/filters/advanced-filter/advanced-filter-column-filter-parity.test.ts +++ b/testing/behavioural/src/filters/advanced-filter/advanced-filter-column-filter-parity.test.ts @@ -89,12 +89,10 @@ describe('Advanced Filter matches the column filter', () => { { colId: 'athlete', filterType: 'text' as const, filter: 'alpha' }, { colId: 'age', filterType: 'number' as const, filter: 2 }, ])('$colId', ({ colId, filterType, filter }) => { - // A number column's `notEqual` is not at parity: the Advanced Filter admits blanks to it by - // `includeBlanksInEquals`, where the column filter reads `includeBlanksInNotEqual`. const valuedOptions = filterType === 'text' ? ['contains', 'notContains', 'equals', 'notEqual', 'startsWith', 'endsWith'] - : ['equals', 'greaterThan', 'greaterThanOrEqual', 'lessThan', 'lessThanOrEqual']; + : ['equals', 'notEqual', 'greaterThan', 'greaterThanOrEqual', 'lessThan', 'lessThanOrEqual']; test.each(valuedOptions)('`%s` filters the same rows', async (type) => { const columnFilterIds = await withColumnFilter(colId, { filterType, type, filter }); @@ -122,6 +120,52 @@ describe('Advanced Filter matches the column filter', () => { }); }); + test('`includeBlanksInNotEqual` admits a blank to `notEqual` in both', async () => { + const columnFilterIds = await withColumnFilter('age', { filterType: 'number', type: 'notEqual', filter: 2 }); + + expect(columnFilterIds).toContain(3); // the row with a null age + expect(await withAdvancedFilter({ filterType: 'number', colId: 'age', type: 'notEqual', filter: 2 })).toEqual( + columnFilterIds + ); + }); + + // The column filter's `isValid(cellValue)` gate keeps an unreadable date out of the comparison; the + // Advanced Filter has no equivalent and converts before it compares. + test('an unreadable date compares rather than throwing, in both', async () => { + expect(await withColumnFilter('date', { filterType: 'date', type: 'equals', dateFrom: '2008-08-24' })).toEqual([ + 0, + ]); + expect( + await withAdvancedFilter({ + filterType: 'dateString', + colId: 'date', + type: 'equals', + filter: '2008-08-24', + }) + ).toEqual([0]); + }); + + // The negated half of the same gate, and the only case in which an unreadable value is a match. + test('an unreadable date is admitted to `notEqual`, in both', async () => { + const columnFilterIds = await withColumnFilter('date', { + filterType: 'date', + type: 'notEqual', + dateFrom: '2008-08-24', + }); + + expect(columnFilterIds).toContain(1); // whitespace + expect(columnFilterIds).toContain(4); // 'not a date' + expect(columnFilterIds).not.toContain(2); // null is blank, not unreadable + expect( + await withAdvancedFilter({ + filterType: 'dateString', + colId: 'date', + type: 'notEqual', + filter: '2008-08-24', + }) + ).toEqual(columnFilterIds); + }); + // A whitespace date is rejected as an invalid date before either filter asks whether it is blank, so // `isBlank`'s treatment of whitespace never reaches it. Pinned because it is the surprise. test('a whitespace `dateString` is not blank to either', async () => { diff --git a/testing/behavioural/src/filters/set-filter-empty-before-data.test.ts b/testing/behavioural/src/filters/set-filter-empty-before-data.test.ts index 8d8db96210f..d2db768c300 100644 --- a/testing/behavioural/src/filters/set-filter-empty-before-data.test.ts +++ b/testing/behavioural/src/filters/set-filter-empty-before-data.test.ts @@ -213,4 +213,68 @@ describe('Set Filter dropdown opened before data arrives (AG-17369)', () => { expect(scenarioA.uiValues).toEqual(['one']); expect(scenarioA.displayedRowCount).toBe(1); }); + + // The reporter and QA (plnkr 41sqYmiFjrSjJN3S) drive the filter through grid-level `api.setFilterModel` — a + // different entry point from the `api.setColumnFilterModel` the tests above exercise (grid-level queues the + // model and replays it against the still-empty client-side row model while inference is pending) — and reuse + // ONE `columnDefs` object across a destroy/recreate "Reset": Scenario A on the cold first grid, Scenario B on + // the grid recreated with that same column def. Opening the dropdown before data must remain observational. + async function runViaGridApi( + api: GridApi, + openBeforeData: boolean + ): Promise<{ ticked: (string | null)[] | null; modelValues: string[] | undefined; displayedRowCount: number }> { + api.setFilterModel({ value: { filterType: 'set', values: ['one'] } }); + api.onFilterChanged(); + await asyncSetTimeout(0); + + if (openBeforeData) { + await openDropdown(api); + api.hideColumnFilter(); + await asyncSetTimeout(0); + } + + api.applyTransaction({ add: [{ value: 'one' }, { value: 'two' }] }); + await asyncSetTimeout(0); + + await openDropdown(api); + const setFilter = await getSetFilter(api); + return { + ticked: setFilter.getModelFromUi()?.values ?? null, + modelValues: api.getColumnFilterModel<{ values: string[] }>('value')?.values, + displayedRowCount: api.getDisplayedRowCount(), + }; + } + + test('grid-level api.setFilterModel converges across a shared-column-def reset (plnkr 41sqYmiFjrSjJN3S)', async () => { + const sharedColumnDefs: GridOptions['columnDefs'] = [{ field: 'value', filter: 'agSetColumnFilter' }]; + + const apiA = gridsManager.createGrid('qa-grid', { + columnDefs: sharedColumnDefs, + defaultColDef: { floatingFilter: true }, + rowData: [], + }); + await asyncSetTimeout(0); + const scenarioA = await runViaGridApi(apiA, false); + + apiA.destroy(); + await asyncSetTimeout(0); + + const apiB = gridsManager.createGrid('qa-grid', { + columnDefs: sharedColumnDefs, + defaultColDef: { floatingFilter: true }, + rowData: [], + }); + await asyncSetTimeout(0); + const scenarioB = await runViaGridApi(apiB, true); + + // Opening the dropdown before data is observational: B converges on A. + expect(scenarioB.ticked).toEqual(scenarioA.ticked); + expect(scenarioB.modelValues).toEqual(scenarioA.modelValues); + expect(scenarioB.displayedRowCount).toBe(scenarioA.displayedRowCount); + + // The converged state applies the reporter's set model — one row, only 'one' ticked. + expect(scenarioA.ticked).toEqual(['one']); + expect(scenarioA.modelValues).toEqual(['one']); + expect(scenarioA.displayedRowCount).toBe(1); + }); }); diff --git a/testing/behavioural/src/version.ts b/testing/behavioural/src/version.ts index e76d311e3ee..6219d4e35d3 100644 --- a/testing/behavioural/src/version.ts +++ b/testing/behavioural/src/version.ts @@ -1,2 +1,2 @@ // DO NOT UPDATE MANUALLY: Generated from script during build time -export const VERSION = '36.1.0-beta.20260818.1531'; +export const VERSION = '36.1.0-beta.20260819.1156'; diff --git a/testing/csp/package.json b/testing/csp/package.json index 858522534b1..8f3537b6d13 100644 --- a/testing/csp/package.json +++ b/testing/csp/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid-csp", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "description": "CSP testing for AG Grid", "main": "index.js", "scripts": {}, diff --git a/testing/module-size-angular/package.json b/testing/module-size-angular/package.json index b3dbb16472f..6bbc8e7b993 100644 --- a/testing/module-size-angular/package.json +++ b/testing/module-size-angular/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid-module-size-angular", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "scripts": { "ng": "ng", "start": "ng serve", @@ -20,9 +20,9 @@ "@angular/platform-browser": "^20.3.25", "@angular/platform-browser-dynamic": "^20.3.25", "@angular/router": "^20.3.25", - "ag-grid-angular": "36.1.0-beta.20260818.1531", - "ag-grid-community": "36.1.0-beta.20260818.1531", - "ag-grid-enterprise": "36.1.0-beta.20260818.1531", + "ag-grid-angular": "36.1.0-beta.20260819.1156", + "ag-grid-community": "36.1.0-beta.20260819.1156", + "ag-grid-enterprise": "36.1.0-beta.20260819.1156", "ag-charts-community": "14.1.0-beta.20260816", "ag-charts-enterprise": "14.1.0-beta.20260816", "rxjs": "~7.8.2", diff --git a/testing/module-size/package.json b/testing/module-size/package.json index 9ff0f60a1e3..05d875f517a 100644 --- a/testing/module-size/package.json +++ b/testing/module-size/package.json @@ -1,7 +1,7 @@ { "name": "ag-grid-module-size", "private": true, - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "scripts": { "dev": "vite", "cp-app": "cp ./src/App_Src.tsx ./src/App_AUTO.tsx", @@ -14,9 +14,9 @@ "test:e2e": "run-s \"module-combinations -- {1}\" module-validate --" }, "dependencies": { - "ag-grid-react": "36.1.0-beta.20260818.1531", - "ag-grid-community": "36.1.0-beta.20260818.1531", - "ag-grid-enterprise": "36.1.0-beta.20260818.1531", + "ag-grid-react": "36.1.0-beta.20260819.1156", + "ag-grid-community": "36.1.0-beta.20260819.1156", + "ag-grid-enterprise": "36.1.0-beta.20260819.1156", "ag-charts-community": "14.1.0-beta.20260816", "ag-charts-enterprise": "14.1.0-beta.20260816", "ag-shared": "0.0.1", diff --git a/testing/public-recipes/e2e/package.json b/testing/public-recipes/e2e/package.json index 6bb8153cc93..12b3ab4de73 100644 --- a/testing/public-recipes/e2e/package.json +++ b/testing/public-recipes/e2e/package.json @@ -1,12 +1,12 @@ { "name": "ag-grid-public-e2e-testing-recipes", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "description": "Public E2E testing recipes for AG Grid", "main": "index.js", "scripts": {}, "license": "MIT", "devDependencies": { - "ag-grid-community": "36.1.0-beta.20260818.1531", + "ag-grid-community": "36.1.0-beta.20260819.1156", "playwright": "^1.59.1", "@playwright/test": "^1.59.1", "@types/node": "^22.15.3" diff --git a/testing/typedoc-links/package.json b/testing/typedoc-links/package.json index 004b65fbb9d..af56c852022 100644 --- a/testing/typedoc-links/package.json +++ b/testing/typedoc-links/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid-typedoc-links", - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "private": true, "type": "module", "description": "Validates that AG Grid's published .d.ts JSDoc {@link} references resolve under TypeDoc, reproducing the environment downstream consumers build their API docs in.", diff --git a/testing/vue3-tests/package.json b/testing/vue3-tests/package.json index 69be9d36347..78438ca3652 100644 --- a/testing/vue3-tests/package.json +++ b/testing/vue3-tests/package.json @@ -1,7 +1,7 @@ { "name": "ag-grid-vue3-tests", "private": true, - "version": "36.1.0-beta.20260818.1531", + "version": "36.1.0-beta.20260819.1156", "type": "module", "scripts": { "dev": "vite", @@ -15,9 +15,9 @@ "dependencies": { "vue": "^3.5.32", "vue-router": "^5.0.6", - "ag-grid-community": "36.1.0-beta.20260818.1531", - "ag-grid-enterprise": "36.1.0-beta.20260818.1531", - "ag-grid-vue3": "36.1.0-beta.20260818.1531", + "ag-grid-community": "36.1.0-beta.20260819.1156", + "ag-grid-enterprise": "36.1.0-beta.20260819.1156", + "ag-grid-vue3": "36.1.0-beta.20260819.1156", "decimal.js": "^10.6.0" }, "devDependencies": {