chore: Remove vue-multiselect and migrate to next components (#13506)

# Pull Request Template

## Description

This PR includes:
1. Removes multiselect usage from the Merge Contact modal (Conversation
sidebar) and replaces it with the existing component used on the Contact
Details page.
2. Replaces legacy form and multiselect elements in Add and Edit
automations flows with next components.**(Also check Macros)**
3. Replace multiselect with ComboBox in contact form country field.
4. Replace multiselect with TagInput in create/edit attribute form.
5. Replace multiselect with TagInput for agent selection in inbox
creation.
6. Replace multiselect with ComboBox in Facebook channel page selection

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

**Screenshots**

1. **Merge modal**
<img width="741" height="449" alt="image"
src="https://github.com/user-attachments/assets/a05a96ec-0692-4d94-9e27-d3e85fd143e4"
/>
<img width="741" height="449" alt="image"
src="https://github.com/user-attachments/assets/fc1dc977-689d-4440-869d-2124e4ca9083"
/>

2. **Automations**
<img width="849" height="1089" alt="image"
src="https://github.com/user-attachments/assets/b0155f06-ab21-4f90-a2c8-5bfbd97b08f7"
/>
<img width="813" height="879" alt="image"
src="https://github.com/user-attachments/assets/0921ac4a-88f5-49ac-a776-cc02941b479c"
/>
<img width="849" height="826" alt="image"
src="https://github.com/user-attachments/assets/44358dae-a076-4e10-b7ba-a4e40ccd817f"
/>

3. **Country field**
<img width="462" height="483" alt="image"
src="https://github.com/user-attachments/assets/d5db9aa1-b859-4327-9960-957d7091678f"
/>

4. **Add/Edit attribute form**
<img width="619" height="646" alt="image"
src="https://github.com/user-attachments/assets/6ab2ea94-73e5-40b8-ac29-399c0543fa7b"
/>
<img width="619" height="646" alt="image"
src="https://github.com/user-attachments/assets/b4c5bb0e-baa0-4ef7-a6a2-adb0f0203243"
/>
<img width="635" height="731" alt="image"
src="https://github.com/user-attachments/assets/74890c80-b213-4567-bf5f-4789dda39d2d"
/>

5. **Agent selection in inbox creation**
<img width="635" height="534" alt="image"
src="https://github.com/user-attachments/assets/0003bad1-1a75-4f20-b014-587e1c19a620"
/>
<img width="809" height="602" alt="image"
src="https://github.com/user-attachments/assets/5e7ab635-7340-420a-a191-e6cd49c02704"
/>

7. **Facebook channel page selection**
<img width="597" height="444" alt="image"
src="https://github.com/user-attachments/assets/f7ec8d84-0a7d-4bc6-92a1-a1365178e319"
/>
<img width="597" height="444" alt="image"
src="https://github.com/user-attachments/assets/d0596c4d-94c1-4544-8b50-e7103ff207a6"
/>
<img width="597" height="444" alt="image"
src="https://github.com/user-attachments/assets/be097921-011b-4dbe-b5f1-5d1306e25349"
/>



## 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

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
This commit is contained in:
Sivin Varghese 2026-02-17 16:40:12 +05:30 committed by GitHub
parent 138840a23f
commit 229f56d6e3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 1209 additions and 1795 deletions

View File

@ -68,8 +68,7 @@ const hasActiveSegments = computed(
); );
const activeSegmentName = computed(() => props.activeSegment?.name); const activeSegmentName = computed(() => props.activeSegment?.name);
const openCreateNewContactDialog = async () => { const openCreateNewContactDialog = () => {
await createNewContactDialogRef.value?.contactsFormRef.resetValidation();
createNewContactDialogRef.value?.dialogRef.open(); createNewContactDialogRef.value?.dialogRef.open();
}; };
const openContactImportDialog = () => const openContactImportDialog = () =>

View File

@ -95,6 +95,7 @@ const inputClass = computed(() => {
:show-dropdown="showCcEmailsDropdown" :show-dropdown="showCcEmailsDropdown"
:is-loading="isLoading" :is-loading="isLoading"
type="email" type="email"
allow-create
class="flex-1 min-h-7" class="flex-1 min-h-7"
@focus="emit('updateDropdown', 'cc', true)" @focus="emit('updateDropdown', 'cc', true)"
@input="emit('searchCcEmails', $event)" @input="emit('searchCcEmails', $event)"
@ -127,6 +128,7 @@ const inputClass = computed(() => {
:show-dropdown="showBccEmailsDropdown" :show-dropdown="showBccEmailsDropdown"
:is-loading="isLoading" :is-loading="isLoading"
type="email" type="email"
allow-create
class="flex-1 min-h-7" class="flex-1 min-h-7"
focus-on-mount focus-on-mount
@focus="emit('updateDropdown', 'bcc', true)" @focus="emit('updateDropdown', 'bcc', true)"

View File

@ -56,8 +56,13 @@ const selectedLabel = computed(() => {
}); });
const selectOption = option => { const selectOption = option => {
selectedValue.value = option.value; if (selectedValue.value === option.value) {
emit('update:modelValue', option.value); selectedValue.value = '';
emit('update:modelValue', '');
} else {
selectedValue.value = option.value;
emit('update:modelValue', option.value);
}
open.value = false; open.value = false;
search.value = ''; search.value = '';
}; };

View File

@ -53,6 +53,11 @@ const props = defineProps({
default: 'lg', default: 'lg',
validator: value => ['3xl', '2xl', 'xl', 'lg', 'md', 'sm'].includes(value), validator: value => ['3xl', '2xl', 'xl', 'lg', 'md', 'sm'].includes(value),
}, },
position: {
type: String,
default: 'center',
validator: value => ['center', 'top'].includes(value),
},
}); });
const emit = defineEmits(['confirm', 'close']); const emit = defineEmits(['confirm', 'close']);
@ -61,6 +66,7 @@ const { t } = useI18n();
const dialogRef = ref(null); const dialogRef = ref(null);
const dialogContentRef = ref(null); const dialogContentRef = ref(null);
const isOpen = ref(false);
const maxWidthClass = computed(() => { const maxWidthClass = computed(() => {
const classesMap = { const classesMap = {
@ -75,13 +81,19 @@ const maxWidthClass = computed(() => {
return classesMap[props.width] ?? 'max-w-md'; return classesMap[props.width] ?? 'max-w-md';
}); });
const positionClass = computed(() =>
props.position === 'top' ? 'dialog-position-top' : ''
);
const open = () => { const open = () => {
isOpen.value = true;
dialogRef.value?.showModal(); dialogRef.value?.showModal();
}; };
const close = () => { const close = () => {
emit('close'); emit('close');
dialogRef.value?.close(); dialogRef.value?.close();
isOpen.value = false;
}; };
const confirm = () => { const confirm = () => {
@ -98,6 +110,7 @@ defineExpose({ open, close });
class="w-full transition-all duration-300 ease-in-out shadow-xl rounded-xl" class="w-full transition-all duration-300 ease-in-out shadow-xl rounded-xl"
:class="[ :class="[
maxWidthClass, maxWidthClass,
positionClass,
overflowYAuto ? 'overflow-y-auto' : 'overflow-visible', overflowYAuto ? 'overflow-y-auto' : 'overflow-visible',
]" ]"
@close="close" @close="close"
@ -105,7 +118,7 @@ defineExpose({ open, close });
<OnClickOutside @trigger="close"> <OnClickOutside @trigger="close">
<form <form
ref="dialogContentRef" ref="dialogContentRef"
class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-left align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl" class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-start align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
@submit.prevent="confirm" @submit.prevent="confirm"
@click.stop @click.stop
> >
@ -119,7 +132,7 @@ defineExpose({ open, close });
</p> </p>
</slot> </slot>
</div> </div>
<slot /> <slot v-if="isOpen" />
<!-- Dialog content will be injected here --> <!-- Dialog content will be injected here -->
<slot name="footer"> <slot name="footer">
<div <div
@ -156,4 +169,9 @@ defineExpose({ open, close });
dialog::backdrop { dialog::backdrop {
@apply bg-n-alpha-black1 backdrop-blur-[4px]; @apply bg-n-alpha-black1 backdrop-blur-[4px];
} }
.dialog-position-top {
margin-top: clamp(2rem, 5vh, 5rem);
margin-bottom: auto;
}
</style> </style>

View File

@ -4,6 +4,10 @@ defineProps({
type: String, type: String,
default: '', default: '',
}, },
height: {
type: String,
default: 'max-h-96',
},
}); });
</script> </script>
@ -15,7 +19,10 @@ defineProps({
> >
{{ title }} {{ title }}
</div> </div>
<ul class="gap-2 grid reset-base list-none px-2 max-h-96 overflow-y-auto"> <ul
class="gap-2 grid reset-base list-none px-2 overflow-y-auto"
:class="height"
>
<slot /> <slot />
</ul> </ul>
</div> </div>

View File

@ -50,12 +50,12 @@ const currentFilter = computed(() =>
); );
const getOperator = (filter, selectedOperator) => { const getOperator = (filter, selectedOperator) => {
const operatorFromOptions = filter.filterOperators.find( const operatorFromOptions = filter?.filterOperators?.find(
operator => operator.value === selectedOperator operator => operator.value === selectedOperator
); );
if (!operatorFromOptions) { if (!operatorFromOptions) {
return filter.filterOperators[0]; return filter?.filterOperators?.[0];
} }
return operatorFromOptions; return operatorFromOptions;
@ -138,7 +138,11 @@ const validate = () => {
return !validationError.value; return !validationError.value;
}; };
defineExpose({ validate }); const resetValidation = () => {
showErrors.value = false;
};
defineExpose({ validate, resetValidation });
</script> </script>
<template> <template>
@ -166,18 +170,20 @@ defineExpose({ validate });
<FilterSelect <FilterSelect
v-model="filterOperator" v-model="filterOperator"
variant="ghost" variant="ghost"
:options="currentFilter.filterOperators" :options="currentFilter?.filterOperators"
/> />
<template v-if="currentOperator.hasInput"> <template v-if="currentOperator?.hasInput">
<MultiSelect <MultiSelect
v-if="inputType === 'multiSelect'" v-if="inputType === 'multiSelect'"
v-model="values" v-model="values"
:options="currentFilter.options" :options="currentFilter.options"
dropdown-max-height="max-h-72"
/> />
<SingleSelect <SingleSelect
v-else-if="inputType === 'searchSelect'" v-else-if="inputType === 'searchSelect'"
v-model="values" v-model="values"
:options="currentFilter.options" :options="currentFilter.options"
dropdown-max-height="max-h-64"
/> />
<SingleSelect <SingleSelect
v-else-if="inputType === 'booleanSelect'" v-else-if="inputType === 'booleanSelect'"

View File

@ -45,7 +45,7 @@ const { height } = useWindowSize();
const { height: dropdownHeight } = useElementBounding(dropdownRef); const { height: dropdownHeight } = useElementBounding(dropdownRef);
const selectedOption = computed(() => { const selectedOption = computed(() => {
return props.options.find(o => o.value === selected.value) || {}; return props.options?.find(o => o.value === selected.value) || {};
}); });
const iconToRender = computed(() => { const iconToRender = computed(() => {
@ -87,18 +87,25 @@ const updateSelected = newValue => {
</template> </template>
<DropdownBody <DropdownBody
ref="dropdownRef" ref="dropdownRef"
class="min-w-48 z-50" class="min-w-56 z-50"
:class="dropdownPosition" :class="dropdownPosition"
strong strong
> >
<DropdownSection class="[&>ul]:max-h-80"> <DropdownSection class="[&>ul]:max-h-72">
<DropdownItem <template v-for="option in options" :key="option.value">
v-for="option in options" <li
:key="option.value" v-if="option.disabled"
:label="option.label" class="px-2 py-1.5 text-xs font-medium text-n-slate-10 select-none"
:icon="option.icon" >
@click="updateSelected(option.value)" {{ option.label }}
/> </li>
<DropdownItem
v-else
:label="option.label"
:icon="option.icon"
@click="updateSelected(option.value)"
/>
</template>
</DropdownSection> </DropdownSection>
</DropdownBody> </DropdownBody>
</DropdownContainer> </DropdownContainer>

View File

@ -8,7 +8,7 @@ import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue'; import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue'; import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
const { options, maxChips } = defineProps({ const { options, maxChips, dropdownMaxHeight } = defineProps({
options: { options: {
type: Array, type: Array,
required: true, required: true,
@ -17,6 +17,10 @@ const { options, maxChips } = defineProps({
type: Number, type: Number,
default: 3, default: 3,
}, },
dropdownMaxHeight: {
type: String,
default: 'max-h-80',
},
}); });
const { t } = useI18n(); const { t } = useI18n();
@ -123,7 +127,7 @@ const toggleOption = option => {
</Button> </Button>
</template> </template>
<DropdownBody class="top-0 min-w-48 z-50" strong> <DropdownBody class="top-0 min-w-48 z-50" strong>
<DropdownSection class="[&>ul]:max-h-80"> <DropdownSection :height="dropdownMaxHeight">
<DropdownItem <DropdownItem
v-for="option in options" v-for="option in options"
:key="option.id" :key="option.id"

View File

@ -12,10 +12,12 @@ import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
const { const {
options, options,
disableSearch, disableSearch,
disableDeselect,
placeholderIcon, placeholderIcon,
placeholder, placeholder,
placeholderTrailingIcon, placeholderTrailingIcon,
searchPlaceholder, searchPlaceholder,
dropdownMaxHeight,
} = defineProps({ } = defineProps({
options: { options: {
type: Array, type: Array,
@ -41,6 +43,14 @@ const {
type: String, type: String,
default: '', default: '',
}, },
dropdownMaxHeight: {
type: String,
default: 'max-h-80',
},
disableDeselect: {
type: Boolean,
default: false,
},
}); });
const { t } = useI18n(); const { t } = useI18n();
@ -63,6 +73,8 @@ const selectedItem = computed(() => {
const optionToSearch = Array.isArray(selected.value) const optionToSearch = Array.isArray(selected.value)
? selected.value[0] ? selected.value[0]
: selected.value; : selected.value;
if (!optionToSearch) return null;
// extract the selected item from the options array // extract the selected item from the options array
// this ensures that options like icon is also included // this ensures that options like icon is also included
return options.find(option => option.id === optionToSearch.id); return options.find(option => option.id === optionToSearch.id);
@ -77,7 +89,7 @@ const toggleSelected = option => {
}; };
if (selected.value && selected.value.id === optionToToggle.id) { if (selected.value && selected.value.id === optionToToggle.id) {
selected.value = null; if (!disableDeselect) selected.value = null;
} else { } else {
selected.value = optionToToggle; selected.value = optionToToggle;
} }
@ -124,7 +136,7 @@ const toggleSelected = option => {
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')" :placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
/> />
</div> </div>
<DropdownSection class="[&>ul]:max-h-80"> <DropdownSection :height="dropdownMaxHeight">
<template v-if="searchResults.length"> <template v-if="searchResults.length">
<DropdownItem <DropdownItem
v-for="option in searchResults" v-for="option in searchResults"

View File

@ -36,6 +36,11 @@ const props = defineProps({
}, },
focusOnMount: { type: Boolean, default: false }, focusOnMount: { type: Boolean, default: false },
allowCreate: { type: Boolean, default: false }, allowCreate: { type: Boolean, default: false },
// Skip label-based dedup when the consumer already filters menuItems by ID.
// Prevents removing all same-name items when one is selected (e.g. duplicate agent names).
skipLabelDedup: { type: Boolean, default: false },
// When false, the dropdown won't auto-open on mount; it opens only on click/focus.
autoOpenDropdown: { type: Boolean, default: true },
}); });
const emit = defineEmits([ const emit = defineEmits([
@ -56,7 +61,7 @@ const modelValue = defineModel({
const tagInputRef = ref(null); const tagInputRef = ref(null);
const tags = ref(props.modelValue); const tags = ref(props.modelValue);
const newTag = ref(''); const newTag = ref('');
const isFocused = ref(true); const isFocused = ref(props.autoOpenDropdown);
const rules = computed(() => getValidationRules(props.type)); const rules = computed(() => getValidationRules(props.type));
const v$ = useVuelidate(rules, { newTag }); const v$ = useVuelidate(rules, { newTag });
@ -74,11 +79,11 @@ const showInput = computed(() =>
const showDropdownMenu = computed(() => const showDropdownMenu = computed(() =>
props.mode === MODE.SINGLE && tags.value.length >= 1 props.mode === MODE.SINGLE && tags.value.length >= 1
? false ? false
: props.showDropdown : props.showDropdown && isFocused.value
); );
const filteredMenuItems = computed(() => const filteredMenuItems = computed(() => {
buildTagMenuItems({ const items = buildTagMenuItems({
mode: props.mode, mode: props.mode,
tags: tags.value, tags: tags.value,
menuItems: props.menuItems, menuItems: props.menuItems,
@ -86,8 +91,14 @@ const filteredMenuItems = computed(() =>
isLoading: props.isLoading, isLoading: props.isLoading,
type: props.type, type: props.type,
isNewTagInValidType: isNewTagInValidType.value, isNewTagInValidType: isNewTagInValidType.value,
}) allowCreate: props.allowCreate,
); skipLabelDedup: props.skipLabelDedup,
});
if (props.type !== INPUT_TYPES.TEXT) return items;
const query = newTag.value?.trim()?.toLowerCase();
if (!query) return items;
return items.filter(item => item.label?.toLowerCase().includes(query));
});
const emitDataOnAdd = value => { const emitDataOnAdd = value => {
const matchingMenuItem = findMatchingMenuItem(props.menuItems, value); const matchingMenuItem = findMatchingMenuItem(props.menuItems, value);
@ -112,10 +123,13 @@ const addTag = async () => {
return; return;
} }
if ( const isValidatedType = [INPUT_TYPES.EMAIL, INPUT_TYPES.TEL].includes(
[INPUT_TYPES.EMAIL, INPUT_TYPES.TEL].includes(props.type) || props.type
props.allowCreate );
) {
if (!isValidatedType && !props.allowCreate && props.showDropdown) return;
if (isValidatedType || props.allowCreate) {
if (!(await v$.value.$validate())) return; if (!(await v$.value.$validate())) return;
emitDataOnAdd(trimmedTag); emitDataOnAdd(trimmedTag);
} }
@ -125,28 +139,31 @@ const addTag = async () => {
const removeTag = index => { const removeTag = index => {
tags.value.splice(index, 1); tags.value.splice(index, 1);
modelValue.value = tags.value; modelValue.value = tags.value;
emit('remove'); emit('remove', index);
}; };
const handleDropdownAction = async ({ const handleDropdownAction = async ({
email: emailAddress, email: emailAddress,
phoneNumber, phoneNumber,
label,
...rest ...rest
}) => { }) => {
if (props.mode === MODE.SINGLE && tags.value.length >= 1) return; if (props.mode === MODE.SINGLE && tags.value.length >= 1) return;
if (!props.showDropdown) return; if (!props.showDropdown) return;
const isEmail = props.type === 'email'; const isEmail = props.type === INPUT_TYPES.EMAIL;
newTag.value = isEmail ? emailAddress : phoneNumber; const tagValue = isEmail ? emailAddress : phoneNumber || label;
if (!(await v$.value.$validate())) return; if (isEmail || props.type === INPUT_TYPES.TEL) {
newTag.value = tagValue;
if (!(await v$.value.$validate())) return;
}
const payload = isEmail emit(
? { email: emailAddress, ...rest } 'add',
: { phoneNumber, ...rest }; isEmail ? { email: emailAddress, ...rest } : { phoneNumber, label, ...rest }
);
emit('add', payload); updateValueAndFocus(tagValue);
updateValueAndFocus(emailAddress);
}; };
const handleFocus = () => { const handleFocus = () => {
@ -163,7 +180,7 @@ const handleKeydown = event => {
}; };
const handleClickOutside = () => { const handleClickOutside = () => {
if (tags.value.length) isFocused.value = false; isFocused.value = false;
emit('onClickOutside'); emit('onClickOutside');
}; };

View File

@ -261,6 +261,71 @@ describe('tagInputHelper', () => {
}); });
expect(result).toEqual(menuItems); expect(result).toEqual(menuItems);
}); });
it('does not create suggestion when allowCreate is false', () => {
const result = buildTagMenuItems({
...baseParams,
type: INPUT_TYPES.EMAIL,
newTag: 'test@example.com',
allowCreate: false,
});
expect(result).toEqual([]);
});
it('still returns available menu items when allowCreate is false', () => {
const menuItems = [
{ label: 'Agent 1', value: '1' },
{ label: 'Agent 2', value: '2' },
];
const result = buildTagMenuItems({
...baseParams,
menuItems,
newTag: 'Agent',
allowCreate: false,
});
expect(result).toEqual(menuItems);
});
it('creates new item when allowCreate is true (default)', () => {
const result = buildTagMenuItems({
...baseParams,
type: INPUT_TYPES.EMAIL,
newTag: 'test@example.com',
allowCreate: true,
});
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
label: 'test@example.com',
action: 'create',
});
});
it('skips label dedup when skipLabelDedup is true', () => {
const menuItems = [
{ label: 'HDMA', value: 1 },
{ label: 'HDMA', value: 2 },
];
const result = buildTagMenuItems({
...baseParams,
tags: ['HDMA'],
menuItems,
skipLabelDedup: true,
});
expect(result).toEqual(menuItems);
});
it('filters by label when skipLabelDedup is false (default)', () => {
const menuItems = [
{ label: 'HDMA', value: 1 },
{ label: 'HDMA', value: 2 },
];
const result = buildTagMenuItems({
...baseParams,
tags: ['HDMA'],
menuItems,
});
expect(result).toEqual([]);
});
}); });
describe('canAddTag', () => { describe('canAddTag', () => {

View File

@ -84,25 +84,29 @@ export const buildTagMenuItems = ({
isLoading, isLoading,
type, type,
isNewTagInValidType, isNewTagInValidType,
allowCreate = true,
skipLabelDedup = false,
}) => { }) => {
if (mode === MODE.SINGLE && tags.length >= 1) return []; if (mode === MODE.SINGLE && tags.length >= 1) return [];
const availableMenuItems = menuItems.filter( const availableMenuItems = skipLabelDedup
item => !tags.includes(item.label) ? menuItems
); : menuItems.filter(item => !tags.includes(item.label));
// Show typed value as suggestion only if: // Show typed value as suggestion only if:
// 1. There's a value being typed // 1. There's a value being typed
// 2. The value isn't already in the tags // 2. The value isn't already in the tags
// 3. Validation passes (email/phone) and There are no menu items available // 3. Validation passes (email/phone) and There are no menu items available
// 4. allowCreate is enabled
const trimmedNewTag = newTag?.trim(); const trimmedNewTag = newTag?.trim();
const shouldShowTypedValue = const shouldShowCreateSuggestion =
allowCreate &&
trimmedNewTag && trimmedNewTag &&
!tags.includes(trimmedNewTag) && !tags.includes(trimmedNewTag) &&
!isLoading && !isLoading &&
!availableMenuItems.length; !availableMenuItems.length;
if (shouldShowTypedValue) { if (shouldShowCreateSuggestion) {
const { isValid, formattedValue } = validateAndFormatNewTag( const { isValid, formattedValue } = validateAndFormatNewTag(
trimmedNewTag, trimmedNewTag,
type, type,

View File

@ -3,6 +3,9 @@ import AutomationActionTeamMessageInput from './AutomationActionTeamMessageInput
import AutomationActionFileInput from './AutomationFileInput.vue'; import AutomationActionFileInput from './AutomationFileInput.vue';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue'; import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import SingleSelect from 'dashboard/components-next/filter/inputs/SingleSelect.vue';
import MultiSelect from 'dashboard/components-next/filter/inputs/MultiSelect.vue';
import NextInput from 'dashboard/components-next/input/Input.vue';
export default { export default {
components: { components: {
@ -10,6 +13,9 @@ export default {
AutomationActionFileInput, AutomationActionFileInput,
WootMessageEditor, WootMessageEditor,
NextButton, NextButton,
SingleSelect,
MultiSelect,
NextInput,
}, },
props: { props: {
modelValue: { modelValue: {
@ -40,6 +46,10 @@ export default {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
dropdownMaxHeight: {
type: String,
default: 'max-h-80',
},
}, },
emits: ['update:modelValue', 'input', 'removeAction', 'resetAction'], emits: ['update:modelValue', 'input', 'removeAction', 'resetAction'],
computed: { computed: {
@ -69,11 +79,21 @@ export default {
return this.actionTypes.find(action => action.key === this.action_name) return this.actionTypes.find(action => action.key === this.action_name)
.inputType; .inputType;
}, },
actionInputStyles() { actionNameAsSelectModel: {
return { get() {
'has-error': this.errorMessage, if (!this.action_name) return null;
'is-a-macro': this.isMacro, const found = this.actionTypes.find(a => a.key === this.action_name);
}; return found ? { id: found.key, name: found.label } : null;
},
set(value) {
this.action_name = value?.id || value;
},
},
actionTypesAsOptions() {
return this.actionTypes.map(a => ({ id: a.key, name: a.label }));
},
isVerticalLayout() {
return ['team_message', 'textarea'].includes(this.inputType);
}, },
castMessageVmodel: { castMessageVmodel: {
get() { get() {
@ -94,203 +114,89 @@ export default {
resetAction() { resetAction() {
this.$emit('resetAction'); this.$emit('resetAction');
}, },
onActionNameChange(value) {
this.actionNameAsSelectModel = value;
this.resetAction();
},
}, },
}; };
</script> </script>
<template> <template>
<div class="filter" :class="actionInputStyles"> <li class="list-none py-2 first:pt-0 last:pb-0">
<div class="filter-inputs"> <div
<select class="flex flex-col gap-2"
v-model="action_name" :class="{ 'animate-wiggle': errorMessage }"
class="action__question" >
:class="{ 'full-width': !showActionInput }" <div class="flex items-center gap-2">
@change="resetAction()" <SingleSelect
> :model-value="actionNameAsSelectModel"
<option :options="actionTypesAsOptions"
v-for="attribute in actionTypes" :dropdown-max-height="dropdownMaxHeight"
:key="attribute.key" disable-deselect
:value="attribute.key" class="flex-shrink-0"
> @update:model-value="onActionNameChange"
{{ attribute.label }} />
</option> <template v-if="showActionInput && !isVerticalLayout">
</select> <SingleSelect
<div v-if="showActionInput" class="filter__answer--wrap">
<div v-if="inputType" class="w-full">
<div
v-if="inputType === 'search_select'" v-if="inputType === 'search_select'"
class="multiselect-wrap--small" v-model="action_params"
> :options="dropdownValues"
<multiselect :dropdown-max-height="dropdownMaxHeight"
v-model="action_params" />
track-by="id" <MultiSelect
label="name"
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label=""
:max-height="160"
:options="dropdownValues"
:allow-empty="false"
:option-height="104"
>
<template #noOptions>
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
</template>
</multiselect>
</div>
<div
v-else-if="inputType === 'multi_select'" v-else-if="inputType === 'multi_select'"
class="multiselect-wrap--small" v-model="action_params"
> :options="dropdownValues"
<multiselect :dropdown-max-height="dropdownMaxHeight"
v-model="action_params" />
track-by="id" <NextInput
label="name"
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
multiple
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label=""
:max-height="160"
:options="dropdownValues"
:allow-empty="false"
:option-height="104"
>
<template #noOptions>
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
</template>
</multiselect>
</div>
<input
v-else-if="inputType === 'email'" v-else-if="inputType === 'email'"
v-model="action_params" v-model="action_params"
type="email" type="email"
class="answer--text-input" size="sm"
:placeholder="$t('AUTOMATION.ACTION.EMAIL_INPUT_PLACEHOLDER')" :placeholder="$t('AUTOMATION.ACTION.EMAIL_INPUT_PLACEHOLDER')"
/> />
<input <NextInput
v-else-if="inputType === 'url'" v-else-if="inputType === 'url'"
v-model="action_params" v-model="action_params"
type="url" type="url"
class="answer--text-input" size="sm"
:placeholder="$t('AUTOMATION.ACTION.URL_INPUT_PLACEHOLDER')" :placeholder="$t('AUTOMATION.ACTION.URL_INPUT_PLACEHOLDER')"
/> />
<AutomationActionFileInput <AutomationActionFileInput
v-if="inputType === 'attachment'" v-else-if="inputType === 'attachment'"
v-model="action_params" v-model="action_params"
:initial-file-name="initialFileName" :initial-file-name="initialFileName"
/> />
</div> </template>
<NextButton
v-if="!isMacro"
sm
solid
slate
icon="i-lucide-trash"
class="flex-shrink-0"
@click="removeAction"
/>
</div> </div>
<NextButton <AutomationActionTeamMessageInput
v-if="!isMacro" v-if="inputType === 'team_message'"
icon="i-lucide-x" v-model="action_params"
slate :teams="dropdownValues"
ghost :dropdown-max-height="dropdownMaxHeight"
class="flex-shrink-0" />
@click="removeAction" <WootMessageEditor
v-if="inputType === 'textarea'"
v-model="castMessageVmodel"
rows="4"
enable-variables
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
class="[&_.ProseMirror-menubar]:hidden px-3 py-1 bg-n-alpha-1 rounded-lg outline outline-1 outline-n-weak dark:outline-n-strong"
/> />
</div> </div>
<AutomationActionTeamMessageInput <span v-if="errorMessage" class="text-sm text-n-ruby-11">
v-if="inputType === 'team_message'"
v-model="action_params"
:teams="dropdownValues"
/>
<WootMessageEditor
v-if="inputType === 'textarea'"
v-model="castMessageVmodel"
rows="4"
enable-variables
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
class="action-message"
/>
<p v-if="errorMessage" class="filter-error">
{{ errorMessage }} {{ errorMessage }}
</p> </span>
</div> </li>
</template> </template>
<style lang="scss" scoped>
.filter {
@apply bg-n-background p-2 border border-solid border-n-strong dark:border-n-strong rounded-lg mb-2;
&.is-a-macro {
@apply mb-0 bg-n-background dark:bg-n-solid-1 p-0 border-0 rounded-none;
}
}
.no-margin-bottom {
@apply mb-0;
}
.filter.has-error {
@apply bg-n-ruby-8/20 border-n-ruby-5 dark:border-n-ruby-5;
&.is-a-macro {
@apply bg-transparent;
}
}
.filter-inputs {
@apply flex gap-1;
}
.filter-error {
@apply text-n-ruby-9 dark:text-n-ruby-9 block my-1 mx-0;
}
.action__question,
.filter__operator {
@apply mb-0 mr-1;
}
.action__question {
@apply max-w-[50%];
}
.action__question.full-width {
@apply max-w-full;
}
.filter__answer--wrap {
@apply max-w-[50%] flex-grow mr-1 flex w-full items-center justify-start;
input {
@apply mb-0;
}
}
.filter__answer {
&.answer--text-input {
@apply mb-0;
}
}
.filter__join-operator-wrap {
@apply relative z-20 m-0;
}
.filter__join-operator {
@apply flex items-center justify-center relative my-2.5 mx-0;
.operator__line {
@apply absolute w-full border-b border-solid border-n-weak;
}
.operator__select {
margin-bottom: 0 !important;
@apply relative w-auto;
}
}
.multiselect {
@apply mb-0;
}
.action-message {
@apply mt-2 mx-0 mb-0;
}
// Prosemirror does not have a native way of hiding the menu bar, hence
::v-deep .ProseMirror-menubar {
@apply hidden;
}
</style>

View File

@ -1,8 +1,14 @@
<script> <script>
import MultiSelect from 'dashboard/components-next/filter/inputs/MultiSelect.vue';
export default { export default {
components: {
MultiSelect,
},
props: { props: {
teams: { type: Array, required: true }, teams: { type: Array, required: true },
modelValue: { type: Object, required: true }, modelValue: { type: Object, required: true },
dropdownMaxHeight: { type: String, default: 'max-h-80' },
}, },
emits: ['update:modelValue'], emits: ['update:modelValue'],
data() { data() {
@ -12,9 +18,9 @@ export default {
}; };
}, },
mounted() { mounted() {
const { team_ids: teamIds } = this.modelValue; const { team_ids: teamIds, message } = this.modelValue || {};
this.selectedTeams = teamIds; this.selectedTeams = teamIds || [];
this.message = this.modelValue.message; this.message = message || '';
}, },
methods: { methods: {
updateValue() { updateValue() {
@ -28,37 +34,19 @@ export default {
</script> </script>
<template> <template>
<div> <div class="flex flex-col gap-2">
<div class="multiselect-wrap--small flex flex-col gap-1 mt-1"> <MultiSelect
<multiselect v-model="selectedTeams"
v-model="selectedTeams" :options="teams"
track-by="id" :dropdown-max-height="dropdownMaxHeight"
label="name" @update:model-value="updateValue"
:placeholder="$t('AUTOMATION.ACTION.TEAM_DROPDOWN_PLACEHOLDER')" />
multiple <textarea
selected-label v-model="message"
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')" class="mb-0 !text-sm"
deselect-label="" rows="4"
:max-height="160" :placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
:options="teams" @input="updateValue"
:allow-empty="false" />
@update:model-value="updateValue"
/>
<textarea
v-model="message"
rows="4"
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
@input="updateValue"
/>
</div>
</div> </div>
</template> </template>
<style scoped>
.multiselect {
margin: 0.25rem 0;
}
textarea {
margin-bottom: 0;
}
</style>

View File

@ -79,7 +79,7 @@ input[type='file'] {
@apply hidden; @apply hidden;
} }
.input-wrapper { .input-wrapper {
@apply flex h-9 bg-n-background py-1 px-2 items-center text-xs cursor-pointer rounded-sm border border-dashed border-n-strong; @apply flex h-8 bg-n-background py-1 px-2 items-center text-xs cursor-pointer rounded-lg border border-dashed border-n-strong;
} }
.success-icon { .success-icon {
@apply text-n-teal-9 mr-2; @apply text-n-teal-9 mr-2;

View File

@ -1,305 +0,0 @@
<script>
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
name: 'FilterInput',
components: {
NextButton,
},
props: {
modelValue: {
type: Object,
default: () => {},
},
filterAttributes: {
type: Array,
default: () => [],
},
inputType: {
type: String,
default: 'plain_text',
},
operators: {
type: Array,
default: () => [],
},
dropdownValues: {
type: Array,
default: () => [],
},
showQueryOperator: {
type: Boolean,
default: false,
},
showUserInput: {
type: Boolean,
default: true,
},
groupedFilters: {
type: Boolean,
default: false,
},
filterGroups: {
type: Array,
default: () => [],
},
customAttributeType: {
type: String,
default: '',
},
errorMessage: {
type: String,
default: '',
},
},
emits: ['update:modelValue', 'removeFilter', 'resetFilter'],
computed: {
attributeKey: {
get() {
if (!this.modelValue) return null;
return this.modelValue.attribute_key;
},
set(value) {
const payload = this.modelValue || {};
this.$emit('update:modelValue', { ...payload, attribute_key: value });
},
},
filterOperator: {
get() {
if (!this.modelValue) return null;
return this.modelValue.filter_operator;
},
set(value) {
const payload = this.modelValue || {};
this.$emit('update:modelValue', { ...payload, filter_operator: value });
},
},
values: {
get() {
if (!this.modelValue) return null;
return this.modelValue.values;
},
set(value) {
const payload = this.modelValue || {};
this.$emit('update:modelValue', { ...payload, values: value });
},
},
query_operator: {
get() {
if (!this.modelValue) return null;
return this.modelValue.query_operator;
},
set(value) {
const payload = this.modelValue || {};
this.$emit('update:modelValue', { ...payload, query_operator: value });
},
},
custom_attribute_type: {
get() {
if (!this.customAttributeType) return '';
return this.customAttributeType;
},
set() {
const payload = this.modelValue || {};
this.$emit('update:modelValue', {
...payload,
custom_attribute_type: this.customAttributeType,
});
},
},
},
watch: {
customAttributeType: {
handler(value) {
if (
value === 'conversation_attribute' ||
value === 'contact_attribute'
) {
// eslint-disable-next-line vue/no-mutating-props
this.modelValue.custom_attribute_type = this.customAttributeType;
// eslint-disable-next-line vue/no-mutating-props
} else this.modelValue.custom_attribute_type = '';
},
immediate: true,
},
},
methods: {
removeFilter() {
this.$emit('removeFilter');
},
resetFilter() {
this.$emit('resetFilter');
},
getInputErrorClass(errorMessage) {
return errorMessage
? 'bg-n-ruby-8/20 border-n-ruby-5 dark:border-n-ruby-5'
: 'bg-n-background border-n-weak dark:border-n-weak';
},
},
};
</script>
<!-- eslint-disable vue/no-mutating-props -->
<template>
<div>
<div
class="p-2 border border-solid rounded-lg"
:class="getInputErrorClass(errorMessage)"
>
<div class="flex gap-1">
<select
v-if="groupedFilters"
v-model="attributeKey"
class="max-w-[30%] mb-0 mr-1"
@change="resetFilter()"
>
<optgroup
v-for="(group, i) in filterGroups"
:key="i"
:label="group.name"
>
<option
v-for="attribute in group.attributes"
:key="attribute.key"
:value="attribute.key"
:selected="true"
>
{{ attribute.name }}
</option>
</optgroup>
</select>
<select
v-else
v-model="attributeKey"
class="max-w-[30%] mb-0 mr-1"
@change="resetFilter()"
>
<option
v-for="attribute in filterAttributes"
:key="attribute.key"
:value="attribute.key"
:disabled="attribute.disabled"
>
{{ attribute.name }}
</option>
</select>
<select v-model="filterOperator" class="max-w-[20%] mb-0 mr-1">
<option
v-for="(operator, o) in operators"
:key="o"
:value="operator.value"
>
{{ $t(`FILTER.OPERATOR_LABELS.${operator.value}`) }}
</option>
</select>
<div v-if="showUserInput" class="flex-grow mr-1 filter__answer--wrap">
<div
v-if="inputType === 'multi_select'"
class="multiselect-wrap--small"
>
<multiselect
v-model="values"
track-by="id"
label="name"
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
multiple
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label=""
:max-height="160"
:options="dropdownValues"
:allow-empty="false"
>
<template #noOptions>
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
</template>
</multiselect>
</div>
<div
v-else-if="inputType === 'search_select'"
class="multiselect-wrap--small"
>
<multiselect
v-model="values"
track-by="id"
label="name"
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label=""
:max-height="160"
:options="dropdownValues"
:allow-empty="false"
:option-height="104"
>
<template #noOptions>
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
</template>
</multiselect>
</div>
<div v-else-if="inputType === 'date'" class="multiselect-wrap--small">
<input
v-model="values"
type="date"
:editable="false"
class="!mb-0 datepicker"
/>
</div>
<input
v-else
v-model="values"
type="text"
class="!mb-0"
:placeholder="$t('FILTER.INPUT_PLACEHOLDER')"
/>
</div>
<NextButton
icon="i-lucide-x"
slate
ghost
class="flex-shrink-0"
@click="removeFilter"
/>
</div>
<p v-if="errorMessage" class="filter-error">
{{ errorMessage }}
</p>
</div>
<div
v-if="showQueryOperator"
class="flex items-center justify-center relative my-2.5 mx-0"
>
<hr class="absolute w-full border-b border-solid border-n-weak" />
<select
v-model="query_operator"
class="relative w-auto mb-0 bg-n-background text-n-slate-12 border-n-weak"
>
<option value="and">
{{ $t('FILTER.QUERY_DROPDOWN_LABELS.AND') }}
</option>
<option value="or">
{{ $t('FILTER.QUERY_DROPDOWN_LABELS.OR') }}
</option>
</select>
</div>
</div>
</template>
<style lang="scss" scoped>
.filter__answer--wrap {
input {
@apply bg-n-background mb-0 text-n-slate-12 border-n-weak;
}
}
.filter-error {
@apply text-n-ruby-9 dark:text-n-ruby-9 block my-1 mx-0;
}
.multiselect {
@apply mb-0;
}
</style>

View File

@ -1,4 +1,4 @@
import { ref, computed } from 'vue'; import { ref, reactive, computed } from 'vue';
import { useStoreGetters } from 'dashboard/composables/store'; import { useStoreGetters } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
@ -40,7 +40,7 @@ export function useAutomation(startValue = null) {
} = useAutomationValues(); } = useAutomationValues();
const automation = ref(startValue); const automation = ref(startValue);
const automationTypes = structuredClone(AUTOMATIONS); const automationTypes = reactive(structuredClone(AUTOMATIONS));
const eventName = computed(() => automation.value?.event_name); const eventName = computed(() => automation.value?.event_name);
/** /**
@ -160,14 +160,24 @@ export function useAutomation(startValue = null) {
t('AUTOMATION.CONDITION.CONTACT_CUSTOM_ATTR_LABEL') t('AUTOMATION.CONDITION.CONTACT_CUSTOM_ATTR_LABEL')
); );
const CUSTOM_ATTR_HEADER_KEYS = new Set([
'conversation_custom_attribute',
'contact_custom_attribute',
]);
[ [
'message_created', 'message_created',
'conversation_created', 'conversation_created',
'conversation_updated', 'conversation_updated',
'conversation_opened', 'conversation_opened',
].forEach(eventToUpdate => { ].forEach(eventToUpdate => {
const standardConditions = automationTypes[
eventToUpdate
].conditions.filter(
c => !c.customAttributeType && !CUSTOM_ATTR_HEADER_KEYS.has(c.key)
);
automationTypes[eventToUpdate].conditions = [ automationTypes[eventToUpdate].conditions = [
...automationTypes[eventToUpdate].conditions, ...standardConditions,
...manifestedCustomAttributes, ...manifestedCustomAttributes,
]; ];
}); });

View File

@ -169,19 +169,19 @@ export const getFileName = (action, files = []) => {
export const getDefaultConditions = eventName => { export const getDefaultConditions = eventName => {
if (eventName === 'message_created') { if (eventName === 'message_created') {
return DEFAULT_MESSAGE_CREATED_CONDITION; return structuredClone(DEFAULT_MESSAGE_CREATED_CONDITION);
} }
if ( if (
eventName === 'conversation_opened' || eventName === 'conversation_opened' ||
eventName === 'conversation_resolved' eventName === 'conversation_resolved'
) { ) {
return DEFAULT_CONVERSATION_CONDITION; return structuredClone(DEFAULT_CONVERSATION_CONDITION);
} }
return DEFAULT_OTHER_CONDITION; return structuredClone(DEFAULT_OTHER_CONDITION);
}; };
export const getDefaultActions = () => { export const getDefaultActions = () => {
return DEFAULT_ACTIONS; return structuredClone(DEFAULT_ACTIONS);
}; };
export const filterCustomAttributes = customAttributes => { export const filterCustomAttributes = customAttributes => {

View File

@ -1,91 +1,101 @@
<script> <script setup>
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { useAlert, useTrack } from 'dashboard/composables'; import { useAlert, useTrack } from 'dashboard/composables';
import { useMapGetter } from 'dashboard/composables/store';
import MergeContact from 'dashboard/modules/contact/components/MergeContact.vue'; import MergeContact from 'dashboard/modules/contact/components/MergeContact.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ContactAPI from 'dashboard/api/contacts'; import ContactAPI from 'dashboard/api/contacts';
import { mapGetters } from 'vuex';
import { CONTACTS_EVENTS } from '../../helper/AnalyticsHelper/events'; import { CONTACTS_EVENTS } from '../../helper/AnalyticsHelper/events';
export default { const props = defineProps({
components: { MergeContact }, primaryContact: {
props: { type: Object,
show: { required: true,
type: Boolean,
default: false,
},
primaryContact: {
type: Object,
required: true,
},
},
emits: ['close', 'update:show'],
data() {
return {
isSearching: false,
searchResults: [],
};
},
computed: {
...mapGetters({
uiFlags: 'contacts/getUIFlags',
}),
localShow: {
get() {
return this.show;
},
set(value) {
this.$emit('update:show', value);
},
},
}, },
});
methods: { const emit = defineEmits(['close']);
onClose() {
this.$emit('close');
},
async onContactSearch(query) {
this.isSearching = true;
this.searchResults = [];
try { const { t } = useI18n();
const { const store = useStore();
data: { payload }, const uiFlags = useMapGetter('contacts/getUIFlags');
} = await ContactAPI.search(query);
this.searchResults = payload.filter( const dialogRef = ref(null);
contact => contact.id !== this.primaryContact.id const isSearching = ref(false);
); const searchResults = ref([]);
} catch (error) {
useAlert(this.$t('MERGE_CONTACTS.SEARCH.ERROR_MESSAGE')); watch(
} finally { () => props.primaryContact.id,
this.isSearching = false; () => {
} isSearching.value = false;
}, searchResults.value = [];
async onMergeContacts(parentContactId) { }
useTrack(CONTACTS_EVENTS.MERGED_CONTACTS); );
try {
await this.$store.dispatch('contacts/merge', { const open = () => {
childId: this.primaryContact.id, dialogRef.value?.open();
parentId: parentContactId, };
});
useAlert(this.$t('MERGE_CONTACTS.FORM.SUCCESS_MESSAGE')); const close = () => {
this.onClose(); dialogRef.value?.close();
} catch (error) { };
useAlert(this.$t('MERGE_CONTACTS.FORM.ERROR_MESSAGE'));
} defineExpose({ open, close });
},
}, const onClose = () => {
close();
emit('close');
};
const onContactSearch = async query => {
isSearching.value = true;
searchResults.value = [];
try {
const {
data: { payload },
} = await ContactAPI.search(query);
searchResults.value = payload.filter(
contact => contact.id !== props.primaryContact.id
);
} catch (error) {
useAlert(t('MERGE_CONTACTS.SEARCH.ERROR_MESSAGE'));
} finally {
isSearching.value = false;
}
};
const onMergeContacts = async parentContactId => {
useTrack(CONTACTS_EVENTS.MERGED_CONTACTS);
try {
await store.dispatch('contacts/merge', {
childId: props.primaryContact.id,
parentId: parentContactId,
});
useAlert(t('MERGE_CONTACTS.FORM.SUCCESS_MESSAGE'));
close();
emit('close');
} catch (error) {
useAlert(t('MERGE_CONTACTS.FORM.ERROR_MESSAGE'));
}
}; };
</script> </script>
<template> <template>
<woot-modal v-model:show="localShow" :on-close="onClose"> <Dialog
<woot-modal-header ref="dialogRef"
:header-title="$t('MERGE_CONTACTS.TITLE')" type="edit"
:header-content="$t('MERGE_CONTACTS.DESCRIPTION')" width="2xl"
/> :title="$t('MERGE_CONTACTS.TITLE')"
:description="$t('MERGE_CONTACTS.DESCRIPTION')"
:show-cancel-button="false"
:show-confirm-button="false"
>
<MergeContact <MergeContact
:key="primaryContact.id"
:primary-contact="primaryContact" :primary-contact="primaryContact"
:is-searching="isSearching" :is-searching="isSearching"
:is-merging="uiFlags.isMerging" :is-merging="uiFlags.isMerging"
@ -94,5 +104,5 @@ export default {
@cancel="onClose" @cancel="onClose"
@submit="onMergeContacts" @submit="onMergeContacts"
/> />
</woot-modal> </Dialog>
</template> </template>

View File

@ -1,87 +0,0 @@
<script setup>
import Avatar from 'next/avatar/Avatar.vue';
defineProps({
name: {
type: String,
default: '',
},
thumbnail: {
type: String,
default: '',
},
email: {
type: String,
default: '',
},
phoneNumber: {
type: String,
default: '',
},
identifier: {
type: [String, Number],
required: true,
},
});
</script>
<template>
<div class="option-item--user">
<Avatar :src="thumbnail" :size="28" :name="name" rounded-full />
<div class="option__user-data">
<h5 class="option__title">
{{ name }}
<span v-if="identifier" class="user-identifier">
{{ $t('MERGE_CONTACTS.DROPDOWN_ITEM.ID', { identifier }) }}
</span>
</h5>
<p class="option__body">
<span v-if="email" class="email-icon-wrap">
<fluent-icon class="merge-contact--icon" icon="mail" size="12" />
{{ email }}
</span>
<span v-if="phoneNumber" class="phone-icon-wrap">
<fluent-icon class="merge-contact--icon" icon="call" size="12" />
{{ phoneNumber }}
</span>
<span v-if="!phoneNumber && !email">{{ '---' }}</span>
</p>
</div>
</div>
</template>
<style lang="scss" scoped>
.option-item--user {
@apply flex items-center;
}
.user-identifier {
@apply text-xs ml-0.5 text-n-slate-12;
}
.option__user-data {
@apply flex flex-col flex-grow ml-2 mr-2;
}
.option__body,
.option__title {
@apply flex items-center justify-start leading-[1.2] text-sm;
}
.option__body .icon {
@apply relative top-px mr-0.5 rtl:mr-0 rtl:ml-0.5;
}
.option__title {
@apply text-n-slate-12 font-medium mb-0.5;
}
.option__body {
@apply text-xs text-n-slate-12 mt-1;
}
.option__user-data .option__body {
> .phone-icon-wrap,
> .email-icon-wrap {
@apply w-auto flex items-center;
}
}
.merge-contact--icon {
@apply -mb-0.5 mr-0.5;
}
</style>

View File

@ -1,174 +1,105 @@
<script> <script setup>
import { ref, computed } from 'vue';
import { required } from '@vuelidate/validators'; import { required } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core'; import { useVuelidate } from '@vuelidate/core';
import { useI18n } from 'vue-i18n';
import MergeContactSummary from 'dashboard/modules/contact/components/MergeContactSummary.vue'; import MergeContactSummary from 'dashboard/modules/contact/components/MergeContactSummary.vue';
import ContactDropdownItem from './ContactDropdownItem.vue'; import ContactMergeForm from 'dashboard/components-next/Contacts/ContactsForm/ContactMergeForm.vue';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
export default { const props = defineProps({
components: { MergeContactSummary, ContactDropdownItem, NextButton }, primaryContact: {
props: { type: Object,
primaryContact: { required: true,
type: Object,
required: true,
},
isSearching: {
type: Boolean,
default: false,
},
isMerging: {
type: Boolean,
default: false,
},
searchResults: {
type: Array,
default: () => [],
},
}, },
emits: ['search', 'submit', 'cancel'], isSearching: {
setup() { type: Boolean,
return { v$: useVuelidate() }; default: false,
}, },
validations: { isMerging: {
primaryContact: { type: Boolean,
required, default: false,
},
parentContact: {
required,
},
}, },
data() { searchResults: {
return { type: Array,
parentContact: undefined, default: () => [],
};
}, },
});
computed: { const emit = defineEmits(['search', 'submit', 'cancel']);
parentContactName() {
return this.parentContact ? this.parentContact.name : ''; const { t } = useI18n();
const parentContactId = ref(null);
const validationRules = {
parentContactId: { required },
};
const v$ = useVuelidate(validationRules, { parentContactId });
const parentContact = computed(() => {
if (!parentContactId.value) return null;
return props.searchResults.find(
contact => contact.id === parentContactId.value
);
});
const parentContactName = computed(() => {
return parentContact.value ? parentContact.value.name : '';
});
const primaryContactList = computed(() => {
return props.searchResults.map(contact => ({
id: contact.id,
label: contact.name,
value: contact.id,
meta: {
thumbnail: contact.thumbnail,
email: contact.email,
phoneNumber: contact.phone_number,
}, },
}, }));
methods: { });
searchChange(query) {
this.$emit('search', query); const hasValidationError = computed(() => v$.value.parentContactId.$error);
}, const validationErrorMessage = computed(() => {
onSubmit() { if (v$.value.parentContactId.$error) {
this.v$.$touch(); return t('MERGE_CONTACTS.FORM.CHILD_CONTACT.ERROR');
if (this.v$.$invalid) { }
return; return '';
} });
this.$emit('submit', this.parentContact.id);
}, const onSearch = query => {
onCancel() { emit('search', query);
this.$emit('cancel'); };
},
}, const onSubmit = () => {
v$.value.$touch();
if (v$.value.$invalid) {
return;
}
emit('submit', parentContactId.value);
};
const onCancel = () => {
emit('cancel');
}; };
</script> </script>
<template> <template>
<form @submit.prevent="onSubmit"> <form @submit.prevent="onSubmit">
<div> <ContactMergeForm
<div> :selected-contact="primaryContact"
<div :primary-contact-id="parentContactId"
class="mt-1 multiselect-wrap--medium" :primary-contact-list="primaryContactList"
:class="{ error: v$.parentContact.$error }" :is-searching="isSearching"
> :has-error="hasValidationError"
<label class="multiselect__label"> :error-message="validationErrorMessage"
{{ $t('MERGE_CONTACTS.PARENT.TITLE') }} @update:primary-contact-id="parentContactId = $event"
<woot-label @search="onSearch"
:title="$t('MERGE_CONTACTS.PARENT.HELP_LABEL')" />
color-scheme="success"
small
class="ml-2"
/>
</label>
<multiselect
v-model="parentContact"
:options="searchResults"
label="name"
track-by="id"
:internal-search="false"
:clear-on-select="false"
:show-labels="false"
:placeholder="$t('MERGE_CONTACTS.PARENT.PLACEHOLDER')"
allow-empty
:loading="isSearching"
:max-height="150"
open-direction="top"
@search-change="searchChange"
>
<template #singleLabel="props">
<ContactDropdownItem
:thumbnail="props.option.thumbnail"
:identifier="props.option.id"
:name="props.option.name"
:email="props.option.email"
:phone-number="props.option.phone_number"
/>
</template>
<template #option="props">
<ContactDropdownItem
:thumbnail="props.option.thumbnail"
:identifier="props.option.id"
:name="props.option.name"
:email="props.option.email"
:phone-number="props.option.phone_number"
/>
</template>
<template #noResult>
<span>
{{ $t('AGENT_MGMT.SEARCH.NO_RESULTS') }}
</span>
</template>
</multiselect>
<span v-if="v$.parentContact.$error" class="message">
{{ $t('MERGE_CONTACTS.FORM.CHILD_CONTACT.ERROR') }}
</span>
</div>
</div>
<div class="flex multiselect-wrap--medium">
<div
class="w-8 relative text-base text-n-strong after:content-[''] after:h-12 after:w-0 ltr:after:left-4 rtl:after:right-4 after:absolute after:border-l after:border-solid after:border-n-strong before:content-[''] before:h-0 before:w-4 ltr:before:left-4 rtl:before:right-4 before:top-12 before:absolute before:border-b before:border-solid before:border-n-strong"
>
<fluent-icon
icon="arrow-up"
class="absolute -top-1 ltr:left-2 rtl:right-2"
size="17"
/>
</div>
<div class="flex flex-col w-full ltr:pl-8 rtl:pr-8">
<label class="multiselect__label">
{{ $t('MERGE_CONTACTS.PRIMARY.TITLE') }}
<woot-label
:title="$t('MERGE_CONTACTS.PRIMARY.HELP_LABEL')"
color-scheme="alert"
small
class="ml-2"
/>
</label>
<multiselect
:model-value="primaryContact"
disabled
:options="[]"
:show-labels="false"
label="name"
track-by="id"
>
<template #singleLabel="props">
<ContactDropdownItem
:thumbnail="props.option.thumbnail"
:name="props.option.name"
:identifier="props.option.id"
:email="props.option.email"
:phone-number="props.option.phoneNumber"
/>
</template>
</multiselect>
</div>
</div>
</div>
<MergeContactSummary <MergeContactSummary
:primary-contact-name="primaryContact.name" :primary-contact-name="primaryContact.name"
:parent-contact-name="parentContactName" :parent-contact-name="parentContactName"
@ -189,32 +120,3 @@ export default {
</div> </div>
</form> </form>
</template> </template>
<style lang="scss" scoped>
/* TDOD: Clean errors in forms style */
.error .message {
@apply mt-0;
}
::v-deep {
.multiselect {
@apply rounded-md;
}
.multiselect--disabled {
@apply border-0;
.multiselect__tags {
@apply border;
}
}
.multiselect__tags {
@apply h-auto;
}
.multiselect__select {
@apply mt-px mr-1;
}
}
</style>

View File

@ -11,11 +11,13 @@ import { isPhoneNumberValid } from 'shared/helpers/Validators';
import parsePhoneNumber from 'libphonenumber-js'; import parsePhoneNumber from 'libphonenumber-js';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import Avatar from 'next/avatar/Avatar.vue'; import Avatar from 'next/avatar/Avatar.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
export default { export default {
components: { components: {
NextButton, NextButton,
Avatar, Avatar,
ComboBox,
}, },
props: { props: {
contact: { contact: {
@ -133,6 +135,12 @@ export default {
if (!name && !id) return ''; if (!name && !id) return '';
return `${name} (${id})`; return `${name} (${id})`;
}, },
onCountryChange(value) {
const selected = this.countries.find(c => c.id === value);
this.country = selected
? { id: selected.id, name: selected.name }
: { id: '', name: '' };
},
setDialCode() { setDialCode() {
if ( if (
this.phoneNumber !== '' && this.phoneNumber !== '' &&
@ -363,26 +371,23 @@ export default {
:label="$t('CONTACT_FORM.FORM.COMPANY_NAME.LABEL')" :label="$t('CONTACT_FORM.FORM.COMPANY_NAME.LABEL')"
:placeholder="$t('CONTACT_FORM.FORM.COMPANY_NAME.PLACEHOLDER')" :placeholder="$t('CONTACT_FORM.FORM.COMPANY_NAME.PLACEHOLDER')"
/> />
<div> <div class="w-full mb-4">
<div class="w-full"> <label>
<label> {{ $t('CONTACT_FORM.FORM.COUNTRY.LABEL') }}
{{ $t('CONTACT_FORM.FORM.COUNTRY.LABEL') }} </label>
</label> <ComboBox
<multiselect :model-value="country.id"
v-model="country" :options="
track-by="id" countries.map(c => ({
label="name" value: c.id,
:placeholder="$t('CONTACT_FORM.FORM.COUNTRY.PLACEHOLDER')" label: countryNameWithCode(c),
selected-label }))
:select-label="$t('CONTACT_FORM.FORM.COUNTRY.SELECT_PLACEHOLDER')" "
:deselect-label="$t('CONTACT_FORM.FORM.COUNTRY.REMOVE')" class="[&>div>button]:!bg-n-alpha-black2"
:custom-label="countryNameWithCode" :placeholder="$t('CONTACT_FORM.FORM.COUNTRY.PLACEHOLDER')"
:max-height="160" :search-placeholder="$t('CONTACT_FORM.FORM.COUNTRY.SELECT_PLACEHOLDER')"
:options="countries" @update:model-value="onCountryChange"
allow-empty />
:option-height="104"
/>
</div>
</div> </div>
<woot-input <woot-input
v-model="city" v-model="city"
@ -426,11 +431,3 @@ export default {
</div> </div>
</form> </form>
</template> </template>
<style scoped lang="scss">
::v-deep {
.multiselect .multiselect__tags .multiselect__single {
@apply pl-0;
}
}
</style>

View File

@ -51,7 +51,6 @@ export default {
data() { data() {
return { return {
showEditModal: false, showEditModal: false,
showMergeModal: false,
showDeleteModal: false, showDeleteModal: false,
}; };
}, },
@ -167,11 +166,8 @@ export default {
); );
} }
}, },
closeMergeModal() {
this.showMergeModal = false;
},
openMergeModal() { openMergeModal() {
this.showMergeModal = true; this.$refs.mergeModal?.open();
}, },
}, },
}; };
@ -324,12 +320,7 @@ export default {
:contact="contact" :contact="contact"
@cancel="toggleEditModal" @cancel="toggleEditModal"
/> />
<ContactMergeModal <ContactMergeModal ref="mergeModal" :primary-contact="contact" />
v-if="showMergeModal"
:primary-contact="contact"
:show="showMergeModal"
@close="closeMergeModal"
/>
</div> </div>
<woot-delete-modal <woot-delete-modal
v-if="showDeleteModal" v-if="showDeleteModal"

View File

@ -7,10 +7,12 @@ import { convertToAttributeSlug } from 'dashboard/helper/commons.js';
import { ATTRIBUTE_MODELS, ATTRIBUTE_TYPES } from './constants'; import { ATTRIBUTE_MODELS, ATTRIBUTE_TYPES } from './constants';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
export default { export default {
components: { components: {
NextButton, NextButton,
TagInput,
}, },
props: { props: {
onClose: { onClose: {
@ -41,9 +43,8 @@ export default {
regexCue: null, regexCue: null,
regexEnabled: false, regexEnabled: false,
values: [], values: [],
options: [],
show: true, show: true,
isTouched: false, tagInputTouched: false,
}; };
}, },
@ -63,21 +64,21 @@ export default {
option: this.$t(`ATTRIBUTES_MGMT.ATTRIBUTE_TYPES.${item.key}`), option: this.$t(`ATTRIBUTES_MGMT.ATTRIBUTE_TYPES.${item.key}`),
})); }));
}, },
isMultiselectInvalid() { isTagInputEmpty() {
return this.isTouched && this.values.length === 0;
},
isTagInputInvalid() {
return this.isAttributeTypeList && this.values.length === 0; return this.isAttributeTypeList && this.values.length === 0;
}, },
isTagInputInvalid() {
return this.tagInputTouched && this.isTagInputEmpty;
},
attributeListValues() { attributeListValues() {
return this.values.map(item => item.name); return this.values;
}, },
isButtonDisabled() { isButtonDisabled() {
return ( return (
this.v$.displayName.$invalid || this.v$.displayName.$invalid ||
this.v$.description.$invalid || this.v$.description.$invalid ||
this.uiFlags.isCreating || this.uiFlags.isCreating ||
this.isTagInputInvalid this.isTagInputEmpty
); );
}, },
keyErrorMessage() { keyErrorMessage() {
@ -119,17 +120,14 @@ export default {
}, },
}, },
watch: {
attributeType() {
this.tagInputTouched = false;
this.values = [];
},
},
methods: { methods: {
addTagValue(tagValue) {
const tag = {
name: tagValue,
};
this.values.push(tag);
this.$refs.tagInput.$el.focus();
},
onTouch() {
this.isTouched = true;
},
onDisplayNameChange() { onDisplayNameChange() {
this.attributeKey = convertToAttributeSlug(this.displayName); this.attributeKey = convertToAttributeSlug(this.displayName);
}, },
@ -237,27 +235,25 @@ export default {
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.ERROR') }} {{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.ERROR') }}
</span> </span>
</label> </label>
<div v-if="isAttributeTypeList" class="multiselect--wrap"> <div v-if="isAttributeTypeList" class="mb-4">
<label> <label class="mb-1 block">
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.LABEL') }} {{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.LABEL') }}
</label> </label>
<multiselect <div
ref="tagInput" class="rounded-xl border px-3 py-2"
v-model="values" :class="isTagInputInvalid ? 'border-n-ruby-9' : 'border-n-weak'"
:placeholder=" >
$t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.PLACEHOLDER') <TagInput
" v-model="values"
label="name" :placeholder="
track-by="name" $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.PLACEHOLDER')
:class="{ invalid: isMultiselectInvalid }" "
:options="options" allow-create
multiple @blur="tagInputTouched = true"
taggable />
@close="onTouch" </div>
@tag="addTagValue"
/>
<label <label
v-show="isMultiselectInvalid" v-show="isTagInputInvalid"
class="text-n-ruby-9 dark:text-n-ruby-9 text-sm font-normal mt-1" class="text-n-ruby-9 dark:text-n-ruby-9 text-sm font-normal mt-1"
> >
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.ERROR') }} {{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.ERROR') }}
@ -312,22 +308,4 @@ export default {
padding: 0 0.5rem 0.5rem 0; padding: 0 0.5rem 0.5rem 0;
font-family: monospace; font-family: monospace;
} }
.multiselect--wrap {
margin-bottom: 1rem;
}
::v-deep {
.multiselect {
margin-bottom: 0;
}
.multiselect__content-wrapper {
display: none;
}
.multiselect--active .multiselect__tags {
border-radius: 0.3125rem;
}
}
</style> </style>

View File

@ -5,10 +5,12 @@ import { required, minLength } from '@vuelidate/validators';
import { getRegexp } from 'shared/helpers/Validators'; import { getRegexp } from 'shared/helpers/Validators';
import { ATTRIBUTE_TYPES } from './constants'; import { ATTRIBUTE_TYPES } from './constants';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
export default { export default {
components: { components: {
NextButton, NextButton,
TagInput,
}, },
props: { props: {
selectedAttribute: { selectedAttribute: {
@ -35,8 +37,7 @@ export default {
show: true, show: true,
attributeKey: '', attributeKey: '',
values: [], values: [],
options: [], tagInputTouched: false,
isTouched: true,
}; };
}, },
validations: { validations: {
@ -65,20 +66,19 @@ export default {
})); }));
}, },
setAttributeListValue() { setAttributeListValue() {
return this.selectedAttribute.attribute_values.map(values => ({ return this.selectedAttribute.attribute_values || [];
name: values,
}));
}, },
updatedAttributeListValues() { updatedAttributeListValues() {
return this.values.map(item => item.name); return this.values;
}, },
isButtonDisabled() { isButtonDisabled() {
return this.v$.description.$invalid || this.isMultiselectInvalid; return this.v$.description.$invalid || this.isTagInputEmpty;
}, },
isMultiselectInvalid() { isTagInputEmpty() {
return ( return this.isAttributeTypeList && this.values.length === 0;
this.isAttributeTypeList && this.isTouched && this.values.length === 0 },
); isTagInputInvalid() {
return this.tagInputTouched && this.isTagInputEmpty;
}, },
pageTitle() { pageTitle() {
@ -116,13 +116,6 @@ export default {
onClose() { onClose() {
this.$emit('onClose'); this.$emit('onClose');
}, },
addTagValue(tagValue) {
const tag = {
name: tagValue,
};
this.values.push(tag);
this.$refs.tagInput.$el.focus();
},
setFormValues() { setFormValues() {
const regexPattern = this.selectedAttribute.regex_pattern const regexPattern = this.selectedAttribute.regex_pattern
? getRegexp(this.selectedAttribute.regex_pattern).source ? getRegexp(this.selectedAttribute.regex_pattern).source
@ -225,24 +218,25 @@ export default {
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.ERROR') }} {{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.ERROR') }}
</span> </span>
</label> </label>
<div v-if="isAttributeTypeList" class="multiselect--wrap"> <div v-if="isAttributeTypeList" class="mb-4">
<label> <label class="mb-1 block">
{{ $t('ATTRIBUTES_MGMT.EDIT.TYPE.LIST.LABEL') }} {{ $t('ATTRIBUTES_MGMT.EDIT.TYPE.LIST.LABEL') }}
</label> </label>
<multiselect <div
ref="tagInput" class="rounded-xl border px-3 py-2"
v-model="values" :class="isTagInputInvalid ? 'border-n-ruby-9' : 'border-n-weak'"
:placeholder="$t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.PLACEHOLDER')" >
label="name" <TagInput
track-by="name" v-model="values"
:class="{ invalid: isMultiselectInvalid }" :placeholder="
:options="options" $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.PLACEHOLDER')
multiple "
taggable allow-create
@tag="addTagValue" @blur="tagInputTouched = true"
/> />
</div>
<label <label
v-show="isMultiselectInvalid" v-show="isTagInputInvalid"
class="text-n-ruby-9 dark:text-n-ruby-9 text-sm font-normal mt-1" class="text-n-ruby-9 dark:text-n-ruby-9 text-sm font-normal mt-1"
> >
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.ERROR') }} {{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.ERROR') }}
@ -297,22 +291,4 @@ export default {
padding: 0 0.5rem 0.5rem 0; padding: 0 0.5rem 0.5rem 0;
font-family: monospace; font-family: monospace;
} }
.multiselect--wrap {
margin-bottom: 1rem;
}
::v-deep {
.multiselect {
margin-bottom: 0;
}
.multiselect__content-wrapper {
display: none;
}
.multiselect--active .multiselect__tags {
border-radius: 0.3125rem;
}
}
</style> </style>

View File

@ -1,21 +1,12 @@
<script> <script setup>
import { mapGetters } from 'vuex'; import { ref, onMounted } from 'vue';
import FilterInputBox from 'dashboard/components/widgets/FilterInput/Index.vue'; import { useStore } from 'dashboard/composables/store';
import AutomationActionInput from 'dashboard/components/widgets/AutomationActionInput.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { useAutomation } from 'dashboard/composables/useAutomation'; import { useAutomation } from 'dashboard/composables/useAutomation';
import { validateAutomation } from 'dashboard/helper/validations'; import AutomationRuleForm from './AutomationRuleForm.vue';
import {
generateAutomationPayload,
getAttributes,
getInputType,
getOperators,
getCustomAttributeType,
showActionInput,
} from 'dashboard/helper/automationHelper';
import { AUTOMATION_RULE_EVENTS, AUTOMATION_ACTION_TYPES } from './constants';
const start_value = { const emit = defineEmits(['saveAutomation']);
const START_VALUE = {
name: null, name: null,
description: null, description: null,
event_name: 'conversation_created', event_name: 'conversation_created',
@ -36,318 +27,60 @@ const start_value = {
], ],
}; };
export default { const store = useStore();
components: { const formRef = ref(null);
FilterInputBox,
AutomationActionInput,
NextButton,
},
props: {
onClose: {
type: Function,
default: () => {},
},
},
emits: ['saveAutomation'],
setup() {
const {
automation,
automationTypes,
onEventChange,
getConditionDropdownValues,
appendNewCondition,
appendNewAction,
removeFilter,
removeAction,
resetFilter,
resetAction,
getActionDropdownValues,
manifestCustomAttributes,
} = useAutomation(start_value);
return {
automation,
automationTypes,
onEventChange,
getConditionDropdownValues,
appendNewCondition,
appendNewAction,
removeFilter,
removeAction,
resetFilter,
resetAction,
getActionDropdownValues,
manifestCustomAttributes,
};
},
data() {
return {
automationRuleEvent: AUTOMATION_RULE_EVENTS[0].key,
automationMutated: false,
show: true,
showDeleteConfirmationModal: false,
allCustomAttributes: [],
mode: 'create',
errors: {},
};
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
automationRuleEvents() {
return AUTOMATION_RULE_EVENTS.map(event => ({
...event,
value: this.$t(`AUTOMATION.EVENTS.${event.value}`),
}));
},
hasAutomationMutated() {
if (
this.automation.conditions[0].values ||
this.automation.actions[0].action_params.length
)
return true;
return false;
},
automationActionTypes() {
const actionTypes = this.isFeatureEnabled('sla')
? AUTOMATION_ACTION_TYPES
: AUTOMATION_ACTION_TYPES.filter(({ key }) => key !== 'add_sla');
return actionTypes.map(action => ({ const {
...action, automation,
label: this.$t(`AUTOMATION.ACTIONS.${action.label}`), automationTypes,
})); onEventChange,
}, getConditionDropdownValues,
}, appendNewCondition,
mounted() { appendNewAction,
this.$store.dispatch('inboxes/get'); removeFilter,
this.$store.dispatch('agents/get'); removeAction,
this.$store.dispatch('contacts/get'); resetAction,
this.$store.dispatch('teams/get'); getActionDropdownValues,
this.$store.dispatch('labels/get'); manifestCustomAttributes,
this.$store.dispatch('campaigns/get'); } = useAutomation(START_VALUE);
this.allCustomAttributes = this.$store.getters['attributes/getAttributes'];
this.manifestCustomAttributes();
},
methods: {
getAttributes,
getInputType,
getOperators,
getCustomAttributeType,
showActionInput,
isFeatureEnabled(flag) {
return this.isFeatureEnabledonAccount(this.accountId, flag);
},
emitSaveAutomation() {
this.errors = validateAutomation(this.automation);
if (Object.keys(this.errors).length === 0) {
const automation = generateAutomationPayload(this.automation);
this.$emit('saveAutomation', automation, this.mode);
}
},
getTranslatedAttributes(type, event) {
return getAttributes(type, event).map(attribute => {
// Skip translation
// 1. If customAttributeType key is present then its rendering attributes from API
// 2. If contact_custom_attribute or conversation_custom_attribute is present then its rendering section title
const skipTranslation =
attribute.customAttributeType ||
[
'contact_custom_attribute',
'conversation_custom_attribute',
].includes(attribute.key);
return { const open = () => {
...attribute, automation.value = structuredClone(START_VALUE);
name: skipTranslation manifestCustomAttributes();
? attribute.name formRef.value?.open();
: this.$t(`AUTOMATION.ATTRIBUTES.${attribute.name}`),
};
});
},
},
}; };
const close = () => formRef.value?.close();
const onSave = (payload, mode) => {
emit('saveAutomation', payload, mode);
};
onMounted(() => {
store.dispatch('inboxes/get');
store.dispatch('agents/get');
store.dispatch('contacts/get');
store.dispatch('teams/get');
store.dispatch('labels/get');
store.dispatch('campaigns/get');
});
defineExpose({ open, close });
</script> </script>
<template> <template>
<div> <AutomationRuleForm
<woot-modal-header :header-title="$t('AUTOMATION.ADD.TITLE')" /> ref="formRef"
<div class="flex flex-col modal-content"> v-model:automation="automation"
<div class="w-full"> mode="create"
<woot-input :automation-types="automationTypes"
v-model="automation.name" :get-condition-dropdown-values="getConditionDropdownValues"
:label="$t('AUTOMATION.ADD.FORM.NAME.LABEL')" :get-action-dropdown-values="getActionDropdownValues"
type="text" :append-new-condition="appendNewCondition"
:class="{ error: errors.name }" :append-new-action="appendNewAction"
:error="errors.name ? $t('AUTOMATION.ADD.FORM.NAME.ERROR') : ''" :remove-filter="removeFilter"
:placeholder="$t('AUTOMATION.ADD.FORM.NAME.PLACEHOLDER')" :remove-action="removeAction"
/> :reset-action="resetAction"
<woot-input :on-event-change="onEventChange"
v-model="automation.description" @save="onSave"
:label="$t('AUTOMATION.ADD.FORM.DESC.LABEL')" />
type="text"
:class="{ error: errors.description }"
:error="
errors.description ? $t('AUTOMATION.ADD.FORM.DESC.ERROR') : ''
"
:placeholder="$t('AUTOMATION.ADD.FORM.DESC.PLACEHOLDER')"
/>
<div class="mb-6">
<label :class="{ error: errors.event_name }">
{{ $t('AUTOMATION.ADD.FORM.EVENT.LABEL') }}
<select
v-model="automation.event_name"
class="m-0"
@change="onEventChange(automation)"
>
<option
v-for="event in automationRuleEvents"
:key="event.key"
:value="event.key"
>
{{ event.value }}
</option>
</select>
<span v-if="errors.event_name" class="message">
{{ $t('AUTOMATION.ADD.FORM.EVENT.ERROR') }}
</span>
</label>
<p
v-if="hasAutomationMutated"
class="text-xs text-right text-n-teal-10 pt-1"
>
{{ $t('AUTOMATION.FORM.RESET_MESSAGE') }}
</p>
</div>
<!-- // Conditions Start -->
<section>
<label>
{{ $t('AUTOMATION.ADD.FORM.CONDITIONS.LABEL') }}
</label>
<div
class="w-full p-4 mb-4 border border-solid rounded-lg bg-n-slate-2 dark:bg-n-solid-2 border-n-strong"
>
<FilterInputBox
v-for="(condition, i) in automation.conditions"
:key="i"
v-model="automation.conditions[i]"
:filter-attributes="
getTranslatedAttributes(automationTypes, automation.event_name)
"
:input-type="
getInputType(
allCustomAttributes,
automationTypes,
automation,
automation.conditions[i].attribute_key
)
"
:operators="
getOperators(
allCustomAttributes,
automationTypes,
automation,
mode,
automation.conditions[i].attribute_key
)
"
:dropdown-values="
getConditionDropdownValues(
automation.conditions[i].attribute_key
)
"
:show-query-operator="i !== automation.conditions.length - 1"
:custom-attribute-type="
getCustomAttributeType(
automationTypes,
automation,
automation.conditions[i].attribute_key
)
"
:error-message="
errors[`condition_${i}`]
? $t(`AUTOMATION.ERRORS.${errors[`condition_${i}`]}`)
: ''
"
@reset-filter="resetFilter(i, automation.conditions[i])"
@remove-filter="removeFilter(i)"
/>
<div class="mt-4">
<NextButton
icon="i-lucide-plus"
blue
faded
sm
:label="$t('AUTOMATION.ADD.CONDITION_BUTTON_LABEL')"
@click="appendNewCondition"
/>
</div>
</div>
</section>
<!-- // Conditions End -->
<!-- // Actions Start -->
<section>
<label>
{{ $t('AUTOMATION.ADD.FORM.ACTIONS.LABEL') }}
</label>
<div
class="w-full p-4 mb-4 border border-solid rounded-lg bg-n-slate-2 dark:bg-n-solid-2 border-n-strong"
>
<AutomationActionInput
v-for="(action, i) in automation.actions"
:key="i"
v-model="automation.actions[i]"
:action-types="automationActionTypes"
:dropdown-values="
getActionDropdownValues(automation.actions[i].action_name)
"
:show-action-input="
showActionInput(
automationActionTypes,
automation.actions[i].action_name
)
"
:error-message="
errors[`action_${i}`]
? $t(`AUTOMATION.ERRORS.${errors[`action_${i}`]}`)
: ''
"
@reset-action="resetAction(i)"
@remove-action="removeAction(i)"
/>
<div class="mt-4">
<NextButton
icon="i-lucide-plus"
blue
faded
sm
:label="$t('AUTOMATION.ADD.ACTION_BUTTON_LABEL')"
@click="appendNewAction"
/>
</div>
</div>
</section>
<!-- // Actions End -->
<div class="w-full">
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<NextButton
faded
slate
type="reset"
:label="$t('AUTOMATION.ADD.CANCEL_BUTTON_TEXT')"
@click.prevent="onClose"
/>
<NextButton
solid
blue
type="submit"
:label="$t('AUTOMATION.ADD.SUBMIT')"
@click="emitSaveAutomation"
/>
</div>
</div>
</div>
</div>
</div>
</template> </template>

View File

@ -0,0 +1,414 @@
<script setup>
import { ref, computed, h, useTemplateRef, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import { useOperators } from 'dashboard/components-next/filter/operators';
import ConditionRow from 'dashboard/components-next/filter/ConditionRow.vue';
import AutomationActionInput from 'dashboard/components/widgets/AutomationActionInput.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import {
generateAutomationPayload,
getAttributes,
getFileName,
showActionInput,
} from 'dashboard/helper/automationHelper';
import { validateAutomation } from 'dashboard/helper/validations';
import { AUTOMATION_RULE_EVENTS, AUTOMATION_ACTION_TYPES } from './constants';
const props = defineProps({
mode: {
type: String,
required: true,
validator: value => ['create', 'edit'].includes(value),
},
automationTypes: {
type: Object,
required: true,
},
getConditionDropdownValues: {
type: Function,
required: true,
},
getActionDropdownValues: {
type: Function,
required: true,
},
appendNewCondition: {
type: Function,
required: true,
},
appendNewAction: {
type: Function,
required: true,
},
removeFilter: {
type: Function,
required: true,
},
removeAction: {
type: Function,
required: true,
},
resetAction: {
type: Function,
required: true,
},
onEventChange: {
type: Function,
required: true,
},
});
const emit = defineEmits(['save']);
const automation = defineModel('automation', { type: Object, default: null });
const INPUT_TYPE_MAP = {
multi_select: 'multiSelect',
search_select: 'searchSelect',
plain_text: 'plainText',
comma_separated_plain_text: 'plainText',
date: 'date',
};
const { t } = useI18n();
const { isCloudFeatureEnabled } = useAccount();
const { operators } = useOperators();
const dialogRef = ref(null);
const conditionsRef = useTemplateRef('conditionsRef');
const errors = ref({});
const isEditMode = computed(() => props.mode === 'edit');
const titleKey = computed(() =>
isEditMode.value ? 'AUTOMATION.EDIT.TITLE' : 'AUTOMATION.ADD.TITLE'
);
const cancelKey = computed(() =>
isEditMode.value
? 'AUTOMATION.EDIT.CANCEL_BUTTON_TEXT'
: 'AUTOMATION.ADD.CANCEL_BUTTON_TEXT'
);
const submitKey = computed(() =>
isEditMode.value ? 'AUTOMATION.EDIT.SUBMIT' : 'AUTOMATION.ADD.SUBMIT'
);
const getTranslatedAttributes = (type, event) => {
return getAttributes(type, event).map(attribute => {
const skipTranslation =
attribute.customAttributeType ||
['contact_custom_attribute', 'conversation_custom_attribute'].includes(
attribute.key
);
return {
...attribute,
name: skipTranslation
? attribute.name
: t(`AUTOMATION.ATTRIBUTES.${attribute.name}`),
};
});
};
const eventName = computed(() => automation.value?.event_name);
const filterTypes = computed(() => {
const event = eventName.value;
if (!event || !props.automationTypes[event]) return [];
const attributes = getTranslatedAttributes(props.automationTypes, event);
return attributes.map(attr => {
if (attr.disabled) {
return { value: attr.key, label: attr.name, disabled: true };
}
const mappedInputType = INPUT_TYPE_MAP[attr.inputType] || 'plainText';
const options = props.getConditionDropdownValues(attr.key) || [];
const filterOperators = (attr.filterOperators || []).map(op => {
const enriched = operators.value[op.value];
if (enriched) return enriched;
return {
value: op.value,
label: t(`FILTER.OPERATOR_LABELS.${op.value}`),
hasInput: true,
inputOverride: null,
icon: h('span', { class: 'i-ph-equals-bold !text-n-blue-11' }),
};
});
return {
attributeKey: attr.key,
value: attr.key,
attributeName: attr.name,
label: attr.name,
inputType: mappedInputType,
options,
filterOperators,
dataType: 'text',
attributeModel: attr.customAttributeType || 'standard',
};
});
});
const automationRuleEvents = computed(() =>
AUTOMATION_RULE_EVENTS.map(event => ({
...event,
value: t(`AUTOMATION.EVENTS.${event.value}`),
}))
);
const hasAutomationMutated = computed(() => {
return Boolean(
automation.value?.conditions[0]?.values ||
automation.value?.actions[0]?.action_params?.length
);
});
const automationActionTypes = computed(() => {
const actionTypes = isCloudFeatureEnabled('sla')
? AUTOMATION_ACTION_TYPES
: AUTOMATION_ACTION_TYPES.filter(({ key }) => key !== 'add_sla');
return actionTypes.map(action => ({
...action,
label: t(`AUTOMATION.ACTIONS.${action.label}`),
}));
});
const hasConditionErrors = computed(() =>
Object.keys(errors.value).some(key => key.startsWith('condition_'))
);
const hasActionErrors = computed(() =>
Object.keys(errors.value).some(key => key.startsWith('action_'))
);
watch(
() => automation.value,
() => {
if (Object.keys(errors.value).length) {
errors.value = {};
}
},
{ deep: true }
);
const isConditionsValid = () => {
if (!conditionsRef.value) return true;
return conditionsRef.value.every(condition => condition.validate());
};
const resetValidation = () => {
errors.value = {};
conditionsRef.value?.forEach(c => c.resetValidation());
};
const syncCustomAttributeTypes = () => {
automation.value.conditions.forEach(condition => {
const filterType = filterTypes.value.find(
ft => ft.attributeKey === condition.attribute_key
);
condition.custom_attribute_type =
filterType?.attributeModel === 'standard'
? ''
: filterType?.attributeModel || '';
});
};
const open = () => {
resetValidation();
dialogRef.value?.open();
};
const close = () => {
resetValidation();
dialogRef.value?.close();
};
const emitSaveAutomation = () => {
syncCustomAttributeTypes();
const conditionsValid = isConditionsValid();
errors.value = validateAutomation(automation.value);
if (Object.keys(errors.value).length === 0 && conditionsValid) {
const payload = generateAutomationPayload(automation.value);
emit('save', payload, props.mode);
}
};
defineExpose({ open, close });
</script>
<template>
<Dialog
ref="dialogRef"
width="3xl"
position="top"
:title="$t(titleKey)"
:show-cancel-button="false"
:show-confirm-button="false"
overflow-y-auto
>
<div v-if="automation" class="flex flex-col w-full">
<woot-input
v-model="automation.name"
:label="$t('AUTOMATION.ADD.FORM.NAME.LABEL')"
type="text"
:class="{ error: errors.name }"
:error="errors.name ? $t('AUTOMATION.ADD.FORM.NAME.ERROR') : ''"
:placeholder="$t('AUTOMATION.ADD.FORM.NAME.PLACEHOLDER')"
/>
<woot-input
v-model="automation.description"
:label="$t('AUTOMATION.ADD.FORM.DESC.LABEL')"
type="text"
:class="{ error: errors.description }"
:error="errors.description ? $t('AUTOMATION.ADD.FORM.DESC.ERROR') : ''"
:placeholder="$t('AUTOMATION.ADD.FORM.DESC.PLACEHOLDER')"
/>
<div class="mb-6">
<label :class="{ error: errors.event_name }">
{{ $t('AUTOMATION.ADD.FORM.EVENT.LABEL') }}
<select
v-model="automation.event_name"
class="m-0"
@change="onEventChange()"
>
<option
v-for="event in automationRuleEvents"
:key="event.key"
:value="event.key"
>
{{ event.value }}
</option>
</select>
<span v-if="errors.event_name" class="message">
{{ $t('AUTOMATION.ADD.FORM.EVENT.ERROR') }}
</span>
</label>
<p
v-if="!isEditMode && hasAutomationMutated"
class="text-xs text-right text-n-teal-10 pt-1"
>
{{ $t('AUTOMATION.FORM.RESET_MESSAGE') }}
</p>
</div>
<!-- Conditions Start -->
<section class="mb-5">
<label>
{{ $t('AUTOMATION.ADD.FORM.CONDITIONS.LABEL') }}
</label>
<ul
class="grid gap-4 list-none p-3 mb-4 outline outline-1 rounded-xl -outline-offset-1"
:class="
hasConditionErrors
? 'outline-n-ruby-5 bg-n-ruby-2/50'
: 'outline-n-weak dark:outline-n-strong'
"
>
<template v-for="(condition, i) in automation.conditions" :key="i">
<ConditionRow
v-if="i === 0"
ref="conditionsRef"
v-model:attribute-key="automation.conditions[i].attribute_key"
v-model:filter-operator="automation.conditions[i].filter_operator"
v-model:values="automation.conditions[i].values"
:filter-types="filterTypes"
:show-query-operator="false"
@remove="removeFilter(i)"
/>
<ConditionRow
v-else
ref="conditionsRef"
v-model:attribute-key="automation.conditions[i].attribute_key"
v-model:filter-operator="automation.conditions[i].filter_operator"
v-model:query-operator="
automation.conditions[i - 1].query_operator
"
v-model:values="automation.conditions[i].values"
:filter-types="filterTypes"
show-query-operator
@remove="removeFilter(i)"
/>
</template>
<div>
<NextButton
icon="i-lucide-plus"
blue
faded
sm
:label="$t('AUTOMATION.ADD.CONDITION_BUTTON_LABEL')"
@click="appendNewCondition"
/>
</div>
</ul>
</section>
<!-- Conditions End -->
<!-- Actions Start -->
<section>
<label>
{{ $t('AUTOMATION.ADD.FORM.ACTIONS.LABEL') }}
</label>
<ul
class="grid list-none p-3 mb-4 outline outline-1 rounded-xl -outline-offset-1 border-solid"
:class="
hasActionErrors
? 'outline-n-ruby-5 bg-n-ruby-2/50'
: 'outline-n-weak dark:outline-n-strong'
"
>
<AutomationActionInput
v-for="(action, i) in automation.actions"
:key="i"
v-model="automation.actions[i]"
:action-types="automationActionTypes"
dropdown-max-height="max-h-[7.5rem]"
:dropdown-values="getActionDropdownValues(action.action_name)"
:show-action-input="
showActionInput(automationActionTypes, action.action_name)
"
:error-message="
errors[`action_${i}`]
? $t(`AUTOMATION.ERRORS.${errors[`action_${i}`]}`)
: ''
"
:initial-file-name="
isEditMode ? getFileName(action, automation.files) : ''
"
@reset-action="resetAction(i)"
@remove-action="removeAction(i)"
/>
<div class="pt-2">
<NextButton
icon="i-lucide-plus"
blue
faded
sm
:label="$t('AUTOMATION.ADD.ACTION_BUTTON_LABEL')"
@click="appendNewAction"
/>
</div>
</ul>
</section>
<!-- Actions End -->
<div class="w-full mt-8">
<div class="flex flex-row justify-end w-full gap-2 px-0 py-4">
<NextButton
faded
slate
type="reset"
:label="$t(cancelKey)"
@click.prevent="close"
/>
<NextButton
solid
blue
type="submit"
:label="$t(submitKey)"
@click="emitSaveAutomation"
/>
</div>
</div>
</div>
</Dialog>
</template>

View File

@ -1,345 +1,80 @@
<script> <script setup>
import { mapGetters } from 'vuex'; import { ref, watch } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useAutomation } from 'dashboard/composables/useAutomation'; import { useAutomation } from 'dashboard/composables/useAutomation';
import { useEditableAutomation } from 'dashboard/composables/useEditableAutomation'; import { useEditableAutomation } from 'dashboard/composables/useEditableAutomation';
import FilterInputBox from 'dashboard/components/widgets/FilterInput/Index.vue'; import AutomationRuleForm from './AutomationRuleForm.vue';
import NextButton from 'dashboard/components-next/button/Button.vue'; import { AUTOMATION_ACTION_TYPES } from './constants';
import AutomationActionInput from 'dashboard/components/widgets/AutomationActionInput.vue';
import {
getFileName,
generateAutomationPayload,
getAttributes,
getInputType,
getOperators,
getCustomAttributeType,
showActionInput,
} from 'dashboard/helper/automationHelper';
import { validateAutomation } from 'dashboard/helper/validations';
import { AUTOMATION_RULE_EVENTS, AUTOMATION_ACTION_TYPES } from './constants'; const props = defineProps({
selectedResponse: {
type: Object,
default: () => ({}),
},
});
export default { const emit = defineEmits(['saveAutomation']);
components: {
FilterInputBox, const allCustomAttributes = useMapGetter('attributes/getAttributes');
NextButton, const formRef = ref(null);
AutomationActionInput,
}, const {
props: { automation,
onClose: { automationTypes,
type: Function, onEventChange,
default: () => {}, getConditionDropdownValues,
}, appendNewCondition,
selectedResponse: { appendNewAction,
type: Object, removeFilter,
default: () => {}, removeAction,
}, resetAction,
}, getActionDropdownValues,
emits: ['saveAutomation'], manifestCustomAttributes,
setup() { } = useAutomation();
const {
automation, const { formatAutomation } = useEditableAutomation();
const open = () => formRef.value?.open();
const close = () => formRef.value?.close();
const onSave = (payload, mode) => {
emit('saveAutomation', payload, mode);
};
watch(
() => props.selectedResponse,
value => {
if (!value?.conditions) return;
manifestCustomAttributes();
automation.value = formatAutomation(
value,
allCustomAttributes.value,
automationTypes, automationTypes,
onEventChange, AUTOMATION_ACTION_TYPES
getConditionDropdownValues,
appendNewCondition,
appendNewAction,
removeFilter,
removeAction,
resetFilter,
resetAction,
getActionDropdownValues,
manifestCustomAttributes,
} = useAutomation();
const { formatAutomation } = useEditableAutomation();
return {
automation,
automationTypes,
onEventChange,
getConditionDropdownValues,
appendNewCondition,
appendNewAction,
removeFilter,
removeAction,
resetFilter,
resetAction,
getActionDropdownValues,
formatAutomation,
manifestCustomAttributes,
};
},
data() {
return {
automationRuleEvent: AUTOMATION_RULE_EVENTS[0].key,
automationMutated: false,
show: true,
showDeleteConfirmationModal: false,
allCustomAttributes: [],
mode: 'edit',
errors: {},
};
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
automationRuleEvents() {
return AUTOMATION_RULE_EVENTS.map(event => ({
...event,
value: this.$t(`AUTOMATION.EVENTS.${event.value}`),
}));
},
hasAutomationMutated() {
if (
this.automation.conditions[0].values ||
this.automation.actions[0].action_params.length
)
return true;
return false;
},
automationActionTypes() {
const actionTypes = this.isFeatureEnabled('sla')
? AUTOMATION_ACTION_TYPES
: AUTOMATION_ACTION_TYPES.filter(({ key }) => key !== 'add_sla');
return actionTypes.map(action => ({
...action,
label: this.$t(`AUTOMATION.ACTIONS.${action.label}`),
}));
},
},
mounted() {
this.manifestCustomAttributes();
this.allCustomAttributes = this.$store.getters['attributes/getAttributes'];
this.automation = this.formatAutomation(
this.selectedResponse,
this.allCustomAttributes,
this.automationTypes,
this.automationActionTypes
); );
}, },
methods: { { immediate: true }
getFileName, );
getAttributes,
getInputType,
getOperators,
getCustomAttributeType,
showActionInput,
isFeatureEnabled(flag) {
return this.isFeatureEnabledonAccount(this.accountId, flag);
},
emitSaveAutomation() {
this.errors = validateAutomation(this.automation);
if (Object.keys(this.errors).length === 0) {
const automation = generateAutomationPayload(this.automation);
this.$emit('saveAutomation', automation, this.mode);
}
},
getTranslatedAttributes(type, event) {
return getAttributes(type, event).map(attribute => {
// Skip translation
// 1. If customAttributeType key is present then its rendering attributes from API
// 2. If contact_custom_attribute or conversation_custom_attribute is present then its rendering section title
const skipTranslation =
attribute.customAttributeType ||
[
'contact_custom_attribute',
'conversation_custom_attribute',
].includes(attribute.key);
return { defineExpose({ open, close });
...attribute,
name: skipTranslation
? attribute.name
: this.$t(`AUTOMATION.ATTRIBUTES.${attribute.name}`),
};
});
},
},
};
</script> </script>
<template> <template>
<div> <AutomationRuleForm
<woot-modal-header :header-title="$t('AUTOMATION.EDIT.TITLE')" /> ref="formRef"
<div class="flex flex-col modal-content"> v-model:automation="automation"
<div v-if="automation" class="w-full"> mode="edit"
<woot-input :automation-types="automationTypes"
v-model="automation.name" :get-condition-dropdown-values="getConditionDropdownValues"
:label="$t('AUTOMATION.ADD.FORM.NAME.LABEL')" :get-action-dropdown-values="getActionDropdownValues"
type="text" :append-new-condition="appendNewCondition"
:class="{ error: errors.name }" :append-new-action="appendNewAction"
:error="errors.name ? $t('AUTOMATION.ADD.FORM.NAME.ERROR') : ''" :remove-filter="removeFilter"
:placeholder="$t('AUTOMATION.ADD.FORM.NAME.PLACEHOLDER')" :remove-action="removeAction"
/> :reset-action="resetAction"
<woot-input :on-event-change="onEventChange"
v-model="automation.description" @save="onSave"
:label="$t('AUTOMATION.ADD.FORM.DESC.LABEL')" />
type="text"
:class="{ error: errors.description }"
:error="
errors.description ? $t('AUTOMATION.ADD.FORM.DESC.ERROR') : ''
"
:placeholder="$t('AUTOMATION.ADD.FORM.DESC.PLACEHOLDER')"
/>
<div class="event_wrapper">
<label :class="{ error: errors.event_name }">
{{ $t('AUTOMATION.ADD.FORM.EVENT.LABEL') }}
<select
v-model="automation.event_name"
@change="onEventChange(automation)"
>
<option
v-for="event in automationRuleEvents"
:key="event.key"
:value="event.key"
>
{{ event.value }}
</option>
</select>
<span v-if="errors.event_name" class="message">
{{ $t('AUTOMATION.ADD.FORM.EVENT.ERROR') }}
</span>
</label>
</div>
<!-- // Conditions Start -->
<section>
<label>
{{ $t('AUTOMATION.ADD.FORM.CONDITIONS.LABEL') }}
</label>
<div
class="w-full p-4 mb-4 border border-solid rounded-lg bg-n-slate-2 dark:bg-n-solid-2 border-n-strong"
>
<FilterInputBox
v-for="(condition, i) in automation.conditions"
:key="i"
v-model="automation.conditions[i]"
:filter-attributes="
getTranslatedAttributes(automationTypes, automation.event_name)
"
:input-type="
getInputType(
allCustomAttributes,
automationTypes,
automation,
automation.conditions[i].attribute_key
)
"
:operators="
getOperators(
allCustomAttributes,
automationTypes,
automation,
mode,
automation.conditions[i].attribute_key
)
"
:dropdown-values="
getConditionDropdownValues(
automation.conditions[i].attribute_key
)
"
:custom-attribute-type="
getCustomAttributeType(
automationTypes,
automation,
automation.conditions[i].attribute_key
)
"
:show-query-operator="i !== automation.conditions.length - 1"
:error-message="
errors[`condition_${i}`]
? $t(`AUTOMATION.ERRORS.${errors[`condition_${i}`]}`)
: ''
"
@reset-filter="resetFilter(i, automation.conditions[i])"
@remove-filter="removeFilter(i)"
/>
<div class="mt-4">
<NextButton
icon="i-lucide-plus"
blue
faded
sm
:label="$t('AUTOMATION.ADD.CONDITION_BUTTON_LABEL')"
@click="appendNewCondition"
/>
</div>
</div>
</section>
<!-- // Conditions End -->
<!-- // Actions Start -->
<section>
<label>
{{ $t('AUTOMATION.ADD.FORM.ACTIONS.LABEL') }}
</label>
<div
class="w-full p-4 mb-4 border border-solid rounded-lg bg-n-slate-2 dark:bg-n-solid-2 border-n-strong"
>
<AutomationActionInput
v-for="(action, i) in automation.actions"
:key="i"
v-model="automation.actions[i]"
:action-types="automationActionTypes"
:dropdown-values="getActionDropdownValues(action.action_name)"
:show-action-input="
showActionInput(automationActionTypes, action.action_name)
"
:error-message="
errors[`action_${i}`]
? $t(`AUTOMATION.ERRORS.${errors[`action_${i}`]}`)
: ''
"
:initial-file-name="getFileName(action, automation.files)"
@reset-action="resetAction(i)"
@remove-action="removeAction(i)"
/>
<div class="mt-4">
<NextButton
icon="i-lucide-plus"
blue
faded
sm
:label="$t('AUTOMATION.ADD.ACTION_BUTTON_LABEL')"
@click="appendNewAction"
/>
</div>
</div>
</section>
<!-- // Actions End -->
<div class="w-full">
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<NextButton
faded
slate
type="reset"
:label="$t('AUTOMATION.EDIT.CANCEL_BUTTON_TEXT')"
@click.prevent="onClose"
/>
<NextButton
solid
blue
type="submit"
:label="$t('AUTOMATION.EDIT.SUBMIT')"
@click="emitSaveAutomation"
/>
</div>
</div>
</div>
</div>
</div>
</template> </template>
<style lang="scss" scoped>
.event_wrapper {
select {
@apply m-0;
}
.info-message {
@apply text-xs text-n-teal-10 text-right;
}
@apply mb-6;
}
</style>

View File

@ -16,8 +16,8 @@ const { t } = useI18n();
const confirmDialog = ref(null); const confirmDialog = ref(null);
const loading = ref({}); const loading = ref({});
const showAddPopup = ref(false); const addDialogRef = ref(null);
const showEditPopup = ref(false); const editDialogRef = ref(null);
const showDeleteConfirmationPopup = ref(false); const showDeleteConfirmationPopup = ref(false);
const selectedAutomation = ref({}); const selectedAutomation = ref({});
const toggleModalTitle = ref(t('AUTOMATION.TOGGLE.ACTIVATION_TITLE')); const toggleModalTitle = ref(t('AUTOMATION.TOGGLE.ACTIVATION_TITLE'));
@ -57,18 +57,18 @@ onMounted(() => {
}); });
const openAddPopup = () => { const openAddPopup = () => {
showAddPopup.value = true; addDialogRef.value?.open();
}; };
const hideAddPopup = () => { const hideAddPopup = () => {
showAddPopup.value = false; addDialogRef.value?.close();
}; };
const openEditPopup = response => { const openEditPopup = response => {
selectedAutomation.value = response; selectedAutomation.value = { ...response };
showEditPopup.value = true; editDialogRef.value?.open();
}; };
const hideEditPopup = () => { const hideEditPopup = () => {
showEditPopup.value = false; editDialogRef.value?.close();
}; };
const openDeletePopup = response => { const openDeletePopup = response => {
@ -221,17 +221,7 @@ const tableHeaders = computed(() => {
</table> </table>
</template> </template>
<woot-modal <AddAutomationRule ref="addDialogRef" @save-automation="submitAutomation" />
v-model:show="showAddPopup"
size="medium"
:on-close="hideAddPopup"
>
<AddAutomationRule
v-if="showAddPopup"
:on-close="hideAddPopup"
@save-automation="submitAutomation"
/>
</woot-modal>
<woot-delete-modal <woot-delete-modal
v-model:show="showDeleteConfirmationPopup" v-model:show="showDeleteConfirmationPopup"
@ -244,18 +234,11 @@ const tableHeaders = computed(() => {
:reject-text="deleteRejectText" :reject-text="deleteRejectText"
/> />
<woot-modal <EditAutomationRule
v-model:show="showEditPopup" ref="editDialogRef"
size="medium" :selected-response="selectedAutomation"
:on-close="hideEditPopup" @save-automation="submitAutomation"
> />
<EditAutomationRule
v-if="showEditPopup"
:on-close="hideEditPopup"
:selected-response="selectedAutomation"
@save-automation="submitAutomation"
/>
</woot-modal>
<woot-confirm-modal <woot-confirm-modal
ref="confirmDialog" ref="confirmDialog"
:title="toggleModalTitle" :title="toggleModalTitle"

View File

@ -5,6 +5,7 @@ import { useAlert } from 'dashboard/composables';
import InboxMembersAPI from '../../../../api/inboxMembers'; import InboxMembersAPI from '../../../../api/inboxMembers';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
import router from '../../../index'; import router from '../../../index';
import PageHeader from '../SettingsSubPageHeader.vue'; import PageHeader from '../SettingsSubPageHeader.vue';
import { useVuelidate } from '@vuelidate/core'; import { useVuelidate } from '@vuelidate/core';
@ -13,11 +14,12 @@ export default {
components: { components: {
PageHeader, PageHeader,
NextButton, NextButton,
TagInput,
}, },
validations: { validations: {
selectedAgents: { selectedAgentIds: {
isEmpty() { isEmpty() {
return !!this.selectedAgents.length; return !!this.selectedAgentIds.length;
}, },
}, },
}, },
@ -26,7 +28,7 @@ export default {
}, },
data() { data() {
return { return {
selectedAgents: [], selectedAgentIds: [],
isCreating: false, isCreating: false,
}; };
}, },
@ -34,18 +36,43 @@ export default {
...mapGetters({ ...mapGetters({
agentList: 'agents/getAgents', agentList: 'agents/getAgents',
}), }),
selectedAgentNames() {
return this.selectedAgentIds.map(
id => this.agentList.find(a => a.id === id)?.name ?? ''
);
},
agentMenuItems() {
return this.agentList
.filter(({ id }) => !this.selectedAgentIds.includes(id))
.map(({ id, name, thumbnail, avatar_url }) => ({
label: name,
value: id,
action: 'select',
thumbnail: { name, src: thumbnail || avatar_url || '' },
}));
},
}, },
mounted() { mounted() {
this.$store.dispatch('agents/get'); this.$store.dispatch('agents/get');
}, },
methods: { methods: {
handleAgentAdd({ value }) {
if (!this.selectedAgentIds.includes(value)) {
this.selectedAgentIds.push(value);
}
},
handleAgentRemove(index) {
this.selectedAgentIds.splice(index, 1);
},
async addAgents() { async addAgents() {
this.isCreating = true; this.isCreating = true;
const inboxId = this.$route.params.inbox_id; const inboxId = this.$route.params.inbox_id;
const selectedAgents = this.selectedAgents.map(x => x.id);
try { try {
await InboxMembersAPI.update({ inboxId, agentList: selectedAgents }); await InboxMembersAPI.update({
inboxId,
agentList: this.selectedAgentIds,
});
router.replace({ router.replace({
name: 'settings_inbox_finish', name: 'settings_inbox_finish',
params: { params: {
@ -72,25 +99,23 @@ export default {
/> />
</div> </div>
<div> <div>
<div class="w-full"> <div class="w-full mb-4">
<label :class="{ error: v$.selectedAgents.$error }"> <label :class="{ error: v$.selectedAgentIds.$error }">
{{ $t('INBOX_MGMT.ADD.AGENTS.TITLE') }} {{ $t('INBOX_MGMT.ADD.AGENTS.TITLE') }}
<multiselect <div
v-model="selectedAgents" class="rounded-xl outline outline-1 -outline-offset-1 outline-n-weak hover:outline-n-strong px-2 py-2"
:options="agentList" >
track-by="id" <TagInput
label="name" :model-value="selectedAgentNames"
multiple :placeholder="$t('INBOX_MGMT.ADD.AGENTS.PICK_AGENTS')"
:close-on-select="false" :menu-items="agentMenuItems"
:clear-on-select="false" show-dropdown
hide-selected skip-label-dedup
selected-label @add="handleAgentAdd"
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')" @remove="handleAgentRemove"
:deselect-label="$t('FORMS.MULTISELECT.ENTER_TO_REMOVE')" />
:placeholder="$t('INBOX_MGMT.ADD.AGENTS.PICK_AGENTS')" </div>
@select="v$.selectedAgents.$touch" <span v-if="v$.selectedAgentIds.$error" class="message">
/>
<span v-if="v$.selectedAgents.$error" class="message">
{{ $t('INBOX_MGMT.ADD.AGENTS.VALIDATION_ERROR') }} {{ $t('INBOX_MGMT.ADD.AGENTS.VALIDATION_ERROR') }}
</span> </span>
</label> </label>

View File

@ -12,6 +12,7 @@ import PageHeader from '../../SettingsSubPageHeader.vue';
import router from '../../../../index'; import router from '../../../../index';
import { useBranding } from 'shared/composables/useBranding'; import { useBranding } from 'shared/composables/useBranding';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import { loadScript } from 'dashboard/helper/DOMHelpers'; import { loadScript } from 'dashboard/helper/DOMHelpers';
import * as Sentry from '@sentry/vue'; import * as Sentry from '@sentry/vue';
@ -21,6 +22,7 @@ export default {
LoadingState, LoadingState,
PageHeader, PageHeader,
NextButton, NextButton,
ComboBox,
}, },
setup() { setup() {
const { accountId } = useAccount(); const { accountId } = useAccount();
@ -67,6 +69,12 @@ export default {
getSelectablePages() { getSelectablePages() {
return this.pageList.filter(item => !item.exists); return this.pageList.filter(item => !item.exists);
}, },
comboBoxPageOptions() {
return this.getSelectablePages.map(({ id, name }) => ({
value: id,
label: name,
}));
},
}, },
mounted() { mounted() {
@ -94,9 +102,16 @@ export default {
} }
}, },
setPageName({ name }) { setPageName(pageId) {
const page = this.pageList.find(p => p.id === pageId);
if (page) {
this.selectedPage = page;
this.pageName = page.name;
} else {
this.selectedPage = { name: null, id: null };
this.pageName = '';
}
this.v$.selectedPage.$touch(); this.v$.selectedPage.$touch();
this.pageName = name;
}, },
initChannelAuth(channel) { initChannelAuth(channel) {
@ -245,23 +260,20 @@ export default {
/> />
</div> </div>
<div class="w-3/5"> <div class="w-3/5">
<div class="w-full"> <div class="w-full mb-2">
<div class="input-wrap" :class="{ error: v$.selectedPage.$error }"> <div class="input-wrap" :class="{ error: v$.selectedPage.$error }">
{{ $t('INBOX_MGMT.ADD.FB.CHOOSE_PAGE') }} <span class="text-n-slate-12 text-start">
<multiselect {{ $t('INBOX_MGMT.ADD.FB.CHOOSE_PAGE') }}
v-model="selectedPage" </span>
close-on-select <ComboBox
allow-empty :model-value="selectedPage.id"
:options="getSelectablePages" :options="comboBoxPageOptions"
track-by="id"
label="name"
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
:deselect-label="$t('FORMS.MULTISELECT.ENTER_TO_REMOVE')"
:placeholder="$t('INBOX_MGMT.ADD.FB.PICK_A_VALUE')" :placeholder="$t('INBOX_MGMT.ADD.FB.PICK_A_VALUE')"
selected-label :has-error="v$.selectedPage.$error"
@select="setPageName" class="[&>div>button]:!bg-n-alpha-black2 mt-1"
@update:model-value="setPageName"
/> />
<span v-if="v$.selectedPage.$error" class="message"> <span v-if="v$.selectedPage.$error" class="message mt-0.5">
{{ $t('INBOX_MGMT.ADD.FB.CHOOSE_PLACEHOLDER') }} {{ $t('INBOX_MGMT.ADD.FB.CHOOSE_PLACEHOLDER') }}
</span> </span>
</div> </div>