Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d01c7d3fa7 | ||
|
|
361878d346 | ||
|
|
2a4c0dfa2a | ||
|
|
6b348da807 | ||
|
|
96ae298464 | ||
|
|
932244a1ec | ||
|
|
d69571f6f8 | ||
|
|
1d88e0dd28 | ||
|
|
b34dac7bbe | ||
|
|
e3109dbb22 | ||
|
|
959d2c0d8c | ||
|
|
622f29a0b7 |
@@ -74,7 +74,7 @@ jobs:
|
|||||||
source ~/.rvm/scripts/rvm
|
source ~/.rvm/scripts/rvm
|
||||||
rvm install "3.3.3"
|
rvm install "3.3.3"
|
||||||
rvm use 3.3.3 --default
|
rvm use 3.3.3 --default
|
||||||
gem install bundler
|
gem install bundler -v 2.5.16
|
||||||
|
|
||||||
- run:
|
- run:
|
||||||
name: Install Application Dependencies
|
name: Install Application Dependencies
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
|||||||
@contacts = fetch_contacts(contacts)
|
@contacts = fetch_contacts(contacts)
|
||||||
rescue CustomExceptions::CustomFilter::InvalidAttribute,
|
rescue CustomExceptions::CustomFilter::InvalidAttribute,
|
||||||
CustomExceptions::CustomFilter::InvalidOperator,
|
CustomExceptions::CustomFilter::InvalidOperator,
|
||||||
|
CustomExceptions::CustomFilter::InvalidQueryOperator,
|
||||||
CustomExceptions::CustomFilter::InvalidValue => e
|
CustomExceptions::CustomFilter::InvalidValue => e
|
||||||
render_could_not_create_error(e.message)
|
render_could_not_create_error(e.message)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||||||
@conversations_count = result[:count]
|
@conversations_count = result[:count]
|
||||||
rescue CustomExceptions::CustomFilter::InvalidAttribute,
|
rescue CustomExceptions::CustomFilter::InvalidAttribute,
|
||||||
CustomExceptions::CustomFilter::InvalidOperator,
|
CustomExceptions::CustomFilter::InvalidOperator,
|
||||||
|
CustomExceptions::CustomFilter::InvalidQueryOperator,
|
||||||
CustomExceptions::CustomFilter::InvalidValue => e
|
CustomExceptions::CustomFilter::InvalidValue => e
|
||||||
render_could_not_create_error(e.message)
|
render_could_not_create_error(e.message)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -81,4 +81,12 @@ module FilterHelper
|
|||||||
def default_filter(query_hash, filter_operator_value)
|
def default_filter(query_hash, filter_operator_value)
|
||||||
"#{filter_config[:table_name]}.#{query_hash[:attribute_key]} #{filter_operator_value} #{query_hash[:query_operator]}"
|
"#{filter_config[:table_name]}.#{query_hash[:attribute_key]} #{filter_operator_value} #{query_hash[:query_operator]}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def validate_single_condition(condition)
|
||||||
|
return if condition['query_operator'].nil?
|
||||||
|
return if condition['query_operator'].empty?
|
||||||
|
|
||||||
|
operator = condition['query_operator'].upcase
|
||||||
|
raise CustomExceptions::CustomFilter::InvalidQueryOperator.new({}) unless %w[AND OR].include?(operator)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ const route = useRoute();
|
|||||||
|
|
||||||
const showDropdown = ref(false);
|
const showDropdown = ref(false);
|
||||||
|
|
||||||
|
// Store the currently hovered label's ID
|
||||||
|
// Using JS state management instead of CSS :hover / group hover
|
||||||
|
// This will solve the flickering issue when hovering over the last label item
|
||||||
|
const hoveredLabel = ref(null);
|
||||||
|
|
||||||
const allLabels = useMapGetter('labels/getLabels');
|
const allLabels = useMapGetter('labels/getLabels');
|
||||||
const contactLabels = useMapGetter('contactLabels/getContactLabels');
|
const contactLabels = useMapGetter('contactLabels/getContactLabels');
|
||||||
|
|
||||||
@@ -37,7 +42,7 @@ const labelMenuItems = computed(() => {
|
|||||||
isSelected: savedLabels.value.some(
|
isSelected: savedLabels.value.some(
|
||||||
savedLabel => savedLabel.id === label.id
|
savedLabel => savedLabel.id === label.id
|
||||||
),
|
),
|
||||||
action: 'addLabel',
|
action: 'contactLabel',
|
||||||
}))
|
}))
|
||||||
.toSorted((a, b) => Number(a.isSelected) - Number(b.isSelected));
|
.toSorted((a, b) => Number(a.isSelected) - Number(b.isSelected));
|
||||||
});
|
});
|
||||||
@@ -49,7 +54,7 @@ const fetchLabels = async contactId => {
|
|||||||
store.dispatch('contactLabels/get', contactId);
|
store.dispatch('contactLabels/get', contactId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLabelAction = async ({ action, value }) => {
|
const handleLabelAction = async ({ value }) => {
|
||||||
try {
|
try {
|
||||||
// Get current label titles
|
// Get current label titles
|
||||||
const currentLabels = savedLabels.value.map(label => label.title);
|
const currentLabels = savedLabels.value.map(label => label.title);
|
||||||
@@ -59,16 +64,15 @@ const handleLabelAction = async ({ action, value }) => {
|
|||||||
if (!selectedLabel) return;
|
if (!selectedLabel) return;
|
||||||
|
|
||||||
let updatedLabels;
|
let updatedLabels;
|
||||||
if (action === 'addLabel') {
|
|
||||||
// If label is already selected, remove it (toggle behavior)
|
// If label is already selected, remove it (toggle behavior)
|
||||||
if (currentLabels.includes(selectedLabel.title)) {
|
if (currentLabels.includes(selectedLabel.title)) {
|
||||||
updatedLabels = currentLabels.filter(
|
updatedLabels = currentLabels.filter(
|
||||||
labelTitle => labelTitle !== selectedLabel.title
|
labelTitle => labelTitle !== selectedLabel.title
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Add the new label
|
// Add the new label
|
||||||
updatedLabels = [...currentLabels, selectedLabel.title];
|
updatedLabels = [...currentLabels, selectedLabel.title];
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await store.dispatch('contactLabels/update', {
|
await store.dispatch('contactLabels/update', {
|
||||||
@@ -82,6 +86,10 @@ const handleLabelAction = async ({ action, value }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRemoveLabel = labelId => {
|
||||||
|
return handleLabelAction({ value: labelId });
|
||||||
|
};
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.contactId,
|
() => props.contactId,
|
||||||
(newVal, oldVal) => {
|
(newVal, oldVal) => {
|
||||||
@@ -95,11 +103,31 @@ onMounted(() => {
|
|||||||
fetchLabels(route.params.contactId);
|
fetchLabels(route.params.contactId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
// Reset hover state when mouse leaves the container
|
||||||
|
// This ensures all labels return to their default state
|
||||||
|
hoveredLabel.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLabelHover = labelId => {
|
||||||
|
// Added this to prevent flickering on when showing remove button on hover
|
||||||
|
// If the label item is at end of the line, it will show the remove button
|
||||||
|
// when hovering over the last label item
|
||||||
|
hoveredLabel.value = labelId;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2" @mouseleave="handleMouseLeave">
|
||||||
<LabelItem v-for="label in savedLabels" :key="label.id" :label="label" />
|
<LabelItem
|
||||||
|
v-for="label in savedLabels"
|
||||||
|
:key="label.id"
|
||||||
|
:label="label"
|
||||||
|
:is-hovered="hoveredLabel === label.id"
|
||||||
|
@remove="handleRemoveLabel"
|
||||||
|
@hover="handleLabelHover(label.id)"
|
||||||
|
/>
|
||||||
<AddLabel
|
<AddLabel
|
||||||
:label-menu-items="labelMenuItems"
|
:label-menu-items="labelMenuItems"
|
||||||
@update-label="handleLabelAction"
|
@update-label="handleLabelAction"
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ const prepareStateBasedOnProps = () => {
|
|||||||
phoneNumber,
|
phoneNumber,
|
||||||
additionalAttributes = {},
|
additionalAttributes = {},
|
||||||
} = props.contactData || {};
|
} = props.contactData || {};
|
||||||
const { firstName, lastName } = splitName(name);
|
const { firstName, lastName } = splitName(name || '');
|
||||||
const {
|
const {
|
||||||
description = '',
|
description = '',
|
||||||
companyName = '',
|
companyName = '',
|
||||||
|
|||||||
@@ -1,22 +1,56 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
defineProps({
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
label: {
|
label: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
|
isHovered: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['remove', 'hover']);
|
||||||
|
|
||||||
|
const handleRemoveLabel = () => {
|
||||||
|
emit('remove', props.label?.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
// Notify parent component when this label is hovered
|
||||||
|
// Added this to show the remove button with transition when hovering over the label
|
||||||
|
// This will solve the flickering issue when hovering over the last label item
|
||||||
|
emit('hover', props.label?.id);
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="bg-n-alpha-2 rounded-md flex items-center h-7 w-fit py-1 ltr:pl-1 rtl:pr-1 ltr:pr-1.5 rtl:pl-1.5"
|
class="flex items-center px-1 py-1 overflow-hidden transition-all duration-300 ease-out rounded-md bg-n-alpha-2 h-7"
|
||||||
|
@mouseenter="handleMouseEnter"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="w-2 h-2 m-1 rounded-sm"
|
class="w-2 h-2 m-1 rounded-sm"
|
||||||
:style="{ backgroundColor: label.color }"
|
:style="{ backgroundColor: label.color }"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm text-n-slate-12">
|
<span class="text-sm text-n-slate-12 ltr:mr-px rtl:ml-px">
|
||||||
{{ label.title }}
|
{{ label.title }}
|
||||||
</span>
|
</span>
|
||||||
|
<div
|
||||||
|
class="w-0 flex relative ltr:left-1 rtl:right-1 flex-shrink-0 overflow-hidden transition-[width] duration-300 ease-out"
|
||||||
|
:class="{ 'w-6': isHovered }"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
class="transition-opacity duration-200 !h-7 ltr:rounded-r-md rtl:rounded-l-md ltr:rounded-l-none rtl:rounded-r-none w-6 bg-transparent"
|
||||||
|
:class="{ 'opacity-0': !isHovered, 'opacity-100': isHovered }"
|
||||||
|
slate
|
||||||
|
xs
|
||||||
|
faded
|
||||||
|
icon="i-lucide-x"
|
||||||
|
@click="handleRemoveLabel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+17
-3
@@ -1,9 +1,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { INPUT_TYPES } from 'dashboard/components-next/taginput/helper/tagInputHelper.js';
|
||||||
|
|
||||||
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
|
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
contacts: {
|
contacts: {
|
||||||
type: Array,
|
type: Array,
|
||||||
@@ -42,15 +44,19 @@ const props = defineProps({
|
|||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
'searchContacts',
|
'searchContacts',
|
||||||
'setSelectedContact',
|
'setSelectedContact',
|
||||||
'clearSelectedContact',
|
'clearSelectedContact',
|
||||||
'updateDropdown',
|
'updateDropdown',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const i18nPrefix = 'COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR';
|
const i18nPrefix = 'COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR';
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const inputType = ref(INPUT_TYPES.EMAIL);
|
||||||
|
|
||||||
const contactsList = computed(() => {
|
const contactsList = computed(() => {
|
||||||
return props.contacts?.map(({ name, id, thumbnail, email, ...rest }) => ({
|
return props.contacts?.map(({ name, id, thumbnail, email, ...rest }) => ({
|
||||||
id,
|
id,
|
||||||
@@ -80,6 +86,14 @@ const errorClass = computed(() => {
|
|||||||
? '[&_input]:placeholder:!text-n-ruby-9 [&_input]:dark:placeholder:!text-n-ruby-9'
|
? '[&_input]:placeholder:!text-n-ruby-9 [&_input]:dark:placeholder:!text-n-ruby-9'
|
||||||
: '';
|
: '';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleInput = value => {
|
||||||
|
// Update input type based on whether input starts with '+'
|
||||||
|
// If it does, set input type to 'tel'
|
||||||
|
// Otherwise, set input type to 'email'
|
||||||
|
inputType.value = value.startsWith('+') ? INPUT_TYPES.TEL : INPUT_TYPES.EMAIL;
|
||||||
|
emit('searchContacts', value);
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -126,11 +140,11 @@ const errorClass = computed(() => {
|
|||||||
:is-loading="isLoading"
|
:is-loading="isLoading"
|
||||||
:disabled="contactableInboxesList?.length > 0 && showInboxesDropdown"
|
:disabled="contactableInboxesList?.length > 0 && showInboxesDropdown"
|
||||||
allow-create
|
allow-create
|
||||||
type="email"
|
:type="inputType"
|
||||||
class="flex-1 min-h-7"
|
class="flex-1 min-h-7"
|
||||||
:class="errorClass"
|
:class="errorClass"
|
||||||
focus-on-mount
|
focus-on-mount
|
||||||
@input="emit('searchContacts', $event)"
|
@input="handleInput"
|
||||||
@on-click-outside="emit('updateDropdown', 'contacts', false)"
|
@on-click-outside="emit('updateDropdown', 'contacts', false)"
|
||||||
@add="emit('setSelectedContact', $event)"
|
@add="emit('setSelectedContact', $event)"
|
||||||
@remove="emit('clearSelectedContact')"
|
@remove="emit('clearSelectedContact')"
|
||||||
|
|||||||
+5
-3
@@ -193,10 +193,12 @@ export const searchContacts = async ({ keys, query }) => {
|
|||||||
return filteredPayload || [];
|
return filteredPayload || [];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createNewContact = async email => {
|
export const createNewContact = async input => {
|
||||||
const payload = {
|
const payload = {
|
||||||
name: getCapitalizedNameFromEmail(email),
|
name: input.startsWith('+')
|
||||||
email,
|
? input.slice(1) // Remove the '+' prefix if it exists
|
||||||
|
: getCapitalizedNameFromEmail(input),
|
||||||
|
...(input.startsWith('+') ? { phone_number: input } : { email: input }),
|
||||||
};
|
};
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|||||||
+22
@@ -429,6 +429,28 @@ describe('composeConversationHelper', () => {
|
|||||||
email: 'john@example.com',
|
email: 'john@example.com',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('creates new contact with phone number', async () => {
|
||||||
|
const mockContact = {
|
||||||
|
id: 1,
|
||||||
|
name: '919999999999',
|
||||||
|
phone_number: '+919999999999',
|
||||||
|
};
|
||||||
|
ContactAPI.create.mockResolvedValue({
|
||||||
|
data: { payload: { contact: mockContact } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await helpers.createNewContact('+919999999999');
|
||||||
|
expect(result).toEqual({
|
||||||
|
id: 1,
|
||||||
|
name: '919999999999',
|
||||||
|
phoneNumber: '+919999999999',
|
||||||
|
});
|
||||||
|
expect(ContactAPI.create).toHaveBeenCalledWith({
|
||||||
|
name: '919999999999',
|
||||||
|
phone_number: '+919999999999',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('fetchContactableInboxes', () => {
|
describe('fetchContactableInboxes', () => {
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch } from 'vue';
|
import { ref, computed, watch } from 'vue';
|
||||||
import { vOnClickOutside } from '@vueuse/components';
|
import { vOnClickOutside } from '@vueuse/components';
|
||||||
import { email } from '@vuelidate/validators';
|
|
||||||
import { useVuelidate } from '@vuelidate/core';
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
|
|
||||||
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
|
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
|
||||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||||
|
import {
|
||||||
|
MODE,
|
||||||
|
INPUT_TYPES,
|
||||||
|
getValidationRules,
|
||||||
|
checkTagTypeValidity,
|
||||||
|
buildTagMenuItems,
|
||||||
|
canAddTag,
|
||||||
|
findMatchingMenuItem,
|
||||||
|
} from './helper/tagInputHelper';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
placeholder: { type: String, default: '' },
|
placeholder: { type: String, default: '' },
|
||||||
disabled: { type: Boolean, default: false },
|
disabled: { type: Boolean, default: false },
|
||||||
type: { type: String, default: 'text' },
|
type: { type: String, default: INPUT_TYPES.TEXT },
|
||||||
isLoading: { type: Boolean, default: false },
|
isLoading: { type: Boolean, default: false },
|
||||||
menuItems: {
|
menuItems: {
|
||||||
type: Array,
|
type: Array,
|
||||||
@@ -23,8 +31,8 @@ const props = defineProps({
|
|||||||
showDropdown: { type: Boolean, default: false },
|
showDropdown: { type: Boolean, default: false },
|
||||||
mode: {
|
mode: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'multiple',
|
default: MODE.MULTIPLE,
|
||||||
validator: value => ['single', 'multiple'].includes(value),
|
validator: value => [MODE.SINGLE, MODE.MULTIPLE].includes(value),
|
||||||
},
|
},
|
||||||
focusOnMount: { type: Boolean, default: false },
|
focusOnMount: { type: Boolean, default: false },
|
||||||
allowCreate: { type: Boolean, default: false },
|
allowCreate: { type: Boolean, default: false },
|
||||||
@@ -45,22 +53,17 @@ const modelValue = defineModel({
|
|||||||
default: () => [],
|
default: () => [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const MODE = {
|
|
||||||
SINGLE: 'single',
|
|
||||||
MULTIPLE: 'multiple',
|
|
||||||
};
|
|
||||||
|
|
||||||
const tagInputRef = ref(null);
|
const tagInputRef = ref(null);
|
||||||
const tags = ref(props.modelValue);
|
const tags = ref(props.modelValue);
|
||||||
const newTag = ref('');
|
const newTag = ref('');
|
||||||
const isFocused = ref(true);
|
const isFocused = ref(true);
|
||||||
|
|
||||||
const rules = computed(() => ({
|
const rules = computed(() => getValidationRules(props.type));
|
||||||
newTag: props.type === 'email' ? { email } : {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const v$ = useVuelidate(rules, { newTag });
|
const v$ = useVuelidate(rules, { newTag });
|
||||||
const isNewTagInValidType = computed(() => v$.value.$invalid);
|
|
||||||
|
const isNewTagInValidType = computed(() =>
|
||||||
|
checkTagTypeValidity(props.type, newTag.value, v$.value)
|
||||||
|
);
|
||||||
|
|
||||||
const showInput = computed(() =>
|
const showInput = computed(() =>
|
||||||
props.mode === MODE.SINGLE
|
props.mode === MODE.SINGLE
|
||||||
@@ -74,68 +77,49 @@ const showDropdownMenu = computed(() =>
|
|||||||
: props.showDropdown
|
: props.showDropdown
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredMenuItems = computed(() => {
|
const filteredMenuItems = computed(() =>
|
||||||
if (props.mode === MODE.SINGLE && tags.value.length >= 1) return [];
|
buildTagMenuItems({
|
||||||
|
mode: props.mode,
|
||||||
const availableMenuItems = props.menuItems.filter(
|
tags: tags.value,
|
||||||
item => !tags.value.includes(item.label)
|
menuItems: props.menuItems,
|
||||||
);
|
newTag: newTag.value,
|
||||||
|
isLoading: props.isLoading,
|
||||||
// Show typed value as suggestion only if:
|
type: props.type,
|
||||||
// 1. There's a value being typed
|
isNewTagInValidType: isNewTagInValidType.value,
|
||||||
// 2. The value isn't already in the tags
|
})
|
||||||
// 3. Email validation passes (if type is email) and There are no menu items available
|
);
|
||||||
const trimmedNewTag = newTag.value?.trim();
|
|
||||||
const shouldShowTypedValue =
|
|
||||||
trimmedNewTag &&
|
|
||||||
!tags.value.includes(trimmedNewTag) &&
|
|
||||||
!props.isLoading &&
|
|
||||||
!availableMenuItems.length &&
|
|
||||||
(props.type === 'email' ? !isNewTagInValidType.value : true);
|
|
||||||
|
|
||||||
if (shouldShowTypedValue) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: trimmedNewTag,
|
|
||||||
value: trimmedNewTag,
|
|
||||||
email: trimmedNewTag,
|
|
||||||
thumbnail: { name: trimmedNewTag, src: '' },
|
|
||||||
action: 'create',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return availableMenuItems;
|
|
||||||
});
|
|
||||||
|
|
||||||
const emitDataOnAdd = emailValue => {
|
|
||||||
const matchingMenuItem = props.menuItems.find(
|
|
||||||
item => item.email === emailValue
|
|
||||||
);
|
|
||||||
|
|
||||||
|
const emitDataOnAdd = value => {
|
||||||
|
const matchingMenuItem = findMatchingMenuItem(props.menuItems, value);
|
||||||
return matchingMenuItem
|
return matchingMenuItem
|
||||||
? emit('add', { email: emailValue, ...matchingMenuItem })
|
? emit('add', { value: value, ...matchingMenuItem })
|
||||||
: emit('add', { value: emailValue, action: 'create' });
|
: emit('add', { value: value, action: 'create' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateValueAndFocus = value => {
|
||||||
|
tags.value.push(value);
|
||||||
|
newTag.value = '';
|
||||||
|
modelValue.value = tags.value;
|
||||||
|
tagInputRef.value?.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
const addTag = async () => {
|
const addTag = async () => {
|
||||||
const trimmedTag = newTag.value?.trim();
|
const trimmedTag = newTag.value?.trim();
|
||||||
if (!trimmedTag) return;
|
if (!trimmedTag) return;
|
||||||
|
|
||||||
if (props.mode === MODE.SINGLE && tags.value.length >= 1) {
|
if (!canAddTag(props.mode, tags.value.length)) {
|
||||||
newTag.value = '';
|
newTag.value = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.type === 'email' || props.allowCreate) {
|
if (
|
||||||
|
[INPUT_TYPES.EMAIL, INPUT_TYPES.TEL].includes(props.type) ||
|
||||||
|
props.allowCreate
|
||||||
|
) {
|
||||||
if (!(await v$.value.$validate())) return;
|
if (!(await v$.value.$validate())) return;
|
||||||
emitDataOnAdd(trimmedTag);
|
emitDataOnAdd(trimmedTag);
|
||||||
}
|
}
|
||||||
|
updateValueAndFocus(trimmedTag);
|
||||||
tags.value.push(trimmedTag);
|
|
||||||
newTag.value = '';
|
|
||||||
modelValue.value = tags.value;
|
|
||||||
tagInputRef.value?.focus();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeTag = index => {
|
const removeTag = index => {
|
||||||
@@ -144,19 +128,25 @@ const removeTag = index => {
|
|||||||
emit('remove');
|
emit('remove');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDropdownAction = async ({ email: emailAddress, ...rest }) => {
|
const handleDropdownAction = async ({
|
||||||
|
email: emailAddress,
|
||||||
|
phoneNumber,
|
||||||
|
...rest
|
||||||
|
}) => {
|
||||||
if (props.mode === MODE.SINGLE && tags.value.length >= 1) return;
|
if (props.mode === MODE.SINGLE && tags.value.length >= 1) return;
|
||||||
|
if (!props.showDropdown) return;
|
||||||
|
|
||||||
if (props.type === 'email' && props.showDropdown) {
|
const isEmail = props.type === 'email';
|
||||||
newTag.value = emailAddress;
|
newTag.value = isEmail ? emailAddress : phoneNumber;
|
||||||
if (!(await v$.value.$validate())) return;
|
|
||||||
emit('add', { email: emailAddress, ...rest });
|
|
||||||
}
|
|
||||||
|
|
||||||
tags.value.push(emailAddress);
|
if (!(await v$.value.$validate())) return;
|
||||||
newTag.value = '';
|
|
||||||
modelValue.value = tags.value;
|
const payload = isEmail
|
||||||
tagInputRef.value?.focus();
|
? { email: emailAddress, ...rest }
|
||||||
|
: { phoneNumber, ...rest };
|
||||||
|
|
||||||
|
emit('add', payload);
|
||||||
|
updateValueAndFocus(emailAddress);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFocus = () => {
|
const handleFocus = () => {
|
||||||
@@ -226,7 +216,6 @@ const handleBlur = e => emit('blur', e);
|
|||||||
ref="tagInputRef"
|
ref="tagInputRef"
|
||||||
v-model="newTag"
|
v-model="newTag"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
:type="type"
|
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:focus-on-mount="focusOnMount"
|
:focus-on-mount="focusOnMount"
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
import {
|
||||||
|
validatePhoneNumber,
|
||||||
|
formatPhoneNumber,
|
||||||
|
buildTagMenuItems,
|
||||||
|
MODE,
|
||||||
|
INPUT_TYPES,
|
||||||
|
getValidationRules,
|
||||||
|
validateAndFormatNewTag,
|
||||||
|
createNewTagMenuItem,
|
||||||
|
canAddTag,
|
||||||
|
findMatchingMenuItem,
|
||||||
|
} from '../tagInputHelper';
|
||||||
|
import { email } from '@vuelidate/validators';
|
||||||
|
|
||||||
|
describe('tagInputHelper', () => {
|
||||||
|
describe('validatePhoneNumber', () => {
|
||||||
|
it('returns true for empty value', () => {
|
||||||
|
expect(validatePhoneNumber('')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates correct phone number', () => {
|
||||||
|
expect(validatePhoneNumber('+918283838283')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates correct phone number id + is present and number is not valid', () => {
|
||||||
|
expect(validatePhoneNumber('+91828383834283')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates correct phone number if + is not present', () => {
|
||||||
|
expect(validatePhoneNumber('91828383834283')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalidates incorrect phone number', () => {
|
||||||
|
expect(validatePhoneNumber('invalid')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles null value', () => {
|
||||||
|
expect(validatePhoneNumber(null)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatPhoneNumber', () => {
|
||||||
|
it('formats valid phone number', () => {
|
||||||
|
const result = formatPhoneNumber('+918283838283');
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.formattedValue).toBe('+91 82838 38283');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles invalid phone number', () => {
|
||||||
|
const result = formatPhoneNumber('invalid');
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.formattedValue).toBe('invalid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles error case', () => {
|
||||||
|
const result = formatPhoneNumber(null);
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.formattedValue).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getValidationRules', () => {
|
||||||
|
it('returns email validation for email type', () => {
|
||||||
|
const rules = getValidationRules(INPUT_TYPES.EMAIL);
|
||||||
|
expect(rules.newTag).toHaveProperty('email');
|
||||||
|
expect(rules.newTag).not.toHaveProperty('isValidPhone');
|
||||||
|
expect(rules.newTag.email).toBe(email);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns phone validation for tel type', () => {
|
||||||
|
const rules = getValidationRules(INPUT_TYPES.TEL);
|
||||||
|
expect(rules.newTag).toHaveProperty('isValidPhone');
|
||||||
|
expect(rules.newTag).not.toHaveProperty('email');
|
||||||
|
expect(rules.newTag.isValidPhone).toBe(validatePhoneNumber);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty rules for text type', () => {
|
||||||
|
const rules = getValidationRules(INPUT_TYPES.TEXT);
|
||||||
|
expect(Object.keys(rules.newTag)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateAndFormatNewTag', () => {
|
||||||
|
it('validates and formats email tag', () => {
|
||||||
|
const result = validateAndFormatNewTag(
|
||||||
|
'test@example.com',
|
||||||
|
INPUT_TYPES.EMAIL,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
isValid: true,
|
||||||
|
formattedValue: 'test@example.com',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates and formats phone tag', () => {
|
||||||
|
const result = validateAndFormatNewTag(
|
||||||
|
'+918283838283',
|
||||||
|
INPUT_TYPES.TEL,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.formattedValue).toBe('+91 82838 38283');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles invalid email', () => {
|
||||||
|
const result = validateAndFormatNewTag(
|
||||||
|
'test@example.com',
|
||||||
|
INPUT_TYPES.EMAIL,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.formattedValue).toBe('test@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles text type', () => {
|
||||||
|
const result = validateAndFormatNewTag(
|
||||||
|
'sample text',
|
||||||
|
INPUT_TYPES.TEXT,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.formattedValue).toBe('sample text');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createNewTagMenuItem', () => {
|
||||||
|
it('creates email menu item', () => {
|
||||||
|
const result = createNewTagMenuItem(
|
||||||
|
'test@example.com',
|
||||||
|
'test@example.com',
|
||||||
|
INPUT_TYPES.EMAIL
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
label: 'test@example.com',
|
||||||
|
value: 'test@example.com',
|
||||||
|
email: 'test@example.com',
|
||||||
|
thumbnail: { name: 'test@example.com', src: '' },
|
||||||
|
action: 'create',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates phone menu item', () => {
|
||||||
|
const result = createNewTagMenuItem(
|
||||||
|
'+91 82838 38283',
|
||||||
|
'+918283838283',
|
||||||
|
INPUT_TYPES.TEL
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
label: '+91 82838 38283',
|
||||||
|
value: '+918283838283',
|
||||||
|
phoneNumber: '+918283838283',
|
||||||
|
thumbnail: { name: '+91 82838 38283', src: '' },
|
||||||
|
action: 'create',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates text menu item', () => {
|
||||||
|
const result = createNewTagMenuItem(
|
||||||
|
'sample text',
|
||||||
|
'sample text',
|
||||||
|
INPUT_TYPES.TEXT
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
label: 'sample text',
|
||||||
|
value: 'sample text',
|
||||||
|
thumbnail: { name: 'sample text', src: '' },
|
||||||
|
action: 'create',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildTagMenuItems', () => {
|
||||||
|
const baseParams = {
|
||||||
|
mode: MODE.MULTIPLE,
|
||||||
|
tags: [],
|
||||||
|
menuItems: [],
|
||||||
|
newTag: '',
|
||||||
|
isLoading: false,
|
||||||
|
type: INPUT_TYPES.TEXT,
|
||||||
|
isNewTagInValidType: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('returns empty array in single mode with existing tag', () => {
|
||||||
|
const result = buildTagMenuItems({
|
||||||
|
...baseParams,
|
||||||
|
mode: MODE.SINGLE,
|
||||||
|
tags: ['existing'],
|
||||||
|
});
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters out existing tags', () => {
|
||||||
|
const result = buildTagMenuItems({
|
||||||
|
...baseParams,
|
||||||
|
menuItems: [
|
||||||
|
{ label: 'item1', value: '1' },
|
||||||
|
{ label: 'item2', value: '2' },
|
||||||
|
],
|
||||||
|
tags: ['item1'],
|
||||||
|
});
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].label).toBe('item2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates new email item when valid', () => {
|
||||||
|
const result = buildTagMenuItems({
|
||||||
|
...baseParams,
|
||||||
|
type: INPUT_TYPES.EMAIL,
|
||||||
|
newTag: 'test@example.com',
|
||||||
|
menuItems: [],
|
||||||
|
});
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
label: 'test@example.com',
|
||||||
|
email: 'test@example.com',
|
||||||
|
action: 'create',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates new phone item when valid', () => {
|
||||||
|
const result = buildTagMenuItems({
|
||||||
|
...baseParams,
|
||||||
|
type: INPUT_TYPES.TEL,
|
||||||
|
newTag: '+918283838283',
|
||||||
|
menuItems: [],
|
||||||
|
});
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
value: '+918283838283',
|
||||||
|
label: '+91 82838 38283',
|
||||||
|
action: 'create',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when loading', () => {
|
||||||
|
const result = buildTagMenuItems({
|
||||||
|
...baseParams,
|
||||||
|
isLoading: true,
|
||||||
|
newTag: 'test',
|
||||||
|
});
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array for invalid tag', () => {
|
||||||
|
const result = buildTagMenuItems({
|
||||||
|
...baseParams,
|
||||||
|
type: INPUT_TYPES.EMAIL,
|
||||||
|
newTag: 'invalid-email',
|
||||||
|
isNewTagInValidType: true,
|
||||||
|
});
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns available menu items when no new tag', () => {
|
||||||
|
const menuItems = [
|
||||||
|
{ label: 'item1', value: '1' },
|
||||||
|
{ label: 'item2', value: '2' },
|
||||||
|
];
|
||||||
|
const result = buildTagMenuItems({
|
||||||
|
...baseParams,
|
||||||
|
menuItems,
|
||||||
|
});
|
||||||
|
expect(result).toEqual(menuItems);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('canAddTag', () => {
|
||||||
|
it('prevents adding tags in single mode when tag exists', () => {
|
||||||
|
expect(canAddTag(MODE.SINGLE, 1)).toBe(false);
|
||||||
|
expect(canAddTag(MODE.SINGLE, 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows adding tags in multiple mode', () => {
|
||||||
|
expect(canAddTag(MODE.MULTIPLE, 1)).toBe(true);
|
||||||
|
expect(canAddTag(MODE.MULTIPLE, 0)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findMatchingMenuItem', () => {
|
||||||
|
const menuItems = [
|
||||||
|
{ email: 'test1@example.com', label: 'Test 1' },
|
||||||
|
{ email: 'test2@example.com', label: 'Test 2' },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('finds matching menu item by email', () => {
|
||||||
|
const result = findMatchingMenuItem(menuItems, 'test1@example.com');
|
||||||
|
expect(result).toEqual(menuItems[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined when no match found', () => {
|
||||||
|
const result = findMatchingMenuItem(menuItems, 'nonexistent@example.com');
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty menu items', () => {
|
||||||
|
const result = findMatchingMenuItem([], 'test@example.com');
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { parsePhoneNumber, isValidPhoneNumber } from 'libphonenumber-js';
|
||||||
|
import { email } from '@vuelidate/validators';
|
||||||
|
|
||||||
|
export const MODE = {
|
||||||
|
SINGLE: 'single',
|
||||||
|
MULTIPLE: 'multiple',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const INPUT_TYPES = {
|
||||||
|
EMAIL: 'email',
|
||||||
|
TEL: 'tel',
|
||||||
|
TEXT: 'text',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validatePhoneNumber = value => {
|
||||||
|
if (!value) return true;
|
||||||
|
try {
|
||||||
|
return isValidPhoneNumber(value);
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatPhoneNumber = value => {
|
||||||
|
try {
|
||||||
|
const phoneNumber = parsePhoneNumber(value);
|
||||||
|
return {
|
||||||
|
isValid: phoneNumber?.isValid() || false,
|
||||||
|
formattedValue: phoneNumber?.formatInternational() || value,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { isValid: false, formattedValue: value };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getValidationRules = type => ({
|
||||||
|
newTag: {
|
||||||
|
...(type === INPUT_TYPES.EMAIL ? { email } : {}),
|
||||||
|
...(type === INPUT_TYPES.TEL ? { isValidPhone: validatePhoneNumber } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const checkTagTypeValidity = (type, value, v$) => {
|
||||||
|
if (type === INPUT_TYPES.TEL) {
|
||||||
|
return !validatePhoneNumber(value);
|
||||||
|
}
|
||||||
|
return v$.$invalid;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateAndFormatNewTag = (
|
||||||
|
trimmedNewTag,
|
||||||
|
type,
|
||||||
|
isNewTagInValidType
|
||||||
|
) => {
|
||||||
|
let isValid = true;
|
||||||
|
let formattedValue = trimmedNewTag;
|
||||||
|
|
||||||
|
if (type === INPUT_TYPES.EMAIL) {
|
||||||
|
isValid = !isNewTagInValidType;
|
||||||
|
} else if (type === INPUT_TYPES.TEL) {
|
||||||
|
const { isValid: phoneValid, formattedValue: phoneFormatted } =
|
||||||
|
formatPhoneNumber(trimmedNewTag);
|
||||||
|
isValid = phoneValid;
|
||||||
|
formattedValue = phoneFormatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid, formattedValue };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createNewTagMenuItem = (formattedValue, trimmedNewTag, type) => ({
|
||||||
|
label: formattedValue,
|
||||||
|
value: trimmedNewTag,
|
||||||
|
...(type === INPUT_TYPES.EMAIL ? { email: trimmedNewTag } : {}),
|
||||||
|
...(type === INPUT_TYPES.TEL ? { phoneNumber: trimmedNewTag } : {}),
|
||||||
|
thumbnail: { name: formattedValue, src: '' },
|
||||||
|
action: 'create',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const buildTagMenuItems = ({
|
||||||
|
mode,
|
||||||
|
tags,
|
||||||
|
menuItems,
|
||||||
|
newTag,
|
||||||
|
isLoading,
|
||||||
|
type,
|
||||||
|
isNewTagInValidType,
|
||||||
|
}) => {
|
||||||
|
if (mode === MODE.SINGLE && tags.length >= 1) return [];
|
||||||
|
|
||||||
|
const availableMenuItems = 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
|
||||||
|
const trimmedNewTag = newTag?.trim();
|
||||||
|
const shouldShowTypedValue =
|
||||||
|
trimmedNewTag &&
|
||||||
|
!tags.includes(trimmedNewTag) &&
|
||||||
|
!isLoading &&
|
||||||
|
!availableMenuItems.length;
|
||||||
|
|
||||||
|
if (shouldShowTypedValue) {
|
||||||
|
const { isValid, formattedValue } = validateAndFormatNewTag(
|
||||||
|
trimmedNewTag,
|
||||||
|
type,
|
||||||
|
isNewTagInValidType
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isValid) {
|
||||||
|
return [createNewTagMenuItem(formattedValue, trimmedNewTag, type)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return availableMenuItems;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const canAddTag = (mode, tagsLength) =>
|
||||||
|
!(mode === MODE.SINGLE && tagsLength >= 1);
|
||||||
|
|
||||||
|
export const findMatchingMenuItem = (menuItems, value) =>
|
||||||
|
menuItems.find(item => item.email === value);
|
||||||
@@ -27,6 +27,7 @@ class AutomationRule < ApplicationRecord
|
|||||||
validate :json_conditions_format
|
validate :json_conditions_format
|
||||||
validate :json_actions_format
|
validate :json_actions_format
|
||||||
validate :query_operator_presence
|
validate :query_operator_presence
|
||||||
|
validate :query_operator_value
|
||||||
validates :account_id, presence: true
|
validates :account_id, presence: true
|
||||||
|
|
||||||
after_update_commit :reauthorized!, if: -> { saved_change_to_conditions? }
|
after_update_commit :reauthorized!, if: -> { saved_change_to_conditions? }
|
||||||
@@ -83,6 +84,24 @@ class AutomationRule < ApplicationRecord
|
|||||||
operators = conditions.select { |obj, _| obj['query_operator'].nil? }
|
operators = conditions.select { |obj, _| obj['query_operator'].nil? }
|
||||||
errors.add(:conditions, 'Automation conditions should have query operator.') if operators.length > 1
|
errors.add(:conditions, 'Automation conditions should have query operator.') if operators.length > 1
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# This validation ensures logical operators are being used correctly in automation conditions.
|
||||||
|
# And we don't push any unsanitized query operators to the database.
|
||||||
|
def query_operator_value
|
||||||
|
conditions.each do |obj|
|
||||||
|
validate_single_condition(obj)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_single_condition(condition)
|
||||||
|
query_operator = condition['query_operator']
|
||||||
|
|
||||||
|
return if query_operator.nil?
|
||||||
|
return if query_operator.empty?
|
||||||
|
|
||||||
|
operator = query_operator.upcase
|
||||||
|
errors.add(:conditions, 'Query operator must be either "AND" or "OR"') unless %w[AND OR].include?(operator)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
AutomationRule.include_mod_with('Audit::AutomationRule')
|
AutomationRule.include_mod_with('Audit::AutomationRule')
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class AutomationRules::ConditionValidationService
|
|||||||
|
|
||||||
def perform
|
def perform
|
||||||
@rule.conditions.each do |condition|
|
@rule.conditions.each do |condition|
|
||||||
return false unless valid_condition?(condition)
|
return false unless valid_condition?(condition) && valid_query_operator?(condition)
|
||||||
end
|
end
|
||||||
|
|
||||||
true
|
true
|
||||||
@@ -23,6 +23,15 @@ class AutomationRules::ConditionValidationService
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def valid_query_operator?(condition)
|
||||||
|
query_operator = condition['query_operator']
|
||||||
|
|
||||||
|
return true if query_operator.nil?
|
||||||
|
return true if query_operator.empty?
|
||||||
|
|
||||||
|
%w[AND OR].include?(query_operator.upcase)
|
||||||
|
end
|
||||||
|
|
||||||
def valid_condition?(condition)
|
def valid_condition?(condition)
|
||||||
key = condition['attribute_key']
|
key = condition['attribute_key']
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ class Contacts::FilterService < FilterService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def perform
|
def perform
|
||||||
|
validate_query_operator
|
||||||
@contacts = query_builder(@filters['contacts'])
|
@contacts = query_builder(@filters['contacts'])
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ class Conversations::FilterService < FilterService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def perform
|
def perform
|
||||||
|
validate_query_operator
|
||||||
@conversations = query_builder(@filters['conversations'])
|
@conversations = query_builder(@filters['conversations'])
|
||||||
mine_count, unassigned_count, all_count, = set_count_for_all_conversations
|
mine_count, unassigned_count, all_count, = set_count_for_all_conversations
|
||||||
assigned_count = all_count - unassigned_count
|
assigned_count = all_count - unassigned_count
|
||||||
|
|||||||
@@ -204,4 +204,10 @@ class FilterService
|
|||||||
end
|
end
|
||||||
base_relation.where(@query_string, @filter_values.with_indifferent_access)
|
base_relation.where(@query_string, @filter_values.with_indifferent_access)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def validate_query_operator
|
||||||
|
@params[:payload].each do |query_hash|
|
||||||
|
validate_single_condition(query_hash)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
shared: &shared
|
shared: &shared
|
||||||
version: '3.15.0'
|
version: '3.16.0'
|
||||||
|
|
||||||
development:
|
development:
|
||||||
<<: *shared
|
<<: *shared
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Bundler.require(*Rails.groups)
|
|||||||
## Load the specific APM agent
|
## Load the specific APM agent
|
||||||
# We rely on DOTENV to load the environment variables
|
# We rely on DOTENV to load the environment variables
|
||||||
# We need these environment variables to load the specific APM agent
|
# We need these environment variables to load the specific APM agent
|
||||||
Dotenv::Railtie.load
|
Dotenv::Rails.load
|
||||||
require 'ddtrace' if ENV.fetch('DD_TRACE_AGENT_URL', false).present?
|
require 'ddtrace' if ENV.fetch('DD_TRACE_AGENT_URL', false).present?
|
||||||
require 'elastic-apm' if ENV.fetch('ELASTIC_APM_SECRET_TOKEN', false).present?
|
require 'elastic-apm' if ENV.fetch('ELASTIC_APM_SECRET_TOKEN', false).present?
|
||||||
require 'scout_apm' if ENV.fetch('SCOUT_KEY', false).present?
|
require 'scout_apm' if ENV.fetch('SCOUT_KEY', false).present?
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ en:
|
|||||||
number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
|
number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
|
||||||
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
|
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
|
||||||
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
|
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
|
||||||
|
invalid_query_operator: Query operator must be either "AND" or "OR".
|
||||||
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
|
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
|
||||||
reports:
|
reports:
|
||||||
period: Reporting period %{since} to %{until}
|
period: Reporting period %{since} to %{until}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
class FixOldAudioAlertData < ActiveRecord::Migration[7.0]
|
||||||
|
def change
|
||||||
|
# rubocop:disable Rails/SkipsModelValidations
|
||||||
|
|
||||||
|
# Update users with audio alerts enabled to 'mine'
|
||||||
|
User.where(
|
||||||
|
"users.ui_settings #>> '{enable_audio_alerts}' = ?", 'true'
|
||||||
|
).update_all(
|
||||||
|
"ui_settings = jsonb_set(ui_settings, '{enable_audio_alerts}', '\"mine\"')"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update users with audio alerts enabled to 'none'
|
||||||
|
User
|
||||||
|
.where("users.ui_settings #>> '{enable_audio_alerts}' = ?", 'true')
|
||||||
|
.update_all("ui_settings = jsonb_set(ui_settings, '{enable_audio_alerts}', '\"mine\"')")
|
||||||
|
# rubocop:enable Rails/SkipsModelValidations
|
||||||
|
end
|
||||||
|
end
|
||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.0].define(version: 2024_09_23_215335) do
|
ActiveRecord::Schema[7.0].define(version: 2024_12_17_041352) do
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pg_stat_statements"
|
enable_extension "pg_stat_statements"
|
||||||
enable_extension "pg_trgm"
|
enable_extension "pg_trgm"
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ module CustomExceptions::CustomFilter
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
class InvalidQueryOperator < CustomExceptions::Base
|
||||||
|
def message
|
||||||
|
I18n.t('errors.custom_filters.invalid_query_operator')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
class InvalidValue < CustomExceptions::Base
|
class InvalidValue < CustomExceptions::Base
|
||||||
def message
|
def message
|
||||||
I18n.t('errors.custom_filters.invalid_value', attribute_name: @data[:attribute_name])
|
I18n.t('errors.custom_filters.invalid_value', attribute_name: @data[:attribute_name])
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
|
class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
|
||||||
|
# TODO: let move this regex from here to a config file where we can update this list much more easily
|
||||||
|
# the config file will also have the matching embed template as well.
|
||||||
YOUTUBE_REGEX = %r{https?://(?:www\.)?(?:youtube\.com/watch\?v=|youtu\.be/)([^&/]+)}
|
YOUTUBE_REGEX = %r{https?://(?:www\.)?(?:youtube\.com/watch\?v=|youtu\.be/)([^&/]+)}
|
||||||
LOOM_REGEX = %r{https?://(?:www\.)?loom\.com/share/([^&/]+)}
|
LOOM_REGEX = %r{https?://(?:www\.)?loom\.com/share/([^&/]+)}
|
||||||
VIMEO_REGEX = %r{https?://(?:www\.)?vimeo\.com/(\d+)}
|
VIMEO_REGEX = %r{https?://(?:www\.)?vimeo\.com/(\d+)}
|
||||||
MP4_REGEX = %r{https?://(?:www\.)?.+\.(mp4)}
|
MP4_REGEX = %r{https?://(?:www\.)?.+\.(mp4)}
|
||||||
|
ARCADE_REGEX = %r{https?://(?:www\.)?app\.arcade\.software/share/([^&/]+)}
|
||||||
|
|
||||||
def text(node)
|
def text(node)
|
||||||
content = node.string_content
|
content = node.string_content
|
||||||
@@ -46,7 +49,8 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
|
|||||||
YOUTUBE_REGEX => :make_youtube_embed,
|
YOUTUBE_REGEX => :make_youtube_embed,
|
||||||
VIMEO_REGEX => :make_vimeo_embed,
|
VIMEO_REGEX => :make_vimeo_embed,
|
||||||
MP4_REGEX => :make_video_embed,
|
MP4_REGEX => :make_video_embed,
|
||||||
LOOM_REGEX => :make_loom_embed
|
LOOM_REGEX => :make_loom_embed,
|
||||||
|
ARCADE_REGEX => :make_arcade_embed
|
||||||
}
|
}
|
||||||
|
|
||||||
embedding_methods.each do |regex, method|
|
embedding_methods.each do |regex, method|
|
||||||
@@ -118,4 +122,21 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
|
|||||||
</video>
|
</video>
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def make_arcade_embed(arcade_match)
|
||||||
|
video_id = arcade_match[1]
|
||||||
|
%(
|
||||||
|
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
|
||||||
|
<iframe
|
||||||
|
src="https://app.arcade.software/embed/#{video_id}"
|
||||||
|
frameborder="0"
|
||||||
|
webkitallowfullscreen
|
||||||
|
mozallowfullscreen
|
||||||
|
allowfullscreen
|
||||||
|
allow="fullscreen"
|
||||||
|
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;">
|
||||||
|
</iframe>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@chatwoot/chatwoot",
|
"name": "@chatwoot/chatwoot",
|
||||||
"version": "3.15.0",
|
"version": "3.16.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"eslint": "eslint app/**/*.{js,vue}",
|
"eslint": "eslint app/**/*.{js,vue}",
|
||||||
|
|||||||
@@ -76,6 +76,23 @@ RSpec.describe 'Api::V1::Accounts::AutomationRulesController', type: :request do
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'processes invalid query operator' do
|
||||||
|
expect(account.automation_rules.count).to eq(0)
|
||||||
|
params[:conditions] << {
|
||||||
|
'attribute_key': 'browser_language',
|
||||||
|
'filter_operator': 'equal_to',
|
||||||
|
'values': ['en'],
|
||||||
|
'query_operator': 'invalid'
|
||||||
|
}
|
||||||
|
|
||||||
|
post "/api/v1/accounts/#{account.id}/automation_rules",
|
||||||
|
headers: administrator.create_new_auth_token,
|
||||||
|
params: params
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(account.automation_rules.count).to eq(0)
|
||||||
|
end
|
||||||
|
|
||||||
it 'throws an error for unknown attributes in condtions' do
|
it 'throws an error for unknown attributes in condtions' do
|
||||||
expect(account.automation_rules.count).to eq(0)
|
expect(account.automation_rules.count).to eq(0)
|
||||||
params[:conditions] << {
|
params[:conditions] << {
|
||||||
|
|||||||
@@ -137,5 +137,33 @@ describe CustomMarkdownRenderer do
|
|||||||
expect(output).to include('src="https://player.vimeo.com/video/1234567"')
|
expect(output).to include('src="https://player.vimeo.com/video/1234567"')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'when link is an Arcade URL' do
|
||||||
|
let(:arcade_url) { 'https://app.arcade.software/share/ARCADE_ID' }
|
||||||
|
|
||||||
|
it 'renders an iframe with Arcade embed code' do
|
||||||
|
output = render_markdown_link(arcade_url)
|
||||||
|
expect(output).to include('src="https://app.arcade.software/embed/ARCADE_ID"')
|
||||||
|
expect(output).to include('<iframe')
|
||||||
|
expect(output).to include('webkitallowfullscreen')
|
||||||
|
expect(output).to include('mozallowfullscreen')
|
||||||
|
expect(output).to include('allowfullscreen')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'wraps iframe in responsive container' do
|
||||||
|
output = render_markdown_link(arcade_url)
|
||||||
|
expect(output).to include('position: relative; padding-bottom: 62.5%; height: 0;')
|
||||||
|
expect(output).to include('position: absolute; top: 0; left: 0; width: 100%; height: 100%;')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when multiple links including Arcade are present' do
|
||||||
|
it 'renders Arcade embed along with other content types' do
|
||||||
|
markdown = "\n[arcade](https://app.arcade.software/share/ARCADE_ID)\n\n[youtube](https://www.youtube.com/watch?v=VIDEO_ID)\n"
|
||||||
|
output = render_markdown(markdown)
|
||||||
|
expect(output).to include('src="https://app.arcade.software/embed/ARCADE_ID"')
|
||||||
|
expect(output).to include('src="https://www.youtube.com/embed/VIDEO_ID"')
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -46,6 +46,17 @@ RSpec.describe AutomationRules::ConditionValidationService do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'with wrong query operator' do
|
||||||
|
before do
|
||||||
|
rule.conditions = [{ 'values': ['open'], 'attribute_key': 'status', 'query_operator': 'invalid', 'filter_operator': 'attribute_changed' }]
|
||||||
|
rule.save
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false' do
|
||||||
|
expect(described_class.new(rule).perform).to be(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
context 'with "attribute_changed" filter operator' do
|
context 'with "attribute_changed" filter operator' do
|
||||||
before do
|
before do
|
||||||
rule.conditions = [
|
rule.conditions = [
|
||||||
|
|||||||
@@ -146,6 +146,19 @@ describe Contacts::FilterService do
|
|||||||
expect(result[:contacts].length).to be 1
|
expect(result[:contacts].length).to be 1
|
||||||
expect(result[:contacts].first.id).to eq el_contact.id
|
expect(result[:contacts].first.id).to eq el_contact.id
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'handles invalid query conditions' do
|
||||||
|
params[:payload] = [
|
||||||
|
{
|
||||||
|
attribute_key: 'labels',
|
||||||
|
filter_operator: 'is_not_present',
|
||||||
|
values: [],
|
||||||
|
query_operator: 'INVALID'
|
||||||
|
}.with_indifferent_access
|
||||||
|
]
|
||||||
|
|
||||||
|
expect { filter_service.new(account, first_user, params).perform }.to raise_error(CustomExceptions::CustomFilter::InvalidQueryOperator)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'with standard attributes - last_activity_at' do
|
context 'with standard attributes - last_activity_at' do
|
||||||
|
|||||||
@@ -185,6 +185,30 @@ describe Conversations::FilterService do
|
|||||||
expect(result[:count][:all_count]).to be 2
|
expect(result[:count][:all_count]).to be 2
|
||||||
expect(result[:conversations].pluck(:campaign_id).sort).to eq [campaign_2.id, campaign_1.id].sort
|
expect(result[:conversations].pluck(:campaign_id).sort).to eq [campaign_2.id, campaign_1.id].sort
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'handles invalid query conditions' do
|
||||||
|
params[:payload] = [
|
||||||
|
{
|
||||||
|
attribute_key: 'assignee_id',
|
||||||
|
filter_operator: 'equal_to',
|
||||||
|
values: [
|
||||||
|
user_1.id,
|
||||||
|
user_2.id
|
||||||
|
],
|
||||||
|
query_operator: 'INVALID',
|
||||||
|
custom_attribute_type: ''
|
||||||
|
}.with_indifferent_access,
|
||||||
|
{
|
||||||
|
attribute_key: 'campaign_id',
|
||||||
|
filter_operator: 'is_present',
|
||||||
|
values: [],
|
||||||
|
query_operator: nil,
|
||||||
|
custom_attribute_type: ''
|
||||||
|
}.with_indifferent_access
|
||||||
|
]
|
||||||
|
|
||||||
|
expect { filter_service.new(params, user_1).perform }.to raise_error(CustomExceptions::CustomFilter::InvalidQueryOperator)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user