From 6386eec5e7683f64ea6c944f8738d101612c04cd Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Tue, 5 May 2026 12:55:53 +0530 Subject: [PATCH] fix: regex validation not applied for custom text attributes in UI (#14110) # Pull Request Template ## Description This PR fixes multiple issues related to regex patterns and validation for custom attributes. 1. Fixed regex patterns being double-escaped when saving from Add and Edit flows 2. Fixed regex validation not being enforced in the widget pre-chat form 3. Minor UI improvements in the Add/Edit custom attribute dialog Fixes [CW-6625](https://linear.app/chatwoot/issue/CW-6625/bug-report-custom-attribute-regex-validation-not-working-in-ui), https://github.com/chatwoot/chatwoot/issues/13771 ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? **Loom video** **Before** https://www.loom.com/share/14f1983a8bc84f9fabc3663afd83cd50 **After** https://www.loom.com/share/867c0484741140c1944fcbd43914c9c0 ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- .../CustomAttributes/OtherAttribute.vue | 6 +- .../dashboard/components/CustomAttribute.vue | 20 +++--- .../settings/attributes/AddAttribute.vue | 22 ++---- .../settings/attributes/EditAttribute.vue | 21 ++---- app/javascript/shared/helpers/Validators.js | 16 +++++ .../helpers/specs/ValidatorsHelper.spec.js | 25 +++++++ .../widget/components/PreChat/Form.vue | 72 ++++++++++--------- 7 files changed, 109 insertions(+), 73 deletions(-) diff --git a/app/javascript/dashboard/components-next/CustomAttributes/OtherAttribute.vue b/app/javascript/dashboard/components-next/CustomAttributes/OtherAttribute.vue index 7f0379b9a..913e3e381 100644 --- a/app/javascript/dashboard/components-next/CustomAttributes/OtherAttribute.vue +++ b/app/javascript/dashboard/components-next/CustomAttributes/OtherAttribute.vue @@ -49,7 +49,11 @@ const rules = computed(() => ({ props.attribute.regexPattern && { regexValidation: value => { if (!value) return true; - return getRegexp(props.attribute.regexPattern).test(value); + try { + return getRegexp(props.attribute.regexPattern).test(value); + } catch { + return false; + } }, }), }, diff --git a/app/javascript/dashboard/components/CustomAttribute.vue b/app/javascript/dashboard/components/CustomAttribute.vue index 8f70a9fa2..232cc9aac 100644 --- a/app/javascript/dashboard/components/CustomAttribute.vue +++ b/app/javascript/dashboard/components/CustomAttribute.vue @@ -102,13 +102,14 @@ export default { return this.v$.editedValue.$error; }, errorMessage() { - if (this.v$.editedValue.url) { + if (this.v$.editedValue.url?.$invalid) { return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_URL'); } - if (!this.v$.editedValue.regexValidation) { - return this.regexCue - ? this.regexCue - : this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_INPUT'); + if (this.v$.editedValue.regexValidation?.$invalid) { + return ( + this.regexCue || + this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_INPUT') + ); } return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.REQUIRED'); }, @@ -134,9 +135,12 @@ export default { editedValue: { required, regexValidation: value => { - return !( - this.attributeRegex && !getRegexp(this.attributeRegex).test(value) - ); + if (!this.attributeRegex || !value) return true; + try { + return getRegexp(this.attributeRegex).test(value); + } catch { + return false; + } }, }, }; diff --git a/app/javascript/dashboard/routes/dashboard/settings/attributes/AddAttribute.vue b/app/javascript/dashboard/routes/dashboard/settings/attributes/AddAttribute.vue index 9b097f6a1..8d85d56af 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/attributes/AddAttribute.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/attributes/AddAttribute.vue @@ -4,6 +4,7 @@ import { required, minLength } from '@vuelidate/validators'; import { mapGetters } from 'vuex'; import { useAlert } from 'dashboard/composables'; import { convertToAttributeSlug } from 'dashboard/helper/commons.js'; +import { normalizeRegexPattern } from 'shared/helpers/Validators'; import { ATTRIBUTE_MODELS, ATTRIBUTE_TYPES } from './constants'; import NextButton from 'dashboard/components-next/button/Button.vue'; @@ -99,19 +100,10 @@ export default { }, validations: { - displayName: { - required, - minLength: minLength(1), - }, - description: { - required, - }, - attributeModel: { - required, - }, - attributeType: { - required, - }, + displayName: { required, minLength: minLength(1) }, + description: { required }, + attributeModel: { required }, + attributeType: { required }, attributeKey: { required, isKey(value) { @@ -151,9 +143,7 @@ export default { attribute_display_type: this.attributeType, attribute_key: this.attributeKey, attribute_values: this.attributeListValues, - regex_pattern: this.regexPattern - ? new RegExp(this.regexPattern).toString() - : null, + regex_pattern: normalizeRegexPattern(this.regexPattern), regex_cue: this.regexCue, }); this.alertMessage = this.$t('ATTRIBUTES_MGMT.ADD.API.SUCCESS_MESSAGE'); diff --git a/app/javascript/dashboard/routes/dashboard/settings/attributes/EditAttribute.vue b/app/javascript/dashboard/routes/dashboard/settings/attributes/EditAttribute.vue index 35cba6a86..308ea35c7 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/attributes/EditAttribute.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/attributes/EditAttribute.vue @@ -2,7 +2,7 @@ import { useVuelidate } from '@vuelidate/core'; import { useAlert } from 'dashboard/composables'; import { required, minLength } from '@vuelidate/validators'; -import { getRegexp } from 'shared/helpers/Validators'; +import { getRegexp, normalizeRegexPattern } from 'shared/helpers/Validators'; import { ATTRIBUTE_TYPES } from './constants'; import NextButton from 'dashboard/components-next/button/Button.vue'; import TagInput from 'dashboard/components-next/taginput/TagInput.vue'; @@ -41,16 +41,9 @@ export default { }; }, validations: { - displayName: { - required, - }, - attributeType: { - required, - }, - description: { - required, - minLength: minLength(1), - }, + displayName: { required }, + attributeType: { required }, + description: { required, minLength: minLength(1) }, attributeKey: { required, isKey(value) { @@ -118,7 +111,7 @@ export default { }, setFormValues() { const regexPattern = this.selectedAttribute.regex_pattern - ? getRegexp(this.selectedAttribute.regex_pattern).source + ? getRegexp(this.selectedAttribute.regex_pattern).toString() : null; this.displayName = this.selectedAttribute.attribute_display_name; this.description = this.selectedAttribute.attribute_description; @@ -144,9 +137,7 @@ export default { attribute_description: this.description, attribute_display_name: this.displayName, attribute_values: this.updatedAttributeListValues, - regex_pattern: this.regexPattern - ? new RegExp(this.regexPattern).toString() - : null, + regex_pattern: normalizeRegexPattern(this.regexPattern), regex_cue: this.regexCue, }); this.alertMessage = this.$t('ATTRIBUTES_MGMT.EDIT.API.SUCCESS_MESSAGE'); diff --git a/app/javascript/shared/helpers/Validators.js b/app/javascript/shared/helpers/Validators.js index 1c10121f1..a3547a5ca 100644 --- a/app/javascript/shared/helpers/Validators.js +++ b/app/javascript/shared/helpers/Validators.js @@ -101,6 +101,22 @@ export const getRegexp = regexPatternValue => { ); }; +/** + * Normalises a user-entered regex pattern into canonical `/source/flags` form. + * Strips `/.../flags` wrapping if the user included it, so `new RegExp` does + * not double-escape the slashes on save. + * + * @param {string} pattern - Raw pattern string from the form. + * @returns {?string} Canonical `/source/flags` string, or null when empty. + */ +export const normalizeRegexPattern = pattern => { + if (!pattern) return null; + const match = pattern.match(/^\/(.+)\/([gimsuy]*)$/); + const source = match ? match[1] : pattern; + const flags = match ? match[2] : ''; + return new RegExp(source, flags).toString(); +}; + /** * Checks if a string is a valid slug (letters, numbers, hyphens only, no spaces or other symbols). * @param {string} value - The slug to validate. diff --git a/app/javascript/shared/helpers/specs/ValidatorsHelper.spec.js b/app/javascript/shared/helpers/specs/ValidatorsHelper.spec.js index 4546b43a8..62c8f8bee 100644 --- a/app/javascript/shared/helpers/specs/ValidatorsHelper.spec.js +++ b/app/javascript/shared/helpers/specs/ValidatorsHelper.spec.js @@ -9,6 +9,7 @@ import { isNumber, isDomain, getRegexp, + normalizeRegexPattern, isValidSlug, } from '../Validators'; @@ -155,6 +156,30 @@ describe('#getRegexp', () => { }); }); +describe('#normalizeRegexPattern', () => { + it('returns null for empty values', () => { + expect(normalizeRegexPattern('')).toBeNull(); + expect(normalizeRegexPattern(null)).toBeNull(); + expect(normalizeRegexPattern(undefined)).toBeNull(); + }); + + it('canonicalises a bare source', () => { + expect(normalizeRegexPattern('^[0-9]+$')).toBe('/^[0-9]+$/'); + }); + + it('strips slash wrapping the user may include', () => { + expect(normalizeRegexPattern('/^[0-9]+$/')).toBe('/^[0-9]+$/'); + }); + + it('preserves flags on wrapped input', () => { + expect(normalizeRegexPattern('/hello/gi')).toBe('/hello/gi'); + }); + + it('throws for an invalid regex source', () => { + expect(() => normalizeRegexPattern('[')).toThrow(); + }); +}); + describe('#isValidSlug', () => { it('should return true for valid slugs', () => { expect(isValidSlug('abc')).toEqual(true); diff --git a/app/javascript/widget/components/PreChat/Form.vue b/app/javascript/widget/components/PreChat/Form.vue index 2868d6c3c..280f08363 100644 --- a/app/javascript/widget/components/PreChat/Form.vue +++ b/app/javascript/widget/components/PreChat/Form.vue @@ -140,24 +140,15 @@ export default { }, }, methods: { - labelClass(input) { - const { state } = input.context; - const hasErrors = state.invalid; - return !hasErrors ? 'text-n-slate-12' : 'text-n-ruby-10'; - }, inputClass(input) { const { state, family: classification, type } = input.context; - const hasErrors = state.invalid; if (classification === 'box' && type === 'checkbox') { return ''; } if (type === 'phoneInput') { - this.hasErrorInPhoneInput = hasErrors; + this.hasErrorInPhoneInput = state.invalid; } - if (!hasErrors) { - return `mt-1 rounded w-full py-2 px-3`; - } - return `mt-1 rounded w-full py-2 px-3 error`; + return 'mt-1 rounded w-full py-2 px-3'; }, isContactFieldRequired(field) { return this.preChatFields.find(option => option.name === field).required; @@ -176,7 +167,12 @@ export default { return this.formValues[name] || null; }, getValidation({ type, name, field_type, regex_pattern }) { - let regex = regex_pattern ? getRegexp(regex_pattern) : null; + const regex = regex_pattern ? getRegexp(regex_pattern) : null; + // FormKit caches the RegExp and calls .test() across keystrokes, so + // drop stateful g/y flags to stop lastIndex mutation flipping validity. + const matchRegex = regex + ? new RegExp(regex.source, regex.flags.replace(/[gy]/g, '')) + : null; const validations = { emailAddress: 'email', phoneNumber: ['startsWithPlus', 'isValidPhoneNumber'], @@ -186,27 +182,32 @@ export default { select: null, number: null, checkbox: false, - contact_attribute: regex ? [['matches', regex]] : null, - conversation_attribute: regex ? [['matches', regex]] : null, + contact_attribute: matchRegex ? [['matches', matchRegex]] : null, + conversation_attribute: matchRegex ? [['matches', matchRegex]] : null, }; const validationKeys = Object.keys(validations); const isRequired = this.isContactFieldRequired(name); - const validation = isRequired ? ['required'] : ['optional']; + const baseRules = isRequired ? [['required']] : [['optional']]; if ( - validationKeys.includes(name) || - validationKeys.includes(type) || - validationKeys.includes(field_type) + !validationKeys.includes(name) && + !validationKeys.includes(type) && + !validationKeys.includes(field_type) ) { - const validationType = - validations[type] || validations[name] || validations[field_type]; - const allValidations = validationType - ? validation.concat(validationType) - : validation; - return allValidations.join('|'); + return ''; } - return ''; + const validationType = + validations[type] || validations[name] || validations[field_type]; + if (!validationType) return baseRules; + + // Normalise into array-of-arrays so RegExp objects in `['matches', regex]` + // survive without being stringified by FormKit. + const extraRules = Array.isArray(validationType) + ? validationType.map(rule => (Array.isArray(rule) ? rule : [rule])) + : [[validationType]]; + + return baseRules.concat(extraRules); }, findFieldType(type) { if (type === 'link') { @@ -283,7 +284,7 @@ export default { } : undefined " - :label-class="context => `text-sm font-medium ${labelClass(context)}`" + label-class="text-sm font-medium text-n-slate-12" :input-class="context => inputClass(context)" :validation-messages="{ startsWithPlus: $t( @@ -302,7 +303,7 @@ export default { v-if="!hasActiveCampaign" name="message" type="textarea" - :label-class="context => `text-sm font-medium ${labelClass(context)}`" + label-class="text-sm font-medium text-n-slate-12" :input-class="context => inputClass(context)" :label="$t('PRE_CHAT_FORM.FIELDS.MESSAGE.LABEL')" :placeholder="$t('PRE_CHAT_FORM.FIELDS.MESSAGE.PLACEHOLDER')" @@ -330,16 +331,21 @@ export default { @apply mt-2; .formkit-inner { - input.error, - textarea.error, - select.error { - @apply outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9 focus:outline-n-ruby-9 dark:focus:outline-n-ruby-9; - } - input[type='checkbox'] { @apply size-4 outline-none; } } + + &[data-invalid] { + .formkit-label { + @apply text-n-ruby-10; + } + .formkit-inner input, + .formkit-inner textarea, + .formkit-inner select { + @apply outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9 focus:outline-n-ruby-9 dark:focus:outline-n-ruby-9; + } + } } [data-invalid] .formkit-message {