Merge branch 'develop' into feat/shopify-app-store-install-flow
This commit is contained in:
+1
-2
@@ -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>
|
||||
|
||||
+57
-324
@@ -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>
|
||||
+68
-333
@@ -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