diff --git a/app/javascript/dashboard/components/CustomAttribute.vue b/app/javascript/dashboard/components/CustomAttribute.vue index e42660ed7..4bf97320b 100644 --- a/app/javascript/dashboard/components/CustomAttribute.vue +++ b/app/javascript/dashboard/components/CustomAttribute.vue @@ -5,7 +5,7 @@ import { BUS_EVENTS } from 'shared/constants/busEvents'; import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown.vue'; import HelperTextPopup from 'dashboard/components/ui/HelperTextPopup.vue'; import { isValidURL } from '../helper/URLHelper'; -import customAttributeMixin from '../mixins/customAttributeMixin'; +import { getRegexp } from 'shared/helpers/Validators'; import { useVuelidate } from '@vuelidate/core'; const DATE_FORMAT = 'yyyy-MM-dd'; @@ -15,7 +15,6 @@ export default { MultiselectDropdown, HelperTextPopup, }, - mixins: [customAttributeMixin], props: { label: { type: String, required: true }, description: { type: String, default: '' }, @@ -128,8 +127,7 @@ export default { required, regexValidation: value => { return !( - this.attributeRegex && - !this.getRegexp(this.attributeRegex).test(value) + this.attributeRegex && !getRegexp(this.attributeRegex).test(value) ); }, }, diff --git a/app/javascript/dashboard/composables/spec/useMacros.spec.js b/app/javascript/dashboard/composables/spec/useMacros.spec.js new file mode 100644 index 000000000..d268547a5 --- /dev/null +++ b/app/javascript/dashboard/composables/spec/useMacros.spec.js @@ -0,0 +1,175 @@ +import { describe, it, expect, vi } from 'vitest'; +import { useMacros } from '../useMacros'; +import { useStoreGetters } from 'dashboard/composables/store'; +import { PRIORITY_CONDITION_VALUES } from 'dashboard/helper/automationHelper.js'; + +vi.mock('dashboard/composables/store'); +vi.mock('dashboard/helper/automationHelper.js'); + +describe('useMacros', () => { + const mockLabels = [ + { + id: 6, + title: 'sales', + description: 'sales team', + color: '#8EA20F', + show_on_sidebar: true, + }, + { + id: 2, + title: 'billing', + description: 'billing', + color: '#4077DA', + show_on_sidebar: true, + }, + { + id: 1, + title: 'snoozed', + description: 'Items marked for later', + color: '#D12F42', + show_on_sidebar: true, + }, + { + id: 5, + title: 'mobile-app', + description: 'tech team', + color: '#2DB1CC', + show_on_sidebar: true, + }, + { + id: 14, + title: 'human-resources-department-with-long-title', + description: 'Test', + color: '#FF6E09', + show_on_sidebar: true, + }, + { + id: 22, + title: 'priority', + description: 'For important sales leads', + color: '#7E7CED', + show_on_sidebar: true, + }, + ]; + const mockTeams = [ + { + id: 1, + name: '⚙️ sales team', + description: 'This is our internal sales team', + allow_auto_assign: true, + account_id: 1, + is_member: true, + }, + { + id: 2, + name: '🤷‍♂️ fayaz', + description: 'Test', + allow_auto_assign: true, + account_id: 1, + is_member: true, + }, + { + id: 3, + name: '🇮🇳 apac sales', + description: 'Sales team for France Territory', + allow_auto_assign: true, + account_id: 1, + is_member: true, + }, + ]; + const mockAgents = [ + { + id: 1, + account_id: 1, + availability_status: 'offline', + auto_offline: true, + confirmed: true, + email: 'john@doe.com', + available_name: 'John Doe', + name: 'John Doe', + role: 'agent', + thumbnail: 'https://example.com/image.png', + }, + { + id: 9, + account_id: 1, + availability_status: 'offline', + auto_offline: true, + confirmed: true, + email: 'clark@kent.com', + available_name: 'Clark Kent', + name: 'Clark Kent', + role: 'agent', + thumbnail: '', + }, + ]; + + beforeEach(() => { + useStoreGetters.mockReturnValue({ + 'labels/getLabels': { value: mockLabels }, + 'teams/getTeams': { value: mockTeams }, + 'agents/getAgents': { value: mockAgents }, + }); + }); + + it('initializes computed properties correctly', () => { + const { getMacroDropdownValues } = useMacros(); + expect(getMacroDropdownValues('add_label')).toHaveLength(mockLabels.length); + expect(getMacroDropdownValues('assign_team')).toHaveLength( + mockTeams.length + ); + expect(getMacroDropdownValues('assign_agent')).toHaveLength( + mockAgents.length + 1 + ); // +1 for "Self" + }); + + it('returns teams for assign_team and send_email_to_team types', () => { + const { getMacroDropdownValues } = useMacros(); + expect(getMacroDropdownValues('assign_team')).toEqual(mockTeams); + expect(getMacroDropdownValues('send_email_to_team')).toEqual(mockTeams); + }); + + it('returns agents with "Self" option for assign_agent type', () => { + const { getMacroDropdownValues } = useMacros(); + const result = getMacroDropdownValues('assign_agent'); + expect(result[0]).toEqual({ id: 'self', name: 'Self' }); + expect(result.slice(1)).toEqual(mockAgents); + }); + + it('returns formatted labels for add_label and remove_label types', () => { + const { getMacroDropdownValues } = useMacros(); + const expectedLabels = mockLabels.map(i => ({ + id: i.title, + name: i.title, + })); + expect(getMacroDropdownValues('add_label')).toEqual(expectedLabels); + expect(getMacroDropdownValues('remove_label')).toEqual(expectedLabels); + }); + + it('returns PRIORITY_CONDITION_VALUES for change_priority type', () => { + const { getMacroDropdownValues } = useMacros(); + expect(getMacroDropdownValues('change_priority')).toEqual( + PRIORITY_CONDITION_VALUES + ); + }); + + it('returns an empty array for unknown types', () => { + const { getMacroDropdownValues } = useMacros(); + expect(getMacroDropdownValues('unknown_type')).toEqual([]); + }); + + it('handles empty data correctly', () => { + useStoreGetters.mockReturnValue({ + 'labels/getLabels': { value: [] }, + 'teams/getTeams': { value: [] }, + 'agents/getAgents': { value: [] }, + }); + + const { getMacroDropdownValues } = useMacros(); + expect(getMacroDropdownValues('add_label')).toEqual([]); + expect(getMacroDropdownValues('assign_team')).toEqual([]); + expect(getMacroDropdownValues('assign_agent')).toEqual([ + { id: 'self', name: 'Self' }, + ]); + }); +}); diff --git a/app/javascript/dashboard/composables/useMacros.js b/app/javascript/dashboard/composables/useMacros.js new file mode 100644 index 000000000..6967331ea --- /dev/null +++ b/app/javascript/dashboard/composables/useMacros.js @@ -0,0 +1,44 @@ +import { computed } from 'vue'; +import { useStoreGetters } from 'dashboard/composables/store'; +import { PRIORITY_CONDITION_VALUES } from 'dashboard/helper/automationHelper.js'; + +/** + * Composable for handling macro-related functionality + * @returns {Object} An object containing the getMacroDropdownValues function + */ +export const useMacros = () => { + const getters = useStoreGetters(); + + const labels = computed(() => getters['labels/getLabels'].value); + const teams = computed(() => getters['teams/getTeams'].value); + const agents = computed(() => getters['agents/getAgents'].value); + + /** + * Get dropdown values based on the specified type + * @param {string} type - The type of dropdown values to retrieve + * @returns {Array} An array of dropdown values + */ + const getMacroDropdownValues = type => { + switch (type) { + case 'assign_team': + case 'send_email_to_team': + return teams.value; + case 'assign_agent': + return [{ id: 'self', name: 'Self' }, ...agents.value]; + case 'add_label': + case 'remove_label': + return labels.value.map(i => ({ + id: i.title, + name: i.title, + })); + case 'change_priority': + return PRIORITY_CONDITION_VALUES; + default: + return []; + } + }; + + return { + getMacroDropdownValues, + }; +}; diff --git a/app/javascript/dashboard/mixins/customAttributeMixin.js b/app/javascript/dashboard/mixins/customAttributeMixin.js deleted file mode 100644 index a0617685d..000000000 --- a/app/javascript/dashboard/mixins/customAttributeMixin.js +++ /dev/null @@ -1,11 +0,0 @@ -export default { - methods: { - getRegexp(regexPatternValue) { - let lastSlash = regexPatternValue.lastIndexOf('/'); - return new RegExp( - regexPatternValue.slice(1, lastSlash), - regexPatternValue.slice(lastSlash + 1) - ); - }, - }, -}; diff --git a/app/javascript/dashboard/mixins/macrosMixin.js b/app/javascript/dashboard/mixins/macrosMixin.js deleted file mode 100644 index 4aea89227..000000000 --- a/app/javascript/dashboard/mixins/macrosMixin.js +++ /dev/null @@ -1,34 +0,0 @@ -import { mapGetters } from 'vuex'; -import { PRIORITY_CONDITION_VALUES } from 'dashboard/helper/automationHelper.js'; -export default { - computed: { - ...mapGetters({ - labels: 'labels/getLabels', - teams: 'teams/getTeams', - agents: 'agents/getAgents', - }), - }, - methods: { - getDropdownValues(type) { - switch (type) { - case 'assign_team': - case 'send_email_to_team': - return this.teams; - case 'assign_agent': - return [{ id: 'self', name: 'Self' }, ...this.agents]; - case 'add_label': - case 'remove_label': - return this.labels.map(i => { - return { - id: i.title, - name: i.title, - }; - }); - case 'change_priority': - return PRIORITY_CONDITION_VALUES; - default: - return []; - } - }, - }, -}; diff --git a/app/javascript/dashboard/mixins/specs/macros.spec.js b/app/javascript/dashboard/mixins/specs/macros.spec.js deleted file mode 100644 index a8b3ee2b1..000000000 --- a/app/javascript/dashboard/mixins/specs/macros.spec.js +++ /dev/null @@ -1,50 +0,0 @@ -import { createWrapper } from '@vue/test-utils'; -import macrosMixin from '../macrosMixin'; -import Vue from 'vue'; -import { teams, labels, agents } from '../../helper/specs/macrosFixtures'; -import { PRIORITY_CONDITION_VALUES } from 'dashboard/helper/automationHelper.js'; -describe('webhookMixin', () => { - describe('#getEventLabel', () => { - it('returns correct i18n translation:', () => { - const Component = { - render() {}, - title: 'MyComponent', - mixins: [macrosMixin], - data: () => { - return { - teams, - labels, - agents, - }; - }, - methods: { - $t(text) { - return text; - }, - }, - }; - - const resolvedLabels = labels.map(i => { - return { - id: i.title, - name: i.title, - }; - }); - - const Constructor = Vue.extend(Component); - const vm = new Constructor().$mount(); - const wrapper = createWrapper(vm); - expect(wrapper.vm.getDropdownValues('assign_team')).toEqual(teams); - expect(wrapper.vm.getDropdownValues('send_email_to_team')).toEqual(teams); - expect(wrapper.vm.getDropdownValues('add_label')).toEqual(resolvedLabels); - expect(wrapper.vm.getDropdownValues('assign_agent')).toEqual([ - { id: 'self', name: 'Self' }, - ...agents, - ]); - expect(wrapper.vm.getDropdownValues('change_priority')).toEqual( - PRIORITY_CONDITION_VALUES - ); - expect(wrapper.vm.getDropdownValues()).toEqual([]); - }); - }); -}); diff --git a/app/javascript/dashboard/routes/dashboard/settings/attributes/EditAttribute.vue b/app/javascript/dashboard/routes/dashboard/settings/attributes/EditAttribute.vue index 0d237d866..03d78e8d1 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/attributes/EditAttribute.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/attributes/EditAttribute.vue @@ -2,11 +2,10 @@ import { useVuelidate } from '@vuelidate/core'; import { useAlert } from 'dashboard/composables'; import { required, minLength } from '@vuelidate/validators'; +import { getRegexp } from 'shared/helpers/Validators'; import { ATTRIBUTE_TYPES } from './constants'; -import customAttributeMixin from '../../../../mixins/customAttributeMixin'; export default { components: {}, - mixins: [customAttributeMixin], props: { selectedAttribute: { type: Object, @@ -116,7 +115,7 @@ export default { }, setFormValues() { const regexPattern = this.selectedAttribute.regex_pattern - ? this.getRegexp(this.selectedAttribute.regex_pattern).source + ? getRegexp(this.selectedAttribute.regex_pattern).source : null; this.displayName = this.selectedAttribute.attribute_display_name; this.description = this.selectedAttribute.attribute_description; diff --git a/app/javascript/dashboard/routes/dashboard/settings/macros/MacroEditor.vue b/app/javascript/dashboard/routes/dashboard/settings/macros/MacroEditor.vue index 1b02ba2e5..f29d822ff 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/macros/MacroEditor.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/macros/MacroEditor.vue @@ -1,126 +1,123 @@ - @@ -128,11 +125,12 @@ export default {
diff --git a/app/javascript/dashboard/routes/dashboard/settings/macros/MacroNode.vue b/app/javascript/dashboard/routes/dashboard/settings/macros/MacroNode.vue index 2e0a5aecd..649f3efbb 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/macros/MacroNode.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/macros/MacroNode.vue @@ -1,84 +1,80 @@ - - - diff --git a/app/javascript/shared/helpers/Validators.js b/app/javascript/shared/helpers/Validators.js index d8cf0b352..6502fef39 100644 --- a/app/javascript/shared/helpers/Validators.js +++ b/app/javascript/shared/helpers/Validators.js @@ -1,22 +1,58 @@ +/** + * Checks if a string is a valid E.164 phone number format. + * @param {string} value - The phone number to validate. + * @returns {boolean} True if the number is in E.164 format, false otherwise. + */ export const isPhoneE164 = value => !!value.match(/^\+[1-9]\d{1,14}$/); +/** + * Validates a phone number after removing the dial code. + * @param {string} value - The full phone number including dial code. + * @param {string} dialCode - The dial code to remove before validation. + * @returns {boolean} True if the number (without dial code) is valid, false otherwise. + */ export const isPhoneNumberValid = (value, dialCode) => { const number = value.replace(dialCode, ''); return !!number.match(/^[0-9]{1,14}$/); }; +/** + * Checks if a string is either a valid E.164 phone number or empty. + * @param {string} value - The phone number to validate. + * @returns {boolean} True if the number is in E.164 format or empty, false otherwise. + */ export const isPhoneE164OrEmpty = value => isPhoneE164(value) || value === ''; +/** + * Validates a phone number with dial code, requiring at least 5 digits. + * @param {string} value - The full phone number including dial code. + * @returns {boolean} True if the number is valid, false otherwise. + */ export const isPhoneNumberValidWithDialCode = value => { const number = value.replace(/^\+/, ''); // Remove the '+' sign return !!number.match(/^[1-9]\d{4,}$/); // Validate the phone number with minimum 5 digits }; +/** + * Checks if a string starts with a plus sign. + * @param {string} value - The string to check. + * @returns {boolean} True if the string starts with '+', false otherwise. + */ export const startsWithPlus = value => value.startsWith('+'); +/** + * Checks if a string is a valid URL (starts with 'http') or is empty. + * @param {string} [value=''] - The string to check. + * @returns {boolean} True if the string is a valid URL or empty, false otherwise. + */ export const shouldBeUrl = (value = '') => value ? value.startsWith('http') : true; +/** + * Validates a password for complexity requirements. + * @param {string} value - The password to validate. + * @returns {boolean} True if the password meets all requirements, false otherwise. + */ export const isValidPassword = value => { const containsUppercase = /[A-Z]/.test(value); const containsLowercase = /[a-z]/.test(value); @@ -32,8 +68,18 @@ export const isValidPassword = value => { ); }; +/** + * Checks if a string consists only of digits. + * @param {string} value - The string to check. + * @returns {boolean} True if the string contains only digits, false otherwise. + */ export const isNumber = value => /^\d+$/.test(value); +/** + * Validates a domain name. + * @param {string} value - The domain name to validate. + * @returns {boolean} True if the domain is valid or empty, false otherwise. + */ export const isDomain = value => { if (value !== '') { const domainRegex = /^([\p{L}0-9]+(-[\p{L}0-9]+)*\.)+[a-z]{2,}$/gmu; @@ -41,3 +87,16 @@ export const isDomain = value => { } return true; }; + +/** + * Creates a RegExp object from a string representation of a regular expression. + * @param {string} regexPatternValue - The string representation of the regex (e.g., '/pattern/flags'). + * @returns {RegExp} A RegExp object created from the input string. + */ +export const getRegexp = regexPatternValue => { + let lastSlash = regexPatternValue.lastIndexOf('/'); + return new RegExp( + regexPatternValue.slice(1, lastSlash), + regexPatternValue.slice(lastSlash + 1) + ); +}; diff --git a/app/javascript/shared/helpers/specs/ValidatorsHelper.spec.js b/app/javascript/shared/helpers/specs/ValidatorsHelper.spec.js index 6d65cb36e..fc15b772c 100644 --- a/app/javascript/shared/helpers/specs/ValidatorsHelper.spec.js +++ b/app/javascript/shared/helpers/specs/ValidatorsHelper.spec.js @@ -8,6 +8,7 @@ import { isPhoneNumberValid, isNumber, isDomain, + getRegexp, } from '../Validators'; describe('#shouldBeUrl', () => { @@ -115,3 +116,40 @@ describe('#startsWithPlus', () => { expect(startsWithPlus('123456789')).toEqual(false); }); }); + +describe('#getRegexp', () => { + it('should create a correct RegExp object', () => { + const regexPattern = '/^[a-z]+$/i'; + const regex = getRegexp(regexPattern); + + expect(regex).toBeInstanceOf(RegExp); + expect(regex.toString()).toBe(regexPattern); + + expect(regex.test('abc')).toBe(true); + expect(regex.test('ABC')).toBe(true); + expect(regex.test('123')).toBe(false); + }); + + it('should handle regex with flags', () => { + const regexPattern = '/hello/gi'; + const regex = getRegexp(regexPattern); + + expect(regex).toBeInstanceOf(RegExp); + expect(regex.toString()).toBe(regexPattern); + + expect(regex.test('hello')).toBe(true); + expect(regex.test('HELLO')).toBe(false); + expect(regex.test('Hello World')).toBe(true); + }); + + it('should handle regex with special characters', () => { + const regexPattern = '/\\d{3}-\\d{2}-\\d{4}/'; + const regex = getRegexp(regexPattern); + + expect(regex).toBeInstanceOf(RegExp); + expect(regex.toString()).toBe(regexPattern); + + expect(regex.test('123-45-6789')).toBe(true); + expect(regex.test('12-34-5678')).toBe(false); + }); +}); diff --git a/app/javascript/widget/components/PreChat/Form.vue b/app/javascript/widget/components/PreChat/Form.vue index 9b8da3798..596ba16d9 100644 --- a/app/javascript/widget/components/PreChat/Form.vue +++ b/app/javascript/widget/components/PreChat/Form.vue @@ -3,25 +3,19 @@ import CustomButton from 'shared/components/Button.vue'; import Spinner from 'shared/components/Spinner.vue'; import { mapGetters } from 'vuex'; import { getContrastingTextColor } from '@chatwoot/utils'; -import messageFormatterMixin from 'shared/mixins/messageFormatterMixin'; import { isEmptyObject } from 'widget/helpers/utils'; +import { getRegexp } from 'shared/helpers/Validators'; +import messageFormatterMixin from 'shared/mixins/messageFormatterMixin'; import routerMixin from 'widget/mixins/routerMixin'; import darkModeMixin from 'widget/mixins/darkModeMixin'; import configMixin from 'widget/mixins/configMixin'; -import customAttributeMixin from '../../../dashboard/mixins/customAttributeMixin'; export default { components: { CustomButton, Spinner, }, - mixins: [ - routerMixin, - darkModeMixin, - messageFormatterMixin, - configMixin, - customAttributeMixin, - ], + mixins: [routerMixin, darkModeMixin, messageFormatterMixin, configMixin], props: { options: { type: Object, @@ -184,7 +178,7 @@ export default { return this.formValues[name] || null; }, getValidation({ type, name, field_type, regex_pattern }) { - let regex = regex_pattern ? this.getRegexp(regex_pattern) : null; + let regex = regex_pattern ? getRegexp(regex_pattern) : null; const validations = { emailAddress: 'email', phoneNumber: ['startsWithPlus', 'isValidPhoneNumber'], diff --git a/tailwind.config.js b/tailwind.config.js index f4b0793f6..93fa296e5 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -66,12 +66,19 @@ module.exports = { transform: 'translateX(1px)', }, }, + shake: { + '0%, 100%': { transform: 'translateX(0)' }, + '25%': { transform: 'translateX(0.234375rem)' }, + '50%': { transform: 'translateX(-0.234375rem)' }, + '75%': { transform: 'translateX(0.234375rem)' }, + }, }, animation: { ...defaultTheme.animation, wiggle: 'wiggle 0.5s ease-in-out', 'loader-pulse': 'loader-pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite', 'card-select': 'card-select 0.25s ease-in-out', + shake: 'shake 0.3s ease-in-out 0s 2', }, }, plugins: [