From 57a80af17d82f3d74b1f0f00597885110ad65ecd Mon Sep 17 00:00:00 2001 From: Salvatore Previti Date: Fri, 21 Aug 2026 15:35:31 +0100 Subject: [PATCH 1/2] AG-18257-column-filter-validation (#14922) --- community-modules/locale/src/ar-EG.ts | 2 + community-modules/locale/src/bg-BG.ts | 2 + community-modules/locale/src/cs-CZ.ts | 2 + community-modules/locale/src/da-DK.ts | 2 + community-modules/locale/src/de-DE.ts | 2 + community-modules/locale/src/el-GR.ts | 2 + community-modules/locale/src/en-US.ts | 2 + community-modules/locale/src/es-ES.ts | 2 + community-modules/locale/src/fa-IR.ts | 2 + community-modules/locale/src/fi-FI.ts | 2 + community-modules/locale/src/fr-FR.ts | 2 + community-modules/locale/src/he-IL.ts | 2 + community-modules/locale/src/hr-HR.ts | 2 + community-modules/locale/src/hu-HU.ts | 2 + community-modules/locale/src/it-IT.ts | 2 + community-modules/locale/src/ja-JP.ts | 2 + community-modules/locale/src/ko-KR.ts | 2 + community-modules/locale/src/nb-NO.ts | 2 + community-modules/locale/src/nl-NL.ts | 2 + community-modules/locale/src/pl-PL.ts | 2 + community-modules/locale/src/pt-BR.ts | 2 + community-modules/locale/src/pt-PT.ts | 2 + community-modules/locale/src/ro-RO.ts | 2 + community-modules/locale/src/sk-SK.ts | 2 + community-modules/locale/src/sv-SE.ts | 2 + community-modules/locale/src/tr-TR.ts | 2 + community-modules/locale/src/uk-UA.ts | 2 + community-modules/locale/src/ur-PK.ts | 2 + community-modules/locale/src/vi-VN.ts | 2 + community-modules/locale/src/zh-CN.ts | 2 + community-modules/locale/src/zh-HK.ts | 2 + community-modules/locale/src/zh-TW.ts | 2 + .../src/content/docs/filter-bigint/index.mdoc | 4 +- .../src/content/docs/filter-number/index.mdoc | 28 +- .../src/agWidgets/agInputTextField.ts | 43 +- .../src/filter/filterLocaleText.ts | 4 + .../floatingFilterTextInputService.ts | 13 +- .../floating/provided/simpleFloatingFilter.ts | 13 +- .../src/filter/provided/allowedCharPattern.ts | 44 ++ .../filter/provided/bigInt/bigIntFilter.ts | 44 +- .../provided/bigInt/bigIntFilterHandler.ts | 6 +- .../bigInt/bigIntFilterModelFormatter.ts | 3 +- .../provided/bigInt/bigIntFilterUtils.ts | 11 +- .../provided/bigInt/bigIntFloatingFilter.ts | 18 +- .../filter/provided/bigInt/iBigIntFilter.ts | 17 +- .../src/filter/provided/date/dateFilter.ts | 121 +++--- .../provided/date/dateFilterHandler.test.ts | 63 ++- .../filter/provided/date/dateFilterHandler.ts | 36 +- .../provided/date/dateFloatingFilter.ts | 6 +- .../provided/date/defaultDateComponent.ts | 17 +- .../src/filter/provided/iSimpleFilter.ts | 2 + .../filter/provided/number/iNumberFilter.ts | 18 +- .../filter/provided/number/numberFilter.ts | 31 +- .../provided/number/numberFilterHandler.ts | 6 +- .../number/numberFilterModelFormatter.ts | 3 +- .../provided/number/numberFilterUtils.test.ts | 39 +- .../provided/number/numberFilterUtils.ts | 11 +- .../provided/number/numberFloatingFilter.ts | 46 ++- .../src/filter/provided/simpleFilter.ts | 167 ++++++-- .../filter/provided/simpleFilterHandler.ts | 56 +-- .../provided/simpleFilterModelFormatter.ts | 5 +- .../src/filter/provided/simpleFilterUtils.ts | 48 ++- .../filter/provided/text/textFilterHandler.ts | 6 +- .../provided/text/textFloatingFilter.ts | 6 +- .../filter/provided/textInputSimpleFilter.ts | 114 ++---- .../src/interfaces/iFilter.ts | 11 + .../ag-grid-community/src/main-internal.ts | 1 + packages/ag-grid-community/src/main.ts | 1 + .../src/validation/errorMessages/errorText.ts | 8 +- .../advancedFilterExpressionService.ts | 21 +- .../advancedFilter/advancedFilterService.ts | 1 + .../builder/conditionPillWrapperComp.ts | 3 +- .../colFilterExpressionParser.ts | 4 +- .../advancedFilter/filterExpressionUtils.ts | 38 +- ...vanced-filter-bigint-custom-parser.test.ts | 61 ++- ...vanced-filter-number-custom-parser.test.ts | 46 ++- .../bigint-filter-range-validation.test.ts | 182 +++++++++ .../date-filter-range-validation.test.ts | 182 ++++++++- .../src/filters/date-filter.test.ts | 45 +++ .../allowed-char-pattern.test.ts | 259 ++++++++++++ .../bigint-filter-custom-parser.test.ts | 102 +++-- .../date-filter-conditions.test.ts | 271 ++++++++++++- .../number-filter-conditions.test.ts | 272 ++++++++++++- ...ilter-conditions-buttons-and-model.test.ts | 39 ++ .../filters/filter-option-switching.test.ts | 162 ++++++++ .../src/filters/filter-value-getter.test.ts | 48 +++ .../number-filter-range-validation.test.ts | 382 ++++++++++++++++++ 87 files changed, 2755 insertions(+), 497 deletions(-) create mode 100644 packages/ag-grid-community/src/filter/provided/allowedCharPattern.ts create mode 100644 testing/behavioural/src/filters/filter-behaviour/allowed-char-pattern.test.ts diff --git a/community-modules/locale/src/ar-EG.ts b/community-modules/locale/src/ar-EG.ts index 438c14d1767..99a0638508c 100644 --- a/community-modules/locale/src/ar-EG.ts +++ b/community-modules/locale/src/ar-EG.ts @@ -182,6 +182,8 @@ export const AG_GRID_LOCALE_EG = { // Editor Validation Errors minDateValidation: 'التاريخ يجب أن يكون بعد ${variable}', maxDateValidation: 'التاريخ يجب أن يكون قبل ${variable}', + minDateInclusiveValidation: 'التاريخ يجب أن يكون في ${variable} أو بعده', + maxDateInclusiveValidation: 'التاريخ يجب أن يكون في ${variable} أو قبله', maxLengthValidation: 'يجب أن يكون ${variable} حرف أو أقل.', minValueValidation: 'يجب أن يكون أكبر من أو يساوي ${variable}', maxValueValidation: 'يجب أن يكون أقل من أو يساوي ${variable}', diff --git a/community-modules/locale/src/bg-BG.ts b/community-modules/locale/src/bg-BG.ts index 6eb84a83d73..8b23952c6cc 100644 --- a/community-modules/locale/src/bg-BG.ts +++ b/community-modules/locale/src/bg-BG.ts @@ -182,6 +182,8 @@ export const AG_GRID_LOCALE_BG = { // Editor Validation Errors minDateValidation: 'Датата трябва да е след ${variable}', maxDateValidation: 'Датата трябва да е преди ${variable}', + minDateInclusiveValidation: 'Датата трябва да е на или след ${variable}', + maxDateInclusiveValidation: 'Датата трябва да е на или преди ${variable}', maxLengthValidation: 'Трябва да бъде ${variable} символа или по-малко.', minValueValidation: 'Трябва да бъде по-голямо или равно на ${variable}', maxValueValidation: 'Трябва да бъде по-малко или равно на ${variable}', diff --git a/community-modules/locale/src/cs-CZ.ts b/community-modules/locale/src/cs-CZ.ts index 260d288e3df..a6a7247d31e 100644 --- a/community-modules/locale/src/cs-CZ.ts +++ b/community-modules/locale/src/cs-CZ.ts @@ -182,6 +182,8 @@ export const AG_GRID_LOCALE_CZ = { // Editor Validation Errors minDateValidation: 'Datum musí být po ${variable}', maxDateValidation: 'Datum musí být před ${variable}', + minDateInclusiveValidation: 'Datum musí být ${variable} nebo pozdější', + maxDateInclusiveValidation: 'Datum musí být ${variable} nebo dřívější', maxLengthValidation: 'Musí mít ${variable} znaků nebo méně.', minValueValidation: 'Musí být větší nebo rovno ${variable}', maxValueValidation: 'Musí být menší nebo rovno ${variable}', diff --git a/community-modules/locale/src/da-DK.ts b/community-modules/locale/src/da-DK.ts index 5ec7e3d7e74..e929cca4316 100644 --- a/community-modules/locale/src/da-DK.ts +++ b/community-modules/locale/src/da-DK.ts @@ -182,6 +182,8 @@ export const AG_GRID_LOCALE_DK = { // Editor Validation Errors minDateValidation: 'Dato skal være efter ${variable}', maxDateValidation: 'Dato skal være før ${variable}', + minDateInclusiveValidation: 'Dato skal være ${variable} eller senere', + maxDateInclusiveValidation: 'Dato skal være ${variable} eller tidligere', maxLengthValidation: 'Må ikke have flere end ${variable} tegn.', minValueValidation: 'Skal være større end eller lig med ${variable}', maxValueValidation: 'Skal være mindre end eller lig med ${variable}', diff --git a/community-modules/locale/src/de-DE.ts b/community-modules/locale/src/de-DE.ts index 12c72a66b14..857df8603db 100644 --- a/community-modules/locale/src/de-DE.ts +++ b/community-modules/locale/src/de-DE.ts @@ -182,6 +182,8 @@ export const AG_GRID_LOCALE_DE = { // Editor Validation Errors minDateValidation: 'Datum muss nach ${variable} liegen', maxDateValidation: 'Datum muss vor ${variable} liegen', + minDateInclusiveValidation: 'Datum muss am ${variable} oder danach liegen', + maxDateInclusiveValidation: 'Datum muss am ${variable} oder davor liegen', maxLengthValidation: 'Muss ${variable} Zeichen oder weniger sein.', minValueValidation: 'Muss größer oder gleich ${variable} sein', maxValueValidation: 'Muss kleiner oder gleich ${variable} sein', diff --git a/community-modules/locale/src/el-GR.ts b/community-modules/locale/src/el-GR.ts index b80d5c35dc6..06c2ff2ceba 100644 --- a/community-modules/locale/src/el-GR.ts +++ b/community-modules/locale/src/el-GR.ts @@ -182,6 +182,8 @@ export const AG_GRID_LOCALE_GR = { // Editor Validation Errors minDateValidation: 'Η ημερομηνία πρέπει να είναι μετά από ${variable}', maxDateValidation: 'Η ημερομηνία πρέπει να είναι πριν από ${variable}', + minDateInclusiveValidation: 'Η ημερομηνία πρέπει να είναι ${variable} ή μεταγενέστερη', + maxDateInclusiveValidation: 'Η ημερομηνία πρέπει να είναι ${variable} ή προγενέστερη', maxLengthValidation: 'Πρέπει να είναι ${variable} χαρακτήρες ή λιγότερο.', minValueValidation: 'Πρέπει να είναι μεγαλύτερο ή ίσο με ${variable}', maxValueValidation: 'Πρέπει να είναι μικρότερο ή ίσο με ${variable}', diff --git a/community-modules/locale/src/en-US.ts b/community-modules/locale/src/en-US.ts index 3743d3343b4..8a6cf5d280a 100644 --- a/community-modules/locale/src/en-US.ts +++ b/community-modules/locale/src/en-US.ts @@ -173,6 +173,8 @@ export const AG_GRID_LOCALE_EN = { // Editor Validation Errors minDateValidation: 'Date must be after ${variable}', maxDateValidation: 'Date must be before ${variable}', + minDateInclusiveValidation: 'Date must be on or after ${variable}', + maxDateInclusiveValidation: 'Date must be on or before ${variable}', maxLengthValidation: 'Must be ${variable} characters or fewer.', minValueValidation: 'Must be greater than or equal to ${variable}', maxValueValidation: 'Must be less than or equal to ${variable}', diff --git a/community-modules/locale/src/es-ES.ts b/community-modules/locale/src/es-ES.ts index bed61b26cf3..96595fa12b6 100644 --- a/community-modules/locale/src/es-ES.ts +++ b/community-modules/locale/src/es-ES.ts @@ -183,6 +183,8 @@ export const AG_GRID_LOCALE_ES = { // Editor Validation Errors minDateValidation: 'La fecha debe ser posterior a ${variable}', maxDateValidation: 'La fecha debe ser anterior a ${variable}', + minDateInclusiveValidation: 'La fecha debe ser ${variable} o posterior', + maxDateInclusiveValidation: 'La fecha debe ser ${variable} o anterior', maxLengthValidation: 'Debe tener ${variable} caracteres o menos.', minValueValidation: 'Debe ser mayor o igual a ${variable}', maxValueValidation: 'Debe ser menor o igual a ${variable}', diff --git a/community-modules/locale/src/fa-IR.ts b/community-modules/locale/src/fa-IR.ts index 4b2a49b0124..6dbe3bffbaf 100644 --- a/community-modules/locale/src/fa-IR.ts +++ b/community-modules/locale/src/fa-IR.ts @@ -182,6 +182,8 @@ export const AG_GRID_LOCALE_IR = { // Editor Validation Errors minDateValidation: 'تاریخ باید بعد از ${variable} باشد', maxDateValidation: 'تاریخ باید قبل از ${variable} باشد', + minDateInclusiveValidation: 'تاریخ باید ${variable} یا بعد از آن باشد', + maxDateInclusiveValidation: 'تاریخ باید ${variable} یا قبل از آن باشد', maxLengthValidation: 'باید ${variable} کاراکتر یا کمتر باشد.', minValueValidation: 'باید بیشتر یا مساوی با ${variable} باشد', maxValueValidation: 'باید کمتر یا مساوی ${variable} باشد', diff --git a/community-modules/locale/src/fi-FI.ts b/community-modules/locale/src/fi-FI.ts index d09fc863fff..9c1e2e1161c 100644 --- a/community-modules/locale/src/fi-FI.ts +++ b/community-modules/locale/src/fi-FI.ts @@ -182,6 +182,8 @@ export const AG_GRID_LOCALE_FI = { // Editor Validation Errors minDateValidation: 'Päivämäärän on oltava jälkeen ${variable}', maxDateValidation: 'Päivämäärän on oltava ennen ${variable}', + minDateInclusiveValidation: 'Päivämäärän on oltava ${variable} tai myöhempi', + maxDateInclusiveValidation: 'Päivämäärän on oltava ${variable} tai aiempi', maxLengthValidation: 'Saa olla enintään ${variable} merkkiä.', minValueValidation: 'Täytyy olla suurempi tai yhtä suuri kuin ${variable}', maxValueValidation: 'On oltava vähemmän tai yhtä suuri kuin ${variable}', diff --git a/community-modules/locale/src/fr-FR.ts b/community-modules/locale/src/fr-FR.ts index 12077b12c9b..834e81ebffc 100644 --- a/community-modules/locale/src/fr-FR.ts +++ b/community-modules/locale/src/fr-FR.ts @@ -183,6 +183,8 @@ export const AG_GRID_LOCALE_FR = { // Editor Validation Errors minDateValidation: 'La date doit être après ${variable}', maxDateValidation: 'La date doit être avant ${variable}', + minDateInclusiveValidation: 'La date doit être le ${variable} ou après', + maxDateInclusiveValidation: 'La date doit être le ${variable} ou avant', maxLengthValidation: 'Doit contenir ${variable} caractères ou moins.', minValueValidation: 'Doit être supérieur ou égal à ${variable}', maxValueValidation: 'Doit être inférieur ou égal à ${variable}', diff --git a/community-modules/locale/src/he-IL.ts b/community-modules/locale/src/he-IL.ts index 7e91c37e182..5995e8a4b6f 100644 --- a/community-modules/locale/src/he-IL.ts +++ b/community-modules/locale/src/he-IL.ts @@ -182,6 +182,8 @@ export const AG_GRID_LOCALE_IL = { // Editor Validation Errors minDateValidation: 'התאריך חייב להיות אחרי ${variable}', maxDateValidation: 'התאריך חייב להיות לפני ${variable}', + minDateInclusiveValidation: 'התאריך חייב להיות ${variable} או אחריו', + maxDateInclusiveValidation: 'התאריך חייב להיות ${variable} או לפניו', maxLengthValidation: 'חייב להיות ${variable} תווים או פחות.', minValueValidation: 'חייב להיות גדול או שווה ל-${variable}', maxValueValidation: 'חייב להיות קטן או שווה ל-${variable}', diff --git a/community-modules/locale/src/hr-HR.ts b/community-modules/locale/src/hr-HR.ts index d4dac609e4a..3f6a2afd25e 100644 --- a/community-modules/locale/src/hr-HR.ts +++ b/community-modules/locale/src/hr-HR.ts @@ -182,6 +182,8 @@ export const AG_GRID_LOCALE_HR = { // Editor Validation Errors minDateValidation: 'Datum mora biti nakon ${variable}', maxDateValidation: 'Datum mora biti prije ${variable}', + minDateInclusiveValidation: 'Datum mora biti ${variable} ili kasnije', + maxDateInclusiveValidation: 'Datum mora biti ${variable} ili ranije', maxLengthValidation: 'Mora imati ${variable} znakova ili manje.', minValueValidation: 'Mora biti veće ili jednako ${variable}', maxValueValidation: 'Mora biti manje ili jednako ${variable}', diff --git a/community-modules/locale/src/hu-HU.ts b/community-modules/locale/src/hu-HU.ts index d189c2b3400..e18e402ff56 100644 --- a/community-modules/locale/src/hu-HU.ts +++ b/community-modules/locale/src/hu-HU.ts @@ -182,6 +182,8 @@ export const AG_GRID_LOCALE_HU = { // Editor Validation Errors minDateValidation: 'A dátumnak későbbinek kell lennie, mint ${variable}', maxDateValidation: 'A dátumnak korábbinak kell lennie, mint ${variable}', + minDateInclusiveValidation: 'A dátumnak ${variable} vagy annál későbbinek kell lennie', + maxDateInclusiveValidation: 'A dátumnak ${variable} vagy annál korábbinak kell lennie', maxLengthValidation: 'Legfeljebb ${variable} karakter hosszú lehet.', minValueValidation: 'Az értéknek nagyobbnak vagy egyenlőnek kell lennie ${variable}-val/vel', maxValueValidation: 'Kisebbnek vagy egyenlőnek kell lennie, mint ${variable}', diff --git a/community-modules/locale/src/it-IT.ts b/community-modules/locale/src/it-IT.ts index 721f1fd3fd6..ea24425eaad 100644 --- a/community-modules/locale/src/it-IT.ts +++ b/community-modules/locale/src/it-IT.ts @@ -183,6 +183,8 @@ export const AG_GRID_LOCALE_IT = { // Editor Validation Errors minDateValidation: 'La data deve essere successiva a ${variable}', maxDateValidation: 'La data deve essere precedente a ${variable}', + minDateInclusiveValidation: 'La data deve essere ${variable} o successiva', + maxDateInclusiveValidation: 'La data deve essere ${variable} o precedente', maxLengthValidation: 'Deve essere di ${variable} caratteri o meno.', minValueValidation: 'Deve essere maggiore o uguale a ${variable}', maxValueValidation: 'Deve essere minore o uguale a ${variable}', diff --git a/community-modules/locale/src/ja-JP.ts b/community-modules/locale/src/ja-JP.ts index 5a2238bbcbb..1341744cfb0 100644 --- a/community-modules/locale/src/ja-JP.ts +++ b/community-modules/locale/src/ja-JP.ts @@ -182,6 +182,8 @@ export const AG_GRID_LOCALE_JP = { // Editor Validation Errors minDateValidation: '日付は${variable}より後でなければなりません', maxDateValidation: '日付は${variable}より前でなければなりません', + minDateInclusiveValidation: '日付は${variable}以降でなければなりません', + maxDateInclusiveValidation: '日付は${variable}以前でなければなりません', maxLengthValidation: '${variable}文字以内でなければなりません。', minValueValidation: '${variable}以上でなければなりません', maxValueValidation: '${variable}以下である必要があります', diff --git a/community-modules/locale/src/ko-KR.ts b/community-modules/locale/src/ko-KR.ts index a4a0410cac7..d28cafff8bd 100644 --- a/community-modules/locale/src/ko-KR.ts +++ b/community-modules/locale/src/ko-KR.ts @@ -182,6 +182,8 @@ export const AG_GRID_LOCALE_KR = { // Editor Validation Errors minDateValidation: '날짜는 ${variable} 이후여야 합니다.', maxDateValidation: '날짜는 ${variable} 이전이어야 합니다.', + minDateInclusiveValidation: '날짜는 ${variable} 또는 그 이후여야 합니다.', + maxDateInclusiveValidation: '날짜는 ${variable} 또는 그 이전이어야 합니다.', maxLengthValidation: '${variable}자 이내여야 합니다.', minValueValidation: '${variable}보다 크거나 같아야 합니다.', maxValueValidation: '${variable} 이하이어야 합니다.', diff --git a/community-modules/locale/src/nb-NO.ts b/community-modules/locale/src/nb-NO.ts index 784442c16f5..63d16e2f4d6 100644 --- a/community-modules/locale/src/nb-NO.ts +++ b/community-modules/locale/src/nb-NO.ts @@ -182,6 +182,8 @@ export const AG_GRID_LOCALE_NO = { // Editor Validation Errors minDateValidation: 'Dato må være etter ${variable}', maxDateValidation: 'Dato må være før ${variable}', + minDateInclusiveValidation: 'Dato må være ${variable} eller senere', + maxDateInclusiveValidation: 'Dato må være ${variable} eller tidligere', maxLengthValidation: 'Må være ${variable} tegn eller færre.', minValueValidation: 'Må være større enn eller lik ${variable}', maxValueValidation: 'Må være mindre enn eller lik ${variable}', diff --git a/community-modules/locale/src/nl-NL.ts b/community-modules/locale/src/nl-NL.ts index a5eb5810bd2..3ec8e95618f 100644 --- a/community-modules/locale/src/nl-NL.ts +++ b/community-modules/locale/src/nl-NL.ts @@ -182,6 +182,8 @@ export const AG_GRID_LOCALE_NL = { // Editor Validation Errors minDateValidation: 'Datum moet na ${variable} zijn', maxDateValidation: 'Datum moet voor ${variable} zijn', + minDateInclusiveValidation: 'Datum moet ${variable} of later zijn', + maxDateInclusiveValidation: 'Datum moet ${variable} of eerder zijn', maxLengthValidation: 'Moet ${variable} tekens of minder zijn.', minValueValidation: 'Moet groter dan of gelijk aan ${variable} zijn', maxValueValidation: 'Moet minder dan of gelijk aan ${variable} zijn', diff --git a/community-modules/locale/src/pl-PL.ts b/community-modules/locale/src/pl-PL.ts index a151defeab6..3980952594b 100644 --- a/community-modules/locale/src/pl-PL.ts +++ b/community-modules/locale/src/pl-PL.ts @@ -182,6 +182,8 @@ export const AG_GRID_LOCALE_PL = { // Editor Validation Errors minDateValidation: 'Data musi być po ${variable}', maxDateValidation: 'Data musi być przed ${variable}', + minDateInclusiveValidation: 'Data musi być ${variable} lub późniejsza', + maxDateInclusiveValidation: 'Data musi być ${variable} lub wcześniejsza', maxLengthValidation: 'Musi mieć ${variable} znaków lub mniej.', minValueValidation: 'Musi być większa lub równa ${variable}', maxValueValidation: 'Musi być mniejsza lub równa ${variable}', diff --git a/community-modules/locale/src/pt-BR.ts b/community-modules/locale/src/pt-BR.ts index 8110bca6baf..921f81f6610 100644 --- a/community-modules/locale/src/pt-BR.ts +++ b/community-modules/locale/src/pt-BR.ts @@ -182,6 +182,8 @@ export const AG_GRID_LOCALE_BR = { // Editor Validation Errors minDateValidation: 'A data deve ser após ${variable}', maxDateValidation: 'A data deve ser antes de ${variable}', + minDateInclusiveValidation: 'A data deve ser ${variable} ou posterior', + maxDateInclusiveValidation: 'A data deve ser ${variable} ou anterior', maxLengthValidation: 'Deve ter ${variable} caracteres ou menos.', minValueValidation: 'Deve ser maior ou igual a ${variable}', maxValueValidation: 'Deve ser menor ou igual a ${variable}', diff --git a/community-modules/locale/src/pt-PT.ts b/community-modules/locale/src/pt-PT.ts index d51e1fc0997..8674d1d906d 100644 --- a/community-modules/locale/src/pt-PT.ts +++ b/community-modules/locale/src/pt-PT.ts @@ -182,6 +182,8 @@ export const AG_GRID_LOCALE_PT = { // Editor Validation Errors minDateValidation: 'A data deve ser após ${variable}', maxDateValidation: 'A data deve ser antes de ${variable}', + minDateInclusiveValidation: 'A data deve ser ${variable} ou posterior', + maxDateInclusiveValidation: 'A data deve ser ${variable} ou anterior', maxLengthValidation: 'Deve ter ${variable} caracteres ou menos.', minValueValidation: 'Deve ser maior ou igual a ${variable}', maxValueValidation: 'Deve ser menor ou igual a ${variable}', diff --git a/community-modules/locale/src/ro-RO.ts b/community-modules/locale/src/ro-RO.ts index 532a473c3f1..e34567b186b 100644 --- a/community-modules/locale/src/ro-RO.ts +++ b/community-modules/locale/src/ro-RO.ts @@ -182,6 +182,8 @@ export const AG_GRID_LOCALE_RO = { // Editor Validation Errors minDateValidation: 'Data trebuie să fie după ${variable}', maxDateValidation: 'Data trebuie să fie înainte de ${variable}', + minDateInclusiveValidation: 'Data trebuie să fie ${variable} sau ulterioară', + maxDateInclusiveValidation: 'Data trebuie să fie ${variable} sau anterioară', maxLengthValidation: 'Trebuie să fie cel mult ${variable} caractere.', minValueValidation: 'Trebuie să fie mai mare sau egal cu ${variable}', maxValueValidation: 'Trebuie să fie mai mic sau egal cu ${variable}', diff --git a/community-modules/locale/src/sk-SK.ts b/community-modules/locale/src/sk-SK.ts index b2cc8d9eb02..fcecc6f53db 100644 --- a/community-modules/locale/src/sk-SK.ts +++ b/community-modules/locale/src/sk-SK.ts @@ -182,6 +182,8 @@ export const AG_GRID_LOCALE_SK = { // Editor Validation Errors minDateValidation: 'Dátum musí byť po ${variable}', maxDateValidation: 'Dátum musí byť pred ${variable}', + minDateInclusiveValidation: 'Dátum musí byť ${variable} alebo neskorší', + maxDateInclusiveValidation: 'Dátum musí byť ${variable} alebo skorší', maxLengthValidation: 'Musí mať ${variable} znakov alebo menej.', minValueValidation: 'Musí byť väčšie alebo rovné ${variable}', maxValueValidation: 'Musí byť menej alebo rovné ${variable}', diff --git a/community-modules/locale/src/sv-SE.ts b/community-modules/locale/src/sv-SE.ts index 8c410cdea52..ff02b1b9d4a 100644 --- a/community-modules/locale/src/sv-SE.ts +++ b/community-modules/locale/src/sv-SE.ts @@ -182,6 +182,8 @@ export const AG_GRID_LOCALE_SE = { // Editor Validation Errors minDateValidation: 'Datum måste vara efter ${variable}', maxDateValidation: 'Datum måste vara före ${variable}', + minDateInclusiveValidation: 'Datum måste vara ${variable} eller senare', + maxDateInclusiveValidation: 'Datum måste vara ${variable} eller tidigare', maxLengthValidation: 'Måste vara ${variable} tecken eller färre.', minValueValidation: 'Måste vara större än eller lika med ${variable}', maxValueValidation: 'Måste vara mindre än eller lika med ${variable}', diff --git a/community-modules/locale/src/tr-TR.ts b/community-modules/locale/src/tr-TR.ts index ac51904d2d8..806f6c7e4ed 100644 --- a/community-modules/locale/src/tr-TR.ts +++ b/community-modules/locale/src/tr-TR.ts @@ -182,6 +182,8 @@ export const AG_GRID_LOCALE_TR = { // Editor Validation Errors minDateValidation: 'Tarih ${variable} tarihinden sonra olmalıdır', maxDateValidation: 'Tarih ${variable} tarihinden önce olmalıdır', + minDateInclusiveValidation: 'Tarih ${variable} tarihinde veya sonrasında olmalıdır', + maxDateInclusiveValidation: 'Tarih ${variable} tarihinde veya öncesinde olmalıdır', maxLengthValidation: 'En fazla ${variable} karakter olmalıdır.', minValueValidation: 'En az ${variable} veya daha büyük olmalıdır', maxValueValidation: "${variable}' değerinden küçük veya eşit olmalıdır", diff --git a/community-modules/locale/src/uk-UA.ts b/community-modules/locale/src/uk-UA.ts index 575348a66ae..597058f8411 100644 --- a/community-modules/locale/src/uk-UA.ts +++ b/community-modules/locale/src/uk-UA.ts @@ -182,6 +182,8 @@ export const AG_GRID_LOCALE_UA = { // Editor Validation Errors minDateValidation: 'Дата повинна бути після ${variable}', maxDateValidation: 'Дата повинна бути до ${variable}', + minDateInclusiveValidation: 'Дата повинна бути ${variable} або пізніше', + maxDateInclusiveValidation: 'Дата повинна бути ${variable} або раніше', maxLengthValidation: 'Повинно бути ${variable} символів або менше.', minValueValidation: 'Повинно бути більше або дорівнювати ${variable}', maxValueValidation: 'Має бути меншим або дорівнювати ${variable}', diff --git a/community-modules/locale/src/ur-PK.ts b/community-modules/locale/src/ur-PK.ts index 87afc89ba2c..ef0a233d548 100644 --- a/community-modules/locale/src/ur-PK.ts +++ b/community-modules/locale/src/ur-PK.ts @@ -182,6 +182,8 @@ export const AG_GRID_LOCALE_PK = { // Editor Validation Errors minDateValidation: 'تاریخ ${variable} کے بعد کی ہونی چاہیے', maxDateValidation: 'تاریخ ${variable} سے پہلے کی ہونی چاہیے', + minDateInclusiveValidation: 'تاریخ ${variable} یا اس کے بعد کی ہونی چاہیے', + maxDateInclusiveValidation: 'تاریخ ${variable} یا اس سے پہلے کی ہونی چاہیے', maxLengthValidation: '${variable} حروف یا کم ہونا چاہیے۔', minValueValidation: '${variable} کے برابر یا اس سے زیادہ ہونا چاہیے', maxValueValidation: '${variable} کے برابر یا کم ہونا چاہیے', diff --git a/community-modules/locale/src/vi-VN.ts b/community-modules/locale/src/vi-VN.ts index ea7295869dc..e71e522081a 100644 --- a/community-modules/locale/src/vi-VN.ts +++ b/community-modules/locale/src/vi-VN.ts @@ -182,6 +182,8 @@ export const AG_GRID_LOCALE_VN = { // Editor Validation Errors minDateValidation: 'Ngày phải sau ${variable}', maxDateValidation: 'Ngày phải trước ${variable}', + minDateInclusiveValidation: 'Ngày phải là ${variable} hoặc sau đó', + maxDateInclusiveValidation: 'Ngày phải là ${variable} hoặc trước đó', maxLengthValidation: 'Phải có ${variable} ký tự hoặc ít hơn.', minValueValidation: 'Phải lớn hơn hoặc bằng ${variable}', maxValueValidation: 'Phải nhỏ hơn hoặc bằng ${variable}', diff --git a/community-modules/locale/src/zh-CN.ts b/community-modules/locale/src/zh-CN.ts index a822147432d..154a7357880 100644 --- a/community-modules/locale/src/zh-CN.ts +++ b/community-modules/locale/src/zh-CN.ts @@ -182,6 +182,8 @@ export const AG_GRID_LOCALE_CN = { // Editor Validation Errors minDateValidation: '日期必须在 ${variable} 之后', maxDateValidation: '日期必须在 ${variable} 之前', + minDateInclusiveValidation: '日期必须在 ${variable} 或之后', + maxDateInclusiveValidation: '日期必须在 ${variable} 或之前', maxLengthValidation: '必须少于或等于 ${variable} 个字符', minValueValidation: '必须大于或等于 ${variable}', maxValueValidation: '必须小于或等于${variable}', diff --git a/community-modules/locale/src/zh-HK.ts b/community-modules/locale/src/zh-HK.ts index 3c54f2e1d95..6ec74db9d81 100644 --- a/community-modules/locale/src/zh-HK.ts +++ b/community-modules/locale/src/zh-HK.ts @@ -182,6 +182,8 @@ export const AG_GRID_LOCALE_HK = { // Editor Validation Errors minDateValidation: '日期必須在 ${variable} 之後', maxDateValidation: '日期必須在 ${variable} 之前', + minDateInclusiveValidation: '日期必須在 ${variable} 或之後', + maxDateInclusiveValidation: '日期必須在 ${variable} 或之前', maxLengthValidation: '必須少於或等於 ${variable} 個字符。', minValueValidation: '必須大於或等於 ${variable}', maxValueValidation: '必須小於或等於${variable}', diff --git a/community-modules/locale/src/zh-TW.ts b/community-modules/locale/src/zh-TW.ts index d41fb8c815b..af086e39697 100644 --- a/community-modules/locale/src/zh-TW.ts +++ b/community-modules/locale/src/zh-TW.ts @@ -182,6 +182,8 @@ export const AG_GRID_LOCALE_TW = { // Editor Validation Errors minDateValidation: '日期必須在 ${variable} 之後', maxDateValidation: '日期必須在 ${variable} 之前', + minDateInclusiveValidation: '日期必須在 ${variable} 或之後', + maxDateInclusiveValidation: '日期必須在 ${variable} 或之前', maxLengthValidation: '必須少於或等於 ${variable} 個字元。', minValueValidation: '必須大於或等於 ${variable}', maxValueValidation: '必須小於或等於${variable}', diff --git a/documentation/ag-grid-docs/src/content/docs/filter-bigint/index.mdoc b/documentation/ag-grid-docs/src/content/docs/filter-bigint/index.mdoc index ec2e0131983..0bff90fd3c6 100644 --- a/documentation/ag-grid-docs/src/content/docs/filter-bigint/index.mdoc +++ b/documentation/ag-grid-docs/src/content/docs/filter-bigint/index.mdoc @@ -44,7 +44,7 @@ The BigInt Filter accepts decimal integer syntax only: ## Custom Parsing -To accept other formats, such as hexadecimal, provide a `bigintParser` that converts the entered text to a `bigint` (return `null` for values it cannot parse). Pair it with `allowedCharPattern` so the extra characters can be typed into the filter input. The parsed value is what gets applied to filtering, and the same parser is used by the [Advanced Filter](./filter-advanced/) for `bigint` operands. +To accept other formats, such as hexadecimal, provide a `bigintParser` that converts the entered text to a `bigint` (return `null` for values it cannot parse). Pair it with `allowedCharPattern` so the extra characters can be typed into the filter input. The parsed value is what gets applied to filtering, and the same parser is used by the [Advanced Filter](./filter-advanced/) for `bigint` operands. Unless you also provide a `bigintFormatter`, have it accept a plain decimal too: without one, every input shows the stored value as a plain decimal and reads it back through this parser once edited. The filter model always stores the parsed value as a canonical decimal string, so provide a `bigintFormatter` — the inverse of the parser — to display stored values back in your own format. It is used by the filter inputs, by the [Floating Filter](./floating-filters/) and by the [Advanced Filter](./filter-advanced/) when displaying an operand, which means an entered value is echoed back in the formatter's format rather than exactly as typed. @@ -74,6 +74,8 @@ const gridOptions = { }; ``` +The `bigintParser` and `bigintFormatter` are also passed the grid `api` and `context` as a second argument, along with the `column` and `colDef` they are working on. One callback set on `defaultColDef.filterParams` can therefore serve every column it applies to. + ## BigInt Filter Model The Filter Model describes the current state of the applied BigInt Filter: diff --git a/documentation/ag-grid-docs/src/content/docs/filter-number/index.mdoc b/documentation/ag-grid-docs/src/content/docs/filter-number/index.mdoc index a8e6f1ff6fd..15966734dd1 100644 --- a/documentation/ag-grid-docs/src/content/docs/filter-number/index.mdoc +++ b/documentation/ag-grid-docs/src/content/docs/filter-number/index.mdoc @@ -68,17 +68,19 @@ const gridOptions = { } ``` -The `allowedCharPattern` is a regex of all the characters that are allowed to be typed. This is surrounded by square brackets `[]` and used as a character class to be compared against each typed character individually and prevent the character from appearing in the input if it does not match (in supported browsers). +The `allowedCharPattern` is a regex of all the characters that are allowed to be typed. A value starting `[` and ending `]` is used as written and must therefore match exactly one character; anything else is surrounded by square brackets `[]` for you. It is compared against each character an edit brings in, and an edit bringing in a character it does not admit is refused whole. That covers a paste and a drop as well as a keystroke. Text committed by an IME or another composing keyboard is not held to it, since a composition cannot be cancelled. A pattern that does not compile to a character pattern is reported as a warning and ignored. The `numberParser` should take the user-entered text and return either a number if one can be interpreted, or `null` if not. The `numberFormatter` should take a number (e.g. from the Filter Model) and convert it into the formatted text to be displayed, or `null` if no value. +`numberParser` and `numberFormatter` are also passed the grid `api` and `context` as a second argument, along with the `column` and `colDef` they are working on. One callback set on `defaultColDef.filterParams` can therefore serve every column it applies to. + An `allowedCharPattern` of `\\d\\-\\.` will give similar behaviour to the default `number` input. -A `text` input is used whenever either `allowedCharPattern` or `numberFormatter` is provided, as a `number` input keeps only its own number syntax and would discard formatted text. +A `text` input is used when `allowedCharPattern` or `numberFormatter` is provided, unless `filterInputType` says otherwise, as a `number` input keeps only its own number syntax and would discard formatted text. -Set `filterInputType` to choose the input yourself. A `numberFormatter` writing text a `number` input can hold, such as `(value) => value.toFixed(2)`, can keep that input with `filterInputType: 'number'`. An `allowedCharPattern` applies to either input, narrowing what a `number` input already accepts. +Set `filterInputType` to choose the input yourself. A `numberFormatter` writing text a `number` input can hold, such as `(value) => (value == null ? null : value.toFixed(2))`, can keep that input with `filterInputType: 'number'`. An `allowedCharPattern` applies to either input, narrowing what a `number` input already accepts. Provide a `numberParser` alongside a `numberFormatter` to have the format read back. Without one, typed text is read with `parseFloat`, so `1,234` becomes `1`. @@ -174,6 +176,26 @@ By default, the values supplied to the Number Filter are retrieved from the data {% apiDocumentation source="column-properties/properties.json" section="filtering" names=["filterValueGetter"] /%} +A [Value Formatter](./value-formatters/) that changes the number itself, such as one that rounds, leaves the column displaying one number and filtering on another, because the filter compares the value the grid holds rather than the formatted text. Return the displayed number from `filterValueGetter` and every filter option compares against that instead: + +```{% frameworkTransform=true %} +const gridOptions = { + columnDefs: [ + { + field: 'price', + filter: 'agNumberColumnFilter', + valueFormatter: ({ value }) => (value == null ? '' : value.toFixed(2)), + filterValueGetter: ({ getValue }) => { + const value = getValue('price'); + return value == null ? null : Number(value.toFixed(2)); + }, + } + ] +} +``` + +Where the displayed text is not itself a number, such as a currency, add `numberParser` to read what the user types and `numberFormatter` to write stored values back in the same form. The formatter gives the filter a `text` input on its own, so no `allowedCharPattern` is needed to make that text typeable — one would only narrow it, and a pattern omitting the symbols the formatter writes would stop the user typing them. + ## Applying the Number Filter Applying the Number Filter is described in more detail in the following sections: diff --git a/packages/ag-grid-community/src/agWidgets/agInputTextField.ts b/packages/ag-grid-community/src/agWidgets/agInputTextField.ts index c92d87ffe90..3ea593b489f 100644 --- a/packages/ag-grid-community/src/agWidgets/agInputTextField.ts +++ b/packages/ag-grid-community/src/agWidgets/agInputTextField.ts @@ -5,14 +5,7 @@ import type { BaseProperties, IPropertiesService, } from 'ag-stack'; -import { - _createAgElement, - _exists, - _isEventFromPrintableCharacter, - _setAriaInvalid, - _setAriaLabel, - _setDisplayed, -} from 'ag-stack'; +import { _createAgElement, _exists, _setAriaInvalid, _setAriaLabel, _setDisplayed } from 'ag-stack'; import type { AgAbstractInputFieldEvent } from './agAbstractInputField'; import { AgAbstractInputField } from './agAbstractInputField'; @@ -26,7 +19,6 @@ const CUSTOM_CLEAR_BUTTON_INPUT_TYPES: ReadonlySet = new Set(['number', export interface AgInputTextFieldParams< TComponentSelectorType extends string, > extends AgInputFieldParams { - allowedCharPattern?: string; clearButton?: boolean; onValueClear?: () => void; searchIcon?: boolean; @@ -65,11 +57,8 @@ export class AgInputTextField< public override postConstruct() { super.postConstruct(); - const { allowedCharPattern, clearButton, onValueClear, searchIcon } = this.config; + const { clearButton, onValueClear, searchIcon } = this.config; - if (allowedCharPattern) { - this.preventDisallowedCharacters(allowedCharPattern); - } if (clearButton) { this.setClearButtonEnabled(true); } @@ -212,34 +201,6 @@ export class AgInputTextField< eInput.classList.toggle('ag-input-field-input-with-clear-button', canDisplay); _setDisplayed(eClearButton, canDisplay && !!eInput.value); } - - private preventDisallowedCharacters(allowedCharPattern: string): void { - // Already a character class: wrapping it again would only ever match a two-character string, - // so every single keystroke would be rejected. - const isCharClass = allowedCharPattern.startsWith('[') && allowedCharPattern.endsWith(']'); - const pattern = new RegExp(isCharClass ? allowedCharPattern : `[${allowedCharPattern}]`); - - const preventCharacters = (event: KeyboardEvent) => { - if (!_isEventFromPrintableCharacter(event)) { - return; - } - - if (event.key && !pattern.test(event.key)) { - event.preventDefault(); - } - }; - - this.addManagedListeners(this.eInput, { - keydown: preventCharacters, - paste: (e: ClipboardEvent) => { - const text = e.clipboardData?.getData('text'); - - if (text?.split('').some((c) => !pattern.test(c))) { - e.preventDefault(); - } - }, - }); - } } /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ export const AgInputTextFieldSelector: AgComponentSelector = { diff --git a/packages/ag-grid-community/src/filter/filterLocaleText.ts b/packages/ag-grid-community/src/filter/filterLocaleText.ts index 06674c5e2fd..ed46c6b63e3 100644 --- a/packages/ag-grid-community/src/filter/filterLocaleText.ts +++ b/packages/ag-grid-community/src/filter/filterLocaleText.ts @@ -77,8 +77,12 @@ const FILTER_LOCALE_TEXT = { filterSummaryTextQuote: (variableValues: string[]) => `"${variableValues[0]}"`, minDateValidation: (variableValues: string[]) => `Date must be after ${variableValues[0]}`, maxDateValidation: (variableValues: string[]) => `Date must be before ${variableValues[0]}`, + minDateInclusiveValidation: (variableValues: string[]) => `Date must be on or after ${variableValues[0]}`, + maxDateInclusiveValidation: (variableValues: string[]) => `Date must be on or before ${variableValues[0]}`, strictMinValueValidation: (variableValues: string[]) => `Must be greater than ${variableValues[0]}`, strictMaxValueValidation: (variableValues: string[]) => `Must be less than ${variableValues[0]}`, + minValueValidation: (variableValues: string[]) => `Must be greater than or equal to ${variableValues[0]}`, + maxValueValidation: (variableValues: string[]) => `Must be less than or equal to ${variableValues[0]}`, }; export type FilterLocaleTextKey = keyof typeof FILTER_LOCALE_TEXT; diff --git a/packages/ag-grid-community/src/filter/floating/provided/floatingFilterTextInputService.ts b/packages/ag-grid-community/src/filter/floating/provided/floatingFilterTextInputService.ts index 2b6e55d190c..130e13c269e 100644 --- a/packages/ag-grid-community/src/filter/floating/provided/floatingFilterTextInputService.ts +++ b/packages/ag-grid-community/src/filter/floating/provided/floatingFilterTextInputService.ts @@ -1,9 +1,7 @@ import { RefPlaceholder, _getActiveDomElement } from 'ag-stack'; -import type { AgInputTextFieldParams } from '../../../agWidgets/agInputTextField'; import { AgInputTextField } from '../../../agWidgets/agInputTextField'; import { BeanStub } from '../../../context/beanStub'; -import type { AgComponentSelectorType } from '../../../widgets/component'; import type { GridInputTextField } from '../../../widgets/gridWidgetTypes'; import type { FloatingFilterInputService } from './iFloatingFilterInputService'; @@ -12,20 +10,23 @@ export class FloatingFilterTextInputService extends BeanStub implements Floating private onValueChanged: (e: KeyboardEvent) => void = () => {}; private onValueCleared: () => void = () => {}; - constructor(private readonly params?: { config?: AgInputTextFieldParams }) { + /** A hook, not the pattern itself: importing the guard here would put it in the text filter's bundle. */ + constructor(private readonly onInputCreated?: (field: GridInputTextField) => void) { super(); } public setupGui(parentElement: HTMLElement): void { - this.eInput = this.createManagedBean( + const field = this.createManagedBean( new AgInputTextField({ - ...this.params?.config, clearButton: true, onValueClear: () => this.onValueCleared(), }) ); + this.eInput = field; - const eInput = this.eInput.getGui(); + this.onInputCreated?.(field); + + const eInput = field.getGui(); parentElement.appendChild(eInput); diff --git a/packages/ag-grid-community/src/filter/floating/provided/simpleFloatingFilter.ts b/packages/ag-grid-community/src/filter/floating/provided/simpleFloatingFilter.ts index 25e8920747b..97d8c8d754d 100644 --- a/packages/ag-grid-community/src/filter/floating/provided/simpleFloatingFilter.ts +++ b/packages/ag-grid-community/src/filter/floating/provided/simpleFloatingFilter.ts @@ -1,5 +1,6 @@ import type { AgColumn } from '../../../entities/agColumn'; import type { FilterChangedEvent } from '../../../events'; +import type { Column } from '../../../interfaces/iColumn'; import { Component } from '../../../widgets/component'; import type { IProvidedFilterParams, ProvidedFilterModel } from '../../provided/iProvidedFilter'; import type { @@ -39,7 +40,8 @@ export abstract class SimpleFloatingFilter; protected setLastTypeFromModel(model: ProvidedFilterModel): void { @@ -51,16 +53,17 @@ export abstract class SimpleFloatingFilter; - condition = combinedModel.conditions[0]; + condition = combinedModel.conditions?.[0]; } else { condition = model as ISimpleFilterModel; } - this.lastType = condition.type; + // A combined model joining no conditions names no type, so the default stands as it does for no model. + this.lastType = condition ? condition.type : this.optionsFactory.defaultOption; } protected canWeEditAfterModelFromParentFilter(model: ProvidedFilterModel): boolean { @@ -100,7 +103,7 @@ export abstract class SimpleFloatingFilter { + if (!allowedCharPattern) { + return; + } + // Wrapping a character class again would only ever match two characters. No `g`, or `lastIndex` would + // carry between the characters `test` is asked about one by one. + const isCharClass = allowedCharPattern.startsWith('[') && allowedCharPattern.endsWith(']'); + let pattern: RegExp; + try { + pattern = new RegExp(isCharClass ? allowedCharPattern : `[${allowedCharPattern}]`); + } catch { + beans.log.warn(327, { pattern: allowedCharPattern }); + return; // Not thrown: this runs while the header and the filter panel are built. + } + + field.addManagedElementListeners(field.getInputElement(), { + beforeinput: (e: InputEvent) => { + const inputType = e.inputType; + // A deletion brings nothing in, and a composition cannot be cancelled, so an IME is not held to it. + if (!inputType?.startsWith('insert') || inputType === 'insertCompositionText') { + return; + } + // Paste and drop carry their text on the transfer, leaving `data` empty or absent by browser. + const inserted = e.data || e.dataTransfer?.getData('text/plain'); + if (!inserted || !e.cancelable) { + return; + } + for (const char of inserted) { + if (!pattern.test(char)) { + e.preventDefault(); + return; + } + } + }, + }); +}; diff --git a/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFilter.ts b/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFilter.ts index 231356412e8..78ae36fdaab 100644 --- a/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFilter.ts +++ b/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFilter.ts @@ -1,7 +1,7 @@ import type { FilterDisplayParams } from '../../../interfaces/iFilter'; import type { GridInputTextField } from '../../../widgets/gridWidgetTypes'; import type { ICombinedSimpleModel } from '../iSimpleFilter'; -import { getValidityMessageKey } from '../simpleFilterUtils'; +import { _bindFilterCallback, getValidityMessageKey } from '../simpleFilterUtils'; import type { RenderChange } from '../textInputSimpleFilter'; import { TextInputSimpleFilter } from '../textInputSimpleFilter'; import { DEFAULT_BIGINT_FILTER_OPTIONS } from './bigIntFilterConstants'; @@ -43,40 +43,44 @@ export class BigIntFilter extends TextInputSimpleFilter< text: string | null | undefined, params: BigIntFilterDisplayParams | undefined ): bigint | null { - return stringToBigInt(params?.bigintParser, text); + return stringToBigInt(params?.bigintParser, text, this.gos, this.params.column); } protected override getValueFormatter(): ((value: bigint | null) => string | null) | undefined { - return this.params.bigintFormatter; + return _bindFilterCallback(this.params.bigintFormatter, this.gos, this.params.column); } protected override createInputWidget(): GridInputTextField { - return this.createTextInput(getAllowedCharPattern(this.params)); + return this.createTextInput(); } protected override refreshInputPairValidation( from: GridInputTextField, to: GridInputTextField, - isFrom = false + isFrom: boolean, + numberOfInputs: number ): void { - const fromValue = this.readValue(from, true); - const toValue = this.readValue(to, true); - const fromInvalid = this.isInvalidValue(from, fromValue); - const toInvalid = this.isInvalidValue(to, toValue); - - const target = isFrom ? from : to; - const other = isFrom ? to : from; - const targetInvalid = isFrom ? fromInvalid : toInvalid; - const otherInvalid = isFrom ? toInvalid : fromInvalid; + // Past the condition's arity an input is mounted but unused, so reading it parses what nothing uses. + const isRange = numberOfInputs >= 2; + const fromValue = numberOfInputs > 0 ? this.readValue(from, true) : null; + const toValue = isRange ? this.readValue(to, true) : null; + const fromInvalid = numberOfInputs > 0 && this.isInvalidValue(from, fromValue); + const toInvalid = isRange && this.isInvalidValue(to, toValue); + + // Ordered by the edited input, except that a one-value option filters on `from` alone. + const targetIsFrom = isFrom || !isRange; + const target = targetIsFrom ? from : to; + const other = targetIsFrom ? to : from; + const targetInvalid = targetIsFrom ? fromInvalid : toInvalid; + const otherInvalid = targetIsFrom ? toInvalid : fromInvalid; let validityMessage = ''; if (targetInvalid) { - const translate = this.getLocaleTextFunc(); - validityMessage = translate('invalidBigInt', 'Invalid BigInt'); - } else if (!fromInvalid && !toInvalid) { - const localeKey = getValidityMessageKey(fromValue, toValue, isFrom); + validityMessage = this.getLocaleTextFunc()('invalidBigInt', 'Invalid BigInt'); + } else if (isRange && !otherInvalid) { + const localeKey = getValidityMessageKey(fromValue, toValue, isFrom, this.params.inRangeInclusive); if (localeKey) { - validityMessage = this.translate(localeKey, [String(isFrom ? to.getValue() : from.getValue())]); + validityMessage = this.translate(localeKey, [String(other.getValue())]); } } @@ -85,7 +89,7 @@ export class BigIntFilter extends TextInputSimpleFilter< other.setCustomValidity(''); } if (validityMessage.length > 0) { - this.beans.ariaAnnounce.announceValue(validityMessage, 'dateFilter'); + this.beans.ariaAnnounce.announceValue(validityMessage, 'filterValidation'); } } diff --git a/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFilterHandler.ts b/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFilterHandler.ts index 98f23193e8e..0dcfad75fd3 100644 --- a/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFilterHandler.ts +++ b/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFilterHandler.ts @@ -1,5 +1,6 @@ import { _parseBigIntOrNull } from 'ag-stack'; +import type { Column } from '../../../interfaces/iColumn'; import type { Comparator } from '../iScalarFilter'; import type { OptionsFactory } from '../optionsFactory'; import { ScalarFilterHandler } from '../scalarFilterHandler'; @@ -16,9 +17,10 @@ export class BigIntFilterHandler extends ScalarFilterHandler { diff --git a/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFilterModelFormatter.ts b/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFilterModelFormatter.ts index db41cede66d..ee2a013e979 100644 --- a/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFilterModelFormatter.ts +++ b/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFilterModelFormatter.ts @@ -1,6 +1,7 @@ import { _parseBigIntOrNull } from 'ag-stack'; import { SCALAR_FILTER_TYPE_KEYS, SimpleFilterModelFormatter } from '../simpleFilterModelFormatter'; +import { _bindFilterCallback } from '../simpleFilterUtils'; import type { BigIntFilterModel, IBigIntFilterParams } from './iBigIntFilter'; export class BigIntFilterModelFormatter extends SimpleFilterModelFormatter< @@ -11,7 +12,7 @@ export class BigIntFilterModelFormatter extends SimpleFilterModelFormatter< protected readonly filterTypeKeys = SCALAR_FILTER_TYPE_KEYS; protected override getValueFormatter(): ((value: bigint | null) => string | null) | undefined { - return this.filterParams.bigintFormatter; + return _bindFilterCallback(this.filterParams.bigintFormatter, this.gos, this.column); } protected conditionToString( diff --git a/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFilterUtils.ts b/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFilterUtils.ts index 61744542316..c137ec2a3b9 100644 --- a/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFilterUtils.ts +++ b/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFilterUtils.ts @@ -1,8 +1,10 @@ import { _parseBigIntOrNull } from 'ag-stack'; +import type { GridOptionsService } from '../../../gridOptionsService'; +import type { Column } from '../../../interfaces/iColumn'; import type { Tuple } from '../iSimpleFilter'; import type { OptionsFactory } from '../optionsFactory'; -import { getNumberOfInputs } from '../simpleFilterUtils'; +import { filterCallbackParams, getNumberOfInputs } from '../simpleFilterUtils'; import type { BigIntFilterModel, IBigIntFilterParams } from './iBigIntFilter'; export function getAllowedCharPattern(filterParams?: IBigIntFilterParams): string | null { @@ -12,12 +14,15 @@ export function getAllowedCharPattern(filterParams?: IBigIntFilterParams): strin /** The one reading of a typed value: `bigintParser` owns it wherever it is configured. */ export function stringToBigInt( bigintParser: IBigIntFilterParams['bigintParser'], - value?: string | null + value: string | null | undefined, + gos: GridOptionsService, + column: Column ): bigint | null { if (value == null || value.trim() === '') { return null; } - return bigintParser ? bigintParser(value) : _parseBigIntOrNull(value); + // Built here, not by the caller: the default configuration has no parser to hand them to. + return bigintParser ? bigintParser(value, filterCallbackParams(gos, column)) : _parseBigIntOrNull(value); } export function mapValuesFromBigIntFilterModel( diff --git a/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFloatingFilter.ts b/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFloatingFilter.ts index f6b9a126f8e..4f0d8e45171 100644 --- a/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFloatingFilter.ts +++ b/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFloatingFilter.ts @@ -1,6 +1,8 @@ +import type { Column } from '../../../interfaces/iColumn'; import { FloatingFilterTextInputService } from '../../floating/provided/floatingFilterTextInputService'; import type { FloatingFilterInputService } from '../../floating/provided/iFloatingFilterInputService'; import { TextInputFloatingFilter } from '../../floating/provided/textInputFloatingFilter'; +import { installAllowedCharPattern } from '../allowedCharPattern'; import type { OptionsFactory } from '../optionsFactory'; import { DEFAULT_BIGINT_FILTER_OPTIONS } from './bigIntFilterConstants'; import { BigIntFilterModelFormatter } from './bigIntFilterModelFormatter'; @@ -20,9 +22,10 @@ export class BigIntFloatingFilter extends TextInputFloatingFilter installAllowedCharPattern(el, allowedCharPattern, this.beans)) + ); } protected override convertValue(value: string | null | undefined): TValue | null { - return stringToBigInt(this.bigintParser, value) as TValue | null; + const { gos, params } = this; + return stringToBigInt(this.bigintParser, value, gos, params.column) as TValue | null; } } diff --git a/packages/ag-grid-community/src/filter/provided/bigInt/iBigIntFilter.ts b/packages/ag-grid-community/src/filter/provided/bigInt/iBigIntFilter.ts index e340d56e25e..79005739a96 100644 --- a/packages/ag-grid-community/src/filter/provided/bigInt/iBigIntFilter.ts +++ b/packages/ag-grid-community/src/filter/provided/bigInt/iBigIntFilter.ts @@ -1,4 +1,4 @@ -import type { IFilterParams } from '../../../interfaces/iFilter'; +import type { FilterInputCallbackParams, IFilterParams } from '../../../interfaces/iFilter'; import type { IScalarFilterParams } from '../iScalarFilter'; import type { CustomFilterOptionKey, @@ -29,30 +29,35 @@ export interface BigIntFilterModel extends ISimpleFilterModel { * Parameters provided by the grid to the `init` method of a `BigIntFilter`. * Do not use in `colDef.filterParams` - see `IBigIntFilterParams` instead. */ -export type BigIntFilterParams = IBigIntFilterParams & IFilterParams; +export type BigIntFilterParams = IBigIntFilterParams & + IFilterParams; /** * Parameters used in `colDef.filterParams` to configure a BigInt Filter (`agBigIntColumnFilter`). */ -export interface IBigIntFilterParams extends IScalarFilterParams { +export interface IBigIntFilterParams extends IScalarFilterParams { /** Array of filter options to present to the user. */ filterOptions?: (IFilterOptionDef | ScalarFilterOptionKey)[]; /** The default filter option to be selected. Must be one of the offered options. */ defaultOption?: ScalarFilterOptionKey | CustomFilterOptionKey; /** * When specified, the input field will be of type `text`, and this will be used as a regex of all the characters that are allowed to be typed. - * This will be compared against any typed character and prevent the character from appearing in the input if it does not match. + * It is compared against each character an edit brings in, and a keystroke, paste or drop bringing in a + * character it does not admit is refused whole. Text committed by an IME or another composing keyboard + * is not held to it, since a composition cannot be cancelled. */ allowedCharPattern?: string; /** * Typically used alongside `allowedCharPattern`, this provides a custom parser to convert the value entered in the filter inputs into a bigint that can be used for comparisons. + * Must also accept a plain decimal: without a `bigintFormatter` every input renders the stored value as + * one, and reads it back through this parser once edited. */ - bigintParser?: (text: string | null) => bigint | null; + bigintParser?: (text: string | null, params: FilterInputCallbackParams) => bigint | null; /** * Typically used alongside `allowedCharPattern`, this provides a custom formatter to convert the bigint value in the filter model * into a string to be used in the filter input. This is the inverse of the `bigintParser`. */ - bigintFormatter?: (value: bigint | null) => string | null; + bigintFormatter?: (value: bigint | null, params: FilterInputCallbackParams) => string | null; } export interface IBigIntFloatingFilterParams extends ITextInputFloatingFilterParams {} diff --git a/packages/ag-grid-community/src/filter/provided/date/dateFilter.ts b/packages/ag-grid-community/src/filter/provided/date/dateFilter.ts index bb45a8137e0..28896548824 100644 --- a/packages/ag-grid-community/src/filter/provided/date/dateFilter.ts +++ b/packages/ag-grid-community/src/filter/provided/date/dateFilter.ts @@ -8,7 +8,7 @@ import { _createElement } from '../../../utils/element'; import type { FilterLocaleTextKey } from '../../filterLocaleText'; import type { ICombinedSimpleModel, Tuple } from '../iSimpleFilter'; import { SimpleFilter } from '../simpleFilter'; -import { getNumberOfInputs, removeItems } from '../simpleFilterUtils'; +import { removeItems } from '../simpleFilterUtils'; import { DateCompWrapper } from './dateCompWrapper'; import type { ValidationReportMode } from './dateCompWrapper'; import { DEFAULT_DATE_FILTER_OPTIONS } from './dateFilterConstants'; @@ -40,19 +40,9 @@ export class DateFilter extends SimpleFilter= 2 ? getRangeValidityMessageKey(fromDate, toDate, isFrom) : null; + // An option taking one value has no order an input can be reported as out of. + const isRange = this.conditionNumberOfInputs(position) >= 2; + const localeKey = isRange + ? getRangeValidityMessageKey(fromDate, toDate, isFrom, this.params.inRangeInclusive) + : null; const message = localeKey ? this.translate(localeKey, [String(isFrom ? toDate : fromDate)]) : ''; // FF seems to handle cursors/focus sufficiently well for the validation to be left as synchronous. @@ -127,37 +115,46 @@ export class DateFilter extends SimpleFilter 0) { - beans.ariaAnnounce.announceValue(message, 'dateFilter'); + this.beans.ariaAnnounce.announceValue(message, 'filterValidation'); } } - private createDateCompWrapper(element: HTMLElement, position: number, fromTo: 'from' | 'to'): DateCompWrapper { + private createDateCompWrapper(element: HTMLElement, fromTo: 'from' | 'to'): DateCompWrapper { const { beans: { userCompFactory, context, gos }, params, } = this; const isFrom = fromTo === 'from'; - const dateCompWrapper = new DateCompWrapper( + // Read per event, never captured: removing a condition from the middle shifts every later one. + const panels = isFrom ? this.eConditionPanelsFrom : this.eConditionPanelsTo; + const refreshValidation = (reportMode: ValidationReportMode) => + this.refreshInputPairValidation(panels.indexOf(element), isFrom, reportMode); + return new DateCompWrapper( context, userCompFactory, params.colDef, _addGridCommonParams(gos, { onDateChanged: () => { - this.refreshInputPairValidation(position, isFrom, 'debounce'); + refreshValidation('debounce'); this.onUiChanged(); }, onDateCleared: () => { - this.refreshInputPairValidation(position, isFrom, 'immediate'); + refreshValidation('immediate'); this.onUiCleared(); }, - onFocusIn: () => this.refreshInputPairValidation(position, isFrom, 'debounceIfChanged'), + onFocusIn: () => refreshValidation('debounceIfChanged'), filterParams: params as any, location: 'filter', }), element ); - this.addDestroyFunc(() => dateCompWrapper.destroy()); - return dateCompWrapper; + } + + /** Not beans, and replaced as conditions come and go, so nothing else tears them down. */ + public override destroy(): void { + this.removeDateComps(this.dateConditionFromComps, 0); + this.removeDateComps(this.dateConditionToComps, 0); + super.destroy(); } protected override getState(): { isInvalid: boolean } { @@ -205,7 +202,7 @@ export class DateFilter extends SimpleFilter if theres no validity state, everything is fine - // ignore incomplete date values (getDate() == null) - this.forEachInput( - (element) => (invalidInputs ||= element.getDate() != null && !(element.getValidity()?.valid ?? true)) - ); - return invalidInputs; + /** No validity state means nothing has rejected the value, so it is fine. */ + protected override isInputInvalid(element: DateCompWrapper): boolean { + return !(element.getValidity()?.valid ?? true); } - protected override positionHasInvalidInputs(position: number): boolean { - let invalidInputs = false; - // Default validity state to true -> if theres no validity state, everything is fine - this.forEachPositionInput(position, (element) => (invalidInputs ||= !(element.getValidity()?.valid ?? true))); - return invalidInputs; - } - - protected override canApply(_model: DateFilterModel | ICombinedSimpleModel | null): boolean { - return !this.hasInvalidInputs(); + /** A date is read only once whole, so a part-typed one is not a value the picker has rejected. */ + protected override isInputValueSettled(element: DateCompWrapper): boolean { + return element.getDate() != null; } protected override isConditionUiComplete(position: number): boolean { @@ -317,17 +303,6 @@ export class DateFilter extends SimpleFilter { const { dateConditionFromComps, dateConditionToComps } = this; - if (position >= dateConditionFromComps.length) { + // Bounded below too: the position is looked up with `indexOf`, which reports a gone comp as -1. + if (position < 0 || position >= dateConditionFromComps.length) { return [null, null]; } return [dateConditionFromComps[position], dateConditionToComps[position]]; @@ -372,11 +348,16 @@ export class DateFilter extends SimpleFilter= toDate; + // An inclusive range of one date is an exact match, so only a strict one has nothing left to match. + const isInvalid = fromDate != null && toDate != null && (inclusive ? fromDate > toDate : fromDate >= toDate); if (!isInvalid) { return null; } - return `${isFrom ? 'max' : 'min'}DateValidation`; + if (inclusive) { + return isFrom ? 'maxDateInclusiveValidation' : 'minDateInclusiveValidation'; + } + return isFrom ? 'maxDateValidation' : 'minDateValidation'; } diff --git a/packages/ag-grid-community/src/filter/provided/date/dateFilterHandler.test.ts b/packages/ag-grid-community/src/filter/provided/date/dateFilterHandler.test.ts index 2e0baf572ac..a64e0e65aa0 100644 --- a/packages/ag-grid-community/src/filter/provided/date/dateFilterHandler.test.ts +++ b/packages/ag-grid-community/src/filter/provided/date/dateFilterHandler.test.ts @@ -166,19 +166,65 @@ describe('getOrRefreshRangeCacheItem', () => { vi.useRealTimers(); }); - it('returns cached range for the same key before expiry', () => { + // Start the clock away from midnight: at the epoch an expiry timestamp and an elapsed time are the same + // number, so a cache that never hits still looks like one that does. + it.each([ + ['at the epoch', new Date(0), new Date(1000)], + ['later the same day', new Date('2026-08-10T09:00:00Z'), new Date('2026-08-10T09:00:01Z')], + ])('returns cached range for the same key before expiry, %s', (_name, start, next) => { + vi.setSystemTime(start); const handler = new DateFilterHandler(); const rangeFn = vi.fn(() => [new Date(1), new Date(2)] as [Date, Date]); const first = handler.getOrRefreshRangeCacheItem(key, rangeFn); + vi.setSystemTime(next); const second = handler.getOrRefreshRangeCacheItem(key, rangeFn); expect(rangeFn).toHaveBeenCalledTimes(1); - expect(first.from).toBe(second.from); - expect(first.to).toBe(second.to); + expect([first.fromTime, first.toTime]).toStrictEqual([1, 2]); + expect([second.fromTime, second.toTime]).toStrictEqual([1, 2]); + }); + + // The range reaches a user `comparator` as dates it is free to normalise, so the cache keeps times: + // there is nothing in it a caller holds a reference to. + it('caches times rather than the dates the range function built', () => { + const handler = new DateFilterHandler(); + const built: Date[] = []; + const rangeFn = vi.fn(() => { + const range: [Date, Date] = [new Date(1_000), new Date(2_000)]; + built.push(...range); + return range; + }); + + const cached = handler.getOrRefreshRangeCacheItem(key, rangeFn); + for (const date of built) { + date.setTime(999_999); + } + + expect(Object.values(cached).every((value) => typeof value === 'number')).toBe(true); + expect([cached.fromTime, cached.toTime]).toStrictEqual([1_000, 2_000]); + }); + + // Built from local parts, since the expiry is the next *local* midnight: pinning the clock to a UTC + // offset would put the boundary an hour out in any zone the suite is not run in. + const MIDMORNING = new Date(2026, 7, 10, 9, 0, 0); + const NEXT_MIDNIGHT = new Date(2026, 7, 11, 0, 0, 0); + + // The ranges are half-open, so the expiry instant already belongs to the day the cached range excludes. + it('refreshes the cache at the instant it expires', () => { + vi.setSystemTime(MIDMORNING); + const handler = new DateFilterHandler(); + const rangeFn = vi.fn(() => [new Date(1), new Date(2)] as [Date, Date]); + + handler.getOrRefreshRangeCacheItem(key, rangeFn); + vi.setSystemTime(NEXT_MIDNIGHT); + handler.getOrRefreshRangeCacheItem(key, rangeFn); + + expect(rangeFn).toHaveBeenCalledTimes(2); }); it('refreshes the cache when expired', () => { + vi.setSystemTime(MIDMORNING); const handler = new DateFilterHandler(); const rangeFn = vi .fn() @@ -187,14 +233,13 @@ describe('getOrRefreshRangeCacheItem', () => { const first = handler.getOrRefreshRangeCacheItem(key, rangeFn); - vi.setSystemTime(new Date(86_400_001)); + vi.setSystemTime(new Date(NEXT_MIDNIGHT.getTime() + 1)); const second = handler.getOrRefreshRangeCacheItem(key, rangeFn); expect(rangeFn).toHaveBeenCalledTimes(2); - expect(first.from).not.toBe(second.from); - expect(first.to).not.toBe(second.to); - expect([second.from, second.to].map((date) => date.getTime())).toStrictEqual([3, 4]); + expect([first.fromTime, first.toTime]).toStrictEqual([1, 2]); + expect([second.fromTime, second.toTime]).toStrictEqual([3, 4]); }); it('keeps separate caches per key', () => { @@ -207,7 +252,7 @@ describe('getOrRefreshRangeCacheItem', () => { expect(rangeFnToday).toHaveBeenCalledTimes(1); expect(rangeFnYesterday).toHaveBeenCalledTimes(1); - expect([today.from, today.to].map((date) => date.getTime())).toStrictEqual([10, 20]); - expect([yesterday.from, yesterday.to].map((date) => date.getTime())).toStrictEqual([30, 40]); + expect([today.fromTime, today.toTime]).toStrictEqual([10, 20]); + expect([yesterday.fromTime, yesterday.toTime]).toStrictEqual([30, 40]); }); }); diff --git a/packages/ag-grid-community/src/filter/provided/date/dateFilterHandler.ts b/packages/ag-grid-community/src/filter/provided/date/dateFilterHandler.ts index 1a25926db3f..ddc75489794 100644 --- a/packages/ag-grid-community/src/filter/provided/date/dateFilterHandler.ts +++ b/packages/ag-grid-community/src/filter/provided/date/dateFilterHandler.ts @@ -1,3 +1,4 @@ +import type { Column } from '../../../interfaces/iColumn'; import type { Comparator } from '../iScalarFilter'; import type { ISimpleFilterModelPresetType, Tuple } from '../iSimpleFilter'; import type { OptionsFactory } from '../optionsFactory'; @@ -21,7 +22,7 @@ function defaultDateComparator(filterDate: Date, cellValue: any): number { return 0; } -type Range = { from: Date; to: Date }; +type Range = { fromTime: number; toTime: number }; interface RangeCacheItem extends Range { expires: number; @@ -37,21 +38,28 @@ export class DateFilterHandler extends ScalarFilterHandler [Date, Date]): Range { + /** Times rather than dates: nothing a user `comparator` can normalise ever reaches the cache. */ + public getOrRefreshRangeCacheItem( + key: ISimpleFilterModelPresetType, + rangeFn: (s: Date, e: Date) => [Date, Date] + ): Range { const { filterTypeToRangeCache } = this; const now = Date.now(); let cache = filterTypeToRangeCache.get(key); - if (cache && cache.expires < now) { + // The ranges are half-open, so at the instant it expires the cached one already excludes `now`. + if (cache && cache.expires <= now) { cache = undefined; } if (!cache) { const [from, to] = rangeFn(new Date(now), new Date(now)); - cache = { from, to, expires: setStartOfNextDay(new Date(now)).getTime() - now }; + const expires = setStartOfNextDay(new Date(now)).getTime(); + cache = { fromTime: from.getTime(), toTime: to.getTime(), expires }; filterTypeToRangeCache.set(key, cache); } return cache; @@ -72,7 +80,6 @@ export class DateFilterHandler extends ScalarFilterHandler= 0 && comparator(to, cellValue) < 0; + const { fromTime, toTime } = this.getOrRefreshRangeCacheItem(maybeTypeAsPreset, presetDateRangeFn); + const userComparator = this.params.filterParams.comparator; + if (userComparator) { + // Dates of its own, built for each of the rows this runs on: a user comparator is free to + // keep or to normalise whatever it is handed. + return ( + userComparator(new Date(fromTime), cellValue) >= 0 && + userComparator(new Date(toTime), cellValue) < 0 + ); + } + // Half-open, as `defaultDateComparator` makes it, and compared as times since nothing is handed a date. + const cellTime = +cellValue; + return cellTime >= fromTime && cellTime < toTime; } return super.evaluateNonNullValue(values, cellValue, filterModel); diff --git a/packages/ag-grid-community/src/filter/provided/date/dateFloatingFilter.ts b/packages/ag-grid-community/src/filter/provided/date/dateFloatingFilter.ts index 96d2f83d84f..9151be5fc19 100644 --- a/packages/ag-grid-community/src/filter/provided/date/dateFloatingFilter.ts +++ b/packages/ag-grid-community/src/filter/provided/date/dateFloatingFilter.ts @@ -4,6 +4,7 @@ import { AgInputTextFieldSelector } from '../../../agWidgets/agInputTextField'; import type { AgColumn } from '../../../entities/agColumn'; import { _addGridCommonParams } from '../../../gridOptionsUtils'; import type { IDateParams } from '../../../interfaces/dateComponent'; +import type { Column } from '../../../interfaces/iColumn'; import type { ElementParams } from '../../../utils/element'; import type { GridInputTextField } from '../../../widgets/gridWidgetTypes'; import type { FloatingFilterDisplayParams, IFloatingFilterParams } from '../../floating/floatingFilter'; @@ -44,9 +45,10 @@ export class DateFloatingFilter extends SimpleFloatingFilter): void { diff --git a/packages/ag-grid-community/src/filter/provided/date/defaultDateComponent.ts b/packages/ag-grid-community/src/filter/provided/date/defaultDateComponent.ts index 55df53d6fb4..8f6fda2e55e 100644 --- a/packages/ag-grid-community/src/filter/provided/date/defaultDateComponent.ts +++ b/packages/ag-grid-community/src/filter/provided/date/defaultDateComponent.ts @@ -98,12 +98,9 @@ export class DefaultDateComponent extends Component implements IDateComp { const shouldUseBrowserDatePicker = this.shouldUseBrowserDatePicker(params); this.usingSafariDatePicker = shouldUseBrowserDatePicker && _isBrowserSafari(); - const { minValidYear, maxValidYear, minValidDate, maxValidDate, buttons, includeTime, colDef } = - params.filterParams || {}; + const { minValidYear, maxValidYear, minValidDate, maxValidDate, buttons } = params.filterParams || {}; - const dataTypeSvc = this.beans.dataTypeSvc; - const shouldUseDateTimeLocal = - includeTime ?? dataTypeSvc?.getDateIncludesTimeFlag?.(colDef.cellDataType) ?? false; + const shouldUseDateTimeLocal = this.includesTime(params); if (shouldUseBrowserDatePicker) { if (shouldUseDateTimeLocal) { @@ -141,9 +138,13 @@ export class DefaultDateComponent extends Component implements IDateComp { } public setDate(date: Date): void { - const colType = this.params.filterParams.colDef.cellDataType; - const includeTime = this.beans.dataTypeSvc?.getDateIncludesTimeFlag(colType) ?? false; - this.eDateInput.setValue(_serialiseDate(date, includeTime)); + // Must match the input type `setParams` chose: a picker blanks a value it cannot read. + this.eDateInput.setValue(_serialiseDate(date, this.includesTime(this.params))); + } + + private includesTime(params: IDateParams): boolean { + const { includeTime, colDef } = params.filterParams || {}; + return includeTime ?? this.beans.dataTypeSvc?.getDateIncludesTimeFlag?.(colDef?.cellDataType) ?? false; } public setInputPlaceholder(placeholder: string): void { diff --git a/packages/ag-grid-community/src/filter/provided/iSimpleFilter.ts b/packages/ag-grid-community/src/filter/provided/iSimpleFilter.ts index 54d445654df..6fbea1b9a3a 100644 --- a/packages/ag-grid-community/src/filter/provided/iSimpleFilter.ts +++ b/packages/ag-grid-community/src/filter/provided/iSimpleFilter.ts @@ -63,6 +63,7 @@ export interface ISimpleFilterParams extends IProvidedFilterParams, IAutoComplet defaultJoinOperator?: JoinOperator; /** * Maximum number of conditions allowed in the filter. + * Must be at least one - anything smaller is treated as one. * * @default 2 */ @@ -72,6 +73,7 @@ export interface ISimpleFilterParams extends IProvidedFilterParams, IAutoComplet * (up to `maxNumConditions`). To have more conditions shown by default, set this to the number required. * Conditions will be disabled until the previous conditions have been entered. * Note that this cannot be greater than `maxNumConditions` - anything larger will be ignored. + * Must be at least one - anything smaller is treated as one. * * @default 1 */ diff --git a/packages/ag-grid-community/src/filter/provided/number/iNumberFilter.ts b/packages/ag-grid-community/src/filter/provided/number/iNumberFilter.ts index 528b3505dd3..b02656a3985 100644 --- a/packages/ag-grid-community/src/filter/provided/number/iNumberFilter.ts +++ b/packages/ag-grid-community/src/filter/provided/number/iNumberFilter.ts @@ -1,4 +1,4 @@ -import type { IFilterParams } from '../../../interfaces/iFilter'; +import type { FilterInputCallbackParams, IFilterParams } from '../../../interfaces/iFilter'; import type { IScalarFilterParams } from '../iScalarFilter'; import type { CustomFilterOptionKey, @@ -29,20 +29,24 @@ export interface NumberFilterModel extends ISimpleFilterModel { * Parameters provided by the grid to the `init` method of a `NumberFilter`. * Do not use in `colDef.filterParams` - see `INumberFilterParams` instead. */ -export type NumberFilterParams = INumberFilterParams & IFilterParams; +export type NumberFilterParams = INumberFilterParams & + IFilterParams; /** * Parameters used in `colDef.filterParams` to configure a Number Filter (`agNumberColumnFilter`). */ -export interface INumberFilterParams extends IScalarFilterParams { +export interface INumberFilterParams extends IScalarFilterParams { /** Array of filter options to present to the user. */ filterOptions?: (IFilterOptionDef | ScalarFilterOptionKey)[]; /** The default filter option to be selected. Must be one of the offered options. */ defaultOption?: ScalarFilterOptionKey | CustomFilterOptionKey; /** * When specified, this will be used as a regex of all the characters that are allowed to be typed. - * This will be compared against any typed character and prevent the character from appearing in the input if it does not match. - * Either this or `numberFormatter` makes the input field of type `text`, unless `filterInputType` says otherwise. + * It is compared against each character an edit brings in, and a keystroke, paste or drop bringing in a + * character it does not admit is refused whole. Text committed by an IME or another composing keyboard + * is not held to it, since a composition cannot be cancelled. + * Either this or `numberFormatter` makes the input field of type `text`, unless `filterInputType` + * says otherwise. */ allowedCharPattern?: string; /** @@ -58,13 +62,13 @@ export interface INumberFilterParams extends IScalarFilterParams { * The Advanced Filter reads this column's operands with it only when a `numberFormatter` is provided too: * without one an operand is written as a plain decimal, which the default parser is what reads back. */ - numberParser?: (text: string | null) => number | null; + numberParser?: (text: string | null, params: FilterInputCallbackParams) => number | null; /** * Provides a custom formatter to convert the number value in the filter model into a string to be used in the * filter input. This is the inverse of the `numberParser`. Often used alongside `allowedCharPattern`, but either * one on its own makes the filter use a text input, since a number input would discard the formatted text. */ - numberFormatter?: (value: number | null) => string | null; + numberFormatter?: (value: number | null, params: FilterInputCallbackParams) => string | null; } export interface INumberFloatingFilterParams extends ITextInputFloatingFilterParams {} diff --git a/packages/ag-grid-community/src/filter/provided/number/numberFilter.ts b/packages/ag-grid-community/src/filter/provided/number/numberFilter.ts index cc63d585339..6b5d97886a5 100644 --- a/packages/ag-grid-community/src/filter/provided/number/numberFilter.ts +++ b/packages/ag-grid-community/src/filter/provided/number/numberFilter.ts @@ -2,7 +2,7 @@ import { AgInputNumberField } from '../../../agWidgets/agInputNumberField'; import type { FilterDisplayParams } from '../../../interfaces/iFilter'; import type { GridInputNumberField, GridInputTextField } from '../../../widgets/gridWidgetTypes'; import type { ICombinedSimpleModel } from '../iSimpleFilter'; -import { getValidityMessageKey } from '../simpleFilterUtils'; +import { _bindFilterCallback, getValidityMessageKey } from '../simpleFilterUtils'; import type { RenderChange } from '../textInputSimpleFilter'; import { TextInputSimpleFilter } from '../textInputSimpleFilter'; import type { INumberFilterParams, NumberFilterModel } from './iNumberFilter'; @@ -55,22 +55,20 @@ export class NumberFilter extends TextInputSimpleFilter< text: string | null | undefined, params: NumberFilterDisplayParams | undefined ): number | null { - return processNumberFilterValue(stringToFloat(params?.numberParser, text)); + return processNumberFilterValue(stringToFloat(params?.numberParser, text, this.gos, this.params.column)); } protected override getValueFormatter(): ((value: number | null) => string | null) | undefined { - return this.params.numberFormatter; + return _bindFilterCallback(this.params.numberFormatter, this.gos, this.params.column); } protected override createInputWidget(): NumberInput { const params = this.params; - const allowedCharPattern = getAllowedCharPattern(params); if (usesTextInput(params)) { - return this.createTextInput(allowedCharPattern); + return this.createTextInput(); } return this.createBean( new AgInputNumberField({ - allowedCharPattern: allowedCharPattern ?? undefined, clearButton: true, searchIcon: true, autoComplete: params.browserAutoComplete, @@ -78,15 +76,24 @@ export class NumberFilter extends TextInputSimpleFilter< ); } - protected override refreshInputPairValidation(from: NumberInput, to: NumberInput, isFrom = false): void { - const fromValue = this.readValue(from, true); - const toValue = this.readValue(to, true); - const localeKey = getValidityMessageKey(fromValue, toValue, isFrom); - const validityMessage = localeKey ? this.translate(localeKey, [String(isFrom ? toValue : fromValue)]) : ''; + protected override refreshInputPairValidation( + from: NumberInput, + to: NumberInput, + isFrom: boolean, + numberOfInputs: number + ): void { + // Only a two-value option has an order to be out of, and reading a value runs the column's own parser. + let validityMessage = ''; + if (numberOfInputs >= 2) { + const fromValue = this.readValue(from, true); + const toValue = this.readValue(to, true); + const localeKey = getValidityMessageKey(fromValue, toValue, isFrom, this.params.inRangeInclusive); + validityMessage = localeKey ? this.translate(localeKey, [String(isFrom ? toValue : fromValue)]) : ''; + } (isFrom ? from : to).setCustomValidity(validityMessage); // Set validity error state for target input (isFrom ? to : from).setCustomValidity(''); // Reset validity error state for other input if (validityMessage.length > 0) { - this.beans.ariaAnnounce.announceValue(validityMessage, 'dateFilter'); + this.beans.ariaAnnounce.announceValue(validityMessage, 'filterValidation'); } } diff --git a/packages/ag-grid-community/src/filter/provided/number/numberFilterHandler.ts b/packages/ag-grid-community/src/filter/provided/number/numberFilterHandler.ts index 0104479719a..e5e302c3107 100644 --- a/packages/ag-grid-community/src/filter/provided/number/numberFilterHandler.ts +++ b/packages/ag-grid-community/src/filter/provided/number/numberFilterHandler.ts @@ -1,3 +1,4 @@ +import type { Column } from '../../../interfaces/iColumn'; import type { Comparator } from '../iScalarFilter'; import type { OptionsFactory } from '../optionsFactory'; import { ScalarFilterHandler } from '../scalarFilterHandler'; @@ -14,9 +15,10 @@ export class NumberFilterHandler extends ScalarFilterHandler { diff --git a/packages/ag-grid-community/src/filter/provided/number/numberFilterModelFormatter.ts b/packages/ag-grid-community/src/filter/provided/number/numberFilterModelFormatter.ts index c2c224dfa23..70786172610 100644 --- a/packages/ag-grid-community/src/filter/provided/number/numberFilterModelFormatter.ts +++ b/packages/ag-grid-community/src/filter/provided/number/numberFilterModelFormatter.ts @@ -1,4 +1,5 @@ import { SCALAR_FILTER_TYPE_KEYS, SimpleFilterModelFormatter } from '../simpleFilterModelFormatter'; +import { _bindFilterCallback } from '../simpleFilterUtils'; import type { INumberFilterParams, NumberFilterModel } from './iNumberFilter'; export class NumberFilterModelFormatter extends SimpleFilterModelFormatter< @@ -9,7 +10,7 @@ export class NumberFilterModelFormatter extends SimpleFilterModelFormatter< protected readonly filterTypeKeys = SCALAR_FILTER_TYPE_KEYS; protected override getValueFormatter(): ((value: number | null) => string | null) | undefined { - return this.filterParams.numberFormatter; + return _bindFilterCallback(this.filterParams.numberFormatter, this.gos, this.column); } protected conditionToString( diff --git a/packages/ag-grid-community/src/filter/provided/number/numberFilterUtils.test.ts b/packages/ag-grid-community/src/filter/provided/number/numberFilterUtils.test.ts index 9c23542c412..3abb162a15e 100644 --- a/packages/ag-grid-community/src/filter/provided/number/numberFilterUtils.test.ts +++ b/packages/ag-grid-community/src/filter/provided/number/numberFilterUtils.test.ts @@ -1,5 +1,15 @@ +import type { ColDef } from '../../../entities/colDef'; +import type { GridOptionsService } from '../../../gridOptionsService'; +import type { Column } from '../../../interfaces/iColumn'; +import type { FilterInputCallbackParams } from '../../../interfaces/iFilter'; import { stringToFloat } from './numberFilterUtils'; +// Only reached when a `numberParser` is configured; the params it is handed are built from these. Stubs +// rather than real beans: `getColDef` and `addCommon` are the whole of what the call touches. +const COL_DEF: ColDef = { field: 'val' }; +const COLUMN = { getColDef: () => COL_DEF } as unknown as Column; +const GOS = { addCommon: (params: object) => ({ ...params, api: {}, context: {} }) } as unknown as GridOptionsService; + // A `number` input keeps scientific notation as the text the user typed — `1e3` is a valid floating-point // number to the HTML parser — so what reads an input back has to read that notation too. The behavioural // suite cannot cover it: happy-dom reports such an input as invalid, where every supported browser does not. @@ -10,7 +20,7 @@ describe('stringToFloat', () => { ['1.5e-3', 0.0015], ['-2.5e2', -250], ])('reads scientific notation %s as %s', (text, expected) => { - expect(stringToFloat(undefined, text)).toBe(expected); + expect(stringToFloat(undefined, text, GOS, COLUMN)).toBe(expected); }); test.each([ @@ -23,35 +33,36 @@ describe('stringToFloat', () => { ['a padded number, which parses as the number', ' 5 ', 5], ['absent', null, null], ])('reads %s as %s', (_name, text, expected) => { - expect(stringToFloat(undefined, text)).toBe(expected); + expect(stringToFloat(undefined, text, GOS, COLUMN)).toBe(expected); }); test.each([ ['a number', 1000, 1000], ['zero, which is a value and not a blank', 0, 0], ])('takes %s as already read', (_name, value, expected) => { - expect(stringToFloat(undefined, value)).toBe(expected); + expect(stringToFloat(undefined, value, GOS, COLUMN)).toBe(expected); // A parser reads text; there is none to read when the value already is a number. - expect(stringToFloat(() => 42, value)).toBe(expected); + expect(stringToFloat(() => 42, value, GOS, COLUMN)).toBe(expected); }); test('a numberParser owns the reading wherever one is configured', () => { const parser = (text: string | null) => (text === 'one thousand' ? 1000 : null); - expect(stringToFloat(parser, 'one thousand')).toBe(1000); + expect(stringToFloat(parser, 'one thousand', GOS, COLUMN)).toBe(1000); // Blank reaches the parser as null rather than as text it never has to recognise. - expect(stringToFloat(parser, ' ')).toBe(null); + expect(stringToFloat(parser, ' ', GOS, COLUMN)).toBe(null); // The default reading does not apply underneath a parser that rejected the text. - expect(stringToFloat(parser, '1e3')).toBe(null); + expect(stringToFloat(parser, '1e3', GOS, COLUMN)).toBe(null); }); - test('a numberParser is handed the text as typed, whitespace included', () => { - const seen: (string | null)[] = []; - const parser = (text: string | null) => { - seen.push(text); + test('a numberParser is handed the text as typed, whitespace included, and the column alongside it', () => { + const seen: [string | null, FilterInputCallbackParams][] = []; + const parser = (text: string | null, common: FilterInputCallbackParams) => { + seen.push([text, common]); return null; }; - stringToFloat(parser, ' 5 '); - stringToFloat(parser, ' '); - expect(seen).toEqual([' 5 ', null]); + stringToFloat(parser, ' 5 ', GOS, COLUMN); + stringToFloat(parser, ' ', GOS, COLUMN); + expect(seen.map(([text]) => text)).toEqual([' 5 ', null]); + expect(seen.every(([, common]) => common.column === COLUMN && common.colDef === COL_DEF)).toBe(true); }); }); diff --git a/packages/ag-grid-community/src/filter/provided/number/numberFilterUtils.ts b/packages/ag-grid-community/src/filter/provided/number/numberFilterUtils.ts index a9927b7ff46..14658daadc2 100644 --- a/packages/ag-grid-community/src/filter/provided/number/numberFilterUtils.ts +++ b/packages/ag-grid-community/src/filter/provided/number/numberFilterUtils.ts @@ -1,6 +1,8 @@ +import type { GridOptionsService } from '../../../gridOptionsService'; +import type { Column } from '../../../interfaces/iColumn'; import type { Tuple } from '../iSimpleFilter'; import type { OptionsFactory } from '../optionsFactory'; -import { getNumberOfInputs } from '../simpleFilterUtils'; +import { filterCallbackParams, getNumberOfInputs } from '../simpleFilterUtils'; import type { INumberFilterParams, NumberFilterModel } from './iNumberFilter'; export function getAllowedCharPattern(filterParams?: INumberFilterParams): string | null { @@ -19,7 +21,9 @@ export function usesTextInput(filterParams?: INumberFilterParams): boolean { /** The one reading of a typed value: `numberParser` owns it wherever it is configured. */ export function stringToFloat( numberParser: INumberFilterParams['numberParser'], - value?: string | number | null + value: string | number | null | undefined, + gos: GridOptionsService, + column: Column ): number | null { if (typeof value === 'number') { return value; @@ -29,8 +33,9 @@ export function stringToFloat( // The parser gets the text as typed; only the emptiness and half-typed tests want it trimmed. const filterText = trimmed === '' ? null : (value ?? null); + // Built here, not by the caller: this runs several times per keystroke and usually has no parser to pay for. if (numberParser) { - return numberParser(filterText); + return numberParser(filterText, filterCallbackParams(gos, column)); } return filterText == null || trimmed === '-' ? null : Number.parseFloat(filterText); diff --git a/packages/ag-grid-community/src/filter/provided/number/numberFloatingFilter.ts b/packages/ag-grid-community/src/filter/provided/number/numberFloatingFilter.ts index 4e7124ec9b2..7e30978bba3 100644 --- a/packages/ag-grid-community/src/filter/provided/number/numberFloatingFilter.ts +++ b/packages/ag-grid-community/src/filter/provided/number/numberFloatingFilter.ts @@ -3,10 +3,12 @@ import { _getActiveDomElement } from 'ag-stack'; import { AgInputNumberField } from '../../../agWidgets/agInputNumberField'; import { AgInputTextField } from '../../../agWidgets/agInputTextField'; import { BeanStub } from '../../../context/beanStub'; +import type { Column } from '../../../interfaces/iColumn'; import type { GridInputNumberField, GridInputTextField } from '../../../widgets/gridWidgetTypes'; import { FloatingFilterTextInputService } from '../../floating/provided/floatingFilterTextInputService'; import type { FloatingFilterInputService } from '../../floating/provided/iFloatingFilterInputService'; import { TextInputFloatingFilter } from '../../floating/provided/textInputFloatingFilter'; +import { installAllowedCharPattern } from '../allowedCharPattern'; import type { OptionsFactory } from '../optionsFactory'; import type { INumberFilterParams, @@ -26,26 +28,30 @@ class FloatingFilterNumberInputService extends BeanStub implements FloatingFilte private numberInputActive = true; - constructor(private readonly allowedCharPattern: string | null) { + /** Matches the text service: a hook, so the guard is imported by the filters that offer it. */ + constructor(private readonly onInputCreated?: (field: GridInputNumberField) => void) { super(); } public setupGui(parentElement: HTMLElement): void { - this.eNumberInput = this.createManagedBean( + const numberField = this.createManagedBean( new AgInputNumberField({ - allowedCharPattern: this.allowedCharPattern ?? undefined, clearButton: true, onValueClear: () => this.onValueCleared(), }) ); - this.eTextInput = this.createManagedBean( + this.eNumberInput = numberField; + this.onInputCreated?.(numberField); + + const textField = this.createManagedBean( new AgInputTextField({ clearButton: true, onValueClear: () => this.onValueCleared() }) ); + this.eTextInput = textField; + // Never typed into: it stands in for the number input while the filter is not editable. + textField.setDisabled(true); - this.eTextInput.setDisabled(true); - - const eNumberInput = this.eNumberInput.getGui(); - const eTextInput = this.eTextInput.getGui(); + const eNumberInput = numberField.getGui(); + const eTextInput = textField.getGui(); parentElement.appendChild(eNumberInput); parentElement.appendChild(eTextInput); @@ -136,9 +142,10 @@ export class NumberFloatingFilter extends TextInputFloatingFilter + installAllowedCharPattern(el, allowedCharPattern, this.beans); + return this.createManagedBean( + isTextInput ? new FloatingFilterTextInputService(install) : new FloatingFilterNumberInputService(install) + ); } /** Read back through `numberParser`, which is the only thing that can read what a `numberFormatter` wrote. */ protected override convertValue(value: string | null | undefined): TValue | null { - const numberParser = (this.params.filterParams as NumberFilterParams | undefined)?.numberParser; - return processNumberFilterValue(stringToFloat(numberParser, value)) as TValue | null; + const { gos, params } = this; + const numberParser = (params.filterParams as NumberFilterParams | undefined)?.numberParser; + return processNumberFilterValue(stringToFloat(numberParser, value, gos, params.column)) as TValue | null; } } diff --git a/packages/ag-grid-community/src/filter/provided/simpleFilter.ts b/packages/ag-grid-community/src/filter/provided/simpleFilter.ts index 20b25cfa4c4..c900a4355d6 100644 --- a/packages/ag-grid-community/src/filter/provided/simpleFilter.ts +++ b/packages/ag-grid-community/src/filter/provided/simpleFilter.ts @@ -2,6 +2,7 @@ import { AgPromise, _addOrRemoveAttribute, _areEqual, + _isBrowserFirefox, _isComponent, _removeFromParent, _setDisabled, @@ -39,6 +40,7 @@ import { getPlaceholderText, translateFilterOption } from './providedFilterUtils import { getDefaultJoinOperator, getNumberOfInputs, + isBelowConditionFloor, removeItems, validateAndUpdateConditions, } from './simpleFilterUtils'; @@ -73,6 +75,11 @@ export abstract class SimpleFilter< protected readonly eJoinOrs: GridRadioButton[] = []; protected readonly eConditionBodies: HTMLElement[] = []; private readonly listener = () => this.onUiChanged(); + /** The chosen option decides which inputs the condition uses, so their messages go stale with it. */ + private readonly typeListener = () => { + this.refreshInputValidation(); + this.onUiChanged(); + }; private maxNumConditions: number; private numAlwaysVisibleConditions: number; @@ -96,20 +103,33 @@ export abstract class SimpleFilter< protected abstract removeEValues(startPosition: number, deleteCount?: number): void; - // filter uses this to know if new model is different from previous model, ie if filter has changed + /** Decides whether the model has changed, and so whether the filter has. */ protected abstract areSimpleModelsEqual(a: ISimpleFilterModel, b: ISimpleFilterModel): boolean; - // getModel() calls this to create the two conditions. if only one condition, - // the result is returned by getModel(), otherwise is called twice and both results - // returned in a CombinedFilter object. + /** Called once per condition; a lone one is the model, more are joined into a combined one. */ protected abstract createCondition(position: number): M; - // allow iteration of all condition inputs managed by sub-classes. + /** The condition's inputs, held by the subclass that mounts them. */ protected abstract getInputs(position: number): Tuple; - // allow retrieval of all condition input values. + /** What those inputs currently hold, read through the subclass's own parsing. */ protected abstract getValues(position: number): Tuple; + /** Re-validates every mounted condition: a message stands for the condition as it is now. */ + protected refreshInputValidation(reattached?: boolean): void { + for (let position = 0, len = this.getNumConditions(); position < len; ++position) { + this.refreshPositionValidation(position, false, reattached); + } + } + + protected refreshPositionValidation(_position: number, _isFrom?: boolean, _reattached?: boolean): void { + // Only the filters whose inputs can reject what they hold override this; `isFrom` names the edited input. + } + + protected conditionNumberOfInputs(position: number): number { + return getNumberOfInputs(this.getConditionType(position), this.optionsFactory); + } + protected override setParams(params: P): void { super.setParams(params); @@ -129,6 +149,9 @@ export abstract class SimpleFilter< super.updateParams(newParams, oldParams); this.commonUpdateSimpleParams(newParams); + // A replacement input carries no validity, and an option keeping its key can still change arity. + this.refreshInputValidation(); + this.updateUiVisibility(); // an invalid condition is not a complete one } protected commonUpdateSimpleParams(params: P): void { @@ -154,6 +177,8 @@ export abstract class SimpleFilter< public onFloatingFilterChanged(type: string | null | undefined, value: V | null): void { this.setTypeFromFloatingFilter(type); this.setValueFromFloatingFilter(value); + // The conditions are the floating filter's now, so a message an earlier edit left is about nothing shown. + this.refreshInputValidation(); this.onUiChanged('immediately', true); } @@ -181,12 +206,8 @@ export abstract class SimpleFilter< return conditions[0]; } - protected getConditionTypes(): (FilterOptionKey | null)[] { - return this.eTypes.map((eType) => eType.getValue() as FilterOptionKey); - } - protected getConditionType(position: number): FilterOptionKey | null { - return this.eTypes[position].getValue() as FilterOptionKey; + return (this.eTypes[position]?.getValue() ?? null) as FilterOptionKey | null; } protected getJoinOperator(): JoinOperator { @@ -255,12 +276,9 @@ export abstract class SimpleFilter< const numConditions = validateAndUpdateConditions(this.beans.log, conditions, this.maxNumConditions); const numPrevConditions = this.getNumConditions(); if (numConditions < numPrevConditions) { - this.removeConditionsAndOperators(numConditions); + this.removeConditionsForModel(numConditions); } else if (numConditions > numPrevConditions) { - for (let i = numPrevConditions; i < numConditions; i++) { - this.createJoinOperatorPanel(); - this.createOption(); - } + this.createConditionsUpTo(numConditions); } const orChecked = combinedModel.operator === 'OR'; @@ -275,8 +293,10 @@ export abstract class SimpleFilter< const simpleModel = model as M; if (this.getNumConditions() > 1) { - this.removeConditionsAndOperators(1); + this.removeConditionsForModel(1); } + // A read-only filter a conditionless model emptied has no widget left to hold this one. + this.createConditionsUpTo(1); this.eTypes[0].setValue(simpleModel.type, true); this.setConditionIntoUi(simpleModel, 0); @@ -286,6 +306,9 @@ export abstract class SimpleFilter< this.createMissingConditionsAndOperators(); + // Every input holds the new model, so a message the old one left is about nothing shown. + this.refreshInputValidation(); + this.updateUiVisibility(); if (!isInitialLoad) { this.params.onUiChange(this.getUiChangeEventParams()); @@ -295,15 +318,16 @@ export abstract class SimpleFilter< } private setNumConditions(params: P): void { - let maxNumConditions = params.maxNumConditions ?? 2; - if (maxNumConditions < 1) { + // Whole counts, so that what the display will build matches the limit a model is held to. + let maxNumConditions = Math.floor(params.maxNumConditions ?? 2); + if (isBelowConditionFloor(maxNumConditions)) { this.beans.log.warn(79); maxNumConditions = 1; } this.maxNumConditions = maxNumConditions; - let numAlwaysVisibleConditions = params.numAlwaysVisibleConditions ?? 1; - if (numAlwaysVisibleConditions < 1) { + let numAlwaysVisibleConditions = Math.floor(params.numAlwaysVisibleConditions ?? 1); + if (isBelowConditionFloor(numAlwaysVisibleConditions)) { this.beans.log.warn(80); numAlwaysVisibleConditions = 1; } @@ -423,19 +447,22 @@ export abstract class SimpleFilter< } } if (this.shouldAddNewConditionAtEnd(areAllConditionsUiComplete)) { - this.createJoinOperatorPanel(); - this.createOption(); + this.createConditionsUpTo(this.getNumConditions() + 1); } else { const activePosition = this.lastUiCompletePosition ?? this.getNumConditions() - 2; if (lastUiCompletePosition < activePosition) { // remove any incomplete conditions at the end, excluding the active position - this.removeConditionsAndOperators(activePosition + 1); + const removed = this.removeConditionsAndOperators(activePosition + 1); const removeStartPosition = lastUiCompletePosition + 1; const numConditionsToRemove = activePosition - removeStartPosition; if (numConditionsToRemove > 0) { this.removeConditionsAndOperators(removeStartPosition, numConditionsToRemove); } this.createMissingConditionsAndOperators(); + // Still on show, so still editable: disabling it would leave no way to correct it. + if (!removed) { + lastUiCompletePosition = activePosition; + } } } this.lastUiCompletePosition = lastUiCompletePosition; @@ -477,7 +504,17 @@ export abstract class SimpleFilter< return areAllConditionsUiComplete && this.getNumConditions() < this.maxNumConditions && !this.isReadOnly(); } - protected removeConditionsAndOperators(startPosition: number, deleteCount?: number): void { + /** A condition the user is still fixing must not vanish under them; false when it was kept for that reason. */ + protected removeConditionsAndOperators(startPosition: number, deleteCount?: number): boolean { + if (this.hasInvalidInputs()) { + return false; + } + this.removeConditionsForModel(startPosition, deleteCount); + return true; + } + + /** A model overrules an input the user is mid-way through, since the conditions are no longer theirs. */ + private removeConditionsForModel(startPosition: number, deleteCount?: number): void { if (startPosition >= this.getNumConditions()) { return; } @@ -536,10 +573,18 @@ export abstract class SimpleFilter< // something needs focus otherwise keyboard navigation breaks, so focus the filter body if missing (elementToFocus ?? this.getGui()).focus({ preventScroll: true }); } + + this.onGuiAttached(params); + this.refreshInputValidation(true); + } + + protected onGuiAttached(_params?: IAfterGuiAttachedParams): void { + // Overridden by a subclass whose inputs need readying before their validity is judged and reported. } + /** Keeps the unfinished edit, as Chrome and Safari keep an incomplete date; Firefox clears those, so it does. */ protected shouldKeepInvalidInputState(): boolean { - return false; + return !_isBrowserFirefox() && this.hasInvalidInputs(); } public override afterGuiDetached(): void { @@ -573,9 +618,10 @@ export abstract class SimpleFilter< position >= this.numAlwaysVisibleConditions && !this.isConditionUiComplete(position - 1); const positionBeforeLastUiCompletePosition = position < lastUiCompletePosition; if (shouldRemovePositionAtEnd || positionBeforeLastUiCompletePosition) { - this.removeConditionsAndOperators(position, 1); - conditionsRemoved = true; - if (positionBeforeLastUiCompletePosition) { + // A refused removal leaves the condition mounted, so nothing after it counts as shifted. + const removed = this.removeConditionsAndOperators(position, 1); + conditionsRemoved ||= removed; + if (removed && positionBeforeLastUiCompletePosition) { updatedLastUiCompletePosition--; } } @@ -667,9 +713,9 @@ export abstract class SimpleFilter< } protected forEachInput(cb: (element: E, index: number, position: number, numberOfInputs: number) => void): void { - this.getConditionTypes().forEach((type, position) => { - this.forEachPositionTypeInput(position, type, cb); - }); + for (let position = 0, len = this.getNumConditions(); position < len; ++position) { + this.forEachPositionTypeInput(position, this.getConditionType(position), cb); + } } protected forEachPositionInput( @@ -709,9 +755,7 @@ export abstract class SimpleFilter< private isConditionBodyVisible(position: number): boolean { // Check that the condition needs inputs. - const type = this.getConditionType(position); - const numberOfInputs = getNumberOfInputs(type, this.optionsFactory); - return numberOfInputs > 0; + return this.conditionNumberOfInputs(position) > 0; } // returns true if the UI represents a working filter, eg all parts are filled out. @@ -723,7 +767,7 @@ export abstract class SimpleFilter< const type = this.getConditionType(position); - if (type === 'empty') { + if (!type || type === 'empty') { return false; } @@ -756,14 +800,21 @@ export abstract class SimpleFilter< if (this.isReadOnly()) { return; } // don't show incomplete conditions when read only - for (let i = this.getNumConditions(); i < this.numAlwaysVisibleConditions; i++) { - this.createJoinOperatorPanel(); + this.createConditionsUpTo(this.numAlwaysVisibleConditions); + } + + /** A join operator joins a condition to the one before it, so the first is not preceded by one. */ + private createConditionsUpTo(count: number): void { + for (let i = this.getNumConditions(); i < count; i++) { + if (i > 0) { + this.createJoinOperatorPanel(); + } this.createOption(); } } private resetUiToDefaults(silent?: boolean): void { - this.removeConditionsAndOperators(this.isReadOnly() ? 1 : this.numAlwaysVisibleConditions); + this.removeConditionsForModel(this.isReadOnly() ? 1 : this.numAlwaysVisibleConditions); this.eTypes.forEach((eType) => this.resetType(eType)); @@ -783,6 +834,8 @@ export abstract class SimpleFilter< this.lastUiCompletePosition = null; + this.refreshInputValidation(); + this.updateUiVisibility(); if (!silent) { this.params.onUiChange(this.getUiChangeEventParams()); @@ -875,7 +928,7 @@ export abstract class SimpleFilter< return; } - eType.onValueChange(this.listener); + eType.onValueChange(this.typeListener); this.attachInputsOnChange(position); } @@ -891,14 +944,44 @@ export abstract class SimpleFilter< }); } - protected hasInvalidInputs(): boolean { + protected isInputInvalid(_element: E): boolean { return false; } - protected positionHasInvalidInputs(_position: number): boolean { + /** Whether the element holds a whole value, i.e. one its filter has had the chance to reject. */ + protected isInputValueSettled(_element: E): boolean { + return true; + } + + /** Reached per keystroke through `canApply`, so it stops at the first invalid condition. */ + protected hasInvalidInputs(): boolean { + for (let position = 0, len = this.getNumConditions(); position < len; ++position) { + if (this.positionHasInvalidInputs(position, true)) { + return true; + } + } return false; } + /** + * Past `numberOfInputs` an element is mounted but not part of the condition, so its message is not either. + * Unsettled inputs count unless `settledOnly`: whether a condition may be applied is not whether it is stable. + */ + protected positionHasInvalidInputs(position: number, settledOnly?: boolean): boolean { + let invalidInputs = false; + this.forEachPositionInput(position, (element, index, _p, numberOfInputs) => { + invalidInputs ||= + index < numberOfInputs && + (!settledOnly || this.isInputValueSettled(element)) && + this.isInputInvalid(element); + }); + return invalidInputs; + } + + protected override canApply(_model: FilterModelOrCombined): boolean { + return !this.hasInvalidInputs(); + } + private isReadOnly(): boolean { return !!this.params.readOnly; } diff --git a/packages/ag-grid-community/src/filter/provided/simpleFilterHandler.ts b/packages/ag-grid-community/src/filter/provided/simpleFilterHandler.ts index 16c731cbba2..533cb119d06 100644 --- a/packages/ag-grid-community/src/filter/provided/simpleFilterHandler.ts +++ b/packages/ag-grid-community/src/filter/provided/simpleFilterHandler.ts @@ -1,4 +1,5 @@ import { BeanStub } from '../../context/beanStub'; +import type { Column } from '../../interfaces/iColumn'; import type { DoesFilterPassParams, FilterHandler, @@ -16,7 +17,7 @@ import type { import { isCombinedFilterModel } from './iSimpleFilter'; import { OptionsFactory } from './optionsFactory'; import type { SimpleFilterModelFormatter } from './simpleFilterModelFormatter'; -import { evaluateCustomFilter } from './simpleFilterUtils'; +import { evaluateCustomFilter, getConditionLimit } from './simpleFilterUtils'; export abstract class SimpleFilterHandler< TModel extends ISimpleFilterModel, @@ -32,7 +33,8 @@ export abstract class SimpleFilterHandler< /** Subclasses narrow `filterParams` to their own filter's params type. */ protected abstract createModelFormatter( optionsFactory: OptionsFactory, - filterParams: ISimpleFilterParams + filterParams: ISimpleFilterParams, + column: Column ): SimpleFilterModelFormatter; protected params: FilterHandlerParams, TParams>; @@ -74,7 +76,9 @@ export abstract class SimpleFilterHandler< this.optionsFactory = optionsFactory; optionsFactory.init(this.beans.log, filterParams, this.defaultOptions); - this.filterModelFormatter = this.createManagedBean(this.createModelFormatter(optionsFactory, filterParams)); + this.filterModelFormatter = this.createManagedBean( + this.createModelFormatter(optionsFactory, filterParams, params.column) + ); this.updateParams(params); @@ -118,6 +122,11 @@ export abstract class SimpleFilterHandler< models.push(model as TModel); } + // A model joining no conditions constrains nothing, where `OR` would instead fail every row. + if (!models.length) { + return true; + } + const combineFunction = operator && operator === 'OR' ? 'some' : 'every'; const cellValue = this.params.getValue(params.node); @@ -146,13 +155,11 @@ export abstract class SimpleFilterHandler< const isCombined = isCombinedFilterModel(model); - let conditions: TModel[] | null = isCombined ? model.conditions : [model]; + // A hand-written combined model can omit `conditions`; read as empty so the caller gets back what they set. + let conditions: TModel[] = (isCombined ? model.conditions : [model]) ?? []; // Checked against the list the dropdown is built from, so a malformed option does not count as offered. - const allConditionsAreOffered = - !conditions || conditions.every((condition) => this.optionsFactory.hasOption(condition.type)); - - if (!allConditionsAreOffered) { + if (!conditions.every((condition) => this.optionsFactory.hasOption(condition.type))) { this.params = { ...params, model: null, @@ -165,33 +172,30 @@ export abstract class SimpleFilterHandler< const filterType = this.filterType; - if ( - (conditions && !conditions.every((condition) => condition.filterType === filterType)) || - model.filterType !== filterType - ) { + if (!conditions.every((condition) => condition.filterType === filterType) || model.filterType !== filterType) { // need to add filterType to model conditions = conditions.map((condition) => ({ ...condition, filterType })); needsUpdate = true; } - // Check number of conditions vs maxNumConditions - if (typeof maxNumConditions === 'number' && conditions && conditions.length > maxNumConditions) { - conditions = conditions.slice(0, maxNumConditions); + const conditionLimit = getConditionLimit(maxNumConditions); + if (conditionLimit !== null && conditions.length > conditionLimit) { + conditions = conditions.slice(0, conditionLimit); needsUpdate = true; } if (needsUpdate) { - const updatedModel = - conditions.length > 1 - ? { - ...(model as ICombinedSimpleModel), - filterType, - conditions, - } - : { - ...conditions[0], - filterType, - }; + let updatedModel: TModel | ICombinedSimpleModel; + if (conditions.length === 1) { + updatedModel = { ...conditions[0], filterType }; + } else { + // Zero conditions stays combined: collapsing would invent a lone condition with no type, and + // a list the caller never set is not one to hand back. + updatedModel = { ...(model as ICombinedSimpleModel), filterType }; + if (isCombined && model.conditions) { + updatedModel.conditions = conditions; + } + } this.params = { ...params, model: updatedModel, diff --git a/packages/ag-grid-community/src/filter/provided/simpleFilterModelFormatter.ts b/packages/ag-grid-community/src/filter/provided/simpleFilterModelFormatter.ts index e2078f4ad8a..40c5e3162ba 100644 --- a/packages/ag-grid-community/src/filter/provided/simpleFilterModelFormatter.ts +++ b/packages/ag-grid-community/src/filter/provided/simpleFilterModelFormatter.ts @@ -1,4 +1,5 @@ import { BeanStub } from '../../context/beanStub'; +import type { Column } from '../../interfaces/iColumn'; import type { FilterLocaleTextKey } from '../filterLocaleText'; import { translateForFilter } from '../filterLocaleText'; import type { ProvidedFilterModel } from './iProvidedFilter'; @@ -36,7 +37,9 @@ export abstract class SimpleFilterModelFormatter< constructor( private optionsFactory: OptionsFactory, - protected filterParams: TFilterParams + protected filterParams: TFilterParams, + /** Named so a `numberFormatter` shared across columns can tell which one it is rendering. */ + protected readonly column: Column ) { super(); } diff --git a/packages/ag-grid-community/src/filter/provided/simpleFilterUtils.ts b/packages/ag-grid-community/src/filter/provided/simpleFilterUtils.ts index 006953e7064..cec60c71c02 100644 --- a/packages/ag-grid-community/src/filter/provided/simpleFilterUtils.ts +++ b/packages/ag-grid-community/src/filter/provided/simpleFilterUtils.ts @@ -1,8 +1,43 @@ +import type { GridOptionsService } from '../../gridOptionsService'; +import { _addGridCommonParams } from '../../gridOptionsUtils'; +import type { Column } from '../../interfaces/iColumn'; +import type { FilterInputCallbackParams } from '../../interfaces/iFilter'; import type { LogService } from '../../validation/logService'; import type { FilterLocaleTextKey } from '../filterLocaleText'; import type { FilterOptionKey, IFilterOptionDef, ISimpleFilterModelType, JoinOperator, Tuple } from './iSimpleFilter'; import type { OptionsFactory } from './optionsFactory'; +/** Built per call, not per binding: `context` is a grid option, so a captured one would go stale. */ +export function filterCallbackParams(gos: GridOptionsService, column: Column): FilterInputCallbackParams { + return _addGridCommonParams(gos, { column, colDef: column.getColDef() }); +} + +/** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ +export function _bindFilterCallback( + callback: ((value: A, params: FilterInputCallbackParams) => R) | undefined, + gos: GridOptionsService, + column: Column | null | undefined +): ((value: A) => R) | undefined { + // A column is needed to name the callback's subject, so without one the default reading stands. + return callback && column ? (value) => callback(value, filterCallbackParams(gos, column)) : undefined; +} + +/** `NaN` is below it too: every comparison against it is false, so left through it would cap nothing. */ +export function isBelowConditionFloor(count: number): boolean { + return count < 1 || Number.isNaN(count); +} + +/** + * Absent where nothing was configured, so a model set through the API keeps every condition it was given; + * otherwise whole and at least one, as the display counts conditions. + */ +export function getConditionLimit(maxNumConditions: number | undefined): number | null { + if (typeof maxNumConditions !== 'number') { + return null; + } + return isBelowConditionFloor(maxNumConditions) ? 1 : Math.floor(maxNumConditions); +} + export function removeItems(items: T[], startPosition: number, deleteCount?: number): T[] { return deleteCount == null ? items.splice(startPosition) : items.splice(startPosition, deleteCount); } @@ -88,14 +123,19 @@ export function getNumberOfInputs(type: FilterOptionKey | null | undefined, opti return 1; } -/** `from >= to` is not a range; the message goes on whichever end the user is editing. */ +/** `from` must be below `to`, or equal where the range is inclusive; the message goes on the end being edited. */ export function getValidityMessageKey( fromValue: V | null, toValue: V | null, - isFrom: boolean + isFrom: boolean, + inclusive?: boolean ): FilterLocaleTextKey | null { - if (fromValue == null || toValue == null || fromValue < toValue) { + // An inclusive range of one value is an exact match, so only a strict one has nothing left to match. + if (fromValue == null || toValue == null || fromValue < toValue || (inclusive && fromValue === toValue)) { return null; } - return `strict${isFrom ? 'Max' : 'Min'}ValueValidation`; + if (inclusive) { + return isFrom ? 'maxValueValidation' : 'minValueValidation'; + } + return isFrom ? 'strictMaxValueValidation' : 'strictMinValueValidation'; } diff --git a/packages/ag-grid-community/src/filter/provided/text/textFilterHandler.ts b/packages/ag-grid-community/src/filter/provided/text/textFilterHandler.ts index a7586b50dd4..de44ccdf4bb 100644 --- a/packages/ag-grid-community/src/filter/provided/text/textFilterHandler.ts +++ b/packages/ag-grid-community/src/filter/provided/text/textFilterHandler.ts @@ -1,3 +1,4 @@ +import type { Column } from '../../../interfaces/iColumn'; import type { FilterHandlerParams, IDoesFilterPassParams } from '../../../interfaces/iFilter'; import type { FilterOptionKey, ICombinedSimpleModel, TextFilterOptionKey, Tuple } from '../iSimpleFilter'; import { isCombinedFilterModel } from '../iSimpleFilter'; @@ -60,9 +61,10 @@ export class TextFilterHandler extends SimpleFilterHandler, + /** Only the number and bigint filters extend this; `TextFilter` extends `SimpleFilter` directly. */ + P extends SimpleFilterDisplayParams & { allowedCharPattern?: string }, > extends SimpleFilter { /** Held by position: removing a condition shifts every later one. */ protected readonly eValuesFrom: E[] = []; @@ -53,19 +53,25 @@ export abstract class TextInputSimpleFilter< /** What the new parameters need done to the mounted inputs, if anything. */ protected abstract getRenderChange(params: P, previous: P | undefined): RenderChange | undefined; - protected abstract refreshInputPairValidation(from: E, to: E, isFrom?: boolean): void; - protected override defaultDebounceMs = 500; - protected override shouldKeepInvalidInputState(): boolean { - // Mimics incomplete date and datetime inputs, which Firefox clears and Chrome/Safari keep. - return !_isBrowserFirefox() && this.hasInvalidInputs() && this.getConditionTypes().includes('inRange'); + protected override isInputInvalid(element: E): boolean { + return !element.getInputElement().validity.valid; + } + + /** `numberOfInputs` says how much of the pair the option in force filters on; past it an input is unused. */ + protected abstract refreshInputPairValidation(from: E, to: E, isFrom: boolean, numberOfInputs: number): void; + + protected override refreshPositionValidation(position: number, isFrom = false): void { + const [from, to] = this.getInputs(position); + if (from && to) { + this.refreshInputPairValidation(from, to, isFrom, this.conditionNumberOfInputs(position)); + } } - protected createTextInput(allowedCharPattern: string | null): GridInputTextField { + protected createTextInput(): GridInputTextField { return this.createBean( new AgInputTextField({ - allowedCharPattern: allowedCharPattern ?? undefined, clearButton: true, searchIcon: true, autoComplete: this.params.browserAutoComplete, @@ -75,37 +81,13 @@ export abstract class TextInputSimpleFilter< private buildInput(fromTo: 'from' | 'to'): E { const element = this.createInputWidget(); + // Every input is built here, so the pattern cannot be bypassed by the element that holds it. + installAllowedCharPattern(element, this.params.allowedCharPattern, this.beans); element.addCss(`ag-filter-${fromTo}`); element.addCss('ag-filter-filter'); return element; } - public override afterGuiAttached(params?: IAfterGuiAttachedParams | undefined): void { - super.afterGuiAttached(params); - - this.refreshInputValidation(); - } - - public override refresh(legacyNewParams: ProvidedFilterParams): boolean { - const result = super.refresh(legacyNewParams); - - const { state: newState, additionalEventAttributes } = legacyNewParams as unknown as P; - const oldState = this.state; - - const fromAction = additionalEventAttributes?.fromAction; - const forceRefreshValidation = fromAction && fromAction != 'apply'; - - if ( - forceRefreshValidation || - newState.model !== oldState.model || - !this.areStatesEqual(newState.state, oldState.state) - ) { - this.refreshInputValidation(); - } - - return result; - } - /** Non-model UI state, so validity changes reach the UI through `ProvidedFilter.refresh`. */ protected override getState(): { isInvalid: boolean } { return { isInvalid: this.hasInvalidInputs() }; @@ -115,31 +97,6 @@ export abstract class TextInputSimpleFilter< return (stateA?.isInvalid ?? false) === (stateB?.isInvalid ?? false); } - protected override hasInvalidInputs(): boolean { - let invalidInputs = false; - this.forEachInput((element) => (invalidInputs ||= !element.getInputElement().validity.valid)); - return invalidInputs; - } - - protected override positionHasInvalidInputs(position: number): boolean { - let invalidInputs = false; - this.forEachPositionInput(position, (element) => (invalidInputs ||= !element.getInputElement().validity.valid)); - return invalidInputs; - } - - protected override canApply(_model: M | ICombinedSimpleModel | null): boolean { - return !this.hasInvalidInputs(); - } - - protected override removeConditionsAndOperators(startPosition: number, deleteCount?: number | undefined): void { - // An invalid range lives in the last condition, which must survive until the user finishes editing it. - if (this.hasInvalidInputs()) { - return; - } - - return super.removeConditionsAndOperators(startPosition, deleteCount); - } - protected override commonUpdateSimpleParams(params: P): void { super.commonUpdateSimpleParams(params); @@ -182,7 +139,10 @@ export abstract class TextInputSimpleFilter< protected override getInputs(position: number): Tuple { const eValuesFrom = this.eValuesFrom; - return position < eValuesFrom.length ? [eValuesFrom[position], this.eValuesTo[position]] : [null, null]; + // Bounded below too: the position is looked up with `indexOf`, which reports a gone element as -1. + return position >= 0 && position < eValuesFrom.length + ? [eValuesFrom[position], this.eValuesTo[position]] + : [null, null]; } protected override getValues(position: number): Tuple { @@ -229,21 +189,16 @@ export abstract class TextInputSimpleFilter< return rendered?.text === element.getInputElement().value ? rendered : undefined; } - /** Re-validates every mounted condition; a replaced element carries none of the original's validity. */ - protected refreshInputValidation(): void { - const { eValuesFrom, eValuesTo } = this; - for (let i = 0, len = eValuesFrom.length; i < len; ++i) { - this.refreshInputPairValidation(eValuesFrom[i], eValuesTo[i]); - } - } - /** A pair's own listeners, re-attached whenever either element is replaced. */ private attachInputPairListeners(from: E, to: E): void { - this.attachInputListeners(from, () => this.refreshInputPairValidation(from, to, true)); - this.attachInputListeners(to, () => this.refreshInputPairValidation(from, to, false)); + this.attachInputListeners(from, true); + this.attachInputListeners(to, false); } - private attachInputListeners(element: E, refreshValidation: () => void): void { + private attachInputListeners(element: E, isFrom: boolean): void { + const eValues = isFrom ? this.eValuesFrom : this.eValuesTo; + // Looked up per event, never captured: removing a condition from the middle shifts every later one. + const refreshValidation = () => this.refreshPositionValidation(eValues.indexOf(element), isFrom); element.onValueChange(() => { // Typing makes the text the user's own, so the value the filter wrote no longer stands for it. this.renderedValues.delete(element); @@ -265,16 +220,11 @@ export abstract class TextInputSimpleFilter< this.refreshInputElement(position, 'to', rebuild, previous); if (rebuild) { // A replacement element carries none of the original's listeners, so re-attach them all. - this.attachInputsOnChange(position); + // Validity first, as when they are built: the UI listener reads it to decide whether to apply. this.attachInputPairListeners(eValuesFrom[position], eValuesTo[position]); + this.attachInputsOnChange(position); } } - // Before the visibility pass: a replacement carries no validity, and an invalid condition is - // not a complete one. - this.refreshInputValidation(); - if (rebuild) { - this.updateUiVisibility(); // the replacements start visible and enabled, whatever the condition is - } } private refreshInputElement( diff --git a/packages/ag-grid-community/src/interfaces/iFilter.ts b/packages/ag-grid-community/src/interfaces/iFilter.ts index 021a5245174..72945588122 100644 --- a/packages/ag-grid-community/src/interfaces/iFilter.ts +++ b/packages/ag-grid-community/src/interfaces/iFilter.ts @@ -293,6 +293,17 @@ export interface FilterWrapperParams { readOnly?: boolean; } +/** + * Passed to a filter callback that reads or judges a value rather than a row, such as a parser, a formatter + * or an input rule. It names the column so that one callback can serve every column it is configured on. + */ +export interface FilterInputCallbackParams extends AgGridCommon { + /** The column definition for the column this filter is on. */ + colDef: ColDef; + /** The column this filter is on. */ + column: Column; +} + export interface SharedFilterParams extends AgGridCommon { /** The column this filter is for. */ column: Column; diff --git a/packages/ag-grid-community/src/main-internal.ts b/packages/ag-grid-community/src/main-internal.ts index 41076eead5a..1d72d09e4a2 100644 --- a/packages/ag-grid-community/src/main-internal.ts +++ b/packages/ag-grid-community/src/main-internal.ts @@ -143,6 +143,7 @@ export type { FilterValueService } from './filter/filterValueService'; export { FilterWrapperComp } from './filter/filterWrapperComp'; export { _getDefaultFloatingFilterType } from './filter/floating/floatingFilterMapper'; export { _isUseApplyButton } from './filter/provided/providedFilterUtils'; +export { _bindFilterCallback } from './filter/provided/simpleFilterUtils'; export type { FocusService } from './focusService'; export { _getGlobalGridOption } from './globalGridOptions'; export { GridCoreCreator } from './grid'; diff --git a/packages/ag-grid-community/src/main.ts b/packages/ag-grid-community/src/main.ts index a8c464711e4..e4392872f9f 100644 --- a/packages/ag-grid-community/src/main.ts +++ b/packages/ag-grid-community/src/main.ts @@ -324,6 +324,7 @@ export type { FilterHandlerParams, FilterHandlers, FilterHandlerSource, + FilterInputCallbackParams, FilterModel, FilterWrapperParams, IDoesFilterPassParams, diff --git a/packages/ag-grid-community/src/validation/errorMessages/errorText.ts b/packages/ag-grid-community/src/validation/errorMessages/errorText.ts index a402ee0a1b9..65c7ec4f59a 100644 --- a/packages/ag-grid-community/src/validation/errorMessages/errorText.ts +++ b/packages/ag-grid-community/src/validation/errorMessages/errorText.ts @@ -381,8 +381,8 @@ export const AG_GRID_ERRORS = { 77: () => `Filter model is missing \`conditions\`` as const, 78: () => 'Filter Model contains more conditions than `filterParams.maxNumConditions`. Additional conditions have been ignored.' as const, - 79: () => '`filterParams.maxNumConditions` must be greater than or equal to zero.' as const, - 80: () => '`filterParams.numAlwaysVisibleConditions` must be greater than or equal to zero.' as const, + 79: () => '`filterParams.maxNumConditions` must be greater than or equal to one.' as const, + 80: () => '`filterParams.numAlwaysVisibleConditions` must be greater than or equal to one.' as const, 81: () => '`filterParams.numAlwaysVisibleConditions` cannot be greater than `filterParams.maxNumConditions`.' as const, 82: ({ param }: { param: any }) => `\`DateFilter\` \`${param}\` is not a number` as const, @@ -948,6 +948,8 @@ export const AG_GRID_ERRORS = { `\`${property}\` must be an array with at least one element, currently it is \`[${String(value)}]\``, 326: ({ defaultOption }: { defaultOption: string }) => `ignoring \`defaultOption\` \`${defaultOption}\` as it is not one of the filter's \`filterOptions\`` as const, + 327: ({ pattern }: { pattern: string }) => + `ignoring \`filterParams.allowedCharPattern\` \`${pattern}\` as it does not compile to a character pattern` as const, // When adding a code above this line, raise `MAX_ERROR_ID` below to match. }; @@ -963,7 +965,7 @@ export type ErrorId = keyof ErrorMap; * * @knipIgnore Read by the docs site's error-page route */ -export const MAX_ERROR_ID = 326; +export const MAX_ERROR_ID = 327; type ErrorValue = TId extends ErrorId ? ErrorMap[TId] : never; export type GetErrorParams = diff --git a/packages/ag-grid-enterprise/src/advancedFilter/advancedFilterExpressionService.ts b/packages/ag-grid-enterprise/src/advancedFilter/advancedFilterExpressionService.ts index 251fa1910d2..7eeb5f14488 100644 --- a/packages/ag-grid-enterprise/src/advancedFilter/advancedFilterExpressionService.ts +++ b/packages/ag-grid-enterprise/src/advancedFilter/advancedFilterExpressionService.ts @@ -29,7 +29,13 @@ import { ScalarFilterExpressionOperators, TextFilterExpressionOperators, } from './filterExpressionOperators'; -import { getBigIntParser, getNumberFormatter, getNumberParser, hasCustomNumberOperands } from './filterExpressionUtils'; +import { + getBigIntFormatter, + getBigIntParser, + getNumberFormatter, + getNumberParser, + hasCustomNumberOperands, +} from './filterExpressionUtils'; /** What an unquoted operand cannot carry: a space or `)` ends it, and a leading quote opens one. */ function needsQuotes(operand: string): boolean { @@ -70,18 +76,18 @@ export class AdvancedFilterExpressionService extends BeanStub implements NamedBe const column = this.colModel.getNonPivotCol(model.colId); return this.formatOperand( model.filter, - getNumberFormatter(column), + getNumberFormatter(column, this.gos), _toFiniteNumber, - getNumberParser(column) + getNumberParser(column, this.gos) ); }, bigint: (model) => { const column = this.colModel.getNonPivotCol(model.colId); return this.formatOperand( model.filter, - column?.colDef.filterParams?.bigintFormatter, + getBigIntFormatter(column, this.gos), _parseBigIntOrNull, - getBigIntParser(column) + getBigIntParser(column, this.gos) ); }, date: (model) => { @@ -119,9 +125,10 @@ export class AdvancedFilterExpressionService extends BeanStub implements NamedBe BaseCellDataType, (op: string, cln: AgColumn, dt: BaseCellDataType) => number | string | null > = { - number: (operand, column) => (operand != null && operand !== '' ? getNumberParser(column)(operand) : null), + number: (operand, column) => + operand != null && operand !== '' ? getNumberParser(column, this.gos)(operand) : null, bigint: (operand, column) => { - const parsed = getBigIntParser(column)(operand); + const parsed = getBigIntParser(column, this.gos)(operand); return parsed == null ? null : String(parsed); }, date: (operand, column, baseCellDataType) => diff --git a/packages/ag-grid-enterprise/src/advancedFilter/advancedFilterService.ts b/packages/ag-grid-enterprise/src/advancedFilter/advancedFilterService.ts index f900e9f9526..c7334beb470 100644 --- a/packages/ag-grid-enterprise/src/advancedFilter/advancedFilterService.ts +++ b/packages/ag-grid-enterprise/src/advancedFilter/advancedFilterService.ts @@ -152,6 +152,7 @@ export class AdvancedFilterService extends BeanStub implements NamedBean, IAdvan return new FilterExpressionParser({ expression, + gos: this.gos, colModel: this.colModel, dataTypeSvc: this.dataTypeSvc, valueSvc: this.valueSvc, diff --git a/packages/ag-grid-enterprise/src/advancedFilter/builder/conditionPillWrapperComp.ts b/packages/ag-grid-enterprise/src/advancedFilter/builder/conditionPillWrapperComp.ts index 2d944121803..8f772d1f2d1 100644 --- a/packages/ag-grid-enterprise/src/advancedFilter/builder/conditionPillWrapperComp.ts +++ b/packages/ag-grid-enterprise/src/advancedFilter/builder/conditionPillWrapperComp.ts @@ -226,7 +226,8 @@ export class ConditionPillWrapperComp extends Component string; text: (a: string) => string; } = { - number: (operand) => getNumberParser(this.columnParser!.column)(operand)!, - bigint: (operand) => getBigIntParser(this.columnParser!.column)(operand)!, + number: (operand) => getNumberParser(this.columnParser!.column, this.params.gos)(operand)!, + bigint: (operand) => getBigIntParser(this.columnParser!.column, this.params.gos)(operand)!, date: (operand) => this.params.valueSvc.parseValue(this.columnParser!.column!, null, operand, undefined) as Date, dateString: (operand) => this.operandValueGetters.date(operand), diff --git a/packages/ag-grid-enterprise/src/advancedFilter/filterExpressionUtils.ts b/packages/ag-grid-enterprise/src/advancedFilter/filterExpressionUtils.ts index 53f9c19a248..760a9011cef 100644 --- a/packages/ag-grid-enterprise/src/advancedFilter/filterExpressionUtils.ts +++ b/packages/ag-grid-enterprise/src/advancedFilter/filterExpressionUtils.ts @@ -1,9 +1,12 @@ import { _parseBigIntOrNull } from 'ag-stack'; +import { _bindFilterCallback } from 'ag-grid-community'; import type { AgColumn, ColumnModel, DataTypeService, + GridOptionsService, + IBigIntFilterParams, IRowNode, NumberFilterParams, ValueService, @@ -14,6 +17,7 @@ import type { FilterExpressionEvaluatorParams, FilterExpressionOperator } from ' export interface FilterExpressionParserParams { expression: string; + gos: GridOptionsService; colModel: ColumnModel; dataTypeSvc?: DataTypeService; valueSvc: ValueService; @@ -48,9 +52,20 @@ export type FilterExpressionFunction = ( params: FilterExpressionFunctionParams ) => boolean; -export function getBigIntParser(column: AgColumn | null | undefined): (value: string | null) => bigint | null { - return column?.colDef.filterParams?.bigintParser ?? _parseBigIntOrNull; -} +type FilterOperandParser = (value: string | null) => V | null; + +const bigIntParams = (column: AgColumn | null | undefined): IBigIntFilterParams | undefined => + column?.colDef.filterParams; + +/** Read unpaired, unlike the number equivalent, so hex and the like can be typed with a parser alone. */ +export const getBigIntParser = ( + column: AgColumn | null | undefined, + gos: GridOptionsService +): FilterOperandParser => + _bindFilterCallback(bigIntParams(column)?.bigintParser, gos, column) ?? _parseBigIntOrNull; + +export const getBigIntFormatter = (column: AgColumn | null | undefined, gos: GridOptionsService) => + _bindFilterCallback(bigIntParams(column)?.bigintFormatter, gos, column); /** * The `filterParams` of a number column whose operands are written in its own syntax rather than as plain @@ -66,15 +81,14 @@ function customNumberOperandParams(column: AgColumn | null | undefined): NumberF const parseNumberOrNull = (value: string | null): number | null => (value?.trim() ? Number(value) : null); /** Plain-number reading stays the default: only a column that reads *and* writes its own syntax departs from it. */ -export function getNumberParser(column: AgColumn | null | undefined): (value: string | null) => number | null { - return customNumberOperandParams(column)?.numberParser ?? parseNumberOrNull; -} - -export function getNumberFormatter( - column: AgColumn | null | undefined -): ((value: number | null) => string | null) | undefined { - return customNumberOperandParams(column)?.numberFormatter; -} +export const getNumberParser = ( + column: AgColumn | null | undefined, + gos: GridOptionsService +): FilterOperandParser => + _bindFilterCallback(customNumberOperandParams(column)?.numberParser, gos, column) ?? parseNumberOrNull; + +export const getNumberFormatter = (column: AgColumn | null | undefined, gos: GridOptionsService) => + _bindFilterCallback(customNumberOperandParams(column)?.numberFormatter, gos, column); export function hasCustomNumberOperands(column: AgColumn | null | undefined): boolean { return customNumberOperandParams(column) != null; diff --git a/testing/behavioural/src/filters/advanced-filter/advanced-filter-bigint-custom-parser.test.ts b/testing/behavioural/src/filters/advanced-filter/advanced-filter-bigint-custom-parser.test.ts index 187226129e3..bc962989fdc 100644 --- a/testing/behavioural/src/filters/advanced-filter/advanced-filter-bigint-custom-parser.test.ts +++ b/testing/behavioural/src/filters/advanced-filter/advanced-filter-bigint-custom-parser.test.ts @@ -8,7 +8,7 @@ import { uninstallFilterLayoutMock, } from 'ag-test-utils'; -import type { GridApi, GridOptions } from 'ag-grid-community'; +import type { FilterInputCallbackParams, GridApi, GridOptions } from 'ag-grid-community'; import { BigIntFilterModule, ClientSideRowModelModule, NumberFilterModule, getGridElement } from 'ag-grid-community'; import { AdvancedFilterModule } from 'ag-grid-enterprise'; @@ -98,6 +98,53 @@ describe('Advanced Filter - bigint custom parser and formatter', () => { afterAll(() => uninstallFilterLayoutMock()); afterEach(() => gridsManager.reset()); + test('the parser and the formatter are given the api and the context', async () => { + const context = { tag: 'advanced-bigint' }; + const parserSaw: FilterInputCallbackParams[] = []; + const formatterSaw: FilterInputCallbackParams[] = []; + const api = gridsManager.createGrid('grid1', { + context, + columnDefs: [ + { + field: 'value', + cellDataType: 'bigint', + filter: 'agBigIntColumnFilter', + filterParams: { + bigintParser: (text: string | null, common: FilterInputCallbackParams) => { + parserSaw.push(common); + return text == null ? null : parseBigInt(text); + }, + bigintFormatter: (value: bigint | null, common: FilterInputCallbackParams) => { + formatterSaw.push(common); + return value == null ? null : formatBigInt(value); + }, + }, + }, + ], + rowData: [{ value: 10n }, { value: 255n }], + enableAdvancedFilter: true, + }); + await asyncSetTimeout(0); + + const gridDiv = getGridElement(api)! as HTMLElement; + applyExpression(gridDiv, '[Value] = 0xFF'); + await asyncSetTimeout(0); + + // The operand is read on apply and written back when the expression is re-displayed. + api.setAdvancedFilterModel(api.getAdvancedFilterModel()); + await asyncSetTimeout(0); + + expect(parserSaw.length).toBeGreaterThan(0); + expect(formatterSaw.length).toBeGreaterThan(0); + for (const common of [...parserSaw, ...formatterSaw]) { + expect(common.api).toBe(api); + expect(common.context).toBe(context); + // The column too, so one callback on `defaultColDef` can tell which one it is working on. + expect(common.column.getColId()).toBe('value'); + expect(common.colDef).toBe(common.column.getColDef()); + } + }); + test('hex typed via text input matches and displays with formatter', async () => { const api = gridsManager.createGrid('grid1', { columnDefs: withParser, @@ -164,6 +211,18 @@ describe('Advanced Filter - bigint custom parser and formatter', () => { `); api.setAdvancedFilterModel(api.getAdvancedFilterModel()); expect(getService(api).getExpressionDisplayValue()).toBe('[Value] = 255'); + + // With no formatter the operand is written as the canonical decimal and read back through the + // column's own parser, so re-applying what is displayed must not reinterpret it. + applyExpression(gridDiv, '[Value] = 255'); + await asyncSetTimeout(0); + expect(api.getAdvancedFilterModel()).toEqual({ + filterType: 'bigint', + colId: 'value', + type: 'equals', + filter: '255', + }); + await new FilterDom(api, 'no formatter falls back to decimal', { mode: 'advanced-filter' }).checkFilterDom(` ADVANCED FILTER input: "[Value] = 255" diff --git a/testing/behavioural/src/filters/advanced-filter/advanced-filter-number-custom-parser.test.ts b/testing/behavioural/src/filters/advanced-filter/advanced-filter-number-custom-parser.test.ts index 20f3c5cd739..e320e7461af 100644 --- a/testing/behavioural/src/filters/advanced-filter/advanced-filter-number-custom-parser.test.ts +++ b/testing/behavioural/src/filters/advanced-filter/advanced-filter-number-custom-parser.test.ts @@ -8,7 +8,7 @@ import { uninstallFilterLayoutMock, } from 'ag-test-utils'; -import type { GridOptions } from 'ag-grid-community'; +import type { FilterInputCallbackParams, GridOptions } from 'ag-grid-community'; import { ClientSideRowModelModule, NumberFilterModule, TextFilterModule } from 'ag-grid-community'; import { AdvancedFilterModule } from 'ag-grid-enterprise'; @@ -68,6 +68,50 @@ describe('Advanced Filter - number custom parser and formatter', () => { afterAll(() => uninstallFilterLayoutMock()); afterEach(() => gridsManager.reset()); + test('the parser and the formatter are given the api and the context', async () => { + const context = { tag: 'advanced' }; + const parserSaw: FilterInputCallbackParams[] = []; + const formatterSaw: FilterInputCallbackParams[] = []; + const api = gridsManager.createGrid('grid1', { + context, + columnDefs: [ + { + field: 'value', + filter: 'agNumberColumnFilter', + filterParams: { + numberParser: (text: string | null, common: FilterInputCallbackParams) => { + parserSaw.push(common); + return parseGrouped(text); + }, + numberFormatter: (value: number | null, common: FilterInputCallbackParams) => { + formatterSaw.push(common); + return value == null ? null : value.toLocaleString('en-US'); + }, + }, + }, + ], + rowData: ROW_DATA, + enableAdvancedFilter: true, + }); + await asyncSetTimeout(0); + await AdvancedFilterHarness.get(api).applyExpression('[Value] = 1,234'); + await asyncSetTimeout(0); + + // The operand is read on apply and written back when the expression is re-displayed. + api.setAdvancedFilterModel(api.getAdvancedFilterModel()); + await asyncSetTimeout(0); + + expect(parserSaw.length).toBeGreaterThan(0); + expect(formatterSaw.length).toBeGreaterThan(0); + for (const common of [...parserSaw, ...formatterSaw]) { + expect(common.api).toBe(api); + expect(common.context).toBe(context); + // The column too, so one callback on `defaultColDef` can tell which one it is working on. + expect(common.column.getColId()).toBe('value'); + expect(common.colDef).toBe(common.column.getColDef()); + } + }); + // Both round-trip: the model holds the plain number, so only the formatter can put the grouping back. test.each([ ['a single separator', '[Value] = 1,234', 'equals', 1234], diff --git a/testing/behavioural/src/filters/bigint-filter-range-validation.test.ts b/testing/behavioural/src/filters/bigint-filter-range-validation.test.ts index 69ed937a5c6..e3eb31c5d00 100644 --- a/testing/behavioural/src/filters/bigint-filter-range-validation.test.ts +++ b/testing/behavioural/src/filters/bigint-filter-range-validation.test.ts @@ -42,6 +42,73 @@ describe('BigInt Range Filter', () => { return { api, filter }; } + // A one-value option filters on the first input, so a refresh nobody drove must report that input rather + // than the unused second one. A rebuild is such a refresh, and it hands the text to a fresh element. + test('a one-value condition reports its own input again after a rebuild', async () => { + const columnDefs = (allowedCharPattern?: string) => [ + { + field: 'val', + cellDataType: 'bigint' as const, + filter: 'agBigIntColumnFilter', + filterParams: { + debounceMs: 0, + filterOptions: ['equals'], + allowedCharPattern, + bigintParser: (text: string | null) => (text && /^\d+$/.test(text) ? BigInt(text) : null), + }, + }, + ]; + const api: GridApi = await gridsManager.createGridAndWait('grid1', { + columnDefs: columnDefs(), + rowData: ROW_DATA, + }); + + const filter = await ColumnFilterHarness.open(api, 'val'); + await filter.setText('zz'); + expect(filter.input('text', 0).validity.valid).toBe(false); + + // Validity is recomputed on the replacement, not inherited. + const before = filter.input('text', 0); + api.setGridOption('columnDefs', columnDefs('\\d')); + await asyncSetTimeout(0); + + const rebuilt = filter.input('text', 0); + // Identity first: the value and the validity would both read the same had the element survived. + expect(rebuilt).not.toBe(before); + expect(rebuilt.value).toBe('zz'); + expect(rebuilt.validity.valid).toBe(false); + }); + + // A zero-input option reads neither input, so text left in one is not something it can be invalid for. + test('a zero-input option is not held to the text an input still holds', async () => { + const api: GridApi = await gridsManager.createGridAndWait('grid1', { + columnDefs: [ + { + field: 'val', + cellDataType: 'bigint', + filter: 'agBigIntColumnFilter', + filterParams: { debounceMs: 0, filterOptions: ['equals', 'blank'] }, + }, + ], + rowData: [{ val: 1n }, { val: 16n }, { val: null }] as { val: bigint | null }[], + }); + + const filter = await ColumnFilterHarness.open(api, 'val'); + await filter.setText('zz'); + expect(filter.input('text', 0).validity.valid).toBe(false); + + await filter.selectOperator('Blank'); + await asyncSetTimeout(0); + + // The condition shows no input at all, so applying is what proves its contents stopped counting. + expect(filter.inputs('text', 0)).toHaveLength(0); + expect(filter.getModel()).toEqual({ filterType: 'bigint', type: 'blank' }); + await new GridRows(api, 'blank applies over text the option never reads').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:2 val:null + `); + }); + test('an inverted range names the bound each input must respect', async () => { const { api, filter } = await openRangeFilter('grid1'); @@ -153,4 +220,119 @@ describe('BigInt Range Filter', () => { expect(filter.input('text', 0).validity.valid).toBe(true); expect(filter.input('text', 0).validationMessage).toBe(''); }); + + test('a one-input option is not held to the range rule of the value left behind', async () => { + const { api, filter } = await openRangeFilter('grid1'); + await filter.setText('16', 0); + await filter.setText('100', 1); + + // `Equals` takes one value, so the 100 the range left in the hidden second input is not a bound on it. + await filter.selectOperator('Equals'); + await filter.setText('255', 0); + await asyncSetTimeout(0); + + expect(filter.input('text', 0).validity.valid).toBe(true); + expect(filter.getModel()).toEqual({ filterType: 'bigint', type: 'equals', filter: '255' }); + await new GridRows(api, 'equals applies over a stale range bound').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:3 val:"255n" + `); + }); + + test('a model applied through the API clears a stale range message', async () => { + const { api, filter } = await openRangeFilter('grid1'); + await filter.setText('255', 0); + await filter.setText('16', 1); + expect(filter.input('text', 1).validity.valid).toBe(false); + + await api.setColumnFilterModel('val', { + filterType: 'bigint', + type: 'inRange', + filter: '16', + filterTo: '255', + }); + api.onFilterChanged(); + await asyncSetTimeout(0); + + // The inputs hold an ordered range, so the message the old one left is gone. Their values are what + // distinguishes that from a model that was dropped rather than displayed, which would be valid too. + expect(filter.input('text', 0).validity.valid).toBe(true); + expect(filter.input('text', 1).validity.valid).toBe(true); + expect(filter.input('text', 0).value).toBe('16'); + expect(filter.input('text', 1).value).toBe('255'); + await new GridRows(api, 'the applied range filters, with no message left over').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:2 val:"100n" + `); + }); + + // Closing discards uncommitted UI state, and a debounced apply has committed everything valid, so what + // survives is the edit the user has not finished. A one-value condition is no less unfinished than a range. + test('a one-value condition keeps the value it cannot apply across a close', async () => { + const api: GridApi = await gridsManager.createGridAndWait('grid1', { + columnDefs: [ + { + field: 'val', + cellDataType: 'bigint', + filter: 'agBigIntColumnFilter', + filterParams: { debounceMs: 0, filterOptions: ['equals'] }, + }, + ], + rowData: ROW_DATA, + }); + + const filter = await ColumnFilterHarness.open(api, 'val'); + await filter.setText('zz'); + expect(filter.input('text', 0).validity.valid).toBe(false); + + api.hidePopupMenu(); + await asyncSetTimeout(0); + + const reopened = await ColumnFilterHarness.open(api, 'val'); + expect(reopened.input('text', 0).value).toBe('zz'); + expect(reopened.input('text', 0).validity.valid).toBe(false); + }); + + // A value the filter wrote stands for the input while the text still matches, so it is read back as the + // value rather than re-parsed. A parser that cannot read the canonical decimal therefore reports nothing. + test('a range the filter wrote is not called invalid by a parser that cannot re-read it', async () => { + const api: GridApi = await gridsManager.createGridAndWait('grid1', { + columnDefs: [ + { + field: 'val', + cellDataType: 'bigint', + filter: 'agBigIntColumnFilter', + filterParams: { + debounceMs: 0, + filterOptions: ['inRange', 'equals'], + // Reads hex alone, so the canonical decimals the model stores are text it refuses. + bigintParser: (text: string | null) => + text && /^0x[0-9a-fA-F]+$/.test(text.trim()) ? BigInt(text.trim()) : null, + }, + }, + ], + rowData: ROW_DATA, + }); + + await api.setColumnFilterModel('val', { + filterType: 'bigint', + type: 'inRange', + filter: '16', + filterTo: '255', + }); + api.onFilterChanged(); + await asyncSetTimeout(0); + + const filter = await ColumnFilterHarness.open(api, 'val'); + await asyncSetTimeout(0); + + expect(filter.input('text', 0).value).toBe('16'); + expect(filter.input('text', 1).value).toBe('255'); + expect(filter.input('text', 0).validity.valid).toBe(true); + expect(filter.input('text', 1).validity.valid).toBe(true); + await new GridRows(api, 'the range the model set still filters').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:2 val:"100n" + `); + }); }); diff --git a/testing/behavioural/src/filters/date-filter-range-validation.test.ts b/testing/behavioural/src/filters/date-filter-range-validation.test.ts index 59b0ce71bbe..973fa24733e 100644 --- a/testing/behavioural/src/filters/date-filter-range-validation.test.ts +++ b/testing/behavioural/src/filters/date-filter-range-validation.test.ts @@ -1,6 +1,6 @@ import { getAllByTestId, getByTestId, waitFor } from '@testing-library/dom'; import { userEvent } from '@testing-library/user-event'; -import { GridColumns, GridRows, TestGridsManager, asyncSetTimeout } from 'ag-test-utils'; +import { ColumnFilterHarness, GridColumns, GridRows, TestGridsManager, asyncSetTimeout } from 'ag-test-utils'; import type { DateFilterModel } from 'ag-grid-community'; import { @@ -212,20 +212,25 @@ describe('Date Range Filter', () => { // Switch back to "inRange" - the from date (2024-12-15) is now after the to date (2024-06-15) await selectFilterOption(gridDiv, userSession, 'Between'); - // Trigger validation by interacting with the from input + await asyncSetTimeout(0); + + // Choosing a two-value option re-validates the pair on its own: from > to is invalid + const toDateInputRange = getByTestId( + gridDiv, + agTestIdFor.dateFilterInstanceInput({ source: 'column-filter', index: 1 }) + ); + expect(toDateInputRange.validity.valid).toBe(false); + // One input carries the message, so this separates re-validation from marking the whole pair invalid. const fromDateInputRange = getByTestId( gridDiv, agTestIdFor.dateFilterInstanceInput({ source: 'column-filter', index: 0 }) ); - fromDateInputRange.dispatchEvent(new Event('focusin', { bubbles: true })); - - await asyncSetTimeout(0); - - // Range validation should now be active again - from > to is invalid - expect(fromDateInputRange.validity.valid).toBe(false); + expect(fromDateInputRange.validity.valid).toBe(true); + // The inverted range is not applied, so the equals filter entered before it still stands. await new GridRows(api, `Switching from equals back to inRange re-enables range validation final state`).check( ` ROOT id:ROOT_NODE_ID + └── LEAF id:2 date:"2024-12-15" ` ); }); @@ -303,7 +308,9 @@ describe('Date Range Filter', () => { await asyncSetTimeout(0); + // The message survived the close, so re-opening must report it again rather than treat it as unchanged. const reportsOnOpen = reportSpy.mock.calls.length; + expect(reportsOnOpen).toBeGreaterThan(0); // Let the 500ms date-report debounce window elapse. The assertion below is negative (no // second report was scheduled), so it can only be made once the window has closed. @@ -521,4 +528,163 @@ describe('Date Range Filter', () => { `); }); }); + + test('cancelling back to an applied range clears the message the edit left behind', async () => { + const api = await gridsManager.createGridAndWait('grid1', { + columnDefs: [ + { + field: 'date', + filter: 'agDateColumnFilter', + filterParams: { filterOptions: ['inRange'], buttons: ['apply', 'cancel'] }, + }, + ], + rowData: [{ date: '2024-01-15' }, { date: '2024-06-15' }, { date: '2024-12-15' }], + }); + + const filter = await ColumnFilterHarness.open(api, 'date'); + await filter.setDate('2024-01-15', 0); + await filter.setDate('2024-06-15', 1); + await filter.apply(); + + // Edit the applied range the wrong way round, then abandon the edit. + await filter.setDate('2024-01-01', 1); + expect(filter.input('date', 1).validity.valid).toBe(false); + + await filter.cancel(); + + // The inputs hold the applied range again, so the message it replaced is gone. Their values are what + // distinguishes that from a cancel that simply emptied them, which would be valid too. + expect(filter.input('date', 1).validity.valid).toBe(true); + expect(filter.input('date', 0).validity.valid).toBe(true); + expect(filter.input('date', 0).value).toBe('2024-01-15'); + expect(filter.input('date', 1).value).toBe('2024-06-15'); + }); + + // An inclusive range of one date is an exact match, so reporting it as out of order would leave a + // legitimate filter that can never be applied, `canApply` being blocked by the invalid input. + test('an inclusive range of a single date is not reported as out of order', async () => { + const api = await gridsManager.createGridAndWait('grid1', { + columnDefs: [ + { + field: 'date', + filter: 'agDateColumnFilter', + filterParams: { debounceMs: 0, filterOptions: ['inRange'], inRangeInclusive: true }, + }, + ], + rowData: [{ date: '2024-01-15' }, { date: '2024-06-15' }, { date: '2024-12-15' }], + }); + + const filter = await ColumnFilterHarness.open(api, 'date'); + await filter.setDate('2024-06-15', 0); + await filter.setDate('2024-06-15', 1); + + expect(filter.input('date', 0).validity.valid).toBe(true); + expect(filter.input('date', 1).validity.valid).toBe(true); + await new GridRows(api, 'an inclusive range of one date matches that date').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:1 date:"2024-06-15" + `); + }); + + // The bound named must be the one the filter enforces, and an inclusive range accepts the bound itself. + // The message lands on the end last edited and names the other one, so both keys are reachable. + test.each([ + { edited: 'to', editedIndex: 1, wording: 'on or after', bound: 'Dec 15 2024' }, + { edited: 'from', editedIndex: 0, wording: 'on or before', bound: 'Jan 15 2024' }, + ])( + 'an inclusive range the wrong way round tells the $edited input it must be $wording the bound', + async ({ editedIndex, wording, bound }) => { + const api = await gridsManager.createGridAndWait('grid1', { + columnDefs: [ + { + field: 'date', + filter: 'agDateColumnFilter', + filterParams: { debounceMs: 0, filterOptions: ['inRange'], inRangeInclusive: true }, + }, + ], + rowData: [{ date: '2024-01-15' }, { date: '2024-12-15' }], + }); + + const filter = await ColumnFilterHarness.open(api, 'date'); + // The other end first, so the edited one is the end the message is reported against. + await filter.setDate(editedIndex === 1 ? '2024-12-15' : '2024-01-15', editedIndex === 1 ? 0 : 1); + await filter.setDate(editedIndex === 1 ? '2024-01-15' : '2024-12-15', editedIndex); + + const editedInput = filter.input('date', editedIndex); + await waitFor(() => expect(editedInput.validationMessage).toContain(`Date must be ${wording} `)); + // The bound too: naming the edited input's own value instead would read as plausible. + expect(editedInput.validationMessage).toContain(bound); + expect(editedInput.validity.valid).toBe(false); + } + ); + + test('a valid model applied through the API clears a stale range message', async () => { + const api = await gridsManager.createGridAndWait('grid1', { + columnDefs: [{ field: 'date', filter: 'agDateColumnFilter', filterParams: { filterOptions: ['inRange'] } }], + rowData: [{ date: '2024-01-15' }, { date: '2024-06-15' }, { date: '2024-12-15' }], + }); + + const filter = await ColumnFilterHarness.open(api, 'date'); + await filter.setDate('2024-06-15', 0); + await filter.setDate('2024-01-01', 1); + expect(filter.input('date', 1).validity.valid).toBe(false); + + await api.setColumnFilterModel('date', { + filterType: 'date', + type: 'inRange', + dateFrom: '2024-01-15', + dateTo: '2024-06-15', + } as DateFilterModel); + api.onFilterChanged(); + await asyncSetTimeout(0); + + // The inputs hold an ordered range, so the message the old one left is gone. Their values are what + // distinguishes that from a model that was dropped rather than displayed, which would be valid too. + expect(filter.input('date', 0).validity.valid).toBe(true); + expect(filter.input('date', 1).validity.valid).toBe(true); + expect(filter.input('date', 0).value).toBe('2024-01-15'); + expect(filter.input('date', 1).value).toBe('2024-06-15'); + }); + + test('a date condition still validates its own pickers once an earlier condition has been dropped', async () => { + const api = await gridsManager.createGridAndWait('grid1', { + columnDefs: [ + { + field: 'date', + filter: 'agDateColumnFilter', + filterParams: { filterOptions: ['inRange'], maxNumConditions: 4 }, + }, + ], + rowData: [{ date: '2024-01-15' }, { date: '2024-06-15' }, { date: '2024-12-15' }], + }); + + const filter = await ColumnFilterHarness.open(api, 'date'); + await filter.setDate('2024-01-01', 0); + await filter.setDate('2024-01-31', 1); + await filter.setDate('2024-02-01', 2); + await filter.setDate('2024-02-28', 3); + await filter.setDate('2024-03-01', 4); + await filter.setDate('2024-03-31', 5); + + // Emptying the middle condition makes it incomplete, so closing the popup drops it and the + // third condition slides down into its place. + await filter.setDate('', 2); + await filter.setDate('', 3); + api.hidePopupMenu(); + await asyncSetTimeout(0); + + const reopened = await ColumnFilterHarness.open(api, 'date'); + // The third condition now holds the second's place: had the emptied one survived, this pair would + // still be the blank one, and the rest of the test would pass without ever exercising the shift. + await waitFor(() => expect(reopened.input('date', 2).value).toBe('2024-03-01')); + expect(reopened.input('date', 3).value).toBe('2024-03-31'); + + // The pair that slid down is the one addressed, rather than whichever pair held this index before. + await reopened.setDate('2024-06-15', 2); + await reopened.setDate('2024-05-01', 3); + + await waitFor(() => expect(reopened.input('date', 3).validity.valid).toBe(false)); + expect(reopened.input('date', 0).validity.valid).toBe(true); + expect(reopened.input('date', 1).validity.valid).toBe(true); + }); }); diff --git a/testing/behavioural/src/filters/date-filter.test.ts b/testing/behavioural/src/filters/date-filter.test.ts index b1abeab157e..5a7f1f637a7 100644 --- a/testing/behavioural/src/filters/date-filter.test.ts +++ b/testing/behavioural/src/filters/date-filter.test.ts @@ -200,4 +200,49 @@ describe('Date Filter - Equals', () => { └── LEAF id:2 date:"2024-01-15T10:30:00" `); }); + + // A preset range is looked up once per row, so a comparator that normalises the date it is handed must + // not leave the next row judged against what it wrote. + test('a comparator that mutates the date it is given cannot corrupt a preset range', async () => { + const startOfToday = new Date(); + startOfToday.setHours(0, 0, 0, 0); + const hoursFromToday = (hours: number) => new Date(startOfToday.getTime() + hours * 3_600_000); + + const api = await gridsManager.createGridAndWait('grid1', { + columnDefs: [ + { + field: 'date', + filter: 'agDateColumnFilter', + filterParams: { + filterOptions: ['today'], + comparator: (filterDate: Date, cellValue: unknown) => { + const filterTime = filterDate.getTime(); + filterDate.setTime(0); // free to normalise what it is handed + const cellTime = (cellValue as Date).getTime(); + if (cellTime < filterTime) { + return -1; + } + return cellTime > filterTime ? 1 : 0; + }, + }, + }, + ], + // Three rows inside the range: the second and third are the ones a corrupted range would drop. + rowData: [ + { date: hoursFromToday(-12) }, + { date: hoursFromToday(1) }, + { date: hoursFromToday(9) }, + { date: hoursFromToday(17) }, + { date: hoursFromToday(30) }, + ], + }); + + await api.setColumnFilterModel('date', { filterType: 'date', type: 'today' }); + api.onFilterChanged(); + + // Asserted by id, not as a GridRows snapshot: the rows are built relative to the clock, so a + // snapshot of their dates would pass only on the day it was generated. + const displayed = Array.from({ length: api.getDisplayedRowCount() }, (_, i) => api.getDisplayedRowAtIndex(i)); + expect(displayed.map((row) => row?.id)).toStrictEqual(['1', '2', '3']); + }); }); diff --git a/testing/behavioural/src/filters/filter-behaviour/allowed-char-pattern.test.ts b/testing/behavioural/src/filters/filter-behaviour/allowed-char-pattern.test.ts new file mode 100644 index 00000000000..435f3ac83e5 --- /dev/null +++ b/testing/behavioural/src/filters/filter-behaviour/allowed-char-pattern.test.ts @@ -0,0 +1,259 @@ +import { userEvent } from '@testing-library/user-event'; +import { + ALL_SEVERITIES, + ColumnFilterHarness, + FloatingFilterHarness, + GridRows, + TestGridsManager, + asyncSetTimeout, + installFilterLayoutMock, + uninstallFilterLayoutMock, +} from 'ag-test-utils'; + +import type { GridApi } from 'ag-grid-community'; +import { + BigIntFilterModule, + ClientSideRowModelModule, + NumberFilterModule, + enableDevValidations, + setupAgTestIds, +} from 'ag-grid-community'; + +/** + * Black-box coverage for `allowedCharPattern`, shared by every text-input filter. Judged on the text an edit + * brings in, so a drop, an autocorrect or a context-menu paste is held to it as a keystroke is. + */ +describe('allowedCharPattern', () => { + const gridsManager = new TestGridsManager({ + modules: [NumberFilterModule, BigIntFilterModule, ClientSideRowModelModule], + }); + + beforeAll(() => { + setupAgTestIds(); + installFilterLayoutMock(); + }); + afterAll(() => uninstallFilterLayoutMock()); + afterEach(() => { + // Restored first: a throw from `reset` would otherwise leave a spy mocked for the rest of the file. + vi.restoreAllMocks(); + gridsManager.reset(); + // A test suppressing a warning must not leave it suppressed for the next one. + enableDevValidations({ throwOn: ALL_SEVERITIES }); + }); + + const numberColumn = (allowedCharPattern?: string) => [ + { field: 'val', filter: 'agNumberColumnFilter' as const, filterParams: { debounceMs: 0, allowedCharPattern } }, + ]; + + // A pattern already written as a character class must not be wrapped in another, which would only ever + // match a two-character string and so refuse every single character. + test.each([ + ['a bare pattern', '\\d'], + ['one already written as a character class', '[0-9]'], + ])('admits the characters it names, given %s', async (_name, allowedCharPattern) => { + const userSession = userEvent.setup(); + const api: GridApi = await gridsManager.createGridAndWait('grid1', { + columnDefs: numberColumn(allowedCharPattern), + rowData: [{ val: 1234 }, { val: 7 }], + }); + + const input = (await ColumnFilterHarness.open(api, 'val')).input('text'); + await userSession.type(input, '1234'); + + expect(input.value).toBe('1234'); + await new GridRows(api, 'the admitted digits filter').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:0 val:1234 + `); + }); + + test('refuses a typed character it does not name', async () => { + const userSession = userEvent.setup(); + const api: GridApi = await gridsManager.createGridAndWait('grid1', { + columnDefs: numberColumn('\\d'), + rowData: [{ val: 12 }], + }); + + const input = (await ColumnFilterHarness.open(api, 'val')).input('text'); + // Typed mid-sequence, so a refusal that swallowed the rest of the sequence would show. + await userSession.type(input, '1a2'); + + expect(input.value).toBe('12'); + }); + + // The edits a `keydown` guard never saw. Each names its text differently, and a paste or a drop carries it + // on the transfer rather than in `data`. + test.each([ + ['a paste', 'insertFromPaste', true], + ['a drop', 'insertFromDrop', true], + ['an autocorrect', 'insertReplacementText', false], + ] as const)('refuses %s bringing in a character it does not name', async (_name, inputType, onTransfer) => { + const api: GridApi = await gridsManager.createGridAndWait('grid1', { + columnDefs: numberColumn('\\d'), + rowData: [{ val: 12 }], + }); + + const input = (await ColumnFilterHarness.open(api, 'val')).input('text'); + await userEvent.setup().type(input, '12'); + + const init: InputEventInit = { inputType, cancelable: true, bubbles: true }; + if (onTransfer) { + const dataTransfer = new DataTransfer(); + dataTransfer.setData('text/plain', '3a4'); + init.dataTransfer = dataTransfer; + } else { + init.data = '3a4'; + } + // A synthetic event has no default action, so the refusal is only observable as the cancellation. + expect(input.dispatchEvent(new InputEvent('beforeinput', init))).toBe(false); + await asyncSetTimeout(0); + + expect(input.value).toBe('12'); + }); + + // Documented on the parameter and on the docs page: a composition event cannot be cancelled, so an IME + // and any keyboard that composes are not held to the pattern. + test('lets composed text through, which is the exemption the parameter names', async () => { + const api: GridApi = await gridsManager.createGridAndWait('grid1', { + columnDefs: numberColumn('\\d'), + rowData: [{ val: 12 }], + }); + + const input = (await ColumnFilterHarness.open(api, 'val')).input('text'); + await userEvent.setup().type(input, '12'); + + const composed = new InputEvent('beforeinput', { + inputType: 'insertCompositionText', + data: '3a4', + cancelable: true, + bubbles: true, + }); + // Not cancelled, where the same text through a paste is: the sibling test above pins that half. + expect(input.dispatchEvent(composed)).toBe(true); + }); + + test('lets an edit whose every character it names through', async () => { + const api: GridApi = await gridsManager.createGridAndWait('grid1', { + columnDefs: numberColumn('\\d'), + rowData: [{ val: 12 }, { val: 1234 }], + }); + + const input = (await ColumnFilterHarness.open(api, 'val')).input('text'); + await userEvent.setup().type(input, '12'); + input.focus(); + await userEvent.setup().paste('34'); + + expect(input.value).toBe('1234'); + await new GridRows(api, 'the admitted paste reaches the filter').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:1 val:1234 + `); + }); + + test('never refuses a deletion, which brings nothing in', async () => { + const userSession = userEvent.setup(); + const api: GridApi = await gridsManager.createGridAndWait('grid1', { + columnDefs: numberColumn('\\d'), + rowData: [{ val: 12 }], + }); + + const input = (await ColumnFilterHarness.open(api, 'val')).input('text'); + await userSession.type(input, '123'); + await userSession.type(input, '{Backspace}'); + + expect(input.value).toBe('12'); + }); + + test('holds a floating filter input to the pattern as the popup is held', async () => { + const api: GridApi = await gridsManager.createGridAndWait('grid1', { + columnDefs: [ + { + field: 'val', + filter: 'agNumberColumnFilter', + floatingFilter: true, + filterParams: { debounceMs: 0, allowedCharPattern: '\\d' }, + }, + ], + rowData: [{ val: 12 }], + }); + + const input = FloatingFilterHarness.get(api, 'val').input(); + await userEvent.setup().type(input, '1a2'); + + expect(input.value).toBe('12'); + }); + + test('holds a bigint filter input to the pattern, which is where hex is typed', async () => { + const api: GridApi = await gridsManager.createGridAndWait('grid1', { + columnDefs: [ + { + field: 'val', + cellDataType: 'bigint' as const, + filter: 'agBigIntColumnFilter' as const, + filterParams: { debounceMs: 0, allowedCharPattern: '\\dxXa-fA-F' }, + }, + ], + rowData: [{ val: 255n }], + }); + + const input = (await ColumnFilterHarness.open(api, 'val')).input('text'); + // The refused character is typed mid-sequence, so a refusal that swallowed the rest would show. + await userEvent.setup().type(input, '0xFzF'); + + expect(input.value).toBe('0xFF'); + }); + + // A filter input is built while the header and the filter panel are, where throwing takes down the grid. + test('a pattern that will not compile is reported rather than thrown', async () => { + // Deliberate: the pattern that will not compile is reported as warning #327. + enableDevValidations({ throwOn: ALL_SEVERITIES, suppress: [327] }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const api: GridApi = await gridsManager.createGridAndWait('grid1', { + columnDefs: [ + { + field: 'val', + filter: 'agNumberColumnFilter', + floatingFilter: true, + filterParams: { debounceMs: 0, allowedCharPattern: '\\' }, + }, + ], + rowData: [{ val: 1 }, { val: 12 }], + }); + + const warnings = warnSpy.mock.calls.flat().join(' '); + expect(warnings).toContain('warning #327'); + expect(warnings).toContain('allowedCharPattern'); + + // Held to nothing, rather than to a pattern that refuses everything. + const input = FloatingFilterHarness.get(api, 'val').input(); + await userEvent.setup().type(input, '12'); + expect(input.value).toBe('12'); + await new GridRows(api, 'a pattern that could not compile constrains nothing').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:1 val:12 + `); + }); + + // The pattern is read once, when an input is built, so a new one takes effect only by replacing the + // element. Identity is asserted too: the value alone would read the same whichever happened. + test('a pattern replaced at runtime replaces the input holding the old one', async () => { + const api: GridApi = await gridsManager.createGridAndWait('grid1', { + columnDefs: numberColumn('\\d'), + rowData: [{ val: 1 }], + }); + + const filter = await ColumnFilterHarness.open(api, 'val'); + const before = filter.input('text'); + await userEvent.setup().type(before, '1a'); + expect(before.value).toBe('1'); + + api.setGridOption('columnDefs', numberColumn('\\da-z')); + await asyncSetTimeout(0); + + const after = filter.input('text'); + expect(after).not.toBe(before); + await userEvent.setup().type(after, 'a'); + expect(after.value).toBe('1a'); + }); +}); diff --git a/testing/behavioural/src/filters/filter-behaviour/bigint-filter-custom-parser.test.ts b/testing/behavioural/src/filters/filter-behaviour/bigint-filter-custom-parser.test.ts index cb7b938c3db..642e4013eef 100644 --- a/testing/behavioural/src/filters/filter-behaviour/bigint-filter-custom-parser.test.ts +++ b/testing/behavioural/src/filters/filter-behaviour/bigint-filter-custom-parser.test.ts @@ -1,3 +1,4 @@ +import { userEvent } from '@testing-library/user-event'; import { ColumnFilterHarness, FilterDom, @@ -9,7 +10,7 @@ import { uninstallFilterLayoutMock, } from 'ag-test-utils'; -import type { GridApi } from 'ag-grid-community'; +import type { FilterInputCallbackParams, GridApi } from 'ag-grid-community'; import { BigIntFilterModule, ClientSideRowModelModule, setupAgTestIds } from 'ag-grid-community'; /** @@ -122,34 +123,9 @@ describe('BigInt Filter — custom bigintParser', () => { `); }); - // A pattern is used as a character class, so one already written as a class must not be wrapped twice. - test.each([ - ['bare characters', '\\dxXa-fA-F'], - ['a character class', '[\\dxXa-fA-F]'], - ])('allowedCharPattern written as %s admits the same keys', async (_name, allowedCharPattern) => { - const api: GridApi = await gridsManager.createGridAndWait('grid10', { - columnDefs: [ - { - field: 'val', - cellDataType: 'bigint' as const, - filter: 'agBigIntColumnFilter' as const, - filterParams: { debounceMs: 0, allowedCharPattern }, - }, - ], - rowData: [{ val: 255n }], - }); - const filter = await ColumnFilterHarness.open(api, 'val'); - const rejectsKey = (key: string): boolean => { - const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }); - filter.inputs('text', 0)[0].dispatchEvent(event); - return event.defaultPrevented; - }; - - expect([rejectsKey('5'), rejectsKey('F'), rejectsKey('x')]).toEqual([false, false, false]); - expect(rejectsKey('z')).toBe(true); - }); - - test('an allowedCharPattern replaced at runtime reaches inputs built before the change', async () => { + // Here rather than in `allowed-char-pattern.test.ts` because the swap also has to reach the parser: + // what the new pattern admits has to survive being read back into the model. + test('an allowedCharPattern replaced at runtime is read back by the parser', async () => { const columnDefs = (allowedCharPattern: string) => [ { field: 'val', @@ -158,7 +134,14 @@ describe('BigInt Filter — custom bigintParser', () => { filterParams: { debounceMs: 0, allowedCharPattern, - bigintParser: (text: string | null) => (text == null || text.trim() === '' ? null : BigInt(text)), + // Typing reaches the parser mid-value, where `0x` is not yet a bigint. + bigintParser: (text: string | null) => { + try { + return text?.trim() ? BigInt(text) : null; + } catch { + return null; + } + }, }, }, ]; @@ -172,20 +155,23 @@ describe('BigInt Filter — custom bigintParser', () => { // Built before the swap: an input reads `allowedCharPattern` once, when it is created. const filter = await ColumnFilterHarness.open(api, 'val'); - // The pattern is a keydown guard, so only a real keystroke shows which pattern an input is holding. - const rejectsKey = (key: string): boolean => { - const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }); - filter.inputs('text', 0)[0].dispatchEvent(event); - return event.defaultPrevented; + // The pattern refuses the edit, so only real typing shows which pattern an input is holding. + const userSession = userEvent.setup(); + const typeHex = async (): Promise => { + const input = filter.inputs('text', 0)[0]; + await userSession.clear(input); + await userSession.type(input, '0xF'); + return input.value; }; - expect(rejectsKey('F')).toBe(true); + // Decimal only, so both `x` and `F` are dropped where they were typed. + expect(await typeHex()).toBe('0'); api.setGridOption('columnDefs', columnDefs('\\dxXa-fA-F')); await asyncSetTimeout(0); // Only a replaced element carries the new pattern; the guard is installed once, at build time. - expect(rejectsKey('F')).toBe(false); + expect(await typeHex()).toBe('0xF'); await filter.selectOperator('Equals'); await filter.setText('0xFF', 0); @@ -319,4 +305,46 @@ describe('BigInt Filter — custom bigintParser', () => { expect(FloatingFilterHarness.get(api, 'val').input().value).toBe('16'); }); + + test('the parser and the formatter are given the api and the context', async () => { + const context = { tag: 'bigint' }; + // Kept apart: one array cannot tell "both were given it" from "only one of them ran". + const parserSaw: FilterInputCallbackParams[] = []; + const formatterSaw: FilterInputCallbackParams[] = []; + const api: GridApi = await gridsManager.createGridAndWait('grid5', { + context, + columnDefs: [ + { + field: 'val', + cellDataType: 'bigint' as const, + filter: 'agBigIntColumnFilter' as const, + filterParams: { + debounceMs: 0, + bigintParser: (text: string | null, common: FilterInputCallbackParams) => { + parserSaw.push(common); + return text == null || text.trim() === '' ? null : BigInt(text); + }, + bigintFormatter: (value: bigint | null, common: FilterInputCallbackParams) => { + formatterSaw.push(common); + return value == null ? null : String(value); + }, + }, + }, + ], + rowData: [{ val: 1n }, { val: 5n }], + }); + + const filter = await ColumnFilterHarness.open(api, 'val'); + await filter.setText('5'); + + expect(parserSaw.length).toBeGreaterThan(0); + expect(formatterSaw.length).toBeGreaterThan(0); + for (const common of [...parserSaw, ...formatterSaw]) { + expect(common.api).toBe(api); + expect(common.context).toBe(context); + // The column too, so one callback on `defaultColDef` can tell which one it is working on. + expect(common.column.getColId()).toBe('val'); + expect(common.colDef).toBe(common.column.getColDef()); + } + }); }); diff --git a/testing/behavioural/src/filters/filter-behaviour/date-filter-conditions.test.ts b/testing/behavioural/src/filters/filter-behaviour/date-filter-conditions.test.ts index 3ca058a26df..83673c36868 100644 --- a/testing/behavioural/src/filters/filter-behaviour/date-filter-conditions.test.ts +++ b/testing/behavioural/src/filters/filter-behaviour/date-filter-conditions.test.ts @@ -1,6 +1,8 @@ import { + ALL_SEVERITIES, ColumnFilterHarness, FilterDom, + FloatingFilterHarness, GridRows, TestGridsManager, asyncSetTimeout, @@ -9,7 +11,7 @@ import { } from 'ag-test-utils'; import type { GridApi } from 'ag-grid-community'; -import { ClientSideRowModelModule, DateFilterModule, setupAgTestIds } from 'ag-grid-community'; +import { ClientSideRowModelModule, DateFilterModule, enableDevValidations, setupAgTestIds } from 'ag-grid-community'; /** * Black-box coverage for `agDateColumnFilter` conditions (operators, inRange boundaries, validation, @@ -26,7 +28,63 @@ describe('Date Filter — conditions coverage', () => { installFilterLayoutMock(); }); afterAll(() => uninstallFilterLayoutMock()); - afterEach(() => gridsManager.reset()); + afterEach(() => { + // Restored first: a throw from `reset` would otherwise leave `console.warn` mocked for the rest of + // the file. The global `beforeEach` puts the validation config back on its own. + vi.restoreAllMocks(); + gridsManager.reset(); + }); + + // A date component is built inside an `AgPromise` that settles inline, so one reporting from its own + // `init` drives the filter's callbacks before the condition it belongs to has its type widget. + test('a date component reporting a change while it initialises builds one whole condition', async () => { + class EagerDateComp { + private eGui!: HTMLInputElement; + public init(params: { onDateChanged: () => void }): void { + this.eGui = document.createElement('input'); + params.onDateChanged(); + } + public getGui(): HTMLElement { + return this.eGui; + } + public getDate(): Date | null { + return null; + } + public setDate(): void {} + } + + const api: GridApi = await gridsManager.createGridAndWait('grid1', { + columnDefs: [ + { + field: 'date', + filter: 'agDateColumnFilter', + dateComponent: EagerDateComp, + filterParams: { debounceMs: 0 }, + }, + ], + rowData: ASCENDING, + }); + + const filter = await ColumnFilterHarness.open(api, 'date'); + // One whole condition, not two: reporting mid-build must not leave a half-registered condition behind + // for the completeness pass to count and then build a successor to. + await new FilterDom(api, 'a panel whose component reported before its type widget existed', { + mode: 'column-filter', + colId: 'date', + }).checkFilterDom(` + COLUMN FILTER + operator: "Equals" + model: null + `); + expect(filter.getModel()).toBe(null); + await new GridRows(api, 'a filter whose component reported early still filters nothing').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 date:"2024-01-10" + ├── LEAF id:1 date:"2024-03-15" + ├── LEAF id:2 date:"2024-05-20" + └── LEAF id:3 date:"2024-07-04" + `); + }); const ASCENDING = [{ date: '2024-01-10' }, { date: '2024-03-15' }, { date: '2024-05-20' }, { date: '2024-07-04' }]; @@ -44,6 +102,49 @@ describe('Date Filter — conditions coverage', () => { expect(body!.hasAttribute('role')).toBe(false); }); + test('a filterParams `includeTime` column shows the date it was given', async () => { + const api: GridApi = await gridsManager.createGridAndWait('grid1', { + // `includeTime` is the only thing saying the input takes a time; no cell data type implies one. + columnDefs: [ + { field: 'date', filter: 'agDateColumnFilter', filterParams: { debounceMs: 0, includeTime: true } }, + ], + rowData: ASCENDING, + }); + + await api.setColumnFilterModel('date', { filterType: 'date', type: 'equals', dateFrom: '2024-03-15 14:30:00' }); + api.onFilterChanged(); + const filter = await ColumnFilterHarness.open(api, 'date'); + await asyncSetTimeout(0); + + const input = filter.input('date', 0); + expect(input.type).toBe('datetime-local'); + // Written for the input the params asked for, not the one the cell data type implies. + expect(input.value).toBe('2024-03-15T14:30'); + }); + + test('a filterParams `includeTime` floating filter shows the date it was given', async () => { + const api: GridApi = await gridsManager.createGridAndWait('grid1', { + columnDefs: [ + { + field: 'date', + filter: 'agDateColumnFilter', + floatingFilter: true, + filterParams: { debounceMs: 0, includeTime: true }, + }, + ], + rowData: ASCENDING, + }); + + await api.setColumnFilterModel('date', { filterType: 'date', type: 'equals', dateFrom: '2024-03-15 14:30:00' }); + api.onFilterChanged(); + await asyncSetTimeout(0); + + // The floating filter is handed the same `filterParams`, so it builds the same kind of input. + const input = FloatingFilterHarness.get(api, 'date').input(); + expect(input.type).toBe('datetime-local'); + expect(input.value).toBe('2024-03-15T14:30'); + }); + test('comparison operators over an ascending date dataset', async () => { const api: GridApi = await gridsManager.createGridAndWait('grid1', { columnDefs: [{ field: 'date', filter: 'agDateColumnFilter', filterParams: { debounceMs: 0 } }], @@ -56,7 +157,7 @@ describe('Date Filter — conditions coverage', () => { await filter.selectOperator('Equals'); await filter.setDate('2024-03-15', 0); await asyncSetTimeout(0); - await new FilterDom(api, 'equals panel', { colId: 'date' }).checkFilterDom(` + await new FilterDom(api, 'equals panel', { mode: 'column-filter', colId: 'date' }).checkFilterDom(` COLUMN FILTER operator: "Equals" input: "2024-03-15" @@ -125,7 +226,7 @@ describe('Date Filter — conditions coverage', () => { await filter.selectOperator('Blank'); await asyncSetTimeout(0); - await new FilterDom(api, 'blank panel', { colId: 'date' }).checkFilterDom(` + await new FilterDom(api, 'blank panel', { mode: 'column-filter', colId: 'date' }).checkFilterDom(` COLUMN FILTER operator: "Blank" AND @@ -174,7 +275,7 @@ describe('Date Filter — conditions coverage', () => { await filter.setDate('2024-07-04', 1); await asyncSetTimeout(0); - await new FilterDom(api, 'inRange exclusive panel', { colId: 'date' }).checkFilterDom(` + await new FilterDom(api, 'inRange exclusive panel', { mode: 'column-filter', colId: 'date' }).checkFilterDom(` COLUMN FILTER operator: "Between" input [0]: "2024-03-15" @@ -245,7 +346,7 @@ describe('Date Filter — conditions coverage', () => { await filter.setDate('2024-03-15', 0); await asyncSetTimeout(0); - await new FilterDom(api, 'inRange incomplete panel', { colId: 'date' }).checkFilterDom(` + await new FilterDom(api, 'inRange incomplete panel', { mode: 'column-filter', colId: 'date' }).checkFilterDom(` COLUMN FILTER operator: "Between" input [0]: "2024-03-15" @@ -440,7 +541,7 @@ describe('Date Filter — conditions coverage', () => { await filter.setDate('2024-09-01', 1); await asyncSetTimeout(0); - await new FilterDom(api, 'AND panel', { colId: 'date' }).checkFilterDom(` + await new FilterDom(api, 'AND panel', { mode: 'column-filter', colId: 'date' }).checkFilterDom(` COLUMN FILTER operator: "After" input: "2024-01-10" @@ -491,7 +592,7 @@ describe('Date Filter — conditions coverage', () => { await filter.setDate('2024-09-01', 1); await asyncSetTimeout(0); - await new FilterDom(api, 'OR panel', { colId: 'date' }).checkFilterDom(` + await new FilterDom(api, 'OR panel', { mode: 'column-filter', colId: 'date' }).checkFilterDom(` COLUMN FILTER operator: "Equals" input: "2024-01-10" @@ -548,7 +649,7 @@ describe('Date Filter — conditions coverage', () => { // Re-opening the popup reflects the programmatic model in its inputs. await ColumnFilterHarness.open(api, 'date'); - await new FilterDom(api, 'round-trip panel', { colId: 'date' }).checkFilterDom(` + await new FilterDom(api, 'round-trip panel', { mode: 'column-filter', colId: 'date' }).checkFilterDom(` COLUMN FILTER operator: "Between" input [0]: "2024-03-15" @@ -563,6 +664,158 @@ describe('Date Filter — conditions coverage', () => { type: "inRange" `); }); + + // A read-only filter builds no replacement for a condition a model removed, so it is the one panel that + // can be attached holding none at all. + test('a read-only filter opens on a combined model with no conditions rather than throwing', async () => { + // Deliberate: a combined model without `conditions` is reported as warning #77. + enableDevValidations({ throwOn: ALL_SEVERITIES, suppress: [77] }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const api: GridApi = await gridsManager.createGridAndWait('grid1', { + columnDefs: [ + { field: 'date', filter: 'agDateColumnFilter', filterParams: { debounceMs: 0, readOnly: true } }, + ], + rowData: ASCENDING, + }); + + await api.setColumnFilterModel('date', { filterType: 'date', operator: 'AND' }); + api.onFilterChanged(); + await asyncSetTimeout(0); + + await ColumnFilterHarness.open(api, 'date'); + await asyncSetTimeout(0); + + // The panel itself is the subject: a model with nothing to show must leave it empty rather than + // holding a condition built out of the gap. + await new FilterDom(api, 'a read-only panel built from a conditionless model', { + mode: 'column-filter', + colId: 'date', + }).checkFilterDom(` + COLUMN FILTER + model: + filterType: "date" + operator: "AND" + `); + + expect(warnSpy.mock.calls.flat().join(' ')).toContain('warning #77'); + expect(api.getColumnFilterModel('date')).toEqual({ filterType: 'date', operator: 'AND' }); + await new GridRows(api, 'a conditionless read-only date model matches every row').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 date:"2024-01-10" + ├── LEAF id:1 date:"2024-03-15" + ├── LEAF id:2 date:"2024-05-20" + └── LEAF id:3 date:"2024-07-04" + `); + }); + + // The read-only panel is the one that can be left holding nothing, so it is the one that rebuilds + // from zero, where a join operator has no condition in front of it to join to. + test('a model arriving at a read-only panel holding no conditions joins them from the second on', async () => { + // Deliberate: the conditionless model on the way in is reported as warning #77. + enableDevValidations({ throwOn: ALL_SEVERITIES, suppress: [77] }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const api: GridApi = await gridsManager.createGridAndWait('grid1', { + columnDefs: [ + { field: 'date', filter: 'agDateColumnFilter', filterParams: { debounceMs: 0, readOnly: true } }, + ], + rowData: ASCENDING, + }); + + await api.setColumnFilterModel('date', { filterType: 'date', operator: 'AND' }); + api.onFilterChanged(); + await asyncSetTimeout(0); + await ColumnFilterHarness.open(api, 'date'); + await asyncSetTimeout(0); + + await api.setColumnFilterModel('date', { + filterType: 'date', + operator: 'AND', + conditions: [ + { filterType: 'date', type: 'greaterThan', dateFrom: '2024-02-01' }, + { filterType: 'date', type: 'lessThan', dateFrom: '2024-06-01' }, + ], + }); + api.onFilterChanged(); + await asyncSetTimeout(0); + + expect(warnSpy.mock.calls.flat().join(' ')).toContain('warning #77'); + // Counted in the DOM: the snapshot renders the join from its own loop over conditions, so a panel + // mounted ahead of the first one is invisible there. + expect(document.querySelectorAll('.ag-filter-menu .ag-filter-condition')).toHaveLength(1); + + await new FilterDom(api, 'two conditions joined by one operator between them', { + mode: 'column-filter', + colId: 'date', + }).checkFilterDom(` + COLUMN FILTER + operator: "After" + input: "2024-02-01" + AND + operator: "Before" + input: "2024-06-01" + model: + filterType: "date" + operator: "AND" + conditions: + - filterType: "date" + type: "greaterThan" + dateFrom: "2024-02-01" + - filterType: "date" + type: "lessThan" + dateFrom: "2024-06-01" + `); + }); + + // The read-only panel is the only one that stays at zero conditions, and a simple model addresses the + // first by index, so it has to build one before it writes to it. + test('a simple model arriving at a read-only panel holding no conditions builds one to hold it', async () => { + // Deliberate: the conditionless model on the way in is reported as warning #77. + enableDevValidations({ throwOn: ALL_SEVERITIES, suppress: [77] }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const api: GridApi = await gridsManager.createGridAndWait('grid1', { + columnDefs: [ + { field: 'date', filter: 'agDateColumnFilter', filterParams: { debounceMs: 0, readOnly: true } }, + ], + rowData: ASCENDING, + }); + + await api.setColumnFilterModel('date', { filterType: 'date', operator: 'AND' }); + api.onFilterChanged(); + await asyncSetTimeout(0); + await ColumnFilterHarness.open(api, 'date'); + await asyncSetTimeout(0); + + await api.setColumnFilterModel('date', { + filterType: 'date', + type: 'greaterThan', + dateFrom: '2024-02-01', + }); + api.onFilterChanged(); + await asyncSetTimeout(0); + + expect(warnSpy.mock.calls.flat().join(' ')).toContain('warning #77'); + await new FilterDom(api, 'a simple model shown by a panel that had been left with no conditions', { + mode: 'column-filter', + colId: 'date', + }).checkFilterDom(` + COLUMN FILTER + operator: "After" + input: "2024-02-01" + model: + filterType: "date" + type: "greaterThan" + dateFrom: "2024-02-01" + `); + await new GridRows(api, 'the simple model reaching an emptied read-only panel still filters').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:1 date:"2024-03-15" + ├── LEAF id:2 date:"2024-05-20" + └── LEAF id:3 date:"2024-07-04" + `); + }); }); /** Day-granularity date comparator (cell vs filter): ignores the time-of-day component. */ diff --git a/testing/behavioural/src/filters/filter-behaviour/number-filter-conditions.test.ts b/testing/behavioural/src/filters/filter-behaviour/number-filter-conditions.test.ts index d9a955a8e1f..5d5d236bb8f 100644 --- a/testing/behavioural/src/filters/filter-behaviour/number-filter-conditions.test.ts +++ b/testing/behavioural/src/filters/filter-behaviour/number-filter-conditions.test.ts @@ -10,14 +10,15 @@ import { uninstallFilterLayoutMock, } from 'ag-test-utils'; -import type { GridApi } from 'ag-grid-community'; +import type { FilterInputCallbackParams, GridApi } from 'ag-grid-community'; import { ClientSideRowModelModule, NumberFilterModule, enableDevValidations, setupAgTestIds } from 'ag-grid-community'; /** * Black-box coverage for `agNumberColumnFilter` conditions: operators, inRange boundary semantics, - * blank handling, `allowedCharPattern`/`numberParser`/`numberFormatter`/`filterInputType`, which element - * type an input is built as and how a `colDef` refresh replaces it, AND/OR compounds, model round-trip. - * Complements number-filter-range-validation.test.ts (validation-focused) — no overlap. + * blank handling, `numberParser`/`numberFormatter`/`filterInputType`, which element type an input is built + * as and how a `colDef` refresh replaces it, AND/OR compounds, condition limits, model round-trip. + * Complements number-filter-range-validation.test.ts (validation) and allowed-char-pattern.test.ts (which + * characters an input admits) — no overlap. */ describe('Number Filter — conditions coverage', () => { const gridsManager = new TestGridsManager({ @@ -216,6 +217,35 @@ describe('Number Filter — conditions coverage', () => { `); }); + // A strict range of one value matches nothing, but an inclusive one is an exact match, so reporting it + // as out of order would leave a legitimate filter that can never be applied. + test('inRange accepts a single-value range when inRangeInclusive is true', async () => { + const api: GridApi = await gridsManager.createGridAndWait('grid1', { + columnDefs: [ + { + field: 'val', + filter: 'agNumberColumnFilter', + filterParams: { debounceMs: 0, inRangeInclusive: true }, + }, + ], + rowData: MIXED, + }); + + const filter = await ColumnFilterHarness.open(api, 'val'); + await filter.selectOperator('Between'); + await filter.setNumber(3, 0); + await filter.setNumber(3, 1); + await asyncSetTimeout(0); + + expect(filter.input('number', 0).validity.valid).toBe(true); + expect(filter.input('number', 1).validity.valid).toBe(true); + expect(filter.getModel()).toEqual({ filterType: 'number', type: 'inRange', filter: 3, filterTo: 3 }); + await new GridRows(api, 'an inclusive range of one value is an exact match').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:3 val:3 + `); + }); + test('blank and notBlank partition null vs non-null rows (0 is not blank)', async () => { const api: GridApi = await gridsManager.createGridAndWait('grid1', { columnDefs: [{ field: 'val', filter: 'agNumberColumnFilter', filterParams: { debounceMs: 0 } }], @@ -735,10 +765,15 @@ describe('Number Filter — conditions coverage', () => { const filter = await ColumnFilterHarness.open(api, 'val'); const inputs = [filter.input('number', 0), FloatingFilterHarness.get(api, 'floating').input()]; + // A `number` input reports its text as blank until it parses, so the edit is what can be observed. + const admits = (input: HTMLInputElement, data: string): boolean => + input.dispatchEvent( + new InputEvent('beforeinput', { inputType: 'insertText', data, cancelable: true, bubbles: true }) + ); for (const input of inputs) { expect(input.type).toBe('number'); - expect(input.dispatchEvent(new KeyboardEvent('keydown', { key: 'e', cancelable: true }))).toBe(false); - expect(input.dispatchEvent(new KeyboardEvent('keydown', { key: '5', cancelable: true }))).toBe(true); + expect(admits(input, 'e')).toBe(false); + expect(admits(input, '5')).toBe(true); } }); @@ -1002,12 +1037,13 @@ describe('Number Filter — conditions coverage', () => { // Hand-written models reach the grid: a join operator with no conditions must not break filtering, // and the open panel must still follow it. - await api.setColumnFilterModel('val', { filterType: 'number', operator: 'AND' } as any); + await api.setColumnFilterModel('val', { filterType: 'number', operator: 'AND' }); api.onFilterChanged(); await asyncSetTimeout(0); expect(warnSpy.mock.calls.flat().join(' ')).toContain('warning #77'); expect(api.getColumnFilterModel('val')).toEqual({ filterType: 'number', operator: 'AND' }); + // The panel is back to one condition, with nothing before the first for a join to join it to. await new FilterDom(api, 'conditionless combined model panel', { colId: 'val' }).checkFilterDom(` COLUMN FILTER operator: "Equals" @@ -1026,6 +1062,22 @@ describe('Number Filter — conditions coverage', () => { └── LEAF id:5 val:10 `); + // `OR` is the operator that separates passing every row from failing every one of them: joining no + // conditions with `some` would match nothing, where `every` over an empty list already matched all. + await api.setColumnFilterModel('val', { filterType: 'number', operator: 'OR' }); + api.onFilterChanged(); + await asyncSetTimeout(0); + + await new GridRows(api, 'a conditionless OR matches every row too').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 val:-10 + ├── LEAF id:1 val:-2.5 + ├── LEAF id:2 val:0 + ├── LEAF id:3 val:3 + ├── LEAF id:4 val:7.5 + └── LEAF id:5 val:10 + `); + // A well-formed model applied over it still takes effect. await api.setColumnFilterModel('val', { filterType: 'number', type: 'greaterThan', filter: 7 }); api.onFilterChanged(); @@ -1038,6 +1090,170 @@ describe('Number Filter — conditions coverage', () => { `); }); + test('a floating filter follows a combined model with no conditions rather than throwing', async () => { + // No popup is built here, so the malformed model reaches the floating filter without warning #77 first. + const api: GridApi = await gridsManager.createGridAndWait('grid1', { + columnDefs: [ + { field: 'val', filter: 'agNumberColumnFilter', floatingFilter: true, filterParams: { debounceMs: 0 } }, + ], + rowData: MIXED, + }); + + // Filled first, or an emptied input cannot be told from one that was never given anything. + await api.setColumnFilterModel('val', { filterType: 'number', type: 'equals', filter: 3 }); + api.onFilterChanged(); + await asyncSetTimeout(0); + expect(FloatingFilterHarness.get(api, 'val').input().value).toBe('3'); + + await api.setColumnFilterModel('val', { filterType: 'number', operator: 'AND' }); + api.onFilterChanged(); + await asyncSetTimeout(0); + + // Strict: a key that was never there must not be matched by one rewritten to `undefined`. + expect(api.getColumnFilterModel('val')).toStrictEqual({ filterType: 'number', operator: 'AND' }); + // No condition to read back, so the floating filter shows nothing rather than a value it does not have. + expect(FloatingFilterHarness.get(api, 'val').input().value).toBe(''); + }); + + test('`maxNumConditions` below one leaves a lone condition alone rather than rewriting it forever', async () => { + const api: GridApi = await gridsManager.createGridAndWait('grid1', { + columnDefs: [ + { field: 'val', filter: 'agNumberColumnFilter', filterParams: { debounceMs: 0, maxNumConditions: 0 } }, + ], + rowData: MIXED, + }); + + await api.setColumnFilterModel('val', { filterType: 'number', type: 'greaterThan', filter: 0 }); + api.onFilterChanged(); + await asyncSetTimeout(0); + expect(api.getColumnFilterModel('val')).toEqual({ filterType: 'number', type: 'greaterThan', filter: 0 }); + + let modelChanges = 0; + api.addEventListener('filterChanged', () => modelChanges++); + + // Only a refresh re-validates, so repeating one is what separates a limit that leaves the model + // alone from one that rewrites it a little further on every pass. + for (let pass = 0; pass < 2; pass++) { + api.setGridOption('columnDefs', [ + { + field: 'val', + filter: 'agNumberColumnFilter', + filterParams: { debounceMs: 0, maxNumConditions: 0 }, + }, + ]); + await asyncSetTimeout(0); + } + + expect(modelChanges).toBe(0); + expect(api.getColumnFilterModel('val')).toEqual({ filterType: 'number', type: 'greaterThan', filter: 0 }); + }); + + // `NaN` is the case a bare `< 1` test misses: every comparison against it is false, so an unfloored + // limit would cap nothing and hand back both conditions. + test.each([ + ['below one', 0], + ['not a number', Number.NaN], + ])('`maxNumConditions` %s keeps one condition rather than emptying the model', async (_name, maxNumConditions) => { + const api: GridApi = await gridsManager.createGridAndWait('grid1', { + columnDefs: [ + { field: 'val', filter: 'agNumberColumnFilter', filterParams: { debounceMs: 0, maxNumConditions } }, + ], + rowData: MIXED, + }); + + await api.setColumnFilterModel('val', { + filterType: 'number', + operator: 'AND', + conditions: [ + { filterType: 'number', type: 'greaterThan', filter: 0 }, + { filterType: 'number', type: 'lessThan', filter: 5 }, + ], + }); + api.onFilterChanged(); + await asyncSetTimeout(0); + + // The first condition survives and filters: emptying the list would leave a model nothing can show. + expect(api.getColumnFilterModel('val')).toEqual({ filterType: 'number', type: 'greaterThan', filter: 0 }); + await new GridRows(api, 'the one surviving condition filters').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:3 val:3 + ├── LEAF id:4 val:7.5 + └── LEAF id:5 val:10 + `); + }); + + // Characterises a mismatch rather than asserting a fix: `maxNumConditions` caps what the panel builds, + // and where it is unset the model keeps every condition the caller set, so a filter can evaluate more + // conditions than the panel can show. Capping the model instead would silently drop a condition from a + // working `setFilterModel` call, which is why it is left as it is. + test('an unset `maxNumConditions` leaves the model every condition it was given', async () => { + const api: GridApi = await gridsManager.createGridAndWait('grid1', { + columnDefs: [{ field: 'val', filter: 'agNumberColumnFilter', filterParams: { debounceMs: 0 } }], + rowData: MIXED, + }); + + await api.setColumnFilterModel('val', { + filterType: 'number', + operator: 'AND', + conditions: [ + { filterType: 'number', type: 'greaterThan', filter: 0 }, + { filterType: 'number', type: 'lessThan', filter: 8 }, + { filterType: 'number', type: 'notEqual', filter: 3 }, + ], + }); + api.onFilterChanged(); + await asyncSetTimeout(0); + + expect(api.getColumnFilterModel('val')).toEqual({ + filterType: 'number', + operator: 'AND', + conditions: [ + { filterType: 'number', type: 'greaterThan', filter: 0 }, + { filterType: 'number', type: 'lessThan', filter: 8 }, + { filterType: 'number', type: 'notEqual', filter: 3 }, + ], + }); + // 3 is what proves the third condition is evaluated: the first two admit it. + await new GridRows(api, 'all three conditions filter, past what the panel would show').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:4 val:7.5 + `); + }); + + // The two limits are floored on the display as well as in the model, and the message names the floor. + // `NaN` is the case a bare `< 1` test misses, leaving a limit every later comparison reads as no limit. + test.each([ + ['below one', 0], + ['not a number', Number.NaN], + ])('a condition limit %s is reported as needing to be at least one', async (_name, limit) => { + enableDevValidations({ throwOn: ALL_SEVERITIES, suppress: [79, 80] }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const api: GridApi = await gridsManager.createGridAndWait('grid1', { + columnDefs: [ + { + field: 'val', + filter: 'agNumberColumnFilter', + filterParams: { debounceMs: 0, maxNumConditions: limit, numAlwaysVisibleConditions: limit }, + }, + ], + rowData: MIXED, + }); + + await ColumnFilterHarness.open(api, 'val'); + + const warnings = warnSpy.mock.calls.flat().join(' '); + expect(warnings).toContain('`filterParams.maxNumConditions` must be greater than or equal to one.'); + expect(warnings).toContain('`filterParams.numAlwaysVisibleConditions` must be greater than or equal to one.'); + // Both floored to one, so the panel shows a single condition and nothing to join it to. + await new FilterDom(api, 'both condition limits floored to one', { colId: 'val' }).checkFilterDom(` + COLUMN FILTER + operator: "Equals" + input: "" ⟨Filter...⟩ + model: null + `); + }); + test('a cell no number can be compared with is excluded rather than ordered against', async () => { const api: GridApi = await gridsManager.createGridAndWait('grid1', { columnDefs: [{ field: 'val', filter: 'agNumberColumnFilter', filterParams: { debounceMs: 0 } }], @@ -1398,4 +1614,46 @@ describe('Number Filter — conditions coverage', () => { // Equals takes one input, so the replacements must not arrive showing the unused second one. expect(filter.inputs('text', 0)).toHaveLength(1); }); + + test('the parser and the formatter are given the api and the context', async () => { + const context = { tag: 'number' }; + // Kept apart: one array cannot tell "both were given it" from "only one of them ran". + const parserSaw: FilterInputCallbackParams[] = []; + const formatterSaw: FilterInputCallbackParams[] = []; + const api: GridApi = await gridsManager.createGridAndWait('grid1', { + context, + columnDefs: [ + { + field: 'val', + filter: 'agNumberColumnFilter', + filterParams: { + debounceMs: 0, + numberParser: (text: string | null, common: FilterInputCallbackParams) => { + parserSaw.push(common); + // `Number('')` is `0`, which would read an emptied input as a real value. + return text ? Number(text) : null; + }, + numberFormatter: (value: number | null, common: FilterInputCallbackParams) => { + formatterSaw.push(common); + return value == null ? null : String(value); + }, + }, + }, + ], + rowData: [{ val: 1 }, { val: 5 }], + }); + + const filter = await ColumnFilterHarness.open(api, 'val'); + await filter.setText('5'); + + expect(parserSaw.length).toBeGreaterThan(0); + expect(formatterSaw.length).toBeGreaterThan(0); + for (const common of [...parserSaw, ...formatterSaw]) { + expect(common.api).toBe(api); + expect(common.context).toBe(context); + // The column too, so one callback on `defaultColDef` can tell which one it is working on. + expect(common.column.getColId()).toBe('val'); + expect(common.colDef).toBe(common.column.getColDef()); + } + }); }); diff --git a/testing/behavioural/src/filters/filter-behaviour/text-filter-conditions-buttons-and-model.test.ts b/testing/behavioural/src/filters/filter-behaviour/text-filter-conditions-buttons-and-model.test.ts index 3248800fc79..661e6c1c2ac 100644 --- a/testing/behavioural/src/filters/filter-behaviour/text-filter-conditions-buttons-and-model.test.ts +++ b/testing/behavioural/src/filters/filter-behaviour/text-filter-conditions-buttons-and-model.test.ts @@ -1,3 +1,4 @@ +import { waitFor } from '@testing-library/dom'; import { ColumnFilterHarness, FilterDom, @@ -207,4 +208,42 @@ describe('Text Filter — buttons & model round-trip', () => { └── LEAF id:2 name:"Charlie" `); }); + + // `OR` is the case that separates a model joining nothing from one matching nothing: `[].some()` is false. + test.each([ + ['missing', 'AND', undefined], + ['empty', 'AND', []], + ['missing', 'OR', undefined], + ['empty', 'OR', []], + ] as const)( + 'a combined %s-conditions %s model with a wrong filterType leaves every row through', + async (_name, operator, conditions) => { + const api: GridApi = await gridsManager.createGridAndWait('grid1', { + columnDefs: [{ field: 'name', filter: 'agTextColumnFilter', filterParams: { debounceMs: 0 } }], + rowData: [{ name: 'Alice' }, { name: 'Bob' }], + }); + + // Filtered first, so leaving every row through has to be this model's doing rather than the start state. + await api.setColumnFilterModel('name', { filterType: 'text', type: 'equals', filter: 'Alice' }); + api.onFilterChanged(); + await waitFor(() => expect(api.getDisplayedRowCount()).toBe(1)); + + // Spread, so the missing case omits the key outright rather than setting it to `undefined`. + const combined = { filterType: 'number', operator, ...(conditions ? { conditions } : {}) }; + // A wrong `filterType` is what makes the grid re-stamp the conditions. + await api.setColumnFilterModel('name', combined); + api.onFilterChanged(); + await waitFor(() => expect(api.getDisplayedRowCount()).toBe(2)); + + // Strict: a key the caller never supplied must not come back as `undefined`. + expect(api.getColumnFilterModel('name')).toStrictEqual({ ...combined, filterType: 'text' }); + // A model that constrains nothing is still a model, so the column keeps advertising a filter. + expect(api.isColumnFilterPresent()).toBe(true); + await new GridRows(api, 'unfiltered').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 name:"Alice" + └── LEAF id:1 name:"Bob" + `); + } + ); }); diff --git a/testing/behavioural/src/filters/filter-option-switching.test.ts b/testing/behavioural/src/filters/filter-option-switching.test.ts index 7dbff9d4d31..d18c2f3a8ac 100644 --- a/testing/behavioural/src/filters/filter-option-switching.test.ts +++ b/testing/behavioural/src/filters/filter-option-switching.test.ts @@ -203,4 +203,166 @@ describe('Filter option switching and floating filter sync', () => { expect(seen).toContainEqual({ filterOptionKey: 'withinOf', filterOption: 'Dentro De' }); }); + + test('an out-of-order range does not block the narrower option chosen after it', async () => { + const api = await gridsManager.createGridAndWait('grid1', { + columnDefs: [{ field: 'gold', filter: 'agNumberColumnFilter', filterParams: { debounceMs: 0 } }], + rowData: [{ gold: 2 }, { gold: 8 }, { gold: 10 }], + }); + await asyncSetTimeout(0); + + const harness = await ColumnFilterHarness.open(api, 'gold'); + await harness.selectOperator('Between'); + // Length first, so a pair that lost an input fails as that rather than as a crash. + expect(harness.inputs('number', 0)).toHaveLength(2); + const [from, to] = harness.inputs('number', 0); + fireEvent.input(from, { target: { value: '10' } }); + fireEvent.input(to, { target: { value: '5' } }); + await waitFor(() => expect(to.validity.valid).toBe(false)); + + await harness.selectOperator('Equals'); + // A different value from the one the range left behind, so applying the retained 10 would not pass. + fireEvent.input(harness.inputs('number', 0)[0], { target: { value: '8' } }); + + // The `to` input is not part of the condition, so the error it reported cannot hold it back. + await waitFor(() => + expect(api.getColumnFilterModel('gold')).toEqual({ filterType: 'number', type: 'equals', filter: 8 }) + ); + await new FilterDom(api, 'the abandoned range leaves no message', { colId: 'gold' }).checkFilterDom(` + COLUMN FILTER + operator: "Equals" + input: "8" + AND + operator: "Equals" + input: "" ⟨Filter...⟩ + model: + filterType: "number" + type: "equals" + filter: 8 + `); + await new GridRows(api, 'equals applies after an abandoned range').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:1 gold:8 + `); + }); + + test('a floating filter applies over a range message the popup left behind', async () => { + const api = await gridsManager.createGridAndWait('grid1', { + // `suppressFloatingFilterButton` keeps the popup on the header button, so both can be driven. + columnDefs: [ + { + field: 'gold', + filter: 'agNumberColumnFilter', + floatingFilter: true, + suppressFloatingFilterButton: true, + filterParams: { debounceMs: 0 }, + }, + ], + rowData: [{ gold: 2 }, { gold: 8 }, { gold: 10 }], + }); + await asyncSetTimeout(0); + + const harness = await ColumnFilterHarness.open(api, 'gold'); + await harness.selectOperator('Between'); + // Length first, so a pair that lost an input fails as that rather than as a crash. + expect(harness.inputs('number', 0)).toHaveLength(2); + const [from, to] = harness.inputs('number', 0); + fireEvent.input(from, { target: { value: '10' } }); + fireEvent.input(to, { target: { value: '5' } }); + await waitFor(() => expect(to.validity.valid).toBe(false)); + + api.hidePopupMenu(); + await asyncSetTimeout(0); + + // The floating filter writes a one-input option, so the abandoned range is no longer the condition + // being applied and the message it left cannot hold the new value back. + await FloatingFilterHarness.get(api, 'gold').setValue('8'); + await waitFor(() => + expect(api.getColumnFilterModel('gold')).toEqual({ filterType: 'number', type: 'equals', filter: 8 }) + ); + await new GridRows(api, 'the floating filter applies over an abandoned range').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:1 gold:8 + `); + }); + + // The sibling above leaves the message on `to`, which the floating filter clears along with that input. + // `from` is the end it writes to, so a message there is the one that would otherwise survive. + test('a floating filter applies over a range message left on the from input', async () => { + const api = await gridsManager.createGridAndWait('grid1', { + columnDefs: [ + { + field: 'gold', + filter: 'agNumberColumnFilter', + floatingFilter: true, + suppressFloatingFilterButton: true, + filterParams: { debounceMs: 0 }, + }, + ], + rowData: [{ gold: 2 }, { gold: 8 }, { gold: 10 }], + }); + await asyncSetTimeout(0); + + const harness = await ColumnFilterHarness.open(api, 'gold'); + await harness.selectOperator('Between'); + const [from, to] = harness.inputs('number', 0); + // `from` last, so it is the end the out-of-order message lands on. + fireEvent.input(to, { target: { value: '5' } }); + fireEvent.input(from, { target: { value: '10' } }); + await waitFor(() => expect(from.validity.valid).toBe(false)); + + api.hidePopupMenu(); + await asyncSetTimeout(0); + + await FloatingFilterHarness.get(api, 'gold').setValue('8'); + await waitFor(() => + expect(api.getColumnFilterModel('gold')).toEqual({ filterType: 'number', type: 'equals', filter: 8 }) + ); + await new GridRows(api, 'the floating filter applies over a message left on from').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:1 gold:8 + `); + }); + + test('a model with fewer conditions drops the extra ones even while another holds an error', async () => { + const api = await gridsManager.createGridAndWait('grid1', { + columnDefs: [{ field: 'gold', filter: 'agNumberColumnFilter', filterParams: { debounceMs: 0 } }], + rowData: [{ gold: 2 }, { gold: 8 }, { gold: 10 }], + }); + await asyncSetTimeout(0); + + const harness = await ColumnFilterHarness.open(api, 'gold'); + await harness.selectOperator('Equals'); + fireEvent.input(harness.inputs('number', 0)[0], { target: { value: '8' } }); + await harness.selectOperator('Between', 1); + // Length first, so a pair that lost an input fails as that rather than as a crash. + expect(harness.inputs('number', 1)).toHaveLength(2); + const [from, to] = harness.inputs('number', 1); + fireEvent.input(from, { target: { value: '10' } }); + fireEvent.input(to, { target: { value: '5' } }); + await waitFor(() => expect(to.validity.valid).toBe(false)); + + await api.setColumnFilterModel('gold', { filterType: 'number', type: 'equals', filter: 10 }); + api.onFilterChanged(); + await asyncSetTimeout(0); + + // The error belongs to a condition the model replaced, so it is not the user's half-finished edit. + await new FilterDom(api, 'one condition from the model', { colId: 'gold' }).checkFilterDom(` + COLUMN FILTER + operator: "Equals" + input: "10" + AND + operator: "Equals" + input: "" ⟨Filter...⟩ + model: + filterType: "number" + type: "equals" + filter: 10 + `); + // Named rather than counted: the `equals 8` the test started from also leaves one row. + await new GridRows(api, 'the model from the API replaced the edited conditions').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:2 gold:10 + `); + }); }); diff --git a/testing/behavioural/src/filters/filter-value-getter.test.ts b/testing/behavioural/src/filters/filter-value-getter.test.ts index 48238cc9d6b..4f8c64821d2 100644 --- a/testing/behavioural/src/filters/filter-value-getter.test.ts +++ b/testing/behavioural/src/filters/filter-value-getter.test.ts @@ -5,6 +5,7 @@ import { GridColumns, GridRows, TestGridsManager, asyncSetTimeout } from 'ag-tes import type { ValueGetterParams } from 'ag-grid-community'; import { ClientSideRowModelModule, + NumberFilterModule, QuickFilterModule, TextFilterModule, agTestIdFor, @@ -553,4 +554,51 @@ describe('filterValueGetter', () => { expect(new Set(capturedValues)).toEqual(new Set([100, 200, 300, 400])); }); }); + + // A `valueFormatter` changes what the column shows, not what it filters on; the getter is what closes + // the gap, for every option rather than just `equals`. + describe('number filter, filtering on the displayed value', () => { + const numberGridsManager = new TestGridsManager({ + modules: [ClientSideRowModelModule, NumberFilterModule], + }); + + afterEach(() => numberGridsManager.reset()); + + test('substitutes the displayed value before the comparison, so every option follows', async () => { + const api = await numberGridsManager.createGridAndWait('grid', { + columnDefs: [ + { + field: 'val', + filter: 'agNumberColumnFilter', + valueFormatter: ({ value }) => value.toFixed(1), + filterValueGetter: ({ getValue }) => Number(Number(getValue('val')).toFixed(1)), + filterParams: { debounceMs: 0 }, + }, + ], + rowData: [{ val: 1.44 }, { val: 1.41 }, { val: 2.2 }], + }); + + // No cell holds 1.4; two display it, and it is the displayed value the filter is given. + await api.setColumnFilterModel('val', { filterType: 'number', type: 'equals', filter: 1.4 }); + api.onFilterChanged(); + await asyncSetTimeout(0); + + await new GridRows(api, 'equals matched what the cells display').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:0 val:"1.4" + └── LEAF id:1 val:"1.4" + `); + + // Discriminating: on the underlying values 1.44 and 1.41 both exceed 1.4, and on the displayed + // ones neither does. So an ordered option needs nothing beyond the substitution. + await api.setColumnFilterModel('val', { filterType: 'number', type: 'greaterThan', filter: 1.4 }); + api.onFilterChanged(); + await asyncSetTimeout(0); + + await new GridRows(api, 'greaterThan matched what the cells display').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:2 val:2.2 + `); + }); + }); }); diff --git a/testing/behavioural/src/filters/number-filter-range-validation.test.ts b/testing/behavioural/src/filters/number-filter-range-validation.test.ts index 4785335856b..31fdedfcc6f 100644 --- a/testing/behavioural/src/filters/number-filter-range-validation.test.ts +++ b/testing/behavioural/src/filters/number-filter-range-validation.test.ts @@ -137,6 +137,65 @@ describe('Number Range Filter', () => { `); }); + test('re-opening reports the range again rather than leaving it invalid in silence', async () => { + const api = await gridsManager.createGridAndWait('grid1', { + columnDefs: [ + { + field: 'gold', + filter: 'agNumberColumnFilter', + filterParams: { filterOptions: ['inRange'] }, + }, + ], + rowData: [{ gold: 2 }, { gold: 8 }, { gold: 3 }], + }); + + const filter = await ColumnFilterHarness.open(api, 'gold'); + await filter.setNumber(8, 0); + await filter.setNumber(2, 1); + expect(filter.input('number', 1).validity.valid).toBe(false); + + api.hidePopupMenu(); + await asyncSetTimeout(0); + + const reopened = await ColumnFilterHarness.open(api, 'gold'); + + // Re-opening focuses `from`, which would claim the message; the attach pass is what hands it back + // to `to`, so the pair is what separates a re-validated condition from a merely focused one. + expect(reopened.input('number', 0).validity.valid).toBe(true); + expect(reopened.input('number', 1).validity.valid).toBe(false); + expect(reopened.input('number', 1).validationMessage).toBe('Must be greater than 8'); + }); + + // An inclusive range accepts `from === to`, so a message saying the value must be strictly beyond the + // other end would name a bound the filter would have taken. + test('an inclusive range names a bound it would accept, not a strict one', async () => { + const api = await gridsManager.createGridAndWait('grid1', { + columnDefs: [ + { + field: 'gold', + filter: 'agNumberColumnFilter', + filterParams: { debounceMs: 0, filterOptions: ['inRange'], inRangeInclusive: true }, + }, + ], + rowData: [{ gold: 2 }, { gold: 8 }, { gold: 3 }], + }); + + const filter = await ColumnFilterHarness.open(api, 'gold'); + await filter.setNumber(8, 0); + await filter.setNumber(2, 1); + + expect(filter.input('number', 1).validity.valid).toBe(false); + expect(filter.input('number', 1).validationMessage).toBe('Must be greater than or equal to 8'); + + // The bound it names is genuinely taken: equal ends are an exact match under an inclusive range. + await filter.setNumber(8, 1); + expect(filter.input('number', 1).validity.valid).toBe(true); + await new GridRows(api, 'an inclusive range of one value matches that value').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:1 gold:8 + `); + }); + test('the message names the bound each input must respect, and follows the touched input', async () => { const api = await gridsManager.createGridAndWait('grid1', { columnDefs: [ @@ -314,6 +373,117 @@ describe('Number Range Filter', () => { `); }); + test('a valid model applied through the API clears a stale range message', async () => { + const api = await gridsManager.createGridAndWait('grid1', { + columnDefs: [ + { field: 'gold', filter: 'agNumberColumnFilter', filterParams: { filterOptions: ['inRange'] } }, + ], + rowData: [{ gold: 2 }, { gold: 8 }, { gold: 3 }], + }); + + const filter = await ColumnFilterHarness.open(api, 'gold'); + await filter.setNumber(5, 0); + await filter.setNumber(1, 1); + expect(filter.input('number', 1).validity.valid).toBe(false); + + await api.setColumnFilterModel('gold', { filterType: 'number', type: 'inRange', filter: 1, filterTo: 5 }); + api.onFilterChanged(); + await asyncSetTimeout(0); + + // The inputs hold an ordered range, so the message the old one left is gone. + expect(filter.input('number', 0).validity.valid).toBe(true); + expect(filter.input('number', 1).validity.valid).toBe(true); + + await new FilterDom(api, 'model applied over a stale message', { colId: 'gold' }).checkFilterDom(` + COLUMN FILTER + operator: "Between" + input [0]: "1" + input [1]: "5" + AND + operator: "Between" + input [0]: "" ⟨From⟩ + input [1]: "" ⟨To⟩ + model: + filterType: "number" + type: "inRange" + filter: 1 + filterTo: 5 + `); + }); + + test('a one-input option is not held to the range rule of the value left behind', async () => { + const api = await gridsManager.createGridAndWait('grid1', { + columnDefs: [ + { + field: 'gold', + filter: 'agNumberColumnFilter', + filterParams: { debounceMs: 0, filterOptions: ['inRange', 'equals'], maxNumConditions: 1 }, + }, + ], + rowData: [{ gold: 2 }, { gold: 8 }, { gold: 3 }], + }); + + const filter = await ColumnFilterHarness.open(api, 'gold'); + await filter.setNumber(1, 0); + await filter.setNumber(5, 1); + + await filter.selectOperator('Equals'); + // `Equals` takes one value, so the 5 the range left in the hidden second input is not a bound on it. + await filter.setNumber(8, 0); + await asyncSetTimeout(0); + + expect(filter.input('number', 0).validity.valid).toBe(true); + await waitFor(() => expect(filter.getModel()).toEqual({ filterType: 'number', type: 'equals', filter: 8 })); + await new FilterDom(api, 'one-input option after a range', { colId: 'gold' }).checkFilterDom(` + COLUMN FILTER + operator: "Equals" + input: "8" + model: + filterType: "number" + type: "equals" + filter: 8 + `); + await new GridRows(api, 'equals applies over a stale range bound').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:1 gold:8 + `); + + // Coming back shows the 5 was kept rather than cleared, which is what made it a bound to ignore. + await filter.selectOperator('Between'); + expect(filter.input('number', 1).value).toBe('5'); + }); + + test('a zero-input option is not held to what either input still holds', async () => { + const api = await gridsManager.createGridAndWait('grid1', { + columnDefs: [ + { + field: 'gold', + filter: 'agNumberColumnFilter', + filterParams: { debounceMs: 0, filterOptions: ['inRange', 'blank'], maxNumConditions: 1 }, + }, + ], + // A blank row: without one, `blank` and the abandoned 9..1 range both leave no rows. + rowData: [{ gold: 2 }, { gold: 8 }, { gold: 3 }, { gold: null }], + }); + + const filter = await ColumnFilterHarness.open(api, 'gold'); + await filter.setNumber(9, 0); + await filter.setNumber(1, 1); + expect(filter.input('number', 1).validity.valid).toBe(false); + + // `Blank` reads neither input, so the inverted range left in them cannot hold the condition back. + await filter.selectOperator('Blank'); + await asyncSetTimeout(0); + + // Both inputs are hidden, so applying at all is what proves their contents stopped counting. + expect(filter.inputs('number')).toHaveLength(0); + await waitFor(() => expect(filter.getModel()).toEqual({ filterType: 'number', type: 'blank' })); + await new GridRows(api, 'blank applies over an abandoned inverted range').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:3 gold:null + `); + }); + test('a condition still validates its own inputs once an earlier condition has been dropped', async () => { const userSession = userEvent.setup(); const api = await gridsManager.createGridAndWait('grid1', { @@ -378,6 +548,152 @@ describe('Number Range Filter', () => { `); }); + // A condition kept back because an input is invalid has to stay editable, or there is no way to fix it. + test('a condition kept back for being invalid is not disabled while the user fixes it', async () => { + const api = await gridsManager.createGridAndWait('grid1', { + columnDefs: [ + { + field: 'gold', + filter: 'agNumberColumnFilter', + filterParams: { debounceMs: 0, maxNumConditions: 3, filterOptions: ['equals', 'inRange'] }, + }, + ], + rowData: [{ gold: 2 }, { gold: 8 }, { gold: 3 }], + }); + + const filter = await ColumnFilterHarness.open(api, 'gold'); + await filter.selectOperator('Equals', 0); + await filter.setNumber(2, 0); + await filter.setJoinOperator('OR'); + await filter.selectOperator('Equals', 1); + await filter.setNumber(8, 1); + await filter.selectOperator('Between', 2); + await filter.setNumber(9, 2); + await filter.setNumber(1, 3); + // Length first, so a pair that lost an input fails as that rather than as a crash. + expect(filter.inputs('number', 2)).toHaveLength(2); + expect(filter.inputs('number', 2)[1].validity.valid).toBe(false); + + // The middle condition stops being complete, which is what asks for the trailing ones to go. + await filter.selectOperator('Between', 1); + await asyncSetTimeout(0); + + // Named first, so a condition that vanished instead of staying editable fails as that, not as a crash. + expect(filter.inputs('number', 2)).toHaveLength(2); + expect(filter.inputs('number', 2)[0].disabled).toBe(false); + expect(filter.inputs('number', 2)[1].disabled).toBe(false); + + // Held back, not applied: the model must not carry the range the user is still fixing. + await new FilterDom(api, 'the invalid condition stays on show without reaching the model', { + colId: 'gold', + }).checkFilterDom(` + COLUMN FILTER + operator: "Equals" + input: "2" + OR + operator: "Between" + input [0]: "8" + input [1]: "" ⟨To⟩ + OR + operator: "Between" + input [0]: "9" + input [1]: "1" ✗ "Must be greater than 9" + model: + filterType: "number" + operator: "OR" + conditions: + - filterType: "number" + type: "equals" + filter: 2 + - filterType: "number" + type: "equals" + filter: 8 + `); + }); + + // The condition is held open by how many values its option takes, not by its key being `inRange`. + test('a custom two-input option keeps its invalid state across a popup close', async () => { + const api = await gridsManager.createGridAndWait('grid1', { + columnDefs: [ + { + field: 'gold', + filter: 'agNumberColumnFilter' as const, + filterParams: { + debounceMs: 0, + filterOptions: [ + { + displayKey: 'span', + displayName: 'Span', + numberOfInputs: 2, + predicate: ([from, to]: (number | null)[], cellValue: number | null) => + cellValue != null && + (from == null || cellValue >= from) && + (to == null || cellValue <= to), + }, + ], + }, + }, + ], + rowData: [{ gold: 2 }, { gold: 8 }], + }); + + const filter = await ColumnFilterHarness.open(api, 'gold'); + await filter.setNumber(9, 0); + await filter.setNumber(1, 1); + expect(filter.input('number', 1).validity.valid).toBe(false); + + api.hidePopupMenu(); + await asyncSetTimeout(0); + const reopened = await ColumnFilterHarness.open(api, 'gold'); + + expect(reopened.input('number', 0).value).toBe('9'); + expect(reopened.input('number', 1).value).toBe('1'); + expect(reopened.input('number', 1).validity.valid).toBe(false); + }); + + test('an option that keeps its key but drops to one input clears the range message it left', async () => { + const columnDefs = (numberOfInputs: 1 | 2) => [ + { + field: 'gold', + filter: 'agNumberColumnFilter' as const, + filterParams: { + debounceMs: 0, + filterOptions: [ + { + displayKey: 'span', + displayName: 'Span', + numberOfInputs, + predicate: ([from, to]: (number | null)[], cellValue: number | null) => + cellValue != null && + (from == null || cellValue >= from) && + (to == null || cellValue <= to), + }, + ], + }, + }, + ]; + + const api = await gridsManager.createGridAndWait('grid1', { + columnDefs: columnDefs(2), + rowData: [{ gold: 2 }, { gold: 8 }], + }); + + const filter = await ColumnFilterHarness.open(api, 'gold'); + // `from` is edited last, so the message lands on the input the one-value option keeps using. + await filter.setNumber(1, 1); + await filter.setNumber(9, 0); + expect(filter.input('number', 0).validationMessage).toBe('Must be less than 1'); + + // The same key takes one value here, so there is no order left for that input to be out of. + api.setGridOption('columnDefs', columnDefs(1)); + await asyncSetTimeout(0); + + expect(filter.input('number', 0).validationMessage).toBe(''); + expect(filter.input('number', 0).validity.valid).toBe(true); + // The value too: clearing the input would also clear the message, and lose what the user typed. + expect(filter.input('number', 0).value).toBe('9'); + }); + test('a `colDef` refresh keeps the value an input showing a range error holds', async () => { const columnDefs = (numberParser: (value: string | null) => number | null) => [ { @@ -408,6 +724,38 @@ describe('Number Range Filter', () => { expect(filter.input('number', 1).value).toBe('1'); }); + test('Reset clears a range message a `numberFormatter` would otherwise leave on an empty input', async () => { + const api = await gridsManager.createGridAndWait('grid1', { + columnDefs: [ + { + field: 'gold', + filter: 'agNumberColumnFilter', + filterParams: { + debounceMs: 0, + filterOptions: ['inRange'], + buttons: ['reset'], + // Writes '' rather than null for no value, so the empty input is not written as null. + numberFormatter: (value: number | null) => (value == null ? '' : String(value)), + }, + }, + ], + rowData: [{ gold: 2 }, { gold: 8 }], + }); + + // A `numberFormatter` gives the column text inputs, so its values are read as text. + const filter = await ColumnFilterHarness.open(api, 'gold'); + await filter.setText('9', 0); + await filter.setText('1', 1); + expect(filter.input('text', 1).validity.valid).toBe(false); + + await filter.reset(); + + // Both inputs are empty, so there is no pair left for the message to be about. + expect(filter.input('text', 1).value).toBe(''); + expect(filter.input('text', 1).validationMessage).toBe(''); + expect(filter.input('text', 1).validity.valid).toBe(true); + }); + test('an out-of-order range keeps the next condition disabled across a rebuild', async () => { const columnDefs = (allowedCharPattern: string) => [ { @@ -437,4 +785,38 @@ describe('Number Range Filter', () => { expect(filter.input('text', 1).validity.valid).toBe(false); expect(filter.inputs('text')).toHaveLength(2); }); + + // A rebuilt pair re-attaches both listeners, and the one that applies the filter reads the validity the + // other maintains, so it has to run second or the edit that puts the range in order is judged stale. + test('the edit that puts a range back in order after a rebuild applies on that edit', async () => { + const columnDefs = (allowedCharPattern: string) => [ + { + field: 'gold', + filter: 'agNumberColumnFilter' as const, + filterParams: { debounceMs: 0, filterOptions: ['inRange'], allowedCharPattern }, + }, + ]; + + const api = await gridsManager.createGridAndWait('grid1', { + columnDefs: columnDefs('\\d\\-\\.'), + rowData: [{ gold: 2 }, { gold: 8 }], + }); + + const filter = await ColumnFilterHarness.open(api, 'gold'); + await filter.setText('1', 0); + await filter.setText('0', 1); + expect(filter.input('text', 1).validity.valid).toBe(false); + + api.setGridOption('columnDefs', columnDefs('\\d\\-\\.,')); + await asyncSetTimeout(0); + + // One edit puts the range in order, and the row it admits is what says the filter followed it. + await filter.setText('5', 1); + + expect(filter.input('text', 1).validity.valid).toBe(true); + await new GridRows(api, 'the corrected range applies on the edit that corrected it').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:0 gold:2 + `); + }); }); From a4e8b82e3d4eea866b500ca0b073902c75784b71 Mon Sep 17 00:00:00 2001 From: seanlandsman Date: Fri, 21 Aug 2026 16:19:07 +0100 Subject: [PATCH 2/2] Merge b36.1.0 into latest (#14934) --- README.md | 2 +- community-modules/locale/README.md | 2 +- community-modules/styles/README.md | 2 +- documentation/ag-grid-docs/.env.build | 1 + documentation/ag-grid-docs/astro.config.mjs | 9 + .../public/changelog/changelog.json | 2 +- .../public/changelog/releases/30_0_6.md | 2 +- .../public/changelog/releases/31_1_0.md | 2 +- .../public/changelog/releases/31_2_0.md | 2 +- .../public/changelog/releases/31_3_1.md | 2 +- .../public/changelog/releases/32_0_0.md | 2 +- .../public/changelog/releases/32_1_0.md | 2 +- .../public/changelog/releases/32_2_0.md | 2 +- .../public/changelog/releases/33_1_0.md | 2 +- .../public/changelog/releases/33_2_0.md | 2 +- .../public/changelog/releases/33_3_0.md | 2 +- .../public/changelog/releases/34_0_0.md | 2 +- .../public/changelog/releases/34_1_0.md | 2 +- .../public/changelog/releases/34_2_0.md | 2 +- .../public/changelog/releases/34_3_0.md | 2 +- .../public/changelog/releases/35_0_0.md | 2 +- .../public/changelog/releases/35_1_0.md | 2 +- .../public/changelog/releases/35_2_0.md | 2 +- .../public/changelog/releases/35_3_0.md | 2 +- .../public/changelog/releases/36_0_0.md | 2 +- .../public/changelog/releases/36_1_0.md | 2 +- .../public/pipeline/pipeline.json | 4 +- .../ag-grid-docs/src/content/about/about.json | 2 +- .../campaigns/bryntum-products/calendar.json | 2 +- .../campaigns/bryntum-products/complete.json | 2 +- .../campaigns/bryntum-products/gantt.json | 2 +- .../campaigns/bryntum-products/scheduler.json | 2 +- .../bryntum-products/schedulerpro.json | 2 +- .../campaigns/bryntum-products/taskboard.json | 2 +- .../src/content/docs-nav/nav.json | 6 +- .../content/docs/license-install/index.mdoc | 2 +- .../docs/upgrading-to-ag-grid-29/index.mdoc | 2 +- .../docs/upgrading-to-ag-grid-30/index.mdoc | 2 +- .../docs/upgrading-to-ag-grid-31-1/index.mdoc | 2 +- .../docs/upgrading-to-ag-grid-31-2/index.mdoc | 2 +- .../docs/upgrading-to-ag-grid-31-3/index.mdoc | 2 +- .../docs/upgrading-to-ag-grid-31/index.mdoc | 2 +- .../docs/upgrading-to-ag-grid-32-1/index.mdoc | 2 +- .../upgrading-to-ag-grid-32-2-1/index.mdoc | 2 +- .../docs/upgrading-to-ag-grid-32/index.mdoc | 2 +- .../docs/upgrading-to-ag-grid-33-1/index.mdoc | 2 +- .../docs/upgrading-to-ag-grid-33-2/index.mdoc | 2 +- .../docs/upgrading-to-ag-grid-33-3/index.mdoc | 2 +- .../docs/upgrading-to-ag-grid-33/index.mdoc | 2 +- .../docs/upgrading-to-ag-grid-34-1/index.mdoc | 2 +- .../docs/upgrading-to-ag-grid-34-2/index.mdoc | 2 +- .../docs/upgrading-to-ag-grid-34-3/index.mdoc | 2 +- .../docs/upgrading-to-ag-grid-34/index.mdoc | 2 +- .../docs/upgrading-to-ag-grid-35/index.mdoc | 2 +- .../docs/upgrading-to-ag-grid-36/index.mdoc | 2 +- .../upgrading-to-older-versions/index.mdoc | 6 +- .../src/content/footer/footer.json | 2 +- .../src/utils/bryntumCampaignAssets.ts | 2 +- .../__snapshots__/htaccessRules.test.ts.snap | 167 ++++++++++- .../src/utils/htaccess/htaccessRules.ts | 165 +++++++++++ .../src/utils/htaccess/redirects.ts | 5 +- .../buildRoadmapWhatsNewMarkdown.test.ts | 2 +- .../ag-grid-docs/src/utils/robotsTxt.ts | 35 +++ .../ag-grid-docs/src/utils/sitemap.ts | 35 ++- .../expectations.generated.tsv | 4 +- .../testing/htaccess-harness/expectations.tsv | 270 +++++++++++++++++- .../testing/htaccess-harness/run.sh | 3 + .../releases-section/ReleasesSection.tsx | 2 +- .../content/community/news-updates/blogs.json | 8 +- .../src/content/community/socials.json | 4 +- .../src/content/whats-new/data.json | 6 +- packages/ag-grid-angular/README.md | 2 +- .../projects/ag-grid-angular/README.md | 2 +- packages/ag-grid-community/README.md | 2 +- packages/ag-grid-react/README.md | 2 +- packages/ag-grid-vue3/README.md | 2 +- packages/ag-stack/README.md | 2 +- 77 files changed, 765 insertions(+), 85 deletions(-) diff --git a/README.md b/README.md index 3f961eeb496..5b14c50ccca 100644 --- a/README.md +++ b/README.md @@ -507,6 +507,6 @@ Initially built to power [Integrated Charts](https://www.ag-grid.com/javascript- Twitter Badge LinkedIn Badge YouTube Badge -Blog Badge +Blog Badge diff --git a/community-modules/locale/README.md b/community-modules/locale/README.md index 3f961eeb496..5b14c50ccca 100644 --- a/community-modules/locale/README.md +++ b/community-modules/locale/README.md @@ -507,6 +507,6 @@ Initially built to power [Integrated Charts](https://www.ag-grid.com/javascript- Twitter Badge LinkedIn Badge YouTube Badge -Blog Badge +Blog Badge diff --git a/community-modules/styles/README.md b/community-modules/styles/README.md index 3f961eeb496..5b14c50ccca 100644 --- a/community-modules/styles/README.md +++ b/community-modules/styles/README.md @@ -507,6 +507,6 @@ Initially built to power [Integrated Charts](https://www.ag-grid.com/javascript- Twitter Badge LinkedIn Badge YouTube Badge -Blog Badge +Blog Badge diff --git a/documentation/ag-grid-docs/.env.build b/documentation/ag-grid-docs/.env.build index aa2ef349a13..d8984ea18e3 100644 --- a/documentation/ag-grid-docs/.env.build +++ b/documentation/ag-grid-docs/.env.build @@ -17,3 +17,4 @@ CHARTS_SITEMAP_INDEX_URL=https://www.ag-grid.com/charts/sitemap-0.xml CHARTS_ROBOTS_DISALLOW_JSON_URL=https://www.ag-grid.com/charts/robots-disallow.json STUDIO_SITEMAP_INDEX_URL=https://www.ag-grid.com/studio/sitemap-0.xml STUDIO_ROBOTS_DISALLOW_JSON_URL=https://www.ag-grid.com/studio/robots-disallow.json +BLOG_SITEMAP_URLS=https://www.ag-grid.com/blog/sitemap-posts.xml,https://www.ag-grid.com/blog/sitemap-pages.xml,https://www.ag-grid.com/blog/sitemap-authors.xml,https://www.ag-grid.com/blog/sitemap-tags.xml diff --git a/documentation/ag-grid-docs/astro.config.mjs b/documentation/ag-grid-docs/astro.config.mjs index c5bb94a47b7..f3627e1dece 100644 --- a/documentation/ag-grid-docs/astro.config.mjs +++ b/documentation/ag-grid-docs/astro.config.mjs @@ -133,6 +133,12 @@ const { */ STUDIO_SITEMAP_INDEX_URL, + /** + * SE-85: Ghost's flat child sitemaps to merge, comma-separated. NOT its index — see + * getSitemapConfig; an index inside an index is invalid and crawlers ignore it. + */ + BLOG_SITEMAP_URLS, + /** * Studio robots.txt disallow json url to merge */ @@ -314,6 +320,9 @@ export default defineConfig({ getSitemapConfig({ chartsSitemap: CHARTS_SITEMAP_INDEX_URL, studioSitemap: STUDIO_SITEMAP_INDEX_URL, + blogSitemaps: BLOG_SITEMAP_URLS?.split(',') + .map((url) => url.trim()) + .filter(Boolean), }) ), agSitemapFilterNoindex({ enabled: PRODUCTION_SITE_URLS.includes(PUBLIC_SITE_URL) }), diff --git a/documentation/ag-grid-docs/public/changelog/changelog.json b/documentation/ag-grid-docs/public/changelog/changelog.json index 0d0d3415281..9b50ed8abb2 100644 --- a/documentation/ag-grid-docs/public/changelog/changelog.json +++ b/documentation/ag-grid-docs/public/changelog/changelog.json @@ -21106,7 +21106,7 @@ "status": "Done", "resolution": "Done", "features": ["Cell_Editing"], - "moreInformation": "You can implement this behavior using the approach and samples here:\r\nhttps://blog.ag-grid.com/deleting-selected-rows-and-cell-ranges-via-key-press/", + "moreInformation": "You can implement this behavior using the approach and samples here:\r\nhttps://www.ag-grid.com/blog/deleting-selected-rows-and-cell-ranges-via-key-press/", "deprecationNotes": null, "breakingChangesNotes": null, "documentationUrl": null diff --git a/documentation/ag-grid-docs/public/changelog/releases/30_0_6.md b/documentation/ag-grid-docs/public/changelog/releases/30_0_6.md index 17e5d9c3d2f..4a26bc274df 100644 --- a/documentation/ag-grid-docs/public/changelog/releases/30_0_6.md +++ b/documentation/ag-grid-docs/public/changelog/releases/30_0_6.md @@ -3,4 +3,4 @@ #### Feature Highlights: - Build & Tooling - - AG-8998 Default Packages to use CommonJS (See [ES Modules & CommonJS](https://blog.ag-grid.com/ag-grid-es-modules-and-commonjs/)) + - AG-8998 Default Packages to use CommonJS (See [ES Modules & CommonJS](https://www.ag-grid.com/blog/ag-grid-es-modules-and-commonjs/)) diff --git a/documentation/ag-grid-docs/public/changelog/releases/31_1_0.md b/documentation/ag-grid-docs/public/changelog/releases/31_1_0.md index 9d17b07d523..f6dfebce0e4 100644 --- a/documentation/ag-grid-docs/public/changelog/releases/31_1_0.md +++ b/documentation/ag-grid-docs/public/changelog/releases/31_1_0.md @@ -1,6 +1,6 @@ #### 20th Feb 2024 - Grid v31.1.0 (Charts v9.1.0) -For more details see [31.1.0 release post](https://blog.ag-grid.com/whats-new-in-ag-grid-31-1/). +For more details see [31.1.0 release post](https://www.ag-grid.com/blog/whats-new-in-ag-grid-31-1/). For migration guide and breaking changes see [31.1.0 migration post](https://www.ag-grid.com/javascript-data-grid/upgrading-to-ag-grid-31-1/). diff --git a/documentation/ag-grid-docs/public/changelog/releases/31_2_0.md b/documentation/ag-grid-docs/public/changelog/releases/31_2_0.md index e38e92a3396..2f2dbcc36fa 100644 --- a/documentation/ag-grid-docs/public/changelog/releases/31_2_0.md +++ b/documentation/ag-grid-docs/public/changelog/releases/31_2_0.md @@ -1,5 +1,5 @@ #### 20th March 2024 - Grid v31.2.0 (Charts v9.2.0) -For more details see [31.2.0 release post](https://blog.ag-grid.com/whats-new-in-ag-grid-31-2/). +For more details see [31.2.0 release post](https://www.ag-grid.com/blog/whats-new-in-ag-grid-31-2/). For migration guide and breaking changes see [31.2.0 migration post](https://www.ag-grid.com/javascript-data-grid/upgrading-to-ag-grid-31-2/). diff --git a/documentation/ag-grid-docs/public/changelog/releases/31_3_1.md b/documentation/ag-grid-docs/public/changelog/releases/31_3_1.md index 2f313b4f9ed..1921cbba327 100644 --- a/documentation/ag-grid-docs/public/changelog/releases/31_3_1.md +++ b/documentation/ag-grid-docs/public/changelog/releases/31_3_1.md @@ -1,5 +1,5 @@ #### 30th April 2023 - Grid v31.3.1 (Charts v9.3.1) -For more details see [31.3.1 release post](https://blog.ag-grid.com/whats-new-in-ag-grid-31-3/). +For more details see [31.3.1 release post](https://www.ag-grid.com/blog/whats-new-in-ag-grid-31-3/). For migration guide and breaking changes see [31.3.0 migration post](https://www.ag-grid.com/javascript-data-grid/upgrading-to-ag-grid-31-3/). diff --git a/documentation/ag-grid-docs/public/changelog/releases/32_0_0.md b/documentation/ag-grid-docs/public/changelog/releases/32_0_0.md index 57965918b5a..65ae4255db9 100644 --- a/documentation/ag-grid-docs/public/changelog/releases/32_0_0.md +++ b/documentation/ag-grid-docs/public/changelog/releases/32_0_0.md @@ -1,5 +1,5 @@ #### 1st July 2024 - Grid v32.0.0 (Charts v10.0.0) -For more details see [32.0.0 release post](https://blog.ag-grid.com/whats-new-in-ag-grid-32/). +For more details see [32.0.0 release post](https://www.ag-grid.com/blog/whats-new-in-ag-grid-32/). For migration guide and breaking changes see [Upgrading to AG Grid 32](https://www.ag-grid.com/javascript-data-grid/upgrading-to-ag-grid-32/). diff --git a/documentation/ag-grid-docs/public/changelog/releases/32_1_0.md b/documentation/ag-grid-docs/public/changelog/releases/32_1_0.md index 88081826334..61bbdfba026 100644 --- a/documentation/ag-grid-docs/public/changelog/releases/32_1_0.md +++ b/documentation/ag-grid-docs/public/changelog/releases/32_1_0.md @@ -1,5 +1,5 @@ #### 8th August 2024 - Grid v32.1.0 (Charts v10.1.0) -For more details see [32.1.0 release post](https://blog.ag-grid.com/whats-new-in-ag-grid-32-1/). +For more details see [32.1.0 release post](https://www.ag-grid.com/blog/whats-new-in-ag-grid-32-1/). For migration guide and breaking changes see [Upgrading to AG Grid 32.1](https://www.ag-grid.com/javascript-data-grid/upgrading-to-ag-grid-32-1/). diff --git a/documentation/ag-grid-docs/public/changelog/releases/32_2_0.md b/documentation/ag-grid-docs/public/changelog/releases/32_2_0.md index 89997084e46..f632cd28cfd 100644 --- a/documentation/ag-grid-docs/public/changelog/releases/32_2_0.md +++ b/documentation/ag-grid-docs/public/changelog/releases/32_2_0.md @@ -1,5 +1,5 @@ #### 17th September 2024 - Grid v32.2.0 (Charts v10.2.0) -For more details see [32.2.0 release post](https://blog.ag-grid.com/whats-new-in-ag-grid-32-2/). +For more details see [32.2.0 release post](https://www.ag-grid.com/blog/whats-new-in-ag-grid-32-2/). For migration guide and breaking changes see [Upgrading to AG Grid 32.2](https://www.ag-grid.com/javascript-data-grid/upgrading-to-ag-grid-32-2-1/). diff --git a/documentation/ag-grid-docs/public/changelog/releases/33_1_0.md b/documentation/ag-grid-docs/public/changelog/releases/33_1_0.md index 97a9acd9d92..0e63ff541d3 100644 --- a/documentation/ag-grid-docs/public/changelog/releases/33_1_0.md +++ b/documentation/ag-grid-docs/public/changelog/releases/33_1_0.md @@ -2,4 +2,4 @@ See table below for changes included in this release. -For more details see [v33.1 release post](https://blog.ag-grid.com/whats-new-in-ag-grid-33-1/). +For more details see [v33.1 release post](https://www.ag-grid.com/blog/whats-new-in-ag-grid-33-1/). diff --git a/documentation/ag-grid-docs/public/changelog/releases/33_2_0.md b/documentation/ag-grid-docs/public/changelog/releases/33_2_0.md index 43969fd4377..1448b1f45bd 100644 --- a/documentation/ag-grid-docs/public/changelog/releases/33_2_0.md +++ b/documentation/ag-grid-docs/public/changelog/releases/33_2_0.md @@ -2,4 +2,4 @@ See table below for changes included in this release. -For more details see [v33.2 release post](https://blog.ag-grid.com/whats-new-in-ag-grid-33-2/). +For more details see [v33.2 release post](https://www.ag-grid.com/blog/whats-new-in-ag-grid-33-2/). diff --git a/documentation/ag-grid-docs/public/changelog/releases/33_3_0.md b/documentation/ag-grid-docs/public/changelog/releases/33_3_0.md index 43688799e88..ca9b17f27da 100644 --- a/documentation/ag-grid-docs/public/changelog/releases/33_3_0.md +++ b/documentation/ag-grid-docs/public/changelog/releases/33_3_0.md @@ -2,4 +2,4 @@ See table below for changes included in this release. -For more details see [v33.3 release post](https://blog.ag-grid.com/whats-new-in-ag-grid-33-3/). +For more details see [v33.3 release post](https://www.ag-grid.com/blog/whats-new-in-ag-grid-33-3/). diff --git a/documentation/ag-grid-docs/public/changelog/releases/34_0_0.md b/documentation/ag-grid-docs/public/changelog/releases/34_0_0.md index 9f6a11c28ba..ccaff15e84c 100644 --- a/documentation/ag-grid-docs/public/changelog/releases/34_0_0.md +++ b/documentation/ag-grid-docs/public/changelog/releases/34_0_0.md @@ -2,4 +2,4 @@ See table below for changes included in this release. -For more details see [v34.0 release post](https://blog.ag-grid.com/whats-new-in-ag-grid-34/). +For more details see [v34.0 release post](https://www.ag-grid.com/blog/whats-new-in-ag-grid-34/). diff --git a/documentation/ag-grid-docs/public/changelog/releases/34_1_0.md b/documentation/ag-grid-docs/public/changelog/releases/34_1_0.md index 4ed6e40121f..4444210ba0d 100644 --- a/documentation/ag-grid-docs/public/changelog/releases/34_1_0.md +++ b/documentation/ag-grid-docs/public/changelog/releases/34_1_0.md @@ -2,4 +2,4 @@ See table below for changes included in this release. -For more details see [v34.1.0 release post](https://blog.ag-grid.com/whats-new-in-ag-grid-34-1/). +For more details see [v34.1.0 release post](https://www.ag-grid.com/blog/whats-new-in-ag-grid-34-1/). diff --git a/documentation/ag-grid-docs/public/changelog/releases/34_2_0.md b/documentation/ag-grid-docs/public/changelog/releases/34_2_0.md index 5caebca8072..c39b88fc13c 100644 --- a/documentation/ag-grid-docs/public/changelog/releases/34_2_0.md +++ b/documentation/ag-grid-docs/public/changelog/releases/34_2_0.md @@ -2,4 +2,4 @@ See table below for changes included in this release. -For more details see [v34.2.0 release post](https://blog.ag-grid.com/whats-new-in-ag-grid-34-2/). +For more details see [v34.2.0 release post](https://www.ag-grid.com/blog/whats-new-in-ag-grid-34-2/). diff --git a/documentation/ag-grid-docs/public/changelog/releases/34_3_0.md b/documentation/ag-grid-docs/public/changelog/releases/34_3_0.md index 963c4cbbf24..9f636204657 100644 --- a/documentation/ag-grid-docs/public/changelog/releases/34_3_0.md +++ b/documentation/ag-grid-docs/public/changelog/releases/34_3_0.md @@ -2,4 +2,4 @@ See table below for changes included in this release. -For more details see [v34.3 release post](https://blog.ag-grid.com/whats-new-in-ag-grid-34-3/). +For more details see [v34.3 release post](https://www.ag-grid.com/blog/whats-new-in-ag-grid-34-3/). diff --git a/documentation/ag-grid-docs/public/changelog/releases/35_0_0.md b/documentation/ag-grid-docs/public/changelog/releases/35_0_0.md index 49ab53b2909..e0d6598163e 100644 --- a/documentation/ag-grid-docs/public/changelog/releases/35_0_0.md +++ b/documentation/ag-grid-docs/public/changelog/releases/35_0_0.md @@ -2,4 +2,4 @@ See table below for changes included in this release. -For more details see [v35.0 release post](https://blog.ag-grid.com/whats-new-in-ag-grid-35/). +For more details see [v35.0 release post](https://www.ag-grid.com/blog/whats-new-in-ag-grid-35/). diff --git a/documentation/ag-grid-docs/public/changelog/releases/35_1_0.md b/documentation/ag-grid-docs/public/changelog/releases/35_1_0.md index 6e163194253..afaaf3aa1e1 100644 --- a/documentation/ag-grid-docs/public/changelog/releases/35_1_0.md +++ b/documentation/ag-grid-docs/public/changelog/releases/35_1_0.md @@ -2,4 +2,4 @@ See table below for changes included in this release. -For more details see [v35.1 release post](https://blog.ag-grid.com/whats-new-in-ag-grid-35-1/). +For more details see [v35.1 release post](https://www.ag-grid.com/blog/whats-new-in-ag-grid-35-1/). diff --git a/documentation/ag-grid-docs/public/changelog/releases/35_2_0.md b/documentation/ag-grid-docs/public/changelog/releases/35_2_0.md index 44eec03cf3f..a4618ed7486 100644 --- a/documentation/ag-grid-docs/public/changelog/releases/35_2_0.md +++ b/documentation/ag-grid-docs/public/changelog/releases/35_2_0.md @@ -2,4 +2,4 @@ See table below for changes included in this release. -For more details see [v35.2 release post](https://blog.ag-grid.com/whats-new-in-ag-grid-35-2/). +For more details see [v35.2 release post](https://www.ag-grid.com/blog/whats-new-in-ag-grid-35-2/). diff --git a/documentation/ag-grid-docs/public/changelog/releases/35_3_0.md b/documentation/ag-grid-docs/public/changelog/releases/35_3_0.md index 7752a7e3b34..c78bf8edfbd 100644 --- a/documentation/ag-grid-docs/public/changelog/releases/35_3_0.md +++ b/documentation/ag-grid-docs/public/changelog/releases/35_3_0.md @@ -2,4 +2,4 @@ See table below for changes included in this release. -For more details see [v35.3 release post](https://blog.ag-grid.com/whats-new-in-ag-grid-35-3/). +For more details see [v35.3 release post](https://www.ag-grid.com/blog/whats-new-in-ag-grid-35-3/). diff --git a/documentation/ag-grid-docs/public/changelog/releases/36_0_0.md b/documentation/ag-grid-docs/public/changelog/releases/36_0_0.md index b084f8e6c0a..d8c0b496377 100644 --- a/documentation/ag-grid-docs/public/changelog/releases/36_0_0.md +++ b/documentation/ag-grid-docs/public/changelog/releases/36_0_0.md @@ -2,4 +2,4 @@ See table below for changes included in this release. -For more details see [v36.0 release post](https://blog.ag-grid.com/whats-new-in-ag-grid-36/). +For more details see [v36.0 release post](https://www.ag-grid.com/blog/whats-new-in-ag-grid-36/). diff --git a/documentation/ag-grid-docs/public/changelog/releases/36_1_0.md b/documentation/ag-grid-docs/public/changelog/releases/36_1_0.md index 9a77b669216..36c9f46e10f 100644 --- a/documentation/ag-grid-docs/public/changelog/releases/36_1_0.md +++ b/documentation/ag-grid-docs/public/changelog/releases/36_1_0.md @@ -2,4 +2,4 @@ See table below for changes included in this release. -For more details see [v36.1 release post](https://blog.ag-grid.com/whats-new-in-ag-grid-36-1/). +For more details see [v36.1 release post](https://www.ag-grid.com/blog/whats-new-in-ag-grid-36-1/). diff --git a/documentation/ag-grid-docs/public/pipeline/pipeline.json b/documentation/ag-grid-docs/public/pipeline/pipeline.json index aadde4d9964..9b63d172b6a 100644 --- a/documentation/ag-grid-docs/public/pipeline/pipeline.json +++ b/documentation/ag-grid-docs/public/pipeline/pipeline.json @@ -17004,7 +17004,7 @@ "status": "Backlog", "resolution": null, "features": ["Cell_Rendering"], - "moreInformation": "You can implement this using the guidance here:\nhttps://stackoverflow.com/questions/62364899/ag-grid-show-buttons-on-row-hover-like-in-gmail \nhttps://blog.ag-grid.com/build-email-client-with-ag-grid-like-gmail/", + "moreInformation": "You can implement this using the guidance here:\nhttps://stackoverflow.com/questions/62364899/ag-grid-show-buttons-on-row-hover-like-in-gmail \nhttps://www.ag-grid.com/blog/build-email-client-with-ag-grid-like-gmail/", "deprecationNotes": null, "breakingChangesNotes": null, "documentationUrl": null @@ -20014,7 +20014,7 @@ "status": "Backlog", "resolution": null, "features": ["PDF_Export"], - "moreInformation": "Until this is implemented please export AG Grid to PDF using a third-party library as demonstrated here (samples in all frameworks):\nhttps://blog.ag-grid.com/exporting-ag-grid-to-pdf/", + "moreInformation": "Until this is implemented please export AG Grid to PDF using a third-party library as demonstrated here (samples in all frameworks):\nhttps://www.ag-grid.com/blog/exporting-ag-grid-to-pdf/", "deprecationNotes": null, "breakingChangesNotes": null, "documentationUrl": null diff --git a/documentation/ag-grid-docs/src/content/about/about.json b/documentation/ag-grid-docs/src/content/about/about.json index aecee4eb01c..e0e9df2842f 100644 --- a/documentation/ag-grid-docs/src/content/about/about.json +++ b/documentation/ag-grid-docs/src/content/about/about.json @@ -36,7 +36,7 @@ "icon": "blog", "title": "Blog", "description": "See our latest articles", - "url": "https://blog.ag-grid.com", + "url": "https://www.ag-grid.com/blog/", "external": true }, { diff --git a/documentation/ag-grid-docs/src/content/campaigns/bryntum-products/calendar.json b/documentation/ag-grid-docs/src/content/campaigns/bryntum-products/calendar.json index ffb9a19c9e1..12e9f25242c 100644 --- a/documentation/ag-grid-docs/src/content/campaigns/bryntum-products/calendar.json +++ b/documentation/ag-grid-docs/src/content/campaigns/bryntum-products/calendar.json @@ -38,7 +38,7 @@ "id": "block_agbryntum_partnership", "type": "partnership", "heading": "Your JavaScript stack just expanded", - "body_html": "

Since our 2025 partnership with Bryntum, Bryntum Calendar sits alongside AG Grid for event-driven calendar UIs, crafted by a similar team and held to the same bar.

", + "body_html": "

Since our 2025 partnership with Bryntum, Bryntum Calendar sits alongside AG Grid for event-driven calendar UIs, crafted by a similar team and held to the same bar.

", "body_text": "Since our 2025 partnership with Bryntum, Bryntum Calendar sits alongside AG Grid for event-driven calendar UIs, crafted by a similar team and held to the same bar.", "logo_light_src": "/images/campaigns/bryntum-products/shared/ag-grid-x-bryntum-light.svg", "logo_dark_src": "/images/campaigns/bryntum-products/shared/ag-grid-x-bryntum-dark.svg", diff --git a/documentation/ag-grid-docs/src/content/campaigns/bryntum-products/complete.json b/documentation/ag-grid-docs/src/content/campaigns/bryntum-products/complete.json index aaa51d7068d..c4ada62835b 100644 --- a/documentation/ag-grid-docs/src/content/campaigns/bryntum-products/complete.json +++ b/documentation/ag-grid-docs/src/content/campaigns/bryntum-products/complete.json @@ -42,7 +42,7 @@ "id": "block_agbryntum_partnership", "type": "partnership", "heading": "Your JavaScript stack just expanded", - "body_html": "

Since our 2025 partnership with Bryntum, the whole Bryntum suite (Gantt, Scheduler, Calendar, Task Board) sits alongside AG Grid for scheduling, planning and task workflows, crafted by a similar team and held to the same bar.

", + "body_html": "

Since our 2025 partnership with Bryntum, the whole Bryntum suite (Gantt, Scheduler, Calendar, Task Board) sits alongside AG Grid for scheduling, planning and task workflows, crafted by a similar team and held to the same bar.

", "body_text": "Since our 2025 partnership with Bryntum, the whole Bryntum suite (Gantt, Scheduler, Calendar, Task Board) sits alongside AG Grid for scheduling, planning and task workflows, crafted by a similar team and held to the same bar.", "logo_light_src": "/images/campaigns/bryntum-products/shared/ag-grid-x-bryntum-light.svg", "logo_dark_src": "/images/campaigns/bryntum-products/shared/ag-grid-x-bryntum-dark.svg", diff --git a/documentation/ag-grid-docs/src/content/campaigns/bryntum-products/gantt.json b/documentation/ag-grid-docs/src/content/campaigns/bryntum-products/gantt.json index 553077c8856..ef640d2f7fc 100644 --- a/documentation/ag-grid-docs/src/content/campaigns/bryntum-products/gantt.json +++ b/documentation/ag-grid-docs/src/content/campaigns/bryntum-products/gantt.json @@ -38,7 +38,7 @@ "id": "block_agbryntum_partnership", "type": "partnership", "heading": "Your JavaScript stack just expanded", - "body_html": "

Since our 2025 partnership with Bryntum, Bryntum Gantt sits alongside AG Grid as the obvious next piece in your stack, crafted by a similar team and held to the same bar.

", + "body_html": "

Since our 2025 partnership with Bryntum, Bryntum Gantt sits alongside AG Grid as the obvious next piece in your stack, crafted by a similar team and held to the same bar.

", "body_text": "Since our 2025 partnership with Bryntum, Bryntum Gantt sits alongside AG Grid as the obvious next piece in your stack, crafted by a similar team and held to the same bar.", "logo_light_src": "/images/campaigns/bryntum-products/shared/ag-grid-x-bryntum-light.svg", "logo_dark_src": "/images/campaigns/bryntum-products/shared/ag-grid-x-bryntum-dark.svg", diff --git a/documentation/ag-grid-docs/src/content/campaigns/bryntum-products/scheduler.json b/documentation/ag-grid-docs/src/content/campaigns/bryntum-products/scheduler.json index 20561212787..3a4158357ba 100644 --- a/documentation/ag-grid-docs/src/content/campaigns/bryntum-products/scheduler.json +++ b/documentation/ag-grid-docs/src/content/campaigns/bryntum-products/scheduler.json @@ -38,7 +38,7 @@ "id": "block_agbryntum_partnership", "type": "partnership", "heading": "Your JavaScript stack just expanded", - "body_html": "

Since our 2025 partnership with Bryntum, Bryntum Scheduler sits alongside AG Grid for events, resources and timelines, crafted by a similar team and held to the same bar.

", + "body_html": "

Since our 2025 partnership with Bryntum, Bryntum Scheduler sits alongside AG Grid for events, resources and timelines, crafted by a similar team and held to the same bar.

", "body_text": "Since our 2025 partnership with Bryntum, Bryntum Scheduler sits alongside AG Grid for events, resources and timelines, crafted by a similar team and held to the same bar.", "logo_light_src": "/images/campaigns/bryntum-products/shared/ag-grid-x-bryntum-light.svg", "logo_dark_src": "/images/campaigns/bryntum-products/shared/ag-grid-x-bryntum-dark.svg", diff --git a/documentation/ag-grid-docs/src/content/campaigns/bryntum-products/schedulerpro.json b/documentation/ag-grid-docs/src/content/campaigns/bryntum-products/schedulerpro.json index de36ac06897..162a019d1ff 100644 --- a/documentation/ag-grid-docs/src/content/campaigns/bryntum-products/schedulerpro.json +++ b/documentation/ag-grid-docs/src/content/campaigns/bryntum-products/schedulerpro.json @@ -38,7 +38,7 @@ "id": "block_agbryntum_partnership", "type": "partnership", "heading": "Your JavaScript stack just expanded", - "body_html": "

Since our 2025 partnership with Bryntum, Bryntum Scheduler Pro sits alongside AG Grid for dependency-aware scheduling and resource planning, crafted by a similar team and held to the same bar.

", + "body_html": "

Since our 2025 partnership with Bryntum, Bryntum Scheduler Pro sits alongside AG Grid for dependency-aware scheduling and resource planning, crafted by a similar team and held to the same bar.

", "body_text": "Since our 2025 partnership with Bryntum, Bryntum Scheduler Pro sits alongside AG Grid for dependency-aware scheduling and resource planning, crafted by a similar team and held to the same bar.", "logo_light_src": "/images/campaigns/bryntum-products/shared/ag-grid-x-bryntum-light.svg", "logo_dark_src": "/images/campaigns/bryntum-products/shared/ag-grid-x-bryntum-dark.svg", diff --git a/documentation/ag-grid-docs/src/content/campaigns/bryntum-products/taskboard.json b/documentation/ag-grid-docs/src/content/campaigns/bryntum-products/taskboard.json index a8b63b0b9f1..a767ea05643 100644 --- a/documentation/ag-grid-docs/src/content/campaigns/bryntum-products/taskboard.json +++ b/documentation/ag-grid-docs/src/content/campaigns/bryntum-products/taskboard.json @@ -38,7 +38,7 @@ "id": "block_agbryntum_partnership", "type": "partnership", "heading": "Your JavaScript stack just expanded", - "body_html": "

Since our 2025 partnership with Bryntum, Bryntum Task Board sits alongside AG Grid for kanban-style task workflows, crafted by a similar team and held to the same bar.

", + "body_html": "

Since our 2025 partnership with Bryntum, Bryntum Task Board sits alongside AG Grid for kanban-style task workflows, crafted by a similar team and held to the same bar.

", "body_text": "Since our 2025 partnership with Bryntum, Bryntum Task Board sits alongside AG Grid for kanban-style task workflows, crafted by a similar team and held to the same bar.", "logo_light_src": "/images/campaigns/bryntum-products/shared/ag-grid-x-bryntum-light.svg", "logo_dark_src": "/images/campaigns/bryntum-products/shared/ag-grid-x-bryntum-dark.svg", diff --git a/documentation/ag-grid-docs/src/content/docs-nav/nav.json b/documentation/ag-grid-docs/src/content/docs-nav/nav.json index 1c3170ea712..200bde2158b 100644 --- a/documentation/ag-grid-docs/src/content/docs-nav/nav.json +++ b/documentation/ag-grid-docs/src/content/docs-nav/nav.json @@ -87,17 +87,17 @@ }, { "title": "Playwright", - "url": "https://blog.ag-grid.com/writing-e2e-tests-for-ag-grid-react-tables-with-playwright/", + "url": "https://www.ag-grid.com/blog/writing-e2e-tests-for-ag-grid-react-tables-with-playwright/", "frameworks": ["react"] }, { "title": "Cypress", - "url": "https://blog.ag-grid.com/end-to-end-testing-for-ag-grid-in-react-with-cypress/", + "url": "https://www.ag-grid.com/blog/end-to-end-testing-for-ag-grid-in-react-with-cypress/", "frameworks": ["react"] }, { "title": "React Testing Library", - "url": "https://blog.ag-grid.com/unit-testing-ag-grid-react-tables-with-react-testing-library-and-vitest/", + "url": "https://www.ag-grid.com/blog/unit-testing-ag-grid-react-tables-with-react-testing-library-and-vitest/", "frameworks": ["react"] } ] diff --git a/documentation/ag-grid-docs/src/content/docs/license-install/index.mdoc b/documentation/ag-grid-docs/src/content/docs/license-install/index.mdoc index d9efc1e6249..03bc4eed1e3 100644 --- a/documentation/ag-grid-docs/src/content/docs/license-install/index.mdoc +++ b/documentation/ag-grid-docs/src/content/docs/license-install/index.mdoc @@ -44,6 +44,6 @@ You can test AG Grid Enterprise locally without a licence. To test in production ## Next.js -If you're using Next.js, we have a [blog](https://blog.ag-grid.com/using-ag-grid-with-next-js-to-build-a-react-table/#how-to-register-the-ag-grid-enterprise-license-key) that details how to get you up and running, as well as how to set the license key. +If you're using Next.js, we have a [blog](https://www.ag-grid.com/blog/using-ag-grid-with-next-js-to-build-a-react-table/#how-to-register-the-ag-grid-enterprise-license-key) that details how to get you up and running, as well as how to set the license key. {% /if %} diff --git a/documentation/ag-grid-docs/src/content/docs/upgrading-to-ag-grid-29/index.mdoc b/documentation/ag-grid-docs/src/content/docs/upgrading-to-ag-grid-29/index.mdoc index 3b520df7ea9..92812df828a 100644 --- a/documentation/ag-grid-docs/src/content/docs/upgrading-to-ag-grid-29/index.mdoc +++ b/documentation/ag-grid-docs/src/content/docs/upgrading-to-ag-grid-29/index.mdoc @@ -8,7 +8,7 @@ Server-side row model transaction support, set filter complex object support, ex ## What's New -AG Grid {% migrationVersion() %} delivers many new features - [server-side row model transaction support](https://www.ag-grid.com/archive/29.0.0/react-data-grid/server-side-model-updating-transactions/), [tree list](https://www.ag-grid.com/archive/29.0.0/react-data-grid/filter-set-tree-list/) and [complex object support](https://www.ag-grid.com/archive/29.0.0/react-data-grid/filter-set-filter-list/#complex-objects) in set filters, exporting of row group and total footer rows to Excel, as described in the [release post](https://blog.ag-grid.com/whats-new-in-ag-grid-29/). These major improvements require certain breaking changes as listed below. +AG Grid {% migrationVersion() %} delivers many new features - [server-side row model transaction support](https://www.ag-grid.com/archive/29.0.0/react-data-grid/server-side-model-updating-transactions/), [tree list](https://www.ag-grid.com/archive/29.0.0/react-data-grid/filter-set-tree-list/) and [complex object support](https://www.ag-grid.com/archive/29.0.0/react-data-grid/filter-set-filter-list/#complex-objects) in set filters, exporting of row group and total footer rows to Excel, as described in the [release post](https://www.ag-grid.com/blog/whats-new-in-ag-grid-29/). These major improvements require certain breaking changes as listed below.