Merge branch 'develop' into fix/conv-order

This commit is contained in:
Shivam Mishra
2024-08-10 15:53:20 +05:30
committed by GitHub
23 changed files with 684 additions and 507 deletions
@@ -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)
);
},
},
@@ -101,6 +101,8 @@ const shouldShowEmptyState = computed(() => {
:key="item.id"
:is-active="isFilterActive(item.id)"
:button-text="item.name"
:icon="item.icon"
:icon-color="item.iconColor"
@click="$emit('click', item)"
/>
</slot>
@@ -8,6 +8,14 @@ defineProps({
type: Boolean,
default: false,
},
icon: {
type: String,
default: '',
},
iconColor: {
type: String,
default: '',
},
});
</script>
@@ -20,6 +28,12 @@ defineProps({
@focus="$emit('focus')"
>
<div class="inline-flex items-center gap-3 overflow-hidden">
<fluent-icon
v-if="icon"
:icon="icon"
size="18"
:style="{ color: iconColor }"
/>
<span
class="text-sm font-medium truncate text-slate-900 dark:text-slate-50"
>
@@ -62,6 +62,8 @@ const onSearch = async value => {
issues.value = response.data.map(issue => ({
id: issue.id,
name: `${issue.identifier} ${issue.title}`,
icon: 'status',
iconColor: issue.state.color,
}));
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
@@ -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' },
]);
});
});
@@ -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,
};
};
@@ -1,11 +0,0 @@
export default {
methods: {
getRegexp(regexPatternValue) {
let lastSlash = regexPatternValue.lastIndexOf('/');
return new RegExp(
regexPatternValue.slice(1, lastSlash),
regexPatternValue.slice(lastSlash + 1)
);
},
},
};
@@ -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 [];
}
},
},
};
@@ -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([]);
});
});
});
@@ -1,43 +1,34 @@
<script>
<script setup>
import { ref, computed } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
export default {
components: {
WootMessageEditor,
},
mixins: [keyboardEventListenerMixins],
data() {
return {
noteContent: '',
};
},
computed: {
buttonDisabled() {
return this.noteContent === '';
},
},
methods: {
getKeyboardEvents() {
return {
'$mod+Enter': {
action: () => this.onAdd(),
allowOnFocusedInput: true,
},
};
},
onAdd() {
if (this.noteContent !== '') {
this.$emit('add', this.noteContent);
}
this.noteContent = '';
},
const emit = defineEmits(['add']);
const addNoteRef = ref(null);
const noteContent = ref('');
const buttonDisabled = computed(() => noteContent.value === '');
const onAdd = () => {
if (noteContent.value !== '') {
emit('add', noteContent.value);
}
noteContent.value = '';
};
const keyboardEvents = {
'$mod+Enter': {
action: () => onAdd(),
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents, addNoteRef);
</script>
<template>
<div
ref="addNoteRef"
class="flex flex-col flex-grow p-4 mb-2 overflow-hidden bg-white border border-solid rounded-md shadow-sm border-slate-75 dark:border-slate-700 dark:bg-slate-900 text-slate-700 dark:text-slate-100"
>
<WootMessageEditor
@@ -1,10 +1,11 @@
<script>
import { ref } from 'vue';
import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import Spinner from 'shared/components/Spinner.vue';
import LabelDropdown from 'shared/components/ui/label/LabelDropdown.vue';
import AddLabel from 'shared/components/ui/dropdown/AddLabel.vue';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import conversationLabelMixin from 'dashboard/mixins/conversation/labelMixin';
export default {
@@ -14,7 +15,7 @@ export default {
AddLabel,
},
mixins: [conversationLabelMixin, keyboardEventListenerMixins],
mixins: [conversationLabelMixin],
props: {
// conversationId prop is used in /conversation/labelMixin,
// remove this props when refactoring to composable if not needed
@@ -26,14 +27,46 @@ export default {
},
setup() {
const { isAdmin } = useAdmin();
const conversationLabelBoxRef = ref(null);
const showSearchDropdownLabel = ref(false);
const toggleLabels = () => {
showSearchDropdownLabel.value = !showSearchDropdownLabel.value;
};
const closeDropdownLabel = () => {
showSearchDropdownLabel.value = false;
};
const keyboardEvents = {
KeyL: {
action: e => {
e.preventDefault();
toggleLabels();
},
},
Escape: {
action: () => {
if (showSearchDropdownLabel.value) {
toggleLabels();
}
},
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents, conversationLabelBoxRef);
return {
isAdmin,
conversationLabelBoxRef,
showSearchDropdownLabel,
closeDropdownLabel,
toggleLabels,
};
},
data() {
return {
selectedLabels: [],
showSearchDropdownLabel: false,
};
},
@@ -42,37 +75,11 @@ export default {
conversationUiFlags: 'conversationLabels/getUIFlags',
}),
},
methods: {
toggleLabels() {
this.showSearchDropdownLabel = !this.showSearchDropdownLabel;
},
closeDropdownLabel() {
this.showSearchDropdownLabel = false;
},
getKeyboardEvents() {
return {
KeyL: {
action: e => {
e.preventDefault();
this.toggleLabels();
},
},
Escape: {
action: () => {
if (this.showSearchDropdownLabel) {
this.toggleLabels();
}
},
allowOnFocusedInput: true,
},
};
},
},
};
</script>
<template>
<div class="sidebar-labels-wrap">
<div ref="conversationLabelBoxRef" class="sidebar-labels-wrap">
<div
v-if="!conversationUiFlags.isFetching"
class="contact-conversation--list"
@@ -1,53 +1,51 @@
<script>
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
<script setup>
import { ref, onMounted } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
export default {
name: 'ChatwootSearch',
mixins: [keyboardEventListenerMixins],
props: {
title: {
type: String,
default: 'Chatwoot',
defineProps({
title: {
type: String,
default: 'Chatwoot',
},
});
const emit = defineEmits(['search', 'close']);
const articleSearchHeaderRef = ref(null);
const searchInputRef = ref(null);
const searchQuery = ref('');
onMounted(() => {
searchInputRef.value.focus();
});
const onInput = e => {
emit('search', e.target.value);
};
const onClose = () => {
emit('close');
};
const keyboardEvents = {
Slash: {
action: e => {
e.preventDefault();
searchInputRef.value.focus();
},
},
data() {
return {
searchQuery: '',
isInputFocused: false,
};
},
mounted() {
this.$refs.searchInput.focus();
},
methods: {
onInput(e) {
this.$emit('search', e.target.value);
},
onClose() {
this.$emit('close');
},
onFocus() {
this.isInputFocused = true;
},
onBlur() {
this.isInputFocused = false;
},
getKeyboardEvents() {
return {
Slash: {
action: e => {
e.preventDefault();
this.$refs.searchInput.focus();
},
},
};
Escape: {
action: () => {
onClose();
},
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents, articleSearchHeaderRef);
</script>
<template>
<div class="flex flex-col py-1">
<div ref="articleSearchHeaderRef" class="flex flex-col py-1">
<div class="flex items-center justify-between py-2 mb-1">
<h3 class="text-base text-slate-900 dark:text-slate-25">
{{ title }}
@@ -68,13 +66,11 @@ export default {
<fluent-icon icon="search" class="" size="18" />
</div>
<input
ref="searchInput"
ref="searchInputRef"
type="text"
:placeholder="$t('HELP_CENTER.ARTICLE_SEARCH.PLACEHOLDER')"
class="block w-full !h-9 ltr:!pl-8 rtl:!pr-8 dark:!bg-slate-700 !bg-slate-25 text-sm rounded-md leading-8 text-slate-700 shadow-sm ring-2 ring-transparent ring-slate-300 border border-solid border-slate-300 placeholder:text-slate-400 focus:border-woot-600 focus:ring-woot-200 !mb-0 focus:bg-slate-25 dark:focus:bg-slate-700 dark:focus:ring-woot-700"
:value="searchQuery"
@focus="onFocus"
@blur="onBlur"
@input="onInput"
/>
</div>
@@ -1,7 +1,6 @@
<script>
import { debounce } from '@chatwoot/utils';
import { useAlert } from 'dashboard/composables';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import SearchHeader from './Header.vue';
import SearchResults from './SearchResults.vue';
@@ -17,7 +16,7 @@ export default {
SearchResults,
ArticleView,
},
mixins: [portalMixin, keyboardEventListenerMixins],
mixins: [portalMixin],
props: {
selectedPortalSlug: {
type: String,
@@ -114,16 +113,6 @@ export default {
useAlert(this.$t('HELP_CENTER.ARTICLE_SEARCH.SUCCESS_ARTICLE_INSERTED'));
this.onClose();
},
getKeyboardEvents() {
return {
Escape: {
action: () => {
this.onClose();
},
allowOnFocusedInput: true,
},
};
},
},
};
</script>
@@ -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;
@@ -1,126 +1,123 @@
<script>
<script setup>
import { ref, computed, watch, provide } from 'vue';
import { useRoute, useRouter } from 'dashboard/composables/route';
import { useI18n } from 'dashboard/composables/useI18n';
import { useStore, useStoreGetters } from 'dashboard/composables/store';
import MacroForm from './MacroForm.vue';
import { MACRO_ACTION_TYPES } from './constants';
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import actionQueryGenerator from 'dashboard/helper/actionQueryGenerator.js';
import macrosMixin from 'dashboard/mixins/macrosMixin';
import { useMacros } from 'dashboard/composables/useMacros';
export default {
components: {
MacroForm,
},
mixins: [macrosMixin],
provide() {
return {
macroActionTypes: this.macroActionTypes,
};
},
data() {
return {
macro: null,
mode: 'CREATE',
macroActionTypes: MACRO_ACTION_TYPES,
};
},
computed: {
...mapGetters({
uiFlags: 'macros/getUIFlags',
}),
macroId() {
return this.$route.params.macroId;
},
},
watch: {
$route: {
handler() {
this.fetchDropdownData();
if (this.$route.params.macroId) {
this.fetchMacro();
} else {
this.initNewMacro();
}
},
immediate: true,
},
},
methods: {
fetchDropdownData() {
this.$store.dispatch('agents/get');
this.$store.dispatch('teams/get');
this.$store.dispatch('labels/get');
},
fetchMacro() {
this.mode = 'EDIT';
this.manifestMacro();
},
async manifestMacro() {
await this.$store.dispatch('macros/getSingleMacro', this.macroId);
const singleMacro = this.$store.getters['macros/getMacro'](this.macroId);
this.macro = this.formatMacro(singleMacro);
},
formatMacro(macro) {
const formattedActions = macro.actions.map(action => {
let actionParams = [];
if (action.action_params.length) {
const inputType = this.macroActionTypes.find(
item => item.key === action.action_name
).inputType;
if (inputType === 'multi_select' || inputType === 'search_select') {
actionParams = [
...this.getDropdownValues(action.action_name, this.$store),
].filter(item => [...action.action_params].includes(item.id));
} else if (inputType === 'team_message') {
actionParams = {
team_ids: [
...this.getDropdownValues(action.action_name, this.$store),
].filter(item =>
[...action.action_params[0].team_ids].includes(item.id)
),
message: action.action_params[0].message,
};
} else actionParams = [...action.action_params];
}
return {
...action,
action_params: actionParams,
const store = useStore();
const getters = useStoreGetters();
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const { getMacroDropdownValues } = useMacros();
const macro = ref(null);
const mode = ref('CREATE');
const macroActionTypes = MACRO_ACTION_TYPES;
provide('macroActionTypes', macroActionTypes);
const uiFlags = computed(() => getters['macros/getUIFlags'].value);
const macroId = computed(() => route.params.macroId);
const fetchDropdownData = () => {
store.dispatch('agents/get');
store.dispatch('teams/get');
store.dispatch('labels/get');
};
const formatMacro = macroData => {
const formattedActions = macroData.actions.map(action => {
let actionParams = [];
if (action.action_params.length) {
const inputType = macroActionTypes.find(
item => item.key === action.action_name
).inputType;
if (inputType === 'multi_select' || inputType === 'search_select') {
actionParams = getMacroDropdownValues(action.action_name).filter(item =>
[...action.action_params].includes(item.id)
);
} else if (inputType === 'team_message') {
actionParams = {
team_ids: getMacroDropdownValues(action.action_name).filter(item =>
[...action.action_params[0].team_ids].includes(item.id)
),
message: action.action_params[0].message,
};
});
return {
...macro,
actions: formattedActions,
};
},
initNewMacro() {
this.mode = 'CREATE';
this.macro = {
name: '',
actions: [
{
action_name: 'assign_team',
action_params: [],
},
],
visibility: 'global',
};
},
async saveMacro(macro) {
try {
const action = this.mode === 'EDIT' ? 'macros/update' : 'macros/create';
let successMessage =
this.mode === 'EDIT'
? this.$t('MACROS.EDIT.API.SUCCESS_MESSAGE')
: this.$t('MACROS.ADD.API.SUCCESS_MESSAGE');
let serializedMacro = JSON.parse(JSON.stringify(macro));
serializedMacro.actions = actionQueryGenerator(serializedMacro.actions);
await this.$store.dispatch(action, serializedMacro);
useAlert(successMessage);
this.$router.push({ name: 'macros_wrapper' });
} catch (error) {
useAlert(this.$t('MACROS.ERROR'));
}
},
} else actionParams = [...action.action_params];
}
return {
...action,
action_params: actionParams,
};
});
return {
...macroData,
actions: formattedActions,
};
};
const manifestMacro = async () => {
await store.dispatch('macros/getSingleMacro', macroId.value);
const singleMacro = store.getters['macros/getMacro'](macroId.value);
macro.value = formatMacro(singleMacro);
};
const fetchMacro = () => {
mode.value = 'EDIT';
manifestMacro();
};
const initNewMacro = () => {
mode.value = 'CREATE';
macro.value = {
name: '',
actions: [
{
action_name: 'assign_team',
action_params: [],
},
],
visibility: 'global',
};
};
watch(
() => route,
() => {
fetchDropdownData();
if (route.params.macroId) {
fetchMacro();
} else {
initNewMacro();
}
},
{ immediate: true, deep: true }
);
const saveMacro = async macroData => {
try {
const action = mode.value === 'EDIT' ? 'macros/update' : 'macros/create';
const successMessage =
mode.value === 'EDIT'
? t('MACROS.EDIT.API.SUCCESS_MESSAGE')
: t('MACROS.ADD.API.SUCCESS_MESSAGE');
let serializedMacro = JSON.parse(JSON.stringify(macroData));
serializedMacro.actions = actionQueryGenerator(serializedMacro.actions);
await store.dispatch(action, serializedMacro);
useAlert(successMessage);
router.push({ name: 'macros_wrapper' });
} catch (error) {
useAlert(t('MACROS.ERROR'));
}
};
</script>
@@ -128,11 +125,12 @@ export default {
<div class="flex flex-col flex-1 h-full overflow-auto">
<woot-loading-state
v-if="uiFlags.isFetchingItem"
:message="$t('MACROS.EDITOR.LOADING')"
:message="t('MACROS.EDITOR.LOADING')"
/>
<MacroForm
v-if="macro && !uiFlags.isFetchingItem"
:macro-data.sync="macro"
:macro-data="macro"
@update:macro-data="macro = $event"
@submit="saveMacro"
/>
</div>
@@ -1,84 +1,80 @@
<script>
import { inject } from 'vue';
<script setup>
import { computed, inject } from 'vue';
import { useMacros } from 'dashboard/composables/useMacros';
import { useI18n } from 'dashboard/composables/useI18n';
import ActionInput from 'dashboard/components/widgets/AutomationActionInput.vue';
import macrosMixin from 'dashboard/mixins/macrosMixin';
export default {
components: {
ActionInput,
const props = defineProps({
singleNode: {
type: Boolean,
default: false,
},
mixins: [macrosMixin],
props: {
singleNode: {
type: Boolean,
default: false,
},
value: {
type: Object,
default: () => ({}),
},
errorKey: {
type: String,
default: '',
},
fileName: {
type: String,
default: '',
},
value: {
type: Object,
default: () => ({}),
},
setup() {
const macroActionTypes = inject('macroActionTypes');
return { macroActionTypes };
errorKey: {
type: String,
default: '',
},
computed: {
actionData: {
get() {
return this.value;
},
set(value) {
this.$emit('input', value);
},
},
errorMessage() {
if (!this.errorKey) return '';
fileName: {
type: String,
default: '',
},
});
return this.$t(`MACROS.ERRORS.${this.errorKey}`);
},
showActionInput() {
if (
this.actionData.action_name === 'send_email_to_team' ||
this.actionData.action_name === 'send_message'
)
return false;
const type = this.macroActionTypes.find(
action => action.key === this.actionData.action_name
).inputType;
return !!type;
},
},
methods: {
dropdownValues() {
return this.getDropdownValues(this.value.action_name, this.$store);
},
},
const emit = defineEmits(['input', 'resetAction', 'deleteNode']);
const { t } = useI18n();
const macroActionTypes = inject('macroActionTypes');
const { getMacroDropdownValues } = useMacros();
const actionData = computed({
get: () => props.value,
set: value => emit('input', value),
});
const errorMessage = computed(() => {
if (!props.errorKey) return '';
return t(`MACROS.ERRORS.${props.errorKey}`);
});
const showActionInput = computed(() => {
if (
actionData.value.action_name === 'send_email_to_team' ||
actionData.value.action_name === 'send_message'
)
return false;
const type = macroActionTypes.find(
action => action.key === actionData.value.action_name
).inputType;
return !!type;
});
const dropdownValues = () => {
return getMacroDropdownValues(props.value.action_name);
};
</script>
<template>
<div class="macro__node-action-container">
<div class="relative flex items-center w-full min-w-0 basis-full">
<woot-button
v-if="!singleNode"
size="small"
variant="clear"
color-scheme="secondary"
icon="navigation"
class="macros__node-drag-handle"
class="absolute cursor-move -left-8 macros__node-drag-handle"
/>
<div
class="macro__node-action-item"
:class="{
'has-error': errorKey,
}"
class="flex-grow p-2 mr-2 rounded-md shadow-sm"
:class="
errorKey
? 'bg-red-50 animate-shake dark:bg-red-800'
: 'bg-white dark:bg-slate-700'
"
>
<ActionInput
v-model="actionData"
@@ -103,39 +99,3 @@ export default {
/>
</div>
</template>
<style scoped lang="scss">
.macros__node-drag-handle {
@apply cursor-move -left-8 absolute;
}
.macro__node-action-container {
@apply w-full min-w-0 basis-full items-center flex relative;
.macro__node-action-item {
@apply flex-grow bg-white dark:bg-slate-700 p-2 mr-2 rounded-md shadow-sm;
&.has-error {
animation: shake 0.3s ease-in-out 0s 2;
@apply bg-red-50 dark:bg-red-800;
}
}
}
@keyframes shake {
0% {
transform: translateX(0);
}
25% {
transform: translateX(0.234375rem);
}
50% {
transform: translateX(-0.234375rem);
}
75% {
transform: translateX(0.234375rem);
}
100% {
transform: translateX(0);
}
}
</style>
@@ -1,59 +1,63 @@
<script>
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
export default {
name: 'WootDropdownMenu',
componentName: 'WootDropdownMenu',
<script setup>
import { ref } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
mixins: [keyboardEventListenerMixins],
props: {
placement: {
type: String,
default: 'top',
},
defineProps({
placement: {
type: String,
default: 'top',
},
methods: {
dropdownMenuButtons() {
return this.$refs.dropdownMenu.querySelectorAll(
'ul.dropdown li.dropdown-menu__item .button'
);
},
getActiveButtonIndex(menuButtons) {
const focusedButton = this.$refs.dropdownMenu.querySelector(
'ul.dropdown li.dropdown-menu__item .button:focus'
);
return Array.from(menuButtons).indexOf(focusedButton);
},
getKeyboardEvents() {
const menuButtons = this.dropdownMenuButtons();
return {
ArrowUp: () => this.focusPreviousButton(menuButtons),
ArrowDown: () => this.focusNextButton(menuButtons),
};
},
focusPreviousButton(menuButtons) {
const activeIndex = this.getActiveButtonIndex(menuButtons);
const newIndex =
activeIndex >= 1 ? activeIndex - 1 : menuButtons.length - 1;
this.focusButton(menuButtons, newIndex);
},
focusNextButton(menuButtons) {
const activeIndex = this.getActiveButtonIndex(menuButtons);
const newIndex =
activeIndex === menuButtons.length - 1 ? 0 : activeIndex + 1;
this.focusButton(menuButtons, newIndex);
},
focusButton(menuButtons, newIndex) {
if (menuButtons.length === 0) return;
menuButtons[newIndex].focus();
},
});
const dropdownMenuRef = ref(null);
const dropdownMenuButtons = () => {
return dropdownMenuRef.value.querySelectorAll(
'ul.dropdown li.dropdown-menu__item .button'
);
};
const getActiveButtonIndex = menuButtons => {
const focusedButton = dropdownMenuRef.value.querySelector(
'ul.dropdown li.dropdown-menu__item .button:focus'
);
return Array.from(menuButtons).indexOf(focusedButton);
};
const focusButton = (menuButtons, newIndex) => {
if (menuButtons.length === 0) return;
menuButtons[newIndex].focus();
};
const focusPreviousButton = menuButtons => {
const activeIndex = getActiveButtonIndex(menuButtons);
const newIndex = activeIndex >= 1 ? activeIndex - 1 : menuButtons.length - 1;
focusButton(menuButtons, newIndex);
};
const focusNextButton = menuButtons => {
const activeIndex = getActiveButtonIndex(menuButtons);
const newIndex = activeIndex === menuButtons.length - 1 ? 0 : activeIndex + 1;
focusButton(menuButtons, newIndex);
};
const keyboardEvents = {
ArrowUp: {
action: () => focusPreviousButton(dropdownMenuButtons()),
allowOnFocusedInput: true,
},
ArrowDown: {
action: () => focusNextButton(dropdownMenuButtons()),
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents, dropdownMenuRef);
</script>
<template>
<ul
ref="dropdownMenu"
ref="dropdownMenuRef"
class="dropdown menu vertical"
:class="[placement && `dropdown--${placement}`]"
>
@@ -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)
);
};
@@ -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);
});
});
@@ -1,6 +1,5 @@
<script>
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import PlaygroundHeader from '../../components/playground/Header.vue';
import UserMessage from '../../components/playground/UserMessage.vue';
import BotMessage from '../../components/playground/BotMessage.vue';
@@ -13,7 +12,7 @@ export default {
BotMessage,
TypingIndicator,
},
mixins: [messageFormatterMixin, keyboardEventListenerMixins],
mixins: [messageFormatterMixin],
props: {
componentData: {
type: Object,
@@ -35,16 +34,6 @@ export default {
this.focusInput();
},
methods: {
getKeyboardEvents() {
return {
'$mod+Enter': {
action: () => {
this.onMessageSend();
},
allowOnFocusedInput: true,
},
};
},
focusInput() {
this.$refs.messageInput.focus();
},
@@ -133,6 +122,8 @@ export default {
placeholder="Type a message... [CMD/CTRL + Enter to send]"
autofocus
autocomplete="off"
@keydown.meta.enter="onMessageSend"
@keydown.ctrl.enter="onMessageSend"
/>
</div>
</section>
@@ -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'],
+4
View File
@@ -54,6 +54,10 @@ module Linear::Queries
title
description
identifier
state {
name
color
}
}
}
}
+7
View File
@@ -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: [