Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62c85ff454 | ||
|
|
8251dd8750 | ||
|
|
4ce23096b7 | ||
|
|
671186afb8 | ||
|
|
a39e5f0ee4 | ||
|
|
1801d3fd20 | ||
|
|
593914d9ad | ||
|
|
3c700261ea | ||
|
|
dcd7277ce9 | ||
|
|
eb4dbf537c | ||
|
|
daf29567d5 | ||
|
|
f2711f9a6f | ||
|
|
85caf39395 | ||
|
|
896a5ad23f | ||
|
|
4b9f2675e1 | ||
|
|
cbe0e7485f | ||
|
|
319f9d31e3 |
@@ -74,7 +74,7 @@ jobs:
|
||||
source ~/.rvm/scripts/rvm
|
||||
rvm install "3.3.3"
|
||||
rvm use 3.3.3 --default
|
||||
gem install bundler -v 2.5.16
|
||||
gem install bundler
|
||||
|
||||
- run:
|
||||
name: Install Application Dependencies
|
||||
|
||||
@@ -68,7 +68,6 @@ 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,7 +46,6 @@ 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,12 +81,4 @@ 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,11 +18,6 @@ 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');
|
||||
|
||||
@@ -42,7 +37,7 @@ const labelMenuItems = computed(() => {
|
||||
isSelected: savedLabels.value.some(
|
||||
savedLabel => savedLabel.id === label.id
|
||||
),
|
||||
action: 'contactLabel',
|
||||
action: 'addLabel',
|
||||
}))
|
||||
.toSorted((a, b) => Number(a.isSelected) - Number(b.isSelected));
|
||||
});
|
||||
@@ -54,7 +49,7 @@ const fetchLabels = async contactId => {
|
||||
store.dispatch('contactLabels/get', contactId);
|
||||
};
|
||||
|
||||
const handleLabelAction = async ({ value }) => {
|
||||
const handleLabelAction = async ({ action, value }) => {
|
||||
try {
|
||||
// Get current label titles
|
||||
const currentLabels = savedLabels.value.map(label => label.title);
|
||||
@@ -64,15 +59,16 @@ const handleLabelAction = async ({ value }) => {
|
||||
if (!selectedLabel) return;
|
||||
|
||||
let updatedLabels;
|
||||
|
||||
// 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 (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];
|
||||
}
|
||||
}
|
||||
|
||||
await store.dispatch('contactLabels/update', {
|
||||
@@ -86,10 +82,6 @@ const handleLabelAction = async ({ value }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveLabel = labelId => {
|
||||
return handleLabelAction({ value: labelId });
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.contactId,
|
||||
(newVal, oldVal) => {
|
||||
@@ -103,31 +95,11 @@ 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" @mouseleave="handleMouseLeave">
|
||||
<LabelItem
|
||||
v-for="label in savedLabels"
|
||||
:key="label.id"
|
||||
:label="label"
|
||||
:is-hovered="hoveredLabel === label.id"
|
||||
@remove="handleRemoveLabel"
|
||||
@hover="handleLabelHover(label.id)"
|
||||
/>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<LabelItem v-for="label in savedLabels" :key="label.id" :label="label" />
|
||||
<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,56 +1,22 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
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="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"
|
||||
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"
|
||||
>
|
||||
<div
|
||||
class="w-2 h-2 m-1 rounded-sm"
|
||||
:style="{ backgroundColor: label.color }"
|
||||
/>
|
||||
<span class="text-sm text-n-slate-12 ltr:mr-px rtl:ml-px">
|
||||
<span class="text-sm text-n-slate-12">
|
||||
{{ 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>
|
||||
|
||||
+3
-17
@@ -1,11 +1,9 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed } 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,
|
||||
@@ -44,19 +42,15 @@ 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,
|
||||
@@ -86,14 +80,6 @@ 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>
|
||||
@@ -140,11 +126,11 @@ const handleInput = value => {
|
||||
:is-loading="isLoading"
|
||||
:disabled="contactableInboxesList?.length > 0 && showInboxesDropdown"
|
||||
allow-create
|
||||
:type="inputType"
|
||||
type="email"
|
||||
class="flex-1 min-h-7"
|
||||
:class="errorClass"
|
||||
focus-on-mount
|
||||
@input="handleInput"
|
||||
@input="emit('searchContacts', $event)"
|
||||
@on-click-outside="emit('updateDropdown', 'contacts', false)"
|
||||
@add="emit('setSelectedContact', $event)"
|
||||
@remove="emit('clearSelectedContact')"
|
||||
|
||||
+3
-5
@@ -193,12 +193,10 @@ export const searchContacts = async ({ keys, query }) => {
|
||||
return filteredPayload || [];
|
||||
};
|
||||
|
||||
export const createNewContact = async input => {
|
||||
export const createNewContact = async email => {
|
||||
const payload = {
|
||||
name: input.startsWith('+')
|
||||
? input.slice(1) // Remove the '+' prefix if it exists
|
||||
: getCapitalizedNameFromEmail(input),
|
||||
...(input.startsWith('+') ? { phone_number: input } : { email: input }),
|
||||
name: getCapitalizedNameFromEmail(email),
|
||||
email,
|
||||
};
|
||||
|
||||
const {
|
||||
|
||||
-22
@@ -429,28 +429,6 @@ 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,6 +1,12 @@
|
||||
<script setup>
|
||||
import { computed, defineAsyncComponent } from 'vue';
|
||||
import { computed, ref, defineAsyncComponent } from 'vue';
|
||||
import { provideMessageContext } from './provider.js';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import { ACCOUNT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import {
|
||||
MESSAGE_TYPES,
|
||||
ATTACHMENT_TYPES,
|
||||
@@ -8,6 +14,7 @@ import {
|
||||
SENDER_TYPES,
|
||||
ORIENTATION,
|
||||
MESSAGE_STATUS,
|
||||
CONTENT_TYPES,
|
||||
} from './constants';
|
||||
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
@@ -30,6 +37,7 @@ const LocationBubble = defineAsyncComponent(
|
||||
|
||||
import MessageError from './MessageError.vue';
|
||||
import MessageMeta from './MessageMeta.vue';
|
||||
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
|
||||
|
||||
/**
|
||||
* @typedef {Object} Attachment
|
||||
@@ -65,7 +73,7 @@ import MessageMeta from './MessageMeta.vue';
|
||||
|
||||
/**
|
||||
* @typedef {Object} Props
|
||||
* @property {('sent'|'delivered'|'read'|'failed')} status - The delivery status of the message
|
||||
* @property {('sent'|'delivered'|'read'|'failed'|'progress')} status - The delivery status of the message
|
||||
* @property {ContentAttributes} [contentAttributes={}] - Additional attributes of the message content
|
||||
* @property {Attachment[]} [attachments=[]] - The attachments associated with the message
|
||||
* @property {Sender|null} [sender=null] - The sender information
|
||||
@@ -78,6 +86,11 @@ import MessageMeta from './MessageMeta.vue';
|
||||
* @property {string|null} [error=null] - Error message if the message failed to send
|
||||
* @property {string|null} [senderType=null] - The type of the sender
|
||||
* @property {string} content - The message content
|
||||
* @property {boolean} [groupWithNext=false] - Whether the message should be grouped with the next message
|
||||
* @property {Object|null} [inReplyTo=null] - The message to which this message is a reply
|
||||
* @property {boolean} [isEmailInbox=false] - Whether the message is from an email inbox
|
||||
* @property {number} conversationId - The ID of the conversation to which the message belongs
|
||||
* @property {number} inboxId - The ID of the inbox to which the message belongs
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line vue/define-macros-order
|
||||
@@ -93,70 +106,51 @@ const props = defineProps({
|
||||
required: true,
|
||||
validator: value => Object.values(MESSAGE_STATUS).includes(value),
|
||||
},
|
||||
attachments: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
private: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
createdAt: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
sender: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
senderId: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
senderType: {
|
||||
attachments: { type: Array, default: () => [] },
|
||||
content: { type: String, default: null },
|
||||
contentAttributes: { type: Object, default: () => ({}) },
|
||||
contentType: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
content: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
contentAttributes: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
currentUserId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
groupWithNext: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
inReplyTo: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
isEmailInbox: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
default: 'text',
|
||||
validator: value => Object.values(CONTENT_TYPES).includes(value),
|
||||
},
|
||||
conversationId: { type: Number, required: true },
|
||||
createdAt: { type: Number, required: true },
|
||||
currentUserId: { type: Number, required: true },
|
||||
groupWithNext: { type: Boolean, default: false },
|
||||
inboxId: { type: Number, required: true },
|
||||
inboxSupportsReplyTo: { type: Object, default: () => ({}) },
|
||||
inReplyTo: { type: Object, default: null },
|
||||
isEmailInbox: { type: Boolean, default: false },
|
||||
private: { type: Boolean, default: false },
|
||||
sender: { type: Object, default: null },
|
||||
senderId: { type: Number, default: null },
|
||||
senderType: { type: String, default: null },
|
||||
sourceId: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const contextMenuPosition = ref({});
|
||||
const showContextMenu = ref(false);
|
||||
/**
|
||||
* Computes the message variant based on props
|
||||
* @type {import('vue').ComputedRef<'user'|'agent'|'activity'|'private'|'bot'|'template'>}
|
||||
*/
|
||||
const variant = computed(() => {
|
||||
if (props.private) return MESSAGE_VARIANTS.PRIVATE;
|
||||
|
||||
if (props.isEmailInbox) {
|
||||
const emailInboxTypes = [MESSAGE_TYPES.INCOMING, MESSAGE_TYPES.OUTGOING];
|
||||
if (emailInboxTypes.includes(props.messageType)) {
|
||||
return MESSAGE_VARIANTS.EMAIL;
|
||||
}
|
||||
}
|
||||
|
||||
if (props.contentType === CONTENT_TYPES.INCOMING_EMAIL) {
|
||||
return MESSAGE_VARIANTS.EMAIL;
|
||||
}
|
||||
|
||||
if (props.status === MESSAGE_STATUS.FAILED) return MESSAGE_VARIANTS.ERROR;
|
||||
if (props.contentAttributes.isUnsupported)
|
||||
if (props.contentAttributes?.isUnsupported)
|
||||
return MESSAGE_VARIANTS.UNSUPPORTED;
|
||||
|
||||
const variants = {
|
||||
@@ -170,10 +164,20 @@ const variant = computed(() => {
|
||||
});
|
||||
|
||||
const isMyMessage = computed(() => {
|
||||
// if an outgoing message is still processing, then it's definitely a
|
||||
// message sent by the current user
|
||||
if (
|
||||
props.status === MESSAGE_STATUS.PROGRESS &&
|
||||
props.messageType === MESSAGE_TYPES.OUTGOING
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const senderId = props.senderId ?? props.sender?.id;
|
||||
const senderType = props.senderType ?? props.sender?.type;
|
||||
|
||||
if (!senderType || !senderId) return false;
|
||||
if (!senderType || !senderId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
senderType.toLowerCase() === SENDER_TYPES.USER.toLowerCase() &&
|
||||
@@ -248,7 +252,11 @@ const componentToRender = computed(() => {
|
||||
if (emailInboxTypes.includes(props.messageType)) return EmailBubble;
|
||||
}
|
||||
|
||||
if (props.contentAttributes.isUnsupported) {
|
||||
if (props.contentType === CONTENT_TYPES.INCOMING_EMAIL) {
|
||||
return EmailBubble;
|
||||
}
|
||||
|
||||
if (props.contentAttributes?.isUnsupported) {
|
||||
return UnsupportedBubble;
|
||||
}
|
||||
|
||||
@@ -260,7 +268,7 @@ const componentToRender = computed(() => {
|
||||
return InstagramStoryBubble;
|
||||
}
|
||||
|
||||
if (props.attachments.length === 1) {
|
||||
if (Array.isArray(props.attachments) && props.attachments.length === 1) {
|
||||
const fileType = props.attachments[0].fileType;
|
||||
|
||||
if (!props.content) {
|
||||
@@ -275,13 +283,88 @@ const componentToRender = computed(() => {
|
||||
if (fileType === ATTACHMENT_TYPES.CONTACT) return ContactBubble;
|
||||
}
|
||||
|
||||
if (props.attachments.length > 1 && !props.content) {
|
||||
if (
|
||||
Array.isArray(props.attachments) &&
|
||||
props.attachments.length > 1 &&
|
||||
!props.content
|
||||
) {
|
||||
return AttachmentsBubble;
|
||||
}
|
||||
|
||||
return TextBubble;
|
||||
});
|
||||
|
||||
const shouldShowContextMenu = computed(() => {
|
||||
return !(
|
||||
props.status === MESSAGE_STATUS.FAILED ||
|
||||
props.status === MESSAGE_STATUS.PROGRESS ||
|
||||
props.contentAttributes?.isUnsupported
|
||||
);
|
||||
});
|
||||
|
||||
const isBubble = computed(() => {
|
||||
return props.messageType !== MESSAGE_TYPES.ACTIVITY;
|
||||
});
|
||||
|
||||
const isMessageDeleted = computed(() => {
|
||||
return props.contentAttributes?.deleted;
|
||||
});
|
||||
|
||||
const payloadForContextMenu = computed(() => {
|
||||
return {
|
||||
id: props.id,
|
||||
content_attributes: props.contentAttributes,
|
||||
content: props.content,
|
||||
conversation_id: props.conversationId,
|
||||
};
|
||||
});
|
||||
|
||||
const contextMenuEnabledOptions = computed(() => {
|
||||
const hasText = !!props.content;
|
||||
const hasAttachments = !!(props.attachments && props.attachments.length > 0);
|
||||
|
||||
const isOutgoing = props.messageType === MESSAGE_TYPES.OUTGOING;
|
||||
|
||||
return {
|
||||
copy: hasText,
|
||||
delete: hasText || hasAttachments,
|
||||
cannedResponse: isOutgoing && hasText,
|
||||
replyTo: !props.private && props.inboxSupportsReplyTo.outgoing,
|
||||
};
|
||||
});
|
||||
|
||||
function openContextMenu(e) {
|
||||
const shouldSkipContextMenu =
|
||||
e.target?.classList.contains('skip-context-menu') ||
|
||||
e.target?.tagName.toLowerCase() === 'a';
|
||||
if (shouldSkipContextMenu || getSelection().toString()) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
if (e.type === 'contextmenu') {
|
||||
useTrack(ACCOUNT_EVENTS.OPEN_MESSAGE_CONTEXT_MENU);
|
||||
}
|
||||
contextMenuPosition.value = {
|
||||
x: e.pageX || e.clientX,
|
||||
y: e.pageY || e.clientY,
|
||||
};
|
||||
showContextMenu.value = true;
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
showContextMenu.value = false;
|
||||
contextMenuPosition.value = { x: null, y: null };
|
||||
}
|
||||
|
||||
function handleReplyTo() {
|
||||
const replyStorageKey = LOCAL_STORAGE_KEYS.MESSAGE_REPLY_TO;
|
||||
const { conversationId, id: replyTo } = props;
|
||||
|
||||
LocalStorage.updateJsonStore(replyStorageKey, conversationId, replyTo);
|
||||
emitter.emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, props);
|
||||
}
|
||||
|
||||
provideMessageContext({
|
||||
variant,
|
||||
inReplyTo: props.inReplyTo,
|
||||
@@ -292,6 +375,7 @@ provideMessageContext({
|
||||
|
||||
<template>
|
||||
<div
|
||||
:id="`message${props.id}`"
|
||||
class="flex w-full"
|
||||
:data-message-id="props.id"
|
||||
:class="[flexOrientationClass, shouldGroupWithNext ? 'mb-2' : 'mb-4']"
|
||||
@@ -324,10 +408,11 @@ provideMessageContext({
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="[grid-area:bubble]"
|
||||
class="[grid-area:bubble] flex"
|
||||
:class="{
|
||||
'pl-9': ORIENTATION.RIGHT === orientation,
|
||||
}"
|
||||
@contextmenu="openContextMenu($event)"
|
||||
>
|
||||
<Component :is="componentToRender" v-bind="props" />
|
||||
</div>
|
||||
@@ -344,8 +429,22 @@ provideMessageContext({
|
||||
:sender="props.sender"
|
||||
:status="props.status"
|
||||
:private="props.private"
|
||||
:is-my-message="isMyMessage"
|
||||
:message-type="props.messageType"
|
||||
:created-at="props.createdAt"
|
||||
:source-id="props.sourceId"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="shouldShowContextMenu" class="context-menu-wrap">
|
||||
<ContextMenu
|
||||
v-if="isBubble && !isMessageDeleted"
|
||||
:context-menu-position="contextMenuPosition"
|
||||
:is-open="showContextMenu"
|
||||
:enabled-options="contextMenuEnabledOptions"
|
||||
:message="payloadForContextMenu"
|
||||
hide-button
|
||||
@open="openContextMenu"
|
||||
@close="closeContextMenu"
|
||||
@reply-to="handleReplyTo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<script setup>
|
||||
import { defineProps, computed } from 'vue';
|
||||
import Message from './Message.vue';
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
|
||||
/**
|
||||
* Props definition for the component
|
||||
* @typedef {Object} Props
|
||||
* @property {Array} readMessages - Array of read messages
|
||||
* @property {Array} unReadMessages - Array of unread messages
|
||||
* @property {Number} currentUserId - ID of the current user
|
||||
* @property {Boolean} isAnEmailChannel - Whether this is an email channel
|
||||
* @property {Object} inboxSupportsReplyTo - Inbox reply support configuration
|
||||
* @property {Array} messages - Array of all messages
|
||||
*/
|
||||
const props = defineProps({
|
||||
readMessages: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
unReadMessages: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
currentUserId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
isAnEmailChannel: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
inboxSupportsReplyTo: {
|
||||
type: Object,
|
||||
default: () => ({ incoming: false, outgoing: false }),
|
||||
},
|
||||
messages: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
shouldShowSpinner: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
unreadMessageCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const unread = computed(() => {
|
||||
return useCamelCase(props.unReadMessages, { deep: true });
|
||||
});
|
||||
|
||||
const read = computed(() => {
|
||||
return useCamelCase(props.readMessages, { deep: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* Determines if a message should be grouped with the next message
|
||||
* @param {Number} index - Index of the current message
|
||||
* @param {Array} messages - Array of messages to check
|
||||
* @returns {Boolean} - Whether the message should be grouped with next
|
||||
*/
|
||||
const shouldGroupWithNext = (index, messages) => {
|
||||
if (index === messages.length - 1) return false;
|
||||
|
||||
const current = messages[index];
|
||||
const next = messages[index + 1];
|
||||
|
||||
if (next.status === 'failed') return false;
|
||||
|
||||
const nextSenderId = next.senderId ?? next.sender?.id;
|
||||
const currentSenderId = current.senderId ?? current.sender?.id;
|
||||
if (currentSenderId !== nextSenderId) return false;
|
||||
|
||||
// Check if messages are in the same minute by rounding down to nearest minute
|
||||
return Math.floor(next.createdAt / 60) === Math.floor(current.createdAt / 60);
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the message that was replied to
|
||||
* @param {Object} parentMessage - The message containing the reply reference
|
||||
* @returns {Object|null} - The message being replied to, or null if not found
|
||||
*/
|
||||
const getInReplyToMessage = parentMessage => {
|
||||
if (!parentMessage) return null;
|
||||
|
||||
const inReplyToMessageId =
|
||||
parentMessage.contentAttributes?.inReplyTo ??
|
||||
parentMessage.content_attributes?.in_reply_to;
|
||||
|
||||
if (!inReplyToMessageId) return null;
|
||||
|
||||
// Find in-reply-to message in the messages prop
|
||||
const replyMessage = props.messages?.find(
|
||||
message => message.id === inReplyToMessageId
|
||||
);
|
||||
|
||||
return replyMessage ? useCamelCase(replyMessage) : null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="px-4 bg-n-background">
|
||||
<transition name="slide-up">
|
||||
<!-- eslint-disable-next-line vue/require-toggle-inside-transition -->
|
||||
<li class="min-h-[4rem]">
|
||||
<span v-if="shouldShowSpinner" class="spinner message" />
|
||||
</li>
|
||||
</transition>
|
||||
<Message
|
||||
v-for="(message, index) in read"
|
||||
:key="message.id"
|
||||
v-bind="message"
|
||||
:is-email-inbox="isAnEmailChannel"
|
||||
:in-reply-to="getInReplyToMessage(message)"
|
||||
:group-with-next="shouldGroupWithNext(index, readMessages)"
|
||||
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
||||
:current-user-id="currentUserId"
|
||||
data-clarity-mask="True"
|
||||
/>
|
||||
<li v-show="unreadMessageCount != 0" class="unread--toast">
|
||||
<span>
|
||||
{{ unreadMessageCount > 9 ? '9+' : unreadMessageCount }}
|
||||
{{
|
||||
unreadMessageCount > 1
|
||||
? $t('CONVERSATION.UNREAD_MESSAGES')
|
||||
: $t('CONVERSATION.UNREAD_MESSAGE')
|
||||
}}
|
||||
</span>
|
||||
</li>
|
||||
<Message
|
||||
v-for="(message, index) in unread"
|
||||
:key="message.id"
|
||||
v-bind="message"
|
||||
:in-reply-to="getInReplyToMessage(message)"
|
||||
:group-with-next="shouldGroupWithNext(index, unReadMessages)"
|
||||
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
||||
:current-user-id="currentUserId"
|
||||
:is-email-inbox="isAnEmailChannel"
|
||||
data-clarity-mask="True"
|
||||
/>
|
||||
<slot name="after" />
|
||||
</ul>
|
||||
</template>
|
||||
@@ -4,8 +4,9 @@ import { messageTimestamp } from 'shared/helpers/timeHelper';
|
||||
|
||||
import MessageStatus from './MessageStatus.vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import { useInbox } from 'dashboard/composables/useInbox';
|
||||
|
||||
import { MESSAGE_STATUS } from './constants';
|
||||
import { MESSAGE_STATUS, MESSAGE_TYPES } from './constants';
|
||||
|
||||
/**
|
||||
* @typedef {Object} Sender
|
||||
@@ -43,21 +44,116 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isMyMessage: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
createdAt: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
sourceId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
messageType: {
|
||||
type: Number,
|
||||
required: true,
|
||||
validator: value => Object.values(MESSAGE_TYPES).includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
isAFacebookInbox,
|
||||
isALineChannel,
|
||||
isAPIInbox,
|
||||
isASmsInbox,
|
||||
isATelegramChannel,
|
||||
isATwilioChannel,
|
||||
isAWebWidgetInbox,
|
||||
isAWhatsAppChannel,
|
||||
isAnEmailChannel,
|
||||
} = useInbox();
|
||||
|
||||
const readableTime = computed(() =>
|
||||
messageTimestamp(props.createdAt, 'LLL d, h:mm a')
|
||||
);
|
||||
|
||||
const showSender = computed(() => !props.isMyMessage && props.sender);
|
||||
|
||||
const showStatusIndicator = computed(() => {
|
||||
if (props.private) return false;
|
||||
if (props.messageType === MESSAGE_TYPES.OUTGOING) return true;
|
||||
if (props.messageType === MESSAGE_TYPES.TEMPLATE) return true;
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const isSent = computed(() => {
|
||||
if (!showStatusIndicator.value) return false;
|
||||
|
||||
// Messages will be marked as sent for the Email channel if they have a source ID.
|
||||
if (isAnEmailChannel.value) return !!props.sourceId;
|
||||
|
||||
if (
|
||||
isAWhatsAppChannel.value ||
|
||||
isATwilioChannel.value ||
|
||||
isAFacebookInbox.value ||
|
||||
isASmsInbox.value ||
|
||||
isATelegramChannel.value
|
||||
) {
|
||||
return props.sourceId && props.status === MESSAGE_STATUS.SENT;
|
||||
}
|
||||
|
||||
// All messages will be mark as sent for the Line channel, as there is no source ID.
|
||||
if (props.isALineChannel) return true;
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const isDelivered = computed(() => {
|
||||
if (!showStatusIndicator.value) return false;
|
||||
|
||||
if (
|
||||
isAWhatsAppChannel.value ||
|
||||
isATwilioChannel.value ||
|
||||
isASmsInbox.value ||
|
||||
isAFacebookInbox.value
|
||||
) {
|
||||
return props.sourceId && props.status === MESSAGE_STATUS.DELIVERED;
|
||||
}
|
||||
// All messages marked as delivered for the web widget inbox and API inbox once they are sent.
|
||||
if (isAWebWidgetInbox.value || isAPIInbox.value) {
|
||||
return props.status === MESSAGE_STATUS.SENT;
|
||||
}
|
||||
if (isALineChannel.value) {
|
||||
return props.status === MESSAGE_STATUS.DELIVERED;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const isRead = computed(() => {
|
||||
if (!showStatusIndicator.value) return false;
|
||||
|
||||
if (
|
||||
isAWhatsAppChannel.value ||
|
||||
isATwilioChannel.value ||
|
||||
isAFacebookInbox.value
|
||||
) {
|
||||
return props.sourceId && props.status === MESSAGE_STATUS.READ;
|
||||
}
|
||||
|
||||
if (isAWebWidgetInbox.value || isAPIInbox.value) {
|
||||
return props.status === MESSAGE_STATUS.READ;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const statusToShow = computed(() => {
|
||||
if (isRead.value) return MESSAGE_STATUS.READ;
|
||||
if (isDelivered.value) return MESSAGE_STATUS.DELIVERED;
|
||||
if (isSent.value) return MESSAGE_STATUS.SENT;
|
||||
|
||||
return MESSAGE_STATUS.PROGRESS;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -72,6 +168,6 @@ const showSender = computed(() => !props.isMyMessage && props.sender);
|
||||
icon="i-lucide-lock-keyhole"
|
||||
class="text-n-slate-10 size-3"
|
||||
/>
|
||||
<MessageStatus v-if="props.isMyMessage" :status />
|
||||
<MessageStatus v-if="showStatusIndicator" :status="statusToShow" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, onMounted, nextTick, useTemplateRef } from 'vue';
|
||||
import BaseAttachmentBubble from './BaseAttachment.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
// import maplibregl from 'maplibre-gl';
|
||||
|
||||
/**
|
||||
* @typedef {Object} Attachment
|
||||
@@ -57,28 +57,28 @@ const mapUrl = computed(
|
||||
|
||||
const mapContainer = useTemplateRef('mapContainer');
|
||||
|
||||
const setupMap = () => {
|
||||
const map = new maplibregl.Map({
|
||||
style: 'https://tiles.openfreemap.org/styles/positron',
|
||||
center: [long.value, lat.value],
|
||||
zoom: 9.5,
|
||||
container: mapContainer.value,
|
||||
attributionControl: false,
|
||||
dragPan: false,
|
||||
dragRotate: false,
|
||||
scrollZoom: false,
|
||||
touchZoom: false,
|
||||
touchRotate: false,
|
||||
keyboard: false,
|
||||
doubleClickZoom: false,
|
||||
});
|
||||
|
||||
return map;
|
||||
};
|
||||
// const setupMap = () => {
|
||||
// const map = new maplibregl.Map({
|
||||
// style: 'https://tiles.openfreemap.org/styles/positron',
|
||||
// center: [long.value, lat.value],
|
||||
// zoom: 15,
|
||||
// container: mapContainer.value,
|
||||
// attributionControl: false,
|
||||
// dragPan: false,
|
||||
// dragRotate: false,
|
||||
// scrollZoom: false,
|
||||
// touchZoom: false,
|
||||
// touchRotate: false,
|
||||
// keyboard: false,
|
||||
// doubleClickZoom: false,
|
||||
// });
|
||||
// new maplibregl.Marker().setLngLat([long.value, lat.value]).addTo(map);
|
||||
// return map;
|
||||
// };
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
setupMap();
|
||||
// setupMap();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
<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: INPUT_TYPES.TEXT },
|
||||
type: { type: String, default: 'text' },
|
||||
isLoading: { type: Boolean, default: false },
|
||||
menuItems: {
|
||||
type: Array,
|
||||
@@ -31,8 +23,8 @@ const props = defineProps({
|
||||
showDropdown: { type: Boolean, default: false },
|
||||
mode: {
|
||||
type: String,
|
||||
default: MODE.MULTIPLE,
|
||||
validator: value => [MODE.SINGLE, MODE.MULTIPLE].includes(value),
|
||||
default: 'multiple',
|
||||
validator: value => ['single', 'multiple'].includes(value),
|
||||
},
|
||||
focusOnMount: { type: Boolean, default: false },
|
||||
allowCreate: { type: Boolean, default: false },
|
||||
@@ -53,17 +45,22 @@ 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(() => getValidationRules(props.type));
|
||||
const v$ = useVuelidate(rules, { newTag });
|
||||
const rules = computed(() => ({
|
||||
newTag: props.type === 'email' ? { email } : {},
|
||||
}));
|
||||
|
||||
const isNewTagInValidType = computed(() =>
|
||||
checkTagTypeValidity(props.type, newTag.value, v$.value)
|
||||
);
|
||||
const v$ = useVuelidate(rules, { newTag });
|
||||
const isNewTagInValidType = computed(() => v$.value.$invalid);
|
||||
|
||||
const showInput = computed(() =>
|
||||
props.mode === MODE.SINGLE
|
||||
@@ -77,49 +74,68 @@ const showDropdownMenu = computed(() =>
|
||||
: props.showDropdown
|
||||
);
|
||||
|
||||
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 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 emitDataOnAdd = value => {
|
||||
const matchingMenuItem = findMatchingMenuItem(props.menuItems, value);
|
||||
return matchingMenuItem
|
||||
? 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();
|
||||
? emit('add', { email: emailValue, ...matchingMenuItem })
|
||||
: emit('add', { value: emailValue, action: 'create' });
|
||||
};
|
||||
|
||||
const addTag = async () => {
|
||||
const trimmedTag = newTag.value?.trim();
|
||||
if (!trimmedTag) return;
|
||||
|
||||
if (!canAddTag(props.mode, tags.value.length)) {
|
||||
if (props.mode === MODE.SINGLE && tags.value.length >= 1) {
|
||||
newTag.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
[INPUT_TYPES.EMAIL, INPUT_TYPES.TEL].includes(props.type) ||
|
||||
props.allowCreate
|
||||
) {
|
||||
if (props.type === 'email' || props.allowCreate) {
|
||||
if (!(await v$.value.$validate())) return;
|
||||
emitDataOnAdd(trimmedTag);
|
||||
}
|
||||
updateValueAndFocus(trimmedTag);
|
||||
|
||||
tags.value.push(trimmedTag);
|
||||
newTag.value = '';
|
||||
modelValue.value = tags.value;
|
||||
tagInputRef.value?.focus();
|
||||
};
|
||||
|
||||
const removeTag = index => {
|
||||
@@ -128,25 +144,19 @@ const removeTag = index => {
|
||||
emit('remove');
|
||||
};
|
||||
|
||||
const handleDropdownAction = async ({
|
||||
email: emailAddress,
|
||||
phoneNumber,
|
||||
...rest
|
||||
}) => {
|
||||
const handleDropdownAction = async ({ email: emailAddress, ...rest }) => {
|
||||
if (props.mode === MODE.SINGLE && tags.value.length >= 1) return;
|
||||
if (!props.showDropdown) return;
|
||||
|
||||
const isEmail = props.type === 'email';
|
||||
newTag.value = isEmail ? emailAddress : phoneNumber;
|
||||
if (props.type === 'email' && props.showDropdown) {
|
||||
newTag.value = emailAddress;
|
||||
if (!(await v$.value.$validate())) return;
|
||||
emit('add', { email: emailAddress, ...rest });
|
||||
}
|
||||
|
||||
if (!(await v$.value.$validate())) return;
|
||||
|
||||
const payload = isEmail
|
||||
? { email: emailAddress, ...rest }
|
||||
: { phoneNumber, ...rest };
|
||||
|
||||
emit('add', payload);
|
||||
updateValueAndFocus(emailAddress);
|
||||
tags.value.push(emailAddress);
|
||||
newTag.value = '';
|
||||
modelValue.value = tags.value;
|
||||
tagInputRef.value?.focus();
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
@@ -216,6 +226,7 @@ const handleBlur = e => emit('blur', e);
|
||||
ref="tagInputRef"
|
||||
v-model="newTag"
|
||||
:placeholder="placeholder"
|
||||
:type="type"
|
||||
:disabled="disabled"
|
||||
class="w-full"
|
||||
:focus-on-mount="focusOnMount"
|
||||
|
||||
@@ -1,299 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,124 +0,0 @@
|
||||
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);
|
||||
@@ -4,10 +4,13 @@ import { ref } from 'vue';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { useAI } from 'dashboard/composables/useAI';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
|
||||
// components
|
||||
import ReplyBox from './ReplyBox.vue';
|
||||
import Message from './Message.vue';
|
||||
import NextMessage from 'next/message/Message.vue';
|
||||
import ConversationLabelSuggestion from './conversation/LabelSuggestion.vue';
|
||||
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||
|
||||
@@ -34,9 +37,26 @@ import { REPLY_POLICY } from 'shared/constants/links';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
|
||||
function shouldGroupWithNext(index, messages) {
|
||||
if (index === messages.length - 1) return false;
|
||||
|
||||
const current = messages[index];
|
||||
const next = messages[index + 1];
|
||||
|
||||
if (next.status === 'failed') return false;
|
||||
|
||||
const nextSenderId = next.senderId ?? next.sender?.id;
|
||||
const currentSenderId = current.senderId ?? current.sender?.id;
|
||||
if (currentSenderId !== nextSenderId) return false;
|
||||
|
||||
// Check if messages are in the same minute by rounding down to nearest minute
|
||||
return Math.floor(next.createdAt / 60) === Math.floor(current.createdAt / 60);
|
||||
}
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Message,
|
||||
NextMessage,
|
||||
ReplyBox,
|
||||
Banner,
|
||||
ConversationLabelSuggestion,
|
||||
@@ -56,6 +76,7 @@ export default {
|
||||
setup() {
|
||||
const isPopOutReplyBox = ref(false);
|
||||
const { isEnterprise } = useConfig();
|
||||
const { accountId } = useAccount();
|
||||
|
||||
const closePopOutReplyBox = () => {
|
||||
isPopOutReplyBox.value = false;
|
||||
@@ -80,6 +101,10 @@ export default {
|
||||
fetchLabelSuggestions,
|
||||
} = useAI();
|
||||
|
||||
const showNextBubbles = LocalStorage.get(
|
||||
LOCAL_STORAGE_KEYS.USE_NEXT_BUBBLE
|
||||
);
|
||||
|
||||
return {
|
||||
isEnterprise,
|
||||
isPopOutReplyBox,
|
||||
@@ -89,6 +114,8 @@ export default {
|
||||
isLabelSuggestionFeatureEnabled,
|
||||
fetchIntegrationsIfRequired,
|
||||
fetchLabelSuggestions,
|
||||
accountId,
|
||||
showNextBubbles,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
@@ -106,6 +133,7 @@ export default {
|
||||
computed: {
|
||||
...mapGetters({
|
||||
currentChat: 'getSelectedChat',
|
||||
currentUserId: 'getCurrentUserID',
|
||||
listLoadingStatus: 'getAllMessagesLoaded',
|
||||
currentAccountId: 'getCurrentAccountId',
|
||||
}),
|
||||
@@ -153,16 +181,28 @@ export default {
|
||||
return messages;
|
||||
},
|
||||
readMessages() {
|
||||
return getReadMessages(
|
||||
const readMessages = getReadMessages(
|
||||
this.getMessages,
|
||||
this.currentChat.agent_last_seen_at
|
||||
);
|
||||
|
||||
if (this.showNextBubbles) {
|
||||
return useCamelCase(readMessages, { deep: true });
|
||||
}
|
||||
|
||||
return readMessages;
|
||||
},
|
||||
unReadMessages() {
|
||||
return getUnreadMessages(
|
||||
const unreadMessages = getUnreadMessages(
|
||||
this.getMessages,
|
||||
this.currentChat.agent_last_seen_at
|
||||
);
|
||||
|
||||
if (this.showNextBubbles) {
|
||||
return useCamelCase(unreadMessages, { deep: true });
|
||||
}
|
||||
|
||||
return unreadMessages;
|
||||
},
|
||||
shouldShowSpinner() {
|
||||
return (
|
||||
@@ -436,11 +476,19 @@ export default {
|
||||
makeMessagesRead() {
|
||||
this.$store.dispatch('markMessagesRead', { id: this.currentChat.id });
|
||||
},
|
||||
|
||||
getInReplyToMessage(parentMessage) {
|
||||
if (!parentMessage) return {};
|
||||
const inReplyToMessageId = parentMessage.content_attributes?.in_reply_to;
|
||||
if (!inReplyToMessageId) return {};
|
||||
// the old implementation took an empty object, but the
|
||||
// new implementation takes null
|
||||
const emptyOption = this.showNextBubbles ? null : {};
|
||||
|
||||
if (!parentMessage) return emptyOption;
|
||||
// to maintain backward compatibility we use both the keys
|
||||
// contentAttributes and content_attributes
|
||||
// TODO: Remove this once we've migrated all the keys to camelCase
|
||||
const inReplyToMessageId =
|
||||
parentMessage.contentAttributes?.inReplyTo ??
|
||||
parentMessage.content_attributes?.in_reply_to;
|
||||
if (!inReplyToMessageId) return emptyOption;
|
||||
|
||||
return this.currentChat?.messages.find(message => {
|
||||
if (message.id === inReplyToMessageId) {
|
||||
@@ -449,6 +497,7 @@ export default {
|
||||
return false;
|
||||
});
|
||||
},
|
||||
shouldGroupWithNext: shouldGroupWithNext,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -473,28 +522,46 @@ export default {
|
||||
@click="onToggleContactPanel"
|
||||
/>
|
||||
</div>
|
||||
<ul class="conversation-panel">
|
||||
<ul
|
||||
class="conversation-panel"
|
||||
:class="{ 'px-4 bg-n-background': showNextBubbles }"
|
||||
>
|
||||
<transition name="slide-up">
|
||||
<!-- eslint-disable-next-line vue/require-toggle-inside-transition -->
|
||||
<li class="min-h-[4rem]">
|
||||
<span v-if="shouldShowSpinner" class="spinner message" />
|
||||
</li>
|
||||
</transition>
|
||||
<Message
|
||||
v-for="message in readMessages"
|
||||
:key="message.id"
|
||||
class="message--read ph-no-capture"
|
||||
data-clarity-mask="True"
|
||||
:data="message"
|
||||
:is-a-tweet="isATweet"
|
||||
:is-a-whatsapp-channel="isAWhatsAppChannel"
|
||||
:is-web-widget-inbox="isAWebWidgetInbox"
|
||||
:is-a-facebook-inbox="isAFacebookInbox"
|
||||
:is-an-email-inbox="isAnEmailChannel"
|
||||
:is-instagram="isInstagramDM"
|
||||
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
||||
:in-reply-to="getInReplyToMessage(message)"
|
||||
/>
|
||||
<template v-if="showNextBubbles">
|
||||
<NextMessage
|
||||
v-for="(message, index) in readMessages"
|
||||
:key="message.id"
|
||||
v-bind="message"
|
||||
:is-email-inbox="isAnEmailChannel"
|
||||
:in-reply-to="getInReplyToMessage(message)"
|
||||
:group-with-next="shouldGroupWithNext(index, readMessages)"
|
||||
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
||||
:current-user-id="currentUserId"
|
||||
data-clarity-mask="True"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Message
|
||||
v-for="message in readMessages"
|
||||
:key="message.id"
|
||||
class="message--read ph-no-capture"
|
||||
data-clarity-mask="True"
|
||||
:data="message"
|
||||
:is-a-tweet="isATweet"
|
||||
:is-a-whatsapp-channel="isAWhatsAppChannel"
|
||||
:is-web-widget-inbox="isAWebWidgetInbox"
|
||||
:is-a-facebook-inbox="isAFacebookInbox"
|
||||
:is-an-email-inbox="isAnEmailChannel"
|
||||
:is-instagram="isInstagramDM"
|
||||
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
||||
:in-reply-to="getInReplyToMessage(message)"
|
||||
/>
|
||||
</template>
|
||||
<li v-show="unreadMessageCount != 0" class="unread--toast">
|
||||
<span>
|
||||
{{ unreadMessageCount > 9 ? '9+' : unreadMessageCount }}
|
||||
@@ -505,20 +572,35 @@ export default {
|
||||
}}
|
||||
</span>
|
||||
</li>
|
||||
<Message
|
||||
v-for="message in unReadMessages"
|
||||
:key="message.id"
|
||||
class="message--unread ph-no-capture"
|
||||
data-clarity-mask="True"
|
||||
:data="message"
|
||||
:is-a-tweet="isATweet"
|
||||
:is-a-whatsapp-channel="isAWhatsAppChannel"
|
||||
:is-web-widget-inbox="isAWebWidgetInbox"
|
||||
:is-a-facebook-inbox="isAFacebookInbox"
|
||||
:is-instagram-dm="isInstagramDM"
|
||||
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
||||
:in-reply-to="getInReplyToMessage(message)"
|
||||
/>
|
||||
<template v-if="showNextBubbles">
|
||||
<NextMessage
|
||||
v-for="(message, index) in unReadMessages"
|
||||
:key="message.id"
|
||||
v-bind="message"
|
||||
:in-reply-to="getInReplyToMessage(message)"
|
||||
:group-with-next="shouldGroupWithNext(index, unReadMessages)"
|
||||
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
||||
:current-user-id="currentUserId"
|
||||
:is-email-inbox="isAnEmailChannel"
|
||||
data-clarity-mask="True"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Message
|
||||
v-for="message in unReadMessages"
|
||||
:key="message.id"
|
||||
class="message--unread ph-no-capture"
|
||||
data-clarity-mask="True"
|
||||
:data="message"
|
||||
:is-a-tweet="isATweet"
|
||||
:is-a-whatsapp-channel="isAWhatsAppChannel"
|
||||
:is-web-widget-inbox="isAWebWidgetInbox"
|
||||
:is-a-facebook-inbox="isAFacebookInbox"
|
||||
:is-instagram-dm="isInstagramDM"
|
||||
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
||||
:in-reply-to="getInReplyToMessage(message)"
|
||||
/>
|
||||
</template>
|
||||
<ConversationLabelSuggestion
|
||||
v-if="shouldShowLabelSuggestions"
|
||||
:suggested-labels="labelSuggestions"
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
|
||||
export const INBOX_FEATURES = {
|
||||
REPLY_TO: 'replyTo',
|
||||
REPLY_TO_OUTGOING: 'replyToOutgoing',
|
||||
};
|
||||
|
||||
// This is a single source of truth for inbox features
|
||||
// This is used to check if a feature is available for a particular inbox or not
|
||||
export const INBOX_FEATURE_MAP = {
|
||||
[INBOX_FEATURES.REPLY_TO]: [
|
||||
INBOX_TYPES.FB,
|
||||
INBOX_TYPES.WEB,
|
||||
INBOX_TYPES.TWITTER,
|
||||
INBOX_TYPES.WHATSAPP,
|
||||
INBOX_TYPES.TELEGRAM,
|
||||
INBOX_TYPES.API,
|
||||
],
|
||||
[INBOX_FEATURES.REPLY_TO_OUTGOING]: [
|
||||
INBOX_TYPES.WEB,
|
||||
INBOX_TYPES.TWITTER,
|
||||
INBOX_TYPES.WHATSAPP,
|
||||
INBOX_TYPES.TELEGRAM,
|
||||
INBOX_TYPES.API,
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Composable for handling macro-related functionality
|
||||
* @returns {Object} An object containing the getMacroDropdownValues function
|
||||
*/
|
||||
export const useInbox = () => {
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const inboxGetter = useMapGetter('inboxes/getInboxById');
|
||||
|
||||
const inbox = computed(() => {
|
||||
const inboxId = currentChat.value.inbox_id;
|
||||
|
||||
return useCamelCase(inboxGetter.value(inboxId), { deep: true });
|
||||
});
|
||||
|
||||
const channelType = computed(() => {
|
||||
return inbox.value.channelType;
|
||||
});
|
||||
|
||||
const isAPIInbox = computed(() => {
|
||||
return channelType.value === INBOX_TYPES.API;
|
||||
});
|
||||
|
||||
const isAFacebookInbox = computed(() => {
|
||||
return channelType.value === INBOX_TYPES.FB;
|
||||
});
|
||||
|
||||
const isAWebWidgetInbox = computed(() => {
|
||||
return channelType.value === INBOX_TYPES.WEB;
|
||||
});
|
||||
|
||||
const isATwilioChannel = computed(() => {
|
||||
return channelType.value === INBOX_TYPES.TWILIO;
|
||||
});
|
||||
|
||||
const isALineChannel = computed(() => {
|
||||
return channelType.value === INBOX_TYPES.LINE;
|
||||
});
|
||||
|
||||
const isAnEmailChannel = computed(() => {
|
||||
return channelType.value === INBOX_TYPES.EMAIL;
|
||||
});
|
||||
|
||||
const isATelegramChannel = computed(() => {
|
||||
return channelType.value === INBOX_TYPES.TELEGRAM;
|
||||
});
|
||||
|
||||
const whatsAppAPIProvider = computed(() => {
|
||||
return inbox.value.provider || '';
|
||||
});
|
||||
|
||||
const isAMicrosoftInbox = computed(() => {
|
||||
return isAnEmailChannel.value && inbox.value.provider === 'microsoft';
|
||||
});
|
||||
|
||||
const isAGoogleInbox = computed(() => {
|
||||
return isAnEmailChannel.value && inbox.value.provider === 'google';
|
||||
});
|
||||
|
||||
const isATwilioSMSChannel = computed(() => {
|
||||
const { medium: medium = '' } = inbox.value;
|
||||
return isATwilioChannel.value && medium === 'sms';
|
||||
});
|
||||
|
||||
const isASmsInbox = computed(() => {
|
||||
return channelType.value === INBOX_TYPES.SMS || isATwilioSMSChannel.value;
|
||||
});
|
||||
|
||||
const isATwilioWhatsAppChannel = computed(() => {
|
||||
const { medium: medium = '' } = inbox.value;
|
||||
return isATwilioChannel.value && medium === 'whatsapp';
|
||||
});
|
||||
|
||||
const isAWhatsAppCloudChannel = computed(() => {
|
||||
return (
|
||||
channelType.value === INBOX_TYPES.WHATSAPP &&
|
||||
whatsAppAPIProvider.value === 'whatsapp_cloud'
|
||||
);
|
||||
});
|
||||
|
||||
const is360DialogWhatsAppChannel = computed(() => {
|
||||
return (
|
||||
channelType.value === INBOX_TYPES.WHATSAPP &&
|
||||
whatsAppAPIProvider.value === 'default'
|
||||
);
|
||||
});
|
||||
|
||||
const isAWhatsAppChannel = computed(() => {
|
||||
return (
|
||||
channelType.value === INBOX_TYPES.WHATSAPP ||
|
||||
isATwilioWhatsAppChannel.value
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
inbox,
|
||||
isAFacebookInbox,
|
||||
isALineChannel,
|
||||
isAPIInbox,
|
||||
isASmsInbox,
|
||||
isATelegramChannel,
|
||||
isATwilioChannel,
|
||||
isAWebWidgetInbox,
|
||||
isAWhatsAppChannel,
|
||||
isAMicrosoftInbox,
|
||||
isAGoogleInbox,
|
||||
isATwilioWhatsAppChannel,
|
||||
isAWhatsAppCloudChannel,
|
||||
is360DialogWhatsAppChannel,
|
||||
isAnEmailChannel,
|
||||
};
|
||||
};
|
||||
@@ -3,6 +3,7 @@
|
||||
import { unref } from 'vue';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
import snakecaseKeys from 'snakecase-keys';
|
||||
import * as Sentry from '@sentry/vue';
|
||||
|
||||
/**
|
||||
* Vue composable that converts object keys to camelCase
|
||||
@@ -12,8 +13,18 @@ import snakecaseKeys from 'snakecase-keys';
|
||||
* @returns {Object|Array} Converted payload with camelCase keys
|
||||
*/
|
||||
export function useCamelCase(payload, options) {
|
||||
const unrefPayload = unref(payload);
|
||||
return camelcaseKeys(unrefPayload, options);
|
||||
try {
|
||||
const unrefPayload = unref(payload);
|
||||
return camelcaseKeys(unrefPayload, options);
|
||||
} catch (e) {
|
||||
Sentry.setContext('transform-keys-error', {
|
||||
payload,
|
||||
options,
|
||||
op: 'camelCase',
|
||||
});
|
||||
Sentry.captureException(e);
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,6 +35,16 @@ export function useCamelCase(payload, options) {
|
||||
* @returns {Object|Array} Converted payload with snake_case keys
|
||||
*/
|
||||
export function useSnakeCase(payload, options) {
|
||||
const unrefPayload = unref(payload);
|
||||
return snakecaseKeys(unrefPayload, options);
|
||||
try {
|
||||
const unrefPayload = unref(payload);
|
||||
return snakecaseKeys(unrefPayload, options);
|
||||
} catch (e) {
|
||||
Sentry.setContext('transform-keys-error', {
|
||||
payload,
|
||||
options,
|
||||
op: 'snakeCase',
|
||||
});
|
||||
Sentry.captureException(e);
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,4 +5,5 @@ export const LOCAL_STORAGE_KEYS = {
|
||||
COLOR_SCHEME: 'color_scheme',
|
||||
DISMISSED_LABEL_SUGGESTIONS: 'labelSuggestionsDismissed',
|
||||
MESSAGE_REPLY_TO: 'messageReplyTo',
|
||||
USE_NEXT_BUBBLE: 'useNextBubble',
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { mapGetters } from 'vuex';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
||||
import AddCannedModal from 'dashboard/routes/dashboard/settings/canned/AddCanned.vue';
|
||||
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import { conversationUrl, frontendURL } from '../../../helper/URLHelper';
|
||||
import {
|
||||
@@ -38,6 +39,10 @@ export default {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
hideButton: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['open', 'close', 'replyTo'],
|
||||
setup() {
|
||||
@@ -62,7 +67,7 @@ export default {
|
||||
return this.getPlainText(this.messageContent);
|
||||
},
|
||||
conversationId() {
|
||||
return this.message.conversation_id;
|
||||
return this.message.conversation_id ?? this.message.conversationId;
|
||||
},
|
||||
messageId() {
|
||||
return this.message.id;
|
||||
@@ -71,7 +76,9 @@ export default {
|
||||
return this.message.content;
|
||||
},
|
||||
contentAttributes() {
|
||||
return this.message.content_attributes;
|
||||
return useSnakeCase(
|
||||
this.message.content_attributes ?? this.message.contentAttributes
|
||||
);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
@@ -183,6 +190,7 @@ export default {
|
||||
:reject-text="$t('CONVERSATION.CONTEXT_MENU.DELETE_CONFIRMATION.CANCEL')"
|
||||
/>
|
||||
<woot-button
|
||||
v-if="!hideButton"
|
||||
icon="more-vertical"
|
||||
color-scheme="secondary"
|
||||
variant="clear"
|
||||
|
||||
@@ -27,7 +27,6 @@ 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? }
|
||||
@@ -84,24 +83,6 @@ 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) && valid_query_operator?(condition)
|
||||
return false unless valid_condition?(condition)
|
||||
end
|
||||
|
||||
true
|
||||
@@ -23,15 +23,6 @@ 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,7 +9,6 @@ class Contacts::FilterService < FilterService
|
||||
end
|
||||
|
||||
def perform
|
||||
validate_query_operator
|
||||
@contacts = query_builder(@filters['contacts'])
|
||||
|
||||
{
|
||||
|
||||
@@ -7,7 +7,6 @@ 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,10 +204,4 @@ 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.16.0'
|
||||
version: '3.15.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::Rails.load
|
||||
Dotenv::Railtie.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,7 +80,6 @@ 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}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
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_12_17_041352) do
|
||||
ActiveRecord::Schema[7.0].define(version: 2024_09_23_215335) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
|
||||
@@ -11,12 +11,6 @@ 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,11 +1,8 @@
|
||||
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
|
||||
@@ -49,8 +46,7 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
|
||||
YOUTUBE_REGEX => :make_youtube_embed,
|
||||
VIMEO_REGEX => :make_vimeo_embed,
|
||||
MP4_REGEX => :make_video_embed,
|
||||
LOOM_REGEX => :make_loom_embed,
|
||||
ARCADE_REGEX => :make_arcade_embed
|
||||
LOOM_REGEX => :make_loom_embed
|
||||
}
|
||||
|
||||
embedding_methods.each do |regex, method|
|
||||
@@ -122,21 +118,4 @@ 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.16.0",
|
||||
"version": "3.15.0",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"eslint": "eslint app/**/*.{js,vue}",
|
||||
|
||||
@@ -76,23 +76,6 @@ 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,33 +137,5 @@ 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,17 +46,6 @@ 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,19 +146,6 @@ 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,30 +185,6 @@ 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