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
|
||||
rvm install "3.3.3"
|
||||
rvm use 3.3.3 --default
|
||||
gem install bundler
|
||||
gem install bundler -v 2.5.16
|
||||
|
||||
- run:
|
||||
name: Install Application Dependencies
|
||||
|
||||
@@ -68,6 +68,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
@contacts = fetch_contacts(contacts)
|
||||
rescue CustomExceptions::CustomFilter::InvalidAttribute,
|
||||
CustomExceptions::CustomFilter::InvalidOperator,
|
||||
CustomExceptions::CustomFilter::InvalidQueryOperator,
|
||||
CustomExceptions::CustomFilter::InvalidValue => e
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
|
||||
@@ -46,6 +46,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
@conversations_count = result[:count]
|
||||
rescue CustomExceptions::CustomFilter::InvalidAttribute,
|
||||
CustomExceptions::CustomFilter::InvalidOperator,
|
||||
CustomExceptions::CustomFilter::InvalidQueryOperator,
|
||||
CustomExceptions::CustomFilter::InvalidValue => e
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
|
||||
@@ -81,4 +81,12 @@ module FilterHelper
|
||||
def default_filter(query_hash, filter_operator_value)
|
||||
"#{filter_config[:table_name]}.#{query_hash[:attribute_key]} #{filter_operator_value} #{query_hash[:query_operator]}"
|
||||
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
|
||||
|
||||
@@ -18,6 +18,11 @@ const route = useRoute();
|
||||
|
||||
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 contactLabels = useMapGetter('contactLabels/getContactLabels');
|
||||
|
||||
@@ -37,7 +42,7 @@ const labelMenuItems = computed(() => {
|
||||
isSelected: savedLabels.value.some(
|
||||
savedLabel => savedLabel.id === label.id
|
||||
),
|
||||
action: 'addLabel',
|
||||
action: 'contactLabel',
|
||||
}))
|
||||
.toSorted((a, b) => Number(a.isSelected) - Number(b.isSelected));
|
||||
});
|
||||
@@ -49,7 +54,7 @@ const fetchLabels = async contactId => {
|
||||
store.dispatch('contactLabels/get', contactId);
|
||||
};
|
||||
|
||||
const handleLabelAction = async ({ action, value }) => {
|
||||
const handleLabelAction = async ({ value }) => {
|
||||
try {
|
||||
// Get current label titles
|
||||
const currentLabels = savedLabels.value.map(label => label.title);
|
||||
@@ -59,16 +64,15 @@ const handleLabelAction = async ({ action, value }) => {
|
||||
if (!selectedLabel) return;
|
||||
|
||||
let updatedLabels;
|
||||
if (action === 'addLabel') {
|
||||
// If label is already selected, remove it (toggle behavior)
|
||||
if (currentLabels.includes(selectedLabel.title)) {
|
||||
updatedLabels = currentLabels.filter(
|
||||
labelTitle => labelTitle !== selectedLabel.title
|
||||
);
|
||||
} else {
|
||||
// Add the new label
|
||||
updatedLabels = [...currentLabels, selectedLabel.title];
|
||||
}
|
||||
|
||||
// If label is already selected, remove it (toggle behavior)
|
||||
if (currentLabels.includes(selectedLabel.title)) {
|
||||
updatedLabels = currentLabels.filter(
|
||||
labelTitle => labelTitle !== selectedLabel.title
|
||||
);
|
||||
} else {
|
||||
// Add the new label
|
||||
updatedLabels = [...currentLabels, selectedLabel.title];
|
||||
}
|
||||
|
||||
await store.dispatch('contactLabels/update', {
|
||||
@@ -82,6 +86,10 @@ const handleLabelAction = async ({ action, value }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveLabel = labelId => {
|
||||
return handleLabelAction({ value: labelId });
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.contactId,
|
||||
(newVal, oldVal) => {
|
||||
@@ -95,11 +103,31 @@ onMounted(() => {
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<LabelItem v-for="label in savedLabels" :key="label.id" :label="label" />
|
||||
<div class="flex flex-wrap items-center gap-2" @mouseleave="handleMouseLeave">
|
||||
<LabelItem
|
||||
v-for="label in savedLabels"
|
||||
:key="label.id"
|
||||
:label="label"
|
||||
:is-hovered="hoveredLabel === label.id"
|
||||
@remove="handleRemoveLabel"
|
||||
@hover="handleLabelHover(label.id)"
|
||||
/>
|
||||
<AddLabel
|
||||
:label-menu-items="labelMenuItems"
|
||||
@update-label="handleLabelAction"
|
||||
|
||||
@@ -94,7 +94,7 @@ const prepareStateBasedOnProps = () => {
|
||||
phoneNumber,
|
||||
additionalAttributes = {},
|
||||
} = props.contactData || {};
|
||||
const { firstName, lastName } = splitName(name);
|
||||
const { firstName, lastName } = splitName(name || '');
|
||||
const {
|
||||
description = '',
|
||||
companyName = '',
|
||||
|
||||
@@ -1,22 +1,56 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: Object,
|
||||
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>
|
||||
|
||||
<template>
|
||||
<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
|
||||
class="w-2 h-2 m-1 rounded-sm"
|
||||
: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 }}
|
||||
</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>
|
||||
</template>
|
||||
|
||||
+17
-3
@@ -1,9 +1,11 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
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 Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
contacts: {
|
||||
type: Array,
|
||||
@@ -42,15 +44,19 @@ const props = defineProps({
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'searchContacts',
|
||||
'setSelectedContact',
|
||||
'clearSelectedContact',
|
||||
'updateDropdown',
|
||||
]);
|
||||
|
||||
const i18nPrefix = 'COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR';
|
||||
const { t } = useI18n();
|
||||
|
||||
const inputType = ref(INPUT_TYPES.EMAIL);
|
||||
|
||||
const contactsList = computed(() => {
|
||||
return props.contacts?.map(({ name, id, thumbnail, email, ...rest }) => ({
|
||||
id,
|
||||
@@ -80,6 +86,14 @@ const errorClass = computed(() => {
|
||||
? '[&_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>
|
||||
|
||||
<template>
|
||||
@@ -126,11 +140,11 @@ const errorClass = computed(() => {
|
||||
:is-loading="isLoading"
|
||||
:disabled="contactableInboxesList?.length > 0 && showInboxesDropdown"
|
||||
allow-create
|
||||
type="email"
|
||||
:type="inputType"
|
||||
class="flex-1 min-h-7"
|
||||
:class="errorClass"
|
||||
focus-on-mount
|
||||
@input="emit('searchContacts', $event)"
|
||||
@input="handleInput"
|
||||
@on-click-outside="emit('updateDropdown', 'contacts', false)"
|
||||
@add="emit('setSelectedContact', $event)"
|
||||
@remove="emit('clearSelectedContact')"
|
||||
|
||||
+5
-3
@@ -193,10 +193,12 @@ export const searchContacts = async ({ keys, query }) => {
|
||||
return filteredPayload || [];
|
||||
};
|
||||
|
||||
export const createNewContact = async email => {
|
||||
export const createNewContact = async input => {
|
||||
const payload = {
|
||||
name: getCapitalizedNameFromEmail(email),
|
||||
email,
|
||||
name: input.startsWith('+')
|
||||
? input.slice(1) // Remove the '+' prefix if it exists
|
||||
: getCapitalizedNameFromEmail(input),
|
||||
...(input.startsWith('+') ? { phone_number: input } : { email: input }),
|
||||
};
|
||||
|
||||
const {
|
||||
|
||||
+22
@@ -429,6 +429,28 @@ describe('composeConversationHelper', () => {
|
||||
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', () => {
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { email } from '@vuelidate/validators';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
|
||||
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.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({
|
||||
placeholder: { type: String, default: '' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
type: { type: String, default: 'text' },
|
||||
type: { type: String, default: INPUT_TYPES.TEXT },
|
||||
isLoading: { type: Boolean, default: false },
|
||||
menuItems: {
|
||||
type: Array,
|
||||
@@ -23,8 +31,8 @@ const props = defineProps({
|
||||
showDropdown: { type: Boolean, default: false },
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'multiple',
|
||||
validator: value => ['single', 'multiple'].includes(value),
|
||||
default: MODE.MULTIPLE,
|
||||
validator: value => [MODE.SINGLE, MODE.MULTIPLE].includes(value),
|
||||
},
|
||||
focusOnMount: { type: Boolean, default: false },
|
||||
allowCreate: { type: Boolean, default: false },
|
||||
@@ -45,22 +53,17 @@ const modelValue = defineModel({
|
||||
default: () => [],
|
||||
});
|
||||
|
||||
const MODE = {
|
||||
SINGLE: 'single',
|
||||
MULTIPLE: 'multiple',
|
||||
};
|
||||
|
||||
const tagInputRef = ref(null);
|
||||
const tags = ref(props.modelValue);
|
||||
const newTag = ref('');
|
||||
const isFocused = ref(true);
|
||||
|
||||
const rules = computed(() => ({
|
||||
newTag: props.type === 'email' ? { email } : {},
|
||||
}));
|
||||
|
||||
const rules = computed(() => getValidationRules(props.type));
|
||||
const v$ = useVuelidate(rules, { newTag });
|
||||
const isNewTagInValidType = computed(() => v$.value.$invalid);
|
||||
|
||||
const isNewTagInValidType = computed(() =>
|
||||
checkTagTypeValidity(props.type, newTag.value, v$.value)
|
||||
);
|
||||
|
||||
const showInput = computed(() =>
|
||||
props.mode === MODE.SINGLE
|
||||
@@ -74,68 +77,49 @@ const showDropdownMenu = computed(() =>
|
||||
: props.showDropdown
|
||||
);
|
||||
|
||||
const filteredMenuItems = computed(() => {
|
||||
if (props.mode === MODE.SINGLE && tags.value.length >= 1) return [];
|
||||
|
||||
const availableMenuItems = props.menuItems.filter(
|
||||
item => !tags.value.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. 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 filteredMenuItems = computed(() =>
|
||||
buildTagMenuItems({
|
||||
mode: props.mode,
|
||||
tags: tags.value,
|
||||
menuItems: props.menuItems,
|
||||
newTag: newTag.value,
|
||||
isLoading: props.isLoading,
|
||||
type: props.type,
|
||||
isNewTagInValidType: isNewTagInValidType.value,
|
||||
})
|
||||
);
|
||||
|
||||
const emitDataOnAdd = value => {
|
||||
const matchingMenuItem = findMatchingMenuItem(props.menuItems, value);
|
||||
return matchingMenuItem
|
||||
? emit('add', { email: emailValue, ...matchingMenuItem })
|
||||
: emit('add', { value: emailValue, action: 'create' });
|
||||
? emit('add', { value: value, ...matchingMenuItem })
|
||||
: 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 trimmedTag = newTag.value?.trim();
|
||||
if (!trimmedTag) return;
|
||||
|
||||
if (props.mode === MODE.SINGLE && tags.value.length >= 1) {
|
||||
if (!canAddTag(props.mode, tags.value.length)) {
|
||||
newTag.value = '';
|
||||
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;
|
||||
emitDataOnAdd(trimmedTag);
|
||||
}
|
||||
|
||||
tags.value.push(trimmedTag);
|
||||
newTag.value = '';
|
||||
modelValue.value = tags.value;
|
||||
tagInputRef.value?.focus();
|
||||
updateValueAndFocus(trimmedTag);
|
||||
};
|
||||
|
||||
const removeTag = index => {
|
||||
@@ -144,19 +128,25 @@ const removeTag = index => {
|
||||
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.showDropdown) return;
|
||||
|
||||
if (props.type === 'email' && props.showDropdown) {
|
||||
newTag.value = emailAddress;
|
||||
if (!(await v$.value.$validate())) return;
|
||||
emit('add', { email: emailAddress, ...rest });
|
||||
}
|
||||
const isEmail = props.type === 'email';
|
||||
newTag.value = isEmail ? emailAddress : phoneNumber;
|
||||
|
||||
tags.value.push(emailAddress);
|
||||
newTag.value = '';
|
||||
modelValue.value = tags.value;
|
||||
tagInputRef.value?.focus();
|
||||
if (!(await v$.value.$validate())) return;
|
||||
|
||||
const payload = isEmail
|
||||
? { email: emailAddress, ...rest }
|
||||
: { phoneNumber, ...rest };
|
||||
|
||||
emit('add', payload);
|
||||
updateValueAndFocus(emailAddress);
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
@@ -226,7 +216,6 @@ const handleBlur = e => emit('blur', e);
|
||||
ref="tagInputRef"
|
||||
v-model="newTag"
|
||||
:placeholder="placeholder"
|
||||
:type="type"
|
||||
:disabled="disabled"
|
||||
class="w-full"
|
||||
: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_actions_format
|
||||
validate :query_operator_presence
|
||||
validate :query_operator_value
|
||||
validates :account_id, presence: true
|
||||
|
||||
after_update_commit :reauthorized!, if: -> { saved_change_to_conditions? }
|
||||
@@ -83,6 +84,24 @@ class AutomationRule < ApplicationRecord
|
||||
operators = conditions.select { |obj, _| obj['query_operator'].nil? }
|
||||
errors.add(:conditions, 'Automation conditions should have query operator.') if operators.length > 1
|
||||
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
|
||||
|
||||
AutomationRule.include_mod_with('Audit::AutomationRule')
|
||||
|
||||
@@ -15,7 +15,7 @@ class AutomationRules::ConditionValidationService
|
||||
|
||||
def perform
|
||||
@rule.conditions.each do |condition|
|
||||
return false unless valid_condition?(condition)
|
||||
return false unless valid_condition?(condition) && valid_query_operator?(condition)
|
||||
end
|
||||
|
||||
true
|
||||
@@ -23,6 +23,15 @@ class AutomationRules::ConditionValidationService
|
||||
|
||||
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)
|
||||
key = condition['attribute_key']
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ class Contacts::FilterService < FilterService
|
||||
end
|
||||
|
||||
def perform
|
||||
validate_query_operator
|
||||
@contacts = query_builder(@filters['contacts'])
|
||||
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ class Conversations::FilterService < FilterService
|
||||
end
|
||||
|
||||
def perform
|
||||
validate_query_operator
|
||||
@conversations = query_builder(@filters['conversations'])
|
||||
mine_count, unassigned_count, all_count, = set_count_for_all_conversations
|
||||
assigned_count = all_count - unassigned_count
|
||||
|
||||
@@ -204,4 +204,10 @@ class FilterService
|
||||
end
|
||||
base_relation.where(@query_string, @filter_values.with_indifferent_access)
|
||||
end
|
||||
|
||||
def validate_query_operator
|
||||
@params[:payload].each do |query_hash|
|
||||
validate_single_condition(query_hash)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
shared: &shared
|
||||
version: '3.15.0'
|
||||
version: '3.16.0'
|
||||
|
||||
development:
|
||||
<<: *shared
|
||||
|
||||
@@ -11,7 +11,7 @@ Bundler.require(*Rails.groups)
|
||||
## Load the specific APM agent
|
||||
# We rely on DOTENV to load the environment variables
|
||||
# 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 'elastic-apm' if ENV.fetch('ELASTIC_APM_SECRET_TOKEN', 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.
|
||||
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_query_operator: Query operator must be either "AND" or "OR".
|
||||
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
|
||||
reports:
|
||||
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.
|
||||
|
||||
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
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
|
||||
@@ -11,6 +11,12 @@ module CustomExceptions::CustomFilter
|
||||
end
|
||||
end
|
||||
|
||||
class InvalidQueryOperator < CustomExceptions::Base
|
||||
def message
|
||||
I18n.t('errors.custom_filters.invalid_query_operator')
|
||||
end
|
||||
end
|
||||
|
||||
class InvalidValue < CustomExceptions::Base
|
||||
def message
|
||||
I18n.t('errors.custom_filters.invalid_value', attribute_name: @data[:attribute_name])
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
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/)([^&/]+)}
|
||||
LOOM_REGEX = %r{https?://(?:www\.)?loom\.com/share/([^&/]+)}
|
||||
VIMEO_REGEX = %r{https?://(?:www\.)?vimeo\.com/(\d+)}
|
||||
MP4_REGEX = %r{https?://(?:www\.)?.+\.(mp4)}
|
||||
ARCADE_REGEX = %r{https?://(?:www\.)?app\.arcade\.software/share/([^&/]+)}
|
||||
|
||||
def text(node)
|
||||
content = node.string_content
|
||||
@@ -46,7 +49,8 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
|
||||
YOUTUBE_REGEX => :make_youtube_embed,
|
||||
VIMEO_REGEX => :make_vimeo_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|
|
||||
@@ -118,4 +122,21 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
|
||||
</video>
|
||||
)
|
||||
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
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chatwoot/chatwoot",
|
||||
"version": "3.15.0",
|
||||
"version": "3.16.0",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"eslint": "eslint app/**/*.{js,vue}",
|
||||
|
||||
@@ -76,6 +76,23 @@ RSpec.describe 'Api::V1::Accounts::AutomationRulesController', type: :request do
|
||||
}
|
||||
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
|
||||
expect(account.automation_rules.count).to eq(0)
|
||||
params[:conditions] << {
|
||||
|
||||
@@ -137,5 +137,33 @@ describe CustomMarkdownRenderer do
|
||||
expect(output).to include('src="https://player.vimeo.com/video/1234567"')
|
||||
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
|
||||
|
||||
@@ -46,6 +46,17 @@ RSpec.describe AutomationRules::ConditionValidationService do
|
||||
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
|
||||
before do
|
||||
rule.conditions = [
|
||||
|
||||
@@ -146,6 +146,19 @@ describe Contacts::FilterService do
|
||||
expect(result[:contacts].length).to be 1
|
||||
expect(result[:contacts].first.id).to eq el_contact.id
|
||||
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
|
||||
|
||||
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[:conversations].pluck(:campaign_id).sort).to eq [campaign_2.id, campaign_1.id].sort
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user