From d1d1398d807167f81919eb84332845567c7fb575 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Fri, 9 Aug 2024 18:37:26 +0530 Subject: [PATCH 1/2] feat: Rewrite `customAttributeMixin` to validation helper (#9916) # Pull Request Template ## Description This PR will replace the use of `customAttributeMixin` with `shared/helpers/Validators` helper. Fixes https://linear.app/chatwoot/issue/CW-3446/rewrite-customattributemixin-mixin-to-a-composable **Files updated** 1. widget/components/PreChat/Form.vue 2. dashboard/components/CustomAttribute.vue 3. dashboard/routes/dashboard/settings/attributes/EditAttribute.vue ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? Test the custom validation is working or not with the custom attributes. ## 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 --- .../dashboard/components/CustomAttribute.vue | 6 +- .../dashboard/mixins/customAttributeMixin.js | 11 ---- .../settings/attributes/EditAttribute.vue | 5 +- app/javascript/shared/helpers/Validators.js | 59 +++++++++++++++++++ .../helpers/specs/ValidatorsHelper.spec.js | 38 ++++++++++++ .../widget/components/PreChat/Form.vue | 14 ++--- 6 files changed, 105 insertions(+), 28 deletions(-) delete mode 100644 app/javascript/dashboard/mixins/customAttributeMixin.js 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/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/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/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'], From 3558878ae2de44dc3fc9aad808beb1df9c8becd2 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Fri, 9 Aug 2024 18:40:06 +0530 Subject: [PATCH 2/2] feat: Replace the use of `macroMixin` with a composable (#9912) # Pull Request Template ## Description This PR will replace usage of `macroMixin` with the `useMacros` composable. And updated components from option API to composition API. **Files updated** 1. dashboard/routes/dashboard/settings/macros/MacroNode.vue 2. dashboard/routes/dashboard/settings/macros/MacroEditor.vue Fixes https://linear.app/chatwoot/issue/CW-3449/rewrite-macrosmixin-mixin-to-a-composable ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? **Test cases** 1. Check whether we can create a new macro. 2. Check whether validations and error animation are working or not. 3. Ability to drag the macro files 4. Check whether the edit pages and functionality is working or not. ## 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 --- .../composables/spec/useMacros.spec.js | 175 +++++++++++++ .../dashboard/composables/useMacros.js | 44 ++++ .../dashboard/mixins/macrosMixin.js | 34 --- .../dashboard/mixins/specs/macros.spec.js | 50 ---- .../dashboard/settings/macros/MacroEditor.vue | 234 +++++++++--------- .../dashboard/settings/macros/MacroNode.vue | 158 +++++------- tailwind.config.js | 7 + 7 files changed, 401 insertions(+), 301 deletions(-) create mode 100644 app/javascript/dashboard/composables/spec/useMacros.spec.js create mode 100644 app/javascript/dashboard/composables/useMacros.js delete mode 100644 app/javascript/dashboard/mixins/macrosMixin.js delete mode 100644 app/javascript/dashboard/mixins/specs/macros.spec.js 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/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/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/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: [