diff --git a/.github/workflows/jira-agent-pipeline.yml b/.github/workflows/jira-agent-pipeline.yml index 4f1ddeab003..36455817354 100644 --- a/.github/workflows/jira-agent-pipeline.yml +++ b/.github/workflows/jira-agent-pipeline.yml @@ -227,11 +227,17 @@ jobs: # resume route → a forced workflow_dispatch stage. `!cancelled()` lets the # gate evaluate even when preflight is skipped (the forced-dispatch path). agent-run: - needs: preflight + # Both gates feed this one job: `preflight` for the resume routes + + # ci-failure-iterate, and `ci-success-handler` for the codex-review + # hold (green PR, open P0/P1 findings → pr-iterate). On any given + # event only one of the two needed jobs runs and the other is skipped + # (empty outputs); `!cancelled()` lets the `if` evaluate regardless. + needs: [preflight, ci-success-handler] if: | !cancelled() && (needs.preflight.outputs.stage != '' || needs.preflight.outputs.ci_failure_eligible == 'true' || + needs.ci-success-handler.outputs.dispatch_pr_iterate == 'true' || (github.event_name == 'workflow_dispatch' && inputs.stage != 'resume')) runs-on: ubuntu-latest timeout-minutes: 60 @@ -267,10 +273,11 @@ jobs: - uses: ./.ag-dev-prompts/node_modules/@ag-grid/dev-prompts/.github/actions/jira-pipeline with: - # ci-failure-iterate wins when the failure preflight is eligible; - # otherwise the resume route, or a forced workflow_dispatch stage. - stage: ${{ needs.preflight.outputs.ci_failure_eligible == 'true' && 'ci-failure-iterate' || needs.preflight.outputs.stage || inputs.stage }} - issue_key: ${{ needs.preflight.outputs.issue_key || env.ISSUE_KEY }} + # Stage precedence: ci-failure-iterate (failure preflight eligible) + # → pr-iterate (green CI held on open P0/P1 Codex findings) + # → the resume route → a forced workflow_dispatch stage. + stage: ${{ needs.preflight.outputs.ci_failure_eligible == 'true' && 'ci-failure-iterate' || needs.ci-success-handler.outputs.dispatch_pr_iterate == 'true' && 'pr-iterate' || needs.preflight.outputs.stage || inputs.stage }} + issue_key: ${{ needs.preflight.outputs.issue_key || needs.ci-success-handler.outputs.issue_key || env.ISSUE_KEY }} ci_failure_run_url: ${{ needs.preflight.outputs.ci_failure_run_url }} recycle_from: ${{ needs.preflight.outputs.prior_pr_outcome }} jira_agent_app_id: ${{ vars.JIRA_AGENT_APP_ID }} @@ -305,6 +312,11 @@ jobs: handled: ${{ steps.handler.outputs.handled }} issue_key: ${{ steps.handler.outputs.issue_key }} pr_number: ${{ steps.handler.outputs.pr_number }} + # 'true' when a green PR still has open P0/P1 Codex findings: the + # handler HELD the Needs-Review promotion and asks for a pr-iterate + # to address them. Routed into agent-run below (the success-side + # analog of ci-failure-iterate) — no self-dispatch needed. + dispatch_pr_iterate: ${{ steps.handler.outputs.dispatch_pr_iterate }} steps: - uses: actions/checkout@v4 with: diff --git a/community-modules/locale/src/ar-EG.ts b/community-modules/locale/src/ar-EG.ts index 8831c7f74a9..de5714f5232 100644 --- a/community-modules/locale/src/ar-EG.ts +++ b/community-modules/locale/src/ar-EG.ts @@ -838,7 +838,8 @@ export const AG_GRID_LOCALE_EG = { calculatedColumnExpressionAmbiguousReference: 'مرجع عمود غامض "${variable}". استخدم قائمة الأعمدة أو مسار مجموعة أكثر تحديدًا.', calculatedColumnExpressionUnknownReference: 'مرجع عمود غير معروف "${variable}".', - calculatedColumnExpressionEmpty: 'أدخل تعبيرًا.', + calculatedColumnExpressionEmpty: 'أدخل تعبيرًا', + calculatedColumnTitleEmpty: 'أدخل عنوانًا', calculatedColumnApply: 'تطبيق', calculatedColumnCancel: 'إلغاء', diff --git a/community-modules/locale/src/bg-BG.ts b/community-modules/locale/src/bg-BG.ts index df36114a5a6..408b9617794 100644 --- a/community-modules/locale/src/bg-BG.ts +++ b/community-modules/locale/src/bg-BG.ts @@ -844,7 +844,8 @@ export const AG_GRID_LOCALE_BG = { calculatedColumnExpressionAmbiguousReference: 'Двусмислена препратка към колона "${variable}". Използвайте списъка с колони или по-конкретен път на групата.', calculatedColumnExpressionUnknownReference: 'Неизвестна препратка към колона "${variable}".', - calculatedColumnExpressionEmpty: 'Въведете израз.', + calculatedColumnExpressionEmpty: 'Въведете израз', + calculatedColumnTitleEmpty: 'Въведете заглавие', calculatedColumnApply: 'Приложи', calculatedColumnCancel: 'Отказ', diff --git a/community-modules/locale/src/cs-CZ.ts b/community-modules/locale/src/cs-CZ.ts index a60824188e1..398b40e9c55 100644 --- a/community-modules/locale/src/cs-CZ.ts +++ b/community-modules/locale/src/cs-CZ.ts @@ -839,7 +839,8 @@ export const AG_GRID_LOCALE_CZ = { calculatedColumnExpressionAmbiguousReference: 'Nejednoznačný odkaz na sloupec "${variable}". Použijte seznam Sloupce nebo konkrétnější cestu skupiny.', calculatedColumnExpressionUnknownReference: 'Neznámý odkaz na sloupec "${variable}".', - calculatedColumnExpressionEmpty: 'Zadejte výraz.', + calculatedColumnExpressionEmpty: 'Zadejte výraz', + calculatedColumnTitleEmpty: 'Zadejte název', calculatedColumnApply: 'Použít', calculatedColumnCancel: 'Zrušit', diff --git a/community-modules/locale/src/da-DK.ts b/community-modules/locale/src/da-DK.ts index a0096d1cba6..7221b1beacf 100644 --- a/community-modules/locale/src/da-DK.ts +++ b/community-modules/locale/src/da-DK.ts @@ -842,7 +842,8 @@ export const AG_GRID_LOCALE_DK = { calculatedColumnExpressionAmbiguousReference: 'Tvetydig kolonnereference "${variable}". Brug listen Kolonner eller en mere specifik gruppesti.', calculatedColumnExpressionUnknownReference: 'Ukendt kolonnereference "${variable}".', - calculatedColumnExpressionEmpty: 'Indtast et udtryk.', + calculatedColumnExpressionEmpty: 'Indtast et udtryk', + calculatedColumnTitleEmpty: 'Indtast en titel', calculatedColumnApply: 'Anvend', calculatedColumnCancel: 'Annuller', diff --git a/community-modules/locale/src/de-DE.ts b/community-modules/locale/src/de-DE.ts index 8712cbfc204..18fce98108d 100644 --- a/community-modules/locale/src/de-DE.ts +++ b/community-modules/locale/src/de-DE.ts @@ -846,7 +846,8 @@ export const AG_GRID_LOCALE_DE = { calculatedColumnExpressionAmbiguousReference: 'Mehrdeutiger Spaltenverweis "${variable}". Verwenden Sie die Spaltenliste oder einen spezifischeren Gruppenpfad.', calculatedColumnExpressionUnknownReference: 'Unbekannter Spaltenverweis "${variable}".', - calculatedColumnExpressionEmpty: 'Geben Sie einen Ausdruck ein.', + calculatedColumnExpressionEmpty: 'Geben Sie einen Ausdruck ein', + calculatedColumnTitleEmpty: 'Geben Sie einen Titel ein', calculatedColumnApply: 'Anwenden', calculatedColumnCancel: 'Abbrechen', diff --git a/community-modules/locale/src/el-GR.ts b/community-modules/locale/src/el-GR.ts index e3663276ff9..f590acd1821 100644 --- a/community-modules/locale/src/el-GR.ts +++ b/community-modules/locale/src/el-GR.ts @@ -845,7 +845,8 @@ export const AG_GRID_LOCALE_GR = { calculatedColumnExpressionAmbiguousReference: 'Ασαφής αναφορά στήλης "${variable}". Χρησιμοποιήστε τη λίστα Στήλες ή μια πιο συγκεκριμένη διαδρομή ομάδας.', calculatedColumnExpressionUnknownReference: 'Άγνωστη αναφορά στήλης "${variable}".', - calculatedColumnExpressionEmpty: 'Εισαγάγετε μια έκφραση.', + calculatedColumnExpressionEmpty: 'Εισαγάγετε μια έκφραση', + calculatedColumnTitleEmpty: 'Εισαγάγετε έναν τίτλο', calculatedColumnApply: 'Εφαρμογή', calculatedColumnCancel: 'Ακύρωση', diff --git a/community-modules/locale/src/en-US.ts b/community-modules/locale/src/en-US.ts index 7ab87bcc03a..57836b46b29 100644 --- a/community-modules/locale/src/en-US.ts +++ b/community-modules/locale/src/en-US.ts @@ -853,7 +853,8 @@ export const AG_GRID_LOCALE_EN = { calculatedColumnExpressionAmbiguousReference: 'Ambiguous column reference "${variable}". Use the Columns list or a more specific group path.', calculatedColumnExpressionUnknownReference: 'Unknown column reference "${variable}".', - calculatedColumnExpressionEmpty: 'Enter an expression.', + calculatedColumnExpressionEmpty: 'Enter an expression', + calculatedColumnTitleEmpty: 'Enter a title', calculatedColumnApply: 'Apply', calculatedColumnCancel: 'Cancel', diff --git a/community-modules/locale/src/es-ES.ts b/community-modules/locale/src/es-ES.ts index 4e5d357f941..468af28b30c 100644 --- a/community-modules/locale/src/es-ES.ts +++ b/community-modules/locale/src/es-ES.ts @@ -844,7 +844,8 @@ export const AG_GRID_LOCALE_ES = { calculatedColumnExpressionAmbiguousReference: 'Referencia de columna ambigua "${variable}". Use la lista de Columnas o una ruta de grupo más específica.', calculatedColumnExpressionUnknownReference: 'Referencia de columna desconocida "${variable}".', - calculatedColumnExpressionEmpty: 'Introduzca una expresión.', + calculatedColumnExpressionEmpty: 'Introduzca una expresión', + calculatedColumnTitleEmpty: 'Introduzca un título', calculatedColumnApply: 'Aplicar', calculatedColumnCancel: 'Cancelar', diff --git a/community-modules/locale/src/fa-IR.ts b/community-modules/locale/src/fa-IR.ts index 8adfc1889e6..084cdf672a2 100644 --- a/community-modules/locale/src/fa-IR.ts +++ b/community-modules/locale/src/fa-IR.ts @@ -841,7 +841,8 @@ export const AG_GRID_LOCALE_IR = { calculatedColumnExpressionAmbiguousReference: 'ارجاع مبهم ستون "${variable}". از فهرست ستون‌ها یا یک مسیر گروه دقیق‌تر استفاده کنید.', calculatedColumnExpressionUnknownReference: 'ارجاع ناشناخته ستون "${variable}".', - calculatedColumnExpressionEmpty: 'یک عبارت وارد کنید.', + calculatedColumnExpressionEmpty: 'یک عبارت وارد کنید', + calculatedColumnTitleEmpty: 'یک عنوان وارد کنید', calculatedColumnApply: 'اعمال', calculatedColumnCancel: 'لغو', diff --git a/community-modules/locale/src/fi-FI.ts b/community-modules/locale/src/fi-FI.ts index dc259b70448..61f5b7c7460 100644 --- a/community-modules/locale/src/fi-FI.ts +++ b/community-modules/locale/src/fi-FI.ts @@ -842,7 +842,8 @@ export const AG_GRID_LOCALE_FI = { calculatedColumnExpressionAmbiguousReference: 'Moniselitteinen sarakeviittaus "${variable}". Käytä Sarakkeet-luetteloa tai tarkempaa ryhmäpolkua.', calculatedColumnExpressionUnknownReference: 'Tuntematon sarakeviittaus "${variable}".', - calculatedColumnExpressionEmpty: 'Anna lauseke.', + calculatedColumnExpressionEmpty: 'Anna lauseke', + calculatedColumnTitleEmpty: 'Anna otsikko', calculatedColumnApply: 'Käytä', calculatedColumnCancel: 'Peruuta', diff --git a/community-modules/locale/src/fr-FR.ts b/community-modules/locale/src/fr-FR.ts index 372d21347c6..174f188827e 100644 --- a/community-modules/locale/src/fr-FR.ts +++ b/community-modules/locale/src/fr-FR.ts @@ -848,7 +848,8 @@ export const AG_GRID_LOCALE_FR = { calculatedColumnExpressionAmbiguousReference: 'Référence de colonne ambiguë "${variable}". Utilisez la liste Colonnes ou un chemin de groupe plus spécifique.', calculatedColumnExpressionUnknownReference: 'Référence de colonne inconnue "${variable}".', - calculatedColumnExpressionEmpty: 'Saisissez une expression.', + calculatedColumnExpressionEmpty: 'Saisissez une expression', + calculatedColumnTitleEmpty: 'Saisissez un titre', calculatedColumnApply: 'Appliquer', calculatedColumnCancel: 'Annuler', diff --git a/community-modules/locale/src/he-IL.ts b/community-modules/locale/src/he-IL.ts index aefd102b7cb..87f6568cb26 100644 --- a/community-modules/locale/src/he-IL.ts +++ b/community-modules/locale/src/he-IL.ts @@ -838,7 +838,8 @@ export const AG_GRID_LOCALE_IL = { calculatedColumnExpressionAmbiguousReference: 'הפניית עמודה דו-משמעית "${variable}". השתמש ברשימת העמודות או בנתיב קבוצה ספציפי יותר.', calculatedColumnExpressionUnknownReference: 'הפניית עמודה לא ידועה "${variable}".', - calculatedColumnExpressionEmpty: 'הזן ביטוי.', + calculatedColumnExpressionEmpty: 'הזן ביטוי', + calculatedColumnTitleEmpty: 'הזן כותרת', calculatedColumnApply: 'החל', calculatedColumnCancel: 'בטל', diff --git a/community-modules/locale/src/hr-HR.ts b/community-modules/locale/src/hr-HR.ts index 6ccd54db580..706078d11d8 100644 --- a/community-modules/locale/src/hr-HR.ts +++ b/community-modules/locale/src/hr-HR.ts @@ -841,7 +841,8 @@ export const AG_GRID_LOCALE_HR = { calculatedColumnExpressionAmbiguousReference: 'Dvosmislena referenca stupca "${variable}". Upotrijebite popis Stupci ili određeniju putanju grupe.', calculatedColumnExpressionUnknownReference: 'Nepoznata referenca stupca "${variable}".', - calculatedColumnExpressionEmpty: 'Unesite izraz.', + calculatedColumnExpressionEmpty: 'Unesite izraz', + calculatedColumnTitleEmpty: 'Unesite naslov', calculatedColumnApply: 'Primijeni', calculatedColumnCancel: 'Odustani', diff --git a/community-modules/locale/src/hu-HU.ts b/community-modules/locale/src/hu-HU.ts index 6f6d8826bfc..ceb8cc3717a 100644 --- a/community-modules/locale/src/hu-HU.ts +++ b/community-modules/locale/src/hu-HU.ts @@ -846,7 +846,8 @@ export const AG_GRID_LOCALE_HU = { calculatedColumnExpressionAmbiguousReference: 'Nem egyértelmű oszlophivatkozás: "${variable}". Használja az Oszlopok listát vagy egy konkrétabb csoportútvonalat.', calculatedColumnExpressionUnknownReference: 'Ismeretlen oszlophivatkozás: "${variable}".', - calculatedColumnExpressionEmpty: 'Adjon meg egy kifejezést.', + calculatedColumnExpressionEmpty: 'Adjon meg egy kifejezést', + calculatedColumnTitleEmpty: 'Adjon meg egy címet', calculatedColumnApply: 'Alkalmaz', calculatedColumnCancel: 'Mégse', diff --git a/community-modules/locale/src/it-IT.ts b/community-modules/locale/src/it-IT.ts index 73d3216674f..1098d57bc0e 100644 --- a/community-modules/locale/src/it-IT.ts +++ b/community-modules/locale/src/it-IT.ts @@ -845,7 +845,8 @@ export const AG_GRID_LOCALE_IT = { calculatedColumnExpressionAmbiguousReference: 'Riferimento di colonna ambiguo "${variable}". Usa l\'elenco Colonne o un percorso di gruppo più specifico.', calculatedColumnExpressionUnknownReference: 'Riferimento di colonna sconosciuto "${variable}".', - calculatedColumnExpressionEmpty: "Inserisci un'espressione.", + calculatedColumnExpressionEmpty: "Inserisci un'espressione", + calculatedColumnTitleEmpty: 'Inserisci un titolo', calculatedColumnApply: 'Applica', calculatedColumnCancel: 'Annulla', diff --git a/community-modules/locale/src/ja-JP.ts b/community-modules/locale/src/ja-JP.ts index 9084d943c53..0edc9a4bdba 100644 --- a/community-modules/locale/src/ja-JP.ts +++ b/community-modules/locale/src/ja-JP.ts @@ -839,7 +839,8 @@ export const AG_GRID_LOCALE_JP = { calculatedColumnExpressionAmbiguousReference: '列参照があいまいです "${variable}"。列リストを使用するか、より具体的なグループパスを指定してください。', calculatedColumnExpressionUnknownReference: '不明な列参照です "${variable}"。', - calculatedColumnExpressionEmpty: '式を入力してください。', + calculatedColumnExpressionEmpty: '式を入力してください', + calculatedColumnTitleEmpty: 'タイトルを入力してください', calculatedColumnApply: '適用', calculatedColumnCancel: 'キャンセル', diff --git a/community-modules/locale/src/ko-KR.ts b/community-modules/locale/src/ko-KR.ts index 77101469a3c..0729285f50c 100644 --- a/community-modules/locale/src/ko-KR.ts +++ b/community-modules/locale/src/ko-KR.ts @@ -839,7 +839,8 @@ export const AG_GRID_LOCALE_KR = { calculatedColumnExpressionAmbiguousReference: '모호한 열 참조 "${variable}". 열 목록을 사용하거나 더 구체적인 그룹 경로를 사용하세요.', calculatedColumnExpressionUnknownReference: '알 수 없는 열 참조 "${variable}".', - calculatedColumnExpressionEmpty: '식을 입력하세요.', + calculatedColumnExpressionEmpty: '식을 입력하세요', + calculatedColumnTitleEmpty: '제목을 입력하세요', calculatedColumnApply: '적용', calculatedColumnCancel: '취소', diff --git a/community-modules/locale/src/nb-NO.ts b/community-modules/locale/src/nb-NO.ts index d0da9d35455..6af1581120c 100644 --- a/community-modules/locale/src/nb-NO.ts +++ b/community-modules/locale/src/nb-NO.ts @@ -840,7 +840,8 @@ export const AG_GRID_LOCALE_NO = { calculatedColumnExpressionAmbiguousReference: 'Tvetydig kolonnereferanse "${variable}". Bruk Kolonner-listen eller en mer spesifikk gruppebane.', calculatedColumnExpressionUnknownReference: 'Ukjent kolonnereferanse "${variable}".', - calculatedColumnExpressionEmpty: 'Skriv inn et uttrykk.', + calculatedColumnExpressionEmpty: 'Skriv inn et uttrykk', + calculatedColumnTitleEmpty: 'Skriv inn en tittel', calculatedColumnApply: 'Bruk', calculatedColumnCancel: 'Avbryt', diff --git a/community-modules/locale/src/nl-NL.ts b/community-modules/locale/src/nl-NL.ts index a41297a03a9..46c84cc546f 100644 --- a/community-modules/locale/src/nl-NL.ts +++ b/community-modules/locale/src/nl-NL.ts @@ -841,7 +841,8 @@ export const AG_GRID_LOCALE_NL = { calculatedColumnExpressionAmbiguousReference: 'Dubbelzinnige kolomverwijzing "${variable}". Gebruik de lijst Kolommen of een specifieker groepspad.', calculatedColumnExpressionUnknownReference: 'Onbekende kolomverwijzing "${variable}".', - calculatedColumnExpressionEmpty: 'Voer een expressie in.', + calculatedColumnExpressionEmpty: 'Voer een expressie in', + calculatedColumnTitleEmpty: 'Voer een titel in', calculatedColumnApply: 'Toepassen', calculatedColumnCancel: 'Annuleren', diff --git a/community-modules/locale/src/pl-PL.ts b/community-modules/locale/src/pl-PL.ts index a081652184c..ebc3d21f857 100644 --- a/community-modules/locale/src/pl-PL.ts +++ b/community-modules/locale/src/pl-PL.ts @@ -844,7 +844,8 @@ export const AG_GRID_LOCALE_PL = { calculatedColumnExpressionAmbiguousReference: 'Niejednoznaczne odwołanie do kolumny "${variable}". Użyj listy Kolumny lub bardziej szczegółowej ścieżki grupy.', calculatedColumnExpressionUnknownReference: 'Nieznane odwołanie do kolumny "${variable}".', - calculatedColumnExpressionEmpty: 'Wprowadź wyrażenie.', + calculatedColumnExpressionEmpty: 'Wprowadź wyrażenie', + calculatedColumnTitleEmpty: 'Wprowadź tytuł', calculatedColumnApply: 'Zastosuj', calculatedColumnCancel: 'Anuluj', diff --git a/community-modules/locale/src/pt-BR.ts b/community-modules/locale/src/pt-BR.ts index 83a21228810..715001df1f5 100644 --- a/community-modules/locale/src/pt-BR.ts +++ b/community-modules/locale/src/pt-BR.ts @@ -844,7 +844,8 @@ export const AG_GRID_LOCALE_BR = { calculatedColumnExpressionAmbiguousReference: 'Referência de coluna ambígua "${variable}". Use a lista Colunas ou um caminho de grupo mais específico.', calculatedColumnExpressionUnknownReference: 'Referência de coluna desconhecida "${variable}".', - calculatedColumnExpressionEmpty: 'Insira uma expressão.', + calculatedColumnExpressionEmpty: 'Insira uma expressão', + calculatedColumnTitleEmpty: 'Insira um título', calculatedColumnApply: 'Aplicar', calculatedColumnCancel: 'Cancelar', diff --git a/community-modules/locale/src/pt-PT.ts b/community-modules/locale/src/pt-PT.ts index f8fa691dc00..6a293b2675a 100644 --- a/community-modules/locale/src/pt-PT.ts +++ b/community-modules/locale/src/pt-PT.ts @@ -843,7 +843,8 @@ export const AG_GRID_LOCALE_PT = { calculatedColumnExpressionAmbiguousReference: 'Referência de coluna ambígua "${variable}". Utilize a lista Colunas ou um caminho de grupo mais específico.', calculatedColumnExpressionUnknownReference: 'Referência de coluna desconhecida "${variable}".', - calculatedColumnExpressionEmpty: 'Introduza uma expressão.', + calculatedColumnExpressionEmpty: 'Introduza uma expressão', + calculatedColumnTitleEmpty: 'Introduza um título', calculatedColumnApply: 'Aplicar', calculatedColumnCancel: 'Cancelar', diff --git a/community-modules/locale/src/ro-RO.ts b/community-modules/locale/src/ro-RO.ts index 3b90c17d885..e55e07d80c8 100644 --- a/community-modules/locale/src/ro-RO.ts +++ b/community-modules/locale/src/ro-RO.ts @@ -843,7 +843,8 @@ export const AG_GRID_LOCALE_RO = { calculatedColumnExpressionAmbiguousReference: 'Referință de coloană ambiguă "${variable}". Utilizați lista Coloane sau o cale de grup mai specifică.', calculatedColumnExpressionUnknownReference: 'Referință de coloană necunoscută "${variable}".', - calculatedColumnExpressionEmpty: 'Introduceți o expresie.', + calculatedColumnExpressionEmpty: 'Introduceți o expresie', + calculatedColumnTitleEmpty: 'Introduceți un titlu', calculatedColumnApply: 'Aplică', calculatedColumnCancel: 'Anulează', diff --git a/community-modules/locale/src/sk-SK.ts b/community-modules/locale/src/sk-SK.ts index 95b300d966f..671ec95aad4 100644 --- a/community-modules/locale/src/sk-SK.ts +++ b/community-modules/locale/src/sk-SK.ts @@ -840,7 +840,8 @@ export const AG_GRID_LOCALE_SK = { calculatedColumnExpressionAmbiguousReference: 'Nejednoznačný odkaz na stĺpec "${variable}". Použite zoznam Stĺpce alebo konkrétnejšiu cestu skupiny.', calculatedColumnExpressionUnknownReference: 'Neznámy odkaz na stĺpec "${variable}".', - calculatedColumnExpressionEmpty: 'Zadajte výraz.', + calculatedColumnExpressionEmpty: 'Zadajte výraz', + calculatedColumnTitleEmpty: 'Zadajte názov', calculatedColumnApply: 'Použiť', calculatedColumnCancel: 'Zrušiť', diff --git a/community-modules/locale/src/sv-SE.ts b/community-modules/locale/src/sv-SE.ts index fd65687fc32..aa2aa900a4c 100644 --- a/community-modules/locale/src/sv-SE.ts +++ b/community-modules/locale/src/sv-SE.ts @@ -842,7 +842,8 @@ export const AG_GRID_LOCALE_SE = { calculatedColumnExpressionAmbiguousReference: 'Tvetydig kolumnreferens "${variable}". Använd listan Kolumner eller en mer specifik gruppsökväg.', calculatedColumnExpressionUnknownReference: 'Okänd kolumnreferens "${variable}".', - calculatedColumnExpressionEmpty: 'Ange ett uttryck.', + calculatedColumnExpressionEmpty: 'Ange ett uttryck', + calculatedColumnTitleEmpty: 'Ange en titel', calculatedColumnApply: 'Verkställ', calculatedColumnCancel: 'Avbryt', diff --git a/community-modules/locale/src/tr-TR.ts b/community-modules/locale/src/tr-TR.ts index 65421f63fc8..e8a19b285a4 100644 --- a/community-modules/locale/src/tr-TR.ts +++ b/community-modules/locale/src/tr-TR.ts @@ -844,7 +844,8 @@ export const AG_GRID_LOCALE_TR = { calculatedColumnExpressionAmbiguousReference: 'Belirsiz sütun başvurusu "${variable}". Sütunlar listesini veya daha belirli bir grup yolunu kullanın.', calculatedColumnExpressionUnknownReference: 'Bilinmeyen sütun başvurusu "${variable}".', - calculatedColumnExpressionEmpty: 'Bir ifade girin.', + calculatedColumnExpressionEmpty: 'Bir ifade girin', + calculatedColumnTitleEmpty: 'Bir başlık girin', calculatedColumnApply: 'Uygula', calculatedColumnCancel: 'İptal', diff --git a/community-modules/locale/src/uk-UA.ts b/community-modules/locale/src/uk-UA.ts index f3700783293..017eccb1221 100644 --- a/community-modules/locale/src/uk-UA.ts +++ b/community-modules/locale/src/uk-UA.ts @@ -841,7 +841,8 @@ export const AG_GRID_LOCALE_UA = { calculatedColumnExpressionAmbiguousReference: 'Неоднозначне посилання на стовпець "${variable}". Скористайтеся списком Стовпці або точнішим шляхом групи.', calculatedColumnExpressionUnknownReference: 'Невідоме посилання на стовпець "${variable}".', - calculatedColumnExpressionEmpty: 'Введіть вираз.', + calculatedColumnExpressionEmpty: 'Введіть вираз', + calculatedColumnTitleEmpty: 'Введіть назву', calculatedColumnApply: 'Застосувати', calculatedColumnCancel: 'Скасувати', diff --git a/community-modules/locale/src/ur-PK.ts b/community-modules/locale/src/ur-PK.ts index 742297b84e9..25c0eae5be5 100644 --- a/community-modules/locale/src/ur-PK.ts +++ b/community-modules/locale/src/ur-PK.ts @@ -840,6 +840,7 @@ export const AG_GRID_LOCALE_PK = { 'مبہم کالم حوالہ "${variable}"۔ کالمز کی فہرست یا زیادہ مخصوص گروپ راستہ استعمال کریں۔', calculatedColumnExpressionUnknownReference: 'نامعلوم کالم حوالہ "${variable}"۔', calculatedColumnExpressionEmpty: 'ایک اظہار درج کریں۔', + calculatedColumnTitleEmpty: 'ایک عنوان درج کریں۔', calculatedColumnApply: 'لاگو کریں', calculatedColumnCancel: 'منسوخ کریں', diff --git a/community-modules/locale/src/vi-VN.ts b/community-modules/locale/src/vi-VN.ts index 22899be74c1..876458e6b31 100644 --- a/community-modules/locale/src/vi-VN.ts +++ b/community-modules/locale/src/vi-VN.ts @@ -839,7 +839,8 @@ export const AG_GRID_LOCALE_VN = { calculatedColumnExpressionAmbiguousReference: 'Tham chiếu cột không rõ ràng "${variable}". Hãy dùng danh sách Cột hoặc đường dẫn nhóm cụ thể hơn.', calculatedColumnExpressionUnknownReference: 'Tham chiếu cột không xác định "${variable}".', - calculatedColumnExpressionEmpty: 'Nhập một biểu thức.', + calculatedColumnExpressionEmpty: 'Nhập một biểu thức', + calculatedColumnTitleEmpty: 'Nhập tiêu đề', calculatedColumnApply: 'Áp dụng', calculatedColumnCancel: 'Hủy', diff --git a/community-modules/locale/src/zh-CN.ts b/community-modules/locale/src/zh-CN.ts index db059133bab..d2e000f1e62 100644 --- a/community-modules/locale/src/zh-CN.ts +++ b/community-modules/locale/src/zh-CN.ts @@ -837,7 +837,8 @@ export const AG_GRID_LOCALE_CN = { calculatedColumnDefaultTitle: '未命名', calculatedColumnExpressionAmbiguousReference: '列引用不明确 "${variable}"。请使用“列”列表或更具体的分组路径。', calculatedColumnExpressionUnknownReference: '未知的列引用 "${variable}"。', - calculatedColumnExpressionEmpty: '请输入表达式。', + calculatedColumnExpressionEmpty: '请输入表达式', + calculatedColumnTitleEmpty: '请输入标题', calculatedColumnApply: '应用', calculatedColumnCancel: '取消', diff --git a/community-modules/locale/src/zh-HK.ts b/community-modules/locale/src/zh-HK.ts index 2208642fd5f..cd1095a77bd 100644 --- a/community-modules/locale/src/zh-HK.ts +++ b/community-modules/locale/src/zh-HK.ts @@ -837,7 +837,8 @@ export const AG_GRID_LOCALE_HK = { calculatedColumnDefaultTitle: '未命名', calculatedColumnExpressionAmbiguousReference: '欄參照不明確 "${variable}"。請使用「欄」清單或更具體的群組路徑。', calculatedColumnExpressionUnknownReference: '未知的欄參照 "${variable}"。', - calculatedColumnExpressionEmpty: '請輸入運算式。', + calculatedColumnExpressionEmpty: '請輸入運算式', + calculatedColumnTitleEmpty: '請輸入標題', calculatedColumnApply: '應用', calculatedColumnCancel: '取消', diff --git a/community-modules/locale/src/zh-TW.ts b/community-modules/locale/src/zh-TW.ts index 34dff35506d..38c5fae4797 100644 --- a/community-modules/locale/src/zh-TW.ts +++ b/community-modules/locale/src/zh-TW.ts @@ -838,7 +838,8 @@ export const AG_GRID_LOCALE_TW = { calculatedColumnExpressionAmbiguousReference: '欄位參照不明確 "${variable}"。請使用「欄位」清單或更具體的群組路徑。', calculatedColumnExpressionUnknownReference: '未知的欄位參照 "${variable}"。', - calculatedColumnExpressionEmpty: '請輸入運算式。', + calculatedColumnExpressionEmpty: '請輸入運算式', + calculatedColumnTitleEmpty: '請輸入標題', calculatedColumnApply: '應用', calculatedColumnCancel: '取消', diff --git a/community-modules/styles/src/internal/base/parts/_calculated-columns.scss b/community-modules/styles/src/internal/base/parts/_calculated-columns.scss index a585837a4b3..80e77c2e438 100644 --- a/community-modules/styles/src/internal/base/parts/_calculated-columns.scss +++ b/community-modules/styles/src/internal/base/parts/_calculated-columns.scss @@ -84,7 +84,6 @@ display: flex; justify-content: flex-end; gap: calc(var(--ag-grid-size) / 2); - margin-top: var(--ag-grid-size); } .ag-calculated-column-action { diff --git a/documentation/ag-grid-docs/src/content/docs/accessibility/index.mdoc b/documentation/ag-grid-docs/src/content/docs/accessibility/index.mdoc index 2b8ec5147b7..eb1fb7c8e0b 100644 --- a/documentation/ag-grid-docs/src/content/docs/accessibility/index.mdoc +++ b/documentation/ag-grid-docs/src/content/docs/accessibility/index.mdoc @@ -179,14 +179,6 @@ customisation in action. Using advanced functionality in AG Grid makes the DOM structure incompatible with the assumptions screen readers make. This results in a few limitations in accessibility when specific functionality is used: -- ### Navigation to pinned rows/columns - - Screen readers assume that the visual and DOM element order are identical. Specifically, when you pin a row/column, it causes elements to be rendered in different containers. This is why you cannot use screen readers to navigate into a pinned row/column cells, as in fact, this means they're rendered in a different element from the rest of the columns/rows which are scrollable. - -- ### Full width rows - - Full Width Rows are rendered in a different container, therefore screen readers have trouble announcing them for the same reason as `Pinned Rows/Columns`. This also includes Row Grouping, when `groupDisplayType="groupRows"` - - ### Limitations in announcing the correct column name in grouped columns Even though all aria tags have been applied to the necessary elements, some screen readers have trouble navigating the tags when the structure of the grid gets more complex (eg. grouped columns). This is the reason why there are some limitations announcing the correct column names. diff --git a/packages/ag-grid-community/src/columns/columnGroups/columnGroupService.ts b/packages/ag-grid-community/src/columns/columnGroups/columnGroupService.ts index 686fa73ca1c..df7c9347d08 100644 --- a/packages/ag-grid-community/src/columns/columnGroups/columnGroupService.ts +++ b/packages/ag-grid-community/src/columns/columnGroups/columnGroupService.ts @@ -94,7 +94,8 @@ export class ColumnGroupService extends BeanStub implements NamedBean { reuse.pinned = pinned; reuse.parent = null; reuse.children = null; - reuse.displayedChildren = null; + // reset to [] (not null) — an empty part keeps [] after recompute, matching released behaviour + reuse.displayedChildren = []; newGroup = reuse; } else { newGroup = new AgColumnGroup(runParent, groupId, instanceId, pinned); diff --git a/packages/ag-grid-community/src/columns/dataTypeService.ts b/packages/ag-grid-community/src/columns/dataTypeService.ts index 0d3946b9740..58884286ea3 100644 --- a/packages/ag-grid-community/src/columns/dataTypeService.ts +++ b/packages/ag-grid-community/src/columns/dataTypeService.ts @@ -488,7 +488,7 @@ export class DataTypeService extends BeanStub implements NamedBean { } // skip type checking for formulas - if (column.colDef.allowFormula && this.beans.formula?.isFormula(value)) { + if (column.allowFormula && this.beans.formula?.isFormula(value)) { return true; } return dataTypeMatcher(value); @@ -817,10 +817,11 @@ function createGroupSafeValueFormatter( } return (params: ValueFormatterParams) => { - const { node, colDef, column, value } = params; + const { node, column, value } = params; if (node?.group) { - const aggFunc = (colDef.pivotValueColumn ?? column).getAggFunc(); + const agColumn = column as AgColumn; + const aggFunc = (agColumn.pivotValueColumn ?? agColumn).aggFunc; if (aggFunc) { // the resulting type of these will be the same, so we call valueFormatter anyway if (aggFunc === 'first' || aggFunc === 'last') { @@ -837,11 +838,11 @@ function createGroupSafeValueFormatter( return undefined; } + // unwrap an aggregation wrapper (avg/count) to its scalar before formatting if (typeof value === 'object') { if (typeof value.toNumber === 'function') { return dataTypeDefinition.valueFormatter!({ ...params, value: value.toNumber() }); } - if ('value' in value) { return dataTypeDefinition.valueFormatter!({ ...params, value: value.value }); } diff --git a/packages/ag-grid-community/src/context/beanStub.ts b/packages/ag-grid-community/src/context/beanStub.ts index 5664411c784..833aa4c733f 100644 --- a/packages/ag-grid-community/src/context/beanStub.ts +++ b/packages/ag-grid-community/src/context/beanStub.ts @@ -8,7 +8,7 @@ import type { AgGridCommon } from '../interfaces/iCommon'; import type { BeanCollection } from './context'; /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ -export abstract class BeanStub extends AgBeanStub< +export interface BeanStub extends AgBeanStub< BeanCollection, GridOptionsWithDefaults, AgEventTypeParams, @@ -16,3 +16,8 @@ export abstract class BeanStub exte GridOptionsService, TEventType > {} + +/** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ +export const BeanStub = AgBeanStub as unknown as abstract new < + TEventType extends string = AgBeanStubEvent, +>() => BeanStub; diff --git a/packages/ag-grid-community/src/edit/editApi.test.ts b/packages/ag-grid-community/src/edit/editApi.test.ts index a75c620fc25..33b3f9f74d3 100644 --- a/packages/ag-grid-community/src/edit/editApi.test.ts +++ b/packages/ag-grid-community/src/edit/editApi.test.ts @@ -58,6 +58,19 @@ describe('Edit API', () => { beforeEach(() => { editMap = new Map(); + const resolveMockValue = (col: Column, rowNode: IRowNode): string | undefined => { + if (rowNode.rowIndex !== 0 && rowNode.rowIndex !== 1) { + return undefined; + } + const colId = col.getColId(); + if (colId === 'col1') { + return 'old1'; + } + if (colId === 'col2') { + return 'old2'; + } + return undefined; + }; beans = { editModelSvc: { getEditMap: vi.fn(() => editMap), @@ -100,18 +113,8 @@ describe('Edit API', () => { getRowCtrls: vi.fn(() => [rowCtrl1, rowCtrl2]), } as unknown as RowRenderer, valueSvc: { - getValue: vi.fn((col: Column, rowNode: IRowNode, _ignoreAggData: boolean, _source: string) => { - if (col.getColId() === 'col1' && rowNode.rowIndex === 0) { - return 'old1'; - } else if (col.getColId() === 'col2' && rowNode.rowIndex === 0) { - return 'old2'; - } else if (col.getColId() === 'col1' && rowNode.rowIndex === 1) { - return 'old1'; - } else if (col.getColId() === 'col2' && rowNode.rowIndex === 1) { - return 'old2'; - } - return undefined; - }), + getValue: vi.fn(resolveMockValue), + getValueFromData: vi.fn(resolveMockValue), } as unknown as ValueService, registry: { createDynamicBean: vi.fn(), diff --git a/packages/ag-grid-community/src/edit/editModelService.ts b/packages/ag-grid-community/src/edit/editModelService.ts index c204beee20d..3da5ad120f6 100644 --- a/packages/ag-grid-community/src/edit/editModelService.ts +++ b/packages/ag-grid-community/src/edit/editModelService.ts @@ -303,7 +303,7 @@ export class EditModelService extends BeanStub implements NamedBean { map.set(column, { editorValue: undefined, pendingValue: UNEDITED, - sourceValue: this.beans.valueSvc.getValue(column as AgColumn, rowNode, 'data'), + sourceValue: this.beans.valueSvc.getValueFromData(column as AgColumn, rowNode), state: 'editing', editorState: { isCancelAfterEnd: undefined, diff --git a/packages/ag-grid-community/src/edit/editService.ts b/packages/ag-grid-community/src/edit/editService.ts index b2c19ab37af..15b25e703ec 100644 --- a/packages/ag-grid-community/src/edit/editService.ts +++ b/packages/ag-grid-community/src/edit/editService.ts @@ -1019,6 +1019,7 @@ export class EditService extends BeanStub implements NamedBean { * Returns undefined to fallback to committed data/valueGetter. */ public getPendingEditValue(rowNode: IRowNode, column: Column, from: Exclude): any { + // Caller (ValueService.getValue) has already resolved any pivot result column. if (from === 'batch' && !this.batch) { return undefined; // 'batch' mode: only return edit values when batch editing is active } @@ -1062,7 +1063,7 @@ export class EditService extends BeanStub implements NamedBean { } // fallback to getting value from ValueService - return this.valueSvc.getValue(position.column as AgColumn, position.rowNode, 'data'); + return this.valueSvc.getValueFromData(position.column as AgColumn, position.rowNode); } public addStopEditingWhenGridLosesFocus(viewports: HTMLElement[]): void { @@ -1271,7 +1272,7 @@ export class EditService extends BeanStub implements NamedBean { const existingEdit = editModelSvc?.getEdit(position); if (existingEdit?.sourceValue === undefined) { editModelSvc?.setEdit(position, { - sourceValue: valueSvc.getValue(column as AgColumn, rowNode, 'data'), + sourceValue: valueSvc.getValueFromData(column as AgColumn, rowNode), }); } editModelSvc?.setEdit(position, { pendingValue: newValue }); @@ -1415,7 +1416,8 @@ export class EditService extends BeanStub implements NamedBean { const isFormula = formula?.isFormula(editValue) ?? false; ranges.forEach((range: CellRange) => { - const hasFormulaColumnsInRange = range.columns.some((col) => col?.isAllowFormula()); + const rangeColumns = range.columns as AgColumn[]; + const hasFormulaColumnsInRange = rangeColumns.some((col) => col?.allowFormula); rangeSvc?.forEachRowInRange(range, (position) => { const rowNode = _getRowNode(beans, position); if (rowNode === undefined) { @@ -1424,15 +1426,15 @@ export class EditService extends BeanStub implements NamedBean { const editRow: EditRow = edits.get(rowNode) ?? new Map(); let valueForColumn = editValue; - for (const column of range.columns) { + for (const column of rangeColumns) { if (!column) { continue; } - const isFormulaForColumn = !!isFormula && column.isAllowFormula(); + const isFormulaForColumn = !!isFormula && column.allowFormula; if (this.isCellEditable({ rowNode, column }, 'api')) { - const sourceValue = valueSvc.getValue(column as AgColumn, rowNode, 'data', true); + const sourceValue = valueSvc.getValueFromData(column as AgColumn, rowNode, true); let pendingValue = valueSvc.parseValue( column as AgColumn, rowNode ?? null, @@ -1524,7 +1526,7 @@ export class EditService extends BeanStub implements NamedBean { if (!rowNode) { continue; } - const sourceValue = valueSvc.getValue(col as AgColumn, rowNode, 'data', true); + const sourceValue = valueSvc.getValueFromData(col as AgColumn, rowNode, true); if ( !params?.forceRefreshOfEditCellsOnly && diff --git a/packages/ag-grid-community/src/edit/utils/editors.ts b/packages/ag-grid-community/src/edit/utils/editors.ts index 97dbb9a673a..aac45956ef1 100644 --- a/packages/ag-grid-community/src/edit/utils/editors.ts +++ b/packages/ag-grid-community/src/edit/utils/editors.ts @@ -64,18 +64,14 @@ export function _setupEditors( if (!curCellCtrl) { if (cellRowNode && cellColumn) { - const oldValue = valueSvc.getValue(cellColumn as AgColumn, cellRowNode, 'data'); + const oldValue = valueSvc.getValueFromData(cellColumn as AgColumn, cellRowNode); const isNewValueCell = position?.rowNode === cellRowNode && position?.column === cellColumn; const cellStartValue = (isNewValueCell && key) || undefined; const newValue = cellStartValue ?? editSvc?.getCellDataValue(cellPosition) ?? - valueSvc.getValueForDisplay({ - column: cellColumn as AgColumn, - node: cellRowNode, - from: 'edit', - })?.value ?? + valueSvc.getDisplayValue(cellColumn as AgColumn, cellRowNode, 'edit') ?? oldValue ?? UNEDITED; @@ -277,14 +273,11 @@ function _createEditorParams( : undefined : cellDataValue; - const value = - initialNewValue === UNEDITED - ? valueSvc.getValueForDisplay({ column: agColumn, node: rowNode, from: 'edit' })?.value - : initialNewValue; + const value = initialNewValue === UNEDITED ? valueSvc.getDisplayValue(agColumn, rowNode, 'edit') : initialNewValue; // if formula, normalise the value to shorthand for users. let paramsValue = enableGroupEditing ? initialNewValue : value; - if (column.isAllowFormula() && beans.formula?.isFormula(paramsValue)) { + if (agColumn.allowFormula && beans.formula?.isFormula(paramsValue)) { // normalise to shorthand for editing paramsValue = beans.formula?.normaliseFormula(paramsValue, true) ?? paramsValue; } @@ -412,7 +405,7 @@ export function _syncFromEditor( // sourceValue not set means sync called without corresponding startEdit - from API call const pendingValue = edit ? getNormalisedFormula(beans, edit.editorValue, false, column) : UNEDITED; const editValue: Partial = { - sourceValue: valueSvc.getValue(column as AgColumn, rowNode, 'data'), + sourceValue: valueSvc.getValueFromData(column as AgColumn, rowNode), pendingValue, }; @@ -439,7 +432,7 @@ export function _syncFromEditor( */ function getNormalisedFormula(beans: BeanCollection, value: any, forEditing: boolean, column: Column): any { const { formula } = beans; - if (column.isAllowFormula() && formula?.isFormula(value)) { + if ((column as AgColumn).allowFormula && formula?.isFormula(value)) { return formula?.normaliseFormula(value, forEditing) ?? value; } return value; diff --git a/packages/ag-grid-community/src/entities/agColumn.ts b/packages/ag-grid-community/src/entities/agColumn.ts index f18b1aad8a2..84336a8e6bf 100644 --- a/packages/ag-grid-community/src/entities/agColumn.ts +++ b/packages/ag-grid-community/src/entities/agColumn.ts @@ -1,7 +1,6 @@ import type { AgEvent, IAgEventEmitter } from 'ag-stack'; import { LocalEventService, _escapeString } from 'ag-stack'; -import { _hasCalculatedExpression } from '../columns/calculatedColumnUtils'; import { _addColumnDefaultAndTypes } from '../columns/colDefUtils'; import { updateSomeColumnState } from '../columns/columnStateUtils'; import type { ColumnState } from '../columns/columnStateUtils'; @@ -32,9 +31,15 @@ import type { AbstractColDef, ColAggFunc, ColDef, + ColDefField, + ColSpanFunc, ColSpanParams, ColumnFunctionCallbackParams, + RefData, + RowSpanFunc, RowSpanParams, + ValueFormatterFunc, + ValueGetterFunc, } from './colDef'; let instanceIdSequence = 0; @@ -44,6 +49,23 @@ export function getNextColInstanceId(): ColumnInstanceId { export const isColumn = (col: Column | ColumnGroup | ProvidedColumnGroup): col is AgColumn => col instanceof AgColumn; +/** + * Redirects a pivot result column to its underlying value column for non-group, non-pinned (leaf) rows, + * so value get/set reads the real source value. Pinned rows are excluded — their data is keyed by pivot + * column ID. Only the deliberate consumers (pivot edit, API reads, pivot aggregation) need this; the hot + * read path (`getValueFromData`) does not. + * @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. + */ +export const _resolvePivotColumnForRow = (column: AgColumn, rowNode: IRowNode): AgColumn => { + if (!rowNode.group && !rowNode.rowPinned) { + const pivotValueColumn = column.pivotValueColumn; + if (pivotValueColumn) { + return pivotValueColumn; + } + } + return column; +}; + const DEFAULT_SORTING_ORDER: SortDirection[] = ['asc', 'desc', null]; const DEFAULT_ABSOLUTE_SORTING_ORDER: (SortDef | SortDirection)[] = [ { type: 'absolute', direction: 'asc' }, @@ -67,76 +89,88 @@ export class AgColumn { public readonly isColumn = true as const; - private frameworkEventListenerService?: IFrameworkEventListenerService; - // framework (React) render key; also identifies old-vs-new cols when destroying unused ones - private readonly instanceId = getNextColInstanceId(); + public readonly instanceId: ColumnInstanceId = getNextColInstanceId(); /** Sanitised version of the column id */ public readonly colIdSanitised: string; + // ── Per-cell hot path ── read by getValue / formatValue / cellCtrl for every cell; clustered first and + // contiguous for cache locality. The colDef mirrors are (re)set on colDef change ({@link initColDefHotFields} + // / {@link initDotNotation}); internal code reads these fields DIRECTLY (never the getters / colDef) to avoid + // a megamorphic load on the user-supplied colDef. The getters exist only for the public interface. + public aggFunc: ColAggFunc = undefined; + public isCalculatedCol = false; + public field: ColDefField | undefined = undefined; + /** Cached split of a dotted `field` (`field.split('.')`); `null` when not dotted / dot-notation suppressed. + * Non-null doubles as the "field contains dots" indicator. Read per cell via `_getValueUsingDotPath`. */ + public fieldPath: string[] | null = null; + public valueGetter: string | ValueGetterFunc | undefined = undefined; + public allowFormula: boolean = false; + public showRowGroup: string | boolean | undefined = undefined; + public pivotValueColumn: AgColumn | null | undefined = undefined; + public valueFormatter: string | ValueFormatterFunc | undefined = undefined; + public refData: RefData | undefined = undefined; + public enableCellChangeFlash: boolean | undefined = undefined; + /** Read per cell when the colSpan/rowSpan feature is used (`getColSpan`/`getRowSpan`). */ + public colSpan: ColSpanFunc | undefined = undefined; + public rowSpan: RowSpanFunc | undefined = undefined; + /** Read per cell on calculated columns (`formulaService.ensureCellFormula`/`fetchRawValue`). */ + public calculatedExpression: string | undefined = undefined; + + // ── Layout / display ── read during rendering and header layout (per column, per refresh). /** Current rendered width in px. Writes must go through `setActualWidth` for min/max clamping and the `widthChanged` event. */ public actualWidth: number = 0; - + public minWidth: number = 0; + private maxWidth: number = 0; + public flex: number | null = null; + public pinned: ColumnPinnedType = null; + public left: number | null = null; + public oldLeft: number | null = null; + /** User intent: should this column be shown if display rules allow it. */ + public visible: boolean = false; + /** Whether this column is in the displayed (rendered) columns — kept in lockstep with `allColsIndex >= 0` */ + public displayed: boolean = false; + public filterActive = false; + public sortDef: SortDef = getSortDefFromInput(); + public sortIndex: number | null | undefined = undefined; // measured header height when autoHeaderHeight is enabled public autoHeaderHeight: number | null = null; + public tooltipEnabled = false; + public tooltipFieldContainsDots: boolean = false; - /** User intent: should this column be shown if display rules allow it. */ - public visible: boolean = false; + // ── Cold ── structure, transient interaction state, indices, events. + private frameworkEventListenerService: IFrameworkEventListenerService | undefined = undefined; + // Lazy — most columns never get a listener; allocated on first __addEventListener/addEventListener. + private colEventSvc: LocalEventService | null = null; /** Most recent build token that claimed this col — used to detect "already used in this refresh". */ public buildToken: number = 0; - /** 0-based index in `VisibleColsService.allCols` (displayed, visual order — RTL reversed), stamped each refresh. `-1` = not displayed. */ public allColsIndex: number = -1; - - /** Whether this column is in the displayed (rendered) columns — kept in lockstep with `allColsIndex >= 0` */ - public displayed: boolean = false; - /** `true` while in `ColumnModel.colsList` (live cols, hidden included); `false` when only in * `colsById` — a pivot **primary** parked while a pivot result shows. Set by `refreshCols`. */ public inColsList: boolean = false; - /** 1-based `aria-colindex`: position in `colsList` reordered `[left, center, right]` (hidden included). `0` = not in `colsList`. */ public ariaColIndex: number = 0; + /** 0-based index in `ColumnModel.colsList` (stamped lazily by `ensureColsListIndex` for O(1) ordered reads); + * `-1` until first stamped / when not in colsList. In pivot, parked primaries keep their pre-pivot index. */ + public colsListIndex: number = -1; - public pinned: ColumnPinnedType = null; - public left: number | null = null; - public oldLeft: number | null = null; - public aggFunc: ColAggFunc = undefined; - public sortDef: SortDef = getSortDefFromInput(); - public sortIndex: number | null | undefined = undefined; public moving = false; public resizing = false; public menuVisible = false; public highlighted: ColumnHighlightPosition | null = null; public formulaRef: string | null = null; - public isCalculatedCol = false; - /** colId this column sits immediately after in display order. Order restoration seats new cols after * this anchor — handles anchors absent from the tree (e.g. auto-group col) and stacks same-anchor adds * newest-first. `undefined` = not anchored. Column-kind agnostic (currently set by the calc-column contributor). */ public anchoredToColId: string | undefined = undefined; - /** 0-based index in `ColumnModel.colsList` (stamped lazily by `ensureColsListIndex` for O(1) ordered reads); - * `-1` until first stamped / when not in colsList. In pivot, parked primaries keep their pre-pivot index. */ - public colsListIndex: number = -1; - private lastLeftPinned: boolean = false; private firstRightPinned: boolean = false; - public minWidth: number = 0; - private maxWidth: number = 0; - - public filterActive = false; - - private readonly colEventSvc: LocalEventService = new LocalEventService(); - - public fieldContainsDots: boolean = false; - private tooltipFieldContainsDots: boolean = false; - public tooltipEnabled = false; - public rowGroupActive = false; /** Position in `rowGroupColsSvc.columns` when {@link rowGroupActive}; else stale — always pair the read with a `rowGroupActive` check. */ public rowGroupActiveIndex = -1; @@ -146,7 +180,6 @@ export class AgColumn public aggregationActive = false; /** The display group col that shows this (source) column; set by `showRowGroupCols` on refresh */ public showRowGroupCol: AgColumn | null = null; - public flex: number | null = null; public parent: AgColumnGroup | null = null; public originalParent: AgProvidedColumnGroup | null = null; @@ -209,7 +242,7 @@ export class AgColumn return false; } this.cachedSortTypes = null; // sort/initialSort/sortingOrder may have changed - this.initCalculatedCol(); + this.initColDefHotFields(); this.initMinAndMaxWidths(); this.initDotNotation(); this.initTooltip(); @@ -259,7 +292,7 @@ export class AgColumn // this is done after constructor as it uses gridOptionsService public postConstruct(): void { - this.initCalculatedCol(); + this.initColDefHotFields(); this.initState(); this.initMinAndMaxWidths(); this.resetActualWidth('gridInitializing'); @@ -268,13 +301,14 @@ export class AgColumn } private initDotNotation(): void { + const { field, tooltipField } = this.colDef; + this.field = field; const suppress = this.gos.get('suppressFieldDotNotation'); if (suppress) { - this.fieldContainsDots = false; + this.fieldPath = null; this.tooltipFieldContainsDots = false; } else { - const { field, tooltipField } = this.colDef; - this.fieldContainsDots = typeof field === 'string' && field.includes('.'); + this.fieldPath = typeof field === 'string' && field.includes('.') ? field.split('.') : null; this.tooltipFieldContainsDots = typeof tooltipField === 'string' && tooltipField.includes('.'); } } @@ -317,7 +351,7 @@ export class AgColumn } public isFieldContainsDots(): boolean { - return this.fieldContainsDots; + return this.fieldPath !== null; } public isTooltipEnabled(): boolean { @@ -332,17 +366,26 @@ export class AgColumn return this.highlighted; } + private getColEventSvc(): LocalEventService { + let svc = this.colEventSvc; + if (!svc) { + svc = new LocalEventService(); + this.colEventSvc = svc; + } + return svc; + } + public __addEventListener( eventType: T, listener: (params: ColumnEvent) => void ): void { - this.colEventSvc.addEventListener(eventType, listener); + this.getColEventSvc().addEventListener(eventType, listener); } public __removeEventListener( eventType: T, listener: (params: ColumnEvent) => void ): void { - this.colEventSvc.removeEventListener(eventType, listener); + this.colEventSvc?.removeEventListener(eventType, listener); } /** @@ -352,13 +395,14 @@ export class AgColumn eventType: T, userListener: (params: ColumnEvent) => void ): void { + const colEventSvc = this.getColEventSvc(); this.frameworkEventListenerService = this.beans.frameworkOverrides.createLocalEventListenerWrapper?.( this.frameworkEventListenerService, - this.colEventSvc + colEventSvc ); const listener = this.frameworkEventListenerService?.wrap(eventType, userListener) ?? userListener; - this.colEventSvc.addEventListener(eventType, listener); + colEventSvc.addEventListener(eventType, listener); } /** @@ -369,7 +413,7 @@ export class AgColumn userListener: (params: ColumnEvent) => void ): void { const listener = this.frameworkEventListenerService?.unwrap(eventType, userListener) ?? userListener; - this.colEventSvc.removeEventListener(eventType, listener); + this.colEventSvc?.removeEventListener(eventType, listener); } public createColumnFunctionCallbackParams(rowNode: IRowNode): ColumnFunctionCallbackParams { @@ -412,8 +456,21 @@ export class AgColumn return this.isCalculatedCol || this.isColumnFunc(rowNode, this.colDef.suppressPaste ?? null); } - private initCalculatedCol(): void { - this.isCalculatedCol = _hasCalculatedExpression(this.colDef) && this.beans.calculatedColsSvc != null; + /** Mirror the hot-path colDef fields onto the column so per-cell reads avoid a megamorphic colDef load. + * `field`/`fieldPath` are set by {@link initDotNotation} (they depend on `suppressFieldDotNotation`). */ + private initColDefHotFields(): void { + const colDef = this.colDef; + this.valueGetter = colDef.valueGetter; + this.allowFormula = colDef.allowFormula === true; + this.showRowGroup = colDef.showRowGroup; + this.pivotValueColumn = colDef.pivotValueColumn as AgColumn | null | undefined; + this.valueFormatter = colDef.valueFormatter; + this.refData = colDef.refData; + this.enableCellChangeFlash = colDef.enableCellChangeFlash; + this.colSpan = colDef.colSpan; + this.rowSpan = colDef.rowSpan; + this.calculatedExpression = colDef.calculatedExpression; + this.isCalculatedCol = this.calculatedExpression !== undefined && this.beans.calculatedColsSvc != null; } public isResizable(): boolean { @@ -648,18 +705,17 @@ export class AgColumn } public getColSpan(rowNode: IRowNode): number { - const colDef = this.colDef; - if (colDef.colSpan == null) { + const colSpanFn = this.colSpan; + if (colSpanFn == null) { return 1; } const params: ColSpanParams = this.createColumnFunctionCallbackParams(rowNode); - const colSpan = colDef.colSpan(params); + const colSpan = colSpanFn(params); return colSpan < 1 ? 1 : colSpan; // colSpan must be number equal to or greater than 1 } public getRowSpan(rowNode: IRowNode): number { - const colDef = this.colDef; - const rowSpan = colDef.rowSpan; + const rowSpan = this.rowSpan; if (rowSpan == null) { return 1; } @@ -739,11 +795,11 @@ export class AgColumn } public isAllowFormula(): boolean { - return this.colDef.allowFormula === true; + return this.allowFormula; } public dispatchColEvent(type: ColumnEventName, source: ColumnEventType, additionalEventAttributes?: any): void { - this.colEventSvc.dispatchEvent( + this.colEventSvc?.dispatchEvent( _addGridCommonParams(this.gos, { type, column: this, @@ -755,7 +811,7 @@ export class AgColumn } public dispatchStateUpdatedEvent(key: keyof ColumnState): void { - this.colEventSvc.dispatchEvent({ type: 'columnStateUpdated', key } as AgEvent<'columnStateUpdated'>); + this.colEventSvc?.dispatchEvent({ type: 'columnStateUpdated', key } as AgEvent<'columnStateUpdated'>); } } diff --git a/packages/ag-grid-community/src/entities/agColumnGroup.ts b/packages/ag-grid-community/src/entities/agColumnGroup.ts index c6280f368fd..ec7b806f4e4 100644 --- a/packages/ag-grid-community/src/entities/agColumnGroup.ts +++ b/packages/ag-grid-community/src/entities/agColumnGroup.ts @@ -28,8 +28,9 @@ export class AgColumnGroup extends BeanStub im // all children, regardless of open/closed state public children: (AgColumn | AgColumnGroup)[] | null = null; - // only the currently displaying children (depends on open/closed state) - public displayedChildren: (AgColumn | AgColumnGroup)[] | null = null; + // only the currently displaying children (depends on open/closed state). Kept as an array (never null at + // runtime) so reads — `getDisplayedChildren()`, `checkLeft`, tool-panel membership — match released behaviour. + public displayedChildren: (AgColumn | AgColumnGroup)[] | null = []; // measured header height when autoHeaderHeight is enabled public autoHeaderHeight: number | null = null; @@ -71,16 +72,18 @@ export class AgColumnGroup extends BeanStub im } public checkLeft(): void { - const displayedChildren = this.displayedChildren!; + const displayedChildren = this.displayedChildren; let minLeft: number | null = null; - for (let i = 0, len = displayedChildren.length; i < len; ++i) { - const child = displayedChildren[i]; - if (isColumnGroup(child)) { - child.checkLeft(); - } - const childLeft = child.left; - if (childLeft != null && (minLeft == null || childLeft < minLeft)) { - minLeft = childLeft; + if (displayedChildren) { + for (let i = 0, len = displayedChildren.length; i < len; ++i) { + const child = displayedChildren[i]; + if (isColumnGroup(child)) { + child.checkLeft(); + } + const childLeft = child.left; + if (childLeft != null && (minLeft == null || childLeft < minLeft)) { + minLeft = childLeft; + } } } this.setLeft(minLeft); diff --git a/packages/ag-grid-community/src/entities/agProvidedColumnGroup.ts b/packages/ag-grid-community/src/entities/agProvidedColumnGroup.ts index 88d399c1ad2..d9ab44ccafc 100644 --- a/packages/ag-grid-community/src/entities/agProvidedColumnGroup.ts +++ b/packages/ag-grid-community/src/entities/agProvidedColumnGroup.ts @@ -33,7 +33,7 @@ export class AgProvidedColumnGroup extends BeanStub private lastVisible = false; // stable key for framework (React) rendering and old-vs-new destroy diffing - private readonly instanceId = getNextColInstanceId(); + public readonly instanceId: ColumnInstanceId = getNextColInstanceId(); constructor( public colGroupDef: ColGroupDef | null, diff --git a/packages/ag-grid-community/src/entities/rowNode.ts b/packages/ag-grid-community/src/entities/rowNode.ts index 88fa6a4e612..4101351344b 100644 --- a/packages/ag-grid-community/src/entities/rowNode.ts +++ b/packages/ag-grid-community/src/entities/rowNode.ts @@ -19,6 +19,7 @@ import type { import type { DetailGridInfo } from '../interfaces/masterDetail'; import { _error, _warn } from '../validation/logging'; import type { AgColumn } from './agColumn'; +import { _resolvePivotColumnForRow } from './agColumn'; import type { ColKey, IAggFuncResult } from './colDef'; /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ @@ -585,12 +586,9 @@ export class RowNode } // Resolve pivot result columns to their underlying value column for non-group, non-pinned rows. - const pivotValueColumn = column.colDef.pivotValueColumn; - if (!this.group && !this.rowPinned && pivotValueColumn) { - column = pivotValueColumn as AgColumn; - } + column = _resolvePivotColumnForRow(column, this); - const oldValue = valueSvc.getValueForDisplay({ column, node: this, from: 'data' }).value; + const oldValue = valueSvc.getDisplayValue(column, this, 'data'); if (gos.get('readOnlyEdit')) { const { @@ -660,7 +658,7 @@ export class RowNode let value: any; if (from === 'data' || !from) { - value = beans.valueSvc.getValue(column, this, 'data', false); + value = beans.valueSvc.getValueFromData(_resolvePivotColumnForRow(column, this), this, false); if (value == null) { return value; } @@ -669,14 +667,13 @@ export class RowNode // 'value' reads committed data like 'data' but resolves agg wrappers (handled below) const dataRaw = from === 'data-raw'; const resolvedFrom = dataRaw || from === 'value' ? 'data' : from; - value = beans.valueSvc.getValue(column, this, resolvedFrom, dataRaw); + value = beans.valueSvc.getValue(_resolvePivotColumnForRow(column, this), this, resolvedFrom, dataRaw); if (dataRaw || value == null) { return value; } // For 'value', 'edit', and 'batch' modes, resolve aggregation wrapper objects to their scalar - // value on agg columns. Matches the resolution pattern in dataTypeService: first try toNumber(), - // then fall back to .value property. `typeof` check precedes `aggFunc` to cheaply skip primitives. + // on agg columns. `typeof` precedes `aggFunc` to cheaply skip primitives (value is non-null here). if (typeof value === 'object' && column.aggFunc) { if (typeof value.toNumber === 'function') { return value.toNumber(); @@ -696,9 +693,9 @@ export class RowNode } } - if (column.colDef.allowFormula) { + if (column.allowFormula) { const formula = beans.formula; - if (formula?.isFormula(value) && column.isAllowFormula()) { + if (formula?.isFormula(value)) { value = formula.resolveValue(column, this); } } diff --git a/packages/ag-grid-community/src/export/baseGridSerializingSession.ts b/packages/ag-grid-community/src/export/baseGridSerializingSession.ts index c3a49aa4d76..d129347ec4b 100644 --- a/packages/ag-grid-community/src/export/baseGridSerializingSession.ts +++ b/packages/ag-grid-community/src/export/baseGridSerializingSession.ts @@ -101,7 +101,7 @@ export abstract class BaseGridSerializingSession implements GridSerializingSe accumulatedRowIndex, column, node, - value: this.valueSvc.getValueForDisplay({ column, node, from: this.valueFrom }).value, + value: this.valueSvc.getDisplayValue(column, node, this.valueFrom), type, parseValue: (valueToParse: string) => this.valueSvc.parseValue( @@ -121,7 +121,7 @@ export abstract class BaseGridSerializingSession implements GridSerializingSe const valueService = this.valueSvc; const isGrandTotalRow = node.level === -1 && node.footer; - const isMultiAutoCol = column.colDef.showRowGroup === true && (node.group || isTreeData); + const isMultiAutoCol = column.showRowGroup === true && (node.group || isTreeData); // when using single auto group column or group row, create arrow separated string of group vals if (!isGrandTotalRow && (isFullWidthGroup || isMultiAutoCol)) { let concatenatedGroupValue: string = ''; diff --git a/packages/ag-grid-community/src/filter/columnFilterService.ts b/packages/ag-grid-community/src/filter/columnFilterService.ts index 66ab5361350..da058945c70 100644 --- a/packages/ag-grid-community/src/filter/columnFilterService.ts +++ b/packages/ag-grid-community/src/filter/columnFilterService.ts @@ -821,7 +821,7 @@ export class ColumnFilterService const { filterManager, rowModel } = this.beans; return _addGridCommonParams(this.gos, { column, - colDef: column.getColDef(), + colDef: column.colDef, getValue: this.createGetValue(column), doesRowPassOtherFilter: forFloatingFilter ? () => true diff --git a/packages/ag-grid-community/src/filter/filterDataTypeUtils.ts b/packages/ag-grid-community/src/filter/filterDataTypeUtils.ts index fb8cfaa2355..7e0d1098d08 100644 --- a/packages/ag-grid-community/src/filter/filterDataTypeUtils.ts +++ b/packages/ag-grid-community/src/filter/filterDataTypeUtils.ts @@ -222,7 +222,11 @@ export function _getFilterParamsForDataType( const usingSetFilter = filter === 'agSetColumnFilter'; if (!filterValueGetter && dataTypeDefinition.baseDataType === 'object' && !usingSetFilter) { filterValueGetter = ({ column, node }: ValueGetterParams) => - formatValue({ column, node, value: beans.valueSvc.getValue(column as AgColumn, node, 'data') }); + formatValue({ + column, + node, + value: node ? beans.valueSvc.getValueFromData(column as AgColumn, node) : undefined, + }); } const filterParamsMap = usingSetFilter ? setFilterParamsForEachDataType : filterParamsForEachDataType; const filterParamsGetter = filterParamsMap[dataTypeDefinition.baseDataType]; diff --git a/packages/ag-grid-community/src/filter/filterValueService.ts b/packages/ag-grid-community/src/filter/filterValueService.ts index df9d0c97257..d4cb9cc5368 100644 --- a/packages/ag-grid-community/src/filter/filterValueService.ts +++ b/packages/ag-grid-community/src/filter/filterValueService.ts @@ -2,6 +2,7 @@ import type { NamedBean } from '../context/bean'; import { BeanStub } from '../context/beanStub'; import type { BeanName } from '../context/context'; import type { AgColumn } from '../entities/agColumn'; +import { _resolvePivotColumnForRow } from '../entities/agColumn'; import type { ValueGetterFunc, ValueGetterParams } from '../entities/colDef'; import type { RowNode } from '../entities/rowNode'; import type { IRowNode } from '../interfaces/iRowNode'; @@ -36,15 +37,19 @@ export class FilterValueService extends BeanStub implements NamedBean { colDef, getValue: (field) => { const col = colModel.getCol(field); - return col ? valueSvc.getValue(col, rowNode, 'data') : null; + // arbitrary user-requested field: may be a pivot result column, so resolve it + return col ? valueSvc.getValueFromData(_resolvePivotColumnForRow(col, rowNode), rowNode) : null; }, }; return isFunction ? filterValueGetter(params) : expressionSvc!.evaluate(filterValueGetter, params); } - const value = valueSvc.getValue(column, rowNode, 'data'); - if (column.colDef.allowFormula) { + // Filtering never reads a pivot-result column on a leaf row: leaf rows are filtered with primary + // columns; pivot-result columns are only evaluated against group rows (aggregate path), where the + // pivot block is skipped anyway. So pivot resolution can never fire here. + const value = valueSvc.getValueFromData(column, rowNode); + if (column.allowFormula) { const formula = beans.formula; if (formula?.isFormula(value)) { return formula.resolveValue(column, rowNode as RowNode); diff --git a/packages/ag-grid-community/src/headerRendering/cells/column/agColumnHeader.ts b/packages/ag-grid-community/src/headerRendering/cells/column/agColumnHeader.ts index f54a52ded9c..04049811848 100644 --- a/packages/ag-grid-community/src/headerRendering/cells/column/agColumnHeader.ts +++ b/packages/ag-grid-community/src/headerRendering/cells/column/agColumnHeader.ts @@ -331,7 +331,8 @@ export class AgColumnHeader extends Component implements IHeaderComp { cellEditingStarted: () => { const editPositions = editModelSvc?.getEditPositions(); const shouldDisplay = - !!this.currentRef && !!editPositions?.some((position) => position.column.isAllowFormula()); + !!this.currentRef && + !!editPositions?.some((position) => (position.column as AgColumn).allowFormula); _setDisplayed(eColRef, shouldDisplay); }, cellEditingStopped: () => { diff --git a/packages/ag-grid-community/src/interfaces/iSortOption.ts b/packages/ag-grid-community/src/interfaces/iSortOption.ts index 7d397c933a6..f713465f475 100644 --- a/packages/ag-grid-community/src/interfaces/iSortOption.ts +++ b/packages/ag-grid-community/src/interfaces/iSortOption.ts @@ -1,3 +1,4 @@ +import type { SortComparatorFn } from '../entities/colDef'; import type { Column } from './iColumn'; import type { SortDirection, SortType } from './iSort'; @@ -6,4 +7,12 @@ export interface SortOption { sort: NonNullable; type: SortType; column: Column; + /** Column's own comparator — applies to every row. Filled by `_resolveSortOptions` (`undefined` until then). */ + colComparator: SortComparatorFn | undefined; + /** Fallback comparator for leaf rows of a row-group display col (the primary column's comparator). */ + leafComparator: SortComparatorFn | undefined; + /** `sort === 'desc'`, precomputed to avoid a per-comparison string compare. Filled by `_resolveSortOptions`. */ + descending: boolean; + /** `type === 'absolute'`, precomputed to avoid a per-comparison string compare. Filled by `_resolveSortOptions`. */ + absolute: boolean; } diff --git a/packages/ag-grid-community/src/main-internal.ts b/packages/ag-grid-community/src/main-internal.ts index ec8dfed88d6..df77bf4802b 100644 --- a/packages/ag-grid-community/src/main-internal.ts +++ b/packages/ag-grid-community/src/main-internal.ts @@ -85,7 +85,7 @@ export type { HorizontalResizeService } from './dragAndDrop/horizontalResizeServ export type { RowDragComp } from './dragAndDrop/rowDragComp'; export type { RowDragService } from './dragAndDrop/rowDragService'; export type { RowsDrop as _RowsDrop } from './dragAndDrop/rowDragTypes'; -export { _getDisplaySortForColumn, _normalizeSortType, AgColumn } from './entities/agColumn'; +export { _getDisplaySortForColumn, _normalizeSortType, _resolvePivotColumnForRow, AgColumn } from './entities/agColumn'; export type { ColKind } from './entities/agColumn'; export { AgColumnGroup } from './entities/agColumnGroup'; export { AgProvidedColumnGroup } from './entities/agProvidedColumnGroup'; @@ -298,6 +298,7 @@ export type { IShowRowGroupColsService } from './interfaces/iShowRowGroupColsSer export type { GroupValueResult, IShowRowGroupColsValueService } from './interfaces/iShowRowGroupColsValueService'; export type { ISideBar, ISideBarService } from './interfaces/iSideBar'; export type { SortOption } from './interfaces/iSortOption'; +export { _resolveSortOptions } from './sort/sortOptionUtils'; export type { IToolbarComp, IToolbarService } from './interfaces/iToolbar'; export type { IStickyRowFeature, IStickyRowService } from './interfaces/iStickyRows'; export type { ComponentType, UserCompDetails } from './interfaces/iUserCompDetails'; @@ -370,6 +371,7 @@ export { _consoleError, _warnOnce } from './utils/log'; export { _mergeDeep, _mergedEqual } from './utils/mergeDeep'; export { _formatNumberCommas } from './utils/number'; export { _selectAllCells } from './utils/selection'; +export { _getValueUsingDotPath } from './utils/value'; export { _errMsg, _error, _logPreInitWarn, _preInitErrMsg, _warn } from './validation/logging'; export type { ExpressionService } from './valueService/expressionService'; export type { ValueCache } from './valueService/valueCache'; diff --git a/packages/ag-grid-community/src/pagination/pageSummaryComp.ts b/packages/ag-grid-community/src/pagination/pageSummaryComp.ts index 56aa7398550..8e1d88ddc5e 100644 --- a/packages/ag-grid-community/src/pagination/pageSummaryComp.ts +++ b/packages/ag-grid-community/src/pagination/pageSummaryComp.ts @@ -1,9 +1,11 @@ -import { KeyCode, RefPlaceholder, _setAriaDisabled } from 'ag-stack'; +import type { AgElementParams, LocaleTextFunc } from 'ag-stack'; +import { KeyCode, RefPlaceholder, _setAriaDisabled, _setAriaLabel, _setAriaRole } from 'ag-stack'; import { AgInputNumberFieldSelector } from '../agWidgets/agInputNumberField'; import type { BeanCollection } from '../context/context'; import type { IRowModel } from '../interfaces/iRowModel'; import { _createIconNoSpan } from '../utils/icon'; +import type { AgComponentSelectorType } from '../widgets/component'; import { Component } from '../widgets/component'; import type { GridInputNumberField } from '../widgets/gridWidgetTypes'; import type { PaginationService } from './paginationService'; @@ -41,86 +43,21 @@ export class PageSummaryComp extends Component { } public postConstruct(): void { - const noInput = this.suppressPageInput; - const idPrefix = this.idPrefix; const localeTextFunc = this.getLocaleTextFunc(); - const pageNumberChild = { - cls: 'ag-paging-number', - attrs: { id: `${idPrefix}-start-page-number` }, - tag: noInput ? 'span' : 'ag-input-number-field', - ref: noInput ? 'lbCurrentStatic' : 'lbCurrentInput', - } as const; - this.setTemplate( - { - tag: 'span', - cls: 'ag-paging-page-summary-panel', - role: 'presentation', - children: [ - { - tag: 'div', - ref: 'btFirst', - cls: 'ag-button ag-paging-button', - role: 'button', - attrs: { 'aria-label': localeTextFunc('firstPage', 'First Page') }, - }, - { - tag: 'div', - ref: 'btPrevious', - cls: 'ag-button ag-paging-button', - role: 'button', - attrs: { 'aria-label': localeTextFunc('previousPage', 'Previous Page') }, - }, - { - tag: 'span', - cls: 'ag-paging-description', - children: [ - { - tag: 'span', - attrs: { id: `${idPrefix}-start-page` }, - children: localeTextFunc('page', 'Page'), - }, - pageNumberChild, - { - tag: 'span', - attrs: { id: `${idPrefix}-of-page` }, - children: localeTextFunc('of', 'of'), - }, - { - tag: 'span', - ref: 'lbTotal', - cls: 'ag-paging-number', - attrs: { id: `${idPrefix}-of-page-number` }, - }, - ], - }, - { - tag: 'div', - ref: 'btNext', - cls: 'ag-button ag-paging-button', - role: 'button', - attrs: { 'aria-label': localeTextFunc('nextPage', 'Next Page') }, - }, - { - tag: 'div', - ref: 'btLast', - cls: 'ag-button ag-paging-button', - role: 'button', - attrs: { 'aria-label': localeTextFunc('lastPage', 'Last Page') }, - }, - ], - }, + buildPageSummaryTemplate(this.idPrefix, this.suppressPageInput, localeTextFunc), this.suppressPageInput ? [] : [AgInputNumberFieldSelector] ); + insertNavIcons(this.gos.get('enableRtl'), this.beans, this.btFirst, this.btPrevious, this.btNext, this.btLast); + this.initNavButtons(); + if (!this.suppressPageInput) { + this.initPageInput(); + } + this.refresh(); + } - const { gos, btFirst, btPrevious, btNext, btLast, beans } = this; - const isRtl = gos.get('enableRtl'); - - btFirst.insertAdjacentElement('afterbegin', _createIconNoSpan(isRtl ? 'last' : 'first', beans)!); - btPrevious.insertAdjacentElement('afterbegin', _createIconNoSpan(isRtl ? 'next' : 'previous', beans)!); - btNext.insertAdjacentElement('afterbegin', _createIconNoSpan(isRtl ? 'previous' : 'next', beans)!); - btLast.insertAdjacentElement('afterbegin', _createIconNoSpan(isRtl ? 'first' : 'last', beans)!); - + private initNavButtons(): void { + const { btFirst, btPrevious, btNext, btLast } = this; this.activateTabIndex([btFirst, btPrevious, btNext, btLast]); for (const { el, fn } of [ { el: btFirst, fn: this.onBtFirst.bind(this) }, @@ -138,19 +75,48 @@ export class PageSummaryComp extends Component { }, }); } + } - if (!this.suppressPageInput) { - const { lbCurrentInput } = this; - lbCurrentInput.onValueChange(this.onInputPage.bind(this)); - this.addManagedListeners(lbCurrentInput.getInputElement(), { - blur: () => { - if (!lbCurrentInput.getInputElement().value.trim()) { - lbCurrentInput.setValue(String(this.pagination.getCurrentPage() + 1), true); - } - }, - }); - } - this.refresh(); + private initPageInput(): void { + const { lbCurrentInput, pagination } = this; + const eInput = lbCurrentInput.getInputElement(); + _setAriaRole(eInput, 'spinbutton'); + this.addManagedListeners(eInput, { + keydown: (e: KeyboardEvent) => { + const { key } = e; + if (key !== KeyCode.ENTER && key !== KeyCode.ESCAPE && key !== KeyCode.UP && key !== KeyCode.DOWN) { + return; + } + e.preventDefault(); + let targetPage: number | null = null; + const current = pagination.getCurrentPage(); + const maxPage = pagination.getTotalPages(); + switch (key) { + case KeyCode.ENTER: + this.commitPageInput(); + break; + case KeyCode.ESCAPE: + lbCurrentInput.setValue(String(current + 1), true); // needs to happen before blur below + eInput.blur(); + break; + case KeyCode.UP: + if (current + 2 <= maxPage) { + targetPage = current + 2; + } + break; + case KeyCode.DOWN: + if (current !== 0) { + targetPage = current; + } + break; + } + if (targetPage !== null) { + lbCurrentInput.setValue(String(targetPage), true); + this.commitPageInput(); + } + }, + blur: () => this.commitPageInput(), + }); } private onBtFirst(): void { @@ -177,20 +143,23 @@ export class PageSummaryComp extends Component { } } - private onInputPage(): void { + private commitPageInput(): void { const { pagination, lbCurrentInput } = this; + const currentPage = pagination.getCurrentPage() + 1; const rawValue = lbCurrentInput.getValue(true); if (!rawValue?.trim()) { + lbCurrentInput.setValue(String(currentPage), true); return; } const rawValueNum = Number(rawValue); - let value = Number.isFinite(rawValueNum) ? rawValueNum : pagination.getCurrentPage() + 1; const total = pagination.getTotalPages(); - value = Math.max(1, Math.min(value, total)); - if (rawValueNum !== value) { - lbCurrentInput.setValue(String(value), true); + const isValid = + Number.isFinite(rawValueNum) && Number.isInteger(rawValueNum) && rawValueNum >= 1 && rawValueNum <= total; + if (!isValid) { + lbCurrentInput.setValue(String(currentPage), true); + return; } - pagination.goToPage(value - 1); + pagination.goToPage(rawValueNum - 1); } public refresh(): void { @@ -249,6 +218,14 @@ export class PageSummaryComp extends Component { lbCurrentInput.setMax(pageCount); lbCurrentInput.getInputElement().style.width = `${Math.floor(Math.log10(pageCount) + 3)}ch`; // log10 returns number of digits (as an integer part + fraction) - 1 lbCurrentInput.setValue(lbCurrentValue.toString()); + const eInput = lbCurrentInput.getInputElement(); + _setAriaLabel( + eInput, + `${localeTextFunc('page', 'Page')} ${localeTextFunc('number', 'number')}, ${lbCurrentValue} ${localeTextFunc('of', 'of')} ${lbTotalStr}` + ); + eInput.setAttribute('aria-valuenow', String(lbCurrentValue)); + eInput.setAttribute('aria-valuemin', '1'); + eInput.setAttribute('aria-valuemax', String(pageCount)); } const strPage = localeTextFunc('page', 'Page'); @@ -260,3 +237,89 @@ export class PageSummaryComp extends Component { return _formatPaginationNumber(value, this.gos, this.getLocaleTextFunc.bind(this)); } } + +function buildPageSummaryTemplate( + idPrefix: string, + noInput: boolean, + localeTextFunc: LocaleTextFunc +): AgElementParams { + const pageNumberChild = { + cls: 'ag-paging-number', + attrs: { id: `${idPrefix}-start-page-number` }, + tag: noInput ? 'span' : 'ag-input-number-field', + ref: noInput ? 'lbCurrentStatic' : 'lbCurrentInput', + } as const; + + return { + tag: 'span', + cls: 'ag-paging-page-summary-panel', + role: 'presentation', + children: [ + { + tag: 'div', + ref: 'btFirst', + cls: 'ag-button ag-paging-button', + role: 'button', + attrs: { 'aria-label': localeTextFunc('firstPage', 'First Page') }, + }, + { + tag: 'div', + ref: 'btPrevious', + cls: 'ag-button ag-paging-button', + role: 'button', + attrs: { 'aria-label': localeTextFunc('previousPage', 'Previous Page') }, + }, + { + tag: 'span', + cls: 'ag-paging-description', + children: [ + { + tag: 'span', + attrs: { id: `${idPrefix}-start-page` }, + children: localeTextFunc('page', 'Page'), + }, + pageNumberChild, + { + tag: 'span', + attrs: { id: `${idPrefix}-of-page` }, + children: localeTextFunc('of', 'of'), + }, + { + tag: 'span', + ref: 'lbTotal', + cls: 'ag-paging-number', + attrs: { id: `${idPrefix}-of-page-number` }, + }, + ], + }, + { + tag: 'div', + ref: 'btNext', + cls: 'ag-button ag-paging-button', + role: 'button', + attrs: { 'aria-label': localeTextFunc('nextPage', 'Next Page') }, + }, + { + tag: 'div', + ref: 'btLast', + cls: 'ag-button ag-paging-button', + role: 'button', + attrs: { 'aria-label': localeTextFunc('lastPage', 'Last Page') }, + }, + ], + }; +} + +function insertNavIcons( + isRtl: boolean, + beans: BeanCollection, + btFirst: HTMLElement, + btPrevious: HTMLElement, + btNext: HTMLElement, + btLast: HTMLElement +): void { + btFirst.insertAdjacentElement('afterbegin', _createIconNoSpan(isRtl ? 'last' : 'first', beans)!); + btPrevious.insertAdjacentElement('afterbegin', _createIconNoSpan(isRtl ? 'next' : 'previous', beans)!); + btNext.insertAdjacentElement('afterbegin', _createIconNoSpan(isRtl ? 'previous' : 'next', beans)!); + btLast.insertAdjacentElement('afterbegin', _createIconNoSpan(isRtl ? 'first' : 'last', beans)!); +} diff --git a/packages/ag-grid-community/src/pagination/paginationComp.css b/packages/ag-grid-community/src/pagination/paginationComp.css index ec96ec59d51..ea87b9fd99f 100644 --- a/packages/ag-grid-community/src/pagination/paginationComp.css +++ b/packages/ag-grid-community/src/pagination/paginationComp.css @@ -44,6 +44,10 @@ display: inline-block; } +:where(.ag-number-field.ag-paging-number) .ag-input-field-input { + text-align: right; +} + .ag-paging-number, .ag-paging-row-summary-panel-number { font-weight: 500; diff --git a/packages/ag-grid-community/src/rendering/cell/cellCtrl.ts b/packages/ag-grid-community/src/rendering/cell/cellCtrl.ts index e7c136754dd..012dad81cdf 100644 --- a/packages/ag-grid-community/src/rendering/cell/cellCtrl.ts +++ b/packages/ag-grid-community/src/rendering/cell/cellCtrl.ts @@ -553,7 +553,7 @@ export class CellCtrl extends BeanStub { const res: ICellRendererParams = _addGridCommonParams(gos, { value: value, valueFormatted: valueFormatted, - getValue: () => valueSvc.getValueForDisplay({ column, node: rowNode, from: 'edit' }).value, + getValue: () => valueSvc.getDisplayValue(column, rowNode, 'edit'), setValue: (value: any) => editSvc?.setDataValue({ rowNode, column }, value) || rowNode.setDataValue(column, value), formatValue: this.formatValue.bind(this), @@ -631,7 +631,7 @@ export class CellCtrl extends BeanStub { return; } - const { field, valueGetter, showRowGroup, enableCellChangeFlash } = column.colDef; + const enableCellChangeFlash = column.enableCellChangeFlash; // we always refresh if cell has no value - this can happen when user provides Cell Renderer and the // cell renderer doesn't rely on a value, instead it could be looking directly at the data, or maybe // printing the current time (which would be silly)???. Generally speaking @@ -641,7 +641,11 @@ export class CellCtrl extends BeanStub { // a calculated column has no field/valueGetter/showRowGroup but DOES have a value (its // expression), so it must not count as value-less here — otherwise it force-refreshes every // pass and flashes on changes to unrelated columns instead of only when its value changes. - const noValueProvided = field == null && valueGetter == null && showRowGroup == null && !column.isCalculatedCol; + const noValueProvided = + column.field == null && + column.valueGetter == null && + column.showRowGroup == null && + !column.isCalculatedCol; const newData = params?.newData ?? false; const forceRefresh = noValueProvided || (params && (params.force || newData)); diff --git a/packages/ag-grid-community/src/rendering/row/normalRowFeature.ts b/packages/ag-grid-community/src/rendering/row/normalRowFeature.ts index 374a09ad69f..b58d0709a15 100644 --- a/packages/ag-grid-community/src/rendering/row/normalRowFeature.ts +++ b/packages/ag-grid-community/src/rendering/row/normalRowFeature.ts @@ -193,7 +193,7 @@ export class NormalRowFeature extends BeanStub implements IRowModeFeature { // we use instanceId's rather than colId as it's possible there is a Column with same Id, // but it's referring to a different column instance. Happens a lot with pivot, as pivot col id's are // reused eg pivot_0, pivot_1 etc - const colInstanceId = col.getInstanceId(); + const colInstanceId = col.instanceId; let cellCtrl: CellCtrl | undefined = prev.map[colInstanceId]; // for spanned cells, if the span ref has changed, need to hard refresh cell @@ -212,7 +212,7 @@ export class NormalRowFeature extends BeanStub implements IRowModeFeature { } for (const prevCellCtrl of prev.list) { - const colInstanceId = prevCellCtrl.column.getInstanceId(); + const colInstanceId = prevCellCtrl.column.instanceId; const cellInResult = res.map[colInstanceId] != null; if (cellInResult) { @@ -242,7 +242,7 @@ export class NormalRowFeature extends BeanStub implements IRowModeFeature { const focusedCol = focusedCell?.column as AgColumn | undefined; // if a cell is focused, might need to be force rendered if it belongs to this pinned section if (focusedCol && focusedCol.pinned == pinned) { - const focusedColInstanceId = focusedCol.getInstanceId(); + const focusedColInstanceId = focusedCol.instanceId; const focusedCellCtrl = res.map[focusedColInstanceId]; // if focused col is visible, and there's no cell here for it, try to create one @@ -320,7 +320,7 @@ export class NormalRowFeature extends BeanStub implements IRowModeFeature { continue; } res.list.push(cellCtrl); - res.map[cellCtrl.column.getInstanceId()] = cellCtrl; + res.map[cellCtrl.column.instanceId] = cellCtrl; } return res; } diff --git a/packages/ag-grid-community/src/rendering/spanning/rowSpanCache.ts b/packages/ag-grid-community/src/rendering/spanning/rowSpanCache.ts index 0426aa30f84..c12bf58dc4e 100644 --- a/packages/ag-grid-community/src/rendering/spanning/rowSpanCache.ts +++ b/packages/ag-grid-community/src/rendering/spanning/rowSpanCache.ts @@ -138,12 +138,12 @@ export class RowSpanCache { node.footer || (spanData && node.rowIndex - 1 !== spanData?.getLastNode().rowIndex) // no span if rows not contiguous (SSRM) ) { - setNewHead(node, valueSvc.getValue(column, node, 'data')); + setNewHead(node, valueSvc.getValueFromData(column, node)); return; } // check value is equal, if not, no span - const value = valueSvc.getValue(column, node, 'data'); + const value = valueSvc.getValueFromData(column, node); if (isCustomCompare) { const params: SpanRowsParams = _addGridCommonParams(gos, { valueA: lastValue, diff --git a/packages/ag-grid-community/src/sort/rowNodeSorter.ts b/packages/ag-grid-community/src/sort/rowNodeSorter.ts index d1d99f7b2f1..229963ceaf6 100644 --- a/packages/ag-grid-community/src/sort/rowNodeSorter.ts +++ b/packages/ag-grid-community/src/sort/rowNodeSorter.ts @@ -1,14 +1,16 @@ import { _defaultComparator } from 'ag-stack'; import { _csrmFirstLeaf } from '../clientSideRowModel/clientSideRowModelUtils'; +import type { ColumnModel } from '../columns/columnModel'; import type { NamedBean } from '../context/bean'; import { BeanStub } from '../context/beanStub'; +import type { BeanCollection } from '../context/context'; import type { AgColumn } from '../entities/agColumn'; -import { _normalizeSortType } from '../entities/agColumn'; -import type { ColDef, SortComparatorFn } from '../entities/colDef'; import type { RowNode } from '../entities/rowNode'; import { _isClientSideRowModel, _isColumnsSortingCoupledToGroup, _isGroupUseEntireRow } from '../gridOptionsUtils'; +import type { IFormulaService } from '../interfaces/formulas'; import type { SortOption } from '../interfaces/iSortOption'; +import type { ValueService } from '../valueService/valueService'; // this logic is used by both SSRM and CSRM @@ -21,31 +23,38 @@ export class RowNodeSorter extends BeanStub implements NamedBean { private pivotActive: boolean = false; private firstLeaf: (row: RowNode) => RowNode | undefined; + private colModel: ColumnModel; + private formula: IFormulaService | undefined; + private valueSvc: ValueService; + public postConstruct(): void { this.firstLeaf = _isClientSideRowModel(this.gos) ? _csrmFirstLeaf : defaultGetLeaf; - this.addManagedPropertyListeners( - ['accentedSort', 'autoGroupColumnDef', 'treeData'], - this.updateOptions.bind(this) - ); + const updatePivotModeState = () => { + this.pivotActive = this.colModel.isPivotActive(); + }; + + const updateOptions = () => { + const gos = this.gos; + this.accentedSort = !!gos.get('accentedSort'); + this.primaryColumnsSortGroups = _isColumnsSortingCoupledToGroup(gos); + }; + + this.addManagedPropertyListeners(['accentedSort', 'autoGroupColumnDef', 'treeData'], updateOptions); - const updatePivotModeState = this.updatePivotModeState.bind(this); this.addManagedEventListeners({ columnPivotModeChanged: updatePivotModeState, columnPivotChanged: updatePivotModeState, }); - this.updateOptions(); + updateOptions(); updatePivotModeState(); } - private updateOptions(): void { - this.accentedSort = !!this.gos.get('accentedSort'); - this.primaryColumnsSortGroups = _isColumnsSortingCoupledToGroup(this.gos); - } - - private updatePivotModeState(): void { - this.pivotActive = this.beans.colModel.isPivotActive(); + public wireBeans(beans: BeanCollection): void { + this.colModel = beans.colModel; + this.formula = beans.formula; + this.valueSvc = beans.valueSvc; } public doFullSortInPlace(rowNodes: RowNode[], sortOptions: SortOption[]): RowNode[] { @@ -54,109 +63,55 @@ export class RowNodeSorter extends BeanStub implements NamedBean { } public compareRowNodes(sortOptions: SortOption[], nodeA: RowNode, nodeB: RowNode): number { - if (nodeA === nodeB) { - return 0; - } - const accentedCompare = this.accentedSort; - // Iterate columns, return the first that doesn't match + // Iterate columns, return the first that doesn't match. Comparators are resolved up front + // (see _resolveSortOptions): a col comparator applies to every row; a row-group display col falls back + // to its primary column's comparator for leaf rows only; otherwise the grid's default comparator is used. for (let i = 0, len = sortOptions.length; i < len; ++i) { const sortOption = sortOptions[i]; - const isDescending = sortOption.sort === 'desc'; - const column = sortOption.column as AgColumn; - let valueA = this.getValue(nodeA, column); - let valueB = this.getValue(nodeB, column); - - let comparatorResult: number; - const providedComparator = this.getComparator(sortOption, nodeA); - if (providedComparator) { - //if comparator provided, use it - comparatorResult = providedComparator(valueA, valueB, nodeA, nodeB, isDescending); + const descending = sortOption.descending; + const valueA = this.getValue(nodeA, column); + const valueB = this.getValue(nodeB, column); + + const comparator = sortOption.colComparator ?? (nodeA.group ? undefined : sortOption.leafComparator); + let result: number; + if (comparator) { + result = comparator(valueA, valueB, nodeA, nodeB, descending); + } else if (sortOption.absolute) { + result = _defaultComparator( + absoluteValueTransformer(valueA), + absoluteValueTransformer(valueB), + accentedCompare + ); } else { - //otherwise do our own comparison - - if (sortOption.type === 'absolute') { - valueA = absoluteValueTransformer(valueA); - valueB = absoluteValueTransformer(valueB); - } - - comparatorResult = _defaultComparator(valueA, valueB, accentedCompare); + result = _defaultComparator(valueA, valueB, accentedCompare); } - // user provided comparators can return 'NaN' if they don't correctly handle 'undefined' values, this - // typically occurs when the comparator is used on a group row - if (comparatorResult) { - return sortOption.sort === 'asc' ? comparatorResult : -comparatorResult; + // user comparators can return NaN for unhandled undefined values; NaN is falsy → treated as equal. + if (result) { + return descending ? -result : result; } } return 0; } - /** - * if user defines a comparator as a function then use that. - * if user defines a dictionary of comparators, then use the one matching the sort type. - * if no comparator provided, or no matching comparator found in dictionary, then return undefined. - * - * grid checks later if undefined is returned here and falls back to a default comparator corresponding to sort type on the coldef. - * @private - */ - private getComparator(sortOption: SortOption, rowNode: RowNode): SortComparatorFn | undefined { - const colDef = (sortOption.column as AgColumn).colDef; - - // comparator on col get preference over everything else - const comparatorOnCol = this.getComparatorFromColDef(colDef, sortOption); - if (comparatorOnCol) { - return comparatorOnCol; - } - - if (!colDef.showRowGroup) { - return; - } - - // if a 'field' is supplied on the autoGroupColumnDef we need to use the associated column comparator - const groupLeafField = !rowNode.group && colDef.field; - if (!groupLeafField) { - return; - } - - const primaryColumn = this.beans.colModel.getNonPivotCol(groupLeafField); - if (!primaryColumn) { - return; - } - // comparator on col get preference over everything else - return this.getComparatorFromColDef(primaryColumn.colDef, sortOption); - } - - private getComparatorFromColDef(colDef: ColDef, sortOption: SortOption): SortComparatorFn | undefined { - const comparator = colDef.comparator; - if (comparator == null) { - return; - } - if (typeof comparator === 'object') { - return comparator[_normalizeSortType(sortOption.type)]; - } - return comparator; - } - private getValue(node: RowNode, column: AgColumn): any { - const beans = this.beans; - if (this.primaryColumnsSortGroups) { if (node.rowGroupColumn === column) { return this.getGroupDataValue(node, column); } - if (node.group && column.colDef.showRowGroup) { + if (node.group && column.showRowGroup) { return undefined; } } - const value = beans.valueSvc.getValue(column, node, 'data'); - if (column.colDef.allowFormula) { - const formula = beans.formula; + const value = this.valueSvc.getValueFromData(column, node); + if (column.allowFormula) { + const formula = this.formula; if (formula?.isFormula(value)) { return formula.resolveValue(column, node); } @@ -170,7 +125,7 @@ export class RowNodeSorter extends BeanStub implements NamedBean { // Formulas are currently not supported on row-group columns, so no formula resolution is needed here. if (_isGroupUseEntireRow(this.gos, this.pivotActive)) { const leafChild = this.firstLeaf(node); - return leafChild && this.beans.valueSvc.getValue(column, leafChild, 'data'); + return leafChild && this.valueSvc.getValueFromData(column, leafChild); } const displayCol = column.showRowGroupCol; diff --git a/packages/ag-grid-community/src/sort/sortIndicatorComp.ts b/packages/ag-grid-community/src/sort/sortIndicatorComp.ts index e009e3ab5c7..40eb4155af0 100644 --- a/packages/ag-grid-community/src/sort/sortIndicatorComp.ts +++ b/packages/ag-grid-community/src/sort/sortIndicatorComp.ts @@ -80,7 +80,7 @@ export class SortIndicatorComp extends Component { this.setupMultiSortIndicator(); - if (!column.isSortable() && !column.colDef.showRowGroup) { + if (!column.isSortable() && !column.showRowGroup) { return; } @@ -160,7 +160,7 @@ export class SortIndicatorComp extends Component { const { eSortMixed, column, gos } = this; this.addInIcon('sortUnSort', eSortMixed, column); - const isColumnShowingRowGroup = column.colDef.showRowGroup; + const isColumnShowingRowGroup = column.showRowGroup; const areGroupsCoupled = _isColumnsSortingCoupledToGroup(gos); if (areGroupsCoupled && isColumnShowingRowGroup) { this.addManagedEventListeners({ diff --git a/packages/ag-grid-community/src/sort/sortOptionUtils.ts b/packages/ag-grid-community/src/sort/sortOptionUtils.ts new file mode 100644 index 00000000000..1e380820eff --- /dev/null +++ b/packages/ag-grid-community/src/sort/sortOptionUtils.ts @@ -0,0 +1,51 @@ +import type { ColumnModel } from '../columns/columnModel'; +import type { AgColumn } from '../entities/agColumn'; +import { _normalizeSortType } from '../entities/agColumn'; +import type { SortComparatorFn } from '../entities/colDef'; +import type { SortType } from '../interfaces/iSort'; +import type { SortOption } from '../interfaces/iSortOption'; + +/** Pick the comparator for the sort type: a bare function applies to all types; a dict selects by type. */ +const resolveComparator = ( + comparator: SortComparatorFn | Partial> | undefined, + type: SortType +): SortComparatorFn | undefined => { + if (comparator == null) { + return undefined; + } + if (typeof comparator === 'object') { + // normalise so an unset/invalid type still selects the dict's `default` entry + return comparator[_normalizeSortType(type)]; + } + return comparator; +}; + +/** + * Resolves each option's comparators in place so the sorter reads them directly per comparison: a comparator on + * the col applies to every row; a row-group display col otherwise falls back to its primary column's comparator + * (the sorter applies that fallback to leaf rows only). Call once after building an option array, before sorting. + * @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. + */ +export const _resolveSortOptions = (options: SortOption[], colModel: ColumnModel): void => { + for (let i = 0, len = options.length; i < len; ++i) { + const sortOption = options[i]; + const column = sortOption.column as AgColumn; + const type = sortOption.type; + + // comparator on col gets preference over everything else + const colComparator = resolveComparator(column.colDef.comparator, type); + let leafComparator: SortComparatorFn | undefined; + // if a 'field' is supplied on the autoGroupColumnDef we need to use the associated column comparator + if (!colComparator && column.showRowGroup) { + const field = column.field; + const primaryColumn = field ? colModel.getNonPivotCol(field) : null; + if (primaryColumn) { + leafComparator = resolveComparator(primaryColumn.colDef.comparator, type); + } + } + sortOption.colComparator = colComparator; + sortOption.leafComparator = leafComparator; + sortOption.descending = sortOption.sort === 'desc'; + sortOption.absolute = type === 'absolute'; + } +}; diff --git a/packages/ag-grid-community/src/sort/sortService.ts b/packages/ag-grid-community/src/sort/sortService.ts index bb181e88d60..0fdb54e9c2c 100644 --- a/packages/ag-grid-community/src/sort/sortService.ts +++ b/packages/ag-grid-community/src/sort/sortService.ts @@ -11,6 +11,7 @@ import type { SortModelItem } from '../interfaces/iSortModelItem'; import type { SortOption } from '../interfaces/iSortOption'; import type { Component } from '../widgets/component'; import { SortIndicatorComp, SortIndicatorSelector } from './sortIndicatorComp'; +import { _resolveSortOptions } from './sortOptionUtils'; /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ export class SortService extends BeanStub implements NamedBean { @@ -49,7 +50,7 @@ export class SortService extends BeanStub implements NamedBean { const { gos, showRowGroupCols } = this.beans; const columnsToUpdate: AgColumn[] = [column]; - if (_isColumnsSortingCoupledToGroup(gos) && column.colDef.showRowGroup) { + if (column.showRowGroup && _isColumnsSortingCoupledToGroup(gos)) { const rowGroupColumns = showRowGroupCols?.getSourceColumnsForGroupColumn?.(column); for (let i = 0, len = rowGroupColumns?.length ?? 0; i < len; ++i) { const col = rowGroupColumns![i]; @@ -86,7 +87,7 @@ export class SortService extends BeanStub implements NamedBean { let nextIndex = 0; for (let i = 0, len = sorted.length; i < len; ++i) { const col = sorted[i]; - if ((isCoupled && col.colDef.showRowGroup) || col === lastSortIndexCol) { + if ((isCoupled && col.showRowGroup) || col === lastSortIndexCol) { continue; } targetIndex.set(col, nextIndex++); @@ -174,7 +175,7 @@ export class SortService extends BeanStub implements NamedBean { continue; } if (pivotMode) { - const isGroup = coupled ? col.showRowGroupCol : col.colDef.showRowGroup; + const isGroup = coupled ? col.showRowGroupCol : col.showRowGroup; if (!col.aggFunc && col.primary && !isGroup) { continue; } @@ -222,9 +223,18 @@ export class SortService extends BeanStub implements NamedBean { const sortDef = column.getSortDef(); const sort = sortDef?.direction; if (sort) { - opts.push({ sort, type: _normalizeSortType(sortDef.type), column }); + opts.push({ + sort, + type: _normalizeSortType(sortDef.type), + column, + colComparator: undefined, + leafComparator: undefined, + descending: false, + absolute: false, + }); } } + _resolveSortOptions(opts, this.beans.colModel); this.opts = opts; } return opts; @@ -233,7 +243,7 @@ export class SortService extends BeanStub implements NamedBean { public getDisplaySort(column: AgColumn): DisplaySortDef | null { const colSortDef = column.getSortDef(); // Mixed sort only on a coupled group display col — check the cheap flags before the linked-col lookup. - if (!column.colDef.showRowGroup || !_isColumnsSortingCoupledToGroup(this.gos)) { + if (!column.showRowGroup || !_isColumnsSortingCoupledToGroup(this.gos)) { return colSortDef; } const linkedColumns = this.beans.showRowGroupCols?.getSourceColumnsForGroupColumn(column); @@ -241,8 +251,7 @@ export class SortService extends BeanStub implements NamedBean { return colSortDef; } // A group col with its own field/valueGetter sorts independently, so it joins the comparison. - const colDef = column.colDef; - const ownData = colDef.field != null || !!colDef.valueGetter; + const ownData = column.field != null || !!column.valueGetter; const firstSort = ownData ? colSortDef : linkedColumns[0].getSortDef(); let allMatch = true; for (let i = 0, len = linkedColumns.length; allMatch && i < len; ++i) { @@ -269,7 +278,7 @@ export class SortService extends BeanStub implements NamedBean { comp.toggleCss('ag-header-cell-sorted-abs-asc', type === 'absolute' && direction === 'asc'); comp.toggleCss('ag-header-cell-sorted-abs-desc', type === 'absolute' && direction === 'desc'); comp.toggleCss('ag-header-cell-sorted-none', !direction); - if (column.colDef.showRowGroup) { + if (column.showRowGroup) { const isMixed = this.beans.showRowGroupCols?.isGroupSortMixed(column, direction) ?? true; comp.toggleCss('ag-header-cell-sorted-mixed', isMixed); } diff --git a/packages/ag-grid-community/src/tooltip/tooltipService.ts b/packages/ag-grid-community/src/tooltip/tooltipService.ts index d01cf78c66a..5de036761ca 100644 --- a/packages/ag-grid-community/src/tooltip/tooltipService.ts +++ b/packages/ag-grid-community/src/tooltip/tooltipService.ts @@ -134,7 +134,7 @@ const resolveCellTooltip = ({ // 1) formula error tooltip has highest priority. const isCalculatedColumn = column.isCalculatedCol; - if ((colDef.allowFormula && formula?.active) || (isCalculatedColumn && formula)) { + if ((column.allowFormula && formula?.active) || (isCalculatedColumn && formula)) { const error = formula.getFormulaError(column, rowNode); if (error) { return { @@ -171,9 +171,7 @@ const resolveCellTooltip = ({ if (colDef.tooltipField && _exists(data)) { const tooltipField = colDef.tooltipField; return { - value: column.isTooltipFieldContainsDots() - ? _getValueUsingDotField(data, tooltipField) - : data[tooltipField], + value: column.tooltipFieldContainsDots ? _getValueUsingDotField(data, tooltipField) : data[tooltipField], location: 'cell', shouldDisplay: shouldDisplayColumnTooltip, }; diff --git a/packages/ag-grid-community/src/utils/value.ts b/packages/ag-grid-community/src/utils/value.ts new file mode 100644 index 00000000000..c056800d464 --- /dev/null +++ b/packages/ag-grid-community/src/utils/value.ts @@ -0,0 +1,16 @@ +/** + * Reads a deep property from `data` following a pre-split dotted path. Hot-path variant of + * `_getValueUsingDotField` (ag-stack): callers reading the same field per cell cache `field.split('.')` once + * (on colDef update) and use this, avoiding a split + array allocation per read. + * @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. + */ +export function _getValueUsingDotPath(data: any, fields: string[]): any { + let currentObject = data; + for (let i = 0, len = fields.length; i < len; i++) { + if (currentObject == null) { + return undefined; + } + currentObject = currentObject[fields[i]]; + } + return currentObject; +} diff --git a/packages/ag-grid-community/src/valueService/cellApi.ts b/packages/ag-grid-community/src/valueService/cellApi.ts index cc0d0430055..525725600c7 100644 --- a/packages/ag-grid-community/src/valueService/cellApi.ts +++ b/packages/ag-grid-community/src/valueService/cellApi.ts @@ -1,6 +1,7 @@ import { _toString } from 'ag-stack'; import type { BeanCollection } from '../context/context'; +import { _resolvePivotColumnForRow } from '../entities/agColumn'; import type { Column } from '../interfaces/iColumn'; import type { CellValueResolveFrom } from '../interfaces/iEditService'; import type { IRowNode } from '../interfaces/iRowNode'; @@ -32,8 +33,10 @@ export function getCellValue(beans: BeanCollection, params: GetCel if (!column) { return null; } + // API accepts an arbitrary node, so a pivot result column may be read on a leaf — redirect to the + // underlying value column (display/selection paths never hit this, see ValueService.getValue). const result = beans.valueSvc.getValueForDisplay({ - column, + column: _resolvePivotColumnForRow(column, rowNode), node: rowNode, includeValueFormatted: useFormatter, from, diff --git a/packages/ag-grid-community/src/valueService/valueService.test.ts b/packages/ag-grid-community/src/valueService/valueService.test.ts index c3eba7f9a34..25e5236b987 100644 --- a/packages/ag-grid-community/src/valueService/valueService.test.ts +++ b/packages/ag-grid-community/src/valueService/valueService.test.ts @@ -20,6 +20,10 @@ describe('formatValue', () => { colDef = {}; column = mock(); column.colDef = colDef; + // The value service reads these off the column (mirrored from colDef by initColDefHotFields), + // so mirror them here for the mock to behave like a real column. + Object.defineProperty(column, 'valueFormatter', { get: () => colDef.valueFormatter, configurable: true }); + Object.defineProperty(column, 'refData', { get: () => colDef.refData, configurable: true }); gos = mock('get', 'addCommon'); gos.addCommon.mockImplementation((params) => params as any); @@ -27,6 +31,9 @@ describe('formatValue', () => { valueSvc = new ValueService(); (valueSvc as any).gos = gos; (valueSvc as any).expressionSvc = expressionSvc; + // ValueService builds params from these cached refs (set in wireBeans on a real grid). + (valueSvc as any).gridApi = {}; + (valueSvc as any).gridOptions = {}; (valueSvc as any).beans = { editSvc: mock('isEditing'), expressionSvc, diff --git a/packages/ag-grid-community/src/valueService/valueService.ts b/packages/ag-grid-community/src/valueService/valueService.ts index 4cf131ccc7a..56c98f4edb6 100644 --- a/packages/ag-grid-community/src/valueService/valueService.ts +++ b/packages/ag-grid-community/src/valueService/valueService.ts @@ -1,5 +1,6 @@ -import { _exists, _getValueUsingDotField, _isExpressionString, _missing } from 'ag-stack'; +import { _isExpressionString } from 'ag-stack'; +import type { GridApi } from '../api/gridApi'; import type { ColumnModel } from '../columns/columnModel'; import type { DataTypeService } from '../columns/dataTypeService'; import type { NamedBean } from '../context/bean'; @@ -7,6 +8,7 @@ import { BeanStub } from '../context/beanStub'; import type { BeanCollection } from '../context/context'; import type { EditService } from '../edit/editService'; import type { AgColumn } from '../entities/agColumn'; +import { _resolvePivotColumnForRow } from '../entities/agColumn'; import type { ColDef, KeyCreatorParams, @@ -15,15 +17,17 @@ import type { ValueParserParams, ValueSetterParams, } from '../entities/colDef'; +import type { GridOptions } from '../entities/gridOptions'; import type { RowNode } from '../entities/rowNode'; import type { CellValueChangedEvent } from '../events'; -import { _addGridCommonParams, _isServerSideRowModel } from '../gridOptionsUtils'; +import { _isServerSideRowModel } from '../gridOptionsUtils'; import type { IFormulaDataService, IFormulaService } from '../interfaces/formulas'; import type { CellValueResolveFrom } from '../interfaces/iEditService'; import type { IFrameworkOverrides } from '../interfaces/iFrameworkOverrides'; import type { IRowGroupingEditValueSvc } from '../interfaces/iRowGroupingEditValueSvc'; import type { IRowNode } from '../interfaces/iRowNode'; import type { IShowRowGroupColsValueService } from '../interfaces/iShowRowGroupColsValueService'; +import { _getValueUsingDotPath } from '../utils/value'; import { _warn } from '../validation/logging'; import type { ChangeDetectionService } from './changeDetectionService'; import type { ExpressionService } from './expressionService'; @@ -36,23 +40,11 @@ export class ValueService extends BeanStub implements NamedBean { // Hot-path fields first (read on every getValue call). All declared with primitive // defaults so V8 picks a stable hidden-class shape from the moment the instance is // constructed — `init()` and `postConstruct` overwrite values without reshaping. - /** - * Bound by `init()` to the cache or no-cache variant. Default is no-cache for safety. - * Unbound method reference is fine — call sites use `this.executeValueGetter(...)`. - */ - private executeValueGetter: ( - valueGetter: string | ((...args: any[]) => any), - data: any, - column: AgColumn, - rowNode: IRowNode - ) => any = this.executeValueGetterWithoutValueCache; private isTreeData: boolean = false; private isSsrm: boolean = false; private cellExpressions: boolean = false; private groupSuppressBlankHeader: boolean = false; - // Bean refs — assigned in wireBeans. Initialised to undefined so the property slot - // exists in the same shape from construction time. private editSvc: EditService | undefined = undefined; private valueCache: ValueCache | undefined = undefined; private colModel!: ColumnModel; @@ -63,7 +55,9 @@ export class ValueService extends BeanStub implements NamedBean { private changeDetectionSvc: ChangeDetectionService | undefined = undefined; private showRowGroupColValueSvc: IShowRowGroupColsValueService | undefined = undefined; private rowGroupingEditValueSvc: IRowGroupingEditValueSvc | undefined = undefined; - private frameworkOverrides: IFrameworkOverrides; + private frameworkOverrides: IFrameworkOverrides = undefined!; + private gridApi: GridApi = undefined!; + private gridOptions: GridOptions = undefined!; public wireBeans(beans: BeanCollection): void { this.expressionSvc = beans.expressionSvc; @@ -77,6 +71,8 @@ export class ValueService extends BeanStub implements NamedBean { this.showRowGroupColValueSvc = beans.showRowGroupColValueSvc; this.rowGroupingEditValueSvc = beans.rowGroupingEditValueSvc; this.frameworkOverrides = beans.frameworkOverrides; + this.gridApi = beans.gridApi; + this.gridOptions = beans.gridOptions; this.init(); } @@ -87,10 +83,9 @@ export class ValueService extends BeanStub implements NamedBean { this.cellExpressions = gos.get('enableCellExpressions'); this.isTreeData = gos.get('treeData'); this.groupSuppressBlankHeader = gos.get('groupSuppressBlankHeader'); - this.executeValueGetter = - this.valueCache && gos.get('valueCache') - ? this.executeValueGetterWithValueCache - : this.executeValueGetterWithoutValueCache; + if (!gos.get('valueCache')) { + this.valueCache = undefined; // Drop the cache ref when disabled. + } } public postConstruct(): void { @@ -127,21 +122,24 @@ export class ValueService extends BeanStub implements NamedBean { } { const column = params.column; const node = params.node; + const isGroup = node.group; const showRowGroupColValueSvc = this.showRowGroupColValueSvc; - const isFullWidthGroup = !column && node.group; - - // Tree data auto col acts as a traditional column, with the exception of footers, so only process footers with - // showRowGroupColValueSvc - const processTreeDataAsGroup = !this.isTreeData || node.footer; - - // handle group cell value - if (showRowGroupColValueSvc && processTreeDataAsGroup && (isFullWidthGroup || column?.colDef.showRowGroup)) { - const groupValue = showRowGroupColValueSvc.getGroupValue(node, column, this.displayIgnoresAggData(node)); + const isFullWidthGroup = !column && isGroup; + + // handle group cell value. Tree-data auto col acts as a traditional column except for footers, so + // the tree-data check is last — skipped for the common non-group-display column. + if ( + showRowGroupColValueSvc && + (isFullWidthGroup || column?.showRowGroup) && + (!this.isTreeData || node.footer) + ) { + const groupValue = showRowGroupColValueSvc.getGroupValue( + node, + column, + isGroup ? this.displayIgnoresAggData(node) : false + ); if (groupValue == null) { - return { - value: null, - valueFormatted: null, - }; + return { value: null, valueFormatted: null }; } return { @@ -154,89 +152,79 @@ export class ValueService extends BeanStub implements NamedBean { // full width row, not full width group - probably should be supported by getValue if (!column) { - return { - value: node.key, - valueFormatted: null, - }; + return { value: node.key, valueFormatted: null }; } - let value = this.getValue(column, node, params.from, this.displayIgnoresAggData(node)); + let value = this.getValue(column, node, params.from, isGroup ? this.displayIgnoresAggData(node) : false); let valueToFormat = value; - const formula = this.formula; - const colDef = column.colDef; - if (colDef.allowFormula && formula?.isFormula(value)) { - if (params.useRawFormula) { - value = formula.normaliseFormula(value, true); - valueToFormat = formula.resolveValue(column, node as RowNode); - } else { - value = formula.resolveValue(column, node as RowNode); - valueToFormat = value; + // Read this.formula only for formula-enabled columns — skipped for the common case. + if (column.allowFormula) { + const formula = this.formula; + if (formula?.isFormula(value)) { + if (params.useRawFormula) { + value = formula.normaliseFormula(value, true); + valueToFormat = formula.resolveValue(column, node as RowNode); + } else { + value = formula.resolveValue(column, node as RowNode); + valueToFormat = value; + } } } const format = - params.includeValueFormatted && !(params.exporting && colDef.useValueFormatterForExport === false); + params.includeValueFormatted && !(params.exporting && column.colDef.useValueFormatterForExport === false); return { value, valueFormatted: format ? this.formatValue(column, node, valueToFormat) : null, }; } - // PERFORMANCE CRITICAL — called for every cell during filtering, rendering, and export. - // Any change here can have a large impact. Run the getValue benchmark to verify. - public getValue( - column: AgColumn, - rowNode: IRowNode | null | undefined, - from: CellValueResolveFrom, - ignoreAggData: boolean = false - ): any { - if (!rowNode) { - return; + /** + * Display value only — same resolution as {@link getValueForDisplay} but returns the bare value, + * skipping formatting and the result-object allocation. For callers that never read `valueFormatted`. + */ + public getDisplayValue(column: AgColumn | undefined, node: IRowNode, from: CellValueResolveFrom): any { + const isGroup = node.group; + const showRowGroupColValueSvc = this.showRowGroupColValueSvc; + const isFullWidthGroup = !column && isGroup; + + if ( + showRowGroupColValueSvc && + (isFullWidthGroup || column?.showRowGroup) && + (!this.isTreeData || node.footer) + ) { + const groupValue = showRowGroupColValueSvc.getGroupValue( + node, + column, + isGroup ? this.displayIgnoresAggData(node) : false + ); + return groupValue == null ? null : groupValue.value; } - const colDef = column.colDef; - const isGroup = rowNode.group; - - // Resolve pivot result columns to their underlying value column for non-group, non-pinned rows. - if (!isGroup && !rowNode.rowPinned) { - const pivotValueColumn = colDef.pivotValueColumn; - if (pivotValueColumn) { - column = pivotValueColumn as AgColumn; - } + if (!column) { + return node.key; } - // 'data' (grouping/sort/agg hot path) never has pending edits — skip the editSvc read entirely. - if (from !== 'data') { - const editSvc = this.editSvc; - if (editSvc) { - const pending = editSvc.getPendingEditValue(rowNode, column, from); - if (pending !== undefined) { - return pending; - } + const value = this.getValue(column, node, from, isGroup ? this.displayIgnoresAggData(node) : false); + if (column.allowFormula) { + const formula = this.formula; + if (formula?.isFormula(value)) { + return formula.resolveValue(column, node as RowNode); } } + return value; + } + // PERFORMANCE CRITICAL — the hot read path (grouping, sort, agg, filter, rendering all reach here). + // Reads committed data for `column` exactly as given — NO pivot-result-column redirection and no + // pending-edit lookup. Callers that may hold a pivot result column (pivot edit, API reads, pivot + // aggregation) must pre-resolve via `_resolvePivotColumnForRow`. Run the getValue benchmark to verify. + // This does NOT resolve pending edit values (edit or batch) + public getValueFromData(column: AgColumn, rowNode: IRowNode, ignoreAggData: boolean = false): any { let result = column.isCalculatedCol ? this.formula?.resolveValue(column, rowNode as RowNode) - : this.resolveValueWithoutCalculatedColumns(column, rowNode, ignoreAggData, isGroup); - - if (result === undefined) { - // For showRowGroup columns on group rows, if no value was resolved and the row's - // group level is shallower than the column's associated row group, return null for - // retro-compatibility (previously getValue returned null early in this case). - // This guard applies to group rows only — leaf rows always return undefined here. - if (isGroup) { - const rowGroupColId = colDef.showRowGroup; - if (typeof rowGroupColId === 'string') { - const col = this.colModel.colsById[rowGroupColId]; - if (col && col.rowGroupActive && col.rowGroupActiveIndex > rowNode.level) { - return null; - } - } - } - return undefined; - } + : this.resolveCoreValue(column, rowNode, ignoreAggData); // the result could be an expression itself, if we are allowing cell values to be expressions if (this.cellExpressions && _isExpressionString(result)) { @@ -247,15 +235,41 @@ export class ValueService extends BeanStub implements NamedBean { return result; } - /** Computes whether to ignore aggregation data for display purposes. */ + /** + * Reads a cell value honouring pending edits for non-`'data'` sources. Does NOT redirect pivot result + * columns — display/selection callers only ever pair them with group rows (no redirect needed). The + * value APIs that accept an arbitrary node ({@link getCellValue}, {@link getDataValue}) pre-resolve via + * `_resolvePivotColumnForRow` since they can be handed a leaf. Hot committed-data reads of a known column + * should call {@link getValueFromData} directly to skip the edit-state lookup too. + */ + public getValue( + column: AgColumn, + rowNode: IRowNode | null | undefined, + from: CellValueResolveFrom, + ignoreAggData: boolean = false + ): any { + if (!rowNode) { + return; + } + if (from !== 'data') { + // null is a valid pending value, so only `undefined` falls through to committed data. + const pending = this.editSvc?.getPendingEditValue(rowNode, column, from); + if (pending !== undefined) { + return pending; + } + } + return this.getValueFromData(column, rowNode, ignoreAggData); + } + + /** Whether to ignore aggregation data for display. Callers must pass a group node (guard on `node.group`). */ private displayIgnoresAggData(node: IRowNode): boolean { // If doing grouping and footers, we don't want to include the agg value // in the header when the group is open. // Result is: isOpenedGroup && !groupShowsAggData - // Check isOpenedGroup conditions: node.group && !node.footer && !isPivotLeaf && node.expanded + // Check isOpenedGroup conditions: !node.footer && !isPivotLeaf && node.expanded // The root node (level -1) is always expanded but should not suppress its agg data display. - if (!node.group || node.footer || node.level === -1) { + if (node.footer || node.level === -1) { return false; } // groupShowsAggData = this.groupSuppressBlankHeader || !node.sibling @@ -271,110 +285,97 @@ export class ValueService extends BeanStub implements NamedBean { return !!node.expanded; } - private resolveValueWithoutCalculatedColumns( - column: AgColumn, - rowNode: IRowNode, - ignoreAggData: boolean, - isGroup: boolean | undefined - ): any { - const colDef = column.colDef; + // PERFORMANCE CRITICAL — the inner resolver for every non-calculated-column read on the hot path + // (reached from getValueFromData). Keep allocation-free and branch-cheap; run the getValue benchmark. + private resolveCoreValue(column: AgColumn, rowNode: IRowNode, ignoreAggData: boolean): any { + const isGroup = rowNode.group; // Skipped for group rows — formulas + row grouping are not supported together. - if (!isGroup && colDef.allowFormula) { + if (!isGroup && column.allowFormula) { const formula = this.formula?.getDataSourceFormula(rowNode as RowNode, column); if (formula !== undefined) { return formula; } } - // Only group rows have aggData — skip for leaf rows - const aggData = isGroup && !ignoreAggData ? rowNode.aggData : undefined; const data = rowNode.data; - - const colId = column.colId; - if (this.isTreeData) { + const isTreeData = this.isTreeData; + + // groupData/aggData only exist on group rows (and tree-data nodes) — skip the lookups (and the + // colId read) entirely for the dominant non-tree leaf case. + if (isGroup || isTreeData) { + // Only group rows have aggData — skip for leaf rows + const aggData = isGroup && !ignoreAggData ? rowNode.aggData : undefined; + const colId = column.colId; const aggDataValue = aggData?.[colId]; - if (aggDataValue !== undefined) { - return aggDataValue; + if (isTreeData) { + if (aggDataValue !== undefined) { + return aggDataValue; + } + const valueGetter = column.valueGetter; + let treeValue; + if (valueGetter) { + treeValue = this.executeValueGetter(valueGetter, data, column, rowNode); + } else if (data) { + // field read deferred to here — skipped entirely on the valueGetter branch above + const field = column.field; + if (field) { + const fieldPath = column.fieldPath; + treeValue = fieldPath ? _getValueUsingDotPath(data, fieldPath) : data[field]; + } + } + if (treeValue !== undefined) { + return treeValue; + } } - const field = colDef.field; - let treeValue; - if (colDef.valueGetter) { - treeValue = this.executeValueGetter(colDef.valueGetter, data, column, rowNode); - } else if (data && field) { - treeValue = column.fieldContainsDots ? _getValueUsingDotField(data, field) : data[field]; + + const groupData = rowNode.groupData; + if (groupData && colId in groupData) { + return groupData[colId]; } - if (treeValue !== undefined) { - return treeValue; + if (aggDataValue !== undefined) { + return aggDataValue; } } - const groupData = rowNode.groupData; - if (groupData && colId in groupData) { - return groupData[colId]; - } - const aggDataValue = aggData?.[colId]; - if (aggDataValue !== undefined) { - return aggDataValue; - } + const valueGetter = column.valueGetter; + // ignoreAggData (a free param) is tested first so the isSsrm/aggFunc reads are skipped on the common path. + const ignoreSsrmAggData = ignoreAggData && this.isSsrm && !!column.aggFunc; - return this.readUserValueForCell(column, rowNode, data, ignoreAggData, isGroup); - } - - private readUserValueForCell( - column: AgColumn, - rowNode: IRowNode, - data: any, - ignoreAggData: boolean, - isGroup: boolean | undefined - ): any { - const colDef = column.colDef; - const rowGroupColId = colDef.showRowGroup; - const allowUserValuesForCell = typeof rowGroupColId !== 'string' || !isGroup; - const ignoreSsrmAggData = this.isSsrm && ignoreAggData && !!colDef.aggFunc; - - if (colDef.valueGetter && !ignoreSsrmAggData) { - if (!allowUserValuesForCell) { - return undefined; + // Group-only machinery: display columns blank values shallower than their group level, and SSRM group + // footers mirror the group field. Leaf rows are never display/footer columns, so they fall straight + // through to the shared field/valueGetter read below (skipping the showRowGroup/footer checks). + if (isGroup) { + const rowGroupColId = column.showRowGroup; + if (valueGetter && !ignoreSsrmAggData) { + // string showRowGroup → group display col: blank instead of running the value getter + if (typeof rowGroupColId === 'string') { + return groupDisplayColEmptyValue(this.colModel, rowGroupColId, rowNode.level); + } + return this.executeValueGetter(valueGetter, data, column, rowNode); } - return this.executeValueGetter(colDef.valueGetter, data, column, rowNode); - } - - // SSRM-only footer values — skip the call entirely on client-side grids. - if (this.isSsrm) { - const ssrmFooterValue = this.readSsrmFooterGroupValue(column, rowNode, data, rowGroupColId); - if (ssrmFooterValue !== undefined) { - return ssrmFooterValue; + // SSRM footer read must precede the string-showRowGroup blank below. + if (this.isSsrm && data) { + const ssrmFooterValue = readSsrmFooterGroupValue(rowNode, data, rowGroupColId); + if (ssrmFooterValue !== undefined) { + return ssrmFooterValue; + } + } + if (typeof rowGroupColId === 'string') { + return groupDisplayColEmptyValue(this.colModel, rowGroupColId, rowNode.level); } } - const field = colDef.field; - if (!field || !data || ignoreSsrmAggData || !allowUserValuesForCell) { - return undefined; - } - return column.fieldContainsDots ? _getValueUsingDotField(data, field) : data[field]; - } - - private readSsrmFooterGroupValue( - column: AgColumn, - rowNode: IRowNode, - data: any, - rowGroupColId: ColDef['showRowGroup'] - ): any { - if (!this.isSsrm || !rowNode.footer) { - return undefined; - } - const rowField = rowNode.field; - - if (!rowField || (rowGroupColId !== true && rowGroupColId !== rowField)) { - return undefined; + // Shared by leaf rows and ordinary (non-display) group columns. + if (valueGetter) { + return ignoreSsrmAggData ? undefined : this.executeValueGetter(valueGetter, data, column, rowNode); } - - if (!data) { + const field = column.field; + if (!field || !data || ignoreSsrmAggData) { return undefined; } - - return column.fieldContainsDots ? _getValueUsingDotField(data, rowField) : data[rowField]; + const fieldPath = column.fieldPath; + return fieldPath ? _getValueUsingDotPath(data, fieldPath) : data[field]; } public parseValue( @@ -384,23 +385,22 @@ export class ValueService extends BeanStub implements NamedBean { oldValue: TValueOld ): TValue { const colDef = column.colDef; - // we do not allow parsing of formulas - if (colDef.allowFormula && this.formula?.isFormula(newValue)) { + if (column.allowFormula && this.formula?.isFormula(newValue)) { return newValue as TValue; } - const valueParser = colDef.valueParser; - - if (_exists(valueParser)) { - const params: ValueParserParams = _addGridCommonParams(this.gos, { + if (valueParser != null && valueParser !== '') { + const params: ValueParserParams = { + api: this.gridApi, + context: this.gridOptions.context, node: rowNode, data: rowNode?.data, oldValue, newValue: newValue as any, colDef, column, - }); + }; if (typeof valueParser === 'function') { return valueParser(params); } @@ -410,15 +410,9 @@ export class ValueService extends BeanStub implements NamedBean { } public getDeleteValue(column: AgColumn, rowNode: IRowNode): any { - if (_exists(column.colDef.valueParser)) { - return ( - this.parseValue( - column, - rowNode, - '', - this.getValueForDisplay({ column, node: rowNode, from: 'edit' }).value - ) ?? null - ); + const valueParser = column.colDef.valueParser; + if (valueParser != null && valueParser !== '') { + return this.parseValue(column, rowNode, '', this.getDisplayValue(column, rowNode, 'edit')) ?? null; } return null; } @@ -430,43 +424,35 @@ export class ValueService extends BeanStub implements NamedBean { suppliedFormatter?: (value: any) => string, useFormatterFromColumn = true ): string | null { - const { expressionSvc } = this.beans; let result: string | null = null; - let formatter: ((value: any) => string) | string | undefined; - - const colDef = column.colDef; - - if (suppliedFormatter) { - // use supplied formatter if provided, e.g. set filter items can have their own value formatters - formatter = suppliedFormatter; - } else if (useFormatterFromColumn) { - formatter = colDef.valueFormatter; - } - + // supplied formatter wins (e.g. set filter items have their own); otherwise use the column's + const formatter = suppliedFormatter ?? (useFormatterFromColumn ? column.valueFormatter : undefined); if (formatter) { - const data = node ? node.data : null; - - const params: ValueFormatterParams = _addGridCommonParams(this.gos, { + const params: ValueFormatterParams = { + api: this.gridApi, + context: this.gridOptions.context, value, node, - data, - colDef, + data: node ? node.data : null, + colDef: column.colDef, column, - }); + }; if (typeof formatter === 'function') { result = formatter(params); } else { + const expressionSvc = this.expressionSvc; result = expressionSvc ? expressionSvc.evaluate(formatter, params) : null; } - } else if (colDef.refData) { - return colDef.refData[value] || ''; + } else { + const refData = column.refData; + if (refData) { + return refData[value] || ''; + } } - // if we don't do this, then arrays get displayed as 1,2,3, but we want 1, 2, 3 (i.e. with spaces) if (result == null && Array.isArray(value)) { result = value.join(', '); } - return result; } @@ -481,7 +467,7 @@ export class ValueService extends BeanStub implements NamedBean { public setValue(rowNode: IRowNode, column: AgColumn, newValue: any, eventSource?: string): boolean { const colDef = column.colDef; - if (!rowNode.data && this.canCreateRowNodeData(rowNode, colDef)) { + if (!rowNode.data && canCreateRowNodeData(rowNode, colDef)) { rowNode.data = {}; // enableGroupEdit allows editing group rows without data. } @@ -490,20 +476,23 @@ export class ValueService extends BeanStub implements NamedBean { } // Get old value from stored data, ignoring any pending edit state - const oldValue = this.getValue(column, rowNode, 'data'); + const oldValue = this.getValueFromData(column, rowNode); - const params: ValueSetterParams = _addGridCommonParams(this.gos, { + const data = rowNode.data; + const params: ValueSetterParams = { + api: this.gridApi, + context: this.gridOptions.context, node: rowNode, - data: rowNode.data, + data, oldValue, newValue: newValue, colDef, column: column, - }); + }; let valueSetterChanged = false; - if (rowNode.data) { + if (data) { const externalFormulaResult = this.handleExternalFormulaChange({ column, eventSource, @@ -520,7 +509,7 @@ export class ValueService extends BeanStub implements NamedBean { rowNode, newValue, params, - rowData: rowNode.data, + rowData: data, valueSetter: colDef.valueSetter, field: colDef.field, }); @@ -574,28 +563,6 @@ export class ValueService extends BeanStub implements NamedBean { } } - private canCreateRowNodeData(rowNode: IRowNode, colDef: ColDef): boolean { - if (!rowNode.group) { - return true; // not a group row - } - - // If groupRowValueSetter or groupRowEditable is defined, do not create row data automatically. - // The user has explicitly configured group editing behavior. - if (colDef.groupRowValueSetter != null || colDef.groupRowEditable != null) { - return false; - } - - // For pivot columns (identified by pivotValueColumn), preserve legacy behavior: - // do not auto-create row data. In previous versions, pivot columns silently - // skipped value changes on group rows because we were not looking for them when calling setDataValue. - // Now we do, so we need to block auto-creation to avoid unexpected data mutations to not change behavior. - if (colDef.pivotValueColumn) { - return false; // Legacy behaviour - pivot groups do not auto-create data with pivot columns - } - - return true; - } - private finishValueChange( rowNode: IRowNode, column: AgColumn, @@ -609,11 +576,12 @@ export class ValueService extends BeanStub implements NamedBean { this.valueCache?.onDataChanged(); const savedValue = - savedValueOverride === undefined ? this.getValue(column, rowNode, 'data') : savedValueOverride; + savedValueOverride === undefined ? this.getValueFromData(column, rowNode) : savedValueOverride; this.dispatchCellValueChangedEvent(rowNode, params, savedValue, eventSource); - if ((rowNode as RowNode).pinnedSibling) { - this.dispatchCellValueChangedEvent((rowNode as RowNode).pinnedSibling!, params, savedValue, eventSource); + const pinnedSibling = (rowNode as RowNode).pinnedSibling; + if (pinnedSibling) { + this.dispatchCellValueChangedEvent(pinnedSibling, params, savedValue, eventSource); } return true; @@ -627,10 +595,12 @@ export class ValueService extends BeanStub implements NamedBean { } const formulaSvc = this.formula; - const isFormulaValue = column.colDef.allowFormula && formulaSvc?.isFormula(newValue); + const isFormulaValue = column.allowFormula && formulaSvc?.isFormula(newValue); const hasExternalFormulaData = !!this.formulaDataSvc?.hasDataSource(); - if (_missing(field) && _missing(valueSetter) && !(hasExternalFormulaData && isFormulaValue)) { + const fieldMissing = field == null || field === ''; + const valueSetterMissing = valueSetter == null || valueSetter === ''; + if (fieldMissing && valueSetterMissing && !(hasExternalFormulaData && isFormulaValue)) { // Group rows with groupRowValueSetter or groupRowEditable don't need field or valueSetter — // the groupRowValueSetter handles the edit entirely. if (rowNode.group && (colDef.groupRowValueSetter || colDef.groupRowEditable)) { @@ -658,7 +628,7 @@ export class ValueService extends BeanStub implements NamedBean { const { column, rowNode, newValue, eventSource, setterParams } = args; const formulaSvc = this.formula; const formulaDataSvc = this.formulaDataSvc; - if (!column.colDef.allowFormula || !formulaDataSvc?.hasDataSource()) { + if (!column.allowFormula || !formulaDataSvc?.hasDataSource()) { return null; } @@ -675,17 +645,16 @@ export class ValueService extends BeanStub implements NamedBean { // Store the computed value into rowData for consumers that do not understand formulas. const computedValue = formulaSvc?.resolveValue(column, rowNode as RowNode); - const colDef = column.colDef; - if (_exists(colDef.valueSetter) || !_missing(colDef.field)) { - const computedParams: ValueSetterParams = { ...setterParams, newValue: computedValue }; + const { valueSetter, field } = column.colDef; + if ((valueSetter != null && valueSetter !== '') || (field != null && field !== '')) { this.computeValueChange({ column, rowNode, newValue: computedValue, - params: computedParams, + params: { ...setterParams, newValue: computedValue }, rowData: rowNode.data, - valueSetter: colDef.valueSetter, - field: colDef.field, + valueSetter, + field, }); } @@ -710,14 +679,39 @@ export class ValueService extends BeanStub implements NamedBean { }): boolean | undefined { const { valueSetter, params: setterParams, rowData, field, column, newValue } = params; - if (_exists(valueSetter)) { + if (valueSetter != null && valueSetter !== '') { if (typeof valueSetter === 'function') { return valueSetter(setterParams); } return this.expressionSvc?.evaluate(valueSetter, setterParams); } - return !!rowData && this.setValueUsingField(rowData, field, newValue, column.fieldContainsDots); + if (!rowData || !field) { + return false; + } + const fieldPath = column.fieldPath; + let valuesAreSame = false; + if (fieldPath === null) { + valuesAreSame = rowData[field] === newValue; + if (!valuesAreSame) { + rowData[field] = newValue; + } + } else { + // deep value — walk the pre-split path (must not mutate the column's cached `fieldPath`) + let currentObject = rowData; + const lastIndex = fieldPath.length - 1; + for (let i = 0; i < lastIndex && currentObject; ++i) { + currentObject = currentObject[fieldPath[i]]; + } + if (currentObject) { + const lastPiece = fieldPath[lastIndex]; + valuesAreSame = currentObject[lastPiece] === newValue; + if (!valuesAreSame) { + currentObject[lastPiece] = newValue; + } + } + } + return !valuesAreSame; } private dispatchCellValueChangedEvent( @@ -746,127 +740,114 @@ export class ValueService extends BeanStub implements NamedBean { private callColumnCellValueChangedHandler(event: CellValueChangedEvent) { const onCellValueChanged = event.colDef.onCellValueChanged; if (typeof onCellValueChanged === 'function') { - this.beans.frameworkOverrides.wrapOutgoing(() => { - onCellValueChanged(event); - }); + this.frameworkOverrides.wrapOutgoing(() => onCellValueChanged(event)); } } - private setValueUsingField( - data: any, - field: string | undefined, - newValue: any, - isFieldContainsDots: boolean - ): boolean { - if (!field) { - return false; - } - - // if no '.', then it's not a deep value - let valuesAreSame: boolean = false; - if (!isFieldContainsDots) { - valuesAreSame = data[field] === newValue; - if (!valuesAreSame) { - data[field] = newValue; - } - } else { - // otherwise it is a deep value, so need to dig for it - const fieldPieces = field.split('.'); - let currentObject = data; - while (fieldPieces.length > 0 && currentObject) { - const fieldPiece: any = fieldPieces.shift(); - if (fieldPieces.length === 0) { - valuesAreSame = currentObject[fieldPiece] === newValue; - if (!valuesAreSame) { - currentObject[fieldPiece] = newValue; - } - } else { - currentObject = currentObject[fieldPiece]; - } - } - } - return !valuesAreSame; - } - - private executeValueGetterWithValueCache( + private executeValueGetter( valueGetter: string | ((...args: any[]) => any), data: any, column: AgColumn, rowNode: IRowNode ): any { - const colId = column.colId; - - const valueFromCache = this.valueCache!.getValue(rowNode as RowNode, colId); - if (valueFromCache !== undefined) { - return valueFromCache; + // valueCache is undefined unless caching is enabled (set in init), so it is the cache gate. + const valueCache = this.valueCache; + if (valueCache) { + const valueFromCache = valueCache.getValue(rowNode as RowNode, column.colId); + if (valueFromCache !== undefined) { + return valueFromCache; + } } - const result = this.executeValueGetterWithoutValueCache(valueGetter, data, column, rowNode); - - this.valueCache!.setValue(rowNode as RowNode, colId, result); - - return result; - } - - private executeValueGetterWithoutValueCache( - valueGetter: string | ((...args: any[]) => any), - data: any, - column: AgColumn, - rowNode: IRowNode - ): any { - const params: ValueGetterParams = _addGridCommonParams(this.gos, { + const params: ValueGetterParams = { + api: this.gridApi, + context: this.gridOptions.context, data: data, node: rowNode, column: column, colDef: column.colDef, getValue: (field) => this.getValueCallback(rowNode, field), - }); + }; - let result; - if (typeof valueGetter === 'function') { - result = valueGetter(params); - } else { - result = this.expressionSvc?.evaluate(valueGetter, params); - } + const result = + typeof valueGetter === 'function' ? valueGetter(params) : this.expressionSvc?.evaluate(valueGetter, params); + if (valueCache) { + valueCache.setValue(rowNode as RowNode, column.colId, result); + } return result; } private getValueCallback(node: IRowNode, field: string): any { const otherColumn = this.colModel.getCol(field); - return otherColumn ? this.getValue(otherColumn, node, 'data') : null; + return otherColumn ? this.getValueFromData(_resolvePivotColumnForRow(otherColumn, node), node) : null; } // used by row grouping and pivot, to get key for a row. col can be a pivot col or a row grouping col public getKeyForNode(col: AgColumn, rowNode: IRowNode): any { // Use 'data' - grouping keys should be based on committed data, not pending edits. // Row structure should remain stable during editing; rows only move groups when edits are committed. - const value = this.getValue(col, rowNode, 'data'); - const keyCreator = col.colDef.keyCreator; - - let result = value; + // col is always a pivot-BY or row-group column (never a pivot result column), so no pivot redirect. + let result = this.getValueFromData(col, rowNode); + const colDef = col.colDef; + const keyCreator = colDef.keyCreator; if (keyCreator) { - const keyParams: KeyCreatorParams = _addGridCommonParams(this.gos, { - value: value, - colDef: col.colDef, + const keyParams: KeyCreatorParams = { + api: this.gridApi, + context: this.gridOptions.context, + value: result, + colDef, column: col, node: rowNode, data: rowNode.data, - }); + }; result = keyCreator(keyParams); } - // if already a string, or missing, just return it if (typeof result === 'string' || result == null) { return result; } - result = String(result); - if (result === '[object Object]') { _warn(121); } - return result; } } + +const canCreateRowNodeData = (rowNode: IRowNode, colDef: ColDef): boolean => { + if (!rowNode.group) { + return true; // not a group row + } + // groupRowValueSetter/groupRowEditable mean the user configured group editing — don't auto-create data. + if (colDef.groupRowValueSetter != null || colDef.groupRowEditable != null) { + return false; + } + // Pivot columns must not auto-create group row data (would mutate data on group-row value changes). + if (colDef.pivotValueColumn) { + return false; + } + return true; +}; + +/** + * Empty value for a showRowGroup display col on a group row: `null` (retro-compat) when the row's group level + * is shallower than the col's associated row group, `undefined` otherwise. + */ +const groupDisplayColEmptyValue = (colModel: ColumnModel, rowGroupColId: string, level: number): null | undefined => { + const col = colModel.colsById[rowGroupColId]; + return col?.rowGroupActive && col.rowGroupActiveIndex > level ? null : undefined; +}; + +/** SSRM group footers mirror their group's field value. Caller guarantees SSRM and non-null `data`. */ +const readSsrmFooterGroupValue = (rowNode: IRowNode, data: any, rowGroupColId: string | boolean | undefined): any => { + if (!rowNode.footer) { + return undefined; + } + const rowField = rowNode.field; + if (!rowField || (rowGroupColId !== true && rowGroupColId !== rowField)) { + return undefined; + } + // Read the group's own field (`rowField`); the column's cached `fieldPath` is for the column's `field`. + return rowField.includes('.') ? _getValueUsingDotPath(data, rowField.split('.')) : data[rowField]; +}; diff --git a/packages/ag-grid-enterprise/src/aggregation/aggColumnNameService.ts b/packages/ag-grid-enterprise/src/aggregation/aggColumnNameService.ts index 63b76cf560d..c00977e3b63 100644 --- a/packages/ag-grid-enterprise/src/aggregation/aggColumnNameService.ts +++ b/packages/ag-grid-enterprise/src/aggregation/aggColumnNameService.ts @@ -14,7 +14,7 @@ export class AggColumnNameService extends BeanStub implements NamedBean, IAggCol const { valueColsSvc, colModel, rowGroupColsSvc } = this.beans; // only columns with aggregation active can have aggregations - const pivotValueColumn = column.colDef.pivotValueColumn; + const pivotValueColumn = column.pivotValueColumn; const pivotActiveOnThisColumn = _exists(pivotValueColumn); let aggFunc: ColAggFunc = null; let aggFuncFound: boolean; @@ -36,7 +36,7 @@ export class AggColumnNameService extends BeanStub implements NamedBean, IAggCol const aggregationPresent = colModel.pivotMode || isGrouping || this.gos.get('treeData'); if (measureActive && aggregationPresent) { - aggFunc = column.getAggFunc(); + aggFunc = column.aggFunc; aggFuncFound = true; } else { aggFuncFound = false; diff --git a/packages/ag-grid-enterprise/src/aggregation/aggregationStage.ts b/packages/ag-grid-enterprise/src/aggregation/aggregationStage.ts index 6d53a6d20d6..5fad4397b8a 100644 --- a/packages/ag-grid-enterprise/src/aggregation/aggregationStage.ts +++ b/packages/ag-grid-enterprise/src/aggregation/aggregationStage.ts @@ -39,6 +39,7 @@ interface ResolvedValueColumn { interface ResolvedPivotColumn { column: AgColumn; colId: string; + colDef: ColDef; aggFunc: IAggFunc | null; /** The secondary (pivot result) column produced by this aggregation. */ pivotResultCol: AgColumn; @@ -117,7 +118,7 @@ export class AggregationStage extends BeanStub implements NamedBean, _IRowNodeAg column: col, colId: col.colId, colDef: col.colDef, - aggFunc: resolveAggFunc(col.getAggFunc(), aggFuncSvc!, col), + aggFunc: resolveAggFunc(col.aggFunc, aggFuncSvc!, col), colSlot, }; } @@ -217,14 +218,14 @@ const aggregateValuesOnly = ( if (colValues !== null) { const vc = valueCols[j]; const v = childAggData[vc.colId]; - colValues[c] = v !== undefined ? v : valueSvc.getValue(vc.column, child, 'data'); + colValues[c] = v !== undefined ? v : valueSvc.getValueFromData(vc.column, child); } } } else { for (let j = 0; j < colCount; ++j) { const colValues = values2d[j]; if (colValues !== null) { - colValues[c] = valueSvc.getValue(valueCols[j].column, child, 'data'); + colValues[c] = valueSvc.getValueFromData(valueCols[j].column, child); } } } @@ -303,7 +304,7 @@ const aggregateValuesAndPivot = ( const nodeCount = aggregatedChildren.length; values = new Array(nodeCount); for (let n = 0; n < nodeCount; ++n) { - values[n] = valueSvc.getValue(column, aggregatedChildren[n], 'data'); + values[n] = valueSvc.getValueFromData(column, aggregatedChildren[n]); } } else { // Regular column on non-leaf group — read aggData from children directly. @@ -316,7 +317,7 @@ const aggregateValuesAndPivot = ( const childNode = aggregatedChildren[n]; const childAggData = childNode.aggData; const v = childAggData ? childAggData[colId] : undefined; - values[n] = v !== undefined ? v : valueSvc.getValue(column, childNode, 'data'); + values[n] = v !== undefined ? v : valueSvc.getValueFromData(column, childNode); } } @@ -325,7 +326,7 @@ const aggregateValuesAndPivot = ( ? aggFunc({ values, column, - colDef: column.colDef, + colDef: rc.colDef, pivotResultColumn: rc.pivotResultCol, rowNode, data, @@ -377,15 +378,16 @@ const resolvePivotColumns = ( let count = 0; for (let i = 0; i < len; ++i) { const pivotResultCol = orderedList[i]; - const resultColDef = pivotResultCol.colDef; - const valueCol = resultColDef.pivotValueColumn as AgColumn | null | undefined; + const valueCol = pivotResultCol.pivotValueColumn as AgColumn | null | undefined; if (!valueCol) { continue; } + const resultColDef = pivotResultCol.colDef; resolved[count++] = { column: valueCol, - colId: resultColDef.colId!, - aggFunc: resolveAggFunc(valueCol.getAggFunc(), aggFuncSvc, valueCol), + colId: pivotResultCol.colId, + colDef: valueCol.colDef, + aggFunc: resolveAggFunc(valueCol.aggFunc, aggFuncSvc, valueCol), pivotResultCol: pivotResultCol, pivotKeys: resultColDef.pivotKeys, totalColIds: resultColDef.pivotTotalColumnIds, diff --git a/packages/ag-grid-enterprise/src/aggregation/valueColsSvc.ts b/packages/ag-grid-enterprise/src/aggregation/valueColsSvc.ts index 66244803aad..5fe57592025 100644 --- a/packages/ag-grid-enterprise/src/aggregation/valueColsSvc.ts +++ b/packages/ag-grid-enterprise/src/aggregation/valueColsSvc.ts @@ -54,7 +54,7 @@ export class ValueColsSvc extends BaseColsService implements NamedBean, IValueCo protected override onColActiveChanged(column: AgColumn, active: boolean): void { // A newly-active col with no agg-func picks up the default for its cell-data type. const aggFuncSvc = this.aggFuncSvc; - if (active && !column.getAggFunc() && aggFuncSvc) { + if (active && aggFuncSvc && !column.aggFunc) { this.writeAggFunc(column, aggFuncSvc.getDefaultAggFunc(column)); } } diff --git a/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnForm.ts b/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnForm.ts index a61f360849d..07ec4445530 100644 --- a/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnForm.ts +++ b/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnForm.ts @@ -120,7 +120,9 @@ export class CalculatedColumnForm extends Component { private activeReplacement: { start: number; end: number } | null = null; private suggestionSource: HTMLElement | null = null; private hideSuggestionPopup: (() => void) | undefined; - private validationTooltipFeature?: TooltipFeature; + private titleTooltipFeature?: TooltipFeature; + private expressionTooltipFeature?: TooltipFeature; + private titleValidationMessage: string | null = null; private expressionValidationMessage: string | null = null; private readonly expressionPickers: ReadonlySet; /** The open suggestion list, recreated whenever the picker type (column/function/operator) changes. */ @@ -155,7 +157,7 @@ export class CalculatedColumnForm extends Component { this.setupActionButtons(); if (!this.liveApply) { - this.setupValidationTooltip(); + this.setupValidationTooltips(); } this.addFormFieldListeners(); @@ -224,7 +226,16 @@ export class CalculatedColumnForm extends Component { private addFormFieldListeners(): void { const initialHeaderName = this.draft.headerName; - this.eTitle.onValueChange((value) => this.updateDraft({ headerName: value || initialHeaderName })); + this.eTitle.onValueChange((value) => { + // Live mode applies every change to the column, so an empty title falls back to the + // initial one; deferred mode keeps the raw value and requires a title before Apply. + if (this.liveApply) { + this.updateDraft({ headerName: value || initialHeaderName }); + return; + } + this.updateDraft({ headerName: value ?? '' }); + this.setTitleError(this.validateTitle()); + }); this.eType.onValueChange((value) => { this.updateDraft({ cellDataType: value ?? this.dataTypeOptions[0]?.value ?? DEFAULT_DRAFT.cellDataType }); }); @@ -272,7 +283,13 @@ export class CalculatedColumnForm extends Component { } if (!this.liveApply) { this.addManagedElementListeners(this.eApply, { - click: () => this.setExpressionError(this.onApply(this.draft)), + click: () => { + const titleError = this.validateTitle(); + this.setTitleError(titleError); + if (titleError == null) { + this.setExpressionError(this.onApply(this.draft)); + } + }, }); this.addManagedElementListeners(this.eCancel, { click: () => this.onCancel(), @@ -296,27 +313,57 @@ export class CalculatedColumnForm extends Component { }); } + private validateTitle(): string | null { + if (this.draft.headerName.trim().length > 0) { + return null; + } + return this.getLocaleTextFunc()('calculatedColumnTitleEmpty', 'Enter a title'); + } + + private setTitleError(message: string | null): void { + this.titleValidationMessage = message; + this.applyFieldError(this.eTitle.getInputElement(), message); + this.titleTooltipFeature?.setTooltipAndRefresh(message); + } + private setExpressionError(message: string | null): void { - const input = this.eExpression.getInputElement(); + this.expressionValidationMessage = message; + this.applyFieldError(this.eExpression.getInputElement(), message); + this.expressionTooltipFeature?.setTooltipAndRefresh(message); + } + + private applyFieldError(input: HTMLInputElement | HTMLTextAreaElement, message: string | null): void { const isInvalid = !!message; input.setCustomValidity(message ?? ''); input.classList.toggle('invalid', isInvalid); input.toggleAttribute('aria-invalid', isInvalid); - this.expressionValidationMessage = message; - this.eApply.disabled = isInvalid; - this.validationTooltipFeature?.setTooltipAndRefresh(message); + this.eApply.disabled = !!this.titleValidationMessage || !!this.expressionValidationMessage; // set title to empty string to prevent default browser tooltip from showing when validation tooltip is active input.setAttribute('title', ''); } - private setupValidationTooltip(): void { - this.validationTooltipFeature = this.createOptionalManagedBean( + private setupValidationTooltips(): void { + this.titleTooltipFeature = this.createValidationTooltip( + () => this.eTitle.getInputElement(), + () => this.titleValidationMessage + ); + this.expressionTooltipFeature = this.createValidationTooltip( + () => this.eExpression.getInputElement(), + () => this.expressionValidationMessage + ); + } + + private createValidationTooltip( + getGui: () => HTMLElement, + getMessage: () => string | null + ): TooltipFeature | undefined { + return this.createOptionalManagedBean( this.beans.registry.createDynamicBean('tooltipFeature', false, { - getGui: () => this.eExpression.getInputElement(), - getTooltipValue: () => this.expressionValidationMessage, + getGui, + getTooltipValue: getMessage, getLocation: () => 'calculatedColumnExpression', - shouldDisplayTooltip: () => !!this.expressionValidationMessage, + shouldDisplayTooltip: () => !!getMessage(), } as ITooltipCtrl) ); } diff --git a/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumns.css b/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumns.css index 8bdd00a36ad..fd2508825cb 100644 --- a/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumns.css +++ b/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumns.css @@ -88,7 +88,6 @@ display: flex; justify-content: flex-end; gap: calc(var(--ag-spacing) / 2); - margin-top: var(--ag-spacing); } .ag-calculated-column-action { diff --git a/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnsService.ts b/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnsService.ts index c1e42d15030..bc9d8db60d8 100644 --- a/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnsService.ts +++ b/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnsService.ts @@ -544,7 +544,7 @@ export class CalculatedColumnsService extends BeanStub implements NamedBean, ICa if (!_isStringLargerThan(nextDraft.calculatedExpression, 0, true)) { return { valid: false, - error: this.getLocaleTextFunc()('calculatedColumnExpressionEmpty', 'Enter an expression.'), + error: this.getLocaleTextFunc()('calculatedColumnExpressionEmpty', 'Enter an expression'), }; } const result = mapper.toInternalExpression(nextDraft.calculatedExpression); diff --git a/packages/ag-grid-enterprise/src/charts/chartComp/model/chartDataModel.ts b/packages/ag-grid-enterprise/src/charts/chartComp/model/chartDataModel.ts index e76ac6be41d..e07afad9f72 100644 --- a/packages/ag-grid-enterprise/src/charts/chartComp/model/chartDataModel.ts +++ b/packages/ag-grid-enterprise/src/charts/chartComp/model/chartDataModel.ts @@ -14,7 +14,13 @@ import type { SortModelItem, SortOption, } from 'ag-grid-community'; -import { BeanStub, CellRangeType, _normalizeSortType, isColumnGroupAutoCol } from 'ag-grid-community'; +import { + BeanStub, + CellRangeType, + _normalizeSortType, + _resolveSortOptions, + isColumnGroupAutoCol, +} from 'ag-grid-community'; import type { ChartDatasourceParams } from '../datasource/chartDatasource'; import { ChartDatasource } from '../datasource/chartDatasource'; @@ -667,9 +673,14 @@ export class ChartDataModel extends BeanStub { sort, column, type: _normalizeSortType(column.getSortDef()?.type), + colComparator: undefined, + leafComparator: undefined, + descending: false, + absolute: false, }); } }); + _resolveSortOptions(sortOptions, this.beans.colModel); return sortOptions; } } diff --git a/packages/ag-grid-enterprise/src/clipboard/clipboardService.ts b/packages/ag-grid-enterprise/src/clipboard/clipboardService.ts index 9054832b43c..2ca0afda212 100644 --- a/packages/ag-grid-enterprise/src/clipboard/clipboardService.ts +++ b/packages/ag-grid-enterprise/src/clipboard/clipboardService.ts @@ -560,7 +560,7 @@ export class ClipboardService extends BeanStub implements NamedBean, IClipboardS return; } - const isFormula = column.colDef.allowFormula && formula?.isFormula(firstRowValues[index]); + const isFormula = column.allowFormula && formula?.isFormula(firstRowValues[index]); if (isFormula) { firstRowValues[index] = formula?.updateFormulaByOffset({ diff --git a/packages/ag-grid-enterprise/src/columnToolPanel/columnToolPanel.ts b/packages/ag-grid-enterprise/src/columnToolPanel/columnToolPanel.ts index 2846f9435e0..3703f9fe46e 100644 --- a/packages/ag-grid-enterprise/src/columnToolPanel/columnToolPanel.ts +++ b/packages/ag-grid-enterprise/src/columnToolPanel/columnToolPanel.ts @@ -283,7 +283,7 @@ export class ColumnToolPanel extends Component implements IColumnToolPanel, IToo .getCols() .filter((c) => c.getSort()) .map((c) => `${c.colId}:${c.getSort()}:${c.getSortIndex()}`), - aggFuncState: (beans.valueColsSvc?.columns ?? []).map((c) => c.getAggFunc()), + aggFuncState: (beans.valueColsSvc?.columns ?? []).map((c) => c.aggFunc), widthState: beans.colModel.getCols().map((c) => `${c.colId}:${c.getActualWidth()}`), }; } diff --git a/packages/ag-grid-enterprise/src/columnToolPanel/modelItemUtils.ts b/packages/ag-grid-enterprise/src/columnToolPanel/modelItemUtils.ts index bfa447ae003..39a947bbe76 100644 --- a/packages/ag-grid-enterprise/src/columnToolPanel/modelItemUtils.ts +++ b/packages/ag-grid-enterprise/src/columnToolPanel/modelItemUtils.ts @@ -113,8 +113,7 @@ function setAllPivotActive( } if (col.isAllowValue()) { - const aggFunc = - typeof col.getAggFunc() === 'string' ? col.getAggFunc() : beans.aggFuncSvc?.getDefaultAggFunc(col); + const aggFunc = typeof col.aggFunc === 'string' ? col.aggFunc : beans.aggFuncSvc?.getDefaultAggFunc(col); colStateItems.push({ colId: col.getId(), aggFunc: aggFunc, @@ -199,7 +198,7 @@ function createPivotState(column: AgColumn): { return { pivot: column.isPivotActive(), rowGroup: column.isRowGroupActive(), - aggFunc: column.isValueActive() ? column.getAggFunc() : undefined, + aggFunc: column.isValueActive() ? column.aggFunc : undefined, }; } diff --git a/packages/ag-grid-enterprise/src/excelExport/excelSerializingSession.ts b/packages/ag-grid-enterprise/src/excelExport/excelSerializingSession.ts index 6122c48ff6e..2cdf0bf3131 100644 --- a/packages/ag-grid-enterprise/src/excelExport/excelSerializingSession.ts +++ b/packages/ag-grid-enterprise/src/excelExport/excelSerializingSession.ts @@ -433,7 +433,7 @@ export class ExcelSerializingSession extends BaseGridSerializingSession 'A' } as any, valueSvc: { getValueForDisplay: () => ({ value: '' }), + getDisplayValue: () => '', getValue: () => '', parseValue: () => '', formatValue: () => '', @@ -69,6 +70,7 @@ const rowValueServiceStub = () => return { value }; }, + getDisplayValue: (column: any, node: any) => node?.data?.[column.getColId()], getValue: (column: any, node: any) => node?.data?.[column.getColId()], parseValue: (_column: any, _node: any, valueToParse: any) => valueToParse, formatValue: (_column: any, _node: any, valueToFormat: any) => valueToFormat, @@ -269,7 +271,7 @@ describe('excelXlsxFactory Workbook', () => { session.addCustomContent([ { - cells: [{ data: { value: '' }, styleId: 'numeric' }], + cells: [{ data: { value: '' } as any, styleId: 'numeric' }], }, ]); @@ -655,7 +657,7 @@ describe('excelXlsxFactory Workbook', () => { imageType: 'png', width: 20, height: 20, - }, + } as any, }, ], }, diff --git a/packages/ag-grid-enterprise/src/find/findService.ts b/packages/ag-grid-enterprise/src/find/findService.ts index 11d97473ff0..d29c92e1f2b 100644 --- a/packages/ag-grid-enterprise/src/find/findService.ts +++ b/packages/ag-grid-enterprise/src/find/findService.ts @@ -438,7 +438,7 @@ export class FindService extends BeanStub implements NamedBean, IFindService { let valueToFind: string | null; const getFindText = (groupRowRendererParams as FindGroupRowRendererParams)?.getFindText; if (getFindText) { - const value = valueSvc.getValueForDisplay({ node, from: 'batch' }).value; + const value = valueSvc.getDisplayValue(undefined, node, 'batch'); valueToFind = getFindText( _addGridCommonParams(gos, { value, @@ -487,7 +487,7 @@ export class FindService extends BeanStub implements NamedBean, IFindService { // if node will be hidden by open parent, don't match on showRowGroup cols // as the cell does not have that value yet - if (column.colDef.showRowGroup && nodeWillBeHiddenByOpenParent) { + if (column.showRowGroup && nodeWillBeHiddenByOpenParent) { continue; } @@ -496,7 +496,7 @@ export class FindService extends BeanStub implements NamedBean, IFindService { const colDef = column.colDef; const getFindText = colDef.getFindText; if (getFindText) { - const value = valueSvc.getValueForDisplay({ column, node, from: 'batch' }).value; + const value = valueSvc.getDisplayValue(column, node, 'batch'); valueToFind = getFindText( _addGridCommonParams(gos, { value, diff --git a/packages/ag-grid-enterprise/src/formula/formulaService.ts b/packages/ag-grid-enterprise/src/formula/formulaService.ts index 3bcd30bfeb9..111cb9d1915 100644 --- a/packages/ag-grid-enterprise/src/formula/formulaService.ts +++ b/packages/ag-grid-enterprise/src/formula/formulaService.ts @@ -113,7 +113,7 @@ export class FormulaService extends BeanStub implements IFormulaService, NamedBe let calculatedColumnsPresent = false; for (let i = 0, len = columns.length; i < len; ++i) { const col = columns[i]; - if (col.isAllowFormula()) { + if (col.allowFormula) { editableFormulaColumnsPresent = true; } if (col.isCalculatedCol) { @@ -189,7 +189,7 @@ export class FormulaService extends BeanStub implements IFormulaService, NamedBe _warn(295, { blockedService: 'Row Groups' }); return false; } - if (col.isAllowValue() || col.isValueActive() || col.getAggFunc()) { + if (col.isAllowValue() || col.isValueActive() || col.aggFunc) { _warn(295, { blockedService: 'Value Aggregation' }); return false; } @@ -629,12 +629,12 @@ export class FormulaService extends BeanStub implements IFormulaService, NamedBe public ensureCellFormula(row: RowNode, col: AgColumn): CellFormula | null { const active = this.active; const calculatedColumnsActive = this.calculatedColumnsActive; - if (active && col.isAllowFormula()) { + if (active && col.allowFormula) { if (!calculatedColumnsActive) { return this.ensureEditableCellFormula(row, col); } - const calculatedExpression = col.colDef.calculatedExpression; + const calculatedExpression = col.calculatedExpression; return calculatedExpression === undefined ? this.ensureEditableCellFormula(row, col) : this.ensureCalculatedCellFormula(row, col, calculatedExpression ?? ''); @@ -644,7 +644,7 @@ export class FormulaService extends BeanStub implements IFormulaService, NamedBe return null; } - const calculatedExpression = col.colDef.calculatedExpression; + const calculatedExpression = col.calculatedExpression; return calculatedExpression === undefined ? null : this.ensureCalculatedCellFormula(row, col, calculatedExpression ?? ''); @@ -741,10 +741,12 @@ export class FormulaService extends BeanStub implements IFormulaService, NamedBe /** Fetch a non-formula value from the grid without triggering nested formula calc. */ private fetchRawValue(col: AgColumn, row: RowNode): unknown { if (col.isCalculatedCol) { - return col.colDef.calculatedExpression?.trim() ? undefined : ''; + return col.calculatedExpression?.trim() ? undefined : ''; } - return this.beans.valueSvc.getValue(col, row, 'data'); + // Calculated columns are incompatible with pivot (see checkForCalculatedColumnIncompatibleServices), + // so `col` is never a pivot result column here — no redirect needed. + return this.beans.valueSvc.getValueFromData(col, row); } /** diff --git a/packages/ag-grid-enterprise/src/groupHierarchy/groupHierarchyColService.ts b/packages/ag-grid-enterprise/src/groupHierarchy/groupHierarchyColService.ts index edf7e4f0a65..61f5c7588c9 100644 --- a/packages/ag-grid-enterprise/src/groupHierarchy/groupHierarchyColService.ts +++ b/packages/ag-grid-enterprise/src/groupHierarchy/groupHierarchyColService.ts @@ -200,7 +200,8 @@ export class GroupHierarchyColService extends BeanStub implements NamedBean, IGr return _addColumnDefaultAndTypes(beans, part, colId, true); } - const defaults: Partial = { hide: true, editable: false }; + // hierarchy cols inherit the source col's pivot affordance so they stay draggable to the pivot area + const defaults: Partial = { enablePivot: sourceCol.colDef.enablePivot, hide: true, editable: false }; const config = gos.get('groupHierarchyConfig') ?? {}; if (part in config) { diff --git a/packages/ag-grid-enterprise/src/groupHierarchy/groupHierarchyUtils.ts b/packages/ag-grid-enterprise/src/groupHierarchy/groupHierarchyUtils.ts index b9e4db296fa..cdd7b0379ae 100644 --- a/packages/ag-grid-enterprise/src/groupHierarchy/groupHierarchyUtils.ts +++ b/packages/ag-grid-enterprise/src/groupHierarchy/groupHierarchyUtils.ts @@ -7,7 +7,7 @@ const getDate = ( sourceCol: AgColumn, node: IRowNode | null ): Date | null => { - const innerValue = valueSvc.getValue(sourceCol, node, 'data'); + const innerValue = node ? valueSvc.getValueFromData(sourceCol, node) : undefined; let date: Date | null = null; if (innerValue instanceof Date) { date = innerValue; diff --git a/packages/ag-grid-enterprise/src/menu/contextMenu.ts b/packages/ag-grid-enterprise/src/menu/contextMenu.ts index 8af890a6b72..d3f92295d85 100644 --- a/packages/ag-grid-enterprise/src/menu/contextMenu.ts +++ b/packages/ag-grid-enterprise/src/menu/contextMenu.ts @@ -197,7 +197,7 @@ export class ContextMenuService extends BeanStub implements NamedBean, IContextM let { anchorToElement, value, source, noteParams } = params; if (rowNode && column && value == null) { - value = this.beans.valueSvc.getValueForDisplay({ column, node: rowNode, from: 'edit' }).value; + value = this.beans.valueSvc.getDisplayValue(column, rowNode, 'edit'); } if (anchorToElement == null) { diff --git a/packages/ag-grid-enterprise/src/menu/menuItemMapper.ts b/packages/ag-grid-enterprise/src/menu/menuItemMapper.ts index cf6fd81c5b6..bb06d9c92fa 100644 --- a/packages/ag-grid-enterprise/src/menu/menuItemMapper.ts +++ b/packages/ag-grid-enterprise/src/menu/menuItemMapper.ts @@ -233,7 +233,7 @@ export class MenuItemMapper extends BeanStub implements NamedBean { } : null; case 'valueAggSubMenu': - if (aggFuncSvc && valueColsSvc && (column?.primary || column?.colDef.pivotValueColumn)) { + if (aggFuncSvc && valueColsSvc && (column?.primary || column?.pivotValueColumn)) { return { name: localeTextFunc('valueAggregation', 'Value Aggregation'), icon: _createIconNoSpan('menuValue', beans, null), @@ -280,7 +280,7 @@ export class MenuItemMapper extends BeanStub implements NamedBean { : null; case 'rowUnGroup': { if (rowGroupColsSvc && gos.isModuleRegistered('SharedRowGrouping')) { - const showRowGroup = column?.colDef.showRowGroup; + const showRowGroup = column?.showRowGroup; const lockedGroups = gos.get('groupLockGroupColumns'); let name: string; let disabled: boolean; @@ -664,7 +664,7 @@ function createAggregationSubMenu( if (column.primary) { columnToUse = column; } else { - const pivotValueColumn = column.colDef.pivotValueColumn as AgColumn; + const pivotValueColumn = column.pivotValueColumn as AgColumn; columnToUse = _exists(pivotValueColumn) ? pivotValueColumn : undefined; } @@ -689,7 +689,7 @@ function createAggregationSubMenu( valueColsSvc.setColumnAggFunc!(columnToUse, funcName, 'contextMenu'); valueColsSvc.addColumns([columnToUse!], 'contextMenu'); }, - checked: columnIsAlreadyAggValue && columnToUse.getAggFunc() === funcName, + checked: columnIsAlreadyAggValue && columnToUse.aggFunc === funcName, }); } } diff --git a/packages/ag-grid-enterprise/src/pivot/pivotColDefService.ts b/packages/ag-grid-enterprise/src/pivot/pivotColDefService.ts index dd10698d6b6..e8e9912d703 100644 --- a/packages/ag-grid-enterprise/src/pivot/pivotColDefService.ts +++ b/packages/ag-grid-enterprise/src/pivot/pivotColDefService.ts @@ -223,7 +223,7 @@ export class PivotColDefService extends BeanStub implements NamedBean, IPivotCol totalColDef.columnGroupShow = !isSuppressExpand ? 'closed' : 'open'; - totalColDef.aggFunc = valueColumn.getAggFunc(); + totalColDef.aggFunc = valueColumn.aggFunc; if (!leafGroup || hasCollapsedLeafGroup) { // add total colDef to group and pivot colDefs array @@ -239,12 +239,12 @@ export class PivotColDefService extends BeanStub implements NamedBean, IPivotCol } // check that value column exists, i.e. aggFunc is supplied - if (!def.pivotValueColumn) { + const pivotValueColumn = def.pivotValueColumn as AgColumn | null | undefined; + if (!pivotValueColumn) { return; } - const pivotValueColId = def.pivotValueColumn.getColId(); - + const pivotValueColId = pivotValueColumn.colId; const exists = acc.has(pivotValueColId); if (exists) { const arr = acc.get(pivotValueColId)!; @@ -267,7 +267,7 @@ export class PivotColDefService extends BeanStub implements NamedBean, IPivotCol const insertAfter = this.gos.get('pivotColumnGroupTotals') === 'after'; const valueCols = this.valueColsSvc?.columns; - const aggFuncs = valueCols?.map((valueCol) => valueCol.getAggFunc()); + const aggFuncs = valueCols?.map((valueCol) => valueCol.aggFunc); // don't add pivot totals if there is less than 1 aggFunc or they are not all the same if (!aggFuncs || aggFuncs.length < 1 || !this.sameAggFuncs(aggFuncs)) { @@ -315,7 +315,7 @@ export class PivotColDefService extends BeanStub implements NamedBean, IPivotCol //create total colDef using an arbitrary value column as a template const totalColDef = this.createColDef(valueColumn, headerName, groupDef.pivotKeys, true); totalColDef.pivotTotalColumnIds = colIds; - totalColDef.aggFunc = valueColumn.getAggFunc(); + totalColDef.aggFunc = valueColumn.aggFunc; totalColDef.columnGroupShow = this.gos.get('suppressExpandablePivotGroups') ? 'open' : undefined; // add total colDef to group and pivot colDefs array @@ -528,7 +528,7 @@ export class PivotColDefService extends BeanStub implements NamedBean, IPivotCol const headerName = this.colNames.getDisplayNameForColumn(potentialAggCol, 'header') ?? key; const colDef = this.createColDef(potentialAggCol, headerName, undefined, false); colDef.colId = id; - colDef.aggFunc = potentialAggCol.getAggFunc(); + colDef.aggFunc = potentialAggCol.aggFunc; colDef.valueGetter = (params) => params.data?.[id]; return colDef; } diff --git a/packages/ag-grid-enterprise/src/pivot/pivotResultColsService.ts b/packages/ag-grid-enterprise/src/pivot/pivotResultColsService.ts index b2f0839bb61..a5b2aaba7ae 100644 --- a/packages/ag-grid-enterprise/src/pivot/pivotResultColsService.ts +++ b/packages/ag-grid-enterprise/src/pivot/pivotResultColsService.ts @@ -62,8 +62,7 @@ export class PivotResultColsService extends BeanStub implements NamedBean, IPivo const valueColumnToFind = this.colModel.getNonPivotCol(valueColKey); for (let i = 0, len = pivotCols.length; i < len; ++i) { const column = pivotCols[i]; - const colDef = column.colDef; - if (colDef.pivotValueColumn === valueColumnToFind && _areEqual(colDef.pivotKeys, pivotKeys)) { + if (column.pivotValueColumn === valueColumnToFind && _areEqual(column.colDef.pivotKeys, pivotKeys)) { return column; } } @@ -237,7 +236,7 @@ export class PivotResultColsService extends BeanStub implements NamedBean, IPivo map = new Map(); for (let i = 0, len = cols.length; i < len; ++i) { const pivotCol = cols[i]; - const src = pivotCol.colDef.pivotValueColumn as AgColumn | null | undefined; + const src = pivotCol.pivotValueColumn as AgColumn | null | undefined; if (src == null) { continue; } diff --git a/packages/ag-grid-enterprise/src/pivot/pivotStage.ts b/packages/ag-grid-enterprise/src/pivot/pivotStage.ts index 9b0a81de06b..02068220ae6 100644 --- a/packages/ag-grid-enterprise/src/pivot/pivotStage.ts +++ b/packages/ag-grid-enterprise/src/pivot/pivotStage.ts @@ -110,7 +110,7 @@ export class PivotStage extends BeanStub implements NamedBean, _IRowNodePivotSta const aggregationColumnsHash = aggregationColumns .map((column) => `${column.getId()}-${column.colDef.headerName}`) .join('#'); - const aggregationFuncsHash = aggregationColumns.map((column) => column.getAggFunc()!.toString()).join('#'); + const aggregationFuncsHash = aggregationColumns.map((column) => column.aggFunc?.toString()).join('#'); const aggregationColumnsChanged = this.aggregationColumnsHashLastTime !== aggregationColumnsHash; const aggregationFuncsChanged = this.aggregationFuncsHashLastTime !== aggregationFuncsHash; diff --git a/packages/ag-grid-enterprise/src/rangeSelection/agFillHandle.ts b/packages/ag-grid-enterprise/src/rangeSelection/agFillHandle.ts index 4054ec0d9bc..aec74900eb2 100644 --- a/packages/ag-grid-enterprise/src/rangeSelection/agFillHandle.ts +++ b/packages/ag-grid-enterprise/src/rangeSelection/agFillHandle.ts @@ -522,7 +522,7 @@ export class AgFillHandle extends AbstractSelectionHandle { const { value: cyclicValue, column: sourceCol, rowNode: sourceRowNode } = values[idx % values.length]; let processedValue: any; - const fromFormula = sourceCol.isAllowFormula() && formula?.isFormula(valueForFunctions); + const fromFormula = sourceCol.allowFormula && formula?.isFormula(valueForFunctions); if (fromFormula) { // Compute the row and column delta based on drag direction diff --git a/packages/ag-grid-enterprise/src/rowGrouping/columnDropZones/rowGroupDropZonePanel.ts b/packages/ag-grid-enterprise/src/rowGrouping/columnDropZones/rowGroupDropZonePanel.ts index be6819be66c..74779bbcfc7 100644 --- a/packages/ag-grid-enterprise/src/rowGrouping/columnDropZones/rowGroupDropZonePanel.ts +++ b/packages/ag-grid-enterprise/src/rowGrouping/columnDropZones/rowGroupDropZonePanel.ts @@ -40,7 +40,7 @@ export class RowGroupDropZonePanel extends BaseDropZonePanel implements Focusabl protected isItemDroppable(column: AgColumn, draggingEvent: GridDraggingEvent): boolean { // we never allow grouping of secondary columns or already-grouped columns - if (this.gos.get('functionsReadOnly') || !column.primary || column.colDef.showRowGroup) { + if (this.gos.get('functionsReadOnly') || !column.primary || column.showRowGroup) { return false; } diff --git a/packages/ag-grid-enterprise/src/rowGrouping/groupStrategy/groupStrategy.ts b/packages/ag-grid-enterprise/src/rowGrouping/groupStrategy/groupStrategy.ts index 7740c541bd8..8957ee7ba83 100644 --- a/packages/ag-grid-enterprise/src/rowGrouping/groupStrategy/groupStrategy.ts +++ b/packages/ag-grid-enterprise/src/rowGrouping/groupStrategy/groupStrategy.ts @@ -1,9 +1,16 @@ import type { AgColumn, + BeanCollection, ChangedPath, + ColumnModel, + IClientSideRowModel, + IRowGroupColsService, IRowNode, + ISelectionService, + IShowRowGroupColsService, InitialGroupOrderComparator, RefreshModelParams, + ValueService, _ChangedRowNodes, } from 'ag-grid-community'; import { BeanStub, RowNode, _csrmFirstLeaf, _forEachChangedGroupDepthFirst, _warn } from 'ag-grid-community'; @@ -23,10 +30,27 @@ export class GroupStrategy extends BeanStub implements IRowGroupingStrategy { private readonly groupCols: GroupColumn[] = []; public readonly nonLeafsById = new Map(); - private checkGroupCols: boolean = true; + private pivotMode: boolean = false; private groupEmpty: boolean = false; + private colModel: ColumnModel; + private rowModel: IClientSideRowModel; + private checkGroupCols: boolean = true; + private valueSvc: ValueService; + private selectionSvc: ISelectionService | undefined; + private showRowGroupCols: IShowRowGroupColsService | undefined; + private rowGroupColsSvc: IRowGroupColsService | undefined; + + public wireBeans(beans: BeanCollection): void { + this.colModel = beans.colModel; + this.rowModel = beans.rowModel as IClientSideRowModel; + this.valueSvc = beans.valueSvc; + this.selectionSvc = beans.selectionSvc; + this.showRowGroupCols = beans.showRowGroupCols; + this.rowGroupColsSvc = beans.rowGroupColsSvc; + } + public invalidateGroupCols(): void { this.checkGroupCols = true; } @@ -52,7 +76,7 @@ export class GroupStrategy extends BeanStub implements IRowGroupingStrategy { } const rowGroupCol = node.rowGroupColumn; - const { valueSvc, showRowGroupCols } = this.beans; + const { valueSvc, showRowGroupCols } = this; const groupData: Record = {}; node._groupData = groupData; @@ -73,7 +97,7 @@ export class GroupStrategy extends BeanStub implements IRowGroupingStrategy { // if rowGroupColumn is present, then it's grid row grouping and we only include if configuration says so if (col.isRowGroupDisplayed(rowGroupColId)) { // if maintain group value type, get the value from any leaf node. - groupData[col.colId] = valueSvc.getValue(rowGroupCol, leafNode, 'data'); + groupData[col.colId] = leafNode && valueSvc.getValueFromData(rowGroupCol, leafNode); } } @@ -95,7 +119,7 @@ export class GroupStrategy extends BeanStub implements IRowGroupingStrategy { this.positionLeafsAndGroups(rootNode, changedPath); this.orderGroups(rootNode); - this.beans.selectionSvc?.updateSelectableAfterGrouping(changedPath); + this.selectionSvc?.updateSelectableAfterGrouping(changedPath); } private positionLeafsAndGroups(rootNode: RowNode, changedPath: ChangedPath | undefined) { @@ -138,7 +162,7 @@ export class GroupStrategy extends BeanStub implements IRowGroupingStrategy { } private initRefresh(params: RefreshModelParams): 'skip' | 'refresh' | 'groupColsChanged' { - const { rowGroupColsSvc, colModel, gos } = this.beans; + const { rowGroupColsSvc, colModel, gos } = this; this.pivotMode = colModel.pivotMode; this.groupEmpty = this.pivotMode || !gos.get('groupAllowUnbalanced'); @@ -318,7 +342,7 @@ export class GroupStrategy extends BeanStub implements IRowGroupingStrategy { // we do this multiple times, as when we remove groups, that means the parent of just removed // group can then be empty. to get around this, if we remove, then we check everything again for // newly emptied groups. the max number of times this will execute is the depth of the group tree. - const selectionSvc = this.beans.selectionSvc; + const selectionSvc = this.selectionSvc; let nodesToUnselect: RowNode[] | undefined; const possibleEmptyGroups = Array.from(parents); const groupsById = this.nonLeafsById; @@ -447,7 +471,7 @@ export class GroupStrategy extends BeanStub implements IRowGroupingStrategy { /** Remove and destroy group nodes that were not reused (still have childrenAfterGroup === null) */ private destroyStaleGroups(groupsById: Map): void { - const selectionSvc = this.beans.selectionSvc; + const selectionSvc = this.selectionSvc; let nodesToDeselect: RowNode[] | undefined; for (const [id, node] of groupsById) { if (node.childrenAfterGroup !== null) { @@ -470,8 +494,7 @@ export class GroupStrategy extends BeanStub implements IRowGroupingStrategy { private insertOneNode(rootNode: RowNode, childNode: RowNode): void { let parentGroup = rootNode; - const { beans, groupCols, groupEmpty } = this; - const valueSvc = beans.valueSvc; + const { valueSvc, groupCols, groupEmpty } = this; if (!groupCols) { return; } @@ -558,7 +581,7 @@ export class GroupStrategy extends BeanStub implements IRowGroupingStrategy { applyValuesToNode(node); node.field = groupCol.field ?? null; node.rowGroupColumn = col; - node.groupValue = this.beans.valueSvc.getValue(col, leafNode, 'data'); + node.groupValue = this.valueSvc.getValueFromData(col, leafNode); // null triggers lazy default resolution in the expanded getter. node._expanded ??= null; @@ -584,12 +607,12 @@ export class GroupStrategy extends BeanStub implements IRowGroupingStrategy { } public onShowRowGroupColsSetChanged(): void { - const { rowModel, valueSvc } = this.beans; + const { rowModel, valueSvc } = this; for (const groupNode of this.nonLeafsById.values()) { groupNode._groupData = undefined; const rowGroupColumn = groupNode.rowGroupColumn; const leafNode = rowGroupColumn && _csrmFirstLeaf(groupNode); - groupNode.groupValue = leafNode && valueSvc.getValue(rowGroupColumn, leafNode, 'data'); + groupNode.groupValue = leafNode && valueSvc.getValueFromData(rowGroupColumn, leafNode); } const allLeafs = rowModel.rootNode?._leafs; diff --git a/packages/ag-grid-enterprise/src/rowHierarchy/groupEditService.ts b/packages/ag-grid-enterprise/src/rowHierarchy/groupEditService.ts index 1a10ad57f28..6de68e84b89 100644 --- a/packages/ag-grid-enterprise/src/rowHierarchy/groupEditService.ts +++ b/packages/ag-grid-enterprise/src/rowHierarchy/groupEditService.ts @@ -469,7 +469,7 @@ export class GroupEditService extends BeanStub implements _IGroupEditService { continue; } const newValue = values[level]; - const currentValue = valueSvc.getValue(column, row, 'data'); + const currentValue = valueSvc.getValueFromData(column, row); if (currentValue === newValue || (currentValue == null && newValue == null)) { continue; } diff --git a/packages/ag-grid-enterprise/src/rowHierarchy/showRowGroupColValueService.ts b/packages/ag-grid-enterprise/src/rowHierarchy/showRowGroupColValueService.ts index b894f760e33..94a1f1c3902 100644 --- a/packages/ag-grid-enterprise/src/rowHierarchy/showRowGroupColValueService.ts +++ b/packages/ag-grid-enterprise/src/rowHierarchy/showRowGroupColValueService.ts @@ -36,7 +36,7 @@ export class ShowRowGroupColValueService extends BeanStub implements NamedBean, } const valueSvc = this.beans.valueSvc; - const rowGroupColId = column.colDef.showRowGroup; + const rowGroupColId = column.showRowGroup; if (!rowGroupColId) { return null; } @@ -57,20 +57,20 @@ export class ShowRowGroupColValueService extends BeanStub implements NamedBean, if (hideOpenParentsNode) { return { displayedNode: hideOpenParentsNode, - value: valueSvc.getValue(column, hideOpenParentsNode, 'data', ignoreAggData), + value: valueSvc.getValueFromData(column, hideOpenParentsNode, ignoreAggData), }; } } // cell value > showOpenedGroup - const value = valueSvc.getValue(column, node, 'data', ignoreAggData); + const value = valueSvc.getValueFromData(column, node, ignoreAggData); if (value == null) { // showOpenedGroup const displayedNode = this.getDisplayedNode(node, column); if (displayedNode) { return { displayedNode, - value: valueSvc.getValue(column, displayedNode, 'data', ignoreAggData), + value: valueSvc.getValueFromData(column, displayedNode, ignoreAggData), }; } } @@ -167,7 +167,7 @@ export class ShowRowGroupColValueService extends BeanStub implements NamedBean, return undefined; } - const showRowGroup = column.colDef.showRowGroup; + const showRowGroup = column.showRowGroup; // single auto col can only showOpenedGroup for leaf rows if (showRowGroup === true) { if (node.group) { diff --git a/packages/ag-grid-enterprise/src/rowHierarchy/showRowGroupColsService.ts b/packages/ag-grid-enterprise/src/rowHierarchy/showRowGroupColsService.ts index 8d686e8775b..b1a98ad0f2b 100644 --- a/packages/ag-grid-enterprise/src/rowHierarchy/showRowGroupColsService.ts +++ b/packages/ag-grid-enterprise/src/rowHierarchy/showRowGroupColsService.ts @@ -80,7 +80,7 @@ export class ShowRowGroupColsService extends BeanStub implements NamedBean, ISho } public getSourceColumnsForGroupColumn(groupCol: AgColumn): AgColumn[] | null { - const sourceColumnId = groupCol.colDef.showRowGroup; + const sourceColumnId = groupCol.showRowGroup; if (!sourceColumnId) { return null; } @@ -95,7 +95,7 @@ export class ShowRowGroupColsService extends BeanStub implements NamedBean, ISho } public isRowGroupDisplayed(column: AgColumn, colId: string | null): boolean { - const showRowGroup = column.colDef.showRowGroup; + const showRowGroup = column.showRowGroup; return showRowGroup === true || (showRowGroup != null && showRowGroup === colId); } diff --git a/packages/ag-grid-enterprise/src/serverSideRowModel/blocks/blockUtils.ts b/packages/ag-grid-enterprise/src/serverSideRowModel/blocks/blockUtils.ts index f1b5043823d..65ef17245c7 100644 --- a/packages/ag-grid-enterprise/src/serverSideRowModel/blocks/blockUtils.ts +++ b/packages/ag-grid-enterprise/src/serverSideRowModel/blocks/blockUtils.ts @@ -118,7 +118,7 @@ export class BlockUtils extends BeanStub implements NamedBean { private setRowGroupInfo(rowNode: RowNode): void { // Use 'data' - group keys should be based on committed data, not pending edits - rowNode.key = this.valueSvc.getValue(rowNode.rowGroupColumn!, rowNode, 'data'); + rowNode.key = this.valueSvc.getValueFromData(rowNode.rowGroupColumn!, rowNode); if (rowNode.key === null || rowNode.key === undefined) { _doOnce(() => { @@ -255,7 +255,7 @@ export class BlockUtils extends BeanStub implements NamedBean { groupData[col.colId] = key; } else if (col.isRowGroupDisplayed(rowNode.rowGroupColumn!.getId())) { // Use 'data' - group keys should be based on committed data, not pending edits - const groupValue = this.valueSvc.getValue(rowNode.rowGroupColumn!, rowNode, 'data'); + const groupValue = this.valueSvc.getValueFromData(rowNode.rowGroupColumn!, rowNode); groupData[col.colId] = groupValue; } } diff --git a/packages/ag-grid-enterprise/src/serverSideRowModel/serverSideRowModel.ts b/packages/ag-grid-enterprise/src/serverSideRowModel/serverSideRowModel.ts index 6332d423ef7..a44387dd0a0 100644 --- a/packages/ag-grid-enterprise/src/serverSideRowModel/serverSideRowModel.ts +++ b/packages/ag-grid-enterprise/src/serverSideRowModel/serverSideRowModel.ts @@ -375,9 +375,9 @@ export class ServerSideRowModel extends BeanStub implements NamedBean, IServerSi (col) => ({ id: col.getId(), - aggFunc: col.getAggFunc(), + aggFunc: col.aggFunc, displayName: this.colNames.getDisplayNameForColumn(col, 'model'), - field: col.colDef.field, + field: col.field, }) as ColumnVO ); } diff --git a/packages/ag-grid-enterprise/src/serverSideRowModel/stores/storeUtils.ts b/packages/ag-grid-enterprise/src/serverSideRowModel/stores/storeUtils.ts index abb9b9760bc..90be00a122f 100644 --- a/packages/ag-grid-enterprise/src/serverSideRowModel/stores/storeUtils.ts +++ b/packages/ag-grid-enterprise/src/serverSideRowModel/stores/storeUtils.ts @@ -80,8 +80,8 @@ export class StoreUtils extends BeanStub implements NamedBean { const allCols = this.colModel.getCols(); const affectedGroupCols = allCols // find all impacted cols which also a group display column - .filter((col) => col.colDef.showRowGroup && params.changedColumns.includes(col.getId())) - .map((col) => col.colDef.showRowGroup) + .filter((col) => col.showRowGroup && params.changedColumns.includes(col.getId())) + .map((col) => col.showRowGroup) // if displaying all groups, or displaying the effected col for this group, refresh .some((group) => group === true || group === colIdThisGroup); diff --git a/packages/ag-grid-enterprise/src/sideBar/common/toolPanelColDefService.ts b/packages/ag-grid-enterprise/src/sideBar/common/toolPanelColDefService.ts index c705f26901b..08daf56f241 100644 --- a/packages/ag-grid-enterprise/src/sideBar/common/toolPanelColDefService.ts +++ b/packages/ag-grid-enterprise/src/sideBar/common/toolPanelColDefService.ts @@ -123,6 +123,6 @@ function getLeafPathTrees(columns: AgColumn[]): AbstractColDef[] { function getGridPrimaryColumns(colModel: ColumnModel): AgColumn[] { return colModel.colsList.filter((column) => { - return column.primary && !column.colDef.showRowGroup; + return column.primary && !column.showRowGroup; }); } diff --git a/packages/ag-grid-enterprise/src/statusBar/providedPanels/aggregationComp.test.ts b/packages/ag-grid-enterprise/src/statusBar/providedPanels/aggregationComp.test.ts index 678eb1603dd..879aadc8efd 100644 --- a/packages/ag-grid-enterprise/src/statusBar/providedPanels/aggregationComp.test.ts +++ b/packages/ag-grid-enterprise/src/statusBar/providedPanels/aggregationComp.test.ts @@ -63,6 +63,7 @@ const createAggregationSnapshot = ( const valueSvc = { getValue: (_col: any, rowNode: any) => rowNode.data.value, + getValueFromData: (_col: any, rowNode: any) => rowNode.data.value, }; (comp as any).beans = { diff --git a/packages/ag-grid-enterprise/src/statusBar/providedPanels/aggregationComp.ts b/packages/ag-grid-enterprise/src/statusBar/providedPanels/aggregationComp.ts index e6f8f9d9d37..cccb072c45f 100644 --- a/packages/ag-grid-enterprise/src/statusBar/providedPanels/aggregationComp.ts +++ b/packages/ag-grid-enterprise/src/statusBar/providedPanels/aggregationComp.ts @@ -257,8 +257,8 @@ export class AggregationComp extends Component implements IStatusPanelComp { // Direct `valueSvc.getValue` + inline formula resolution — `rowNode.getDataValue` // would pay an extra `colModel.getColOrColDefCol` lookup per cell on this hot // path (called for every cell across the selected ranges on each selection change). - let value: any = valueSvc.getValue(col, rowNode, 'data'); - if (col.colDef.allowFormula && formulaSvc?.isFormula(value)) { + let value: any = valueSvc.getValueFromData(col, rowNode); + if (col.allowFormula && formulaSvc?.isFormula(value)) { value = formulaSvc.resolveValue(col, rowNode); } diff --git a/packages/ag-stack/src/core/agBeanStub.ts b/packages/ag-stack/src/core/agBeanStub.ts index 0f4be4d5f98..e2aabe796aa 100644 --- a/packages/ag-stack/src/core/agBeanStub.ts +++ b/packages/ag-stack/src/core/agBeanStub.ts @@ -16,6 +16,7 @@ import type { AgPropertyValueChangedListener, IPropertiesService, } from '../interfaces/iProperties'; +import { _removeFromArray } from '../utils/array'; import { _addSafePassiveEventListener } from '../utils/event'; import { _getLocaleTextFunc } from '../utils/locale'; @@ -25,6 +26,8 @@ type AgEventOrDestroyed = TEventType | AgBeanStubEven type EventHandlers = { [K in TEventKey]?: (event?: TEvent) => void }; +const DESTROYED_EVENT = { type: 'destroyed' as AgBeanStubEvent }; + /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ export abstract class AgBeanStub< TBeanCollection extends AgCoreBeanCollection, @@ -38,25 +41,34 @@ export abstract class AgBeanStub< AgBean, IEventEmitter> { - protected localEventService?: LocalEventService>; + // Vue 3 reactivity-skip flag — prevents Vue from proxying beans (and avoids identity issues). Lives on the + // prototype (see below), not per instance: Vue reads it via a normal property access that walks the chain. + declare public __v_skip: boolean; + + protected beans: TBeanCollection = null!; + protected gos: TPropertiesService = null!; + protected eventSvc: AgEventService = null!; + + /** Indicates whether the bean has been destroyed */ + public destroyed = false; + protected localEventService: LocalEventService> | null = null; - private stubContext: IContext; // not named context to allow children to use 'context' as a variable name - private destroyFunctions: (() => void)[] = []; - private destroyed = false; + // Cold — touched only on bean creation / destruction. + private stubContext: IContext = null!; // not named context to allow children to use 'context' as a variable name + private destroyFunctions: (() => void)[] | null = null; - // for vue 3 - prevents Vue from trying to make this (and obviously any sub classes) from being reactive - // prevents vue from creating proxies for created objects and prevents identity related issues - public __v_skip = true; + private propertyListenerId = 0; - protected beans: TBeanCollection; - protected eventSvc: AgEventService; - protected gos: TPropertiesService; + // Enable multiple grid properties to be updated together by the user but only trigger shared logic once. + // Closely related to logic in GridOptionsUtils.ts _processOnChange + // Lazy — most beans never register grouped property listeners, so the lookup object is allocated on first use. + private lastChangeSetIdLookup: Record | null = null; public preWireBeans(beans: TBeanCollection): void { this.beans = beans; - this.stubContext = beans.context; - this.eventSvc = beans.eventSvc; this.gos = beans.gos; + this.eventSvc = beans.eventSvc; + this.stubContext = beans.context; } // this was a test constructor niall built, when active, it prints after 5 seconds all beans/components that are @@ -75,16 +87,18 @@ export abstract class AgBeanStub< // } public destroy(): void { - const { destroyFunctions } = this; - for (let i = 0; i < destroyFunctions.length; i++) { - destroyFunctions[i](); + const destroyFunctions = this.destroyFunctions; + if (destroyFunctions) { + for (let i = 0; i < destroyFunctions.length; i++) { + destroyFunctions[i](); + } + destroyFunctions.length = 0; } - destroyFunctions.length = 0; this.destroyed = true; // cast destroy type as we do not want to expose destroy event type to the dispatchLocalEvent method // as no one else should be firing destroyed at the bean stub. - this.dispatchLocalEvent({ type: 'destroyed' } as { type: AgBeanStubEvent } as any); + this.dispatchLocalEvent(DESTROYED_EVENT as any); } /** Add a local event listener against this BeanStub */ @@ -93,10 +107,12 @@ export abstract class AgBeanStub< listener: IEventListener, async?: boolean ): void { - if (!this.localEventService) { - this.localEventService = new LocalEventService(); + let localEventService = this.localEventService; + if (!localEventService) { + localEventService = new LocalEventService(); + this.localEventService = localEventService; } - this.localEventService.addEventListener(eventType, listener, async); + localEventService.addEventListener(eventType, listener, async); } /** Remove a local event listener from this BeanStub */ @@ -120,12 +136,8 @@ export abstract class AgBeanStub< } public addManagedEventListeners( handlers: - | { - [K in keyof TGlobalEvents]?: (event: TGlobalEvents[K]) => void; - } - | { - [K in keyof BaseEvents]?: (event: BaseEvents[K]) => void; - } + | { [K in keyof TGlobalEvents]?: (event: TGlobalEvents[K]) => void } + | { [K in keyof BaseEvents]?: (event: BaseEvents[K]) => void } ) { return this._setupListeners(this.eventSvc, handlers); } @@ -141,7 +153,9 @@ export abstract class AgBeanStub< handlers: EventHandlers ) { const destroyFuncs: (() => null)[] = []; - for (const k of Object.keys(handlers)) { + const keys = Object.keys(handlers); + for (let i = 0, len = keys.length; i < len; ++i) { + const k = keys[i]; const handler = handlers[k as TEvent]; if (handler) { destroyFuncs.push(this._setupListener(object, k, handler)); @@ -188,14 +202,7 @@ export abstract class AgBeanStub< }; } - this.destroyFunctions.push(destroyFunc); - - return () => { - destroyFunc(); - // Only remove if manually called before bean is destroyed - this.destroyFunctions = this.destroyFunctions.filter((fn) => fn !== destroyFunc); - return null; - }; + return this.registerDestroyFunc(destroyFunc); } /** @@ -214,14 +221,7 @@ export abstract class AgBeanStub< gos.removePropertyEventListener(event, listener); return null; }; - this.destroyFunctions.push(destroyFunc); - - return () => { - destroyFunc(); - // Only remove if manually called before bean is destroyed - this.destroyFunctions = this.destroyFunctions.filter((fn) => fn !== destroyFunc); - return null; - }; + return this.registerDestroyFunc(destroyFunc); } /** @@ -240,10 +240,6 @@ export abstract class AgBeanStub< return this.setupPropertyListener(event, listener); } - private propertyListenerId = 0; - // Enable multiple grid properties to be updated together by the user but only trigger shared logic once. - // Closely related to logic in GridOptionsUtils.ts _processOnChange - private lastChangeSetIdLookup: Record = {}; /** * Setup managed property listeners for the given set of GridOption properties. * The listener will be run if any of the property changes but will only run once if @@ -264,41 +260,74 @@ export abstract class AgBeanStub< const eventsKey = events.join('-') + this.propertyListenerId++; const wrappedListener = (event: AgPropertyValueChangedEvent) => { - if (event.changeSet) { + const changeSet = event.changeSet; + if (changeSet) { // ChangeSet is only set when the property change is part of a group of changes from ComponentUtils // Direct api calls should always be run as - if (event.changeSet?.id === this.lastChangeSetIdLookup[eventsKey]) { + let lookup = this.lastChangeSetIdLookup; + if (!lookup) { + lookup = {}; + this.lastChangeSetIdLookup = lookup; + } + if (changeSet.id === lookup[eventsKey]) { // Already run the listener for this set of prop changes so don't run again return; } - this.lastChangeSetIdLookup[eventsKey] = event.changeSet.id; + lookup[eventsKey] = changeSet.id; } // Don't expose the underlying event value changes to the group listener. const propertiesChangeEvent: AgPropertyChangedEvent = { type: 'propertyChanged', - changeSet: event.changeSet, + changeSet, source: event.source, }; listener(propertiesChangeEvent); }; - for (const event of events) { - this.setupPropertyListener(event, wrappedListener); + for (let i = 0, len = events.length; i < len; ++i) { + this.setupPropertyListener(events[i], wrappedListener); } } - public isAlive = (): boolean => !this.destroyed; + // Prototype method, not a per-instance arrow — never invoked detached, so binding per bean only wastes memory. + public isAlive(): boolean { + return !this.destroyed; + } public getLocaleTextFunc(): LocaleTextFunc { return _getLocaleTextFunc(this.beans.localeSvc); } + // Lazy — most beans never register a destroy func, so the array is allocated on first push. + private pushDestroyFunc(destroyFunc: () => void): void { + const destroyFunctions = this.destroyFunctions; + if (destroyFunctions) { + destroyFunctions.push(destroyFunc); + } else { + this.destroyFunctions = [destroyFunc]; + } + } + + /** Register a destroy func and return an unregister callback that removes it if called before destroy. */ + private registerDestroyFunc(destroyFunc: () => null): () => null { + this.pushDestroyFunc(destroyFunc); + return () => { + destroyFunc(); + // Only remove if manually called before bean is destroyed + const destroyFunctions = this.destroyFunctions; + if (destroyFunctions) { + _removeFromArray(destroyFunctions, destroyFunc); + } + return null; + }; + } + public addDestroyFunc(func: () => void): void { // if we are already destroyed, we execute the func now - if (this.isAlive()) { - this.destroyFunctions.push(func); - } else { + if (this.destroyed) { func(); + } else { + this.pushDestroyFunc(func); } } @@ -347,6 +376,9 @@ export abstract class AgBeanStub< } } +// Single shared value on the prototype — Vue's reactive() reads `__v_skip` through the prototype chain. +AgBeanStub.prototype.__v_skip = true; + // type guard for IAgEventEmitter function isAgEventEmitter( object: IEventEmitter | IAgEventEmitter | AgEventService diff --git a/testing/behavioural/src/benchmarks/getvalue.bench.ts b/testing/behavioural/src/benchmarks/getvalue.bench.ts index 84f4ab5983f..5686fe4eaca 100644 --- a/testing/behavioural/src/benchmarks/getvalue.bench.ts +++ b/testing/behavioural/src/benchmarks/getvalue.bench.ts @@ -1,7 +1,15 @@ import { bench, describe } from 'vitest'; import type { AgColumn, ColDef, IRowNode } from 'ag-grid-community'; -import { CellApiModule, ClientSideRowModelModule, ColumnApiModule, RowApiModule } from 'ag-grid-community'; +import { + CellApiModule, + CellStyleModule, + ClientSideRowModelModule, + ColumnApiModule, + RowApiModule, + TextEditorModule, + TooltipModule, +} from 'ag-grid-community'; import { SimplePRNG, TestGridsManager } from '../test-utils'; @@ -11,7 +19,15 @@ describe('getValue profiling', () => { const gridsManager = new TestGridsManager({ benchmark: true, - modules: [ClientSideRowModelModule, RowApiModule, CellApiModule, ColumnApiModule], + modules: [ + ClientSideRowModelModule, + RowApiModule, + CellApiModule, + ColumnApiModule, + CellStyleModule, + TooltipModule, + TextEditorModule, + ], }); const columnDefs: ColDef[] = []; @@ -84,3 +100,138 @@ describe('getValue profiling', () => { return sum as any; }); }); + +describe('getValue profiling (all columns per row)', () => { + // The hot grid paths (render, filter, sort, aggregation) read EVERY column per row, so the value + // read site sees many columns — not one repeated column like the suite above (which keeps the + // `colDef` read site monomorphic regardless of colDef variety). With varied colDefs (the realistic + // case) the `colDef.X` site goes megamorphic; a mirrored `column.X` field stays monomorphic on the + // single AgColumn shape. The uniform grid is the control that isolates the colDef-shape-variety effect. + const rowCount = 1000; + const colCount = 100; + + const gridsManager = new TestGridsManager({ + benchmark: true, + modules: [ + ClientSideRowModelModule, + RowApiModule, + CellApiModule, + ColumnApiModule, + CellStyleModule, + TooltipModule, + TextEditorModule, + ], + }); + + // Props that change the colDef object SHAPE without diverting value resolution away from `field`. + const shapeVariants: Partial[] = [ + { headerName: 'A' }, + { sortable: true }, + { resizable: false }, + { width: 120 }, + { minWidth: 60, maxWidth: 400 }, + { cellClass: 'c' }, + { headerTooltip: 't' }, + { editable: true }, + { flex: 1 }, + { suppressMovable: true }, + { headerClass: 'h', cellStyle: { color: 'red' } }, + { initialPinned: 'left' }, + ]; + + const uniformDefs: ColDef[] = []; + const variedDefs: ColDef[] = []; + for (let i = 0; i < colCount; i++) { + uniformDefs.push({ colId: `col_${i}`, field: `col_${i}` }); + variedDefs.push({ colId: `col_${i}`, field: `col_${i}`, ...shapeVariants[i % shapeVariants.length] }); + } + + const prng = new SimplePRNG(0x12345678); + const rowData: Record[] = []; + for (let r = 0; r < rowCount; r++) { + const row: Record = { id: r.toString() }; + for (let c = 0; c < colCount; c++) { + row[`col_${c}`] = prng.nextString(6); + } + rowData.push(row); + } + + const uniformApi = gridsManager.createGrid('U', { + columnDefs: uniformDefs, + rowData, + getRowId: ({ data }) => data.id, + }); + const variedApi = gridsManager.createGrid('V', { + columnDefs: variedDefs, + rowData, + getRowId: ({ data }) => data.id, + }); + + const uniformNodes: IRowNode[] = []; + uniformApi.forEachNode((n) => uniformNodes.push(n)); + const variedNodes: IRowNode[] = []; + variedApi.forEachNode((n) => variedNodes.push(n)); + + const uniformCols = uniformApi.getColumns()! as AgColumn[]; + const variedCols = variedApi.getColumns()! as AgColumn[]; + + bench(`getDataValue uniform colDefs (${colCount} cols x ${rowCount} rows)`, () => { + for (let i = 0; i < rowCount; ++i) { + const node = uniformNodes[i]; + for (let c = 0; c < colCount; ++c) { + node.getDataValue(uniformCols[c]); + } + } + }); + + bench(`getDataValue varied colDefs (${colCount} cols x ${rowCount} rows)`, () => { + for (let i = 0; i < rowCount; ++i) { + const node = variedNodes[i]; + for (let c = 0; c < colCount; ++c) { + node.getDataValue(variedCols[c]); + } + } + }); +}); + +describe('getValue profiling (valueGetter columns)', () => { + // Exercises the executeValueGetter dispatch path (not hit by plain field columns above). + const rowCount = 1000; + const colCount = 100; + + const gridsManager = new TestGridsManager({ + benchmark: true, + modules: [ClientSideRowModelModule, RowApiModule, CellApiModule, ColumnApiModule], + }); + + const columnDefs: ColDef[] = []; + for (let i = 0; i < colCount; i++) { + const field = `col_${i}`; + columnDefs.push({ colId: field, valueGetter: (p) => p.data?.[field] }); + } + + const prng = new SimplePRNG(0x12345678); + const rowData: Record[] = []; + for (let r = 0; r < rowCount; r++) { + const row: Record = { id: r.toString() }; + for (let c = 0; c < colCount; c++) { + row[`col_${c}`] = prng.nextString(6); + } + rowData.push(row); + } + + const api = gridsManager.createGrid('VG', { columnDefs, rowData, getRowId: ({ data }) => data.id }); + + const nodes: IRowNode[] = []; + api.forEachNode((n) => nodes.push(n)); + const cols = api.getColumns()! as AgColumn[]; + + bench(`getDataValue valueGetter cols (${colCount} cols x ${rowCount} rows)`, () => { + for (let i = 0; i < rowCount; ++i) { + const node = nodes[i]; + for (let c = 0; c < colCount; ++c) { + node.getDataValue(cols[c]); + } + } + }); +}); diff --git a/testing/behavioural/src/columns/column-groups.test.ts b/testing/behavioural/src/columns/column-groups.test.ts index 5c8a01683d6..520034077d3 100644 --- a/testing/behavioural/src/columns/column-groups.test.ts +++ b/testing/behavioural/src/columns/column-groups.test.ts @@ -1,4 +1,4 @@ -import type { ColDef, ColGroupDef } from 'ag-grid-community'; +import type { ColDef, ColGroupDef, ColumnGroup } from 'ag-grid-community'; import { ClientSideRowModelModule } from 'ag-grid-community'; import { GridColumns, GridRows, TestGridsManager, asyncSetTimeout } from '../test-utils'; @@ -1898,4 +1898,52 @@ describe('Column Groups', () => { `); }); }); + + describe('collapsed group part with no displayed children', () => { + test('a pin-split expandable group whose part has only columnGroupShow:open children does not crash', async () => { + const api = gridsManager.createGrid('collapsed-empty-part', { + columnDefs: [ + { + headerName: 'G', + groupId: 'g', + children: [ + { field: 'a', pinned: 'left' }, + { field: 'b', columnGroupShow: 'open' }, + ], + }, + ] as (ColDef | ColGroupDef)[], + rowData: [{ a: 1, b: 2 }], + }); + await asyncSetTimeout(1); + + await new GridRows(api, 'collapsed group: empty center part').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:0 a:1 b:2 + `); + + // The collapsed center part of 'g' has no displayed children — getDisplayedChildren() must be + // [] (never null), matching released behaviour so reads like the tool panel stay consistent. + const centerGroups = api.getCenterDisplayedColumnGroups(); + const emptyCenterPart = centerGroups.find( + (g): g is ColumnGroup => 'getGroupId' in g && (g as ColumnGroup).getGroupId() === 'g' + ); + expect(emptyCenterPart).toBeTruthy(); + expect(emptyCenterPart!.getDisplayedChildren()).toEqual([]); + + api.setColumnGroupOpened('g', true); + await asyncSetTimeout(1); + await new GridColumns(api, 'expanded group: center part with b').checkColumns(` + LEFT + └─┬ "G" GROUP open + └── a "A" width:200 + CENTER + └─┬ "G" GROUP open + └── b "B" width:200 columnGroupShow:open + `); + await new GridRows(api, 'expanded group: center part with b').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:0 a:1 b:2 + `); + }); + }); }); diff --git a/testing/behavioural/src/formulas/calculated-columns-pivot.test.ts b/testing/behavioural/src/formulas/calculated-columns-pivot.test.ts index e743e1ccaa7..bc5c0c61bff 100644 --- a/testing/behavioural/src/formulas/calculated-columns-pivot.test.ts +++ b/testing/behavioural/src/formulas/calculated-columns-pivot.test.ts @@ -178,4 +178,29 @@ describe('calculated columns - pivot mode', () => { expect(api.getColumn('profit')).toBeTruthy(); expect(order(api).some((c) => c.startsWith('pivot_year_2020'))).toBe(true); }); + + test('calc col referencing a pivot result column id does not read it on a leaf', async () => { + // warning 295 is expected — the calc col is blocked by the active pivot. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const api = createGrid('pivot-calc-refs-pivot-col', { + rowData, + columnDefs: [ + ...pivotColumnDefs, + { + colId: 'doubled', + calculatedExpression: '[pivot_year_2020_revenue] * 2', + cellDataType: 'number', + }, + ], + pivotMode: true, + }); + await asyncSetTimeout(10); + + const doubledOf = (id: string) => + api.getCellValue({ rowNode: api.getRowNode(id)!, colKey: 'doubled', useFormatter: false }); + + expect(doubledOf('r1')).toBeUndefined(); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); }); diff --git a/testing/behavioural/src/formulas/calculated-columns.test.ts b/testing/behavioural/src/formulas/calculated-columns.test.ts index b1739203cd9..87a860448f0 100644 --- a/testing/behavioural/src/formulas/calculated-columns.test.ts +++ b/testing/behavioural/src/formulas/calculated-columns.test.ts @@ -526,6 +526,27 @@ describe('ag-grid calculated columns', () => { expect(coveredCell).toBeNull(); }); + test('empty or null calculatedExpression is still a calculated column (renders empty, not a plain blank cell)', async () => { + const api = createGrid('calculated-empty-expression', { + columnDefs: [ + { colId: 'calcEmpty', calculatedExpression: '' }, + { colId: 'calcNull', calculatedExpression: null as unknown as string }, + { colId: 'plain' }, + ], + rowData: [{ id: '0' }], + }); + const node = api.getRowNode('0')!; + + await new GridRows(api, 'empty/null calculatedExpression', gridRowsOpts).check(` + ROOT id:ROOT_NODE_ID calcEmpty:"" calcNull:"" + └── LEAF id:0 calcEmpty:"" calcNull:"" + `); + + expect(api.getCellValue({ rowNode: node, colKey: 'calcEmpty', useFormatter: false })).toBe(''); + expect(api.getCellValue({ rowNode: node, colKey: 'calcNull', useFormatter: false })).toBe(''); + expect(api.getCellValue({ rowNode: node, colKey: 'plain', useFormatter: false })).toBeUndefined(); + }); + test('editing a calculated column expression re-groups its row spans (and dependents)', async () => { const api = createGrid('calculated-span-rows-expression-edit', { enableCellSpan: true, @@ -835,6 +856,7 @@ describe('ag-grid calculated columns', () => { { field: 'cost' }, { colId: 'profitable', + headerName: 'Profitable', calculatedExpression: 'IF([revenue] > [cost], "yes", "no")', cellDataType: 'text', }, @@ -868,7 +890,7 @@ describe('ag-grid calculated columns', () => { CENTER ├── revenue "Revenue" width:200 ├── cost "Cost" width:200 - └── profitable width:200 + └── profitable "Profitable" width:200 `); }); @@ -1421,7 +1443,7 @@ describe('ag-grid calculated columns', () => { setExpression(''); const input = getExpressionInput(); - expect(input.validationMessage).toBe('Enter an expression.'); + expect(input.validationMessage).toBe('Enter an expression'); expect(input.validationMessage).not.toContain('begin with'); expect(input).toHaveClass('invalid'); expect(getDialogButton('Apply')).toBeDisabled(); @@ -1432,6 +1454,41 @@ describe('ag-grid calculated columns', () => { expect(api.getColumn('calculated_1')).toBeNull(); }); + test('deferred dialog requires a title before apply', async () => { + const api = createGrid('calculated-deferred-title-required', { + calculatedColumns: { applyMode: 'deferred' }, + rowData: [{ id: 'r1', revenue: 10, cost: 3 }], + columnDefs: [ + { field: 'revenue' }, + { field: 'cost' }, + { colId: 'profit', headerName: 'Profit', calculatedExpression: '[revenue] - [cost]' }, + ], + }); + await asyncSetTimeout(1); + + await openEditDialogViaMenu(api, 'profit'); + + const titleInput = getCalculatedColumnDialog().querySelector('input')!; + titleInput.value = ''; + titleInput.dispatchEvent(new Event('input', { bubbles: true })); + + expect(titleInput).toHaveClass('invalid'); + expect(titleInput.validationMessage).toBe('Enter a title'); + expect(getDialogButton('Apply')).toBeDisabled(); + + // The column keeps its title while the dialog is invalid. + expect(api.getColumn('profit')!.getColDef().headerName).toBe('Profit'); + + titleInput.value = 'Net Profit'; + titleInput.dispatchEvent(new Event('input', { bubbles: true })); + expect(titleInput).not.toHaveClass('invalid'); + expect(getDialogButton('Apply')).not.toBeDisabled(); + + clickDialogButton('Apply'); + await asyncSetTimeout(1); + expect(api.getColumn('profit')!.getColDef().headerName).toBe('Net Profit'); + }); + test('dialog column picker renders group path and leaf as fixed-height clickable rows', async () => { const api = createGrid('calculated-dialog-column-picker-group-path', { rowData: [{ id: 'r1', revenue: 10, cost: 3 }], diff --git a/testing/behavioural/src/grouping-data/pivot-group-hierarchy.test.ts b/testing/behavioural/src/grouping-data/pivot-group-hierarchy.test.ts index 3ed6c4bc111..885280ee7b5 100644 --- a/testing/behavioural/src/grouping-data/pivot-group-hierarchy.test.ts +++ b/testing/behavioural/src/grouping-data/pivot-group-hierarchy.test.ts @@ -1412,4 +1412,36 @@ describe('pivot with groupHierarchy (date-time)', () => { └── total "Total" width:200 `); }); + + test('hierarchy cols inherit enablePivot from their source col so they stay draggable to pivot', async () => { + const api = gridsManager.createGrid('hierarchyEnablePivot', { + columnDefs: [ + { field: 'date', enablePivot: true, groupHierarchy: ['year', 'month'] }, + { field: 'total', aggFunc: 'sum' }, + ], + rowData: [{ date: new Date(2020, 0, 1), total: 1 }], + }); + await asyncSetTimeout(0); + + const yearCol = api.getColumn('ag-Grid-HierarchyColumn-date-year')!; + const monthCol = api.getColumn('ag-Grid-HierarchyColumn-date-month')!; + expect(yearCol).toBeTruthy(); + expect(monthCol).toBeTruthy(); + // enablePivot on the source col propagates to its generated hierarchy cols. + expect(yearCol.isAllowPivot()).toBe(true); + expect(monthCol.isAllowPivot()).toBe(true); + }); + + test('hierarchy cols are not pivotable when the source col is not enablePivot', async () => { + const api = gridsManager.createGrid('hierarchyNoPivot', { + columnDefs: [ + { field: 'date', enableRowGroup: true, groupHierarchy: ['year'] }, + { field: 'total', aggFunc: 'sum' }, + ], + rowData: [{ date: new Date(2020, 0, 1), total: 1 }], + }); + await asyncSetTimeout(0); + + expect(api.getColumn('ag-Grid-HierarchyColumn-date-year')!.isAllowPivot()).toBe(false); + }); }); diff --git a/testing/behavioural/src/grouping-data/ssrm/ssrm-grouping.test.ts b/testing/behavioural/src/grouping-data/ssrm/ssrm-grouping.test.ts index 8c7441b080f..d5d8f158191 100644 --- a/testing/behavioural/src/grouping-data/ssrm/ssrm-grouping.test.ts +++ b/testing/behavioural/src/grouping-data/ssrm/ssrm-grouping.test.ts @@ -379,3 +379,70 @@ describe('csv exports for server-side grouping', () => { `); }); }); + +describe('SSRM footer mirrors the group field value', () => { + const gridManager = new TestGridsManager({ + modules: [RowGroupingModule, ServerSideRowModelModule], + }); + + beforeEach(() => gridManager.reset()); + afterEach(() => gridManager.reset()); + + const createMirrorDatasource = (): IServerSideDatasource => ({ + getRows(params: IServerSideGetRowsParams) { + const isRoot = (params.request.groupKeys ?? []).length === 0; + setTimeout(() => { + params.success?.({ + rowData: isRoot + ? [ + { + id: 'g-Ireland', + key: 'Ireland', + country: 'Ireland', + meta: { label: 'meta-IE' }, + group: true, + leafGroup: true, + }, + ] + : [ + { id: 'ie-1', country: 'Ireland', meta: { label: 'meta-IE' }, sales: 10 }, + { id: 'ie-2', country: 'Ireland', meta: { label: 'meta-IE' }, sales: 20 }, + ], + }); + }, 0); + }, + }); + + test('footer reads the group field, not the column dotted field', async () => { + const api = await gridManager.createGridAndWait(null, { + columnDefs: [ + { colId: 'countryCol', field: 'country', rowGroup: true, hide: true }, + { colId: 'grp', showRowGroup: 'country', field: 'meta.label', cellRenderer: 'agGroupCellRenderer' }, + { field: 'sales', aggFunc: 'sum' }, + ], + groupDisplayType: 'custom', + rowModelType: 'serverSide', + serverSideDatasource: createMirrorDatasource(), + getRowId: ({ data, parentKeys }: GetRowIdParams) => + data.id ?? [...(parentKeys ?? []), data.country].join('|'), + groupTotalRow: 'bottom', + }); + + await ssrmExpandAndLoadAll(api); + await waitForNoLoadingRows(api); + + // Footer's 'grp' mirrors the group's `country` ('Ireland'), not the column's own `meta.label`. + await new GridRows(api, 'ssrm footer mirrors group field').check(` + ROOT id: + └─┬ GROUP-leafGroup id:g-Ireland countryCol:"Ireland" + · ├── LEAF id:ie-1 countryCol:"Ireland" grp:"meta-IE" sales:10 + · ├── LEAF id:ie-2 countryCol:"Ireland" grp:"meta-IE" sales:20 + · └─ footer collapsed id:rowGroupFooter_g-Ireland countryCol:"Ireland" grp:"Ireland" + `); + + const footerNode = api.getRowNode(GROUP_TOTAL_ROW_ID_PREFIX + 'g-Ireland')!; + expect(footerNode.footer).toBe(true); + + expect(api.getCellValue({ rowNode: footerNode, colKey: 'grp' })).toBe('Ireland'); + }); +}); diff --git a/testing/behavioural/src/pagination/pagination-panels.test.ts b/testing/behavioural/src/pagination/pagination-panels.test.ts index a4215840feb..14096284abf 100644 --- a/testing/behavioural/src/pagination/pagination-panels.test.ts +++ b/testing/behavioural/src/pagination/pagination-panels.test.ts @@ -146,7 +146,7 @@ describe('paginationPanels', () => { expect(pageNumbers[1].textContent).toBe('5'); // total pages }); - test('page input navigates on value change', () => { + test('typing alone does not navigate', () => { const api = createPaginationGrid(gridsManager); const panel = getPagingPanel(api)!; const input = panel.querySelector('.ag-paging-page-summary-panel input')!; @@ -154,20 +154,98 @@ describe('paginationPanels', () => { input.value = '3'; input.dispatchEvent(new Event('input')); + expect(api.paginationGetCurrentPage()).toBe(0); + }); + + test('Enter key navigates to typed page', () => { + const api = createPaginationGrid(gridsManager); + const panel = getPagingPanel(api)!; + const input = panel.querySelector('.ag-paging-page-summary-panel input')!; + + input.value = '3'; + input.dispatchEvent(new Event('input')); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + expect(api.paginationGetCurrentPage()).toBe(2); }); - test('clearing the input does not navigate', () => { + test('blur navigates to typed page', () => { + const api = createPaginationGrid(gridsManager); + const panel = getPagingPanel(api)!; + const input = panel.querySelector('.ag-paging-page-summary-panel input')!; + + input.value = '3'; + input.dispatchEvent(new Event('input')); + input.dispatchEvent(new Event('blur')); + + expect(api.paginationGetCurrentPage()).toBe(2); + }); + + test('Escape cancels edit and restores current page without navigating', () => { + const api = createPaginationGrid(gridsManager); + const panel = getPagingPanel(api)!; + const input = panel.querySelector('.ag-paging-page-summary-panel input')!; + + api.paginationGoToPage(1); + + input.value = '5'; + input.dispatchEvent(new Event('input')); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + + expect(api.paginationGetCurrentPage()).toBe(1); + expect(input.value).toBe('2'); + }); + + test('ArrowUp navigates to next page', () => { + const api = createPaginationGrid(gridsManager); + const panel = getPagingPanel(api)!; + const input = panel.querySelector('.ag-paging-page-summary-panel input')!; + + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); + + expect(api.paginationGetCurrentPage()).toBe(1); + }); + + test('ArrowDown navigates to previous page', () => { const api = createPaginationGrid(gridsManager); const panel = getPagingPanel(api)!; const input = panel.querySelector('.ag-paging-page-summary-panel input')!; api.paginationGoToPage(2); - input.value = ''; + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + + expect(api.paginationGetCurrentPage()).toBe(1); + }); + + test('invalid input resets to current page without navigating', () => { + const api = createPaginationGrid(gridsManager); + const panel = getPagingPanel(api)!; + const input = panel.querySelector('.ag-paging-page-summary-panel input')!; + + api.paginationGoToPage(1); + + input.value = '0'; input.dispatchEvent(new Event('input')); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); - expect(api.paginationGetCurrentPage()).toBe(2); + expect(api.paginationGetCurrentPage()).toBe(1); + expect(input.value).toBe('2'); + }); + + test('out-of-range input resets to current page without navigating', () => { + const api = createPaginationGrid(gridsManager); + const panel = getPagingPanel(api)!; + const input = panel.querySelector('.ag-paging-page-summary-panel input')!; + + api.paginationGoToPage(1); + + input.value = '99'; + input.dispatchEvent(new Event('input')); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + + expect(api.paginationGetCurrentPage()).toBe(1); + expect(input.value).toBe('2'); }); test('blurring with empty input resets to current page', () => { @@ -184,6 +262,28 @@ describe('paginationPanels', () => { expect(api.paginationGetCurrentPage()).toBe(2); expect(input.value).toBe('3'); }); + + test('page input has spinbutton role and correct ARIA attributes', () => { + const api = createPaginationGrid(gridsManager); + const panel = getPagingPanel(api)!; + const input = panel.querySelector('.ag-paging-page-summary-panel input')!; + + expect(input.getAttribute('role')).toBe('spinbutton'); + expect(input.getAttribute('aria-valuenow')).toBe('1'); + expect(input.getAttribute('aria-valuemin')).toBe('1'); + expect(input.getAttribute('aria-valuemax')).toBe('5'); + expect(input.getAttribute('aria-label')).toContain('1'); + }); + + test('ARIA attributes update when page changes', () => { + const api = createPaginationGrid(gridsManager); + const panel = getPagingPanel(api)!; + const input = panel.querySelector('.ag-paging-page-summary-panel input')!; + + api.paginationGoToPage(3); + + expect(input.getAttribute('aria-valuenow')).toBe('4'); + }); }); describe('reordering', () => { diff --git a/testing/behavioural/src/row-data/bulk-add-cell-flicker-react.test.tsx b/testing/behavioural/src/row-data/bulk-add-cell-flicker-react.test.tsx index 786ffb29477..ef73cf45883 100644 --- a/testing/behavioural/src/row-data/bulk-add-cell-flicker-react.test.tsx +++ b/testing/behavioural/src/row-data/bulk-add-cell-flicker-react.test.tsx @@ -188,10 +188,10 @@ describe('Eager row content seed (bulk-add flicker regression)', () => { ); } - async function streamRowDataCycles(cycles = 3): Promise { + async function streamRowDataCycles(root: HTMLElement, cycles = 3): Promise { for (let cycle = 0; cycle < cycles; cycle++) { act(() => driveRowData!(cycleData(cycle))); - await asyncSetTimeout(20); // flush React commits + rAF before next cycle + await waitFor(() => expect(root.textContent).toContain(`c${cycle}-0`)); } } @@ -201,7 +201,9 @@ describe('Eager row content seed (bulk-add flicker regression)', () => { const rendered = render(); await waitFor(() => expect(rendered.container.querySelectorAll(ROW_SELECTOR).length).toBeGreaterThan(0)); - const records = await recordContentAppendedIntoExistingRows(rendered.container, streamRowDataCycles); + const records = await recordContentAppendedIntoExistingRows(rendered.container, () => + streamRowDataCycles(rendered.container) + ); expectNoFlicker(records); } @@ -233,7 +235,7 @@ describe('Eager row content seed (bulk-add flicker regression)', () => { }); observer.observe(rendered.container, { subtree: true, attributes: true, attributeFilter: ['class'] }); - await streamRowDataCycles(); + await streamRowDataCycles(rendered.container); observer.disconnect(); if (opacityToggles.length > 0) { diff --git a/testing/behavioural/src/row-data/row-node-get-data-value.test.ts b/testing/behavioural/src/row-data/row-node-get-data-value.test.ts index 57d5c346fc6..74efabbcb90 100644 --- a/testing/behavioural/src/row-data/row-node-get-data-value.test.ts +++ b/testing/behavioural/src/row-data/row-node-get-data-value.test.ts @@ -707,6 +707,42 @@ describe('RowNode.getDataValue', () => { └── LEAF id:1 price:20 quantity:5 total:100 `); // 20 * 5 }); + + test('getDataValue and setDataValue round-trip through a dotted field', async () => { + const api = await gridsManager.createGridAndWait('dotted-field', { + columnDefs: [{ colId: 'nested', field: 'a.b.c' }], + rowData: [{ id: '1', a: { b: { c: 'first' } } }], + getRowId: (params) => params.data.id, + }); + await new GridColumns(api, `dotted field setup`).checkColumns(` + CENTER + └── nested "A B C" width:200 + `); + await new GridRows(api, `dotted field setup`).check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:1 nested:"first" + `); + + const rowNode = api.getRowNode('1')!; + expect(rowNode.getDataValue('nested')).toBe('first'); + + // a real change writes the nested value and reports changed=true + expect(rowNode.setDataValue('nested', 'second')).toBe(true); + expect(rowNode.data.a.b.c).toBe('second'); + expect(rowNode.getDataValue('nested')).toBe('second'); + + // writing the same value reports changed=false and leaves data untouched + expect(rowNode.setDataValue('nested', 'second')).toBe(false); + expect(rowNode.data.a.b.c).toBe('second'); + + // a second distinct write still works — the column's cached fieldPath must not be consumed/mutated + expect(rowNode.setDataValue('nested', 'third')).toBe(true); + expect(rowNode.getDataValue('nested')).toBe('third'); + await new GridRows(api, `dotted field final state`).check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:1 nested:"third" + `); + }); }); describe('column lookup', () => { diff --git a/testing/behavioural/src/services/value-service-init.test.ts b/testing/behavioural/src/services/value-service-init.test.ts index b06b7e16be6..a3d8dc62ddf 100644 --- a/testing/behavioural/src/services/value-service-init.test.ts +++ b/testing/behavioural/src/services/value-service-init.test.ts @@ -365,3 +365,69 @@ describe('ValueService init in wireBeans', () => { `); }); }); + +describe('ValueService value cache', () => { + const gridsManager = new TestGridsManager({ + modules: [ClientSideRowModelModule, ValueCacheModule], + }); + + afterEach(() => gridsManager.reset()); + + test('valueCache + enableCellExpressions returns the evaluated value, stable across reads', async () => { + const api = gridsManager.createGrid('grid-expr-cache', { + columnDefs: [{ colId: 'doubled', field: 'doubled' }], + rowData: [{ id: '0', doubled: '=ctx.n * 2' }], + getRowId: (params) => params.data.id, + context: { n: 21 }, + enableCellExpressions: true, + valueCache: true, + }); + + // Rendered cell shows the evaluated expression, not the raw `=ctx.n * 2` string. + await new GridRows(api, 'valueCache + cell expression: rendered value').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:0 doubled:42 + `); + + const node = api.getRowNode('0')!; + // First read evaluates and populates the cache; the second must return the evaluated value, + // not the cached raw expression string. + expect(api.getCellValue({ rowNode: node, colKey: 'doubled' })).toBe(42); + expect(api.getCellValue({ rowNode: node, colKey: 'doubled' })).toBe(42); + }); + + test('valueCache invalidates on expireValueCache and on a data change, so the getter re-runs', async () => { + let calls = 0; + const api = gridsManager.createGrid('grid-cache-invalidation', { + columnDefs: [ + { colId: 'base', field: 'base' }, + { + colId: 'computed', + valueGetter: (p: ValueGetterParams) => { + calls++; + return (p.data?.base ?? 0) * 2; + }, + }, + ], + rowData: [{ id: '0', base: 21 }], + getRowId: (params) => params.data.id, + valueCache: true, + }); + const node = api.getRowNode('0')!; + + // Repeat reads are served from the cache — the getter is not re-invoked. + expect(api.getCellValue({ rowNode: node, colKey: 'computed' })).toBe(42); + const callsAfterRead = calls; + expect(api.getCellValue({ rowNode: node, colKey: 'computed' })).toBe(42); + expect(calls).toBe(callsAfterRead); + + // expireValueCache forces re-evaluation (value unchanged, but the getter runs again). + api.expireValueCache(); + expect(api.getCellValue({ rowNode: node, colKey: 'computed' })).toBe(42); + expect(calls).toBeGreaterThan(callsAfterRead); + + // A committed data change invalidates the cache, so the new value is computed. + node.setDataValue('base', 50); + expect(api.getCellValue({ rowNode: node, colKey: 'computed' })).toBe(100); + }); +}); diff --git a/testing/behavioural/src/sorting/pivot-sort-leaf-order.test.ts b/testing/behavioural/src/sorting/pivot-sort-leaf-order.test.ts new file mode 100644 index 00000000000..4c0179ebfc8 --- /dev/null +++ b/testing/behavioural/src/sorting/pivot-sort-leaf-order.test.ts @@ -0,0 +1,73 @@ +import type { GridApi, GridOptions, IRowNode } from 'ag-grid-community'; +import { ClientSideRowModelModule } from 'ag-grid-community'; +import { PivotModule, RowGroupingModule } from 'ag-grid-enterprise'; + +import { GridRows, TestGridsManager, applyTransactionChecked, asyncSetTimeout } from '../test-utils'; + +// Characterizes sort behaviour when sorting BY A PIVOT RESULT COLUMN in pivot mode. +// UX contract: the visible group rows reorder by that pivot column's aggregate. +// The hidden leaf rows are not part of the contract (leaves are never displayed in pivot). +describe('pivot: sorting by a pivot result column', () => { + const gridsManager = new TestGridsManager({ + modules: [ClientSideRowModelModule, RowGroupingModule, PivotModule], + }); + beforeEach(() => gridsManager.reset()); + afterEach(() => gridsManager.reset()); + + test('group rows order by the pivot aggregate; hidden leaf order is not by the pivot column', async () => { + const gridOptions: GridOptions = { + columnDefs: [ + { field: 'country', rowGroup: true, hide: true }, + { field: 'year', pivot: true, hide: true }, + { field: 'sales', aggFunc: 'sum', hide: true }, + ], + pivotMode: true, + getRowId: ({ data }) => data.id, + }; + const api: GridApi = gridsManager.createGrid('pivotSort', gridOptions); + + applyTransactionChecked(api, { + add: [ + // USA has two 2020 leaves in ascending-sales insertion order (id u1=300 before u2=1700). + { id: 'u1', country: 'USA', year: 2020, sales: 300 }, + { id: 'u2', country: 'USA', year: 2020, sales: 1700 }, + { id: 'ie', country: 'Ireland', year: 2020, sales: 1000 }, + { id: 'de', country: 'Germany', year: 2020, sales: 1500 }, + ], + }); + await asyncSetTimeout(10); + + // Sort by the 2020 sales pivot result column, descending. + api.applyColumnState({ state: [{ colId: 'pivot_year_2020_sales', sort: 'desc' }] }); + await asyncSetTimeout(10); + + // Group rows are ordered by the pivot aggregate; USA's leaves keep insertion order (u1 before u2). + await new GridRows(api, `sorted by pivot result column desc`).check(` + ROOT id:ROOT_NODE_ID pivot_year_2020_sales:4500 + ├─┬ LEAF_GROUP collapsed id:row-group-country-USA ag-Grid-AutoColumn:"USA" pivot_year_2020_sales:2000 + │ ├── LEAF hidden id:u1 pivot_year_2020_sales:300 + │ └── LEAF hidden id:u2 pivot_year_2020_sales:1700 + ├─┬ LEAF_GROUP collapsed id:row-group-country-Germany ag-Grid-AutoColumn:"Germany" pivot_year_2020_sales:1500 + │ └── LEAF hidden id:de pivot_year_2020_sales:1500 + └─┬ LEAF_GROUP collapsed id:row-group-country-Ireland ag-Grid-AutoColumn:"Ireland" pivot_year_2020_sales:1000 + · └── LEAF hidden id:ie pivot_year_2020_sales:1000 + `); + + // UX contract: visible GROUP rows reorder by the pivot aggregate (USA 2000 > Germany 1500 > Ireland 1000). + const displayedGroups: string[] = []; + const usaLeafOrder: string[] = []; + api.forEachNodeAfterFilterAndSort((node: IRowNode) => { + if (node.group) { + displayedGroups.push(node.key as string); + } else if (node.data?.country === 'USA') { + usaLeafOrder.push(node.id as string); + } + }); + expect(displayedGroups).toEqual(['USA', 'Germany', 'Ireland']); + + // Hidden leaves are NOT sorted by the pivot column: they keep insertion order (u1 then u2), + // not sales-descending (which would be u2 then u1). Leaves are never displayed in pivot, so + // their order carries no UX contract — this locks in the lean (no pivot redirect) behaviour. + expect(usaLeafOrder).toEqual(['u1', 'u2']); + }); +}); diff --git a/testing/behavioural/src/sorting/sorting.test.ts b/testing/behavioural/src/sorting/sorting.test.ts index 77d36822d97..8ed81de580c 100644 --- a/testing/behavioural/src/sorting/sorting.test.ts +++ b/testing/behavioural/src/sorting/sorting.test.ts @@ -437,4 +437,40 @@ describe('Sorting', () => { └── LEAF id:abs-d amount:1 `); }); + + test('comparator dictionary selects the entry matching the sort type', async () => { + const api = gridMgr.createGrid('comparatorDict', { + columnDefs: [ + { + field: 'amount', + comparator: { + default: (a: number, b: number) => b - a, // reverse signed + absolute: (a: number, b: number) => Math.abs(b) - Math.abs(a), // reverse magnitude + }, + }, + ], + rowData: [ + { id: 'c-a', amount: -20 }, + { id: 'c-b', amount: 5 }, + { id: 'c-c', amount: -3 }, + ], + getRowId: (params) => params.data?.id, + }); + + api.applyColumnState({ state: [{ colId: 'amount', sort: 'asc' }] }); + await new GridRows(api, 'comparator dict: default entry').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:c-b amount:5 + ├── LEAF id:c-c amount:-3 + └── LEAF id:c-a amount:-20 + `); + + api.applyColumnState({ state: [{ colId: 'amount', sort: 'asc', sortType: 'absolute' }] }); + await new GridRows(api, 'comparator dict: absolute entry').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:c-a amount:-20 + ├── LEAF id:c-b amount:5 + └── LEAF id:c-c amount:-3 + `); + }); });