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:
@@ -68,8 +68,7 @@ const hasActiveSegments = computed(
|
||||
);
|
||||
const activeSegmentName = computed(() => props.activeSegment?.name);
|
||||
|
||||
const openCreateNewContactDialog = async () => {
|
||||
await createNewContactDialogRef.value?.contactsFormRef.resetValidation();
|
||||
const openCreateNewContactDialog = () => {
|
||||
createNewContactDialogRef.value?.dialogRef.open();
|
||||
};
|
||||
const openContactImportDialog = () =>
|
||||
|
||||
@@ -95,6 +95,7 @@ const inputClass = computed(() => {
|
||||
:show-dropdown="showCcEmailsDropdown"
|
||||
:is-loading="isLoading"
|
||||
type="email"
|
||||
allow-create
|
||||
class="flex-1 min-h-7"
|
||||
@focus="emit('updateDropdown', 'cc', true)"
|
||||
@input="emit('searchCcEmails', $event)"
|
||||
@@ -127,6 +128,7 @@ const inputClass = computed(() => {
|
||||
:show-dropdown="showBccEmailsDropdown"
|
||||
:is-loading="isLoading"
|
||||
type="email"
|
||||
allow-create
|
||||
class="flex-1 min-h-7"
|
||||
focus-on-mount
|
||||
@focus="emit('updateDropdown', 'bcc', true)"
|
||||
|
||||
@@ -56,8 +56,13 @@ const selectedLabel = computed(() => {
|
||||
});
|
||||
|
||||
const selectOption = option => {
|
||||
selectedValue.value = option.value;
|
||||
emit('update:modelValue', option.value);
|
||||
if (selectedValue.value === option.value) {
|
||||
selectedValue.value = '';
|
||||
emit('update:modelValue', '');
|
||||
} else {
|
||||
selectedValue.value = option.value;
|
||||
emit('update:modelValue', option.value);
|
||||
}
|
||||
open.value = false;
|
||||
search.value = '';
|
||||
};
|
||||
|
||||
@@ -53,6 +53,11 @@ const props = defineProps({
|
||||
default: 'lg',
|
||||
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']);
|
||||
@@ -61,6 +66,7 @@ const { t } = useI18n();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const dialogContentRef = ref(null);
|
||||
const isOpen = ref(false);
|
||||
|
||||
const maxWidthClass = computed(() => {
|
||||
const classesMap = {
|
||||
@@ -75,13 +81,19 @@ const maxWidthClass = computed(() => {
|
||||
return classesMap[props.width] ?? 'max-w-md';
|
||||
});
|
||||
|
||||
const positionClass = computed(() =>
|
||||
props.position === 'top' ? 'dialog-position-top' : ''
|
||||
);
|
||||
|
||||
const open = () => {
|
||||
isOpen.value = true;
|
||||
dialogRef.value?.showModal();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
emit('close');
|
||||
dialogRef.value?.close();
|
||||
isOpen.value = false;
|
||||
};
|
||||
|
||||
const confirm = () => {
|
||||
@@ -98,6 +110,7 @@ defineExpose({ open, close });
|
||||
class="w-full transition-all duration-300 ease-in-out shadow-xl rounded-xl"
|
||||
:class="[
|
||||
maxWidthClass,
|
||||
positionClass,
|
||||
overflowYAuto ? 'overflow-y-auto' : 'overflow-visible',
|
||||
]"
|
||||
@close="close"
|
||||
@@ -105,7 +118,7 @@ defineExpose({ open, close });
|
||||
<OnClickOutside @trigger="close">
|
||||
<form
|
||||
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"
|
||||
@click.stop
|
||||
>
|
||||
@@ -119,7 +132,7 @@ defineExpose({ open, close });
|
||||
</p>
|
||||
</slot>
|
||||
</div>
|
||||
<slot />
|
||||
<slot v-if="isOpen" />
|
||||
<!-- Dialog content will be injected here -->
|
||||
<slot name="footer">
|
||||
<div
|
||||
@@ -156,4 +169,9 @@ defineExpose({ open, close });
|
||||
dialog::backdrop {
|
||||
@apply bg-n-alpha-black1 backdrop-blur-[4px];
|
||||
}
|
||||
|
||||
.dialog-position-top {
|
||||
margin-top: clamp(2rem, 5vh, 5rem);
|
||||
margin-bottom: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,10 @@ defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: 'max-h-96',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -15,7 +19,10 @@ defineProps({
|
||||
>
|
||||
{{ title }}
|
||||
</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 />
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -50,12 +50,12 @@ const currentFilter = computed(() =>
|
||||
);
|
||||
|
||||
const getOperator = (filter, selectedOperator) => {
|
||||
const operatorFromOptions = filter.filterOperators.find(
|
||||
const operatorFromOptions = filter?.filterOperators?.find(
|
||||
operator => operator.value === selectedOperator
|
||||
);
|
||||
|
||||
if (!operatorFromOptions) {
|
||||
return filter.filterOperators[0];
|
||||
return filter?.filterOperators?.[0];
|
||||
}
|
||||
|
||||
return operatorFromOptions;
|
||||
@@ -138,7 +138,11 @@ const validate = () => {
|
||||
return !validationError.value;
|
||||
};
|
||||
|
||||
defineExpose({ validate });
|
||||
const resetValidation = () => {
|
||||
showErrors.value = false;
|
||||
};
|
||||
|
||||
defineExpose({ validate, resetValidation });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -166,18 +170,20 @@ defineExpose({ validate });
|
||||
<FilterSelect
|
||||
v-model="filterOperator"
|
||||
variant="ghost"
|
||||
:options="currentFilter.filterOperators"
|
||||
:options="currentFilter?.filterOperators"
|
||||
/>
|
||||
<template v-if="currentOperator.hasInput">
|
||||
<template v-if="currentOperator?.hasInput">
|
||||
<MultiSelect
|
||||
v-if="inputType === 'multiSelect'"
|
||||
v-model="values"
|
||||
:options="currentFilter.options"
|
||||
dropdown-max-height="max-h-72"
|
||||
/>
|
||||
<SingleSelect
|
||||
v-else-if="inputType === 'searchSelect'"
|
||||
v-model="values"
|
||||
:options="currentFilter.options"
|
||||
dropdown-max-height="max-h-64"
|
||||
/>
|
||||
<SingleSelect
|
||||
v-else-if="inputType === 'booleanSelect'"
|
||||
|
||||
@@ -45,7 +45,7 @@ const { height } = useWindowSize();
|
||||
const { height: dropdownHeight } = useElementBounding(dropdownRef);
|
||||
|
||||
const selectedOption = computed(() => {
|
||||
return props.options.find(o => o.value === selected.value) || {};
|
||||
return props.options?.find(o => o.value === selected.value) || {};
|
||||
});
|
||||
|
||||
const iconToRender = computed(() => {
|
||||
@@ -87,18 +87,25 @@ const updateSelected = newValue => {
|
||||
</template>
|
||||
<DropdownBody
|
||||
ref="dropdownRef"
|
||||
class="min-w-48 z-50"
|
||||
class="min-w-56 z-50"
|
||||
:class="dropdownPosition"
|
||||
strong
|
||||
>
|
||||
<DropdownSection class="[&>ul]:max-h-80">
|
||||
<DropdownItem
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:icon="option.icon"
|
||||
@click="updateSelected(option.value)"
|
||||
/>
|
||||
<DropdownSection class="[&>ul]:max-h-72">
|
||||
<template v-for="option in options" :key="option.value">
|
||||
<li
|
||||
v-if="option.disabled"
|
||||
class="px-2 py-1.5 text-xs font-medium text-n-slate-10 select-none"
|
||||
>
|
||||
{{ option.label }}
|
||||
</li>
|
||||
<DropdownItem
|
||||
v-else
|
||||
:label="option.label"
|
||||
:icon="option.icon"
|
||||
@click="updateSelected(option.value)"
|
||||
/>
|
||||
</template>
|
||||
</DropdownSection>
|
||||
</DropdownBody>
|
||||
</DropdownContainer>
|
||||
|
||||
@@ -8,7 +8,7 @@ import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
|
||||
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
|
||||
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
|
||||
|
||||
const { options, maxChips } = defineProps({
|
||||
const { options, maxChips, dropdownMaxHeight } = defineProps({
|
||||
options: {
|
||||
type: Array,
|
||||
required: true,
|
||||
@@ -17,6 +17,10 @@ const { options, maxChips } = defineProps({
|
||||
type: Number,
|
||||
default: 3,
|
||||
},
|
||||
dropdownMaxHeight: {
|
||||
type: String,
|
||||
default: 'max-h-80',
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -123,7 +127,7 @@ const toggleOption = option => {
|
||||
</Button>
|
||||
</template>
|
||||
<DropdownBody class="top-0 min-w-48 z-50" strong>
|
||||
<DropdownSection class="[&>ul]:max-h-80">
|
||||
<DropdownSection :height="dropdownMaxHeight">
|
||||
<DropdownItem
|
||||
v-for="option in options"
|
||||
:key="option.id"
|
||||
|
||||
@@ -12,10 +12,12 @@ import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
|
||||
const {
|
||||
options,
|
||||
disableSearch,
|
||||
disableDeselect,
|
||||
placeholderIcon,
|
||||
placeholder,
|
||||
placeholderTrailingIcon,
|
||||
searchPlaceholder,
|
||||
dropdownMaxHeight,
|
||||
} = defineProps({
|
||||
options: {
|
||||
type: Array,
|
||||
@@ -41,6 +43,14 @@ const {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
dropdownMaxHeight: {
|
||||
type: String,
|
||||
default: 'max-h-80',
|
||||
},
|
||||
disableDeselect: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -63,6 +73,8 @@ const selectedItem = computed(() => {
|
||||
const optionToSearch = Array.isArray(selected.value)
|
||||
? selected.value[0]
|
||||
: selected.value;
|
||||
|
||||
if (!optionToSearch) return null;
|
||||
// extract the selected item from the options array
|
||||
// this ensures that options like icon is also included
|
||||
return options.find(option => option.id === optionToSearch.id);
|
||||
@@ -77,7 +89,7 @@ const toggleSelected = option => {
|
||||
};
|
||||
|
||||
if (selected.value && selected.value.id === optionToToggle.id) {
|
||||
selected.value = null;
|
||||
if (!disableDeselect) selected.value = null;
|
||||
} else {
|
||||
selected.value = optionToToggle;
|
||||
}
|
||||
@@ -124,7 +136,7 @@ const toggleSelected = option => {
|
||||
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
|
||||
/>
|
||||
</div>
|
||||
<DropdownSection class="[&>ul]:max-h-80">
|
||||
<DropdownSection :height="dropdownMaxHeight">
|
||||
<template v-if="searchResults.length">
|
||||
<DropdownItem
|
||||
v-for="option in searchResults"
|
||||
|
||||
@@ -36,6 +36,11 @@ const props = defineProps({
|
||||
},
|
||||
focusOnMount: { 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([
|
||||
@@ -56,7 +61,7 @@ const modelValue = defineModel({
|
||||
const tagInputRef = ref(null);
|
||||
const tags = ref(props.modelValue);
|
||||
const newTag = ref('');
|
||||
const isFocused = ref(true);
|
||||
const isFocused = ref(props.autoOpenDropdown);
|
||||
|
||||
const rules = computed(() => getValidationRules(props.type));
|
||||
const v$ = useVuelidate(rules, { newTag });
|
||||
@@ -74,11 +79,11 @@ const showInput = computed(() =>
|
||||
const showDropdownMenu = computed(() =>
|
||||
props.mode === MODE.SINGLE && tags.value.length >= 1
|
||||
? false
|
||||
: props.showDropdown
|
||||
: props.showDropdown && isFocused.value
|
||||
);
|
||||
|
||||
const filteredMenuItems = computed(() =>
|
||||
buildTagMenuItems({
|
||||
const filteredMenuItems = computed(() => {
|
||||
const items = buildTagMenuItems({
|
||||
mode: props.mode,
|
||||
tags: tags.value,
|
||||
menuItems: props.menuItems,
|
||||
@@ -86,8 +91,14 @@ const filteredMenuItems = computed(() =>
|
||||
isLoading: props.isLoading,
|
||||
type: props.type,
|
||||
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 matchingMenuItem = findMatchingMenuItem(props.menuItems, value);
|
||||
@@ -112,10 +123,13 @@ const addTag = async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
[INPUT_TYPES.EMAIL, INPUT_TYPES.TEL].includes(props.type) ||
|
||||
props.allowCreate
|
||||
) {
|
||||
const isValidatedType = [INPUT_TYPES.EMAIL, INPUT_TYPES.TEL].includes(
|
||||
props.type
|
||||
);
|
||||
|
||||
if (!isValidatedType && !props.allowCreate && props.showDropdown) return;
|
||||
|
||||
if (isValidatedType || props.allowCreate) {
|
||||
if (!(await v$.value.$validate())) return;
|
||||
emitDataOnAdd(trimmedTag);
|
||||
}
|
||||
@@ -125,28 +139,31 @@ const addTag = async () => {
|
||||
const removeTag = index => {
|
||||
tags.value.splice(index, 1);
|
||||
modelValue.value = tags.value;
|
||||
emit('remove');
|
||||
emit('remove', index);
|
||||
};
|
||||
|
||||
const handleDropdownAction = async ({
|
||||
email: emailAddress,
|
||||
phoneNumber,
|
||||
label,
|
||||
...rest
|
||||
}) => {
|
||||
if (props.mode === MODE.SINGLE && tags.value.length >= 1) return;
|
||||
if (!props.showDropdown) return;
|
||||
|
||||
const isEmail = props.type === 'email';
|
||||
newTag.value = isEmail ? emailAddress : phoneNumber;
|
||||
const isEmail = props.type === INPUT_TYPES.EMAIL;
|
||||
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
|
||||
? { email: emailAddress, ...rest }
|
||||
: { phoneNumber, ...rest };
|
||||
|
||||
emit('add', payload);
|
||||
updateValueAndFocus(emailAddress);
|
||||
emit(
|
||||
'add',
|
||||
isEmail ? { email: emailAddress, ...rest } : { phoneNumber, label, ...rest }
|
||||
);
|
||||
updateValueAndFocus(tagValue);
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
@@ -163,7 +180,7 @@ const handleKeydown = event => {
|
||||
};
|
||||
|
||||
const handleClickOutside = () => {
|
||||
if (tags.value.length) isFocused.value = false;
|
||||
isFocused.value = false;
|
||||
emit('onClickOutside');
|
||||
};
|
||||
|
||||
|
||||
@@ -261,6 +261,71 @@ describe('tagInputHelper', () => {
|
||||
});
|
||||
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', () => {
|
||||
|
||||
@@ -84,25 +84,29 @@ export const buildTagMenuItems = ({
|
||||
isLoading,
|
||||
type,
|
||||
isNewTagInValidType,
|
||||
allowCreate = true,
|
||||
skipLabelDedup = false,
|
||||
}) => {
|
||||
if (mode === MODE.SINGLE && tags.length >= 1) return [];
|
||||
|
||||
const availableMenuItems = menuItems.filter(
|
||||
item => !tags.includes(item.label)
|
||||
);
|
||||
const availableMenuItems = skipLabelDedup
|
||||
? menuItems
|
||||
: menuItems.filter(item => !tags.includes(item.label));
|
||||
|
||||
// Show typed value as suggestion only if:
|
||||
// 1. There's a value being typed
|
||||
// 2. The value isn't already in the tags
|
||||
// 3. Validation passes (email/phone) and There are no menu items available
|
||||
// 4. allowCreate is enabled
|
||||
const trimmedNewTag = newTag?.trim();
|
||||
const shouldShowTypedValue =
|
||||
const shouldShowCreateSuggestion =
|
||||
allowCreate &&
|
||||
trimmedNewTag &&
|
||||
!tags.includes(trimmedNewTag) &&
|
||||
!isLoading &&
|
||||
!availableMenuItems.length;
|
||||
|
||||
if (shouldShowTypedValue) {
|
||||
if (shouldShowCreateSuggestion) {
|
||||
const { isValid, formattedValue } = validateAndFormatNewTag(
|
||||
trimmedNewTag,
|
||||
type,
|
||||
|
||||
@@ -3,6 +3,9 @@ import AutomationActionTeamMessageInput from './AutomationActionTeamMessageInput
|
||||
import AutomationActionFileInput from './AutomationFileInput.vue';
|
||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.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 {
|
||||
components: {
|
||||
@@ -10,6 +13,9 @@ export default {
|
||||
AutomationActionFileInput,
|
||||
WootMessageEditor,
|
||||
NextButton,
|
||||
SingleSelect,
|
||||
MultiSelect,
|
||||
NextInput,
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
@@ -40,6 +46,10 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
dropdownMaxHeight: {
|
||||
type: String,
|
||||
default: 'max-h-80',
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue', 'input', 'removeAction', 'resetAction'],
|
||||
computed: {
|
||||
@@ -69,11 +79,21 @@ export default {
|
||||
return this.actionTypes.find(action => action.key === this.action_name)
|
||||
.inputType;
|
||||
},
|
||||
actionInputStyles() {
|
||||
return {
|
||||
'has-error': this.errorMessage,
|
||||
'is-a-macro': this.isMacro,
|
||||
};
|
||||
actionNameAsSelectModel: {
|
||||
get() {
|
||||
if (!this.action_name) return null;
|
||||
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: {
|
||||
get() {
|
||||
@@ -94,203 +114,89 @@ export default {
|
||||
resetAction() {
|
||||
this.$emit('resetAction');
|
||||
},
|
||||
onActionNameChange(value) {
|
||||
this.actionNameAsSelectModel = value;
|
||||
this.resetAction();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="filter" :class="actionInputStyles">
|
||||
<div class="filter-inputs">
|
||||
<select
|
||||
v-model="action_name"
|
||||
class="action__question"
|
||||
:class="{ 'full-width': !showActionInput }"
|
||||
@change="resetAction()"
|
||||
>
|
||||
<option
|
||||
v-for="attribute in actionTypes"
|
||||
:key="attribute.key"
|
||||
:value="attribute.key"
|
||||
>
|
||||
{{ attribute.label }}
|
||||
</option>
|
||||
</select>
|
||||
<div v-if="showActionInput" class="filter__answer--wrap">
|
||||
<div v-if="inputType" class="w-full">
|
||||
<div
|
||||
<li class="list-none py-2 first:pt-0 last:pb-0">
|
||||
<div
|
||||
class="flex flex-col gap-2"
|
||||
:class="{ 'animate-wiggle': errorMessage }"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<SingleSelect
|
||||
:model-value="actionNameAsSelectModel"
|
||||
:options="actionTypesAsOptions"
|
||||
:dropdown-max-height="dropdownMaxHeight"
|
||||
disable-deselect
|
||||
class="flex-shrink-0"
|
||||
@update:model-value="onActionNameChange"
|
||||
/>
|
||||
<template v-if="showActionInput && !isVerticalLayout">
|
||||
<SingleSelect
|
||||
v-if="inputType === 'search_select'"
|
||||
class="multiselect-wrap--small"
|
||||
>
|
||||
<multiselect
|
||||
v-model="action_params"
|
||||
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-model="action_params"
|
||||
:options="dropdownValues"
|
||||
:dropdown-max-height="dropdownMaxHeight"
|
||||
/>
|
||||
<MultiSelect
|
||||
v-else-if="inputType === 'multi_select'"
|
||||
class="multiselect-wrap--small"
|
||||
>
|
||||
<multiselect
|
||||
v-model="action_params"
|
||||
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"
|
||||
:option-height="104"
|
||||
>
|
||||
<template #noOptions>
|
||||
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
|
||||
</template>
|
||||
</multiselect>
|
||||
</div>
|
||||
<input
|
||||
v-model="action_params"
|
||||
:options="dropdownValues"
|
||||
:dropdown-max-height="dropdownMaxHeight"
|
||||
/>
|
||||
<NextInput
|
||||
v-else-if="inputType === 'email'"
|
||||
v-model="action_params"
|
||||
type="email"
|
||||
class="answer--text-input"
|
||||
size="sm"
|
||||
:placeholder="$t('AUTOMATION.ACTION.EMAIL_INPUT_PLACEHOLDER')"
|
||||
/>
|
||||
<input
|
||||
<NextInput
|
||||
v-else-if="inputType === 'url'"
|
||||
v-model="action_params"
|
||||
type="url"
|
||||
class="answer--text-input"
|
||||
size="sm"
|
||||
:placeholder="$t('AUTOMATION.ACTION.URL_INPUT_PLACEHOLDER')"
|
||||
/>
|
||||
<AutomationActionFileInput
|
||||
v-if="inputType === 'attachment'"
|
||||
v-else-if="inputType === 'attachment'"
|
||||
v-model="action_params"
|
||||
:initial-file-name="initialFileName"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<NextButton
|
||||
v-if="!isMacro"
|
||||
sm
|
||||
solid
|
||||
slate
|
||||
icon="i-lucide-trash"
|
||||
class="flex-shrink-0"
|
||||
@click="removeAction"
|
||||
/>
|
||||
</div>
|
||||
<NextButton
|
||||
v-if="!isMacro"
|
||||
icon="i-lucide-x"
|
||||
slate
|
||||
ghost
|
||||
class="flex-shrink-0"
|
||||
@click="removeAction"
|
||||
<AutomationActionTeamMessageInput
|
||||
v-if="inputType === 'team_message'"
|
||||
v-model="action_params"
|
||||
:teams="dropdownValues"
|
||||
:dropdown-max-height="dropdownMaxHeight"
|
||||
/>
|
||||
<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>
|
||||
<AutomationActionTeamMessageInput
|
||||
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">
|
||||
<span v-if="errorMessage" class="text-sm text-n-ruby-11">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</span>
|
||||
</li>
|
||||
</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>
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
<script>
|
||||
import MultiSelect from 'dashboard/components-next/filter/inputs/MultiSelect.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
MultiSelect,
|
||||
},
|
||||
props: {
|
||||
teams: { type: Array, required: true },
|
||||
modelValue: { type: Object, required: true },
|
||||
dropdownMaxHeight: { type: String, default: 'max-h-80' },
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
data() {
|
||||
@@ -12,9 +18,9 @@ export default {
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
const { team_ids: teamIds } = this.modelValue;
|
||||
this.selectedTeams = teamIds;
|
||||
this.message = this.modelValue.message;
|
||||
const { team_ids: teamIds, message } = this.modelValue || {};
|
||||
this.selectedTeams = teamIds || [];
|
||||
this.message = message || '';
|
||||
},
|
||||
methods: {
|
||||
updateValue() {
|
||||
@@ -28,37 +34,19 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="multiselect-wrap--small flex flex-col gap-1 mt-1">
|
||||
<multiselect
|
||||
v-model="selectedTeams"
|
||||
track-by="id"
|
||||
label="name"
|
||||
:placeholder="$t('AUTOMATION.ACTION.TEAM_DROPDOWN_PLACEHOLDER')"
|
||||
multiple
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
deselect-label=""
|
||||
:max-height="160"
|
||||
:options="teams"
|
||||
: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 class="flex flex-col gap-2">
|
||||
<MultiSelect
|
||||
v-model="selectedTeams"
|
||||
:options="teams"
|
||||
:dropdown-max-height="dropdownMaxHeight"
|
||||
@update:model-value="updateValue"
|
||||
/>
|
||||
<textarea
|
||||
v-model="message"
|
||||
class="mb-0 !text-sm"
|
||||
rows="4"
|
||||
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
|
||||
@input="updateValue"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.multiselect {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
textarea {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -79,7 +79,7 @@ input[type='file'] {
|
||||
@apply hidden;
|
||||
}
|
||||
.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 {
|
||||
@apply text-n-teal-9 mr-2;
|
||||
|
||||
@@ -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>
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import { ref, reactive, computed } from 'vue';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
@@ -40,7 +40,7 @@ export function useAutomation(startValue = null) {
|
||||
} = useAutomationValues();
|
||||
|
||||
const automation = ref(startValue);
|
||||
const automationTypes = structuredClone(AUTOMATIONS);
|
||||
const automationTypes = reactive(structuredClone(AUTOMATIONS));
|
||||
const eventName = computed(() => automation.value?.event_name);
|
||||
|
||||
/**
|
||||
@@ -160,14 +160,24 @@ export function useAutomation(startValue = null) {
|
||||
t('AUTOMATION.CONDITION.CONTACT_CUSTOM_ATTR_LABEL')
|
||||
);
|
||||
|
||||
const CUSTOM_ATTR_HEADER_KEYS = new Set([
|
||||
'conversation_custom_attribute',
|
||||
'contact_custom_attribute',
|
||||
]);
|
||||
|
||||
[
|
||||
'message_created',
|
||||
'conversation_created',
|
||||
'conversation_updated',
|
||||
'conversation_opened',
|
||||
].forEach(eventToUpdate => {
|
||||
const standardConditions = automationTypes[
|
||||
eventToUpdate
|
||||
].conditions.filter(
|
||||
c => !c.customAttributeType && !CUSTOM_ATTR_HEADER_KEYS.has(c.key)
|
||||
);
|
||||
automationTypes[eventToUpdate].conditions = [
|
||||
...automationTypes[eventToUpdate].conditions,
|
||||
...standardConditions,
|
||||
...manifestedCustomAttributes,
|
||||
];
|
||||
});
|
||||
|
||||
@@ -169,19 +169,19 @@ export const getFileName = (action, files = []) => {
|
||||
|
||||
export const getDefaultConditions = eventName => {
|
||||
if (eventName === 'message_created') {
|
||||
return DEFAULT_MESSAGE_CREATED_CONDITION;
|
||||
return structuredClone(DEFAULT_MESSAGE_CREATED_CONDITION);
|
||||
}
|
||||
if (
|
||||
eventName === 'conversation_opened' ||
|
||||
eventName === 'conversation_resolved'
|
||||
) {
|
||||
return DEFAULT_CONVERSATION_CONDITION;
|
||||
return structuredClone(DEFAULT_CONVERSATION_CONDITION);
|
||||
}
|
||||
return DEFAULT_OTHER_CONDITION;
|
||||
return structuredClone(DEFAULT_OTHER_CONDITION);
|
||||
};
|
||||
|
||||
export const getDefaultActions = () => {
|
||||
return DEFAULT_ACTIONS;
|
||||
return structuredClone(DEFAULT_ACTIONS);
|
||||
};
|
||||
|
||||
export const filterCustomAttributes = customAttributes => {
|
||||
|
||||
@@ -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 { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
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 { mapGetters } from 'vuex';
|
||||
import { CONTACTS_EVENTS } from '../../helper/AnalyticsHelper/events';
|
||||
|
||||
export default {
|
||||
components: { MergeContact },
|
||||
props: {
|
||||
show: {
|
||||
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);
|
||||
},
|
||||
},
|
||||
const props = defineProps({
|
||||
primaryContact: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
methods: {
|
||||
onClose() {
|
||||
this.$emit('close');
|
||||
},
|
||||
async onContactSearch(query) {
|
||||
this.isSearching = true;
|
||||
this.searchResults = [];
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
try {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await ContactAPI.search(query);
|
||||
this.searchResults = payload.filter(
|
||||
contact => contact.id !== this.primaryContact.id
|
||||
);
|
||||
} catch (error) {
|
||||
useAlert(this.$t('MERGE_CONTACTS.SEARCH.ERROR_MESSAGE'));
|
||||
} finally {
|
||||
this.isSearching = false;
|
||||
}
|
||||
},
|
||||
async onMergeContacts(parentContactId) {
|
||||
useTrack(CONTACTS_EVENTS.MERGED_CONTACTS);
|
||||
try {
|
||||
await this.$store.dispatch('contacts/merge', {
|
||||
childId: this.primaryContact.id,
|
||||
parentId: parentContactId,
|
||||
});
|
||||
useAlert(this.$t('MERGE_CONTACTS.FORM.SUCCESS_MESSAGE'));
|
||||
this.onClose();
|
||||
} catch (error) {
|
||||
useAlert(this.$t('MERGE_CONTACTS.FORM.ERROR_MESSAGE'));
|
||||
}
|
||||
},
|
||||
},
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const uiFlags = useMapGetter('contacts/getUIFlags');
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const isSearching = ref(false);
|
||||
const searchResults = ref([]);
|
||||
|
||||
watch(
|
||||
() => props.primaryContact.id,
|
||||
() => {
|
||||
isSearching.value = false;
|
||||
searchResults.value = [];
|
||||
}
|
||||
);
|
||||
|
||||
const open = () => {
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
<woot-modal v-model:show="localShow" :on-close="onClose">
|
||||
<woot-modal-header
|
||||
:header-title="$t('MERGE_CONTACTS.TITLE')"
|
||||
:header-content="$t('MERGE_CONTACTS.DESCRIPTION')"
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="edit"
|
||||
width="2xl"
|
||||
:title="$t('MERGE_CONTACTS.TITLE')"
|
||||
:description="$t('MERGE_CONTACTS.DESCRIPTION')"
|
||||
:show-cancel-button="false"
|
||||
:show-confirm-button="false"
|
||||
>
|
||||
<MergeContact
|
||||
:key="primaryContact.id"
|
||||
:primary-contact="primaryContact"
|
||||
:is-searching="isSearching"
|
||||
:is-merging="uiFlags.isMerging"
|
||||
@@ -94,5 +104,5 @@ export default {
|
||||
@cancel="onClose"
|
||||
@submit="onMergeContacts"
|
||||
/>
|
||||
</woot-modal>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
@@ -1,174 +1,105 @@
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
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';
|
||||
|
||||
export default {
|
||||
components: { MergeContactSummary, ContactDropdownItem, NextButton },
|
||||
props: {
|
||||
primaryContact: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isSearching: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isMerging: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
searchResults: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
const props = defineProps({
|
||||
primaryContact: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
emits: ['search', 'submit', 'cancel'],
|
||||
setup() {
|
||||
return { v$: useVuelidate() };
|
||||
isSearching: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
validations: {
|
||||
primaryContact: {
|
||||
required,
|
||||
},
|
||||
parentContact: {
|
||||
required,
|
||||
},
|
||||
isMerging: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
parentContact: undefined,
|
||||
};
|
||||
searchResults: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
computed: {
|
||||
parentContactName() {
|
||||
return this.parentContact ? this.parentContact.name : '';
|
||||
const emit = defineEmits(['search', 'submit', 'cancel']);
|
||||
|
||||
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);
|
||||
},
|
||||
onSubmit() {
|
||||
this.v$.$touch();
|
||||
if (this.v$.$invalid) {
|
||||
return;
|
||||
}
|
||||
this.$emit('submit', this.parentContact.id);
|
||||
},
|
||||
onCancel() {
|
||||
this.$emit('cancel');
|
||||
},
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
const hasValidationError = computed(() => v$.value.parentContactId.$error);
|
||||
const validationErrorMessage = computed(() => {
|
||||
if (v$.value.parentContactId.$error) {
|
||||
return t('MERGE_CONTACTS.FORM.CHILD_CONTACT.ERROR');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const onSearch = query => {
|
||||
emit('search', query);
|
||||
};
|
||||
|
||||
const onSubmit = () => {
|
||||
v$.value.$touch();
|
||||
if (v$.value.$invalid) {
|
||||
return;
|
||||
}
|
||||
emit('submit', parentContactId.value);
|
||||
};
|
||||
|
||||
const onCancel = () => {
|
||||
emit('cancel');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<div>
|
||||
<div>
|
||||
<div
|
||||
class="mt-1 multiselect-wrap--medium"
|
||||
:class="{ error: v$.parentContact.$error }"
|
||||
>
|
||||
<label class="multiselect__label">
|
||||
{{ $t('MERGE_CONTACTS.PARENT.TITLE') }}
|
||||
<woot-label
|
||||
: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>
|
||||
<ContactMergeForm
|
||||
:selected-contact="primaryContact"
|
||||
:primary-contact-id="parentContactId"
|
||||
:primary-contact-list="primaryContactList"
|
||||
:is-searching="isSearching"
|
||||
:has-error="hasValidationError"
|
||||
:error-message="validationErrorMessage"
|
||||
@update:primary-contact-id="parentContactId = $event"
|
||||
@search="onSearch"
|
||||
/>
|
||||
<MergeContactSummary
|
||||
:primary-contact-name="primaryContact.name"
|
||||
:parent-contact-name="parentContactName"
|
||||
@@ -189,32 +120,3 @@ export default {
|
||||
</div>
|
||||
</form>
|
||||
</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>
|
||||
|
||||
@@ -11,11 +11,13 @@ import { isPhoneNumberValid } from 'shared/helpers/Validators';
|
||||
import parsePhoneNumber from 'libphonenumber-js';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NextButton,
|
||||
Avatar,
|
||||
ComboBox,
|
||||
},
|
||||
props: {
|
||||
contact: {
|
||||
@@ -133,6 +135,12 @@ export default {
|
||||
if (!name && !id) return '';
|
||||
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() {
|
||||
if (
|
||||
this.phoneNumber !== '' &&
|
||||
@@ -363,26 +371,23 @@ export default {
|
||||
:label="$t('CONTACT_FORM.FORM.COMPANY_NAME.LABEL')"
|
||||
:placeholder="$t('CONTACT_FORM.FORM.COMPANY_NAME.PLACEHOLDER')"
|
||||
/>
|
||||
<div>
|
||||
<div class="w-full">
|
||||
<label>
|
||||
{{ $t('CONTACT_FORM.FORM.COUNTRY.LABEL') }}
|
||||
</label>
|
||||
<multiselect
|
||||
v-model="country"
|
||||
track-by="id"
|
||||
label="name"
|
||||
:placeholder="$t('CONTACT_FORM.FORM.COUNTRY.PLACEHOLDER')"
|
||||
selected-label
|
||||
:select-label="$t('CONTACT_FORM.FORM.COUNTRY.SELECT_PLACEHOLDER')"
|
||||
:deselect-label="$t('CONTACT_FORM.FORM.COUNTRY.REMOVE')"
|
||||
:custom-label="countryNameWithCode"
|
||||
:max-height="160"
|
||||
:options="countries"
|
||||
allow-empty
|
||||
:option-height="104"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full mb-4">
|
||||
<label>
|
||||
{{ $t('CONTACT_FORM.FORM.COUNTRY.LABEL') }}
|
||||
</label>
|
||||
<ComboBox
|
||||
:model-value="country.id"
|
||||
:options="
|
||||
countries.map(c => ({
|
||||
value: c.id,
|
||||
label: countryNameWithCode(c),
|
||||
}))
|
||||
"
|
||||
class="[&>div>button]:!bg-n-alpha-black2"
|
||||
:placeholder="$t('CONTACT_FORM.FORM.COUNTRY.PLACEHOLDER')"
|
||||
:search-placeholder="$t('CONTACT_FORM.FORM.COUNTRY.SELECT_PLACEHOLDER')"
|
||||
@update:model-value="onCountryChange"
|
||||
/>
|
||||
</div>
|
||||
<woot-input
|
||||
v-model="city"
|
||||
@@ -426,11 +431,3 @@ export default {
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
::v-deep {
|
||||
.multiselect .multiselect__tags .multiselect__single {
|
||||
@apply pl-0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -51,7 +51,6 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
showEditModal: false,
|
||||
showMergeModal: false,
|
||||
showDeleteModal: false,
|
||||
};
|
||||
},
|
||||
@@ -167,11 +166,8 @@ export default {
|
||||
);
|
||||
}
|
||||
},
|
||||
closeMergeModal() {
|
||||
this.showMergeModal = false;
|
||||
},
|
||||
openMergeModal() {
|
||||
this.showMergeModal = true;
|
||||
this.$refs.mergeModal?.open();
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -324,12 +320,7 @@ export default {
|
||||
:contact="contact"
|
||||
@cancel="toggleEditModal"
|
||||
/>
|
||||
<ContactMergeModal
|
||||
v-if="showMergeModal"
|
||||
:primary-contact="contact"
|
||||
:show="showMergeModal"
|
||||
@close="closeMergeModal"
|
||||
/>
|
||||
<ContactMergeModal ref="mergeModal" :primary-contact="contact" />
|
||||
</div>
|
||||
<woot-delete-modal
|
||||
v-if="showDeleteModal"
|
||||
|
||||
@@ -7,10 +7,12 @@ import { convertToAttributeSlug } from 'dashboard/helper/commons.js';
|
||||
import { ATTRIBUTE_MODELS, ATTRIBUTE_TYPES } from './constants';
|
||||
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NextButton,
|
||||
TagInput,
|
||||
},
|
||||
props: {
|
||||
onClose: {
|
||||
@@ -41,9 +43,8 @@ export default {
|
||||
regexCue: null,
|
||||
regexEnabled: false,
|
||||
values: [],
|
||||
options: [],
|
||||
show: true,
|
||||
isTouched: false,
|
||||
tagInputTouched: false,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -63,21 +64,21 @@ export default {
|
||||
option: this.$t(`ATTRIBUTES_MGMT.ATTRIBUTE_TYPES.${item.key}`),
|
||||
}));
|
||||
},
|
||||
isMultiselectInvalid() {
|
||||
return this.isTouched && this.values.length === 0;
|
||||
},
|
||||
isTagInputInvalid() {
|
||||
isTagInputEmpty() {
|
||||
return this.isAttributeTypeList && this.values.length === 0;
|
||||
},
|
||||
isTagInputInvalid() {
|
||||
return this.tagInputTouched && this.isTagInputEmpty;
|
||||
},
|
||||
attributeListValues() {
|
||||
return this.values.map(item => item.name);
|
||||
return this.values;
|
||||
},
|
||||
isButtonDisabled() {
|
||||
return (
|
||||
this.v$.displayName.$invalid ||
|
||||
this.v$.description.$invalid ||
|
||||
this.uiFlags.isCreating ||
|
||||
this.isTagInputInvalid
|
||||
this.isTagInputEmpty
|
||||
);
|
||||
},
|
||||
keyErrorMessage() {
|
||||
@@ -119,17 +120,14 @@ export default {
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
attributeType() {
|
||||
this.tagInputTouched = false;
|
||||
this.values = [];
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
addTagValue(tagValue) {
|
||||
const tag = {
|
||||
name: tagValue,
|
||||
};
|
||||
this.values.push(tag);
|
||||
this.$refs.tagInput.$el.focus();
|
||||
},
|
||||
onTouch() {
|
||||
this.isTouched = true;
|
||||
},
|
||||
onDisplayNameChange() {
|
||||
this.attributeKey = convertToAttributeSlug(this.displayName);
|
||||
},
|
||||
@@ -237,27 +235,25 @@ export default {
|
||||
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
<div v-if="isAttributeTypeList" class="multiselect--wrap">
|
||||
<label>
|
||||
<div v-if="isAttributeTypeList" class="mb-4">
|
||||
<label class="mb-1 block">
|
||||
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.LABEL') }}
|
||||
</label>
|
||||
<multiselect
|
||||
ref="tagInput"
|
||||
v-model="values"
|
||||
:placeholder="
|
||||
$t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.PLACEHOLDER')
|
||||
"
|
||||
label="name"
|
||||
track-by="name"
|
||||
:class="{ invalid: isMultiselectInvalid }"
|
||||
:options="options"
|
||||
multiple
|
||||
taggable
|
||||
@close="onTouch"
|
||||
@tag="addTagValue"
|
||||
/>
|
||||
<div
|
||||
class="rounded-xl border px-3 py-2"
|
||||
:class="isTagInputInvalid ? 'border-n-ruby-9' : 'border-n-weak'"
|
||||
>
|
||||
<TagInput
|
||||
v-model="values"
|
||||
:placeholder="
|
||||
$t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.PLACEHOLDER')
|
||||
"
|
||||
allow-create
|
||||
@blur="tagInputTouched = true"
|
||||
/>
|
||||
</div>
|
||||
<label
|
||||
v-show="isMultiselectInvalid"
|
||||
v-show="isTagInputInvalid"
|
||||
class="text-n-ruby-9 dark:text-n-ruby-9 text-sm font-normal mt-1"
|
||||
>
|
||||
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.ERROR') }}
|
||||
@@ -312,22 +308,4 @@ export default {
|
||||
padding: 0 0.5rem 0.5rem 0;
|
||||
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>
|
||||
|
||||
@@ -5,10 +5,12 @@ import { required, minLength } from '@vuelidate/validators';
|
||||
import { getRegexp } from 'shared/helpers/Validators';
|
||||
import { ATTRIBUTE_TYPES } from './constants';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NextButton,
|
||||
TagInput,
|
||||
},
|
||||
props: {
|
||||
selectedAttribute: {
|
||||
@@ -35,8 +37,7 @@ export default {
|
||||
show: true,
|
||||
attributeKey: '',
|
||||
values: [],
|
||||
options: [],
|
||||
isTouched: true,
|
||||
tagInputTouched: false,
|
||||
};
|
||||
},
|
||||
validations: {
|
||||
@@ -65,20 +66,19 @@ export default {
|
||||
}));
|
||||
},
|
||||
setAttributeListValue() {
|
||||
return this.selectedAttribute.attribute_values.map(values => ({
|
||||
name: values,
|
||||
}));
|
||||
return this.selectedAttribute.attribute_values || [];
|
||||
},
|
||||
updatedAttributeListValues() {
|
||||
return this.values.map(item => item.name);
|
||||
return this.values;
|
||||
},
|
||||
isButtonDisabled() {
|
||||
return this.v$.description.$invalid || this.isMultiselectInvalid;
|
||||
return this.v$.description.$invalid || this.isTagInputEmpty;
|
||||
},
|
||||
isMultiselectInvalid() {
|
||||
return (
|
||||
this.isAttributeTypeList && this.isTouched && this.values.length === 0
|
||||
);
|
||||
isTagInputEmpty() {
|
||||
return this.isAttributeTypeList && this.values.length === 0;
|
||||
},
|
||||
isTagInputInvalid() {
|
||||
return this.tagInputTouched && this.isTagInputEmpty;
|
||||
},
|
||||
|
||||
pageTitle() {
|
||||
@@ -116,13 +116,6 @@ export default {
|
||||
onClose() {
|
||||
this.$emit('onClose');
|
||||
},
|
||||
addTagValue(tagValue) {
|
||||
const tag = {
|
||||
name: tagValue,
|
||||
};
|
||||
this.values.push(tag);
|
||||
this.$refs.tagInput.$el.focus();
|
||||
},
|
||||
setFormValues() {
|
||||
const regexPattern = this.selectedAttribute.regex_pattern
|
||||
? getRegexp(this.selectedAttribute.regex_pattern).source
|
||||
@@ -225,24 +218,25 @@ export default {
|
||||
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
<div v-if="isAttributeTypeList" class="multiselect--wrap">
|
||||
<label>
|
||||
<div v-if="isAttributeTypeList" class="mb-4">
|
||||
<label class="mb-1 block">
|
||||
{{ $t('ATTRIBUTES_MGMT.EDIT.TYPE.LIST.LABEL') }}
|
||||
</label>
|
||||
<multiselect
|
||||
ref="tagInput"
|
||||
v-model="values"
|
||||
:placeholder="$t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.PLACEHOLDER')"
|
||||
label="name"
|
||||
track-by="name"
|
||||
:class="{ invalid: isMultiselectInvalid }"
|
||||
:options="options"
|
||||
multiple
|
||||
taggable
|
||||
@tag="addTagValue"
|
||||
/>
|
||||
<div
|
||||
class="rounded-xl border px-3 py-2"
|
||||
:class="isTagInputInvalid ? 'border-n-ruby-9' : 'border-n-weak'"
|
||||
>
|
||||
<TagInput
|
||||
v-model="values"
|
||||
:placeholder="
|
||||
$t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.PLACEHOLDER')
|
||||
"
|
||||
allow-create
|
||||
@blur="tagInputTouched = true"
|
||||
/>
|
||||
</div>
|
||||
<label
|
||||
v-show="isMultiselectInvalid"
|
||||
v-show="isTagInputInvalid"
|
||||
class="text-n-ruby-9 dark:text-n-ruby-9 text-sm font-normal mt-1"
|
||||
>
|
||||
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.ERROR') }}
|
||||
@@ -297,22 +291,4 @@ export default {
|
||||
padding: 0 0.5rem 0.5rem 0;
|
||||
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>
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import FilterInputBox from 'dashboard/components/widgets/FilterInput/Index.vue';
|
||||
import AutomationActionInput from 'dashboard/components/widgets/AutomationActionInput.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAutomation } from 'dashboard/composables/useAutomation';
|
||||
import { validateAutomation } from 'dashboard/helper/validations';
|
||||
import {
|
||||
generateAutomationPayload,
|
||||
getAttributes,
|
||||
getInputType,
|
||||
getOperators,
|
||||
getCustomAttributeType,
|
||||
showActionInput,
|
||||
} from 'dashboard/helper/automationHelper';
|
||||
import { AUTOMATION_RULE_EVENTS, AUTOMATION_ACTION_TYPES } from './constants';
|
||||
import AutomationRuleForm from './AutomationRuleForm.vue';
|
||||
|
||||
const start_value = {
|
||||
const emit = defineEmits(['saveAutomation']);
|
||||
|
||||
const START_VALUE = {
|
||||
name: null,
|
||||
description: null,
|
||||
event_name: 'conversation_created',
|
||||
@@ -36,318 +27,60 @@ const start_value = {
|
||||
],
|
||||
};
|
||||
|
||||
export default {
|
||||
components: {
|
||||
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');
|
||||
const store = useStore();
|
||||
const formRef = ref(null);
|
||||
|
||||
return actionTypes.map(action => ({
|
||||
...action,
|
||||
label: this.$t(`AUTOMATION.ACTIONS.${action.label}`),
|
||||
}));
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('inboxes/get');
|
||||
this.$store.dispatch('agents/get');
|
||||
this.$store.dispatch('contacts/get');
|
||||
this.$store.dispatch('teams/get');
|
||||
this.$store.dispatch('labels/get');
|
||||
this.$store.dispatch('campaigns/get');
|
||||
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);
|
||||
const {
|
||||
automation,
|
||||
automationTypes,
|
||||
onEventChange,
|
||||
getConditionDropdownValues,
|
||||
appendNewCondition,
|
||||
appendNewAction,
|
||||
removeFilter,
|
||||
removeAction,
|
||||
resetAction,
|
||||
getActionDropdownValues,
|
||||
manifestCustomAttributes,
|
||||
} = useAutomation(START_VALUE);
|
||||
|
||||
return {
|
||||
...attribute,
|
||||
name: skipTranslation
|
||||
? attribute.name
|
||||
: this.$t(`AUTOMATION.ATTRIBUTES.${attribute.name}`),
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
const open = () => {
|
||||
automation.value = structuredClone(START_VALUE);
|
||||
manifestCustomAttributes();
|
||||
formRef.value?.open();
|
||||
};
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<woot-modal-header :header-title="$t('AUTOMATION.ADD.TITLE')" />
|
||||
<div class="flex flex-col modal-content">
|
||||
<div class="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(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>
|
||||
<AutomationRuleForm
|
||||
ref="formRef"
|
||||
v-model:automation="automation"
|
||||
mode="create"
|
||||
:automation-types="automationTypes"
|
||||
:get-condition-dropdown-values="getConditionDropdownValues"
|
||||
:get-action-dropdown-values="getActionDropdownValues"
|
||||
:append-new-condition="appendNewCondition"
|
||||
:append-new-action="appendNewAction"
|
||||
:remove-filter="removeFilter"
|
||||
:remove-action="removeAction"
|
||||
:reset-action="resetAction"
|
||||
:on-event-change="onEventChange"
|
||||
@save="onSave"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
@@ -1,345 +1,80 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAutomation } from 'dashboard/composables/useAutomation';
|
||||
import { useEditableAutomation } from 'dashboard/composables/useEditableAutomation';
|
||||
import FilterInputBox from 'dashboard/components/widgets/FilterInput/Index.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
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 AutomationRuleForm from './AutomationRuleForm.vue';
|
||||
import { AUTOMATION_ACTION_TYPES } from './constants';
|
||||
|
||||
import { AUTOMATION_RULE_EVENTS, AUTOMATION_ACTION_TYPES } from './constants';
|
||||
const props = defineProps({
|
||||
selectedResponse: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
export default {
|
||||
components: {
|
||||
FilterInputBox,
|
||||
NextButton,
|
||||
AutomationActionInput,
|
||||
},
|
||||
props: {
|
||||
onClose: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
selectedResponse: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
emits: ['saveAutomation'],
|
||||
setup() {
|
||||
const {
|
||||
automation,
|
||||
const emit = defineEmits(['saveAutomation']);
|
||||
|
||||
const allCustomAttributes = useMapGetter('attributes/getAttributes');
|
||||
const formRef = ref(null);
|
||||
|
||||
const {
|
||||
automation,
|
||||
automationTypes,
|
||||
onEventChange,
|
||||
getConditionDropdownValues,
|
||||
appendNewCondition,
|
||||
appendNewAction,
|
||||
removeFilter,
|
||||
removeAction,
|
||||
resetAction,
|
||||
getActionDropdownValues,
|
||||
manifestCustomAttributes,
|
||||
} = useAutomation();
|
||||
|
||||
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,
|
||||
onEventChange,
|
||||
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
|
||||
AUTOMATION_ACTION_TYPES
|
||||
);
|
||||
},
|
||||
methods: {
|
||||
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);
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
return {
|
||||
...attribute,
|
||||
name: skipTranslation
|
||||
? attribute.name
|
||||
: this.$t(`AUTOMATION.ATTRIBUTES.${attribute.name}`),
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<woot-modal-header :header-title="$t('AUTOMATION.EDIT.TITLE')" />
|
||||
<div class="flex flex-col modal-content">
|
||||
<div v-if="automation" class="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="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>
|
||||
<AutomationRuleForm
|
||||
ref="formRef"
|
||||
v-model:automation="automation"
|
||||
mode="edit"
|
||||
:automation-types="automationTypes"
|
||||
:get-condition-dropdown-values="getConditionDropdownValues"
|
||||
:get-action-dropdown-values="getActionDropdownValues"
|
||||
:append-new-condition="appendNewCondition"
|
||||
:append-new-action="appendNewAction"
|
||||
:remove-filter="removeFilter"
|
||||
:remove-action="removeAction"
|
||||
:reset-action="resetAction"
|
||||
:on-event-change="onEventChange"
|
||||
@save="onSave"
|
||||
/>
|
||||
</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>
|
||||
|
||||
@@ -16,8 +16,8 @@ const { t } = useI18n();
|
||||
const confirmDialog = ref(null);
|
||||
|
||||
const loading = ref({});
|
||||
const showAddPopup = ref(false);
|
||||
const showEditPopup = ref(false);
|
||||
const addDialogRef = ref(null);
|
||||
const editDialogRef = ref(null);
|
||||
const showDeleteConfirmationPopup = ref(false);
|
||||
const selectedAutomation = ref({});
|
||||
const toggleModalTitle = ref(t('AUTOMATION.TOGGLE.ACTIVATION_TITLE'));
|
||||
@@ -57,18 +57,18 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
const openAddPopup = () => {
|
||||
showAddPopup.value = true;
|
||||
addDialogRef.value?.open();
|
||||
};
|
||||
const hideAddPopup = () => {
|
||||
showAddPopup.value = false;
|
||||
addDialogRef.value?.close();
|
||||
};
|
||||
|
||||
const openEditPopup = response => {
|
||||
selectedAutomation.value = response;
|
||||
showEditPopup.value = true;
|
||||
selectedAutomation.value = { ...response };
|
||||
editDialogRef.value?.open();
|
||||
};
|
||||
const hideEditPopup = () => {
|
||||
showEditPopup.value = false;
|
||||
editDialogRef.value?.close();
|
||||
};
|
||||
|
||||
const openDeletePopup = response => {
|
||||
@@ -221,17 +221,7 @@ const tableHeaders = computed(() => {
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<woot-modal
|
||||
v-model:show="showAddPopup"
|
||||
size="medium"
|
||||
:on-close="hideAddPopup"
|
||||
>
|
||||
<AddAutomationRule
|
||||
v-if="showAddPopup"
|
||||
:on-close="hideAddPopup"
|
||||
@save-automation="submitAutomation"
|
||||
/>
|
||||
</woot-modal>
|
||||
<AddAutomationRule ref="addDialogRef" @save-automation="submitAutomation" />
|
||||
|
||||
<woot-delete-modal
|
||||
v-model:show="showDeleteConfirmationPopup"
|
||||
@@ -244,18 +234,11 @@ const tableHeaders = computed(() => {
|
||||
:reject-text="deleteRejectText"
|
||||
/>
|
||||
|
||||
<woot-modal
|
||||
v-model:show="showEditPopup"
|
||||
size="medium"
|
||||
:on-close="hideEditPopup"
|
||||
>
|
||||
<EditAutomationRule
|
||||
v-if="showEditPopup"
|
||||
:on-close="hideEditPopup"
|
||||
:selected-response="selectedAutomation"
|
||||
@save-automation="submitAutomation"
|
||||
/>
|
||||
</woot-modal>
|
||||
<EditAutomationRule
|
||||
ref="editDialogRef"
|
||||
:selected-response="selectedAutomation"
|
||||
@save-automation="submitAutomation"
|
||||
/>
|
||||
<woot-confirm-modal
|
||||
ref="confirmDialog"
|
||||
:title="toggleModalTitle"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAlert } from 'dashboard/composables';
|
||||
|
||||
import InboxMembersAPI from '../../../../api/inboxMembers';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
|
||||
import router from '../../../index';
|
||||
import PageHeader from '../SettingsSubPageHeader.vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
@@ -13,11 +14,12 @@ export default {
|
||||
components: {
|
||||
PageHeader,
|
||||
NextButton,
|
||||
TagInput,
|
||||
},
|
||||
validations: {
|
||||
selectedAgents: {
|
||||
selectedAgentIds: {
|
||||
isEmpty() {
|
||||
return !!this.selectedAgents.length;
|
||||
return !!this.selectedAgentIds.length;
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -26,7 +28,7 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedAgents: [],
|
||||
selectedAgentIds: [],
|
||||
isCreating: false,
|
||||
};
|
||||
},
|
||||
@@ -34,18 +36,43 @@ export default {
|
||||
...mapGetters({
|
||||
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() {
|
||||
this.$store.dispatch('agents/get');
|
||||
},
|
||||
methods: {
|
||||
handleAgentAdd({ value }) {
|
||||
if (!this.selectedAgentIds.includes(value)) {
|
||||
this.selectedAgentIds.push(value);
|
||||
}
|
||||
},
|
||||
handleAgentRemove(index) {
|
||||
this.selectedAgentIds.splice(index, 1);
|
||||
},
|
||||
async addAgents() {
|
||||
this.isCreating = true;
|
||||
const inboxId = this.$route.params.inbox_id;
|
||||
const selectedAgents = this.selectedAgents.map(x => x.id);
|
||||
|
||||
try {
|
||||
await InboxMembersAPI.update({ inboxId, agentList: selectedAgents });
|
||||
await InboxMembersAPI.update({
|
||||
inboxId,
|
||||
agentList: this.selectedAgentIds,
|
||||
});
|
||||
router.replace({
|
||||
name: 'settings_inbox_finish',
|
||||
params: {
|
||||
@@ -72,25 +99,23 @@ export default {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="w-full">
|
||||
<label :class="{ error: v$.selectedAgents.$error }">
|
||||
<div class="w-full mb-4">
|
||||
<label :class="{ error: v$.selectedAgentIds.$error }">
|
||||
{{ $t('INBOX_MGMT.ADD.AGENTS.TITLE') }}
|
||||
<multiselect
|
||||
v-model="selectedAgents"
|
||||
:options="agentList"
|
||||
track-by="id"
|
||||
label="name"
|
||||
multiple
|
||||
:close-on-select="false"
|
||||
:clear-on-select="false"
|
||||
hide-selected
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
:deselect-label="$t('FORMS.MULTISELECT.ENTER_TO_REMOVE')"
|
||||
:placeholder="$t('INBOX_MGMT.ADD.AGENTS.PICK_AGENTS')"
|
||||
@select="v$.selectedAgents.$touch"
|
||||
/>
|
||||
<span v-if="v$.selectedAgents.$error" class="message">
|
||||
<div
|
||||
class="rounded-xl outline outline-1 -outline-offset-1 outline-n-weak hover:outline-n-strong px-2 py-2"
|
||||
>
|
||||
<TagInput
|
||||
:model-value="selectedAgentNames"
|
||||
:placeholder="$t('INBOX_MGMT.ADD.AGENTS.PICK_AGENTS')"
|
||||
:menu-items="agentMenuItems"
|
||||
show-dropdown
|
||||
skip-label-dedup
|
||||
@add="handleAgentAdd"
|
||||
@remove="handleAgentRemove"
|
||||
/>
|
||||
</div>
|
||||
<span v-if="v$.selectedAgentIds.$error" class="message">
|
||||
{{ $t('INBOX_MGMT.ADD.AGENTS.VALIDATION_ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@@ -12,6 +12,7 @@ import PageHeader from '../../SettingsSubPageHeader.vue';
|
||||
import router from '../../../../index';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
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 * as Sentry from '@sentry/vue';
|
||||
@@ -21,6 +22,7 @@ export default {
|
||||
LoadingState,
|
||||
PageHeader,
|
||||
NextButton,
|
||||
ComboBox,
|
||||
},
|
||||
setup() {
|
||||
const { accountId } = useAccount();
|
||||
@@ -67,6 +69,12 @@ export default {
|
||||
getSelectablePages() {
|
||||
return this.pageList.filter(item => !item.exists);
|
||||
},
|
||||
comboBoxPageOptions() {
|
||||
return this.getSelectablePages.map(({ id, name }) => ({
|
||||
value: id,
|
||||
label: name,
|
||||
}));
|
||||
},
|
||||
},
|
||||
|
||||
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.pageName = name;
|
||||
},
|
||||
|
||||
initChannelAuth(channel) {
|
||||
@@ -245,23 +260,20 @@ export default {
|
||||
/>
|
||||
</div>
|
||||
<div class="w-3/5">
|
||||
<div class="w-full">
|
||||
<div class="w-full mb-2">
|
||||
<div class="input-wrap" :class="{ error: v$.selectedPage.$error }">
|
||||
{{ $t('INBOX_MGMT.ADD.FB.CHOOSE_PAGE') }}
|
||||
<multiselect
|
||||
v-model="selectedPage"
|
||||
close-on-select
|
||||
allow-empty
|
||||
:options="getSelectablePages"
|
||||
track-by="id"
|
||||
label="name"
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
:deselect-label="$t('FORMS.MULTISELECT.ENTER_TO_REMOVE')"
|
||||
<span class="text-n-slate-12 text-start">
|
||||
{{ $t('INBOX_MGMT.ADD.FB.CHOOSE_PAGE') }}
|
||||
</span>
|
||||
<ComboBox
|
||||
:model-value="selectedPage.id"
|
||||
:options="comboBoxPageOptions"
|
||||
:placeholder="$t('INBOX_MGMT.ADD.FB.PICK_A_VALUE')"
|
||||
selected-label
|
||||
@select="setPageName"
|
||||
:has-error="v$.selectedPage.$error"
|
||||
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') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user