diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab787f59e43..972ef2041ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -513,10 +513,18 @@ jobs: if: needs.test.result == 'success' || needs.test.result == 'failure' || needs.e2e.result == 'success' || needs.e2e.result == 'failure' run: | + mkdir -p reports/ + if [ ! -d test-results/ ]; then + echo "No test-results/ directory; nothing to merge." + exit 0 + fi + reports=$(find test-results/ -name \*.xml -type f -exec basename \{\} \; | sort | uniq) + if [ -z "$reports" ]; then + echo "No JUnit XML files found under test-results/; nothing to merge." + exit 0 + fi YARN_REGISTRY= yarn global add junit-report-merger - reports=$(find test-results/ -name \*.xml -type f -exec basename \{\} \; | sort | uniq) - mkdir -p reports/ echo "$reports" | (while read name ; do yarn exec -s jrm reports/${name} "test-results/**/${name}" done) @@ -606,44 +614,31 @@ jobs: - name: SlackBot if: always() && job.status != 'cancelled' && github.ref == 'refs/heads/latest' - env: - CI_GRID_GATE_CHANNEL: ${{ env.SLACK_CHANNEL }} - CHARTS_TEAM_CITY_CHANNEL: ${{ secrets.CHARTS_TEAM_CITY_CHANNEL }} - WEBSITE_STATUS_CHANNEL: ${{ secrets.WEBSITE_STATUS_CHANNEL }} + uses: ./external/ag-shared/github/actions/slack-ci-notification + with: SLACK_BOT_OAUTH_TOKEN: ${{ secrets.SLACK_BOT_OAUTH_TOKEN }} - SLACK_DEBUG_CHANNEL: ${{ secrets.SLACK_DEBUG_CHANNEL }} - SLACK_GITHUB_MAPPING: ${{ secrets.SLACK_GITHUB_MAPPING }} - RUN_CONTEXT: > + NOTION_API_TOKEN: ${{ secrets.SLACK_USER_CONFIG_NOTION_API_TOKEN }} + NOTION_DATA_SOURCE_ID: ${{ secrets.SLACK_USER_CONFIG_NOTION_DATA_SOURCE_ID }} + AG_PROJECT: AgGrid + LAST_SUCCESSFUL_SHA: ${{ steps.find_latest_sha.outputs.sha }} + TEAM_CHANNEL: ${{ env.SLACK_CHANNEL }} + DEBUG_CHANNEL: ${{ secrets.SLACK_DEBUG_CHANNEL }} + WEBSITE_STATUS_CHANNEL: ${{ secrets.WEBSITE_STATUS_CHANNEL }} + REPORT_URL: ${{ steps.testReport.outputs.url_html }} + CHANGED_STATE: ${{ steps.lastJobStatus.outputs.changedState }} + DEPLOY_TO_STAGING: ${{ needs.docs.outputs.docs_deployed == 'success' }} + JOB_STATUSES: > { - "workflow": "${{ github.workflow }}", - "ref": "${{ github.ref }}", - "currentSha": "${{ github.sha }}", - "lastSuccessfulSha": "${{ steps.find_latest_sha.outputs.sha }}", - "runId": "${{ github.run_id }}", - "project": "AgGrid", - "reportUrl": "${{ steps.testReport.outputs.url_html }}", - "deployToStaging": ${{ needs.docs.outputs.docs_deployed == 'success' }}, - "changedState": ${{ steps.lastJobStatus.outputs.changedState == 'true' }}, - "jobStatuses": - { - "Format": "${{ needs.lint.outputs.format }}", - "Lint": "${{ needs.lint.outputs.lint }}", - "Build": "${{ needs.build.outputs.build }}", - "Test": "${{ needs.test.result }}", - "e2e": "${{ needs.e2e.result }}", - "FW_Pkg": "${{ needs.fw_pkg_test.result }}", - "Docs": "${{ needs.docs.result }}", - "SonarCommunity": "${{ needs.sonar_community.result }}", - "SonarEnterprise": "${{ needs.sonar_enterprise.result }}" - } + "Format": "${{ needs.lint.outputs.format }}", + "Lint": "${{ needs.lint.outputs.lint }}", + "Build": "${{ needs.build.outputs.build }}", + "Test": "${{ needs.test.result }}", + "e2e": "${{ needs.e2e.result }}", + "FW_Pkg": "${{ needs.fw_pkg_test.result }}", + "Docs": "${{ needs.docs.result }}", + "SonarCommunity": "${{ needs.sonar_community.result }}", + "SonarEnterprise": "${{ needs.sonar_enterprise.result }}" } - run: | - npx ts-node ./scripts/agBotSlackMessage.ts --auth-token "$SLACK_BOT_OAUTH_TOKEN" \ - --grid-channel "$CI_GRID_GATE_CHANNEL" \ - --charts-channel "$CHARTS_TEAM_CITY_CHANNEL" \ - --website-status-channel "$WEBSITE_STATUS_CHANNEL" \ - --debug-channel "$SLACK_DEBUG_CHANNEL" \ - --run-context "$RUN_CONTEXT" - name: Fail job if workflow failed if: success() && steps.lastJobStatus.outputs.workflowStatus == 'failure' diff --git a/documentation/ag-grid-docs/eslint.config.mjs b/documentation/ag-grid-docs/eslint.config.mjs index aff7eade294..b3921732ed3 100644 --- a/documentation/ag-grid-docs/eslint.config.mjs +++ b/documentation/ag-grid-docs/eslint.config.mjs @@ -19,6 +19,7 @@ export default [ '**/systemjs.config.js', '**/systemjs.config.dev.js', '.playwright-network-cache/', + '**/*.ics', ], }, { diff --git a/documentation/ag-grid-docs/public/downloads/beyond-the-prompt/beyond-the-prompt-agenda.ics b/documentation/ag-grid-docs/public/downloads/beyond-the-prompt/beyond-the-prompt-agenda.ics new file mode 100644 index 00000000000..4a04fac2af7 --- /dev/null +++ b/documentation/ag-grid-docs/public/downloads/beyond-the-prompt/beyond-the-prompt-agenda.ics @@ -0,0 +1,412 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//AG Grid & Bryntum//Beyond The Prompt 2026//EN +CALSCALE:GREGORIAN +X-WR-CALNAME:Beyond The Prompt — 19 May 2026 +X-WR-TIMEZONE:Europe/London +BEGIN:VTIMEZONE +TZID:Europe/London +X-LIC-LOCATION:Europe/London +BEGIN:DAYLIGHT +TZOFFSETFROM:+0000 +TZOFFSETTO:+0100 +TZNAME:BST +DTSTART:19700329T010000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0100 +TZOFFSETTO:+0000 +TZNAME:GMT +DTSTART:19701025T020000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +UID:btp-2026-registration@ag-grid.com +DTSTAMP:20260514T131617Z +DTSTART;TZID=Europe/London:20260519T083000 +DTEND;TZID=Europe/London:20260519T093000 +SUMMARY:Registration and Coffee +ORGANIZER;CN=Sylwia Vargas:mailto:sylwia.vargas@ag-grid.com +LOCATION:Hasslett\, IET London\, 2 Savoy Place\, London WC2R 0BL +DESCRIPTION: +END:VEVENT +BEGIN:VEVENT +UID:btp-2026-welcome@ag-grid.com +DTSTAMP:20260514T131617Z +DTSTART;TZID=Europe/London:20260519T093000 +DTEND;TZID=Europe/London:20260519T094000 +SUMMARY:Welcome | Phil Hawksworth (Event MC) +ORGANIZER;CN=Sylwia Vargas:mailto:sylwia.vargas@ag-grid.com +LOCATION:Auditorium\, IET London\, 2 Savoy Place\, London WC2R 0BL +DESCRIPTION:Welcome | Phil Hawksworth (Event MC)\nAuditorium\n\n**Talk desc + ription**: Opening remarks to set the theme for the day: building with too + ls you can trust.\n\n**Speaker bio**: With a passion for browser technolog + ies\, and the empowering properties of the web\, Phil loves seeking out in + genuity and simplicity\, especially in places where over-engineering is co + mmon. After 25 years of building web applications for companies such as Go + ogle\, Apple\, Nike\, R/GA\, and The London Stock Exchange\, he has worked + to challenge traditional technical architectures in favour of simplicity + and effectiveness\, working in Developer Experience at Netlify and Deno.\n + \n✨ Tag us on social media!\n\n* Twitter: @ag_grid\n* Bsky: @ag-grid.bsk + y.social\n* LinkedIn: @ag-grid\n\nConnect with Phil:\n\n* Twitter: https:/ + /twitter.com/philhawksworth\n* Bluesky: https://bsky.app/profile/philhawks + worth.dev\n* GitHub: https://github.com/philhawksworth\n\n---------------- + ------------------------------- +END:VEVENT +BEGIN:VEVENT +UID:btp-2026-keynote@ag-grid.com +DTSTAMP:20260514T131617Z +DTSTART;TZID=Europe/London:20260519T094000 +DTEND;TZID=Europe/London:20260519T101000 +SUMMARY:Opening Keynote | John Masterson (CEO\, AG Grid) & Mats Bryntse (Fo + under & CEO\, Bryntum) +ORGANIZER;CN=Sylwia Vargas:mailto:sylwia.vargas@ag-grid.com +LOCATION:Auditorium\, IET London\, 2 Savoy Place\, London WC2R 0BL +DESCRIPTION:Opening Keynote | John Masterson (CEO\, AG Grid) & Mats Bryntse + (Founder & CEO\, Bryntum)\nAuditorium\n\n**Speaker bios**:\n\nJohn Master + son is the CEO of AG Grid\, which he first joined in 2016 as employee numb + er two. After a detour as CTO of a London health-tech startup\, he returne + d in 2020 and stepped into the CEO role in 2024. He's spent fifteen years + building software and leading engineering teams\, and is determined to kee + p AG Grid the first choice for JavaScript developers. Outside of work\, Jo + hn can be found on his bike\, in his headphones listening to a podcast\, o + r picking up a guitar.\n\nMats Bryntse is the founder and CEO of Bryntum\, + where he and his team build advanced scheduling and project planning tool + s for modern web apps. For the past 15 years\, he has obsessed over JavaSc + ript performance\, developer experience\, and making complex UIs feel simp + le. He used to enjoy chess\, badminton\, and independent thought\, until C + laude entered his life and optimized those away.\n\n\n✨ Tag us on social + media!\n\n* Twitter: @ag_grid\n* Bsky: @ag-grid.bsky.social\n* LinkedIn: + @ag-grid\n\nConnect with Mats:\n\n* Twitter: https://x.com/bryntum\n* Link + edIn: https://www.linkedin.com/in/matsbryntse/\n* GitHub: https://github.c + om/matsbryntse\n\n----------------------------------------------- +END:VEVENT +BEGIN:VEVENT +UID:btp-2026-khourshid@ag-grid.com +DTSTAMP:20260514T131617Z +DTSTART;TZID=Europe/London:20260519T101000 +DTEND;TZID=Europe/London:20260519T104000 +SUMMARY:Goodbye slop\; welcome determinism | David Khourshid (Founder\, Sta + tely.ai) +ORGANIZER;CN=Sylwia Vargas:mailto:sylwia.vargas@ag-grid.com +LOCATION:Auditorium\, IET London\, 2 Savoy Place\, London WC2R 0BL +DESCRIPTION:Goodbye slop\; welcome determinism | David Khourshid (Founder\, + Stately.ai)\nAuditorium\n\n**Talk description**: Vibe coding feels produc + tive until you have to maintain it. Behind the agent "thinking..." you ign + ore and the code you never opened lies a growing pile of wasted tokens\, n + ondeterministic behavior\, and compounding errors hiding in plain sight. T + his talk pushes back on the status quo: elaborate agent architectures\, "p + rompting astrology"\, overnight automation loops burning through context w + indows and credit cards. Come for the critique\, leave with a framework fo + r using AI to build software you actually understand.\n\n**Speaker bio**: + David is the founder of Stately.ai and creator of XState\, the most popula + r open-source state machine & statecharts library. He's a longtime advocat + e for event-driven modeling and visual diagramming as the foundation for r + eliable UIs and\, increasingly\, AI agents. When he's not at a computer ke + yboard\, he's at a piano keyboard.\n\n✨ Tag us on social media!\n\n* Twi + tter: @ag_grid\n* Bsky: @ag-grid.bsky.social\n* LinkedIn: @ag-grid\n\nConn + ect with David:\n\n* Twitter: https://x.com/davidkpiano\n* LinkedIn: https + ://linkedin.com/in/davidkpiano\n* GitHub: https://github.com/davidkpiano\n + \n----------------------------------------------- +END:VEVENT +BEGIN:VEVENT +UID:btp-2026-coffee1@ag-grid.com +DTSTAMP:20260514T131617Z +DTSTART;TZID=Europe/London:20260519T104000 +DTEND;TZID=Europe/London:20260519T111000 +SUMMARY:Coffee Break +ORGANIZER;CN=Sylwia Vargas:mailto:sylwia.vargas@ag-grid.com +LOCATION:Hasslett\, IET London\, 2 Savoy Place\, London WC2R 0BL +DESCRIPTION: +END:VEVENT +BEGIN:VEVENT +UID:btp-2026-cooper@ag-grid.com +DTSTAMP:20260514T131617Z +DTSTART;TZID=Europe/London:20260519T111000 +DTEND;TZID=Europe/London:20260519T113500 +SUMMARY:Codebase design for the agent era | Stephen Cooper (Team Lead\, AG + Grid) +ORGANIZER;CN=Sylwia Vargas:mailto:sylwia.vargas@ag-grid.com +LOCATION:Auditorium\, IET London\, 2 Savoy Place\, London WC2R 0BL +DESCRIPTION:Codebase design for the agent era | Stephen Cooper (Team Lead\, + AG Grid)\nAuditorium\n\n**Talk description**: As AI agents become part of + the development workflow\, codebase structure and well-designed system pr + ompts matter more than ever. This session will show you how we're approach + ing this in the AG Grid and AG Charts codebases.\n\n**Speaker bio**: Steph + en is the Team Lead for AG Grid and loves sharing practical\, experience-b + ased tips\, tricks\, and case studies from years in the codebase. He's gon + e deep into grid performance and framework integrations\, and has spent mo + re time than he'd like profiling render cycles. Outside of work\, life rev + olves around family\, four kids and two dogs\, and he's happiest when the + whole crew is out together exploring in the park.\n\n✨ Tag us on social + media!\n\n* Twitter: @ag_grid\n* Bsky: @ag-grid.bsky.social\n* LinkedIn: @ + ag-grid\n\nConnect with Stephen:\n\n* LinkedIn: https://linkedin.com/in/sc + ooper-dev\n* GitHub: https://github.com/StephenCooper\n\n----------------- + ------------------------------ +END:VEVENT +BEGIN:VEVENT +UID:btp-2026-hobson@ag-grid.com +DTSTAMP:20260514T131617Z +DTSTART;TZID=Europe/London:20260519T113500 +DTEND;TZID=Europe/London:20260519T120000 +SUMMARY:AI in AG Studio | Josh Hobson (Developer\, AG Grid) +ORGANIZER;CN=Sylwia Vargas:mailto:sylwia.vargas@ag-grid.com +LOCATION:Auditorium\, IET London\, 2 Savoy Place\, London WC2R 0BL +DESCRIPTION:AI in AG Studio | Josh Hobson (Developer\, AG Grid)\nAuditorium + \n\n**Talk description**: How do you build a dashboard you can't see? A be + hind-the-scenes look at AG Studio's multi-agent architecture and the clien + t-side tools that let any LLM build reports it otherwise couldn't.\n\n**Sp + eaker bio**: Josh is a developer at AG Grid\, where he's been building AG + Studio — a new dashboard library with multi-agent AI baked in. He's a ma + thematician by training and a maker by instinct\, with a particular love f + or developer tooling\, type systems\, and intelligent interfaces. Outside + of work\, Josh can be found tinkering with 3D printers\, ski touring in th + e Alps\, or out on long walks with his dog.\n\n✨ Tag us on social media! + \n\n* Twitter: @ag_grid\n* Bsky: @ag-grid.bsky.social\n* LinkedIn: @ag-gri + d\n\nConnect with Josh:\n\n* GitHub: https://github.com/AlpineJosh\n\n---- + ------------------------------------------- +END:VEVENT +BEGIN:VEVENT +UID:btp-2026-ruiz@ag-grid.com +DTSTAMP:20260514T131617Z +DTSTART;TZID=Europe/London:20260519T120000 +DTEND;TZID=Europe/London:20260519T122500 +SUMMARY:Bringing AI to the Canvas | Steve Ruiz (CEO\, tldraw) +ORGANIZER;CN=Sylwia Vargas:mailto:sylwia.vargas@ag-grid.com +LOCATION:Auditorium\, IET London\, 2 Savoy Place\, London WC2R 0BL +DESCRIPTION:Bringing AI to the Canvas | Steve Ruiz (CEO\, tldraw)\nAuditori + um\n\n**Talk description**: At tldraw\, we've been bringing agents to our + infinite canvas. In December 2025\, we ran a one-month experiment named Fa + irydraw where users could work with three fairies — virtual collaborator + s who work with you\, with your human collaborators\, and coordinate toget + her on large tasks.\n\n**Speaker bio**: A developer\, designer\, and now s + tartup founder in London. With a background in visual art\, Steve works pr + imarily in creative tools for the web. He is known for tldraw\, and demos + of perfect arrows.\n\n✨ Tag us on social media!\n\n* Twitter: @ag_grid\n + * Bsky: @ag-grid.bsky.social\n* LinkedIn: @ag-grid\n\nConnect with Steve:\ + n\n* Twitter: https://x.com/steveruizok\n\n------------------------------- + ---------------- +END:VEVENT +BEGIN:VEVENT +UID:btp-2026-lunch@ag-grid.com +DTSTAMP:20260514T131617Z +DTSTART;TZID=Europe/London:20260519T122500 +DTEND;TZID=Europe/London:20260519T133500 +SUMMARY:Lunch and Product Demos +ORGANIZER;CN=Sylwia Vargas:mailto:sylwia.vargas@ag-grid.com +LOCATION:Hasslett\, IET London\, 2 Savoy Place\, London WC2R 0BL +DESCRIPTION: +END:VEVENT +BEGIN:VEVENT +UID:btp-2026-sumption@ag-grid.com +DTSTAMP:20260514T131617Z +DTSTART;TZID=Europe/London:20260519T133500 +DTEND;TZID=Europe/London:20260519T140500 +SUMMARY:Debugging CSS performance with AI | Bernie Sumption (Engineer\, AG + Grid) +ORGANIZER;CN=Sylwia Vargas:mailto:sylwia.vargas@ag-grid.com +LOCATION:Auditorium\, IET London\, 2 Savoy Place\, London WC2R 0BL +DESCRIPTION:Debugging CSS performance with AI | Bernie Sumption (Engineer\, + AG Grid)\nAuditorium\n\n**Talk description**: CSS performance issues can + be subtle and time-consuming. This talk will show you how you can guide AI + to uncover bugs without needing to learn the intricacies of CSS rendering + internals.\n\n**Speaker bio**: Bernie is an engineer at AG Grid specialis + ing in theming. "The other engineers make it work fast and well\, I make i + t look pretty" he likes to say. Outside work he goes hiking\, plays with h + is kids\, and once made a tweeting cat flap that has 10x more social media + followers than he does.\n\n✨ Tag us on social media!\n\n* Twitter: @ag_ + grid\n* Bsky: @ag-grid.bsky.social\n* LinkedIn: @ag-grid\n\nConnect with B + ernie:\n\n* LinkedIn: https://uk.linkedin.com/in/berniesumption\n* GitHub: + https://github.com/BernieSumption\n\n------------------------------------ + ----------- +END:VEVENT +BEGIN:VEVENT +UID:btp-2026-rau@ag-grid.com +DTSTAMP:20260514T131617Z +DTSTART;TZID=Europe/London:20260519T140500 +DTEND;TZID=Europe/London:20260519T142000 +SUMMARY:Software that moves fleets: Lessons from AG Grid\, Bryntum\, and Be + yond | Patrick Rau (Senior Developer\, TCS) +ORGANIZER;CN=Sylwia Vargas:mailto:sylwia.vargas@ag-grid.com +LOCATION:Auditorium\, IET London\, 2 Savoy Place\, London WC2R 0BL +DESCRIPTION:Software that moves fleets: Lessons from AG Grid\, Bryntum\, an + d Beyond | Patrick Rau (Senior Developer\, TCS)\nAuditorium\n\n**Talk desc + ription**: In aviation\, the software has to be rock solid. Patrick pulls + back the curtain on the components powering Fleetplan's UI\, including AG + Grid\, Bryntum's Scheduler Pro\, and the modules behind audit tracking and + safety reporting that keep operations airtight.\n\n**Speaker bio**: Patri + ck builds and maintains core parts of Team Centric Software's fleetplan pl + atform\, specializing in UI integration and complex components like AG Gri + d\, Bryntum's Scheduler Pro\, and modules for audit tracking and safety re + porting. He's driven by a passion for creating software that makes a real + difference for the people who use it. When he's not solving problems at wo + rk\, you'll find him exploring new technologies through personal projects\ + , gaming\, or planning his next trip.\n\n✨ Tag us on social media!\n\n* + Twitter: @ag_grid\n* Bsky: @ag-grid.bsky.social\n* LinkedIn: @ag-grid\n\nC + onnect with Patrick:\n\n* LinkedIn: https://www.linkedin.com/in/patrick-r- + ab2168183/\n* GitHub: https://github.com/14rau\n\n------------------------ + ----------------------- +END:VEVENT +BEGIN:VEVENT +UID:btp-2026-roadmap@ag-grid.com +DTSTAMP:20260514T131617Z +DTSTART;TZID=Europe/London:20260519T142000 +DTEND;TZID=Europe/London:20260519T145000 +SUMMARY:Product Roadmap | Johan Isaksson (Head of Engineering\, Bryntum) & + Adam Wang (AG Studio Product Lead\, AG Grid) +ORGANIZER;CN=Sylwia Vargas:mailto:sylwia.vargas@ag-grid.com +LOCATION:Auditorium\, IET London\, 2 Savoy Place\, London WC2R 0BL +DESCRIPTION:Product Roadmap | Johan Isaksson (Head of Engineering\, Bryntum + ) & Adam Wang (AG Studio Product Lead\, AG Grid)\nAuditorium\n\n**Speaker + bios**:\n\nJohan Isaksson is responsible for architecture\, gate keeping\, + performance and styling across all Bryntum products. When not checking pu + ll requests\, recording performance profiles or tweaking CSS he enjoys wat + ching hockey and playing floorball.\n\nAdam Wang is the AG Studio Product + Lead at AG Grid\, with around 10 years of experience in product management + across various disciplines. He previously worked on AG Grid Integrated Ch + arts before focusing on AG Studio. He enjoys the creative process of produ + ct management and is keen to solve problems by truly understanding user ne + eds. Outside of work\, he's a spin instructor who loves to put together a + fire playlist. He also collects coloured vinyl.\n\n\n✨ Tag us on social + media!\n\n* Twitter: @ag_grid\n* Bsky: @ag-grid.bsky.social\n* LinkedIn: @ + ag-grid\n\nConnect with Adam:\n\n* LinkedIn: https://www.linkedin.com/in/a + dam-wang-77a8bb88/\n\n----------------------------------------------- +END:VEVENT +BEGIN:VEVENT +UID:btp-2026-coffee2@ag-grid.com +DTSTAMP:20260514T131617Z +DTSTART;TZID=Europe/London:20260519T145000 +DTEND;TZID=Europe/London:20260519T152000 +SUMMARY:Coffee Break +ORGANIZER;CN=Sylwia Vargas:mailto:sylwia.vargas@ag-grid.com +LOCATION:Hasslett\, IET London\, 2 Savoy Place\, London WC2R 0BL +DESCRIPTION: +END:VEVENT +BEGIN:VEVENT +UID:btp-2026-bryntse@ag-grid.com +DTSTAMP:20260514T131617Z +DTSTART;TZID=Europe/London:20260519T152000 +DTEND;TZID=Europe/London:20260519T154500 +SUMMARY:One-click agentic SDLC | Mats Bryntse (Founder & CEO\, Bryntum) +ORGANIZER;CN=Sylwia Vargas:mailto:sylwia.vargas@ag-grid.com +LOCATION:Auditorium\, IET London\, 2 Savoy Place\, London WC2R 0BL +DESCRIPTION:One-click agentic SDLC | Mats Bryntse (Founder & CEO\, Bryntum) + \nAuditorium\n\n**Talk description**: A demo of a headless Claude workflow + \, built by Bryntum CEO Mats\, that turns GitHub issues into mergeable PRs + \, with agents doing the work\, and a custom Kanban UI keeping things in c + heck.\n\n**Speaker bio**: Mats is the founder and CEO of Bryntum\, where h + e and his team build advanced scheduling and project planning tools for mo + dern web apps. For the past 15 years\, he has obsessed over JavaScript per + formance\, developer experience\, and making complex UIs feel simple. He u + sed to enjoy chess\, badminton\, and independent thought\, until Claude en + tered his life and optimized those away.\n\n✨ Tag us on social media!\n\ + n* Twitter: @ag_grid\n* Bsky: @ag-grid.bsky.social\n* LinkedIn: @ag-grid\n + \nConnect with Mats:\n\n* Twitter: https://x.com/bryntum\n* LinkedIn: http + s://www.linkedin.com/in/matsbryntse/\n* GitHub: https://github.com/matsbry + ntse\n\n----------------------------------------------- +END:VEVENT +BEGIN:VEVENT +UID:btp-2026-panel@ag-grid.com +DTSTAMP:20260514T131617Z +DTSTART;TZID=Europe/London:20260519T154500 +DTEND;TZID=Europe/London:20260519T163000 +SUMMARY:How agentic AI is reshaping software engineering | Panel: Maggie Ap + pleton\, Matt Pocock\, Sophie Koonin\, Phil Hawksworth (MC) +ORGANIZER;CN=Sylwia Vargas:mailto:sylwia.vargas@ag-grid.com +LOCATION:Auditorium\, IET London\, 2 Savoy Place\, London WC2R 0BL +DESCRIPTION:How agentic AI is reshaping software engineering | Maggie Apple + ton (Staff Research Engineer\, GitHub)\, Matt Pocock (Senior Developer Edu + cator\, AI Hero)\, Sophie Koonin (Web Discipline Lead\, Monzo)\, Phil Hawk + sworth (MC)\nAuditorium\n\n**Talk description**: As AI reshapes how softwa + re gets built\, what actually changes for engineers\, teams\, and develope + r tools? This panel explores the real-world impact of agentic workflows on + software engineering — from code review and system design to cognitive + skills\, ownership\, and developer experience. Expect practical insights\, + rapid-fire hot takes\, and honest discussion from engineers building beyo + nd the prompt.\n\n**Speaker bios**:\n\nMaggie Appleton is a Staff Research + Engineer at GitHub\, where she works on tools for thinking\, writing\, an + d building with code. With a background in anthropology\, she's known for + her visual essays that explore how developers understand systems\, languag + es\, and ideas. She's a strong advocate for "digital gardens" over traditi + onal publishing\, and spends her time mapping out how knowledge grows on t + he web. Outside of work\, she's usually sketching concepts\, writing\, or + connecting dots between code and culture.\n\nMatt Pocock is a senior devel + oper educator at AI Hero\, a TypeScript author\, and an educator passionat + e about bringing real software engineering rigour to the age of AI. He co- + organizes the AI Coding for Real Engineers cohort\, a community for experi + enced developers who want to build with AI tools without throwing away eve + rything they already know. Outside of the keyboard\, Matt enjoys long runs + through the Oxfordshire countryside\, loudly supporting Arsenal\, and exp + erimenting with new ways to make complex ideas click for developers everyw + here.\n\nSophie Koonin leads a team within the Operations collective at Mo + nzo\, building the software that powers the bank's award-winning customer + experience. She's also Monzo's Web Discipline Lead\, advocating for web an + d empowering others to lead web platform improvements throughout the compa + ny. Outside of work\, Sophie loves arranging pop songs for her choir Mixta + pe\, playing video games\, cooking\, and gardening.\n\nPhil Hawksworth — + With a passion for browser technologies\, and the empowering properties o + f the web\, Phil loves seeking out ingenuity and simplicity\, especially i + n places where over-engineering is common. After 25 years of building web + applications for companies such as Google\, Apple\, Nike\, R/GA\, and The + London Stock Exchange\, he has worked to challenge traditional technical a + rchitectures in favour of simplicity and effectiveness\, working in Develo + per Experience at Netlify and Deno.\n\n\n✨ Tag us on social media!\n\n* + Twitter: @ag_grid\n* Bsky: @ag-grid.bsky.social\n* LinkedIn: @ag-grid\n\nC + onnect with Matt Pocock:\n\n* Twitter: https://x.com/mattpocockuk\n* GitHu + b: https://github.com/mattpocock\n\nConnect with Sophie:\n\n* Website: htt + ps://localghost.dev\n\nConnect with Phil:\n\n* Twitter: https://twitter.co + m/philhawksworth\n* Bluesky: https://bsky.app/profile/philhawksworth.dev\n + * GitHub: https://github.com/philhawksworth\n\n--------------------------- + -------------------- +END:VEVENT +BEGIN:VEVENT +UID:btp-2026-webb@ag-grid.com +DTSTAMP:20260514T131617Z +DTSTART;TZID=Europe/London:20260519T163000 +DTEND;TZID=Europe/London:20260519T170000 +SUMMARY:Vibe Coding as a Maker | Matt Webb (Co-Founder\, Inanimate) +ORGANIZER;CN=Sylwia Vargas:mailto:sylwia.vargas@ag-grid.com +LOCATION:Auditorium\, IET London\, 2 Savoy Place\, London WC2R 0BL +DESCRIPTION:Vibe Coding as a Maker | Matt Webb (Co-Founder\, Inanimate)\nAu + ditorium\n\n**Talk description**: Matt will show his vibe coding experimen + ts\, from his AI clock to an app that points to the centre of the galaxy\, + and share some learnings from building hardware at his startup\, Inanimat + e. Then we ask: what are the limits of vibing and agentic coding? And how + might we create libraries that agents love?\n\n**Speaker bio**: Matt is co + -founder of Inanimate\, consumer hardware bringing agents into the real wo + rld. Previously he has consulted with Google's AI research group\, run sta + rtup accelerators with R/GA Ventures\, and was CEO and co-founder of the d + esign studio BERG which invented some of the world's first internet-connec + ted consumer products like Little Printer (and has work in the New York Mo + MA). He is co-author of Mind Hacks (O'Reilly\, 2004). Since 2000 Matt has + blogged at interconnected.org. He writes weekly+ on computing\, design\, a + nd speculative futures. He lives in London.\n\n✨ Tag us on social media! + \n\n* Twitter: @ag_grid\n* Bsky: @ag-grid.bsky.social\n* LinkedIn: @ag-gri + d\n\nConnect with Matt:\n\n* Twitter: https://x.com/genmon\n* Bluesky: htt + ps://bsky.app/profile/genmon.fyi\n* GitHub: https://github.com/inanimate-t + ech\n* LinkedIn: https://www.linkedin.com/in/genmon\n\n------------------- + ---------------------------- +END:VEVENT +BEGIN:VEVENT +UID:btp-2026-closing@ag-grid.com +DTSTAMP:20260514T131617Z +DTSTART;TZID=Europe/London:20260519T170000 +DTEND;TZID=Europe/London:20260519T171000 +SUMMARY:Closing Remarks +ORGANIZER;CN=Sylwia Vargas:mailto:sylwia.vargas@ag-grid.com +LOCATION:Auditorium\, IET London\, 2 Savoy Place\, London WC2R 0BL +DESCRIPTION:Closing thoughts to wrap up the day and set the stage for the n + etworking reception. +END:VEVENT +BEGIN:VEVENT +UID:btp-2026-networking@ag-grid.com +DTSTAMP:20260514T131617Z +DTSTART;TZID=Europe/London:20260519T171000 +DTEND;TZID=Europe/London:20260519T200000 +SUMMARY:Networking Reception +ORGANIZER;CN=Sylwia Vargas:mailto:sylwia.vargas@ag-grid.com +DESCRIPTION: +END:VEVENT +END:VCALENDAR diff --git a/documentation/ag-grid-docs/public/downloads/beyond-the-prompt/beyond-the-prompt-agenda.json b/documentation/ag-grid-docs/public/downloads/beyond-the-prompt/beyond-the-prompt-agenda.json new file mode 100644 index 00000000000..9d64615468c --- /dev/null +++ b/documentation/ag-grid-docs/public/downloads/beyond-the-prompt/beyond-the-prompt-agenda.json @@ -0,0 +1,370 @@ +{ + "schema_version": "1.0", + "last_updated": "2026-05-13T00:00:00+01:00", + "url": "https://www.ag-grid.com/downloads/beyond-the-prompt/beyond-the-prompt-agenda.json", + "conference": { + "name": "Beyond The Prompt", + "tagline": "A one-day conference on building applications that hold up in production", + "organiser": "AG Grid & Bryntum", + "contact": "sylwia.vargas@ag-grid.com", + "date": "2026-05-19", + "timezone": "Europe/London", + "venue": { + "name": "IET London", + "address": "2 Savoy Place, London WC2R 0BL" + }, + "social": { + "twitter": "@ag_grid", + "bluesky": "@ag-grid.bsky.social", + "linkedin": "@ag-grid" + }, + "rooms": { + "talks": "Auditorium", + "breaks": "Hasslett" + } + }, + "speakers": { + "phil-hawksworth": { + "name": "Phil Hawksworth", + "role": "Event MC", + "bio": "With a passion for browser technologies, and the empowering properties of the web, Phil loves seeking out ingenuity and simplicity, especially in places where over-engineering is common. After 25 years of building web applications for companies such as Google, Apple, Nike, R/GA, and The London Stock Exchange, he has worked to challenge traditional technical architectures in favour of simplicity and effectiveness, working in Developer Experience at Netlify and Deno.", + "social": { + "twitter": "https://twitter.com/philhawksworth", + "bluesky": "https://bsky.app/profile/philhawksworth.dev", + "github": "https://github.com/philhawksworth" + } + }, + "john-masterson": { + "name": "John Masterson", + "title": "CEO", + "company": "AG Grid", + "bio": "John Masterson is the CEO of AG Grid, which he first joined in 2016 as employee number two. After a detour as CTO of a London health-tech startup, he returned in 2020 and stepped into the CEO role in 2024. He's spent fifteen years building software and leading engineering teams, and is determined to keep AG Grid the first choice for JavaScript developers. Outside of work, John can be found on his bike, in his headphones listening to a podcast, or picking up a guitar.", + "social": {} + }, + "mats-bryntse": { + "name": "Mats Bryntse", + "title": "Founder & CEO", + "company": "Bryntum", + "bio": "Mats is the founder and CEO of Bryntum, where he and his team build advanced scheduling and project planning tools for modern web apps. For the past 15 years, he has obsessed over JavaScript performance, developer experience, and making complex UIs feel simple. He used to enjoy chess, badminton, and independent thought, until Claude entered his life and optimized those away.", + "social": { + "twitter": "https://x.com/bryntum", + "linkedin": "https://www.linkedin.com/in/matsbryntse/", + "github": "https://github.com/matsbryntse" + } + }, + "david-khourshid": { + "name": "David Khourshid", + "title": "Founder", + "company": "Stately.ai", + "bio": "David is the founder of Stately.ai and creator of XState, the most popular open-source state machine & statecharts library. He's a longtime advocate for event-driven modeling and visual diagramming as the foundation for reliable UIs and, increasingly, AI agents. When he's not at a computer keyboard, he's at a piano keyboard.", + "social": { + "twitter": "https://x.com/davidkpiano", + "linkedin": "https://linkedin.com/in/davidkpiano", + "github": "https://github.com/davidkpiano" + } + }, + "stephen-cooper": { + "name": "Stephen Cooper", + "title": "Team Lead", + "company": "AG Grid", + "bio": "Stephen is the Team Lead for AG Grid and loves sharing practical, experience-based tips, tricks, and case studies from years in the codebase. He's gone deep into grid performance and framework integrations, and has spent more time than he'd like profiling render cycles. Outside of work, life revolves around family, four kids and two dogs, and he's happiest when the whole crew is out together exploring in the park.", + "social": { + "linkedin": "https://linkedin.com/in/scooper-dev", + "github": "https://github.com/StephenCooper" + } + }, + "josh-hobson": { + "name": "Josh Hobson", + "title": "Developer", + "company": "AG Grid", + "bio": "Josh is a developer at AG Grid, where he's been building AG Studio — a new dashboard library with multi-agent AI baked in. He's a mathematician by training and a maker by instinct, with a particular love for developer tooling, type systems, and intelligent interfaces. Outside of work, Josh can be found tinkering with 3D printers, ski touring in the Alps, or out on long walks with his dog.", + "social": { + "github": "https://github.com/AlpineJosh" + } + }, + "steve-ruiz": { + "name": "Steve Ruiz", + "title": "CEO", + "company": "tldraw", + "bio": "A developer, designer, and now startup founder in London. With a background in visual art, Steve works primarily in creative tools for the web. He is known for tldraw, and demos of perfect arrows.", + "social": { + "twitter": "https://x.com/steveruizok" + } + }, + "bernie-sumption": { + "name": "Bernie Sumption", + "title": "Engineer", + "company": "AG Grid", + "bio": "Bernie is an engineer at AG Grid specialising in theming. \"The other engineers make it work fast and well, I make it look pretty\" he likes to say. Outside work he goes hiking, plays with his kids, and once made a tweeting cat flap that has 10x more social media followers than he does.", + "social": { + "linkedin": "https://uk.linkedin.com/in/berniesumption", + "github": "https://github.com/BernieSumption" + } + }, + "patrick-rau": { + "name": "Patrick Rau", + "title": "Senior Developer", + "company": "TCS", + "bio": "Patrick builds and maintains core parts of Team Centric Software's fleetplan platform, specializing in UI integration and complex components like AG Grid, Bryntum's Scheduler Pro, and modules for audit tracking and safety reporting. He's driven by a passion for creating software that makes a real difference for the people who use it. When he's not solving problems at work, you'll find him exploring new technologies through personal projects, gaming, or planning his next trip.", + "social": { + "linkedin": "https://www.linkedin.com/in/patrick-r-ab2168183/", + "github": "https://github.com/14rau" + } + }, + "johan-isaksson": { + "name": "Johan Isaksson", + "title": "Head of Engineering", + "company": "Bryntum", + "bio": "Johan is responsible for architecture, gate keeping, performance and styling across all Bryntum products. When not checking pull requests, recording performance profiles or tweaking CSS he enjoys watching hockey and playing floorball.", + "social": {} + }, + "adam-wang": { + "name": "Adam Wang", + "title": "AG Studio Product Lead", + "company": "AG Grid", + "bio": "Adam is the AG Studio Product Lead at AG Grid, with around 10 years of experience in product management across various disciplines. He previously worked on AG Grid Integrated Charts before focusing on AG Studio. He enjoys the creative process of product management and is keen to solve problems by truly understanding user needs. Outside of work, he's a spin instructor who loves to put together a fire playlist. He also collects coloured vinyl.", + "social": { + "linkedin": "https://www.linkedin.com/in/adam-wang-77a8bb88/" + } + }, + "maggie-appleton": { + "name": "Maggie Appleton", + "title": "Staff Research Engineer", + "company": "GitHub", + "bio": "Maggie is a Staff Research Engineer at GitHub, where she works on tools for thinking, writing, and building with code. With a background in anthropology, she's known for her visual essays that explore how developers understand systems, languages, and ideas. She's a strong advocate for \"digital gardens\" over traditional publishing, and spends her time mapping out how knowledge grows on the web. Outside of work, she's usually sketching concepts, writing, or connecting dots between code and culture.", + "social": {} + }, + "matt-pocock": { + "name": "Matt Pocock", + "title": "Senior Developer Educator", + "company": "AI Hero", + "bio": "Matt is a senior developer educator at AI Hero, a TypeScript author, and an educator passionate about bringing real software engineering rigour to the age of AI. He co-organizes the AI Coding for Real Engineers cohort, a community for experienced developers who want to build with AI tools without throwing away everything they already know. Outside of the keyboard, Matt enjoys long runs through the Oxfordshire countryside, loudly supporting Arsenal, and experimenting with new ways to make complex ideas click for developers everywhere.", + "social": { + "twitter": "https://x.com/mattpocockuk", + "github": "https://github.com/mattpocock" + } + }, + "sophie-koonin": { + "name": "Sophie Koonin", + "title": "Web Discipline Lead", + "company": "Monzo", + "bio": "Sophie leads a team within the Operations collective at Monzo, building the software that powers the bank's award-winning customer experience. She's also Monzo's Web Discipline Lead, advocating for web and empowering others to lead web platform improvements throughout the company. Outside of work, Sophie loves arranging pop songs for her choir Mixtape, playing video games, cooking, and gardening.", + "social": { + "website": "https://localghost.dev" + } + }, + "matt-webb": { + "name": "Matt Webb", + "title": "Co-Founder", + "company": "Inanimate", + "bio": "Matt is co-founder of Inanimate, consumer hardware bringing agents into the real world. Previously he has consulted with Google's AI research group, run startup accelerators with R/GA Ventures, and was CEO and co-founder of the design studio BERG which invented some of the world's first internet-connected consumer products like Little Printer (and has work in the New York MoMA). He is co-author of Mind Hacks (O'Reilly, 2004). Since 2000 Matt has blogged at interconnected.org. He writes weekly+ on computing, design, and speculative futures. He lives in London.", + "social": { + "twitter": "https://x.com/genmon", + "bluesky": "https://bsky.app/profile/genmon.fyi", + "github": "https://github.com/inanimate-tech", + "linkedin": "https://www.linkedin.com/in/genmon" + } + } + }, + "events": [ + { + "id": "registration", + "type": "logistics", + "title": "Registration and Coffee", + "start": "2026-05-19T08:30:00+01:00", + "end": "2026-05-19T09:30:00+01:00", + "duration_minutes": 60, + "room": "Hasslett" + }, + { + "id": "welcome", + "type": "talk", + "title": "Welcome", + "description": "Opening remarks to set the theme for the day: building with tools you can trust.", + "start": "2026-05-19T09:30:00+01:00", + "end": "2026-05-19T09:40:00+01:00", + "duration_minutes": 10, + "room": "Auditorium", + "tags": ["opening", "welcome"], + "speakers": ["phil-hawksworth"] + }, + { + "id": "opening-keynote", + "type": "keynote", + "title": "Opening Keynote", + "start": "2026-05-19T09:40:00+01:00", + "end": "2026-05-19T10:10:00+01:00", + "duration_minutes": 30, + "room": "Auditorium", + "tags": ["keynote", "production engineering", "AG Grid", "Bryntum"], + "speakers": ["john-masterson", "mats-bryntse"] + }, + { + "id": "goodbye-slop", + "type": "talk", + "title": "Goodbye slop; welcome determinism", + "description": "Vibe coding feels productive until you have to maintain it. Behind the agent \"thinking...\" you ignore and the code you never opened lies a growing pile of wasted tokens, nondeterministic behavior, and compounding errors hiding in plain sight. This talk pushes back on the status quo: elaborate agent architectures, \"prompting astrology\", overnight automation loops burning through context windows and credit cards. Come for the critique, leave with a framework for using AI to build software you actually understand.", + "start": "2026-05-19T10:10:00+01:00", + "end": "2026-05-19T10:40:00+01:00", + "duration_minutes": 30, + "room": "Auditorium", + "tags": ["determinism", "agentic AI", "vibe coding", "state machines", "software quality"], + "speakers": ["david-khourshid"] + }, + { + "id": "coffee-break-1", + "type": "break", + "title": "Coffee Break", + "start": "2026-05-19T10:40:00+01:00", + "end": "2026-05-19T11:10:00+01:00", + "duration_minutes": 30, + "room": "Hasslett" + }, + { + "id": "codebase-design", + "type": "talk", + "title": "Codebase design for the agent era", + "description": "As AI agents become part of the development workflow, codebase structure and well-designed system prompts matter more than ever. This session will show you how we're approaching this in the AG Grid and AG Charts codebases.", + "start": "2026-05-19T11:10:00+01:00", + "end": "2026-05-19T11:35:00+01:00", + "duration_minutes": 25, + "room": "Auditorium", + "tags": ["codebase design", "agentic AI", "system prompts", "developer workflow", "AG Grid"], + "speakers": ["stephen-cooper"] + }, + { + "id": "ai-in-ag-studio", + "type": "talk", + "title": "AI in AG Studio", + "description": "How do you build a dashboard you can't see? A behind-the-scenes look at AG Studio's multi-agent architecture and the client-side tools that let any LLM build reports it otherwise couldn't.", + "start": "2026-05-19T11:35:00+01:00", + "end": "2026-05-19T12:00:00+01:00", + "duration_minutes": 25, + "room": "Auditorium", + "tags": ["multi-agent", "dashboards", "LLM tooling", "AG Studio", "AG Grid"], + "speakers": ["josh-hobson"] + }, + { + "id": "bringing-ai-to-canvas", + "type": "talk", + "title": "Bringing AI to the Canvas", + "description": "At tldraw, we've been bringing agents to our infinite canvas. In December 2025, we ran a one-month experiment named Fairydraw where users could work with three fairies — virtual collaborators who work with you, with your human collaborators, and coordinate together on large tasks.", + "start": "2026-05-19T12:00:00+01:00", + "end": "2026-05-19T12:25:00+01:00", + "duration_minutes": 25, + "room": "Auditorium", + "tags": ["canvas", "collaborative AI", "agentic AI", "creative tools", "tldraw"], + "speakers": ["steve-ruiz"] + }, + { + "id": "lunch", + "type": "logistics", + "title": "Lunch and Product Demos", + "start": "2026-05-19T12:25:00+01:00", + "end": "2026-05-19T13:35:00+01:00", + "duration_minutes": 70, + "room": "Hasslett" + }, + { + "id": "debugging-css", + "type": "talk", + "title": "Debugging CSS performance with AI", + "description": "CSS performance issues can be subtle and time-consuming. This talk will show you how you can guide AI to uncover bugs without needing to learn the intricacies of CSS rendering internals.", + "start": "2026-05-19T13:35:00+01:00", + "end": "2026-05-19T14:05:00+01:00", + "duration_minutes": 30, + "room": "Auditorium", + "tags": ["CSS", "performance", "debugging", "AI tooling", "frontend"], + "speakers": ["bernie-sumption"] + }, + { + "id": "software-moves-fleets", + "type": "talk", + "title": "Software that moves fleets: Lessons from AG Grid, Bryntum, and Beyond", + "description": "In aviation, the software has to be rock solid. Patrick pulls back the curtain on the components powering Fleetplan's UI, including AG Grid, Bryntum's Scheduler Pro, and the modules behind audit tracking and safety reporting that keep operations airtight.", + "start": "2026-05-19T14:05:00+01:00", + "end": "2026-05-19T14:20:00+01:00", + "duration_minutes": 15, + "room": "Auditorium", + "tags": ["production engineering", "aviation", "AG Grid", "Bryntum", "enterprise software"], + "speakers": ["patrick-rau"] + }, + { + "id": "product-roadmap", + "type": "talk", + "title": "Product Roadmap", + "description": "AG Grid and Bryntum share what's coming next across their product lines.", + "start": "2026-05-19T14:20:00+01:00", + "end": "2026-05-19T14:50:00+01:00", + "duration_minutes": 30, + "room": "Auditorium", + "tags": ["product roadmap", "AG Grid", "Bryntum", "AG Studio"], + "speakers": ["johan-isaksson", "adam-wang"] + }, + { + "id": "coffee-break-2", + "type": "break", + "title": "Coffee Break", + "start": "2026-05-19T14:50:00+01:00", + "end": "2026-05-19T15:20:00+01:00", + "duration_minutes": 30, + "room": "Hasslett" + }, + { + "id": "one-click-agentic-sdlc", + "type": "talk", + "title": "One-click agentic SDLC", + "description": "A demo of a headless Claude workflow, built by Bryntum CEO Mats, that turns GitHub issues into mergeable PRs, with agents doing the work, and a custom Kanban UI keeping things in check.", + "start": "2026-05-19T15:20:00+01:00", + "end": "2026-05-19T15:45:00+01:00", + "duration_minutes": 25, + "room": "Auditorium", + "tags": ["agentic AI", "SDLC", "automation", "GitHub", "CI/CD", "demo"], + "speakers": ["mats-bryntse"] + }, + { + "id": "agentic-ai-panel", + "type": "panel", + "title": "How agentic AI is reshaping software engineering", + "description": "As AI reshapes how software gets built, what actually changes for engineers, teams, and developer tools? This panel explores the real-world impact of agentic workflows on software engineering — from code review and system design to cognitive skills, ownership, and developer experience. Expect practical insights, rapid-fire hot takes, and honest discussion from engineers building beyond the prompt.", + "start": "2026-05-19T15:45:00+01:00", + "end": "2026-05-19T16:30:00+01:00", + "duration_minutes": 45, + "room": "Auditorium", + "tags": ["agentic AI", "software engineering", "developer experience", "panel", "future of work"], + "speakers": ["maggie-appleton", "matt-pocock", "sophie-koonin", "phil-hawksworth"] + }, + { + "id": "vibe-coding-maker", + "type": "talk", + "title": "Vibe Coding as a Maker", + "description": "Matt will show his vibe coding experiments, from his AI clock to an app that points to the centre of the galaxy, and share some learnings from building hardware at his startup, Inanimate. Then we ask: what are the limits of vibing and agentic coding? And how might we create libraries that agents love?", + "start": "2026-05-19T16:30:00+01:00", + "end": "2026-05-19T17:00:00+01:00", + "duration_minutes": 30, + "room": "Auditorium", + "tags": ["vibe coding", "hardware", "agentic AI", "maker", "creative coding"], + "speakers": ["matt-webb"] + }, + { + "id": "closing-remarks", + "type": "talk", + "title": "Closing Remarks", + "description": "Closing thoughts to wrap up the day and set the stage for the networking reception.", + "start": "2026-05-19T17:00:00+01:00", + "end": "2026-05-19T17:10:00+01:00", + "duration_minutes": 10, + "room": "Auditorium", + "tags": ["closing"], + "speakers": ["phil-hawksworth"] + }, + { + "id": "networking", + "type": "networking", + "title": "Networking Reception", + "start": "2026-05-19T17:10:00+01:00", + "end": "2026-05-19T20:00:00+01:00", + "duration_minutes": 170 + } + ] +} diff --git a/documentation/ag-grid-docs/public/landing-pages/lets-cook/chilli-hero.svg b/documentation/ag-grid-docs/public/landing-pages/lets-cook/chilli-hero.svg new file mode 100644 index 00000000000..cee39ba74c3 --- /dev/null +++ b/documentation/ag-grid-docs/public/landing-pages/lets-cook/chilli-hero.svg @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/documentation/ag-grid-docs/src/pages-styles/beyond-the-prompt.module.scss b/documentation/ag-grid-docs/src/pages-styles/beyond-the-prompt.module.scss index 5039d7eb1e7..1de75270153 100644 --- a/documentation/ag-grid-docs/src/pages-styles/beyond-the-prompt.module.scss +++ b/documentation/ag-grid-docs/src/pages-styles/beyond-the-prompt.module.scss @@ -734,6 +734,13 @@ $btp-logo-orange-filter: brightness(0) saturate(100%) invert(31%) sepia(87%) sat max-width: 720px; } +.agendaDownloads { + display: flex; + flex-wrap: wrap; + gap: 16px; + padding: 16px 0; +} + // ─── Full-width section title rule ─────────────────────────────────────────── // Editorial banner that replaces the large `.sectionTitle` heading on the // At a glance / Speakers / Full schedule / The venue sections. Top + bottom @@ -1040,20 +1047,6 @@ $btp-logo-orange-filter: brightness(0) saturate(100%) invert(31%) sepia(87%) sat color: var(--btp-ink-muted); } -.speakersMore { - margin-top: 48px; - padding: 24px; - text-align: center; - border: 1.5px dashed var(--btp-border); - border-radius: 12px; - font-family: 'Geist Mono', ui-monospace, monospace; - font-size: 13px; - font-weight: 500; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--btp-ink-muted); -} - // ─── Agenda ────────────────────────────────────────────────────────────────── .agendaSection { composes: section; diff --git a/documentation/ag-grid-docs/src/pages-styles/lets-cook.module.scss b/documentation/ag-grid-docs/src/pages-styles/lets-cook.module.scss new file mode 100644 index 00000000000..2758c2d5069 --- /dev/null +++ b/documentation/ag-grid-docs/src/pages-styles/lets-cook.module.scss @@ -0,0 +1,94 @@ +@use 'design-system' as *; + +.letsCookPage { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: calc(100vh - 80px); + padding: $spacing-size-12 $spacing-size-4; + text-align: center; +} + +.letsCookInner { + display: flex; + flex-direction: column-reverse; + align-items: center; + gap: $spacing-size-8; + max-width: 1100px; + width: 100%; + + @media screen and (min-width: 900px) { + flex-direction: row; + align-items: center; + justify-content: center; + gap: $spacing-size-16; + text-align: left; + } +} + +.copy { + display: flex; + flex-direction: column; + align-items: center; + gap: $spacing-size-6; + max-width: 640px; + + @media screen and (min-width: 900px) { + align-items: flex-start; + } +} + +.title { + font-size: clamp(3rem, 9vw, 6.5rem); + line-height: 1; + margin: 0; + font-weight: var(--text-bold); + color: var(--color-fg-primary); + + @media screen and (min-width: 900px) { + font-size: clamp(4rem, 7vw, 7.5rem); + } +} + +.intro { + font-size: var(--text-fs-xl); + line-height: 1.4; + margin: 0; + color: var(--color-fg-secondary); + + @media screen and (min-width: 900px) { + font-size: var(--text-fs-2xl); + } + + @media screen and (max-width: 480px) { + font-size: var(--text-fs-sm); + } +} + +.cta { + font-size: var(--text-fs-lg); + padding-right: 0.333em; + + :global(.icon) { + --icon-size: 1.333em; + } + + @media screen and (min-width: 900px) { + font-size: var(--text-fs-lg); + } + + @media screen and (max-width: 480px) { + font-size: var(--text-fs-sm); + } +} + +.heroImage { + width: clamp(180px, 50vw, 320px); + height: auto; + flex-shrink: 0; + + @media screen and (min-width: 900px) { + width: clamp(260px, 30vw, 420px); + } +} diff --git a/documentation/ag-grid-docs/src/pages/campaigns/beyond-the-prompt.astro b/documentation/ag-grid-docs/src/pages/campaigns/beyond-the-prompt.astro index d234076c1a6..866279dc0a8 100644 --- a/documentation/ag-grid-docs/src/pages/campaigns/beyond-the-prompt.astro +++ b/documentation/ag-grid-docs/src/pages/campaigns/beyond-the-prompt.astro @@ -617,8 +617,6 @@ const VENUE_IMAGES: VenueImage[] = [ )) } - -

More speakers to be announced soon...

@@ -631,6 +629,23 @@ const VENUE_IMAGES: VenueImage[] = [ A single track across the day, with breaks designed for conversation. Some sessions are still being finalised and speakers may be added.

+ +

+ + Download .ics agenda + + + Download .json agenda + +

{ diff --git a/documentation/ag-grid-docs/src/pages/community/lets-cook.astro b/documentation/ag-grid-docs/src/pages/community/lets-cook.astro new file mode 100644 index 00000000000..52906b821d5 --- /dev/null +++ b/documentation/ag-grid-docs/src/pages/community/lets-cook.astro @@ -0,0 +1,64 @@ +--- +import Layout from '../../layouts/Layout.astro'; +import styles from '../../pages-styles/lets-cook.module.scss'; + +import { Icon } from '@ag-website-shared/components/icon/Icon'; +import { urlWithBaseUrl } from '@utils/urlWithBaseUrl'; +--- + + +
+
+
+

Now you're cooking

+ +

+ Show us your AG Grill hot sauce in action!
+ Got a banger recipe? A pic of a sizzling BBQ?
+ Share the joy on socials or DM us. +

+ +

Need more sauce? 👀 Hit us up!

+ + + Connect with us on LinkedIn + + + Connect with us on X + + + Connect with us on bluesky + +
+ + AG Grill chilli mascot +
+
+
diff --git a/external/ag-shared/.claude-settings.template.json b/external/ag-shared/.claude-settings.template.json index c034fa42341..95e6a66efc8 100644 --- a/external/ag-shared/.claude-settings.template.json +++ b/external/ag-shared/.claude-settings.template.json @@ -153,13 +153,15 @@ "source": { "source": "github", "repo": "ag-grid/ag-dev-prompts"${AG_DEV_PROMPTS_REF_FIELD} - } + }, + "autoUpdate": true }, "openai-codex": { "source": { "source": "github", "repo": "openai/codex-plugin-cc" - } + }, + "autoUpdate": true } } } diff --git a/external/ag-shared/.gitrepo b/external/ag-shared/.gitrepo index 79989ce3cd7..a36dcd60297 100644 --- a/external/ag-shared/.gitrepo +++ b/external/ag-shared/.gitrepo @@ -6,7 +6,7 @@ [subrepo] remote = https://github.com/ag-grid/ag-shared.git branch = latest - commit = 8c443db12c6963ff9ad47b45873bb0200ab5d64b - parent = 184ce2a211eb430bf97890502d19d421042bf551 + commit = 12cbf13c673a630311f10cf532949160e863d182 + parent = 45ac5bcb9690d6b0ebc591dad6505ed1655b4502 method = rebase cmdver = 0.4.9 diff --git a/external/ag-shared/docs/SYNC-LOG.md b/external/ag-shared/docs/SYNC-LOG.md index d8e598a73c9..8b419fd5c2d 100644 --- a/external/ag-shared/docs/SYNC-LOG.md +++ b/external/ag-shared/docs/SYNC-LOG.md @@ -6,6 +6,37 @@ Newest entries first. Generated by `/ag-shared-sync-log`. --- +## 2026-05-18 -- Consolidate Slack CI notification into shared action + +**Branch:** `AG-3390-consolidate-staging-notification` +**Ticket:** AG-3390 + +### Changes + +- **[github/actions/slack-ci-notification/action.yml]** New composite GitHub Action that wraps the Slack notification flow. Inputs cover token, Notion lookup credentials, target channels, project name, job statuses, last-successful SHA, report URL, changed-state flag, and deploy-to-staging flag. Replaces the per-repo `scripts/agBotSlackMessage.ts` invocation. +- **[scripts/slack/_ci-notification-utils.mjs]** Shared utility module: workflow context parsing, Slack message construction, channel resolution, and failure classification. +- **[scripts/slack/notify-job-status.mjs]** Job-status notifier used by the composite action when CI completes. +- **[scripts/slack/notify-staging-deploy.mjs]** Staging-deploy notifier gated on docs deploy success. +- **[scripts/slack/get-slack-user-config.mjs + get-slack-user-config-command.mjs]** Notion-backed lookup that resolves GitHub usernames to Slack IDs with caching and error handling. +- **[.claude-settings.template.json]** Enable `ag-eng@ag-dev` and `ag-product@ag-dev` plugins by default. +- **[package.json]** Bump TypeScript dev-dep `^5.4.5` → `^5.8.3` (paired with the TS 5.8 / Angular 20 upgrade landing across consumer repos). + +### Migration Actions + +- [ ] Pull ag-shared in each consumer repo (`yarn subrepo pull ag-shared`). The new action and scripts arrive automatically. +- [ ] Run `./external/ag-shared/scripts/setup-prompts/setup-prompts.sh` to pick up `ag-eng` and `ag-product` plugin enablement in `.claude/settings.json`. +- [ ] Run `./external/ag-shared/scripts/setup-prompts/verify-rulesync.sh` to confirm rulesync integrity. +- [ ] **Per-repo follow-up (not part of this sync):** migrate the consumer's `.github/workflows/ci.yml` Slack step to call `./external/ag-shared/github/actions/slack-ci-notification` (see ag-grid PR for the canonical example). +- [ ] **Per-repo follow-up:** ensure repository secrets `SLACK_USER_CONFIG_NOTION_API_TOKEN` and `SLACK_USER_CONFIG_NOTION_DATA_SOURCE_ID` are configured before switching ci.yml over. + +### Notes + +The shared action does not replace each repo's `ci.yml` automatically — it provides the action surface. The ag-grid PR (https://github.com/ag-grid/ag-grid/pull/13793) demonstrates the full migration; ag-charts and ag-studio will land their `ci.yml` migrations in follow-up PRs once their secrets are configured. + +The ag-studio sync hit local subrepo commit conflicts during this sync (snyk-related `.snyk`, `scripts/snyk/snykClean.mjs`, and upstream renames of `prompts/guides/code-quality.md` → skill, `prompts/skills/jira/SKILL.md`). The ag-studio pull needs manual rebase resolution before the next sync attempt. + +--- + ## 2026-04-23 -- Worktree create fast path **Branch:** `ajt/worktree-create-fast-path` diff --git a/external/ag-shared/github/actions/slack-ci-notification/action.yml b/external/ag-shared/github/actions/slack-ci-notification/action.yml new file mode 100644 index 00000000000..4840fb1c100 --- /dev/null +++ b/external/ag-shared/github/actions/slack-ci-notification/action.yml @@ -0,0 +1,81 @@ +name: slack-ci-notification +description: Sends CI job-status and staging-deploy notifications to Slack. User mapping is fetched from Notion at runtime. +inputs: + SLACK_BOT_OAUTH_TOKEN: + description: Slack OAuth token for sending messages. + required: true + NOTION_API_TOKEN: + description: Notion API token for fetching the slack user config. + required: true + NOTION_DATA_SOURCE_ID: + description: Notion data source id holding the slack user config. + required: true + NOTION_API_VERSION: + description: Notion API version. + required: false + AG_PROJECT: + description: Project name (AgGrid | AgCharts | AgStudio | Blog) - used for branding and base URLs. + required: true + LAST_SUCCESSFUL_SHA: + description: SHA of the last successful workflow run on the target branch (used for git diff and change list). + required: true + JOB_STATUSES: + description: JSON object mapping job name to status (success | failure | n/a | skipped | cancelled). + required: true + CHANGED_STATE: + description: 'true if the overall workflow status changed compared to the previous run.' + required: true + DEPLOY_TO_STAGING: + description: 'true if changes were deployed to staging in this run.' + required: true + TEAM_CHANNEL: + description: Slack channel that receives the team-facing job-status message (only when CHANGED_STATE is true). + required: false + DEBUG_CHANNEL: + description: Slack channel that receives debug messages with raw run context. + required: false + WEBSITE_STATUS_CHANNEL: + description: Slack channel that receives the staging-deploy notification. + required: false + REPORT_URL: + description: Optional URL for the test report. + required: false + +runs: + using: composite + steps: + - name: Notify job status + shell: bash + env: + SLACK_BOT_OAUTH_TOKEN: ${{ inputs.SLACK_BOT_OAUTH_TOKEN }} + NOTION_API_TOKEN: ${{ inputs.NOTION_API_TOKEN }} + NOTION_DATA_SOURCE_ID: ${{ inputs.NOTION_DATA_SOURCE_ID }} + NOTION_API_VERSION: ${{ inputs.NOTION_API_VERSION }} + AG_PROJECT: ${{ inputs.AG_PROJECT }} + RUN_ID: ${{ github.run_id }} + WORKFLOW: ${{ github.workflow }} + REF: ${{ github.ref }} + CURRENT_SHA: ${{ github.sha }} + LAST_SUCCESSFUL_SHA: ${{ inputs.LAST_SUCCESSFUL_SHA }} + TEAM_CHANNEL: ${{ inputs.TEAM_CHANNEL }} + DEBUG_CHANNEL: ${{ inputs.DEBUG_CHANNEL }} + JOB_STATUSES: ${{ inputs.JOB_STATUSES }} + CHANGED_STATE: ${{ inputs.CHANGED_STATE }} + REPORT_URL: ${{ inputs.REPORT_URL }} + run: node ./external/ag-shared/scripts/slack/notify-job-status.mjs + + - name: Notify staging deploy + if: inputs.DEPLOY_TO_STAGING == 'true' + shell: bash + env: + SLACK_BOT_OAUTH_TOKEN: ${{ inputs.SLACK_BOT_OAUTH_TOKEN }} + NOTION_API_TOKEN: ${{ inputs.NOTION_API_TOKEN }} + NOTION_DATA_SOURCE_ID: ${{ inputs.NOTION_DATA_SOURCE_ID }} + NOTION_API_VERSION: ${{ inputs.NOTION_API_VERSION }} + AG_PROJECT: ${{ inputs.AG_PROJECT }} + RUN_ID: ${{ github.run_id }} + CURRENT_SHA: ${{ github.sha }} + LAST_SUCCESSFUL_SHA: ${{ inputs.LAST_SUCCESSFUL_SHA }} + WEBSITE_STATUS_CHANNEL: ${{ inputs.WEBSITE_STATUS_CHANNEL }} + DEPLOY_TO_STAGING: ${{ inputs.DEPLOY_TO_STAGING }} + run: node ./external/ag-shared/scripts/slack/notify-staging-deploy.mjs diff --git a/external/ag-shared/scripts/slack/_ci-notification-utils.mjs b/external/ag-shared/scripts/slack/_ci-notification-utils.mjs new file mode 100644 index 00000000000..2c9cc3dee33 --- /dev/null +++ b/external/ag-shared/scripts/slack/_ci-notification-utils.mjs @@ -0,0 +1,223 @@ +import { execSync } from 'node:child_process'; + +// ──────────────────────────────────────────────────────────────────────────── +// Per-library config. Add/edit entries here when a new library is onboarded. +// `project` values match the AG_PROJECT env var set by the calling workflow. +// ──────────────────────────────────────────────────────────────────────────── +const LIBRARY_CONFIG = { + AgGrid: { + githubBaseUrl: 'https://github.com/ag-grid/ag-grid', + stagingUrl: 'https://grid-staging.ag-grid.com', + emoji: ':bento:', + }, + AgCharts: { + githubBaseUrl: 'https://github.com/ag-grid/ag-charts', + stagingUrl: 'https://charts-staging.ag-grid.com', + emoji: ':bar_chart:', + }, + AgStudio: { + githubBaseUrl: 'https://github.com/ag-grid/ag-studio', + stagingUrl: 'https://studio-staging.ag-grid.com', + emoji: ':puzzle:', + }, + Blog: { + githubBaseUrl: 'https://github.com/ag-grid/ag-blog-content', + stagingUrl: 'https://grid-staging.ag-grid.com', + emoji: '', + }, +}; + +const DEFAULT_LIBRARY = 'AgGrid'; + +// JIRA ticket prefix → project's browse URL. Tickets in commit messages from +// any library are linked regardless of which prefix they use. +const JIRA_BASE_URL_BY_PREFIX = { + AG: 'https://ag-grid.atlassian.net/browse/AG', + AS: 'https://ag-grid.atlassian.net/browse/AS', +}; + +const MANY_CHANGES_LIMIT = 10; + +// ──────────────────────────────────────────────────────────────────────────── +// GitHub Actions workflow-command helpers. Emit `::warning::` / `::error::` +// annotations so failures surface in the Actions run summary even when the +// script exits 0. https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions +// ──────────────────────────────────────────────────────────────────────────── +function escapeAnnotation(message) { + return String(message).replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A'); +} + +function emitAnnotation(level, message, { title } = {}) { + const titlePart = title ? ` title=${escapeAnnotation(title)}` : ''; + console.log(`::${level}${titlePart}::${escapeAnnotation(message)}`); +} + +export function ghaWarning(message, opts) { + emitAnnotation('warning', message, opts); +} + +export function ghaError(message, opts) { + emitAnnotation('error', message, opts); +} + +function getLibrary(project) { + return LIBRARY_CONFIG[project] ?? LIBRARY_CONFIG[DEFAULT_LIBRARY]; +} + +export function getGithubBaseUrl(project) { + return getLibrary(project).githubBaseUrl; +} + +export function getRunUrl(project, runId) { + return `${getGithubBaseUrl(project)}/actions/runs/${runId}`; +} + +export function getEmoji(project) { + return getLibrary(project).emoji; +} + +export function getStagingUrl(project) { + return getLibrary(project).stagingUrl; +} + +export function getBranchLink(ref, project) { + if (!ref) return ''; + const baseUrl = getGithubBaseUrl(project); + if (ref === 'refs/heads/latest') return `<${baseUrl}/tree/latest|latest>`; + // GitHub Actions exposes PR refs as `refs/pull//{merge,head}`; also accept the bare `pull/` form. + const pullMatch = ref.match(/^(?:refs\/)?pull\/(\d+)(?:\/[^/]+)?$/); + if (pullMatch) { + const prNumber = pullMatch[1]; + return `<${baseUrl}/pull/${prNumber}|PR #${prNumber}>`; + } + if (ref.startsWith('refs/tags/')) { + const tag = ref.slice('refs/tags/'.length); + return `<${baseUrl}/tree/${tag}|${tag}>`; + } + return ref; +} + +export function findUserByEmail(testEmail, users) { + return users.find(({ gitEmails }) => Array.isArray(gitEmails) && gitEmails.some((e) => e === testEmail)); +} + +export function getUser(githubUsername, users) { + return users.find((u) => u.github === githubUsername); +} + +export function getUserDisplay(githubUsername, userDisplayType, users) { + const user = getUser(githubUsername, users); + const slackId = user?.slackId; + let display = user?.fullName || githubUsername; + if (slackId) { + if (userDisplayType === 'name') { + display = user.fullName || githubUsername; + } else if (userDisplayType === 'slack') { + display = `<@${slackId}>`; + } else if (userDisplayType === 'debug') { + display = `${display} (${slackId})`; + } + } + return display; +} + +export function updateWithJiraUrl(str) { + return str.replace(/((AG|AS)-[0-9]+)(.*)/gm, (_, ticket, prefix, rest) => { + const base = JIRA_BASE_URL_BY_PREFIX[prefix]; + return base ? `<${base}/${ticket}|${ticket}>${rest}` : `${ticket}${rest}`; + }); +} + +export function updateWithGithubPRUrl({ str, baseGithubUrl }) { + return str.replace(/#(\d+)/gm, `<${baseGithubUrl}/pull/$1 | #$1>`); +} + +export function getGitChanges(currentSha, lastSuccessfulSha, users) { + // Trim trailing newline before interpolating — `head -1` keeps it and the + // embedded newline would otherwise break the next `git log` command. + const firstAfterSuccess = execSync( + `git log --reverse --ancestry-path --pretty=%H ${lastSuccessfulSha}..HEAD | head -1`, + { stdio: 'pipe', encoding: 'utf-8' } + ).trim(); + + const gitCommand = + firstAfterSuccess.length === 0 || firstAfterSuccess === currentSha + ? `git log ${currentSha} --format="%ae||%an||%h||%s" | head -1` + : `git log ${lastSuccessfulSha}..${currentSha} --format="%ae||%an||%h||%s"`; + + const rawChanges = execSync(gitCommand, { stdio: 'pipe', encoding: 'utf-8' }); + + return rawChanges + .split('\n') + .filter((change) => change.length > 0) + .map((change) => change.split('||')) + .map(([email, authorName, version, comment]) => { + const user = findUserByEmail(email, users); + return { + username: user?.github || authorName, + slackId: user?.slackId, + version, + comment, + }; + }); +} + +export function getChangesData({ currentSha, lastSuccessfulSha, project, gitChanges, userDisplayTypeSetting, users }) { + const baseGithubUrl = getGithubBaseUrl(project); + const githubUrl = + gitChanges.length > 1 + ? `${baseGithubUrl}/compare/${lastSuccessfulSha}...${currentSha}` + : `${baseGithubUrl}/commit/${currentSha}`; + + const tooManyChanges = gitChanges.length > MANY_CHANGES_LIMIT; + const changes = tooManyChanges ? gitChanges.slice(0, MANY_CHANGES_LIMIT) : gitChanges; + + const allUsers = gitChanges.map(({ username }) => username); + const allOtherUsers = allUsers.slice(MANY_CHANGES_LIMIT); + const uniqueUsers = [...new Set(allUsers)]; + + // Use names (not slack mentions) when there are many changes from multiple authors, + // so we don't ping a long list of people. + const userDisplayType = + tooManyChanges && userDisplayTypeSetting === 'slack' && uniqueUsers.length > 1 + ? 'name' + : userDisplayTypeSetting; + + const otherUsers = [...new Set(allOtherUsers)] + .map((username) => getUserDisplay(username, userDisplayType, users)) + .join(', '); + + let changeDetails = changes + .map(({ username, comment, version }) => { + const firstLine = updateWithGithubPRUrl({ + str: updateWithJiraUrl(comment.split('\n')[0]), + baseGithubUrl, + }); + const shortSha = version.slice(0, 7); + const userDisplay = getUserDisplay(username, userDisplayType, users); + return `• ${userDisplay}: ${firstLine} (<${baseGithubUrl}/commit/${version}|${shortSha}>)`; + }) + .join('\n'); + + if (tooManyChanges) { + changeDetails += `\n• ...(${gitChanges.length - MANY_CHANGES_LIMIT} more changes from ${otherUsers})`; + } + + const changesText = + gitChanges.length === 0 ? '_No changes_' : `Changes (<${githubUrl}|Github diff>):\n${changeDetails}`; + + return { uniqueUsers, changesText }; +} + +export function getJobStatusSummary(jobStatuses) { + const symbol = (status) => (status === 'success' ? '✅' : status === 'failure' ? '❌' : '➖'); + return Object.entries(jobStatuses) + .map(([job, status]) => `${job}: ${symbol(status)}`) + .join(' | '); +} + +export function deriveStatus(jobStatuses) { + // Mirror the workflow's own check: only an explicit 'failure' counts as a failure; + // 'skipped' / 'cancelled' / 'n/a' are not failures. + return Object.values(jobStatuses).some((status) => status === 'failure') ? 'failure' : 'success'; +} diff --git a/external/ag-shared/scripts/slack/get-slack-user-config-command.mjs b/external/ag-shared/scripts/slack/get-slack-user-config-command.mjs new file mode 100644 index 00000000000..126e4d3dfd2 --- /dev/null +++ b/external/ag-shared/scripts/slack/get-slack-user-config-command.mjs @@ -0,0 +1,28 @@ +import { getSlackUserConfig } from './get-slack-user-config.mjs'; + +const { NOTION_DATA_SOURCE_ID, NOTION_API_TOKEN, NOTION_API_VERSION} = process.env; + +if (!NOTION_API_TOKEN || !NOTION_DATA_SOURCE_ID) { + console.error('Error: NOTION_API_TOKEN or NOTION_DATA_SOURCE_ID environment variable is not set.'); + process.exit(1); +} + +(async () => { + try { + const { results, error } = await getSlackUserConfig({ + notionApiToken: NOTION_API_TOKEN, + notionDataSourceId: NOTION_DATA_SOURCE_ID, + notionApiVersion: NOTION_API_VERSION, + }); + + if (error) { + console.error('Error fetching Slack user config:', error); + process.exit(1); + } + + console.log(JSON.stringify(results, null, 2)); + } catch (error) { + console.error('Error fetching Slack user config:', error); + process.exit(1); + } +})(); diff --git a/external/ag-shared/scripts/slack/get-slack-user-config.mjs b/external/ag-shared/scripts/slack/get-slack-user-config.mjs new file mode 100644 index 00000000000..40b63f96206 --- /dev/null +++ b/external/ag-shared/scripts/slack/get-slack-user-config.mjs @@ -0,0 +1,175 @@ +const SLACK_USER_KEY_MAP = { + "Slack ID": "slackId", + "Full Name": "fullName", + "Github": "github", + "Staging notification": "stagingNotification", + "Git Emails": { key: "gitEmails", extract: extractEmailsFromRichText }, +}; + +const getDataSourceQueryUrl = (dataSourceId) => `https://api.notion.com/v1/data_sources/${dataSourceId}/query`; + +/** + * Extracts plain values from Notion property objects. + * Returns null for empty/unset values. + */ +function extractPropertyValue(property) { + switch (property.type) { + case "title": + return property.title.map((t) => t.plain_text).join("") || null; + + case "rich_text": + return property.rich_text.map((t) => t.plain_text).join("") || null; + + case "checkbox": + return property.checkbox; + + case "number": + return property.number; + + case "select": + return property.select?.name ?? null; + + case "multi_select": + return property.multi_select.map((s) => s.name); + + case "status": + return property.status?.name ?? null; + + case "date": + return property.date + ? { start: property.date.start, end: property.date.end } + : null; + + case "people": + return property.people.map((p) => ({ id: p.id, name: p.name ?? null })); + + case "url": + return property.url; + + case "email": + return property.email; + + case "phone_number": + return property.phone_number; + + case "created_time": + return property.created_time; + + case "last_edited_time": + return property.last_edited_time; + + case "created_by": + return property.created_by?.id ?? null; + + case "last_edited_by": + return property.last_edited_by?.id ?? null; + + case "files": + return property.files.map((f) => f.file?.url ?? f.external?.url ?? null); + + case "relation": + return property.relation.map((r) => r.id); + + case "formula": + return property.formula[property.formula.type]; + + case "rollup": + return property.rollup[property.rollup.type]; + + case "unique_id": + return property.unique_id.prefix + ? `${property.unique_id.prefix}-${property.unique_id.number}` + : property.unique_id.number; + + default: + return null; + } +} + +/** + * Extracts email addresses from a Notion rich_text property. Notion renders + * emails as either bare text (`someone@example.com`) or as mailto links; in + * both cases the address appears in the segment's `plain_text`, so we scan + * the concatenated plain text with an email regex and dedupe. + */ +const EMAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z0-9-]+/g; +function extractEmailsFromRichText(prop) { + const text = prop.rich_text.map((t) => t.plain_text ?? "").join(""); + return [...new Set(text.match(EMAIL_RE) ?? [])]; +} + +/** + * Converts a single Notion page into a flat object. + * Includes page metadata under `_meta` so it doesn't collide with property names. + * + * keyMap entries may be either a string (rename only) or `{ key, extract }` + * to override extraction for a specific property. + */ +function simplifyPage(page, keyMap = {}) { + const simplified = { _meta: { id: page.id, url: page.url } }; + for (const [name, prop] of Object.entries(page.properties)) { + const mapping = keyMap[name]; + if (mapping && typeof mapping === "object") { + simplified[mapping.key] = mapping.extract(prop); + } else { + simplified[mapping ?? name] = extractPropertyValue(prop); + } + } + return simplified; +} + +/** + * Converts a full query response into an array of simplified objects. + */ +function simplifyQueryResults(response, keyMap) { + return response.results.map((page) => simplifyPage(page, keyMap)); +} + +export async function getSlackUserConfig({ + notionApiToken, + notionDataSourceId, + notionApiVersion = "2026-03-11", +}) { + const queryUrl = getDataSourceQueryUrl(notionDataSourceId); + // Notion paginates query results; loop until `has_more` is false so the user list is complete + // even after the data source grows past Notion's default page size. + const allResults = []; + let startCursor; + do { + const body = startCursor ? JSON.stringify({ start_cursor: startCursor }) : undefined; + const response = await fetch(queryUrl, { + method: "post", + headers: { + "Authorization": `Bearer ${notionApiToken}`, + "Notion-Version": notionApiVersion, + ...(body ? { "Content-Type": "application/json" } : {}), + }, + ...(body ? { body } : {}), + }); + const data = await response.json(); + + if (!Array.isArray(data?.results)) { + const notionMessage = data?.message ? ` Notion said: ${data.message}` : ""; + return { error: `Notion query did not return a 'results' array (status ${response.status}).${notionMessage}` }; + } + + allResults.push(...data.results); + startCursor = data.has_more ? data.next_cursor : undefined; + } while (startCursor); + + if (allResults.length === 0) { + return { error: "Notion query returned no rows, so the schema cannot be validated. Check the data source has at least one entry." }; + } + + const expectedHeadings = Object.keys(SLACK_USER_KEY_MAP); + const presentHeadings = Object.keys(allResults[0].properties ?? {}); + const missing = expectedHeadings.filter((h) => !presentHeadings.includes(h)); + if (missing.length > 0) { + const quote = (xs) => xs.map((x) => `"${x}"`).join(", "); + return { + error: `Notion data source is missing required column(s): ${quote(missing)}. Available columns: ${quote(presentHeadings)}.`, + }; + } + + return { results: simplifyQueryResults({ results: allResults }, SLACK_USER_KEY_MAP) }; +} diff --git a/external/ag-shared/scripts/slack/notify-job-status.mjs b/external/ag-shared/scripts/slack/notify-job-status.mjs new file mode 100644 index 00000000000..92b742afcc5 --- /dev/null +++ b/external/ag-shared/scripts/slack/notify-job-status.mjs @@ -0,0 +1,170 @@ +import { getSlackUserConfig } from './get-slack-user-config.mjs'; +import { sendSlackMessage } from './send-slack-message.mjs'; +import { + deriveStatus, + getBranchLink, + getChangesData, + getEmoji, + getGitChanges, + getJobStatusSummary, + getRunUrl, + ghaError, +} from './_ci-notification-utils.mjs'; + +const { + SLACK_BOT_OAUTH_TOKEN, + NOTION_API_TOKEN, + NOTION_DATA_SOURCE_ID, + NOTION_API_VERSION, + AG_PROJECT, + RUN_ID, + WORKFLOW, + REF, + CURRENT_SHA, + LAST_SUCCESSFUL_SHA, + TEAM_CHANNEL, + DEBUG_CHANNEL, + JOB_STATUSES, + CHANGED_STATE, + REPORT_URL, +} = process.env; + +const required = { SLACK_BOT_OAUTH_TOKEN, NOTION_API_TOKEN, NOTION_DATA_SOURCE_ID, AG_PROJECT, RUN_ID, CURRENT_SHA, LAST_SUCCESSFUL_SHA, JOB_STATUSES }; +for (const [name, value] of Object.entries(required)) { + if (!value) { + ghaError(`${name} environment variable is not set.`, { title: 'Slack notification: missing config' }); + process.exit(1); + } +} + +const THREAD_DEBUG_RAW = true; + +(async () => { + let jobStatuses; + try { + jobStatuses = JSON.parse(JOB_STATUSES); + } catch (err) { + // Don't fail the workflow over a notification-formatting issue; the + // 'Fail job if workflow failed' step is the source of truth for CI status. + ghaError(`Failed to parse JOB_STATUSES JSON; skipping slack notification. Error: ${err.message}\nReceived: ${JOB_STATUSES}`, { + title: 'Slack notification: bad JOB_STATUSES', + }); + process.exit(0); + } + const status = deriveStatus(jobStatuses); + const changedState = CHANGED_STATE === 'true'; + + const { results: users, error } = await getSlackUserConfig({ + notionApiToken: NOTION_API_TOKEN, + notionDataSourceId: NOTION_DATA_SOURCE_ID, + notionApiVersion: NOTION_API_VERSION, + }); + if (error) { + ghaError(`Error fetching Slack user config from Notion: ${error}`, { title: 'Slack notification: Notion fetch failed' }); + process.exit(1); + } + + const changes = getGitChanges(CURRENT_SHA, LAST_SUCCESSFUL_SHA, users); + + const ctx = { + project: AG_PROJECT, + runId: RUN_ID, + workflow: WORKFLOW, + ref: REF, + currentSha: CURRENT_SHA, + lastSuccessfulSha: LAST_SUCCESSFUL_SHA, + status, + changedState, + jobStatuses, + reportUrl: REPORT_URL || '', + }; + + // Debug channel: always send, regardless of changedState, with raw JSON threads. + if (DEBUG_CHANNEL) { + const debugResp = await sendStatusMessage({ channel: DEBUG_CHANNEL, ctx, changes, users, userDisplayType: 'debug' }); + if (THREAD_DEBUG_RAW && debugResp?.ts) { + await sendCodeBlock({ channel: DEBUG_CHANNEL, threadTs: debugResp.ts, label: 'Run context', code: JSON.stringify(ctx, null, 2) }); + await sendCodeBlock({ channel: DEBUG_CHANNEL, threadTs: debugResp.ts, label: 'Detected changes', code: JSON.stringify(changes, null, 2) }); + } + } + + // Team channel: only when the status changed since the last run. + if (changedState && TEAM_CHANNEL) { + await sendStatusMessage({ channel: TEAM_CHANNEL, ctx, changes, users, userDisplayType: 'slack' }); + } +})(); + +async function sendStatusMessage({ channel, ctx, changes, users, userDisplayType }) { + if (ctx.status === 'failure') { + return sendFailureMessage({ channel, ctx, changes, users, userDisplayType }); + } + return sendSuccessMessage({ channel, ctx, changes, users, userDisplayType }); +} + +async function sendFailureMessage({ channel, ctx, changes, users, userDisplayType }) { + const branchLink = getBranchLink(ctx.ref, ctx.project); + const branchDetails = branchLink ? ` (on ${branchLink})` : ''; + const { changesText } = getChangesData({ + currentSha: ctx.currentSha, + lastSuccessfulSha: ctx.lastSuccessfulSha, + project: ctx.project, + gitChanges: changes, + userDisplayTypeSetting: userDisplayType, + users, + }); + const testReportUrl = ctx.reportUrl ? `(<${ctx.reportUrl}|Test Results>)` : ''; + const emoji = getEmoji(ctx.project); + const webUrl = getRunUrl(ctx.project, ctx.runId); + + const blocks = [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `:x: ${emoji} ${ctx.project} / <${webUrl} | ${ctx.workflow} #${ctx.runId}>${branchDetails} *failed* ${testReportUrl}\n${getJobStatusSummary(ctx.jobStatuses)}`, + }, + }, + { + type: 'section', + text: { type: 'mrkdwn', text: changesText }, + }, + ]; + + return sendSlackMessage({ + authToken: SLACK_BOT_OAUTH_TOKEN, + data: { channel, blocks, unfurl_links: false }, + }); +} + +async function sendSuccessMessage({ channel, ctx, changes, users, userDisplayType }) { + const branchLink = getBranchLink(ctx.ref, ctx.project); + const branchDetails = branchLink ? ` (on ${branchLink})` : ''; + const { changesText } = getChangesData({ + currentSha: ctx.currentSha, + lastSuccessfulSha: ctx.lastSuccessfulSha, + project: ctx.project, + gitChanges: changes, + userDisplayTypeSetting: userDisplayType, + users, + }); + const emoji = getEmoji(ctx.project); + const webUrl = getRunUrl(ctx.project, ctx.runId); + + const text = `:white_check_mark: ${emoji} ${ctx.project} / <${webUrl} | ${ctx.workflow} #${ctx.runId}>${branchDetails} successful\n${getJobStatusSummary(ctx.jobStatuses)}\n${changesText}`; + + return sendSlackMessage({ + authToken: SLACK_BOT_OAUTH_TOKEN, + data: { channel, text, unfurl_links: false }, + }); +} + +async function sendCodeBlock({ channel, threadTs, label, code }) { + return sendSlackMessage({ + authToken: SLACK_BOT_OAUTH_TOKEN, + data: { + channel, + text: `${label}:\n\`\`\`${code}\n\`\`\``, + thread_ts: threadTs, + }, + }); +} diff --git a/external/ag-shared/scripts/slack/notify-staging-deploy.mjs b/external/ag-shared/scripts/slack/notify-staging-deploy.mjs new file mode 100644 index 00000000000..58004dfc9b5 --- /dev/null +++ b/external/ag-shared/scripts/slack/notify-staging-deploy.mjs @@ -0,0 +1,103 @@ +import { getSlackUserConfig } from './get-slack-user-config.mjs'; +import { sendSlackMessage } from './send-slack-message.mjs'; +import { + getChangesData, + getEmoji, + getGitChanges, + getRunUrl, + getStagingUrl, + ghaError, + ghaWarning, +} from './_ci-notification-utils.mjs'; + +const { + SLACK_BOT_OAUTH_TOKEN, + NOTION_API_TOKEN, + NOTION_DATA_SOURCE_ID, + NOTION_API_VERSION, + AG_PROJECT, + RUN_ID, + CURRENT_SHA, + LAST_SUCCESSFUL_SHA, + WEBSITE_STATUS_CHANNEL, + DEPLOY_TO_STAGING, +} = process.env; + +if (DEPLOY_TO_STAGING !== 'true') { + console.log('DEPLOY_TO_STAGING is not true; skipping staging deploy notification.'); + process.exit(0); +} + +// WEBSITE_STATUS_CHANNEL is optional — when unset we skip the shared-channel +// post but still send opt-in DMs. +const required = { SLACK_BOT_OAUTH_TOKEN, NOTION_API_TOKEN, NOTION_DATA_SOURCE_ID, AG_PROJECT, RUN_ID, CURRENT_SHA, LAST_SUCCESSFUL_SHA }; +for (const [name, value] of Object.entries(required)) { + if (!value) { + ghaError(`${name} environment variable is not set.`, { title: 'Staging deploy notification: missing config' }); + process.exit(1); + } +} + +(async () => { + const { results: users, error } = await getSlackUserConfig({ + notionApiToken: NOTION_API_TOKEN, + notionDataSourceId: NOTION_DATA_SOURCE_ID, + notionApiVersion: NOTION_API_VERSION, + }); + if (error) { + ghaError(`Error fetching Slack user config from Notion: ${error}`, { + title: 'Staging deploy notification: Notion fetch failed', + }); + process.exit(1); + } + + const changes = getGitChanges(CURRENT_SHA, LAST_SUCCESSFUL_SHA, users); + const buildChangesData = (userDisplayTypeSetting) => + getChangesData({ + currentSha: CURRENT_SHA, + lastSuccessfulSha: LAST_SUCCESSFUL_SHA, + project: AG_PROJECT, + gitChanges: changes, + userDisplayTypeSetting, + users, + }); + + const webUrl = getRunUrl(AG_PROJECT, RUN_ID); + const stagingUrl = getStagingUrl(AG_PROJECT); + const emoji = getEmoji(AG_PROJECT); + + // Post to shared #website-status channel using author names (no slack mentions) + // so the channel post doesn't ping contributors. Skip when no channel is configured. + if (WEBSITE_STATUS_CHANNEL) { + const { changesText: channelChangesText } = buildChangesData('name'); + const channelText = `:rocket: ${emoji} ${AG_PROJECT} changes were deployed to ${stagingUrl} (<${webUrl}|#${RUN_ID}>)\n${channelChangesText}`; + await sendSlackMessage({ + authToken: SLACK_BOT_OAUTH_TOKEN, + data: { channel: WEBSITE_STATUS_CHANNEL, text: channelText, unfurl_links: false }, + }); + } else { + ghaWarning('WEBSITE_STATUS_CHANNEL is not set; skipping shared-channel post (opt-in DMs will still be sent).', { + title: 'Staging deploy notification: no shared channel', + }); + } + + // DM each opt-in user whose changes are in this deploy. The DM keeps slack + // mentions so co-contributors in the change list are linked. + const { uniqueUsers, changesText: dmChangesText } = buildChangesData('slack'); + const optInUsers = users.filter((u) => u.stagingNotification === true); + const changedSlackIds = new Set( + uniqueUsers.map((github) => users.find((u) => u.github === github)?.slackId).filter(Boolean) + ); + + const dmText = `:rocket: ${emoji} Your recent changes were deployed to ${stagingUrl} (<${webUrl}|#${RUN_ID}>)\n${dmChangesText}`; + await Promise.all( + optInUsers + .filter((u) => changedSlackIds.has(u.slackId)) + .map((u) => + sendSlackMessage({ + authToken: SLACK_BOT_OAUTH_TOKEN, + data: { channel: u.slackId, text: dmText, unfurl_links: false }, + }) + ) + ); +})(); diff --git a/external/ag-website-shared/src/constants.ts b/external/ag-website-shared/src/constants.ts index a20e98c2495..aea3ca31b96 100644 --- a/external/ag-website-shared/src/constants.ts +++ b/external/ag-website-shared/src/constants.ts @@ -24,7 +24,7 @@ export const CONTACT_FORM_DATA = { messagePlaceholder: 'Tell us about your interest in AG Grid', leadSource: 'AG Grid Contact Form', formLocationId: '00NQ500000CVgqT', - captchaSettingsKeyName: 'agGridStagingV2', + captchaSettingsKeyName: 'agGridComV2', }, }; diff --git a/packages/ag-grid-community/src/entities/rowNode.ts b/packages/ag-grid-community/src/entities/rowNode.ts index 87279f0e613..bfb59663200 100644 --- a/packages/ag-grid-community/src/entities/rowNode.ts +++ b/packages/ag-grid-community/src/entities/rowNode.ts @@ -126,6 +126,9 @@ export class RowNode /** * Either 'top' or 'bottom' if row pinned, otherwise `undefined` or `null`. + * Invariant: only the pinned clone has this set; the source row keeps it null + * even while its `pinnedSibling` clone is in a container. Several pin/destroy + * code paths rely on this asymmetry to disambiguate clone vs source. * If re-naming this property, you must also update `IGNORED_SIBLING_PROPERTIES` */ public rowPinned: RowPinnedType; @@ -134,6 +137,7 @@ export class RowNode * If using manual row pinning, a reference to the sibling node. * If this node is in the pinned section, `pinnedSibling` is the source row. * If this node is in the main viewport, `pinnedSibling` is the pinned row. + * If re-naming this property, you must also update `IGNORED_SIBLING_PROPERTIES` */ public pinnedSibling?: RowNode; @@ -955,10 +959,10 @@ export class RowNode } this.destroyed = true; - // Check pinnedSibling.rowPinned to ensure we're the source row (not a pinned clone being destroyed). - // Also prevents re-entrance when _destroyRowNodeSibling clears rowPinned before calling _destroy. + // Unpin my clone if I'm the source. Only clones have rowPinned (see _createPinnedSibling), + // so this naturally no-ops when the recursive destroy hits the clone. const pinnedSibling = this.pinnedSibling; - if (pinnedSibling?.rowPinned && !this.rowPinned) { + if (pinnedSibling?.rowPinned) { this.beans.pinnedRowModel?.pinRow(pinnedSibling, null); } diff --git a/packages/ag-grid-community/src/entities/rowNodeUtils.ts b/packages/ag-grid-community/src/entities/rowNodeUtils.ts index c18b55e0030..3f945ac83cc 100644 --- a/packages/ag-grid-community/src/entities/rowNodeUtils.ts +++ b/packages/ag-grid-community/src/entities/rowNodeUtils.ts @@ -40,8 +40,11 @@ const IGNORED_SIBLING_PROPERTIES = new Set< '_groupData', '_leafs', 'childStore', + 'destroyed', 'groupValue', 'oldRowTop', + 'pinnedSibling', + 'rowPinned', 'sticky', 'treeNodeFlags', 'treeParent', diff --git a/packages/ag-grid-community/src/pinnedRowModel/manualPinnedRowModel.ts b/packages/ag-grid-community/src/pinnedRowModel/manualPinnedRowModel.ts index 42f34d05b6a..8ea828e2804 100644 --- a/packages/ag-grid-community/src/pinnedRowModel/manualPinnedRowModel.ts +++ b/packages/ag-grid-community/src/pinnedRowModel/manualPinnedRowModel.ts @@ -127,7 +127,10 @@ export class ManualPinnedRowModel extends BeanStub implements IPinnedRowModel { // 3. We then react to the `modelUpdated` event (above) to actually add the footer to the pinned row model. // Otherwise we would run into either an infinite recursion of `modelUpdated` events, or be missing the `sibling` // on the root node. - if (level === -1) { + // Skip this path when unpinning an existing clone (called from RowNode._destroy): + // the standard unpin path below cleans the DOM without mutating _grandTotalPinned. + const unpinningExistingClone = float == null && rowNode.rowPinned != null; + if (level === -1 && !unpinningExistingClone) { this._grandTotalPinned = float; // CSRM goes through reMapRows so the modelUpdated listener picks up the // change; SSRM has no model-update path so we apply it directly. diff --git a/packages/ag-grid-community/src/theming/parts/input-style/input-styles.ts b/packages/ag-grid-community/src/theming/parts/input-style/input-styles.ts index 0de1820e110..ec82c67a38d 100644 --- a/packages/ag-grid-community/src/theming/parts/input-style/input-styles.ts +++ b/packages/ag-grid-community/src/theming/parts/input-style/input-styles.ts @@ -210,7 +210,7 @@ const baseParams: InputStyleParams = { ref: 'inputTextColor', }, pickerButtonBorder: false, - pickerButtonBorderRadius: 0, + pickerButtonBorderRadius: { ref: 'borderRadius' }, pickerButtonFocusBorder: { ref: 'inputFocusBorder' }, pickerButtonBackgroundColor: { ref: 'backgroundColor' }, pickerButtonFocusBackgroundColor: { ref: 'backgroundColor' }, @@ -260,9 +260,6 @@ const makeInputStyleBorderedTreeShakeable = () => color: { ref: 'invalidColor' }, }, pickerButtonBorder: true, - pickerButtonBorderRadius: { - ref: 'borderRadius', - }, pickerListBorder: true, }, css: () => inputStyleBaseCSS + inputStyleBorderedCSS, diff --git a/packages/ag-grid-enterprise/src/menu/menuItemMapper.ts b/packages/ag-grid-enterprise/src/menu/menuItemMapper.ts index 66cf1b3a65d..db27cce1b57 100644 --- a/packages/ag-grid-enterprise/src/menu/menuItemMapper.ts +++ b/packages/ag-grid-enterprise/src/menu/menuItemMapper.ts @@ -126,10 +126,11 @@ export class MenuItemMapper extends BeanStub implements NamedBean { (sideOrRemove: 'top' | 'bottom' | null) => ({ node, column }: IMenuActionParams) => { if (node) { - return pinnedRowModel!.pinRow(node as RowNode, sideOrRemove ?? null, column as AgColumn); + pinnedRowModel!.pinRow(node as RowNode, sideOrRemove ?? null, column as AgColumn); + return; } // pick selected cells / rows / columns - return rangeSvc?.getCellRanges()?.forEach((cellRange) => { + rangeSvc?.getCellRanges()?.forEach((cellRange) => { rangeSvc.forEachRowInRange(cellRange, (row) => { const nodeFromSelection = _getRowNode(beans, row); if (nodeFromSelection) { diff --git a/scripts/agBotSlackMessage.ts b/scripts/agBotSlackMessage.ts deleted file mode 100644 index 558369ce3a4..00000000000 --- a/scripts/agBotSlackMessage.ts +++ /dev/null @@ -1,541 +0,0 @@ -import { execSync } from 'child_process'; -import fetch from 'node-fetch'; -import { hideBin } from 'yargs/helpers'; -import yargs from 'yargs/yargs'; - -const args = yargs(hideBin(process.argv)) - .usage( - 'Usage: $0 --auth-token [auth-token] -grid-channel [grid-channel] --charts-channel [charts-channel] --website-status-channel [website-status-channel] --slack-bot-oauth-token [slack-bot-oauth-token] --debug-channel [debug-channel] --run-context [run-context]' - ) - .options({ - 'auth-token': { - type: 'string', - demandOption: true, - }, - 'grid-channel': { - type: 'string', - demandOption: true, - }, - 'charts-channel': { - type: 'string', - demandOption: true, - }, - 'website-status-channel': { - type: 'string', - demandOption: true, - }, - 'debug-channel': { - type: 'string', - demandOption: true, - }, - 'run-context': { - type: 'string', - demandOption: true, - }, - }) - .parseSync(); - -const SLACK_BOT_OAUTH_TOKEN = args.authToken; -const GRID_TEAM_CITY_CHANNEL = args.gridChannel; -const CHARTS_TEAM_CITY_CHANNEL = args.chartsChannel; -const WEBSITE_STATUS_CHANNEL = args.websiteStatusChannel; -const SLACK_DEBUG_CHANNEL = args.debugChannel; - -type GH_MAPPING = { - id: string; - name: string; - email: string[]; - real_name: string; - github: string; - directNotification?: boolean; -}; - -type JobStatus = 'success' | 'failure' | 'n/a'; - -type RunContext = { - runId: number; - workflow: string; - ref: string; - currentSha: string; - lastSuccessfulSha: string; - status: 'success' | 'failure'; - changedState: boolean; - project: AgProject; - reportUrl: number; - jobStatuses: { [key: string]: JobStatus }; - deployToStaging: boolean; -}; - -type AgProject = 'AgGrid' | 'AgCharts' | 'Blog'; - -const JIRA_BASE_URL = 'https://ag-grid.atlassian.net/jira/software/c/projects/AG'; - -const SLACK_POST_MESSAGE_URL = 'https://slack.com/api/chat.postMessage'; -const SLACK_POST_EPHEMERAL_URL = 'https://slack.com/api/chat.postEphemeral'; -const THREAD_TEAMCITY_RESPONSE = true; -const MANY_CHANGES_LIMIT = 10; -const SLACK_GITHUB_MAPPING = process.env.SLACK_GITHUB_MAPPING; - -interface GitChange { - id?: string; - version: string; - username: string; - comment: string; -} - -interface User { - id: string; - name: string; - real_name?: string; - github: string; - directNotification?: boolean; -} - -type UserDisplayType = 'slack' | 'name' | 'debug'; - -let slackGithubMapping: GH_MAPPING[]; -function getSlackGithubMapping(): GH_MAPPING[] { - if (slackGithubMapping === undefined) { - slackGithubMapping = []; - try { - slackGithubMapping = JSON.parse(SLACK_GITHUB_MAPPING!); - } catch (error) { - console.error('Error parsing SLACK_GITHUB_MAPPING:', error); - } - } - - return slackGithubMapping; -} - -function sendSlackMessage({ isEphemeral, data }: { isEphemeral?: boolean; data: object }) { - const url = isEphemeral ? SLACK_POST_EPHEMERAL_URL : SLACK_POST_MESSAGE_URL; - return fetch(url, { - headers: { - 'Content-Type': 'application/json; charset=utf-8', - Authorization: `Bearer ${SLACK_BOT_OAUTH_TOKEN}`, - }, - method: 'POST', - body: JSON.stringify(data), - }); -} - -function getGithubBaseUrl(project: AgProject) { - let baseUrl = 'https://github.com/ag-grid/ag-grid'; - if (project === 'Blog') { - baseUrl = 'https://github.com/ag-grid/ag-blog-content'; - } else if (project === 'AgCharts') { - baseUrl = 'https://github.com/ag-grid/ag-charts'; - } - - return baseUrl; -} - -function getEmoji(project: AgProject) { - return project === 'AgGrid' ? ':bento:' : project === 'AgCharts' ? ':bar_chart:' : ''; -} - -function getStagingUrl(project: AgProject) { - let url = 'https://grid-staging.ag-grid.com'; - if (project === 'AgCharts') { - url = 'https://charts-staging.ag-grid.com'; - } - - return url; -} - -function getBranchLink(runContext: RunContext) { - const branchName = runContext.ref; - const baseUrl = getGithubBaseUrl(runContext.project); - - if (branchName === undefined) { - return ''; - } else if (branchName === 'refs/heads/latest') { - return `<${baseUrl}/tree/latest|latest>`; - } else if (branchName.startsWith('pull/')) { - return `<${baseUrl}/${branchName}|PR #${branchName.slice('pull/'.length)}>`; - } else if (branchName.startsWith('refs/tags/')) { - const tagName = branchName.slice('refs/tags/'.length); - return `<${baseUrl}/tree/${tagName}|${tagName}>`; - } - return branchName; -} - -function getUser(username: string): User | undefined { - return getSlackGithubMapping().find(({ github }: { github: string }) => github === username); -} - -function getUserDisplay(username: string, userDisplayType: UserDisplayType) { - const user = getUser(username); - const slackUserId = user?.id; - - let userDisplay = user?.real_name || username; - if (slackUserId) { - if (userDisplayType === 'name') { - userDisplay = user.real_name || user.name; - } else if (userDisplayType === 'slack') { - userDisplay = `<@${slackUserId}>`; - } else if (userDisplayType === 'debug') { - userDisplay = `${userDisplay} (${slackUserId})`; - } - } - - return userDisplay; -} - -function updateWithJiraUrl(str: string) { - const regex = /(AG-[0-9]+)(.*)/gm; - const subst = `<${JIRA_BASE_URL}/$1|$1>$2`; - - return str.replace(regex, subst); -} - -function updateWithGithubPRUrl({ str, baseGithubUrl }: { str: string; baseGithubUrl: string }) { - const regex = /#(\d+)/gm; - const subst = `<${baseGithubUrl}/pull/$1 | #$1>`; - - return str.replace(regex, subst); -} - -function findUserByEmail(testEmail: string): GH_MAPPING | undefined { - return getSlackGithubMapping().find(({ email }) => email.some((email) => email === testEmail)); -} - -function getGitChanges(currentSha: string, lastSuccessfulSha: string): GitChange[] { - const firstAfterSuccess = execSync( - `git log --reverse --ancestry-path --pretty=%H ${lastSuccessfulSha}..HEAD | head -1`, - { - stdio: 'pipe', - encoding: 'utf-8', - } - ); - - const gitCommand = - firstAfterSuccess.length === 0 || firstAfterSuccess === currentSha - ? `git log ${currentSha} --format="%ae||%an||%h||%s" | head -1` - : `git log ${currentSha}...${firstAfterSuccess} --format="%ae||%an||%h||%s" | head -1`; - - const rawChanges = execSync(`${gitCommand}`, { - stdio: 'pipe', - encoding: 'utf-8', - }); - - return rawChanges - .split('\n') - .filter((change) => change.length > 0) - .map((change) => change.split('||')) - .map((change) => ({ - username: findUserByEmail(change[0])?.github || change[1], - id: findUserByEmail(change[0])?.id, - version: change[2], - comment: change[3], - })); -} - -function getChangesData( - currentSha: string, - lastSuccessfulSha: string, - project: AgProject, - gitChanges: GitChange[], - userDisplayTypeSetting: UserDisplayType -) { - const firstChangeSha = lastSuccessfulSha; - const lastChangeSha = currentSha; - - const baseGithubUrl = getGithubBaseUrl(project); - const githubUrl = - gitChanges.length > 1 - ? `${baseGithubUrl}/compare/${lastChangeSha}...${firstChangeSha}` - : `${baseGithubUrl}/commit/${currentSha}`; - - const changesLimit = MANY_CHANGES_LIMIT; - const tooManyChanges = gitChanges.length > changesLimit; - const changes = tooManyChanges ? gitChanges.slice(0, changesLimit) : gitChanges; - - const allUsers = gitChanges.map(({ username }: GitChange) => username); - const allOtherUsers = allUsers.slice(changesLimit); - const uniqueUsers: string[] = [...new Set(allUsers)] as string[]; - - // Only show the name if there are too many changes, display setting is `slack` and there is more than 1 user with changes - const userDisplayType = - tooManyChanges && userDisplayTypeSetting === 'slack' && uniqueUsers.length > 1 - ? 'name' - : userDisplayTypeSetting; - const otherUsers = [...new Set(allOtherUsers)] - .map((username) => { - return getUserDisplay(username, userDisplayType); - }) - .join(', '); - - let changeDetails = changes - .map(({ username, comment, version }: GitChange) => { - const commentFirstLine = comment.split('\n')[0]; - const firstLine = updateWithGithubPRUrl({ str: updateWithJiraUrl(commentFirstLine), baseGithubUrl }); - const shortSha = version.slice(0, 7); - const userDisplay = getUserDisplay(username, userDisplayType); - - return `• ${userDisplay}: ${firstLine} (<${baseGithubUrl}/commit/${version}|${shortSha}>)`; - }) - .join('\n'); - if (tooManyChanges) { - changeDetails += `\n• ...(${gitChanges.length - changesLimit} more changes from ${otherUsers})`; - } - - const changesText = - gitChanges.length === 0 ? '_No changes_' : `Changes (<${githubUrl}|Github diff>):\n${changeDetails}`; - - return { - uniqueUsers, - changesText, - }; -} - -function getJobStatusSummary(runContext: RunContext) { - const getStatus = (status: JobStatus) => `${status === 'success' ? '✅' : status === 'failure' ? '❌' : '➖'}`; - return `${Object.entries(runContext.jobStatuses) - .map(([job, status]) => `${job}: ${getStatus(status)}`) - .join(' | ')}`; -} - -function buildFailureSlackMessageBlocks( - changes: GitChange[], - runContext: RunContext, - userDisplayType: UserDisplayType -) { - const branchLink = getBranchLink(runContext); - const branchDetails = branchLink ? ` (on ${branchLink})` : ''; - const { currentSha, lastSuccessfulSha, runId, project, workflow, reportUrl } = runContext; - const { changesText } = getChangesData(currentSha, lastSuccessfulSha, project, changes, userDisplayType); - - const testReportUrl = reportUrl ? `(<${reportUrl}|Test Results>)` : ''; - const emoji = getEmoji(project); - const webUrl = `https://github.com/ag-grid/ag-grid/actions/runs/${runId}`; - return [ - { - type: 'section', - text: { - type: 'mrkdwn', - text: `:x: ${emoji} ${project} / <${webUrl} | ${workflow} #${runId}>${branchDetails} *failed* ${testReportUrl} -${getJobStatusSummary(runContext)}`, - }, - }, - { - type: 'section', - text: { - type: 'mrkdwn', - text: changesText, - }, - }, - ]; -} - -async function sendFailureSlackMessage( - changes: GitChange[], - runContext: RunContext, - channel: string, - userDisplayType: UserDisplayType -) { - const blocks = buildFailureSlackMessageBlocks(changes, runContext, userDisplayType); - const res = await sendSlackMessage({ - data: { - channel, - blocks, - unfurl_links: false, - }, - }); - - return res.json(); -} - -async function sendSuccessSlackMessage( - changes: GitChange[], - runContext: RunContext, - channel: string, - userDisplayType: UserDisplayType -) { - const branchLink = getBranchLink(runContext); - const branchDetails = branchLink ? ` (on ${branchLink})` : ''; - - const { currentSha, lastSuccessfulSha, runId, project, workflow } = runContext; - const { changesText } = getChangesData(currentSha, lastSuccessfulSha, project, changes, userDisplayType); - const emoji = getEmoji(project); - const webUrl = `https://github.com/ag-grid/ag-grid/actions/runs/${runId}`; - const text = - `:white_check_mark: ${emoji} ${project} / <${webUrl} | ${workflow} #${runId}>${branchDetails} successful -${getJobStatusSummary(runContext)}` + `\n${changesText}`; - - const res = await sendSlackMessage({ - data: { - channel, - text, - unfurl_links: false, - }, - }); - - return res.json(); -} - -async function sendCodeSlackMessage({ channel, code, threadTs }: { channel: string; code: string; threadTs: string }) { - const text = `Generated from teamcity response:\n\`\`\`${code}\n\`\`\``; - - const res = await sendSlackMessage({ - data: { - channel, - text, - thread_ts: threadTs, - }, - }); - - return res.json(); -} - -function getUserSlackId(username: string) { - return getUser(username)?.id; -} - -async function notifyIndividualStagingDeploy( - runContext: RunContext, - changes: GitChange[], - userDisplayType: UserDisplayType -) { - const { currentSha, lastSuccessfulSha, runId, project, deployToStaging } = runContext; - const { uniqueUsers, changesText } = getChangesData( - currentSha, - lastSuccessfulSha, - project, - changes, - userDisplayType - ); - - const usersToCheck = getSlackGithubMapping().filter((mapping) => !!mapping.directNotification); - const slackUsers = uniqueUsers.map((userName) => getUserSlackId(userName)); - - const usersWithChanges = usersToCheck - .map((mapping) => { - const userHasChange = slackUsers.includes(mapping.id); - - if (deployToStaging && userHasChange) { - return mapping.id; - } - }) - .filter(Boolean); - - const promises = usersWithChanges.map(async (slackId) => { - const webUrl = `https://github.com/ag-grid/ag-grid/actions/runs/${runId}`; - const stagingUrl = getStagingUrl(project); - const emoji = getEmoji(project); - const text = `:rocket: ${emoji} Your recent changes were deployed to ${stagingUrl} (<${webUrl}|#${runId}>)\n${changesText}`; - - const slackResp = await sendSlackMessage({ data: { channel: slackId, text, unfurl_links: false } }); - return await slackResp.json(); - }); - - return await Promise.all(promises); -} - -async function notifySlackDebug(changes: GitChange[], runContext: RunContext, channel: string) { - const { status } = runContext; - - // NOTE: Don't use slack username, so that it doesn't notify - const userDisplayType: UserDisplayType = 'debug'; - let slackDebugMessage; - - if (status === 'failure') { - slackDebugMessage = sendFailureSlackMessage(changes, runContext, channel, userDisplayType); - } else if (status === 'success') { - // NOTE: Don't use slack username, so that it doesn't notify - slackDebugMessage = sendSuccessSlackMessage(changes, runContext, channel, userDisplayType); - } - - const slackDebugResponse = slackDebugMessage ? await slackDebugMessage : undefined; - - if (THREAD_TEAMCITY_RESPONSE && slackDebugResponse?.ts) { - await sendCodeSlackMessage({ - channel, - code: JSON.stringify(runContext, null, 2), - threadTs: slackDebugResponse.ts, - }); - - await sendCodeSlackMessage({ - channel, - code: JSON.stringify(changes, null, 2), - threadTs: slackDebugResponse.ts, - }); - } -} - -async function notifyStagingDeploy( - runContext: RunContext, - changes: GitChange[], - userDisplayType: UserDisplayType, - channel: string -) { - const { currentSha, lastSuccessfulSha, runId, project, deployToStaging } = runContext; - - let slackMessage; - - if (deployToStaging) { - const { changesText } = getChangesData(currentSha, lastSuccessfulSha, project, changes, userDisplayType); - const webUrl = `https://github.com/ag-grid/ag-grid/actions/runs/${runId}`; - const stagingUrl = getStagingUrl(project); - const emoji = getEmoji(project); - - const text = `:rocket: ${emoji} ${project} changes were deployed to ${stagingUrl} (<${webUrl}|#${runId}>)\n${changesText}`; - - const res = await sendSlackMessage({ - data: { - channel, - text, - unfurl_links: false, - }, - }); - - slackMessage = res.json(); - } - - return slackMessage ? await slackMessage : undefined; -} - -async function processChanges(runContext: RunContext, userDisplayType: UserDisplayType) { - try { - const { project, currentSha, lastSuccessfulSha, status, changedState } = runContext; - const changes = getGitChanges(currentSha, lastSuccessfulSha); - - // Notify slack debugging - await notifySlackDebug(changes, runContext, SLACK_DEBUG_CHANNEL); - - if (changedState) { - // Notify slack of build failures - let buildStatusChannel = GRID_TEAM_CITY_CHANNEL; - if (project === 'AgCharts') { - buildStatusChannel = CHARTS_TEAM_CITY_CHANNEL; - } - - if (status === 'failure') { - await sendFailureSlackMessage(changes, runContext, buildStatusChannel, userDisplayType); - } else if (status === 'success') { - await sendSuccessSlackMessage(changes, runContext, buildStatusChannel, userDisplayType); - } - } - - // Notify user when deployment to staging is done - await notifyIndividualStagingDeploy(runContext, changes, userDisplayType); - - // Notify website status when staging deploy is finished - await notifyStagingDeploy(runContext, changes, userDisplayType, WEBSITE_STATUS_CHANNEL); - } catch (e) { - console.log(e); - } -} - -(async () => { - const runContext: RunContext = JSON.parse(args.runContext); - // Mirror the workflow's own check: only an explicit 'failure' counts as a failure; - // 'skipped' / 'cancelled' / 'n/a' are not failures. - runContext.status = Object.values(runContext.jobStatuses).some((status) => status === 'failure') - ? 'failure' - : 'success'; - - // temporary - to facilitate testing while this new implementation is being tested - console.log(runContext); - await processChanges(runContext, 'slack'); -})(); diff --git a/testing/behavioural/src/grouping-data/grouped-pinned-sibling-aggregation.test.ts b/testing/behavioural/src/grouping-data/grouped-pinned-sibling-aggregation.test.ts index d6131024d73..3f65cd71c6a 100644 --- a/testing/behavioural/src/grouping-data/grouped-pinned-sibling-aggregation.test.ts +++ b/testing/behavioural/src/grouping-data/grouped-pinned-sibling-aggregation.test.ts @@ -467,6 +467,306 @@ describe('ag-grid grouping pinned sibling aggregation', () => { PINNED_BOTTOM id:b-bottom-rowGroupFooter_ROOT_NODE_ID ag-Grid-AutoColumn:"Total " amount:1400 `); }); + + test('CSRM pinned grand total stays as a single row after filter change', async () => { + const api = await gridsManager.createGridAndWait('myGrid', { + columnDefs: [ + { field: 'country', rowGroup: true, hide: true }, + { field: 'amount', aggFunc: 'sum', filter: 'agNumberColumnFilter' }, + ], + rowData: createRowData(), + getRowId: (params) => params.data.id, + grandTotalRow: 'pinnedBottom', + groupDefaultExpanded: -1, + }); + + expect(api.getPinnedBottomRowCount()).toBe(1); + expect(api.getPinnedBottomRow(0)?.aggData?.amount).toBe(1000); + + await api.setColumnFilterModel('amount', { filterType: 'number', type: 'greaterThanOrEqual', filter: 200 }); + api.onFilterChanged(); + + expect(api.getPinnedBottomRowCount()).toBe(1); + expect(api.getPinnedTopRowCount()).toBe(0); + expect(api.getPinnedBottomRow(0)?.aggData?.amount).toBe(750); // lyon 200 + hamburg 250 + rome 300 + }); + + test('CSRM pinned grand total stays as a single row after cycling position', async () => { + const api = await gridsManager.createGridAndWait('myGrid', { + columnDefs: [ + { field: 'country', rowGroup: true, hide: true }, + { field: 'amount', aggFunc: 'sum' }, + ], + rowData: createRowData(), + getRowId: (params) => params.data.id, + grandTotalRow: 'pinnedBottom', + groupDefaultExpanded: -1, + }); + + expect(api.getPinnedBottomRowCount()).toBe(1); + const firstPinned = api.getPinnedBottomRow(0)!; + + api.setGridOption('grandTotalRow', 'pinnedTop'); + expect(api.getPinnedBottomRowCount()).toBe(0); + expect(api.getPinnedTopRowCount()).toBe(1); + expect(firstPinned.destroyed).toBe(true); + const topPinned = api.getPinnedTopRow(0)!; + + api.setGridOption('grandTotalRow', undefined); + expect(api.getPinnedBottomRowCount()).toBe(0); + expect(api.getPinnedTopRowCount()).toBe(0); + expect(topPinned.destroyed).toBe(true); + + api.setGridOption('grandTotalRow', 'pinnedBottom'); + expect(api.getPinnedBottomRowCount()).toBe(1); + expect(api.getPinnedBottomRow(0)?.destroyed).toBe(false); + }); + + test('grouped CSRM pinnedBottom grand total repopulates after clearing rowData and adding via transaction', async () => { + const api = await gridsManager.createGridAndWait('myGrid', { + columnDefs: [ + { field: 'country', rowGroup: true, hide: true }, + { field: 'amount', aggFunc: 'sum' }, + ], + rowData: createRowData(), + getRowId: (params) => params.data.id, + grandTotalRow: 'pinnedBottom', + groupDefaultExpanded: -1, + }); + + expect(api.getPinnedBottomRowCount()).toBe(1); + expect(api.getPinnedBottomRow(0)?.aggData?.amount).toBe(1000); + + // Clear all rows via setGridOption, then repopulate via transaction + api.setGridOption('rowData', []); + applyTransactionChecked(api, { add: createRowData() }); + + expect(api.getPinnedBottomRowCount()).toBe(1); + expect(api.getPinnedBottomRow(0)?.aggData?.amount).toBe(1000); + + await new GridRows(api, 'after clear+add').check(` + ROOT id:ROOT_NODE_ID amount:1000 + ├─┬ LEAF_GROUP id:row-group-country-France ag-Grid-AutoColumn:"France" amount:300 + │ ├── LEAF id:fr-paris country:"France" amount:100 + │ └── LEAF id:fr-lyon country:"France" amount:200 + ├─┬ LEAF_GROUP id:row-group-country-Germany ag-Grid-AutoColumn:"Germany" amount:400 + │ ├── LEAF id:de-berlin country:"Germany" amount:150 + │ └── LEAF id:de-hamburg country:"Germany" amount:250 + └─┬ LEAF_GROUP id:row-group-country-Italy ag-Grid-AutoColumn:"Italy" amount:300 + · └── LEAF id:it-rome country:"Italy" amount:300 + PINNED_BOTTOM id:b-bottom-rowGroupFooter_ROOT_NODE_ID ag-Grid-AutoColumn:"Total " amount:1000 + `); + }); + + test('grouped CSRM pinnedTop grand total repopulates after clearing rowData and adding via transaction', async () => { + const api = await gridsManager.createGridAndWait('myGrid', { + columnDefs: [ + { field: 'country', rowGroup: true, hide: true }, + { field: 'amount', aggFunc: 'sum' }, + ], + rowData: createRowData(), + getRowId: (params) => params.data.id, + grandTotalRow: 'pinnedTop', + groupDefaultExpanded: -1, + }); + + expect(api.getPinnedTopRowCount()).toBe(1); + expect(api.getPinnedTopRow(0)?.aggData?.amount).toBe(1000); + + api.setGridOption('rowData', []); + applyTransactionChecked(api, { add: createRowData() }); + + expect(api.getPinnedTopRowCount()).toBe(1); + expect(api.getPinnedTopRow(0)?.aggData?.amount).toBe(1000); + + await new GridRows(api, 'after clear+add').check(` + PINNED_TOP id:t-top-rowGroupFooter_ROOT_NODE_ID ag-Grid-AutoColumn:"Total " amount:1000 + ROOT id:ROOT_NODE_ID amount:1000 + ├─┬ LEAF_GROUP id:row-group-country-France ag-Grid-AutoColumn:"France" amount:300 + │ ├── LEAF id:fr-paris country:"France" amount:100 + │ └── LEAF id:fr-lyon country:"France" amount:200 + ├─┬ LEAF_GROUP id:row-group-country-Germany ag-Grid-AutoColumn:"Germany" amount:400 + │ ├── LEAF id:de-berlin country:"Germany" amount:150 + │ └── LEAF id:de-hamburg country:"Germany" amount:250 + └─┬ LEAF_GROUP id:row-group-country-Italy ag-Grid-AutoColumn:"Italy" amount:300 + · └── LEAF id:it-rome country:"Italy" amount:300 + `); + }); + + test('flat CSRM pinnedBottom grand total repopulates after clearing rowData and adding via transaction', async () => { + const api = await gridsManager.createGridAndWait('myGrid', { + columnDefs: [{ field: 'amount', aggFunc: 'sum' }], + rowData: createRowData(), + getRowId: (params) => params.data.id, + grandTotalRow: 'pinnedBottom', + }); + + expect(api.getPinnedBottomRowCount()).toBe(1); + expect(api.getPinnedBottomRow(0)?.aggData?.amount).toBe(1000); + + api.setGridOption('rowData', []); + applyTransactionChecked(api, { add: createRowData() }); + + expect(api.getPinnedBottomRowCount()).toBe(1); + expect(api.getPinnedBottomRow(0)?.aggData?.amount).toBe(1000); + + await new GridRows(api, 'after clear+add').check(` + ROOT id:ROOT_NODE_ID amount:1000 + ├── LEAF id:fr-paris amount:100 + ├── LEAF id:fr-lyon amount:200 + ├── LEAF id:de-berlin amount:150 + ├── LEAF id:de-hamburg amount:250 + └── LEAF id:it-rome amount:300 + PINNED_BOTTOM id:b-bottom-rowGroupFooter_ROOT_NODE_ID amount:1000 + `); + }); + + test('flat CSRM pinnedTop grand total repopulates after clearing rowData and adding via transaction', async () => { + const api = await gridsManager.createGridAndWait('myGrid', { + columnDefs: [{ field: 'amount', aggFunc: 'sum' }], + rowData: createRowData(), + getRowId: (params) => params.data.id, + grandTotalRow: 'pinnedTop', + }); + + expect(api.getPinnedTopRowCount()).toBe(1); + expect(api.getPinnedTopRow(0)?.aggData?.amount).toBe(1000); + + api.setGridOption('rowData', []); + applyTransactionChecked(api, { add: createRowData() }); + + expect(api.getPinnedTopRowCount()).toBe(1); + expect(api.getPinnedTopRow(0)?.aggData?.amount).toBe(1000); + + await new GridRows(api, 'after clear+add').check(` + PINNED_TOP id:t-top-rowGroupFooter_ROOT_NODE_ID amount:1000 + ROOT id:ROOT_NODE_ID amount:1000 + ├── LEAF id:fr-paris amount:100 + ├── LEAF id:fr-lyon amount:200 + ├── LEAF id:de-berlin amount:150 + ├── LEAF id:de-hamburg amount:250 + └── LEAF id:it-rome amount:300 + `); + }); + + test('grouped CSRM pinnedTop grand total switches between views via setGridOption([]) + applyTransaction', async () => { + const VIEW_A: RowData[] = cachedJSONObjects.array([ + { id: '1', country: 'Electronics', amount: 1000 }, + { id: '2', country: 'Electronics', amount: 2000 }, + { id: '3', country: 'Food', amount: 300 }, + { id: '4', country: 'Food', amount: 200 }, + ]); + const VIEW_B: RowData[] = cachedJSONObjects.array([ + { id: '5', country: 'Clothing', amount: 500 }, + { id: '6', country: 'Clothing', amount: 700 }, + { id: '7', country: 'Books', amount: 150 }, + { id: '8', country: 'Books', amount: 400 }, + ]); + + const api = await gridsManager.createGridAndWait('myGrid', { + columnDefs: [ + { field: 'country', rowGroup: true, hide: true }, + { field: 'amount', aggFunc: 'sum' }, + ], + rowData: VIEW_A, + getRowId: (params) => params.data.id, + grandTotalRow: 'pinnedTop', + groupDefaultExpanded: -1, + }); + + expect(api.getPinnedTopRowCount()).toBe(1); + expect(api.getPinnedTopRow(0)?.aggData?.amount).toBe(3500); + + // Switch to VIEW_B using the pattern from the bug report + api.setGridOption('rowData', []); + applyTransactionChecked(api, { add: VIEW_B }); + + expect(api.getPinnedTopRowCount()).toBe(1); + expect(api.getPinnedTopRow(0)?.aggData?.amount).toBe(1750); + + await new GridRows(api, 'after switch to VIEW_B').check(` + PINNED_TOP id:t-top-rowGroupFooter_ROOT_NODE_ID ag-Grid-AutoColumn:"Total " amount:1750 + ROOT id:ROOT_NODE_ID amount:1750 + ├─┬ LEAF_GROUP id:row-group-country-Clothing ag-Grid-AutoColumn:"Clothing" amount:1200 + │ ├── LEAF id:5 country:"Clothing" amount:500 + │ └── LEAF id:6 country:"Clothing" amount:700 + └─┬ LEAF_GROUP id:row-group-country-Books ag-Grid-AutoColumn:"Books" amount:550 + · ├── LEAF id:7 country:"Books" amount:150 + · └── LEAF id:8 country:"Books" amount:400 + `); + + // Switch back to VIEW_A + api.setGridOption('rowData', []); + applyTransactionChecked(api, { add: VIEW_A }); + + expect(api.getPinnedTopRowCount()).toBe(1); + expect(api.getPinnedTopRow(0)?.aggData?.amount).toBe(3500); + + await new GridRows(api, 'after switch back to VIEW_A').check(` + PINNED_TOP id:t-top-rowGroupFooter_ROOT_NODE_ID ag-Grid-AutoColumn:"Total " amount:3500 + ROOT id:ROOT_NODE_ID amount:3500 + ├─┬ LEAF_GROUP id:row-group-country-Electronics ag-Grid-AutoColumn:"Electronics" amount:3000 + │ ├── LEAF id:1 country:"Electronics" amount:1000 + │ └── LEAF id:2 country:"Electronics" amount:2000 + └─┬ LEAF_GROUP id:row-group-country-Food ag-Grid-AutoColumn:"Food" amount:500 + · ├── LEAF id:3 country:"Food" amount:300 + · └── LEAF id:4 country:"Food" amount:200 + `); + }); + + test('CSRM pinned grand total stays as a single row after filter change', async () => { + const api = await gridsManager.createGridAndWait('myGrid', { + columnDefs: [ + { field: 'country', rowGroup: true, hide: true }, + { field: 'amount', aggFunc: 'sum', filter: 'agNumberColumnFilter' }, + ], + rowData: createRowData(), + getRowId: (params) => params.data.id, + grandTotalRow: 'pinnedBottom', + groupDefaultExpanded: -1, + }); + + expect(api.getPinnedBottomRowCount()).toBe(1); + expect(api.getPinnedBottomRow(0)?.aggData?.amount).toBe(1000); + + await api.setColumnFilterModel('amount', { filterType: 'number', type: 'greaterThanOrEqual', filter: 200 }); + api.onFilterChanged(); + + expect(api.getPinnedBottomRowCount()).toBe(1); + expect(api.getPinnedTopRowCount()).toBe(0); + expect(api.getPinnedBottomRow(0)?.aggData?.amount).toBe(750); // lyon 200 + hamburg 250 + rome 300 + }); + + test('CSRM pinned grand total stays as a single row after cycling position', async () => { + const api = await gridsManager.createGridAndWait('myGrid', { + columnDefs: [ + { field: 'country', rowGroup: true, hide: true }, + { field: 'amount', aggFunc: 'sum' }, + ], + rowData: createRowData(), + getRowId: (params) => params.data.id, + grandTotalRow: 'pinnedBottom', + groupDefaultExpanded: -1, + }); + + expect(api.getPinnedBottomRowCount()).toBe(1); + const firstPinned = api.getPinnedBottomRow(0)!; + + api.setGridOption('grandTotalRow', 'pinnedTop'); + expect(api.getPinnedBottomRowCount()).toBe(0); + expect(api.getPinnedTopRowCount()).toBe(1); + expect(firstPinned.destroyed).toBe(true); + const topPinned = api.getPinnedTopRow(0)!; + + api.setGridOption('grandTotalRow', undefined); + expect(api.getPinnedBottomRowCount()).toBe(0); + expect(api.getPinnedTopRowCount()).toBe(0); + expect(topPinned.destroyed).toBe(true); + + api.setGridOption('grandTotalRow', 'pinnedBottom'); + expect(api.getPinnedBottomRowCount()).toBe(1); + expect(api.getPinnedBottomRow(0)?.destroyed).toBe(false); + }); }); describe('getAggregatedChildren on pinned siblings', () => { diff --git a/testing/behavioural/src/grouping-data/ssrm/ssrm-grand-total.test.ts b/testing/behavioural/src/grouping-data/ssrm/ssrm-grand-total.test.ts index bf1676cdd06..0149bfae038 100644 --- a/testing/behavioural/src/grouping-data/ssrm/ssrm-grand-total.test.ts +++ b/testing/behavioural/src/grouping-data/ssrm/ssrm-grand-total.test.ts @@ -1498,6 +1498,300 @@ describe('SSRM grand total row', () => { expect(api.getRowNode(GRAND_TOTAL_ID)?.data?.value).toBe(expectedTotal); }); + test('pinnedBottom grand total is replaced (not duplicated) after filter change', async () => { + const api: GridApi = gridManager.createGrid(null, { + columnDefs: [{ field: 'id' }, { field: 'value', filter: 'agNumberColumnFilter' }], + rowModelType: 'serverSide', + getRowId: (params: GetRowIdParams) => params.data.id, + grandTotalRow: 'pinnedBottom', + serverSideDatasource: { + getRows(params: IServerSideGetRowsParams) { + const filter = params.request.filterModel as { value?: { filter?: number } } | null; + const threshold = filter?.value?.filter; + const filtered = threshold != null ? flatRows.filter((r) => r.value > threshold) : flatRows; + const rowData: any[] = [...filtered]; + if (params.needsGrandTotal) { + const total = filtered.reduce((s, r) => s + r.value, 0); + rowData.push({ id: GRAND_TOTAL_ID, value: total }); + } + setTimeout(() => params.success({ rowData, rowCount: filtered.length }), 0); + }, + }, + }); + + await waitForEvent('firstDataRendered', api); + await waitForNoLoadingRows(api); + + expect(api.getPinnedBottomRowCount()).toBe(1); + expect(api.getPinnedBottomRow(0)?.data?.value).toBe(60); + + await new GridRows(api, 'pinnedBottom initial').check(unindentText` + ROOT id: + ├── LEAF id:1 id:"1" value:10 + ├── LEAF id:2 id:"2" value:20 + └── LEAF id:3 id:"3" value:30 + PINNED_BOTTOM id:b-bottom-rowGroupFooter_ROOT_NODE_ID id:"rowGroupFooter_ROOT_NODE_ID" value:60 + `); + + api.setFilterModel({ value: { type: 'greaterThan', filter: 15 } }); + await waitForNoLoadingRows(api); + await asyncSetTimeout(50); + + expect(api.getPinnedBottomRowCount()).toBe(1); + expect(api.getPinnedBottomRow(0)?.data?.value).toBe(50); + + await new GridRows(api, 'pinnedBottom after filter').check(unindentText` + ROOT id: + ├── LEAF id:2 id:"2" value:20 + └── LEAF id:3 id:"3" value:30 + PINNED_BOTTOM id:b-bottom-rowGroupFooter_ROOT_NODE_ID id:"rowGroupFooter_ROOT_NODE_ID" value:50 + `); + }); + + test('pinnedTop grand total is replaced (not duplicated) after filter change', async () => { + const api: GridApi = gridManager.createGrid(null, { + columnDefs: [{ field: 'id' }, { field: 'value', filter: 'agNumberColumnFilter' }], + rowModelType: 'serverSide', + getRowId: (params: GetRowIdParams) => params.data.id, + grandTotalRow: 'pinnedTop', + serverSideDatasource: { + getRows(params: IServerSideGetRowsParams) { + const filter = params.request.filterModel as { value?: { filter?: number } } | null; + const threshold = filter?.value?.filter; + const filtered = threshold != null ? flatRows.filter((r) => r.value > threshold) : flatRows; + const rowData: any[] = [...filtered]; + if (params.needsGrandTotal) { + const total = filtered.reduce((s, r) => s + r.value, 0); + rowData.push({ id: GRAND_TOTAL_ID, value: total }); + } + setTimeout(() => params.success({ rowData, rowCount: filtered.length }), 0); + }, + }, + }); + + await waitForEvent('firstDataRendered', api); + await waitForNoLoadingRows(api); + + expect(api.getPinnedTopRowCount()).toBe(1); + expect(api.getPinnedTopRow(0)?.data?.value).toBe(60); + + api.setFilterModel({ value: { type: 'greaterThan', filter: 15 } }); + await waitForNoLoadingRows(api); + await asyncSetTimeout(20); + + expect(api.getPinnedTopRowCount()).toBe(1); + expect(api.getPinnedBottomRowCount()).toBe(0); + expect(api.getPinnedTopRow(0)?.data?.value).toBe(50); + }); + + test('pinnedBottom grand total survives repeated filter changes', async () => { + const api: GridApi = gridManager.createGrid(null, { + columnDefs: [{ field: 'id' }, { field: 'value', filter: 'agNumberColumnFilter' }], + rowModelType: 'serverSide', + getRowId: (params: GetRowIdParams) => params.data.id, + grandTotalRow: 'pinnedBottom', + serverSideDatasource: { + getRows(params: IServerSideGetRowsParams) { + const filter = params.request.filterModel as { value?: { filter?: number } } | null; + const threshold = filter?.value?.filter; + const filtered = threshold != null ? flatRows.filter((r) => r.value > threshold) : flatRows; + const rowData: any[] = [...filtered]; + if (params.needsGrandTotal) { + const total = filtered.reduce((s, r) => s + r.value, 0); + rowData.push({ id: GRAND_TOTAL_ID, value: total }); + } + setTimeout(() => params.success({ rowData, rowCount: filtered.length }), 0); + }, + }, + }); + + await waitForEvent('firstDataRendered', api); + await waitForNoLoadingRows(api); + + const thresholds = [15, 25, undefined, 9, 15]; + const expectedValues = [50, 30, 60, 60, 50]; + for (let i = 0; i < thresholds.length; i++) { + const t = thresholds[i]; + api.setFilterModel(t === undefined ? null : { value: { type: 'greaterThan', filter: t } }); + await waitForNoLoadingRows(api); + await asyncSetTimeout(10); + + expect(api.getPinnedBottomRowCount()).toBe(1); + expect(api.getPinnedBottomRow(0)?.data?.value).toBe(expectedValues[i]); + } + }); + + test('pinnedBottom grand total is replaced after aggregation change', async () => { + // Aggregation change resets the store entirely — exercises the pinned cleanup path. + const api: GridApi = gridManager.createGrid(null, { + columnDefs: [{ field: 'id' }, { field: 'value', aggFunc: 'sum' }], + rowModelType: 'serverSide', + getRowId: (params: GetRowIdParams) => params.data.id, + grandTotalRow: 'pinnedBottom', + serverSideDatasource: createFlatDatasource(), + }); + + await waitForEvent('firstDataRendered', api); + await waitForNoLoadingRows(api); + expect(api.getPinnedBottomRowCount()).toBe(1); + + api.setColumnAggFunc('value', 'avg'); + await waitForNoLoadingRows(api); + await asyncSetTimeout(20); + + expect(api.getPinnedBottomRowCount()).toBe(1); + }); + + test('pinnedBottom grand total is replaced after refreshServerSide({ purge: true })', async () => { + let total = 60; + const api: GridApi = gridManager.createGrid(null, { + columnDefs: [{ field: 'id' }, { field: 'value' }], + rowModelType: 'serverSide', + getRowId: (params: GetRowIdParams) => params.data.id, + grandTotalRow: 'pinnedBottom', + serverSideDatasource: { + getRows(params: IServerSideGetRowsParams) { + const rowData: any[] = [...flatRows]; + if (params.needsGrandTotal) { + rowData.push({ id: GRAND_TOTAL_ID, value: total }); + } + setTimeout(() => params.success({ rowData, rowCount: flatRows.length }), 0); + }, + }, + }); + + await waitForEvent('firstDataRendered', api); + await waitForNoLoadingRows(api); + expect(api.getPinnedBottomRowCount()).toBe(1); + expect(api.getPinnedBottomRow(0)?.data?.value).toBe(60); + + total = 123; + api.refreshServerSide({ purge: true }); + await waitForNoLoadingRows(api); + await asyncSetTimeout(20); + + expect(api.getPinnedBottomRowCount()).toBe(1); + expect(api.getPinnedBottomRow(0)?.data?.value).toBe(123); + }); + + test('pinnedBottom grand total with grouped grid survives filter change', async () => { + interface GroupedRow { + id: string; + category: string; + value: number; + } + + const serverRows: GroupedRow[] = [ + { id: 'a1', category: 'A', value: 10 }, + { id: 'a2', category: 'A', value: 20 }, + { id: 'b1', category: 'B', value: 30 }, + ]; + + const api = gridManager.createGrid(null, { + columnDefs: [ + { field: 'category', rowGroup: true, hide: true }, + { field: 'value', aggFunc: 'sum', filter: 'agNumberColumnFilter' }, + ], + autoGroupColumnDef: { headerName: 'Category' }, + rowModelType: 'serverSide', + getRowId: (params: GetRowIdParams) => params.data.id, + grandTotalRow: 'pinnedBottom', + serverSideDatasource: { + getRows(params: IServerSideGetRowsParams) { + const { request } = params; + const filter = request.filterModel as { value?: { filter?: number } } | null; + const threshold = filter?.value?.filter; + const filtered = threshold != null ? serverRows.filter((r) => r.value > threshold) : serverRows; + let rowData: any[]; + + if (request.groupKeys.length === 0) { + const groups = new Map(); + for (const row of filtered) { + groups.set(row.category, (groups.get(row.category) ?? 0) + row.value); + } + rowData = [...groups.entries()].map(([category, value]) => ({ + id: `category:${category}`, + category, + value, + group: true, + leafGroup: true, + key: category, + })); + if (params.needsGrandTotal) { + const total = filtered.reduce((s, r) => s + r.value, 0); + rowData.push({ id: GRAND_TOTAL_ID, value: total }); + } + } else { + const groupKey = request.groupKeys[0]; + rowData = filtered.filter((r) => r.category === groupKey).map((r) => ({ ...r })); + } + + const dataRowCount = + request.groupKeys.length === 0 + ? rowData.filter((r: any) => r.id !== GRAND_TOTAL_ID).length + : rowData.length; + setTimeout(() => params.success({ rowData, rowCount: dataRowCount }), 0); + }, + }, + }); + + await waitForEvent('firstDataRendered', api); + await waitForNoLoadingRows(api); + expect(api.getPinnedBottomRowCount()).toBe(1); + expect(api.getPinnedBottomRow(0)?.data?.value).toBe(60); + + // Filter out category A entirely + api.setFilterModel({ value: { type: 'greaterThan', filter: 25 } }); + await waitForNoLoadingRows(api); + await asyncSetTimeout(20); + + expect(api.getPinnedBottomRowCount()).toBe(1); + expect(api.getPinnedBottomRow(0)?.data?.value).toBe(30); + }); + + test('switching from pinnedBottom to bottom after filter change leaves no orphan pinned row', async () => { + const api: GridApi = gridManager.createGrid(null, { + columnDefs: [{ field: 'id' }, { field: 'value', filter: 'agNumberColumnFilter' }], + rowModelType: 'serverSide', + getRowId: (params: GetRowIdParams) => params.data.id, + grandTotalRow: 'pinnedBottom', + serverSideDatasource: { + getRows(params: IServerSideGetRowsParams) { + const filter = params.request.filterModel as { value?: { filter?: number } } | null; + const threshold = filter?.value?.filter; + const filtered = threshold != null ? flatRows.filter((r) => r.value > threshold) : flatRows; + const rowData: any[] = [...filtered]; + if (params.needsGrandTotal) { + const total = filtered.reduce((s, r) => s + r.value, 0); + rowData.push({ id: GRAND_TOTAL_ID, value: total }); + } + setTimeout(() => params.success({ rowData, rowCount: filtered.length }), 0); + }, + }, + }); + + await waitForEvent('firstDataRendered', api); + await waitForNoLoadingRows(api); + + api.setFilterModel({ value: { type: 'greaterThan', filter: 15 } }); + await waitForNoLoadingRows(api); + await asyncSetTimeout(20); + expect(api.getPinnedBottomRowCount()).toBe(1); + + api.setGridOption('grandTotalRow', 'bottom'); + await asyncSetTimeout(20); + + expect(api.getPinnedBottomRowCount()).toBe(0); + expect(api.getPinnedTopRowCount()).toBe(0); + + await new GridRows(api, 'after switch to inline bottom').check(unindentText` + ROOT id: + ├── LEAF id:2 id:"2" value:20 + ├── LEAF id:3 id:"3" value:30 + └─ footer id:rowGroupFooter_ROOT_NODE_ID id:"rowGroupFooter_ROOT_NODE_ID" value:50 + `); + }); + test('async grand total via transaction: filter change hides then restores grand total', async () => { const computeTotal = (threshold?: number) => flatRows.filter((r) => threshold === undefined || r.value > threshold).reduce((s, r) => s + r.value, 0); diff --git a/testing/behavioural/src/rows/manual-pinned-rows.test.ts b/testing/behavioural/src/rows/manual-pinned-rows.test.ts index a5426c81a10..bba72c39243 100644 --- a/testing/behavioural/src/rows/manual-pinned-rows.test.ts +++ b/testing/behavioural/src/rows/manual-pinned-rows.test.ts @@ -1,6 +1,6 @@ import { ClientSideRowModelModule, PaginationModule, PinnedRowModule } from 'ag-grid-community'; import type { GridApi, RowNode, RowPinnedType } from 'ag-grid-community'; -import { RowGroupingModule } from 'ag-grid-enterprise'; +import { PivotModule, RowGroupingModule } from 'ag-grid-enterprise'; import { GridColumns, GridRows, TestGridsManager, asyncSetTimeout } from '../test-utils'; @@ -24,7 +24,7 @@ function getPinnedRows(api: GridApi, floating: NonNullable): RowN describe('Manual pinned rows', () => { const gridsManager = new TestGridsManager({ - modules: [PinnedRowModule, ClientSideRowModelModule, RowGroupingModule, PaginationModule], + modules: [PinnedRowModule, ClientSideRowModelModule, RowGroupingModule, PaginationModule, PivotModule], }); const columnDefs = [{ field: 'sport' }]; @@ -497,6 +497,133 @@ describe('Manual pinned rows', () => { // Note: isRowPinned is only called on firstDataRendered, so we need to test via setGridOption }); + test('isRowPinnable callback unpins a row when it stops being pinnable', async () => { + // Track which sports are pinnable. We start with rugby pinnable, then make it + // non-pinnable. The model listens to `rowNodeDataChanged` and re-evaluates pinnability; + // when a previously-pinned row becomes non-pinnable, it must be unpinned. + let pinnable = new Set(['rugby']); + + const api = await gridsManager.createGridAndWait('myGrid', { + columnDefs, + rowData, + enableRowPinning: true, + isRowPinned: (node) => (node.data?.sport === 'rugby' ? 'top' : null), + isRowPinnable: (node) => pinnable.has(node.data?.sport ?? ''), + getRowId(params) { + return `${params.level}-${params.data?.sport}`; + }, + }); + + assertPinnedRows(api, 'top', ['t-top-0-rugby']); + const pinnedRugby = getPinnedRows(api, 'top')[0]; + const sourceRugby = pinnedRugby.pinnedSibling!; + + // Make rugby no longer pinnable, then trigger rowNodeDataChanged via update. + pinnable = new Set(); + api.applyTransaction({ update: [{ sport: 'rugby' }] }); + await asyncSetTimeout(10); + + // The previously-pinned row should be unpinned (rowNodeDataChanged listener handles it). + assertPinnedRows(api, 'top', []); + expect(sourceRugby.destroyed).toBe(false); // source row stays alive + expect(sourceRugby.pinnedSibling).toBeUndefined(); + expect(pinnedRugby.destroyed).toBe(true); + }); + + test('sort change re-sorts pinned containers', async () => { + const api = await gridsManager.createGridAndWait('myGrid', { + columnDefs: [{ field: 'sport', sortable: true }], + rowData, + enableRowPinning: true, + isRowPinned: (node) => { + const s = node.data?.sport; + return s === 'tennis' || s === 'football' || s === 'cricket' ? 'top' : null; + }, + getRowId(params) { + return `${params.level}-${params.data?.sport}`; + }, + }); + + // Initial order matches source row order: football, tennis, cricket + assertPinnedRows(api, 'top', ['t-top-0-football', 't-top-0-tennis', 't-top-0-cricket']); + + // Sort ascending by sport — pinned area must re-sort + api.applyColumnState({ state: [{ colId: 'sport', sort: 'asc' }] }); + await asyncSetTimeout(10); + assertPinnedRows(api, 'top', ['t-top-0-cricket', 't-top-0-football', 't-top-0-tennis']); + + // Sort descending + api.applyColumnState({ state: [{ colId: 'sport', sort: 'desc' }] }); + await asyncSetTimeout(10); + assertPinnedRows(api, 'top', ['t-top-0-tennis', 't-top-0-football', 't-top-0-cricket']); + + // Clear sort — falls back to source row order + api.applyColumnState({ state: [{ colId: 'sport', sort: null }] }); + await asyncSetTimeout(10); + assertPinnedRows(api, 'top', ['t-top-0-football', 't-top-0-tennis', 't-top-0-cricket']); + }); + + test('pivotMode toggle hides pinned leaf clones and shows them again on toggle off', async () => { + // In pivot mode, _shouldHidePinnedRows returns !node.group, hiding leaf clones. + // Toggling pivotMode off should bring them back. + const api = await gridsManager.createGridAndWait('myGrid', { + columnDefs: [ + { field: 'country', rowGroup: true, hide: true }, + { field: 'year', pivot: true, hide: true }, + { field: 'sport' }, + { field: 'value', aggFunc: 'sum' }, + ], + rowData: [ + { country: 'A', year: 2024, sport: 'rugby', value: 1 }, + { country: 'A', year: 2024, sport: 'tennis', value: 2 }, + { country: 'B', year: 2024, sport: 'rugby', value: 3 }, + ], + enableRowPinning: true, + // Pin both a group row and a leaf row. + isRowPinned: (node) => { + if (node.group && node.key === 'A') { + return 'top'; + } + if (!node.group && node.data?.sport === 'rugby') { + return 'top'; + } + return null; + }, + getRowId(params) { + return params.data?.sport ? `leaf-${params.data.country}-${params.data.sport}` : ''; + }, + groupDefaultExpanded: -1, + }); + + // Initially (pivotMode off): both the group AND the leaf clones are visible. + const initialPinned = getPinnedRows(api, 'top'); + const groupClone = initialPinned.find((n) => n.group); + const leafClones = initialPinned.filter((n) => !n.group); + expect(groupClone).toBeDefined(); + expect(leafClones.length).toBeGreaterThan(0); + const initialCount = initialPinned.length; + + // Turn pivot mode on — leaf clones should be hidden, group clones remain. + api.setGridOption('pivotMode', true); + await asyncSetTimeout(10); + + const afterPivot = getPinnedRows(api, 'top'); + expect(afterPivot.every((n) => n.group)).toBe(true); // only groups visible + expect(afterPivot.length).toBeLessThan(initialCount); + + // Source nodes for hidden leaves should NOT be destroyed — they're still pinned, just hidden. + for (const leaf of leafClones) { + expect(leaf.destroyed).toBe(false); + } + + // Toggle pivot mode off — leaf clones should come back. + api.setGridOption('pivotMode', false); + await asyncSetTimeout(10); + + const afterToggleOff = getPinnedRows(api, 'top'); + expect(afterToggleOff.length).toBe(initialCount); + }); + test('pinned rows survive data updates to other rows', async () => { const api = await gridsManager.createGridAndWait('myGrid', { columnDefs, diff --git a/utilities/all/project.json b/utilities/all/project.json index abdad5029ac..cd2137a1344 100644 --- a/utilities/all/project.json +++ b/utilities/all/project.json @@ -230,6 +230,13 @@ "unlink": { "command": "./scripts/removeLocalDeps.sh" }, + "slack:user-config": { + "cache": false, + "command": "node --env-file-if-exists=.env.local ./external/ag-shared/scripts/slack/get-slack-user-config-command.mjs", + "options": { + "cwd": "{workspaceRoot}" + } + }, "snyk:test": { "command": "npx snyk test --yarn-workspaces --dev --strict-out-of-sync=false --severity-threshold=high --exclude=.nx,project.json,dist,.claude --remote-repo-url=https://github.com/ag-grid/ag-grid", "options": {