From b906a9f8954c8627f1ef3107b02c010d53e5e45e Mon Sep 17 00:00:00 2001 From: Mark Durrant Date: Fri, 21 Aug 2026 13:11:36 +0100 Subject: [PATCH 1/4] AG-18120 Add new marketing CTAs to the grid website (#14927) Homepage hero gains Free Trial and Buy Now CTAs; the Get Started For Free section CTA becomes Explore the Docs with a Try Enterprise Free CTA beside it, and Integrated Charts gains a Free Trial CTA. Adds an optional secondaryCta to LandingPageSection so a section can carry a second CTA, and shares the demo video tour link across all four demo pages, renamed to Video Tour. --- .../src/components/demos/demosData.ts | 5 ++ .../landing-pages/FrameworkCtaLink.tsx | 30 +++++++++++ .../ag-grid-docs/src/content.config.ts | 15 ++++++ .../src/content/homepage/homepage.json | 20 +++++++- .../src/pages-styles/homepage.module.scss | 3 ++ .../src/pages/example-finance.astro | 5 ++ .../ag-grid-docs/src/pages/example-hr.astro | 5 ++ .../src/pages/example-inventory.astro | 5 ++ .../ag-grid-docs/src/pages/example.astro | 10 ++-- .../ag-grid-docs/src/pages/index.astro | 17 +++++++ .../utils/markdown-pages/buildDemoMarkdown.ts | 2 + .../buildExampleMarkdown.test.ts | 2 +- .../markdown-pages/buildExampleMarkdown.ts | 7 +-- .../buildHomepageMarkdown.test.ts | 14 +++++- .../markdown-pages/buildHomepageMarkdown.ts | 33 +++++++++--- .../LandingPageSection.module.scss | 9 ++++ .../landing-pages/LandingPageSection.tsx | 50 ++++++++++++++++--- 17 files changed, 202 insertions(+), 30 deletions(-) create mode 100644 documentation/ag-grid-docs/src/components/landing-pages/FrameworkCtaLink.tsx diff --git a/documentation/ag-grid-docs/src/components/demos/demosData.ts b/documentation/ag-grid-docs/src/components/demos/demosData.ts index af3b398a2e4..495304d5952 100644 --- a/documentation/ag-grid-docs/src/components/demos/demosData.ts +++ b/documentation/ag-grid-docs/src/components/demos/demosData.ts @@ -13,6 +13,11 @@ export interface DemoTab { altDark: string; } +// The video tour of the demos. Linked from the header of every demo page and from the markdown +// twins, so the label and URL live here rather than being repeated per page. +export const VIDEO_TOUR_URL = 'https://youtu.be/bcMvTUVbMvI'; +export const VIDEO_TOUR_TEXT = 'Video Tour'; + export const demoTabs: DemoTab[] = [ { key: 'complete', diff --git a/documentation/ag-grid-docs/src/components/landing-pages/FrameworkCtaLink.tsx b/documentation/ag-grid-docs/src/components/landing-pages/FrameworkCtaLink.tsx new file mode 100644 index 00000000000..e9d6b5b7e5a --- /dev/null +++ b/documentation/ag-grid-docs/src/components/landing-pages/FrameworkCtaLink.tsx @@ -0,0 +1,30 @@ +import { Icon } from '@ag-website-shared/components/icon/Icon'; +import { gridUrlWithPrefix } from '@ag-website-shared/utils/gridUrlWithPrefix'; +import { useFramework } from '@utils/hooks/useFramework'; +import type { FunctionComponent, ReactNode } from 'react'; + +interface Props { + /** A './' path, resolved against the visitor's selected framework. */ + url: string; + id?: string; + className?: string; + /** Append a chevron, which the caller's styles animate on hover. */ + withChevron?: boolean; + children: ReactNode; +} + +/** + * A CTA link pointing at a framework-prefixed docs page, for use in the server-rendered parts of a + * landing page. Renders against the default framework on the server and follows the visitor's + * selection once hydrated, so it stays in step with the framework CTAs further down the page. + */ +export const FrameworkCtaLink: FunctionComponent = ({ url, id, className, withChevron, children }) => { + const { framework } = useFramework(); + + return ( + + {children} + {withChevron && } + + ); +}; diff --git a/documentation/ag-grid-docs/src/content.config.ts b/documentation/ag-grid-docs/src/content.config.ts index 323b582386e..63aa9e4529c 100644 --- a/documentation/ag-grid-docs/src/content.config.ts +++ b/documentation/ag-grid-docs/src/content.config.ts @@ -530,6 +530,11 @@ const homepage = defineCollection({ seeDemosUrl: z.string(), githubText: z.string(), githubUrl: z.string(), + freeTrialText: z.string(), + // A './' path, resolved against the visitor's selected framework at render. + freeTrialUrl: z.string(), + buyNowText: z.string(), + buyNowUrl: z.string(), }), sections: z.array( z.object({ @@ -545,6 +550,16 @@ const homepage = defineCollection({ // per-section behaviour); framework CTAs instead pass the raw './' path through. ctaUrlIsBaseUrl: z.boolean().optional(), ctaId: z.string().optional(), + // An extra CTA rendered after the section's main CTA. `isFramework` resolves its + // './' url against the visitor's selected framework. + secondaryCta: z + .object({ + title: z.string(), + url: z.string(), + id: z.string().optional(), + isFramework: z.boolean().optional(), + }) + .optional(), isFramework: z.boolean().optional(), showBackgroundGradient: z.boolean().optional(), sectionClass: z.string().optional(), diff --git a/documentation/ag-grid-docs/src/content/homepage/homepage.json b/documentation/ag-grid-docs/src/content/homepage/homepage.json index 8486e1bcbe9..54a39293205 100644 --- a/documentation/ag-grid-docs/src/content/homepage/homepage.json +++ b/documentation/ag-grid-docs/src/content/homepage/homepage.json @@ -6,7 +6,11 @@ "seeDemosText": "See demos", "seeDemosUrl": "./example/", "githubText": "View on GitHub", - "githubUrl": "https://github.com/ag-grid/ag-grid-demos/tree/main/finance" + "githubUrl": "https://github.com/ag-grid/ag-grid-demos/tree/main/finance", + "freeTrialText": "Free Trial", + "freeTrialUrl": "./community-vs-enterprise/#request-a-30-day-enterprise-bundle-trial-licence", + "buyNowText": "Buy Now", + "buyNowUrl": "/license-pricing/" }, "sections": [ { @@ -14,9 +18,15 @@ "tag": "Unbeatable Speed & Performance", "heading": "The Fastest Data Grid In The World", "subHeading": "Handle millions of rows, and thousands of updates per second out of the box, without compromising on performance", - "ctaTitle": "Get Started For Free", + "ctaTitle": "Explore the Docs", "ctaUrl": "./getting-started", "ctaId": "get-started-for-free", + "secondaryCta": { + "title": "Try Enterprise Free", + "url": "./community-vs-enterprise/#request-a-30-day-enterprise-bundle-trial-licence", + "id": "try-enterprise-free-cta", + "isFramework": true + }, "isFramework": true, "showBackgroundGradient": true }, @@ -38,6 +48,12 @@ "ctaTitle": "Explore Integrated Charts", "ctaUrl": "./integrated-charts/", "ctaId": "integrated-charting-cta", + "secondaryCta": { + "title": "Free Trial", + "url": "./community-vs-enterprise/#request-a-30-day-enterprise-bundle-trial-licence", + "id": "integrated-charting-free-trial-cta", + "isFramework": true + }, "isFramework": true, "showBackgroundGradient": true }, 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 51aca2c2416..3758f575149 100644 --- a/documentation/ag-grid-docs/src/pages-styles/homepage.module.scss +++ b/documentation/ag-grid-docs/src/pages-styles/homepage.module.scss @@ -163,6 +163,9 @@ body { .heroLinks { position: relative; display: flex; + // Wraps because the row now carries four CTAs — without it the trial and pricing buttons + // push the row past the viewport on narrow screens. + flex-wrap: wrap; gap: $spacing-size-2; align-items: center; margin: $spacing-size-4 0 0; diff --git a/documentation/ag-grid-docs/src/pages/example-finance.astro b/documentation/ag-grid-docs/src/pages/example-finance.astro index be7224d03ca..dec3417e606 100644 --- a/documentation/ag-grid-docs/src/pages/example-finance.astro +++ b/documentation/ag-grid-docs/src/pages/example-finance.astro @@ -2,6 +2,7 @@ import styles from '@pages-styles/example.module.scss'; import DemosLayout from '@components/demos/DemosLayout.astro'; import DemoTabs from '@components/demos/DemosTabs.astro'; +import { VIDEO_TOUR_TEXT, VIDEO_TOUR_URL } from '@components/demos/demosData'; import { Icon } from '@ag-website-shared/components/icon/Icon'; import { Finance } from 'src/components/demos/examples/finance'; import LogoMark from '@components/logo/LogoMark'; @@ -21,6 +22,10 @@ const content = demoContent('finance'); See On GitHub + + + {VIDEO_TOUR_TEXT} + Contact Us diff --git a/documentation/ag-grid-docs/src/pages/example-hr.astro b/documentation/ag-grid-docs/src/pages/example-hr.astro index a581ae04330..9d2cc90cf5c 100644 --- a/documentation/ag-grid-docs/src/pages/example-hr.astro +++ b/documentation/ag-grid-docs/src/pages/example-hr.astro @@ -1,6 +1,7 @@ --- import DemosLayout from '@components/demos/DemosLayout.astro'; import DemoTabs from '@components/demos/DemosTabs.astro'; +import { VIDEO_TOUR_TEXT, VIDEO_TOUR_URL } from '@components/demos/demosData'; import styles from '@pages-styles/example.module.scss'; import { Icon } from '@ag-website-shared/components/icon/Icon'; import { HR } from '@components/demos/examples/hr'; @@ -21,6 +22,10 @@ const content = demoContent('hr'); See On GitHub + + + {VIDEO_TOUR_TEXT} + Contact Us diff --git a/documentation/ag-grid-docs/src/pages/example-inventory.astro b/documentation/ag-grid-docs/src/pages/example-inventory.astro index 73a9dc8d397..b6b72f0f5c6 100644 --- a/documentation/ag-grid-docs/src/pages/example-inventory.astro +++ b/documentation/ag-grid-docs/src/pages/example-inventory.astro @@ -2,6 +2,7 @@ import styles from '@pages-styles/example.module.scss'; import DemosLayout from '@components/demos/DemosLayout.astro'; import DemoTabs from '@components/demos/DemosTabs.astro'; +import { VIDEO_TOUR_TEXT, VIDEO_TOUR_URL } from '@components/demos/demosData'; import { Icon } from '@ag-website-shared/components/icon/Icon'; import { Inventory } from '@components/demos/examples/inventory'; import LogoMark from '@components/logo/LogoMark'; @@ -21,6 +22,10 @@ const content = demoContent('inventory'); See On GitHub + + + {VIDEO_TOUR_TEXT} + Contact Us diff --git a/documentation/ag-grid-docs/src/pages/example.astro b/documentation/ag-grid-docs/src/pages/example.astro index 78e50d22a19..2dc218c7572 100644 --- a/documentation/ag-grid-docs/src/pages/example.astro +++ b/documentation/ag-grid-docs/src/pages/example.astro @@ -4,6 +4,7 @@ import Example from '@components/example-grid/Example'; import styles from '@pages-styles/example.module.scss'; import { Icon } from '@ag-website-shared/components/icon/Icon'; import DemoTabs from '@components/demos/DemosTabs.astro'; +import { VIDEO_TOUR_TEXT, VIDEO_TOUR_URL } from '@components/demos/demosData'; import LogoMark from '@components/logo/LogoMark'; import { urlWithBaseUrl } from '@utils/urlWithBaseUrl'; import { DISABLE_MARKDOWN_DOCS } from '@constants'; @@ -31,14 +32,9 @@ const markdownUrl = DISABLE_MARKDOWN_DOCS ? undefined : urlWithBaseUrl('/example See On GitHub - + - See The Video Tour + {VIDEO_TOUR_TEXT} Contact Us diff --git a/documentation/ag-grid-docs/src/pages/index.astro b/documentation/ag-grid-docs/src/pages/index.astro index 2f619e88c0d..41555554c7e 100644 --- a/documentation/ag-grid-docs/src/pages/index.astro +++ b/documentation/ag-grid-docs/src/pages/index.astro @@ -5,6 +5,7 @@ import styles from '@pages-styles/homepage.module.scss'; import { Quotes } from '@components/quotes/Quotes'; import { quotesData } from '@components/quotes/quotesData'; import { LandingPageFWSelector } from '@ag-website-shared/components/landing-pages/LandingPageFWSelector'; +import { FrameworkCtaLink } from '@components/landing-pages/FrameworkCtaLink'; import { urlWithBaseUrl } from '@utils/urlWithBaseUrl'; import { Version } from '@ag-website-shared/components/whats-new/components/Version'; import { Icon } from '@ag-website-shared/components/icon/Icon'; @@ -163,6 +164,22 @@ const markdownUrl = DISABLE_MARKDOWN_DOCS ? undefined : urlWithBaseUrl('/index.m
+ + {hero.freeTrialText} + + + {hero.buyNowText} + { }); it('includes the video and contact resources', () => { - expect(output).toContain('[See the video tour](https://youtu.be/bcMvTUVbMvI)'); + expect(output).toContain('[Video Tour](https://youtu.be/bcMvTUVbMvI)'); expect(output).toContain('[Contact Us](https://www.ag-grid.com/contact/)'); }); diff --git a/documentation/ag-grid-docs/src/utils/markdown-pages/buildExampleMarkdown.ts b/documentation/ag-grid-docs/src/utils/markdown-pages/buildExampleMarkdown.ts index d011e6d8c62..def131688cb 100644 --- a/documentation/ag-grid-docs/src/utils/markdown-pages/buildExampleMarkdown.ts +++ b/documentation/ag-grid-docs/src/utils/markdown-pages/buildExampleMarkdown.ts @@ -1,8 +1,5 @@ import { toAbsoluteUrl } from '@ag-website-shared/markdoc/toAbsoluteUrl'; -import { demoTabs } from '@components/demos/demosData'; - -// The video-tour link shown on the /example page header (example.astro). -const VIDEO_TOUR_URL = 'https://youtu.be/bcMvTUVbMvI'; +import { VIDEO_TOUR_TEXT, VIDEO_TOUR_URL, demoTabs } from '@components/demos/demosData'; /** * Build the markdown twin of the /example (demo) page. The page is almost entirely a live @@ -22,7 +19,7 @@ export function buildExampleMarkdown({ siteRoot }: { siteRoot?: string } = {}): .join('\n'); const resources = [ - `- [See the video tour](${VIDEO_TOUR_URL})`, + `- [${VIDEO_TOUR_TEXT}](${VIDEO_TOUR_URL})`, `- [Contact Us](${toAbsoluteUrl('/contact/', siteRoot)})`, ].join('\n'); diff --git a/documentation/ag-grid-docs/src/utils/markdown-pages/buildHomepageMarkdown.test.ts b/documentation/ag-grid-docs/src/utils/markdown-pages/buildHomepageMarkdown.test.ts index 79577abcafc..1ac0949d0f8 100644 --- a/documentation/ag-grid-docs/src/utils/markdown-pages/buildHomepageMarkdown.test.ts +++ b/documentation/ag-grid-docs/src/utils/markdown-pages/buildHomepageMarkdown.test.ts @@ -26,10 +26,22 @@ describe('buildHomepageMarkdown', () => { it('resolves CTA links absolutely', () => { // Framework CTA (./getting-started) and base-url CTA (/theme-builder/) both absolute. - expect(output).toMatch(/\[Get Started For Free\]\(https:\/\/www\.ag-grid\.com\/[^)]*getting-started/); + expect(output).toMatch(/\[Explore the Docs\]\(https:\/\/www\.ag-grid\.com\/[^)]*getting-started/); expect(output).toContain('[Create a Custom Theme](https://www.ag-grid.com/theme-builder/)'); }); + it('renders the hero trial and pricing CTAs', () => { + expect(output).toMatch( + /\[Free Trial\]\(https:\/\/www\.ag-grid\.com\/[^)]*community-vs-enterprise\/#request-a-30-day-enterprise-bundle-trial-licence\)/ + ); + expect(output).toContain('[Buy Now](https://www.ag-grid.com/license-pricing/)'); + }); + + it('renders a section secondary CTA after its main CTA', () => { + expect(output).toMatch(/\[Explore the Docs\]\([^)]*\) \| \[Try Enterprise Free\]\([^)]*\)/); + expect(output).toMatch(/\[Explore Integrated Charts\]\([^)]*\) \| \[Free Trial\]\([^)]*\)/); + }); + it('renders each section eyebrow headline as a kicker above its heading', () => { expect(output).toContain('*Unbeatable Speed & Performance*\n\n## The Fastest Data Grid In The World'); expect(output).toContain('*JavaScript Data Grid FAQs*\n\n## Frequently Asked Questions'); diff --git a/documentation/ag-grid-docs/src/utils/markdown-pages/buildHomepageMarkdown.ts b/documentation/ag-grid-docs/src/utils/markdown-pages/buildHomepageMarkdown.ts index 34bf7a69466..5d80a88665a 100644 --- a/documentation/ag-grid-docs/src/utils/markdown-pages/buildHomepageMarkdown.ts +++ b/documentation/ag-grid-docs/src/utils/markdown-pages/buildHomepageMarkdown.ts @@ -12,6 +12,11 @@ import versionsData from '../../content/versions/ag-grid-versions.json'; const FRAMEWORK = 'javascript'; const NUM_WHATS_NEW = 3; +interface SecondaryCta { + title: string; + url: string; + isFramework?: boolean; +} interface HomepageSection { id: string; tag?: string; @@ -22,6 +27,7 @@ interface HomepageSection { ctaTitle?: string; ctaUrl?: string; ctaUrlIsBaseUrl?: boolean; + secondaryCta?: SecondaryCta; } interface FaqItem { question: string; @@ -36,18 +42,27 @@ interface VersionEntry { highlights?: VersionHighlight[]; } -function resolveCtaUrl(section: HomepageSection, siteRoot?: string): string | undefined { - if (!section.ctaUrl) { +function resolveCtaUrl(ctaUrl: string | undefined, siteRoot?: string): string | undefined { + if (!ctaUrl) { return undefined; } // Framework CTAs store a './'-relative path resolved by the grid framework prefix at render; // the rest are already root-relative. - const url = section.ctaUrl.startsWith('./') - ? urlWithPrefix({ framework: FRAMEWORK, url: section.ctaUrl }) - : section.ctaUrl; + const url = ctaUrl.startsWith('./') ? urlWithPrefix({ framework: FRAMEWORK, url: ctaUrl }) : ctaUrl; return toAbsoluteUrl(url, siteRoot); } +/** The section's CTA links in the order the page renders them: main CTA, then any secondary CTA. */ +function ctaLinks(section: HomepageSection, siteRoot?: string): string[] { + const mainUrl = resolveCtaUrl(section.ctaUrl, siteRoot); + const { secondaryCta } = section; + + return [ + ...(mainUrl && section.ctaTitle ? [`[${section.ctaTitle}](${mainUrl})`] : []), + ...(secondaryCta ? [`[${secondaryCta.title}](${resolveCtaUrl(secondaryCta.url, siteRoot)})`] : []), + ]; +} + function whatsNewSection(): string { const versions = (versionsData as VersionEntry[]) .filter((version) => version.version.endsWith('.0')) @@ -90,9 +105,9 @@ function sectionBlock(section: HomepageSection, siteRoot?: string): string { if (subHeading) { parts.push(subHeading); } - const ctaUrl = resolveCtaUrl(section, siteRoot); - if (ctaUrl && section.ctaTitle) { - parts.push(`[${section.ctaTitle}](${ctaUrl})`); + const ctas = ctaLinks(section, siteRoot); + if (ctas.length) { + parts.push(ctas.join(' | ')); } if (section.id === 'whats-new') { parts.push(whatsNewSection()); @@ -119,6 +134,8 @@ export function buildHomepageMarkdown({ siteRoot }: { siteRoot?: string } = {}): ].join('\n'); const heroLinks = [ + `[${hero.freeTrialText}](${toAbsoluteUrl(urlWithPrefix({ framework: FRAMEWORK, url: hero.freeTrialUrl }), siteRoot)})`, + `[${hero.buyNowText}](${toAbsoluteUrl(urlWithPrefix({ framework: FRAMEWORK, url: hero.buyNowUrl }), siteRoot)})`, `[${hero.seeDemosText}](${toAbsoluteUrl(urlWithPrefix({ framework: FRAMEWORK, url: hero.seeDemosUrl }), siteRoot)})`, `[${hero.githubText}](${hero.githubUrl})`, ].join(' | '); diff --git a/external/ag-website-shared/src/components/landing-pages/LandingPageSection.module.scss b/external/ag-website-shared/src/components/landing-pages/LandingPageSection.module.scss index f1a7aec6614..3cb227f4900 100644 --- a/external/ag-website-shared/src/components/landing-pages/LandingPageSection.module.scss +++ b/external/ag-website-shared/src/components/landing-pages/LandingPageSection.module.scss @@ -85,6 +85,15 @@ gap: $spacing-size-2; } +// Lays out the section's CTAs in a row. A section with a single CTA is unaffected; sections that +// add a secondary CTA wrap onto a second line rather than overflowing on narrow viewports. +.ctaGroup { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: $spacing-size-3; +} + .ctaButton { display: inline-flex; align-items: center; diff --git a/external/ag-website-shared/src/components/landing-pages/LandingPageSection.tsx b/external/ag-website-shared/src/components/landing-pages/LandingPageSection.tsx index 4d4a8cfac8d..a0f1e34befc 100644 --- a/external/ag-website-shared/src/components/landing-pages/LandingPageSection.tsx +++ b/external/ag-website-shared/src/components/landing-pages/LandingPageSection.tsx @@ -31,6 +31,15 @@ const FRAMEWORK_CONFIGS: Record = { }, }; +/** An additional CTA rendered alongside the section's main CTA. */ +export interface SecondaryCta { + title: string; + url: string; + id?: string; + /** Resolve `url` against the visitor's selected framework, as the main CTA does when `isFramework`. */ + isFramework?: boolean; +} + interface Props { id: string; tag: string; @@ -42,6 +51,7 @@ interface Props { ctaTitle?: string; ctaUrl?: string; ctaId?: string; + secondaryCta?: SecondaryCta; sectionClass?: string; showBackgroundGradient?: boolean; children: ReactNode; @@ -160,6 +170,21 @@ const CTAWithFrameworks: FunctionComponent<{ ctaId: string; ctaTitle: string; ct ); }; +/** + * A secondary CTA. Carries no framework picker of its own — when `isFramework` is set it follows + * whichever framework the visitor has already selected, so it stays in step with the main CTA. + */ +const SecondaryCtaLink: FunctionComponent<{ cta: SecondaryCta }> = ({ cta }) => { + const { framework } = useFrameworkSelector(); + const href = cta.isFramework ? gridUrlWithPrefix({ framework, url: cta.url }) : cta.url; + + return ( + + {cta.title} + + ); +}; + export const LandingPageSection: FunctionComponent = ({ id, tag, @@ -170,6 +195,7 @@ export const LandingPageSection: FunctionComponent = ({ ctaTitle = 'Learn more', ctaUrl, ctaId, + secondaryCta, isFramework = false, sectionClass, showBackgroundGradient, @@ -180,7 +206,7 @@ export const LandingPageSection: FunctionComponent = ({ // strip pass no tag/heading/subHeading and must not render empty

/

/

tags. const hasHeading = Boolean(heading || headingHtml); const hasSubHeading = Boolean(subHeading || subHeadingHtml); - const hasHeader = Boolean(tag) || hasHeading || hasSubHeading || Boolean(ctaUrl); + const hasHeader = Boolean(tag) || hasHeading || hasSubHeading || Boolean(ctaUrl) || Boolean(secondaryCta); return (
= ({

{subHeading}

))} - {ctaUrl && isFramework && } + {(ctaUrl || secondaryCta) && ( +
+ {ctaUrl && isFramework && ( + + )} + + {ctaUrl && !isFramework && ( + + {ctaTitle} + + )} - {ctaUrl && !isFramework && ( - - {ctaTitle} - + {secondaryCta && } +
)} )} From b09da1e9ba7c511c371684d0b66f0bc29b22f2ee Mon Sep 17 00:00:00 2001 From: Guilherme Lopes Date: Fri, 21 Aug 2026 10:37:46 -0300 Subject: [PATCH 2/4] AG-16763 - [Excel Export] - Improved Row and Column outlines (#14929) * [Excel Export] - Fixed Row Outline * [Excel Export] - Fixed Column Outline * [Excel Export] - Row Outline coverage for groupHideOpenParents --- .../src/export/gridSerializer.ts | 11 +- .../ag-grid-community/src/gridOptionsUtils.ts | 9 + .../ag-grid-community/src/main-internal.ts | 1 + .../excelExport/excelSerializingSession.ts | 40 ++- .../src/excelExport/files/ooxml/worksheet.ts | 73 +++--- .../src/export/excel-export-outline.test.ts | 236 ++++++++++++++++++ 6 files changed, 319 insertions(+), 51 deletions(-) create mode 100644 testing/behavioural/src/export/excel-export-outline.test.ts diff --git a/packages/ag-grid-community/src/export/gridSerializer.ts b/packages/ag-grid-community/src/export/gridSerializer.ts index d115946d1c7..9fb5d00be40 100644 --- a/packages/ag-grid-community/src/export/gridSerializer.ts +++ b/packages/ag-grid-community/src/export/gridSerializer.ts @@ -10,8 +10,8 @@ import type { AgColumnGroup } from '../entities/agColumnGroup'; import type { RowNode } from '../entities/rowNode'; import { _addGridCommonParams, - _canSkipShowingRowGroup, _isClientSideRowModel, + _isHiddenSingleChildGroup, _isServerSideRowModel, } from '../gridOptionsUtils'; import type { ExportParams, ShouldRowBeSkippedParams } from '../interfaces/exportParams'; @@ -72,10 +72,7 @@ export class GridSerializer extends BeanStub implements NamedBean { const hideOpenParents = this.gos.get('groupHideOpenParents') && !isExplicitExportSelection; const isLeafNode = this.colModel.pivotMode ? node.leafGroup : !node.group; const isFooter = !!node.footer; - const shouldSkipCurrentGroup = - node.allChildrenCount === 1 && - node.childrenAfterGroup?.length === 1 && - _canSkipShowingRowGroup(this.gos, node); + const shouldSkipCurrentGroup = _isHiddenSingleChildGroup(this.gos, node); if ( (!isLeafNode && !isFooter && (params.skipRowGroups || shouldSkipCurrentGroup || hideOpenParents)) || @@ -389,7 +386,9 @@ export class GridSerializer extends BeanStub implements NamedBean { } private withCollapsibleGroupRanges(cell: GridHeaderCell, columnsToExport: AgColumn[]): GridHeaderCell { - if (cell.type !== 'group' && cell.type !== 'padding') { + // only real group cells contribute ranges: padding cells wrapping an expandable chain + // would re-emit the same range once per padded row, inflating the outline nesting + if (cell.type !== 'group') { return cell; } if (!cell.column?.isExpandable()) { diff --git a/packages/ag-grid-community/src/gridOptionsUtils.ts b/packages/ag-grid-community/src/gridOptionsUtils.ts index 58e471e5a8f..113693b48a3 100644 --- a/packages/ag-grid-community/src/gridOptionsUtils.ts +++ b/packages/ag-grid-community/src/gridOptionsUtils.ts @@ -338,6 +338,15 @@ export function _canSkipShowingRowGroup(gos: GridOptionsService, node: RowNode): return false; } +/** + * Returns true when a single-child group row is hidden from the UI (and from exports) + * due to `groupHideParentOfSingleChild` (or its deprecated equivalents). + * @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. + */ +export function _isHiddenSingleChildGroup(gos: GridOptionsService, node: RowNode): boolean { + return node.allChildrenCount === 1 && node.childrenAfterGroup?.length === 1 && _canSkipShowingRowGroup(gos, node); +} + /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ export function _getMaxConcurrentDatasourceRequests(gos: GridOptionsService): number | undefined { const res = gos.get('maxConcurrentDatasourceRequests'); diff --git a/packages/ag-grid-community/src/main-internal.ts b/packages/ag-grid-community/src/main-internal.ts index 1594d16c414..41076eead5a 100644 --- a/packages/ag-grid-community/src/main-internal.ts +++ b/packages/ag-grid-community/src/main-internal.ts @@ -204,6 +204,7 @@ export { _isGroupMultiAutoColumn, _isGroupRowsSticky, _isGroupUseEntireRow, + _isHiddenSingleChildGroup, _isLegacyMenuEnabled, _isMultiRowSelection, _isRowNumbers, diff --git a/packages/ag-grid-enterprise/src/excelExport/excelSerializingSession.ts b/packages/ag-grid-enterprise/src/excelExport/excelSerializingSession.ts index ab6753e82d2..19a976cbc75 100644 --- a/packages/ag-grid-enterprise/src/excelExport/excelSerializingSession.ts +++ b/packages/ag-grid-enterprise/src/excelExport/excelSerializingSession.ts @@ -25,7 +25,12 @@ import type { RowHeightCallbackParams, RowNode, } from 'ag-grid-community'; -import { BaseGridSerializingSession, _addGridCommonParams, _mergeDeep } from 'ag-grid-community'; +import { + BaseGridSerializingSession, + _addGridCommonParams, + _isHiddenSingleChildGroup, + _mergeDeep, +} from 'ag-grid-community'; import type { InternalExcelCell } from './assets/excelInterfaces'; import { getHeightFromProperty } from './assets/excelUtils'; @@ -338,16 +343,12 @@ export class ExcelSerializingSession extends BaseGridSerializingSession
-
+
{captchaError &&

Please click on the reCAPTCHA checkbox

}
diff --git a/external/ag-website-shared/src/components/contact-form/initCaptcha.ts b/external/ag-website-shared/src/components/contact-form/initCaptcha.ts index 8315eb5bae1..8f2a3d47d87 100644 --- a/external/ag-website-shared/src/components/contact-form/initCaptcha.ts +++ b/external/ag-website-shared/src/components/contact-form/initCaptcha.ts @@ -1,5 +1,12 @@ +export interface CaptchaTicker { + /** Writes the frozen timestamp back into the hidden input, just before submitting. */ + reapply: () => void; + /** Stops the ticker. Call on unmount, or it keeps running for the life of the page. */ + stop: () => void; +} + /** - * Starts Salesforce's captcha timestamp ticker. + * Starts Salesforce's captcha timestamp ticker for one rendered reCAPTCHA widget. * * `onTimestamp` receives every value written, so the caller can render it back into the * hidden input: React resets a controlled input on re-render, and `form.submit()` only @@ -10,7 +17,7 @@ * The ticker stops once the captcha is solved, freezing `ts` at solve time, which is the * elapsed-time signal Salesforce validates. */ -export function initCaptcha(onTimestamp?: (ts: string) => void): () => void { +export function initCaptcha(container: HTMLElement, onTimestamp?: (ts: string) => void): CaptchaTicker { let latest = ''; function write(ts: string) { @@ -24,14 +31,21 @@ export function initCaptcha(onTimestamp?: (ts: string) => void): () => void { } function timestamp() { - const response = document.getElementById('g-recaptcha-response') as HTMLInputElement; - if (response == null || response.value.trim() == '') { + // Scoped to this widget's own container: reCAPTCHA only names the first widget's field + // `g-recaptcha-response`, and suffixes every later one (`g-recaptcha-response-1` and so + // on), so a lookup by bare id silently misses any widget rendered after the first. + const response = container.querySelector('textarea[name="g-recaptcha-response"]'); + if (response == null || response.value.trim() === '') { latest = JSON.stringify(new Date().getTime()); write(latest); onTimestamp?.(latest); } } - setInterval(timestamp, 500); - return () => write(latest); + const ticker = setInterval(timestamp, 500); + + return { + reapply: () => write(latest), + stop: () => clearInterval(ticker), + }; }