Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b1e8d3c27 | ||
|
|
598ece9a2d | ||
|
|
059506b1db | ||
|
|
397b0bcc9d | ||
|
|
fd69b4c8f2 | ||
|
|
3ea5f258a4 | ||
|
|
42a244369d | ||
|
|
3abe32a2c7 | ||
|
|
f24e7eb231 | ||
|
|
8cfbb75128 | ||
|
|
a1b98a253c | ||
|
|
374d2258c7 | ||
|
|
89da4a2292 | ||
|
|
9aacc0335b | ||
|
|
ab93821d2b | ||
|
|
8d48e05283 | ||
|
|
c08fa631a9 | ||
|
|
14b4c83dc6 | ||
|
|
df92fd12cb | ||
|
|
d84ae196d5 | ||
|
|
bdcc62f1b0 | ||
|
|
7acd239c70 | ||
|
|
9ca03c1af3 | ||
|
|
c218eff5ec | ||
|
|
7c60ad9e28 | ||
|
|
6b3f1114fd | ||
|
|
109b43aadb | ||
|
|
3ddab3ab26 | ||
|
|
e2dd2ccb42 | ||
|
|
9fab70aebf | ||
|
|
efe49f7da4 | ||
|
|
5aef9d2dd0 | ||
|
|
b98c614669 | ||
|
|
ba804e0f30 | ||
|
|
a44cb2c738 | ||
|
|
172ff87b5b | ||
|
|
55f6257313 | ||
|
|
76f129efaf | ||
|
|
7cec4ebaae | ||
|
|
6be95e79f8 | ||
|
|
2b85275e26 |
@@ -68,6 +68,15 @@
|
||||
- Example: `feat(auth): add user authentication`
|
||||
- Don't reference Claude in commit messages
|
||||
|
||||
## PR Description Format
|
||||
|
||||
- Start with a short, user-facing paragraph describing the product change.
|
||||
- Add a `Closes` section with relevant issue links (GitHub, Linear, etc.).
|
||||
- For feature PRs, add `How to test` from a product/UX standpoint.
|
||||
- For bugfix PRs, use `How to reproduce` when helpful.
|
||||
- Optionally add a `What changed` section for implementation highlights.
|
||||
- Do not add a `How this was tested` section listing specs/commands.
|
||||
|
||||
## Project-Specific
|
||||
|
||||
- **Translations**:
|
||||
|
||||
@@ -191,7 +191,7 @@ gem 'reverse_markdown'
|
||||
|
||||
gem 'iso-639'
|
||||
gem 'ruby-openai'
|
||||
gem 'ai-agents'
|
||||
gem 'ai-agents', '>= 0.9.1'
|
||||
|
||||
# TODO: Move this gem as a dependency of ai-agents
|
||||
gem 'ruby_llm', '>= 1.8.2'
|
||||
|
||||
+2
-2
@@ -126,7 +126,7 @@ GEM
|
||||
jbuilder (~> 2)
|
||||
rails (>= 4.2, < 7.2)
|
||||
selectize-rails (~> 0.6)
|
||||
ai-agents (0.9.0)
|
||||
ai-agents (0.9.1)
|
||||
ruby_llm (~> 1.9.1)
|
||||
annotaterb (4.20.0)
|
||||
activerecord (>= 6.0.0)
|
||||
@@ -1024,7 +1024,7 @@ DEPENDENCIES
|
||||
administrate (>= 0.20.1)
|
||||
administrate-field-active_storage (>= 1.0.3)
|
||||
administrate-field-belongs_to_search (>= 0.9.0)
|
||||
ai-agents
|
||||
ai-agents (>= 0.9.1)
|
||||
annotaterb
|
||||
attr_extras
|
||||
audited (~> 5.4, >= 5.4.1)
|
||||
|
||||
@@ -40,8 +40,12 @@ run:
|
||||
fi
|
||||
|
||||
force_run:
|
||||
rm -f ./.overmind.sock
|
||||
rm -f tmp/pids/*.pid
|
||||
@echo "Cleaning up Overmind processes..."
|
||||
@lsof -ti:3036 2>/dev/null | xargs kill -9 2>/dev/null || true
|
||||
@lsof -ti:3000 2>/dev/null | xargs kill -9 2>/dev/null || true
|
||||
@rm -f ./.overmind.sock
|
||||
@rm -f tmp/pids/*.pid
|
||||
@echo "Cleanup complete"
|
||||
overmind start -f Procfile.dev
|
||||
|
||||
force_run_tunnel:
|
||||
|
||||
@@ -105,15 +105,19 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
end
|
||||
|
||||
def message_params
|
||||
content_attributes = {
|
||||
in_reply_to_external_id: response.in_reply_to_external_id
|
||||
}
|
||||
content_attributes[:external_echo] = true if @outgoing_echo
|
||||
|
||||
{
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
message_type: @message_type,
|
||||
status: @outgoing_echo ? :delivered : :sent,
|
||||
content: response.content,
|
||||
source_id: response.identifier,
|
||||
content_attributes: {
|
||||
in_reply_to_external_id: response.in_reply_to_external_id
|
||||
},
|
||||
content_attributes: content_attributes,
|
||||
sender: @outgoing_echo ? nil : @contact_inbox.contact
|
||||
}
|
||||
end
|
||||
|
||||
@@ -2,12 +2,17 @@ class Messages::Messenger::MessageBuilder
|
||||
include ::FileTypeHelper
|
||||
|
||||
def process_attachment(attachment)
|
||||
# This check handles very rare case if there are multiple files to attach with only one usupported file
|
||||
# This check handles very rare case if there are multiple files to attach with only one unsupported file
|
||||
return if unsupported_file_type?(attachment['type'])
|
||||
|
||||
attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
|
||||
params = attachment_params(attachment)
|
||||
attachment_obj = @message.attachments.new(params.except(:remote_file_url))
|
||||
attachment_obj.save!
|
||||
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
|
||||
if facebook_reel?(attachment)
|
||||
update_facebook_reel_content(attachment)
|
||||
elsif params[:remote_file_url]
|
||||
attach_file(attachment_obj, params[:remote_file_url])
|
||||
end
|
||||
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
|
||||
fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
|
||||
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
|
||||
@@ -26,7 +31,7 @@ class Messages::Messenger::MessageBuilder
|
||||
end
|
||||
|
||||
def attachment_params(attachment)
|
||||
file_type = attachment['type'].to_sym
|
||||
file_type = normalize_file_type(attachment['type'])
|
||||
params = { file_type: file_type, account_id: @message.account_id }
|
||||
|
||||
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel, :ig_post, :ig_story].include? file_type
|
||||
@@ -100,6 +105,28 @@ class Messages::Messenger::MessageBuilder
|
||||
|
||||
private
|
||||
|
||||
# Facebook may send attachment types that don't directly match our file_type enum.
|
||||
# Map known aliases to their canonical enum values.
|
||||
FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel }.freeze
|
||||
|
||||
def normalize_file_type(type)
|
||||
sym = type.to_sym
|
||||
FACEBOOK_FILE_TYPE_MAP.fetch(sym, sym)
|
||||
end
|
||||
|
||||
# Facebook sends reel URLs as webpage links (facebook.com/reel/...) rather than
|
||||
# direct video URLs. Downloading these yields HTML, not video content.
|
||||
def facebook_reel?(attachment)
|
||||
attachment['type'].to_sym == :reel
|
||||
end
|
||||
|
||||
def update_facebook_reel_content(attachment)
|
||||
url = attachment.dig('payload', 'url')
|
||||
return if url.blank?
|
||||
|
||||
@message.update!(content: url) if @message.content.blank?
|
||||
end
|
||||
|
||||
def unsupported_file_type?(attachment_type)
|
||||
[:template, :unsupported_type, :ephemeral].include? attachment_type.to_sym
|
||||
end
|
||||
|
||||
@@ -40,7 +40,7 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def reorder
|
||||
Article.update_positions(params[:positions_hash])
|
||||
Article.update_positions(portal: @portal, positions_hash: params[:positions_hash])
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseController
|
||||
before_action :portal
|
||||
before_action :check_authorization
|
||||
before_action :fetch_category, except: [:index, :create]
|
||||
before_action :fetch_category, except: [:index, :create, :reorder]
|
||||
before_action :set_current_page, only: [:index]
|
||||
|
||||
def index
|
||||
@@ -32,6 +32,11 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
|
||||
head :ok
|
||||
end
|
||||
|
||||
def reorder
|
||||
Category.update_positions(portal: @portal, positions_hash: params[:positions_hash])
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_category
|
||||
@@ -39,7 +44,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
|
||||
end
|
||||
|
||||
def portal
|
||||
@portal ||= Current.account.portals.find_by(slug: params[:portal_id])
|
||||
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
|
||||
end
|
||||
|
||||
def related_categories_records
|
||||
|
||||
@@ -107,7 +107,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
end
|
||||
|
||||
def toggle_typing_status
|
||||
typing_status_manager = ::Conversations::TypingStatusManager.new(@conversation, current_user, params)
|
||||
typing_status_manager = ::Conversations::TypingStatusManager.new(@conversation, Current.user, params)
|
||||
typing_status_manager.toggle_typing_status
|
||||
head :ok
|
||||
end
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_inbox
|
||||
before_action :validate_whatsapp_channel
|
||||
before_action :validate_captain_enabled, only: [:analyze]
|
||||
|
||||
def show
|
||||
service = CsatTemplateManagementService.new(@inbox)
|
||||
@@ -24,6 +25,23 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
|
||||
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def analyze
|
||||
template_params = extract_template_params
|
||||
return render_missing_message_error if template_params[:message].blank?
|
||||
|
||||
result = CsatTemplateUtilityAnalysisService.new(
|
||||
account: Current.account,
|
||||
inbox: @inbox,
|
||||
message: template_params[:message],
|
||||
button_text: template_params[:button_text],
|
||||
language: template_params[:language]
|
||||
).perform
|
||||
|
||||
render json: result
|
||||
rescue ActionController::ParameterMissing
|
||||
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_inbox
|
||||
@@ -46,6 +64,12 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
|
||||
render json: { error: 'Message is required' }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def validate_captain_enabled
|
||||
return if Current.account.feature_enabled?('captain_integration')
|
||||
|
||||
render json: { error: 'Captain is required for template analysis' }, status: :forbidden
|
||||
end
|
||||
|
||||
def render_template_creation_result(result)
|
||||
if result[:success]
|
||||
render_successful_template_creation(result)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module AccessTokenAuthHelper
|
||||
BOT_ACCESSIBLE_ENDPOINTS = {
|
||||
'api/v1/accounts/conversations' => %w[toggle_status toggle_priority create update custom_attributes],
|
||||
'api/v1/accounts/conversations' => %w[toggle_status toggle_typing_status toggle_priority create update custom_attributes],
|
||||
'api/v1/accounts/conversations/messages' => ['create'],
|
||||
'api/v1/accounts/conversations/assignments' => ['create']
|
||||
}.freeze
|
||||
@@ -28,7 +28,7 @@ module AccessTokenAuthHelper
|
||||
|
||||
def validate_bot_access_token!
|
||||
return if Current.user.is_a?(User)
|
||||
return if agent_bot_accessible?
|
||||
return if @resource.is_a?(AgentBot) && agent_bot_accessible?
|
||||
|
||||
render_unauthorized('Access to this endpoint is not authorized for bots')
|
||||
end
|
||||
|
||||
@@ -11,6 +11,7 @@ class ConversationFinder
|
||||
'priority_desc' => %w[sort_on_priority desc],
|
||||
'waiting_since_asc' => %w[sort_on_waiting_since asc],
|
||||
'waiting_since_desc' => %w[sort_on_waiting_since desc],
|
||||
'priority_desc_created_at_asc' => %w[sort_on_priority_created_at desc],
|
||||
|
||||
# To be removed in v3.5.0
|
||||
'latest' => %w[sort_on_last_activity_at desc],
|
||||
|
||||
@@ -57,14 +57,14 @@ class ContactAPI extends ApiClient {
|
||||
return axios.post(`${this.url}/${contactId}/labels`, { labels });
|
||||
}
|
||||
|
||||
search(search = '', page = 1, sortAttr = 'name', label = '') {
|
||||
search(search = '', page = 1, sortAttr = 'name', label = '', options = {}) {
|
||||
let requestURL = `${this.url}/search?${buildContactParams(
|
||||
page,
|
||||
sortAttr,
|
||||
label,
|
||||
search
|
||||
)}`;
|
||||
return axios.get(requestURL);
|
||||
return axios.get(requestURL, { signal: options.signal });
|
||||
}
|
||||
|
||||
active(page = 1, sortAttr = 'name') {
|
||||
|
||||
@@ -25,6 +25,12 @@ class CategoriesAPI extends PortalsAPI {
|
||||
delete({ portalSlug, categoryId }) {
|
||||
return axios.delete(`${this.url}/${portalSlug}/categories/${categoryId}`);
|
||||
}
|
||||
|
||||
reorder({ portalSlug, reorderedGroup }) {
|
||||
return axios.post(`${this.url}/${portalSlug}/categories/reorder`, {
|
||||
positions_hash: reorderedGroup,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new CategoriesAPI();
|
||||
|
||||
@@ -42,6 +42,12 @@ class Inboxes extends CacheEnabledApiClient {
|
||||
getCSATTemplateStatus(inboxId) {
|
||||
return axios.get(`${this.url}/${inboxId}/csat_template`);
|
||||
}
|
||||
|
||||
analyzeCSATTemplateUtility(inboxId, template) {
|
||||
return axios.post(`${this.url}/${inboxId}/csat_template/analyze`, {
|
||||
template,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new Inboxes();
|
||||
|
||||
@@ -68,7 +68,19 @@ describe('#ContactsAPI', () => {
|
||||
it('#search', () => {
|
||||
contactAPI.search('leads', 1, 'date', 'customer-support');
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support'
|
||||
'/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support',
|
||||
{ signal: undefined }
|
||||
);
|
||||
});
|
||||
|
||||
it('#search with signal', () => {
|
||||
const controller = new AbortController();
|
||||
contactAPI.search('leads', 1, 'date', 'customer-support', {
|
||||
signal: controller.signal,
|
||||
});
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support',
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -8,5 +8,6 @@ describe('#BulkActionsAPI', () => {
|
||||
expect(categoriesAPI).toHaveProperty('create');
|
||||
expect(categoriesAPI).toHaveProperty('update');
|
||||
expect(categoriesAPI).toHaveProperty('delete');
|
||||
expect(categoriesAPI).toHaveProperty('reorder');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ const props = defineProps({
|
||||
medium: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const emit = defineEmits(['update:modelValue', 'executeCopilotAction']);
|
||||
|
||||
const slots = useSlots();
|
||||
|
||||
@@ -113,6 +113,9 @@ watch(
|
||||
@input="handleInput"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
@execute-copilot-action="
|
||||
(...args) => emit('executeCopilotAction', ...args)
|
||||
"
|
||||
/>
|
||||
<div
|
||||
v-if="showCharacterCount || slots.actions"
|
||||
|
||||
+12
-8
@@ -58,18 +58,22 @@ const openArticle = id => {
|
||||
}
|
||||
};
|
||||
|
||||
const onReorder = reorderedGroup => {
|
||||
store.dispatch('articles/reorder', {
|
||||
reorderedGroup,
|
||||
portalSlug: route.params.portalSlug,
|
||||
});
|
||||
const onReorder = async reorderedGroup => {
|
||||
try {
|
||||
await store.dispatch('articles/reorder', {
|
||||
reorderedGroup,
|
||||
portalSlug: route.params.portalSlug,
|
||||
});
|
||||
} catch {
|
||||
useAlert(t('HELP_CENTER.REORDER_ARTICLE.API.ERROR_MESSAGE'));
|
||||
}
|
||||
};
|
||||
|
||||
const onDragEnd = () => {
|
||||
// Reuse existing positions to maintain order within the current group
|
||||
// Collect and sort existing positions, falling back to index+1 for null/0 values
|
||||
const sortedArticlePositions = localArticles.value
|
||||
.map(article => article.position)
|
||||
.sort((a, b) => a - b); // Use custom sort to handle numeric values correctly
|
||||
.map((article, index) => article.position || index + 1)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
const orderedArticles = localArticles.value.map(article => article.id);
|
||||
|
||||
|
||||
+12
@@ -98,6 +98,17 @@ const handleAction = ({ action, id, category: categoryData }) => {
|
||||
deleteCategory(categoryData);
|
||||
}
|
||||
};
|
||||
|
||||
const reorderCategories = async reorderedGroup => {
|
||||
try {
|
||||
await store.dispatch('categories/reorder', {
|
||||
portalSlug: route.params.portalSlug,
|
||||
reorderedGroup,
|
||||
});
|
||||
} catch {
|
||||
useAlert(t('HELP_CENTER.REORDER_CATEGORY.API.ERROR_MESSAGE'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -122,6 +133,7 @@ const handleAction = ({ action, id, category: categoryData }) => {
|
||||
:categories="categories"
|
||||
@click="openCategoryArticles"
|
||||
@action="handleAction"
|
||||
@reorder="reorderCategories"
|
||||
/>
|
||||
<CategoryEmptyState
|
||||
v-else
|
||||
|
||||
+60
-16
@@ -1,14 +1,22 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import Draggable from 'vuedraggable';
|
||||
import CategoryCard from 'dashboard/components-next/HelpCenter/CategoryCard/CategoryCard.vue';
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
categories: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click', 'action']);
|
||||
const emit = defineEmits(['click', 'action', 'reorder']);
|
||||
|
||||
const localCategories = ref(props.categories);
|
||||
|
||||
const dragEnabled = computed(() => {
|
||||
return localCategories.value?.length > 1;
|
||||
});
|
||||
|
||||
const handleClick = slug => {
|
||||
emit('click', slug);
|
||||
@@ -17,21 +25,57 @@ const handleClick = slug => {
|
||||
const handleAction = ({ action, value, id }, category) => {
|
||||
emit('action', { action, value, id, category });
|
||||
};
|
||||
|
||||
const onDragEnd = () => {
|
||||
// Collect and sort existing positions, falling back to index+1 for null/0 values
|
||||
const sortedPositions = localCategories.value
|
||||
.map((category, index) => category.position || index + 1)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
const reorderedGroup = localCategories.value.reduce(
|
||||
(obj, category, index) => {
|
||||
obj[category.id] = sortedPositions[index];
|
||||
return obj;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
emit('reorder', reorderedGroup);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.categories,
|
||||
newCategories => {
|
||||
localCategories.value = newCategories;
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul role="list" class="grid w-full h-full grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<CategoryCard
|
||||
v-for="category in categories"
|
||||
:id="category.id"
|
||||
:key="category.id"
|
||||
:title="category.name"
|
||||
:icon="category.icon"
|
||||
:description="category.description"
|
||||
:articles-count="category.meta.articles_count || 0"
|
||||
:slug="category.slug"
|
||||
@click="handleClick(category.slug)"
|
||||
@action="handleAction($event, category)"
|
||||
/>
|
||||
</ul>
|
||||
<Draggable
|
||||
v-model="localCategories"
|
||||
:disabled="!dragEnabled"
|
||||
item-key="id"
|
||||
tag="ul"
|
||||
role="list"
|
||||
class="grid w-full h-full grid-cols-1 gap-4 md:grid-cols-2"
|
||||
@end="onDragEnd"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<li class="list-none">
|
||||
<CategoryCard
|
||||
:id="element.id"
|
||||
:title="element.name"
|
||||
:icon="element.icon"
|
||||
:description="element.description"
|
||||
:articles-count="element.meta?.articles_count || 0"
|
||||
:slug="element.slug"
|
||||
:class="{ 'cursor-grab': dragEnabled }"
|
||||
@click="handleClick(element.slug)"
|
||||
@action="handleAction($event, element)"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
||||
</Draggable>
|
||||
</template>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import {
|
||||
searchContacts,
|
||||
createContactSearcher,
|
||||
createNewContact,
|
||||
fetchContactableInboxes,
|
||||
processContactableInboxes,
|
||||
@@ -39,6 +39,7 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const searchContacts = createContactSearcher();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
@@ -107,15 +108,17 @@ const onContactSearch = debounce(
|
||||
isSearching.value = true;
|
||||
contacts.value = [];
|
||||
try {
|
||||
contacts.value = await searchContacts(query);
|
||||
const results = await searchContacts(query);
|
||||
// null means the request was aborted (a newer search is in-flight),
|
||||
if (results === null) return;
|
||||
contacts.value = results;
|
||||
isSearching.value = false;
|
||||
} catch (error) {
|
||||
useAlert(t('COMPOSE_NEW_CONVERSATION.CONTACT_SEARCH.ERROR_MESSAGE'));
|
||||
} finally {
|
||||
isSearching.value = false;
|
||||
useAlert(t('COMPOSE_NEW_CONVERSATION.CONTACT_SEARCH.ERROR_MESSAGE'));
|
||||
}
|
||||
},
|
||||
300,
|
||||
400,
|
||||
false
|
||||
);
|
||||
|
||||
@@ -138,6 +141,7 @@ const handleSelectedContact = async ({ value, action, ...rest }) => {
|
||||
contact = rest;
|
||||
}
|
||||
selectedContact.value = contact;
|
||||
contacts.value = [];
|
||||
if (contact?.id) {
|
||||
isFetchingInboxes.value = true;
|
||||
try {
|
||||
|
||||
+49
-20
@@ -15,6 +15,9 @@ import {
|
||||
prepareWhatsAppMessagePayload,
|
||||
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper.js';
|
||||
|
||||
import { useCopilotReply } from 'dashboard/composables/useCopilotReply';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
|
||||
import ContactSelector from './ContactSelector.vue';
|
||||
import InboxSelector from './InboxSelector.vue';
|
||||
import EmailOptions from './EmailOptions.vue';
|
||||
@@ -22,6 +25,7 @@ import MessageEditor from './MessageEditor.vue';
|
||||
import ActionButtons from './ActionButtons.vue';
|
||||
import InboxEmptyState from './InboxEmptyState.vue';
|
||||
import AttachmentPreviews from './AttachmentPreviews.vue';
|
||||
import CopilotReplyBottomPanel from 'dashboard/components/widgets/WootWriter/CopilotReplyBottomPanel.vue';
|
||||
|
||||
const props = defineProps({
|
||||
contacts: { type: Array, default: () => [] },
|
||||
@@ -42,6 +46,7 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits([
|
||||
'searchContacts',
|
||||
'resetContactSearch',
|
||||
'discard',
|
||||
'updateSelectedContact',
|
||||
'updateTargetInbox',
|
||||
@@ -51,6 +56,8 @@ const emit = defineEmits([
|
||||
|
||||
const DEFAULT_FORMATTING = 'Context::Default';
|
||||
|
||||
const copilot = useCopilotReply();
|
||||
|
||||
const showContactsDropdown = ref(false);
|
||||
const showInboxesDropdown = ref(false);
|
||||
const showCcEmailsDropdown = ref(false);
|
||||
@@ -157,22 +164,8 @@ const isAnyDropdownActive = computed(() => {
|
||||
});
|
||||
|
||||
const handleContactSearch = value => {
|
||||
showContactsDropdown.value = true;
|
||||
const query = typeof value === 'string' ? value.trim() : '';
|
||||
const hasAlphabet = Array.from(query).some(char => {
|
||||
const lower = char.toLowerCase();
|
||||
const upper = char.toUpperCase();
|
||||
return lower !== upper;
|
||||
});
|
||||
const isEmailLike = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(query);
|
||||
|
||||
const keys = ['email', 'phone_number', 'name'].filter(key => {
|
||||
if (key === 'phone_number' && hasAlphabet) return false;
|
||||
if (key === 'name' && isEmailLike) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
emit('searchContacts', { keys, query: value });
|
||||
showContactsDropdown.value = value.trim().length > 1;
|
||||
emit('searchContacts', value);
|
||||
};
|
||||
|
||||
const handleDropdownUpdate = (type, value) => {
|
||||
@@ -186,13 +179,17 @@ const handleDropdownUpdate = (type, value) => {
|
||||
};
|
||||
|
||||
const searchCcEmails = value => {
|
||||
showCcEmailsDropdown.value = true;
|
||||
emit('searchContacts', { keys: ['email'], query: value });
|
||||
showBccEmailsDropdown.value = false;
|
||||
emit('resetContactSearch');
|
||||
showCcEmailsDropdown.value = value.trim().length >= 2;
|
||||
emit('searchContacts', value);
|
||||
};
|
||||
|
||||
const searchBccEmails = value => {
|
||||
showBccEmailsDropdown.value = true;
|
||||
emit('searchContacts', { keys: ['email'], query: value });
|
||||
showCcEmailsDropdown.value = false;
|
||||
emit('resetContactSearch');
|
||||
showBccEmailsDropdown.value = value.trim().length >= 2;
|
||||
emit('searchContacts', value);
|
||||
};
|
||||
|
||||
const setSelectedContact = async ({ value, action, ...rest }) => {
|
||||
@@ -210,6 +207,7 @@ const stripMessageFormatting = channelType => {
|
||||
|
||||
const handleInboxAction = ({ value, action, channelType, medium, ...rest }) => {
|
||||
v$.value.$reset();
|
||||
copilot.reset(false);
|
||||
|
||||
// Strip unsupported formatting when changing the target inbox
|
||||
if (channelType) {
|
||||
@@ -236,6 +234,7 @@ const removeSignatureFromMessage = () => {
|
||||
|
||||
const removeTargetInbox = value => {
|
||||
v$.value.$reset();
|
||||
copilot.reset(false);
|
||||
removeSignatureFromMessage();
|
||||
|
||||
stripMessageFormatting(DEFAULT_FORMATTING);
|
||||
@@ -245,6 +244,7 @@ const removeTargetInbox = value => {
|
||||
};
|
||||
|
||||
const clearSelectedContact = () => {
|
||||
copilot.reset(false);
|
||||
removeSignatureFromMessage();
|
||||
emit('clearSelectedContact');
|
||||
state.message = '';
|
||||
@@ -276,6 +276,7 @@ const handleAttachFile = files => {
|
||||
};
|
||||
|
||||
const clearForm = () => {
|
||||
copilot.reset(false);
|
||||
Object.assign(state, {
|
||||
message: '',
|
||||
subject: '',
|
||||
@@ -338,6 +339,24 @@ const shouldShowMessageEditor = computed(() => {
|
||||
!inboxTypes.value.isTwilioWhatsapp
|
||||
);
|
||||
});
|
||||
|
||||
const isCopilotActive = computed(() => copilot.isActive?.value ?? false);
|
||||
|
||||
const onSubmitCopilotReply = () => {
|
||||
const acceptedMessage = copilot.accept();
|
||||
state.message = acceptedMessage;
|
||||
};
|
||||
|
||||
useKeyboardEvents({
|
||||
'$mod+Enter': {
|
||||
action: () => {
|
||||
if (isCopilotActive.value && !copilot.isButtonDisabled.value) {
|
||||
onSubmitCopilotReply();
|
||||
}
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -368,6 +387,7 @@ const shouldShowMessageEditor = computed(() => {
|
||||
:show-inboxes-dropdown="showInboxesDropdown"
|
||||
:contactable-inboxes-list="contactableInboxesList"
|
||||
:has-errors="validationStates.isInboxInvalid"
|
||||
:is-fetching-inboxes="isFetchingInboxes"
|
||||
@update-inbox="removeTargetInbox"
|
||||
@toggle-dropdown="showInboxesDropdown = $event"
|
||||
@handle-inbox-action="handleInboxAction"
|
||||
@@ -396,6 +416,7 @@ const shouldShowMessageEditor = computed(() => {
|
||||
:has-errors="validationStates.isMessageInvalid"
|
||||
:channel-type="inboxChannelType"
|
||||
:medium="targetInbox?.medium || ''"
|
||||
:copilot="copilot"
|
||||
/>
|
||||
|
||||
<AttachmentPreviews
|
||||
@@ -405,7 +426,15 @@ const shouldShowMessageEditor = computed(() => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CopilotReplyBottomPanel
|
||||
v-if="isCopilotActive"
|
||||
:is-generating-content="copilot.isButtonDisabled.value"
|
||||
class="h-[3.25rem] !px-4 !py-2"
|
||||
@submit="onSubmitCopilotReply"
|
||||
@cancel="copilot.reset"
|
||||
/>
|
||||
<ActionButtons
|
||||
v-else
|
||||
:attached-files="state.attachedFiles"
|
||||
:is-whatsapp-inbox="inboxTypes.isWhatsapp"
|
||||
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
|
||||
|
||||
+10
-10
@@ -44,14 +44,16 @@ const bccEmailsArray = computed(() =>
|
||||
);
|
||||
|
||||
const contactEmailsList = computed(() => {
|
||||
return props.contacts?.map(({ name, id, email }) => ({
|
||||
id,
|
||||
label: email,
|
||||
email,
|
||||
thumbnail: { name: name, src: '' },
|
||||
value: id,
|
||||
action: 'email',
|
||||
}));
|
||||
return props.contacts
|
||||
?.filter(contact => contact.email)
|
||||
.map(({ name, id, email }) => ({
|
||||
id,
|
||||
label: email,
|
||||
email,
|
||||
thumbnail: { name: name, src: '' },
|
||||
value: id,
|
||||
action: 'email',
|
||||
}));
|
||||
});
|
||||
|
||||
// Handle updates from TagInput and convert array back to string
|
||||
@@ -97,7 +99,6 @@ const inputClass = computed(() => {
|
||||
type="email"
|
||||
allow-create
|
||||
class="flex-1 min-h-7"
|
||||
@focus="emit('updateDropdown', 'cc', true)"
|
||||
@input="emit('searchCcEmails', $event)"
|
||||
@on-click-outside="emit('updateDropdown', 'cc', false)"
|
||||
@update:model-value="handleCcUpdate"
|
||||
@@ -131,7 +132,6 @@ const inputClass = computed(() => {
|
||||
allow-create
|
||||
class="flex-1 min-h-7"
|
||||
focus-on-mount
|
||||
@focus="emit('updateDropdown', 'bcc', true)"
|
||||
@input="emit('searchBccEmails', $event)"
|
||||
@on-click-outside="emit('updateDropdown', 'bcc', false)"
|
||||
@update:model-value="handleBccUpdate"
|
||||
|
||||
+7
-1
@@ -1,9 +1,15 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center w-full px-4 py-3 dark:bg-n-amber-11/15 bg-n-amber-3"
|
||||
>
|
||||
<span class="text-sm dark:text-n-amber-11 text-n-amber-11">
|
||||
{{ $t('COMPOSE_NEW_CONVERSATION.FORM.NO_INBOX_ALERT') }}
|
||||
{{ t('COMPOSE_NEW_CONVERSATION.FORM.NO_INBOX_ALERT') }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { generateLabelForContactableInboxesList } from 'dashboard/components-nex
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const props = defineProps({
|
||||
targetInbox: {
|
||||
@@ -28,6 +29,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isFetchingInboxes: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -71,7 +76,9 @@ const targetInboxLabel = computed(() => {
|
||||
v-on-click-outside="() => emit('toggleDropdown', false)"
|
||||
class="relative flex items-center h-7"
|
||||
>
|
||||
<Spinner v-if="isFetchingInboxes" :size="16" />
|
||||
<Button
|
||||
v-else
|
||||
:label="t('COMPOSE_NEW_CONVERSATION.FORM.INBOX_SELECTOR.BUTTON')"
|
||||
variant="link"
|
||||
size="sm"
|
||||
|
||||
+61
-21
@@ -3,6 +3,7 @@ import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
import CopilotEditorSection from 'dashboard/components/widgets/conversation/CopilotEditorSection.vue';
|
||||
|
||||
const props = defineProps({
|
||||
hasErrors: { type: Boolean, default: false },
|
||||
@@ -10,6 +11,7 @@ const props = defineProps({
|
||||
messageSignature: { type: String, default: '' },
|
||||
channelType: { type: String, default: '' },
|
||||
medium: { type: String, default: '' },
|
||||
copilot: { type: Object, default: null },
|
||||
});
|
||||
|
||||
const editorKey = computed(() => `editor-${props.channelType}-${props.medium}`);
|
||||
@@ -20,29 +22,67 @@ const modelValue = defineModel({
|
||||
type: String,
|
||||
default: '',
|
||||
});
|
||||
|
||||
const isCopilotActive = computed(() => props.copilot?.isActive?.value ?? false);
|
||||
|
||||
const executeCopilotAction = (action, data) => {
|
||||
if (props.copilot) {
|
||||
props.copilot.execute(action, data);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 h-full">
|
||||
<Editor
|
||||
v-model="modelValue"
|
||||
:editor-key="editorKey"
|
||||
:placeholder="
|
||||
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
|
||||
"
|
||||
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[12.5rem] [&_.ProseMirror-woot-style]:!min-h-[10rem] [&_.ProseMirror-menubar]:!pt-0 [&_.mention--box]:-top-[7.5rem] [&_.mention--box]:bottom-[unset]"
|
||||
:class="
|
||||
hasErrors
|
||||
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
|
||||
: ''
|
||||
"
|
||||
enable-variables
|
||||
:show-character-count="false"
|
||||
:signature="messageSignature"
|
||||
allow-signature
|
||||
:send-with-signature="sendWithSignature"
|
||||
:channel-type="channelType"
|
||||
:medium="medium"
|
||||
/>
|
||||
<div class="flex-1 h-full px-4 py-4">
|
||||
<Transition
|
||||
mode="out-in"
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 translate-y-2 scale-[0.98]"
|
||||
enter-to-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-to-class="opacity-0 translate-y-2 scale-[0.98]"
|
||||
>
|
||||
<div
|
||||
:key="copilot ? copilot.editorTransitionKey.value : 'rich'"
|
||||
class="h-full"
|
||||
>
|
||||
<CopilotEditorSection
|
||||
v-if="isCopilotActive"
|
||||
:show-copilot-editor="copilot.showEditor.value"
|
||||
:is-generating-content="copilot.isGenerating.value"
|
||||
:generated-content="copilot.generatedContent.value"
|
||||
class="!mb-0"
|
||||
@focus="() => {}"
|
||||
@blur="() => {}"
|
||||
@clear-selection="() => {}"
|
||||
@content-ready="copilot.setContentReady"
|
||||
@send="copilot.sendFollowUp"
|
||||
/>
|
||||
<Editor
|
||||
v-else
|
||||
v-model="modelValue"
|
||||
:editor-key="editorKey"
|
||||
:placeholder="
|
||||
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
|
||||
"
|
||||
class="[&>div]:!border-transparent [&>div]:px-0 [&>div]:py-0 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[12.5rem] [&_.ProseMirror-woot-style]:!min-h-[12rem] [&_.ProseMirror-menubar]:!pt-0 [&_.mention--box]:-top-[7.5rem] [&_.mention--box]:bottom-[unset]"
|
||||
:class="
|
||||
hasErrors
|
||||
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
|
||||
: ''
|
||||
"
|
||||
enable-variables
|
||||
enable-captain-tools
|
||||
:show-character-count="false"
|
||||
:signature="messageSignature"
|
||||
allow-signature
|
||||
:send-with-signature="sendWithSignature"
|
||||
:channel-type="channelType"
|
||||
:medium="medium"
|
||||
@execute-copilot-action="executeCopilotAction"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+36
-31
@@ -176,38 +176,43 @@ export const prepareWhatsAppMessagePayload = ({
|
||||
};
|
||||
};
|
||||
|
||||
export const generateContactQuery = ({ keys = ['email'], query }) => {
|
||||
return {
|
||||
payload: keys.map(key => {
|
||||
const filterPayload = {
|
||||
attribute_key: key,
|
||||
filter_operator: 'contains',
|
||||
values: [query],
|
||||
attribute_model: 'standard',
|
||||
};
|
||||
if (keys.findIndex(k => k === key) !== keys.length - 1) {
|
||||
filterPayload.query_operator = 'or';
|
||||
}
|
||||
return filterPayload;
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
// API Calls
|
||||
export const searchContacts = async ({ keys, query }) => {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await ContactAPI.filter(
|
||||
undefined,
|
||||
'name',
|
||||
generateContactQuery({ keys, query })
|
||||
);
|
||||
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
|
||||
// Filter contacts that have either phone_number or email
|
||||
const filteredPayload = camelCasedPayload?.filter(
|
||||
contact => contact.phoneNumber || contact.email
|
||||
);
|
||||
return filteredPayload || [];
|
||||
const MIN_SEARCH_LENGTH = 2;
|
||||
|
||||
export const createContactSearcher = () => {
|
||||
let controller = null;
|
||||
|
||||
return async (query, { skipMinLength = false } = {}) => {
|
||||
const trimmed = typeof query === 'string' ? query.trim() : '';
|
||||
|
||||
controller?.abort();
|
||||
|
||||
if (!trimmed || (!skipMinLength && trimmed.length < MIN_SEARCH_LENGTH))
|
||||
return [];
|
||||
|
||||
controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
|
||||
try {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await ContactAPI.search(trimmed, 1, 'name', '', { signal });
|
||||
|
||||
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
|
||||
// Filter contacts that have either phone_number or email
|
||||
const filteredPayload = camelCasedPayload?.filter(
|
||||
contact => contact.phoneNumber || contact.email
|
||||
);
|
||||
return filteredPayload || [];
|
||||
} catch (error) {
|
||||
// Return null for aborted requests so callers can distinguish
|
||||
// "request was cancelled" from "no results found"
|
||||
if (error?.name === 'AbortError' || error?.name === 'CanceledError') {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const createNewContact = async input => {
|
||||
|
||||
+100
-98
@@ -336,72 +336,13 @@ describe('composeConversationHelper', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateContactQuery', () => {
|
||||
it('generates correct query structure for contact search', () => {
|
||||
const query = 'test@example.com';
|
||||
const expected = {
|
||||
payload: [
|
||||
{
|
||||
attribute_key: 'email',
|
||||
filter_operator: 'contains',
|
||||
values: [query],
|
||||
attribute_model: 'standard',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(helpers.generateContactQuery({ keys: ['email'], query })).toEqual(
|
||||
expected
|
||||
);
|
||||
});
|
||||
|
||||
it('handles empty query', () => {
|
||||
const expected = {
|
||||
payload: [
|
||||
{
|
||||
attribute_key: 'email',
|
||||
filter_operator: 'contains',
|
||||
values: [''],
|
||||
attribute_model: 'standard',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(
|
||||
helpers.generateContactQuery({ keys: ['email'], query: '' })
|
||||
).toEqual(expected);
|
||||
});
|
||||
|
||||
it('handles mutliple keys', () => {
|
||||
const expected = {
|
||||
payload: [
|
||||
{
|
||||
attribute_key: 'email',
|
||||
filter_operator: 'contains',
|
||||
values: ['john'],
|
||||
attribute_model: 'standard',
|
||||
query_operator: 'or',
|
||||
},
|
||||
{
|
||||
attribute_key: 'phone_number',
|
||||
filter_operator: 'contains',
|
||||
values: ['john'],
|
||||
attribute_model: 'standard',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(
|
||||
helpers.generateContactQuery({
|
||||
keys: ['email', 'phone_number'],
|
||||
query: 'john',
|
||||
})
|
||||
).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('API calls', () => {
|
||||
describe('searchContacts', () => {
|
||||
describe('createContactSearcher', () => {
|
||||
let searchContacts;
|
||||
beforeEach(() => {
|
||||
searchContacts = helpers.createContactSearcher();
|
||||
});
|
||||
|
||||
it('searches contacts and returns camelCase results', async () => {
|
||||
const mockPayload = [
|
||||
{
|
||||
@@ -413,14 +354,11 @@ describe('composeConversationHelper', () => {
|
||||
},
|
||||
];
|
||||
|
||||
ContactAPI.filter.mockResolvedValue({
|
||||
ContactAPI.search.mockResolvedValue({
|
||||
data: { payload: mockPayload },
|
||||
});
|
||||
|
||||
const result = await helpers.searchContacts({
|
||||
keys: ['email'],
|
||||
query: 'john',
|
||||
});
|
||||
const result = await searchContacts('john');
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
@@ -432,16 +370,56 @@ describe('composeConversationHelper', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
expect(ContactAPI.filter).toHaveBeenCalledWith(undefined, 'name', {
|
||||
payload: [
|
||||
{
|
||||
attribute_key: 'email',
|
||||
filter_operator: 'contains',
|
||||
values: ['john'],
|
||||
attribute_model: 'standard',
|
||||
},
|
||||
],
|
||||
expect(ContactAPI.search).toHaveBeenCalledWith(
|
||||
'john',
|
||||
1,
|
||||
'name',
|
||||
'',
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) })
|
||||
);
|
||||
});
|
||||
|
||||
it('returns empty array for queries shorter than 2 characters', async () => {
|
||||
const result = await searchContacts('j');
|
||||
expect(result).toEqual([]);
|
||||
expect(ContactAPI.search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns empty array for empty or whitespace-only queries', async () => {
|
||||
expect(await searchContacts('')).toEqual([]);
|
||||
expect(await searchContacts(' ')).toEqual([]);
|
||||
expect(await searchContacts(null)).toEqual([]);
|
||||
expect(ContactAPI.search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('aborts previous in-flight request when a new search starts', async () => {
|
||||
const mockPayload = [
|
||||
{ id: 1, name: 'Result', email: 'r@test.com', phone_number: null },
|
||||
];
|
||||
|
||||
let resolveFirst;
|
||||
const firstCall = new Promise(resolve => {
|
||||
resolveFirst = resolve;
|
||||
});
|
||||
ContactAPI.search
|
||||
.mockReturnValueOnce(firstCall)
|
||||
.mockResolvedValueOnce({ data: { payload: mockPayload } });
|
||||
|
||||
// Start first search (will hang)
|
||||
const first = searchContacts('alpha');
|
||||
// Start second search (aborts first)
|
||||
const second = searchContacts('beta');
|
||||
|
||||
// Resolve the first call with CanceledError (simulating axios abort)
|
||||
const canceledError = new Error('canceled');
|
||||
canceledError.name = 'CanceledError';
|
||||
resolveFirst(Promise.reject(canceledError));
|
||||
|
||||
const [firstResult, secondResult] = await Promise.all([first, second]);
|
||||
expect(firstResult).toBeNull();
|
||||
expect(secondResult).toEqual([
|
||||
{ id: 1, name: 'Result', email: 'r@test.com', phoneNumber: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('searches contacts and returns only contacts with email or phone number', async () => {
|
||||
@@ -469,14 +447,11 @@ describe('composeConversationHelper', () => {
|
||||
},
|
||||
];
|
||||
|
||||
ContactAPI.filter.mockResolvedValue({
|
||||
ContactAPI.search.mockResolvedValue({
|
||||
data: { payload: mockPayload },
|
||||
});
|
||||
|
||||
const result = await helpers.searchContacts({
|
||||
keys: ['email'],
|
||||
query: 'john',
|
||||
});
|
||||
const result = await searchContacts('john');
|
||||
|
||||
// Should only return contacts with either email or phone number
|
||||
expect(result).toEqual([
|
||||
@@ -496,24 +471,21 @@ describe('composeConversationHelper', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
expect(ContactAPI.filter).toHaveBeenCalledWith(undefined, 'name', {
|
||||
payload: [
|
||||
{
|
||||
attribute_key: 'email',
|
||||
filter_operator: 'contains',
|
||||
values: ['john'],
|
||||
attribute_model: 'standard',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(ContactAPI.search).toHaveBeenCalledWith(
|
||||
'john',
|
||||
1,
|
||||
'name',
|
||||
'',
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) })
|
||||
);
|
||||
});
|
||||
|
||||
it('handles empty search results', async () => {
|
||||
ContactAPI.filter.mockResolvedValue({
|
||||
ContactAPI.search.mockResolvedValue({
|
||||
data: { payload: [] },
|
||||
});
|
||||
|
||||
const result = await helpers.searchContacts('nonexistent');
|
||||
const result = await searchContacts('nonexistent');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -536,11 +508,11 @@ describe('composeConversationHelper', () => {
|
||||
},
|
||||
];
|
||||
|
||||
ContactAPI.filter.mockResolvedValue({
|
||||
ContactAPI.search.mockResolvedValue({
|
||||
data: { payload: mockPayload },
|
||||
});
|
||||
|
||||
const result = await helpers.searchContacts('test');
|
||||
const result = await searchContacts('test');
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
@@ -562,6 +534,36 @@ describe('composeConversationHelper', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createContactSearcher isolation', () => {
|
||||
it('creates isolated searcher instances that do not cancel each other', async () => {
|
||||
const searcherA = helpers.createContactSearcher();
|
||||
const searcherB = helpers.createContactSearcher();
|
||||
|
||||
const payloadA = [
|
||||
{ id: 1, name: 'Alice', email: 'a@test.com', phone_number: null },
|
||||
];
|
||||
const payloadB = [
|
||||
{ id: 2, name: 'Bob', email: 'b@test.com', phone_number: null },
|
||||
];
|
||||
|
||||
ContactAPI.search
|
||||
.mockResolvedValueOnce({ data: { payload: payloadA } })
|
||||
.mockResolvedValueOnce({ data: { payload: payloadB } });
|
||||
|
||||
const [resultA, resultB] = await Promise.all([
|
||||
searcherA('alice'),
|
||||
searcherB('bob'),
|
||||
]);
|
||||
|
||||
expect(resultA).toEqual([
|
||||
{ id: 1, name: 'Alice', email: 'a@test.com', phoneNumber: null },
|
||||
]);
|
||||
expect(resultB).toEqual([
|
||||
{ id: 2, name: 'Bob', email: 'b@test.com', phoneNumber: null },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createNewContact', () => {
|
||||
it('creates new contact with capitalized name', async () => {
|
||||
const mockContact = { id: 1, name: 'John', email: 'john@example.com' };
|
||||
|
||||
@@ -129,6 +129,7 @@ const props = defineProps({
|
||||
inReplyTo: { type: Object, default: null }, // eslint-disable-line vue/no-unused-properties
|
||||
isEmailInbox: { type: Boolean, default: false },
|
||||
private: { type: Boolean, default: false },
|
||||
additionalAttributes: { type: Object, default: () => ({}) }, // eslint-disable-line vue/no-unused-properties
|
||||
sender: { type: Object, default: null },
|
||||
senderId: { type: Number, default: null },
|
||||
senderType: { type: String, default: null },
|
||||
@@ -172,7 +173,10 @@ const variant = computed(() => {
|
||||
return MESSAGE_VARIANTS.AGENT;
|
||||
}
|
||||
|
||||
const isBot = !props.sender || props.sender.type === SENDER_TYPES.AGENT_BOT;
|
||||
const isBot =
|
||||
props.sender?.type === SENDER_TYPES.AGENT_BOT ||
|
||||
props.senderType === SENDER_TYPES.AGENT_BOT ||
|
||||
(!props.sender && !props.additionalAttributes?.senderName);
|
||||
if (isBot && props.messageType === MESSAGE_TYPES.OUTGOING) {
|
||||
return MESSAGE_VARIANTS.BOT;
|
||||
}
|
||||
@@ -450,12 +454,13 @@ const avatarInfo = computed(() => {
|
||||
};
|
||||
}
|
||||
|
||||
// If no sender, return bot info
|
||||
// If no sender, check for Slack (or other integration) sender info
|
||||
if (!props.sender) {
|
||||
return {
|
||||
name: t('CONVERSATION.BOT'),
|
||||
src: '',
|
||||
};
|
||||
const { senderName, senderAvatarUrl } = props.additionalAttributes || {};
|
||||
if (senderName) {
|
||||
return { name: senderName, src: senderAvatarUrl ?? '' };
|
||||
}
|
||||
return { name: t('CONVERSATION.BOT'), src: '' };
|
||||
}
|
||||
|
||||
const { sender } = props;
|
||||
|
||||
@@ -81,6 +81,7 @@ const isDelivered = computed(() => {
|
||||
isATwilioChannel.value ||
|
||||
isASmsInbox.value ||
|
||||
isAFacebookInbox.value ||
|
||||
isAnInstagramChannel.value ||
|
||||
isATiktokChannel.value
|
||||
) {
|
||||
return sourceId.value && status.value === MESSAGE_STATUS.DELIVERED;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import RadioCard from '../RadioCard.vue';
|
||||
import RadioCard from './RadioCard.vue';
|
||||
|
||||
const selectedOption = ref('round_robin');
|
||||
|
||||
@@ -72,7 +72,7 @@ const isNewTagInValidType = computed(() =>
|
||||
|
||||
const showInput = computed(() =>
|
||||
props.mode === MODE.SINGLE
|
||||
? isFocused.value && !tags.value.length
|
||||
? !tags.value.length
|
||||
: isFocused.value || !tags.value.length
|
||||
);
|
||||
|
||||
|
||||
@@ -17,10 +17,7 @@ import {
|
||||
useFunctionGetter,
|
||||
} from 'dashboard/composables/store.js';
|
||||
|
||||
// [VITE] [TODO] We are using vue-virtual-scroll for now, since that seemed the simplest way to migrate
|
||||
// from the current one. But we should consider using tanstack virtual in the future
|
||||
// https://tanstack.com/virtual/latest/docs/framework/vue/examples/variable
|
||||
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
|
||||
import { Virtualizer } from 'virtua/vue';
|
||||
import ChatListHeader from './ChatListHeader.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import ConversationFilter from 'next/filter/ConversationFilter.vue';
|
||||
@@ -29,9 +26,9 @@ import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
|
||||
import ConversationItem from './ConversationItem.vue';
|
||||
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
|
||||
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
|
||||
import IntersectionObserver from './IntersectionObserver.vue';
|
||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
|
||||
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.vue';
|
||||
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
@@ -46,7 +43,6 @@ import {
|
||||
useSnakeCase,
|
||||
} from 'dashboard/composables/useTransformKeys';
|
||||
import { useEmitter } from 'dashboard/composables/emitter';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
import { useConversationRequiredAttributes } from 'dashboard/composables/useConversationRequiredAttributes';
|
||||
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
@@ -70,8 +66,6 @@ import { matchesFilters } from '../store/modules/conversations/helpers/filterHel
|
||||
import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
|
||||
import { ASSIGNEE_TYPE_TAB_PERMISSIONS } from 'dashboard/constants/permissions.js';
|
||||
|
||||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
|
||||
|
||||
const props = defineProps({
|
||||
conversationInbox: { type: [String, Number], default: 0 },
|
||||
teamId: { type: [String, Number], default: 0 },
|
||||
@@ -91,9 +85,9 @@ const store = useStore();
|
||||
|
||||
const resolveAttributesModalRef = ref(null);
|
||||
const conversationListRef = ref(null);
|
||||
const conversationDynamicScroller = ref(null);
|
||||
const virtualListRef = ref(null);
|
||||
|
||||
provide('contextMenuElementTarget', conversationDynamicScroller);
|
||||
provide('contextMenuElementTarget', virtualListRef);
|
||||
|
||||
const activeAssigneeTab = ref(wootConstants.ASSIGNEE_TYPE.ME);
|
||||
const activeStatus = ref(wootConstants.STATUS_TYPE.OPEN);
|
||||
@@ -161,12 +155,6 @@ const {
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
// computed
|
||||
const intersectionObserverOptions = computed(() => {
|
||||
return {
|
||||
root: conversationListRef.value,
|
||||
rootMargin: '100px 0px 100px 0px',
|
||||
};
|
||||
});
|
||||
|
||||
const hasAppliedFilters = computed(() => {
|
||||
return appliedFilters.value.length !== 0;
|
||||
@@ -384,18 +372,6 @@ function setFiltersFromUISettings() {
|
||||
|
||||
function emitConversationLoaded() {
|
||||
emit('conversationLoad');
|
||||
// [VITE] removing this since the library has changed
|
||||
// nextTick(() => {
|
||||
// // Addressing a known issue in the virtual list library where dynamically added items
|
||||
// // might not render correctly. This workaround involves a slight manual adjustment
|
||||
// // to the scroll position, triggering the list to refresh its rendering.
|
||||
// const virtualList = conversationListRef.value;
|
||||
// const scrollToOffset = virtualList?.scrollToOffset;
|
||||
// const currentOffset = virtualList?.getOffset() || 0;
|
||||
// if (scrollToOffset) {
|
||||
// scrollToOffset(currentOffset + 1);
|
||||
// }
|
||||
// });
|
||||
}
|
||||
|
||||
function fetchFilteredConversations(payload) {
|
||||
@@ -607,16 +583,13 @@ function loadMoreConversations() {
|
||||
}
|
||||
}
|
||||
|
||||
// Add a method to handle scroll events
|
||||
function handleScroll() {
|
||||
const scroller = conversationDynamicScroller.value;
|
||||
if (scroller && scroller.hasScrollbar) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = scroller.$el;
|
||||
if (scrollHeight - (scrollTop + clientHeight) < 100) {
|
||||
loadMoreConversations();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Use IntersectionObserver instead of @scroll since Virtualizer only emits on user scroll.
|
||||
// If the list doesn’t fill the viewport, loading can stall.
|
||||
// IntersectionObserver triggers as soon as the sentinel is visible.
|
||||
const intersectionObserverOptions = computed(() => ({
|
||||
root: conversationListRef.value,
|
||||
rootMargin: '100px 0px 100px 0px',
|
||||
}));
|
||||
|
||||
function updateAssigneeTab(selectedTab) {
|
||||
if (activeAssigneeTab.value !== selectedTab) {
|
||||
@@ -822,8 +795,6 @@ useEmitter('fetch_conversation_stats', () => {
|
||||
store.dispatch('conversationStats/get', conversationFilters.value);
|
||||
});
|
||||
|
||||
useEventListener(conversationDynamicScroller, 'scroll', handleScroll);
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('setChatListFilters', conversationFilters.value);
|
||||
setFiltersFromUISettings();
|
||||
@@ -977,61 +948,40 @@ watch(conversationFilters, (newVal, oldVal) => {
|
||||
/>
|
||||
<div
|
||||
ref="conversationListRef"
|
||||
class="overflow-hidden flex-1 conversations-list hover:overflow-y-auto"
|
||||
:class="{ 'overflow-hidden': isContextMenuOpen }"
|
||||
class="flex-1 min-h-0 overflow-y-auto conversations-list"
|
||||
:class="{ '!overflow-hidden': isContextMenuOpen }"
|
||||
>
|
||||
<DynamicScroller
|
||||
ref="conversationDynamicScroller"
|
||||
:items="conversationList"
|
||||
:min-item-size="24"
|
||||
class="overflow-auto w-full h-full"
|
||||
<Virtualizer
|
||||
ref="virtualListRef"
|
||||
v-slot="{ item, index }"
|
||||
:data="conversationList"
|
||||
>
|
||||
<template #default="{ item, index, active }">
|
||||
<!--
|
||||
If we encounter resizing issues, we can set the `watchData` prop to true
|
||||
this will deeply watch the entire object instead of just size dependencies
|
||||
But it can impact performance
|
||||
-->
|
||||
<DynamicScrollerItem
|
||||
:item="item"
|
||||
:active="active"
|
||||
:data-index="index"
|
||||
:size-dependencies="[
|
||||
item.messages,
|
||||
item.labels,
|
||||
item.uuid,
|
||||
item.inbox_id,
|
||||
]"
|
||||
>
|
||||
<ConversationItem
|
||||
:source="item"
|
||||
:label="label"
|
||||
:team-id="teamId"
|
||||
:folders-id="foldersId"
|
||||
:conversation-type="conversationType"
|
||||
:show-assignee="showAssigneeInConversationCard"
|
||||
@select-conversation="selectConversation"
|
||||
@de-select-conversation="deSelectConversation"
|
||||
/>
|
||||
</DynamicScrollerItem>
|
||||
</template>
|
||||
<template #after>
|
||||
<div v-if="chatListLoading" class="flex justify-center my-4">
|
||||
<Spinner class="text-n-brand" />
|
||||
</div>
|
||||
<p
|
||||
v-else-if="showEndOfListMessage"
|
||||
class="p-4 text-center text-n-slate-11"
|
||||
>
|
||||
{{ $t('CHAT_LIST.EOF') }}
|
||||
</p>
|
||||
<IntersectionObserver
|
||||
v-else
|
||||
:options="intersectionObserverOptions"
|
||||
@observed="loadMoreConversations"
|
||||
/>
|
||||
</template>
|
||||
</DynamicScroller>
|
||||
<ConversationItem
|
||||
:source="item"
|
||||
:label="label"
|
||||
:team-id="teamId"
|
||||
:folders-id="foldersId"
|
||||
:conversation-type="conversationType"
|
||||
:show-assignee="showAssigneeInConversationCard"
|
||||
:data-index="index"
|
||||
@select-conversation="selectConversation"
|
||||
@de-select-conversation="deSelectConversation"
|
||||
/>
|
||||
</Virtualizer>
|
||||
<div v-if="chatListLoading" class="flex justify-center my-4">
|
||||
<Spinner class="text-n-brand" />
|
||||
</div>
|
||||
<p
|
||||
v-else-if="showEndOfListMessage"
|
||||
class="p-4 text-center text-n-slate-11"
|
||||
>
|
||||
{{ $t('CHAT_LIST.EOF') }}
|
||||
</p>
|
||||
<IntersectionObserver
|
||||
v-else
|
||||
:options="intersectionObserverOptions"
|
||||
@observed="loadMoreConversations"
|
||||
/>
|
||||
</div>
|
||||
<Dialog
|
||||
ref="deleteConversationDialogRef"
|
||||
|
||||
@@ -50,7 +50,6 @@ export default {
|
||||
|
||||
<template>
|
||||
<ConversationCard
|
||||
:key="source.id"
|
||||
:active-label="label"
|
||||
:team-id="teamId"
|
||||
:folders-id="foldersId"
|
||||
|
||||
@@ -24,6 +24,10 @@ export default {
|
||||
type: [String, Date, Number],
|
||||
default: '',
|
||||
},
|
||||
conversationId: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -74,6 +78,15 @@ export default {
|
||||
createdAtTimestamp() {
|
||||
this.createdAtTimeAgo = dynamicTime(this.createdAtTimestamp);
|
||||
},
|
||||
conversationId() {
|
||||
// Reset display values and timer when the row is recycled to a different conversation.
|
||||
this.lastActivityAtTimeAgo = dynamicTime(this.lastActivityTimestamp);
|
||||
this.createdAtTimeAgo = dynamicTime(this.createdAtTimestamp);
|
||||
if (this.isAutoRefreshEnabled) {
|
||||
clearTimeout(this.timer);
|
||||
this.createTimer();
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (this.isAutoRefreshEnabled) {
|
||||
@@ -111,7 +124,6 @@ export default {
|
||||
v-tooltip.top="{
|
||||
content: tooltipText,
|
||||
delay: { show: 1000, hide: 0 },
|
||||
hideOnClick: true,
|
||||
}"
|
||||
class="ml-auto leading-4 text-xxs text-n-slate-10 hover:text-n-slate-11"
|
||||
>
|
||||
|
||||
@@ -10,11 +10,23 @@ import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
|
||||
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
hasSelection: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isEditorMenuPopover: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
editorContent: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
conversationId: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['executeCopilotAction']);
|
||||
@@ -25,6 +37,13 @@ const { draftMessage } = useCaptain();
|
||||
|
||||
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
|
||||
|
||||
// When editorContent prop is passed, use it exclusively (even if empty)
|
||||
// This ensures each editor instance shows menu items based on its own content
|
||||
// Falls back to global draftMessage only when editorContent is not provided
|
||||
const effectiveContent = computed(() =>
|
||||
props.editorContent !== undefined ? props.editorContent : draftMessage.value
|
||||
);
|
||||
|
||||
// Selection-based menu items (when text is selected)
|
||||
const menuItems = computed(() => {
|
||||
const items = [];
|
||||
@@ -42,8 +61,9 @@ const menuItems = computed(() => {
|
||||
icon: 'i-fluent-pen-sparkle-24-regular',
|
||||
});
|
||||
} else if (
|
||||
props.conversationId &&
|
||||
replyMode.value === REPLY_EDITOR_MODES.REPLY &&
|
||||
draftMessage.value
|
||||
effectiveContent.value
|
||||
) {
|
||||
items.push({
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY'),
|
||||
@@ -52,7 +72,7 @@ const menuItems = computed(() => {
|
||||
});
|
||||
}
|
||||
|
||||
if (draftMessage.value) {
|
||||
if (effectiveContent.value) {
|
||||
items.push(
|
||||
{
|
||||
label: t(
|
||||
@@ -105,7 +125,7 @@ const menuItems = computed(() => {
|
||||
|
||||
const generalMenuItems = computed(() => {
|
||||
const items = [];
|
||||
if (replyMode.value === REPLY_EDITOR_MODES.REPLY) {
|
||||
if (props.conversationId && replyMode.value === REPLY_EDITOR_MODES.REPLY) {
|
||||
items.push({
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUGGESTION'),
|
||||
key: 'reply_suggestion',
|
||||
@@ -113,7 +133,10 @@ const generalMenuItems = computed(() => {
|
||||
});
|
||||
}
|
||||
|
||||
if (replyMode.value === REPLY_EDITOR_MODES.NOTE || true) {
|
||||
if (
|
||||
props.conversationId &&
|
||||
(replyMode.value === REPLY_EDITOR_MODES.NOTE || true)
|
||||
) {
|
||||
items.push({
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUMMARIZE'),
|
||||
key: 'summarize',
|
||||
@@ -176,8 +199,8 @@ const handleSubMenuItemClick = (parentItem, subItem) => {
|
||||
<DropdownBody
|
||||
ref="menuRef"
|
||||
class="min-w-56 [&>ul]:gap-3 z-50 [&>ul]:px-4 [&>ul]:py-3.5"
|
||||
:class="{ 'selection-menu': hasSelection }"
|
||||
:style="hasSelection ? selectionMenuStyle : {}"
|
||||
:class="{ 'selection-menu': hasSelection && isEditorMenuPopover }"
|
||||
:style="hasSelection && isEditorMenuPopover ? selectionMenuStyle : {}"
|
||||
>
|
||||
<div v-if="menuItems.length > 0" class="flex flex-col items-start gap-2.5">
|
||||
<div
|
||||
|
||||
@@ -202,6 +202,11 @@ const editorRoot = useTemplateRef('editorRoot');
|
||||
const imageUpload = useTemplateRef('imageUpload');
|
||||
const editor = useTemplateRef('editor');
|
||||
|
||||
const isEditorMenuPopover = computed(
|
||||
() =>
|
||||
editorRoot.value?.classList.contains('popover-prosemirror-menu') ?? false
|
||||
);
|
||||
|
||||
const handleCopilotAction = actionKey => {
|
||||
if (actionKey === 'improve_selection' && editorView?.state) {
|
||||
const { from, to } = editorView.state.selection;
|
||||
@@ -211,7 +216,7 @@ const handleCopilotAction = actionKey => {
|
||||
emit('executeCopilotAction', 'improve', selectedText);
|
||||
}
|
||||
} else {
|
||||
emit('executeCopilotAction', actionKey);
|
||||
emit('executeCopilotAction', actionKey, props.modelValue);
|
||||
}
|
||||
|
||||
showSelectionMenu.value = false;
|
||||
@@ -484,6 +489,7 @@ function setToolbarPosition() {
|
||||
function setMenubarPosition({ selection } = {}) {
|
||||
const wrapper = editorRoot.value;
|
||||
if (!selection || !wrapper) return;
|
||||
if (!isEditorMenuPopover.value) return;
|
||||
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const isRtl = getComputedStyle(wrapper).direction === 'rtl';
|
||||
@@ -866,8 +872,12 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
v-if="showSelectionMenu"
|
||||
v-on-click-outside="handleClickOutside"
|
||||
:has-selection="isTextSelected"
|
||||
:is-editor-menu-popover="isEditorMenuPopover"
|
||||
:editor-content="modelValue"
|
||||
:conversation-id="conversationId"
|
||||
:show-selection-menu="showSelectionMenu"
|
||||
:show-general-menu="false"
|
||||
class="copilot-editor-menu"
|
||||
@execute-copilot-action="handleCopilotAction"
|
||||
/>
|
||||
<input
|
||||
@@ -1026,6 +1036,17 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
@apply text-n-ruby-9 dark:text-n-ruby-9 font-normal text-sm pt-1 pb-0 px-0;
|
||||
}
|
||||
|
||||
// Default copilot menu position (non-popover editors like components-next/Editor)
|
||||
// When popover-prosemirror-menu is NOT on the wrapper, anchor below the menubar
|
||||
:not(.popover-prosemirror-menu) > .copilot-editor-menu {
|
||||
top: 1.5rem !important;
|
||||
|
||||
[dir='rtl'] & {
|
||||
left: auto !important;
|
||||
right: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Float editor menu
|
||||
.popover-prosemirror-menu {
|
||||
position: relative;
|
||||
|
||||
@@ -189,7 +189,7 @@ export default {
|
||||
},
|
||||
showAudioRecorderButton() {
|
||||
if (this.isEditorDisabled) return false;
|
||||
if (this.isALineChannel) {
|
||||
if (this.isALineChannel || this.isATiktokChannel) {
|
||||
return false;
|
||||
}
|
||||
// Disable audio recorder for safari browser as recording is not supported
|
||||
@@ -380,7 +380,11 @@ export default {
|
||||
@click="$emit('selectContentTemplate')"
|
||||
/>
|
||||
<VideoCallButton
|
||||
v-if="(isAWebWidgetInbox || isAPIInbox) && !isOnPrivateNote"
|
||||
v-if="
|
||||
(isAWebWidgetInbox || isAPIInbox) &&
|
||||
!isOnPrivateNote &&
|
||||
!isEditorDisabled
|
||||
"
|
||||
:conversation-id="conversationId"
|
||||
/>
|
||||
<transition name="modal-fade">
|
||||
|
||||
@@ -49,6 +49,10 @@ export default {
|
||||
type: Number,
|
||||
default: () => 0,
|
||||
},
|
||||
editorContent: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
emits: ['setReplyMode', 'togglePopout', 'executeCopilotAction'],
|
||||
setup(props, { emit }) {
|
||||
@@ -73,8 +77,8 @@ export default {
|
||||
const { captainTasksEnabled } = useCaptain();
|
||||
const showCopilotMenu = ref(false);
|
||||
|
||||
const handleCopilotAction = actionKey => {
|
||||
emit('executeCopilotAction', actionKey);
|
||||
const handleCopilotAction = (actionKey, data) => {
|
||||
emit('executeCopilotAction', actionKey, data || props.editorContent);
|
||||
showCopilotMenu.value = false;
|
||||
};
|
||||
|
||||
@@ -174,6 +178,8 @@ export default {
|
||||
v-if="showCopilotMenu"
|
||||
v-on-click-outside="handleClickOutside"
|
||||
:has-selection="false"
|
||||
:editor-content="editorContent"
|
||||
:conversation-id="conversationId"
|
||||
class="ltr:right-0 rtl:left-0 bottom-full mb-2"
|
||||
@execute-copilot-action="handleCopilotAction"
|
||||
/>
|
||||
|
||||
@@ -86,6 +86,10 @@ const chatSortOptions = computed(() => [
|
||||
label: t('CHAT_LIST.SORT_ORDER_ITEMS.priority_asc.TEXT'),
|
||||
value: 'priority_asc',
|
||||
},
|
||||
{
|
||||
label: t('CHAT_LIST.SORT_ORDER_ITEMS.priority_desc_created_at_asc.TEXT'),
|
||||
value: 'priority_desc_created_at_asc',
|
||||
},
|
||||
{
|
||||
label: t('CHAT_LIST.SORT_ORDER_ITEMS.waiting_since_asc.TEXT'),
|
||||
value: 'waiting_since_asc',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
||||
@@ -50,10 +50,21 @@ const store = useStore();
|
||||
|
||||
const hovered = ref(false);
|
||||
const showContextMenu = ref(false);
|
||||
const contextMenu = ref({
|
||||
x: null,
|
||||
y: null,
|
||||
});
|
||||
const contextMenu = ref({ x: null, y: null });
|
||||
|
||||
// Reset UI state when conversation changes at same index (no :key, instance reused on reorder)
|
||||
// This prevents context menu/hover state from leaking to a different conversation
|
||||
// Emit contextMenuToggle(false) to sync parent state if menu was open during recycling
|
||||
const resetState = () => {
|
||||
if (showContextMenu.value) {
|
||||
emit('contextMenuToggle', false);
|
||||
}
|
||||
hovered.value = false;
|
||||
showContextMenu.value = false;
|
||||
contextMenu.value = { x: null, y: null };
|
||||
};
|
||||
|
||||
watch(() => props.chat.id, resetState);
|
||||
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
@@ -352,6 +363,7 @@ const deleteConversation = () => {
|
||||
<TimeAgo
|
||||
:last-activity-timestamp="chat.timestamp"
|
||||
:created-at-timestamp="chat.created_at"
|
||||
:conversation-id="chat.id"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
|
||||
@@ -85,7 +85,12 @@ export default {
|
||||
useAlert(this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS'));
|
||||
this.onCancel();
|
||||
} catch (error) {
|
||||
useAlert(this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'));
|
||||
const status = error?.response?.status;
|
||||
if (status === 402) {
|
||||
useAlert(this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_PAYMENT_REQUIRED'));
|
||||
} else {
|
||||
useAlert(this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'));
|
||||
}
|
||||
} finally {
|
||||
this.isSubmitting = false;
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@ export default {
|
||||
v-tooltip="{
|
||||
content: tooltipText,
|
||||
delay: { show: 1500, hide: 0 },
|
||||
hideOnClick: true,
|
||||
}"
|
||||
class="shrink-0 rounded-sm inline-flex items-center justify-center w-3.5 h-3.5"
|
||||
:class="{
|
||||
|
||||
@@ -200,9 +200,13 @@ export default {
|
||||
},
|
||||
messagePlaceHolder() {
|
||||
if (this.isEditorDisabled) {
|
||||
return this.isAWhatsAppChannel
|
||||
? this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED_WHATSAPP')
|
||||
: this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED');
|
||||
if (this.isAWhatsAppChannel) {
|
||||
return this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED_WHATSAPP');
|
||||
}
|
||||
if (this.isAPIInbox) {
|
||||
return this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED_API');
|
||||
}
|
||||
return this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED');
|
||||
}
|
||||
return this.isPrivate
|
||||
? this.$t('CONVERSATION.FOOTER.PRIVATE_MSG_INPUT')
|
||||
@@ -279,7 +283,8 @@ export default {
|
||||
this.isASmsInbox ||
|
||||
this.isATelegramChannel ||
|
||||
this.isALineChannel ||
|
||||
this.isAnInstagramChannel
|
||||
this.isAnInstagramChannel ||
|
||||
this.isATiktokChannel
|
||||
);
|
||||
},
|
||||
replyButtonLabel() {
|
||||
@@ -426,7 +431,7 @@ export default {
|
||||
},
|
||||
isEditorDisabled() {
|
||||
return (
|
||||
this.isAWhatsAppChannel &&
|
||||
(this.isAWhatsAppChannel || this.isAPIInbox) &&
|
||||
!this.isOnPrivateNote &&
|
||||
!this.currentChat.can_reply
|
||||
);
|
||||
@@ -751,11 +756,13 @@ export default {
|
||||
this.isATwilioWhatsAppChannel ||
|
||||
this.isAWhatsAppCloudChannel ||
|
||||
this.is360DialogWhatsAppChannel;
|
||||
// When users send messages containing both text and attachments on Instagram, Instagram treats them as separate messages.
|
||||
// Although Chatwoot combines these into a single message, Instagram sends separate echo events for each component.
|
||||
// This can create duplicate messages in Chatwoot. To prevent this issue, we'll handle text and attachments as separate messages.
|
||||
// Instagram and TikTok do not support sending text and attachments in the same message.
|
||||
// For Instagram, combining them causes duplicate messages due to separate echo events per component.
|
||||
// For TikTok, the API rejects messages that mix text and media.
|
||||
// To handle both cases, text and attachments are always sent as separate messages.
|
||||
const isOnInstagram = this.isAnInstagramChannel;
|
||||
if ((isOnWhatsApp || isOnInstagram) && !this.isPrivate) {
|
||||
const isOnTiktok = this.isATiktokChannel;
|
||||
if ((isOnWhatsApp || isOnInstagram || isOnTiktok) && !this.isPrivate) {
|
||||
this.sendMessageAsMultipleMessages(
|
||||
this.message,
|
||||
copilotAcceptedMessage
|
||||
@@ -1069,7 +1076,8 @@ export default {
|
||||
const multipleMessagePayload = [];
|
||||
|
||||
if (this.attachedFiles && this.attachedFiles.length) {
|
||||
let caption = this.isAnInstagramChannel ? '' : message;
|
||||
let caption =
|
||||
this.isAnInstagramChannel || this.isATiktokChannel ? '' : message;
|
||||
this.attachedFiles.forEach(attachment => {
|
||||
const attachedFile = this.globalConfig.directUploadsEnabled
|
||||
? attachment.blobSignedId
|
||||
@@ -1091,11 +1099,13 @@ export default {
|
||||
|
||||
const hasNoAttachments =
|
||||
!this.attachedFiles || !this.attachedFiles.length;
|
||||
// For Instagram, we need a separate text message
|
||||
// For WhatsApp, we only need a text message if there are no attachments
|
||||
// For Instagram and TikTok, text must always be sent as a separate message (no captions on attachments).
|
||||
// For WhatsApp, we only need a text message if there are no attachments.
|
||||
if (
|
||||
(this.isAnInstagramChannel && this.message) ||
|
||||
(!this.isAnInstagramChannel && hasNoAttachments)
|
||||
((this.isAnInstagramChannel || this.isATiktokChannel) &&
|
||||
this.message) ||
|
||||
(!(this.isAnInstagramChannel || this.isATiktokChannel) &&
|
||||
hasNoAttachments)
|
||||
) {
|
||||
let messagePayload = {
|
||||
conversationId: this.currentChat.id,
|
||||
@@ -1235,6 +1245,7 @@ export default {
|
||||
:is-editor-disabled="isEditorDisabled"
|
||||
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
|
||||
:characters-remaining="charactersRemaining"
|
||||
:editor-content="message"
|
||||
:popout-reply-box="popOutReplyBox"
|
||||
@set-reply-mode="setReplyMode"
|
||||
@toggle-popout="togglePopout"
|
||||
|
||||
@@ -21,6 +21,7 @@ export default {
|
||||
PRIORITY_DESC: 'priority_desc',
|
||||
WAITING_SINCE_ASC: 'waiting_since_asc',
|
||||
WAITING_SINCE_DESC: 'waiting_since_desc',
|
||||
PRIORITY_DESC_CREATED_AT_ASC: 'priority_desc_created_at_asc',
|
||||
},
|
||||
ARTICLE_STATUS_TYPES: {
|
||||
DRAFT: 0,
|
||||
|
||||
@@ -21,10 +21,8 @@ export const FEATURE_FLAGS = {
|
||||
AUDIT_LOGS: 'audit_logs',
|
||||
INBOX_VIEW: 'inbox_view',
|
||||
SLA: 'sla',
|
||||
RESPONSE_BOT: 'response_bot',
|
||||
CHANNEL_EMAIL: 'channel_email',
|
||||
CHANNEL_FACEBOOK: 'channel_facebook',
|
||||
CHANNEL_TWITTER: 'channel_twitter',
|
||||
CHANNEL_WEBSITE: 'channel_website',
|
||||
CUSTOM_REPLY_DOMAIN: 'custom_reply_domain',
|
||||
CUSTOM_REPLY_EMAIL: 'custom_reply_email',
|
||||
@@ -36,7 +34,6 @@ export const FEATURE_FLAGS = {
|
||||
CAPTAIN: 'captain_integration',
|
||||
CUSTOM_ROLES: 'custom_roles',
|
||||
CHATWOOT_V4: 'chatwoot_v4',
|
||||
REPORT_V4: 'report_v4',
|
||||
CHANNEL_INSTAGRAM: 'channel_instagram',
|
||||
CHANNEL_TIKTOK: 'channel_tiktok',
|
||||
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
|
||||
|
||||
@@ -13,7 +13,6 @@ const FEATURE_HELP_URLS = {
|
||||
integrations: 'https://chwt.app/hc/integrations',
|
||||
labels: 'https://chwt.app/hc/labels',
|
||||
macros: 'https://chwt.app/hc/macros',
|
||||
message_reply_to: 'https://chwt.app/hc/reply-to',
|
||||
reports: 'https://chwt.app/hc/reports',
|
||||
sla: 'https://chwt.app/hc/sla',
|
||||
team_management: 'https://chwt.app/hc/teams',
|
||||
|
||||
@@ -76,6 +76,9 @@
|
||||
},
|
||||
"waiting_since_desc": {
|
||||
"TEXT": "Pending Response: Shortest first"
|
||||
},
|
||||
"priority_desc_created_at_asc": {
|
||||
"TEXT": "Priority: Highest first, Created: Oldest first"
|
||||
}
|
||||
},
|
||||
"ATTACHMENTS": {
|
||||
|
||||
@@ -613,7 +613,7 @@
|
||||
"NO_INBOX_ALERT": "There are no available inboxes to start a conversation with this contact.",
|
||||
"CONTACT_SELECTOR": {
|
||||
"LABEL": "To:",
|
||||
"TAG_INPUT_PLACEHOLDER": "Search for a contact with name, email or phone number",
|
||||
"TAG_INPUT_PLACEHOLDER": "Enter at least 2 characters to search by name, email, or phone number",
|
||||
"CONTACT_CREATING": "Creating contact..."
|
||||
},
|
||||
"INBOX_SELECTOR": {
|
||||
@@ -624,9 +624,9 @@
|
||||
"SUBJECT_LABEL": "Subject :",
|
||||
"SUBJECT_PLACEHOLDER": "Enter your email subject here",
|
||||
"CC_LABEL": "Cc:",
|
||||
"CC_PLACEHOLDER": "Search for a contact with their email address",
|
||||
"CC_PLACEHOLDER": "Enter at least 2 characters to search by email",
|
||||
"BCC_LABEL": "Bcc:",
|
||||
"BCC_PLACEHOLDER": "Search for a contact with their email address",
|
||||
"BCC_PLACEHOLDER": "Enter at least 2 characters to search by email",
|
||||
"BCC_BUTTON": "Bcc"
|
||||
},
|
||||
"MESSAGE_EDITOR": {
|
||||
|
||||
@@ -192,6 +192,7 @@
|
||||
"PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents",
|
||||
"MESSAGING_RESTRICTED": "You cannot reply to this conversation",
|
||||
"MESSAGING_RESTRICTED_WHATSAPP": "You can only reply using a template message due to 24-hour message window restriction",
|
||||
"MESSAGING_RESTRICTED_API": "You can only reply using a template message due to message window restriction",
|
||||
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "Message signature is not configured, please configure it in profile settings.",
|
||||
"COPILOT_MSG_INPUT": "Give copilot additional prompts, or ask anything else... Press enter to send follow-up",
|
||||
"CLICK_HERE": "Click here to update",
|
||||
@@ -305,6 +306,7 @@
|
||||
"CANCEL": "Cancel",
|
||||
"SEND_EMAIL_SUCCESS": "The chat transcript was sent successfully",
|
||||
"SEND_EMAIL_ERROR": "There was an error, please try again",
|
||||
"SEND_EMAIL_PAYMENT_REQUIRED": "Email transcript is not available on your current plan. Please upgrade to use this feature.",
|
||||
"FORM": {
|
||||
"SEND_TO_CONTACT": "Send the transcript to the customer",
|
||||
"SEND_TO_AGENT": "Send the transcript to the assigned agent",
|
||||
|
||||
@@ -374,6 +374,16 @@
|
||||
"ERROR_MESSAGE": "Error while deleting article"
|
||||
}
|
||||
},
|
||||
"REORDER_ARTICLE": {
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "Unable to reorder articles. Please try again."
|
||||
}
|
||||
},
|
||||
"REORDER_CATEGORY": {
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "Unable to reorder categories. Please try again."
|
||||
}
|
||||
},
|
||||
"CREATE_ARTICLE": {
|
||||
"ERROR_MESSAGE": "Please add the article heading and content then only you can update the settings"
|
||||
},
|
||||
@@ -839,7 +849,7 @@
|
||||
"STATUS": {
|
||||
"UPLOADED": "Ready",
|
||||
"PROCESSING": "Processing",
|
||||
"PROCESSED": "Completed",
|
||||
"PROCESSED": "Completed",
|
||||
"FAILED": "Failed"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -592,8 +592,10 @@
|
||||
"DISABLED": "Disabled"
|
||||
},
|
||||
"LOCK_TO_SINGLE_CONVERSATION": {
|
||||
"ENABLED": "Enabled",
|
||||
"DISABLED": "Disabled"
|
||||
"ENABLED": "Reopen same conversation",
|
||||
"DISABLED": "Create new conversations",
|
||||
"ENABLED_DESCRIPTION": "When a contact messages again, the previous conversation will be reopened.",
|
||||
"DISABLED_DESCRIPTION": "A new conversation will be created each time after the previous one is resolved."
|
||||
},
|
||||
"ENABLE_HMAC": {
|
||||
"LABEL": "Enable"
|
||||
@@ -713,8 +715,8 @@
|
||||
"SENDER_NAME_SECTION_TEXT": "Enable/Disable showing Agent's name in email, if disabled it will show business name",
|
||||
"ENABLE_CONTINUITY_VIA_EMAIL": "Enable conversation continuity via email",
|
||||
"ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT": "Conversations will continue over email if the contact email address is available.",
|
||||
"LOCK_TO_SINGLE_CONVERSATION": "Lock to single conversation",
|
||||
"LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT": "Enable or disable multiple conversations for the same contact in this inbox",
|
||||
"LOCK_TO_SINGLE_CONVERSATION": "Conversation Routing",
|
||||
"LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT": "Configure conversation creation for existing contacts",
|
||||
"INBOX_UPDATE_TITLE": "Inbox Settings",
|
||||
"INBOX_UPDATE_SUB_TEXT": "Update your inbox settings",
|
||||
"AUTO_ASSIGNMENT_SUB_TEXT": "Enable or disable the automatic assignment of new conversations to the agents added to this inbox.",
|
||||
@@ -890,6 +892,20 @@
|
||||
"CONFIRM": "Create new template",
|
||||
"CANCEL": "Go back"
|
||||
},
|
||||
"UTILITY_ANALYZER": {
|
||||
"ACTION": "Check utility fit",
|
||||
"HELPER_NOTE": "Check this message before submission to improve Utility fit. The system creates a dedicated CSAT template with buttons for reporting and submits it as Utility; Meta may still reclassify it as Marketing based on content.",
|
||||
"RESULT_LABEL": "Meta category prediction",
|
||||
"GUIDANCE_NOTE": "This is a guidance check, not a guarantee of Meta approval.",
|
||||
"SUGGESTION_LABEL": "Suggested utility-safe rewrite",
|
||||
"APPLY": "Use this rewrite",
|
||||
"ERROR_MESSAGE": "Couldn't analyze the message. Please try again.",
|
||||
"CLASSIFICATION": {
|
||||
"LIKELY_UTILITY": "Likely Utility",
|
||||
"LIKELY_MARKETING": "Likely Marketing",
|
||||
"UNCLEAR": "Needs clarification"
|
||||
}
|
||||
},
|
||||
"SURVEY_RULE": {
|
||||
"LABEL": "Survey rule",
|
||||
"DESCRIPTION_PREFIX": "Send the survey if the conversation",
|
||||
@@ -901,7 +917,7 @@
|
||||
"SELECT_PLACEHOLDER": "select labels"
|
||||
},
|
||||
"NOTE": "Note: CSAT surveys are sent only once per conversation",
|
||||
"WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.",
|
||||
"WHATSAPP_NOTE": "Note: When you save, the system creates a dedicated CSAT template in WhatsApp (used to capture rating and feedback in reports) and submits it as Utility for approval. Meta may still classify it as Marketing based on content. After approval, surveys are sent only once per conversation as per the survey rule.",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "CSAT settings updated successfully",
|
||||
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
|
||||
|
||||
@@ -31,6 +31,14 @@
|
||||
"WEBHOOK": {
|
||||
"SUBSCRIBED_EVENTS": "Subscribed Events",
|
||||
"LEARN_MORE": "Learn more about webhooks",
|
||||
"SECRET": {
|
||||
"LABEL": "Secret",
|
||||
"COPY": "Copy secret to clipboard",
|
||||
"COPY_SUCCESS": "Secret copied to clipboard",
|
||||
"TOGGLE": "Toggle secret visibility",
|
||||
"CREATED_DESC": "Your webhook has been created. Use the secret below to verify webhook signatures. Please copy it now — you can also find it later in the webhook edit form.",
|
||||
"DONE": "Done"
|
||||
},
|
||||
"COUNT": "{n} webhook | {n} webhooks",
|
||||
"SEARCH_PLACEHOLDER": "Search webhooks...",
|
||||
"NO_RESULTS": "No webhooks found matching your search",
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useToggle } from '@vueuse/core';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { searchContacts } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
|
||||
import { createContactSearcher } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { fetchContactDetails } from '../helpers/searchHelper';
|
||||
|
||||
@@ -18,6 +18,8 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['change']);
|
||||
|
||||
const searchContacts = createContactSearcher();
|
||||
|
||||
const FROM_TYPE = {
|
||||
CONTACT: 'contact',
|
||||
AGENT: 'agent',
|
||||
@@ -119,10 +121,10 @@ const debouncedSearch = debounce(async query => {
|
||||
}
|
||||
|
||||
try {
|
||||
const contacts = await searchContacts({
|
||||
keys: ['name', 'email', 'phone_number'],
|
||||
query,
|
||||
});
|
||||
const contacts = await searchContacts(query, { skipMinLength: true });
|
||||
|
||||
// null means the request was aborted (a newer search is in-flight),
|
||||
if (contacts === null) return;
|
||||
|
||||
// Add selected contact to top if not already in results
|
||||
const allContacts = selectedContact.value
|
||||
@@ -133,9 +135,8 @@ const debouncedSearch = debounce(async query => {
|
||||
: contacts;
|
||||
|
||||
searchedContacts.value = allContacts;
|
||||
isSearching.value = false;
|
||||
} catch {
|
||||
// Ignore error
|
||||
} finally {
|
||||
isSearching.value = false;
|
||||
}
|
||||
}, 300);
|
||||
|
||||
+9
-1
@@ -11,7 +11,10 @@ const store = useStore();
|
||||
|
||||
const pageNumber = ref(1);
|
||||
|
||||
const articles = useMapGetter('articles/allArticles');
|
||||
const allArticles = useMapGetter('articles/allArticles');
|
||||
const articlesSortedByPosition = useMapGetter(
|
||||
'articles/allArticlesSortedByPosition'
|
||||
);
|
||||
const categories = useMapGetter('categories/allCategories');
|
||||
const meta = useMapGetter('articles/getMeta');
|
||||
const portalMeta = useMapGetter('portals/getMeta');
|
||||
@@ -58,6 +61,11 @@ const isCategoryArticles = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
// Use position-sorted articles for category views and categories filter view (where drag reorder is enabled)
|
||||
const articles = computed(() =>
|
||||
isCategoryArticles.value ? articlesSortedByPosition.value : allArticles.value
|
||||
);
|
||||
|
||||
const fetchArticles = ({ pageNumber: pageNumberParam } = {}) => {
|
||||
store.dispatch('articles/index', {
|
||||
pageNumber: pageNumberParam || pageNumber.value,
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ import CategoriesPage from 'dashboard/components-next/HelpCenter/Pages/CategoryP
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
|
||||
const categories = useMapGetter('categories/allCategories');
|
||||
const categories = useMapGetter('categories/allCategoriesSortedByPosition');
|
||||
|
||||
const selectedPortalSlug = computed(() => route.params.portalSlug);
|
||||
const getPortalBySlug = useMapGetter('portals/portalBySlug');
|
||||
|
||||
@@ -160,6 +160,7 @@ export default {
|
||||
@submit.prevent="updateAccount"
|
||||
>
|
||||
<WithLabel
|
||||
name="account-name"
|
||||
:has-error="v$.name.$error"
|
||||
:label="$t('GENERAL_SETTINGS.FORM.NAME.LABEL')"
|
||||
:error-message="$t('GENERAL_SETTINGS.FORM.NAME.ERROR')"
|
||||
@@ -173,6 +174,7 @@ export default {
|
||||
/>
|
||||
</WithLabel>
|
||||
<WithLabel
|
||||
name="site-language"
|
||||
:has-error="v$.locale.$error"
|
||||
:label="$t('GENERAL_SETTINGS.FORM.LANGUAGE.LABEL')"
|
||||
:error-message="$t('GENERAL_SETTINGS.FORM.LANGUAGE.ERROR')"
|
||||
@@ -189,6 +191,7 @@ export default {
|
||||
</WithLabel>
|
||||
<WithLabel
|
||||
v-if="featureCustomReplyDomainEnabled"
|
||||
name="custom-domain"
|
||||
:label="$t('GENERAL_SETTINGS.FORM.DOMAIN.LABEL')"
|
||||
>
|
||||
<NextInput
|
||||
@@ -211,6 +214,7 @@ export default {
|
||||
</WithLabel>
|
||||
<WithLabel
|
||||
v-if="featureCustomReplyEmailEnabled"
|
||||
name="support-email"
|
||||
:label="$t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.LABEL')"
|
||||
>
|
||||
<NextInput
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import BaseInfo from 'dashboard/components-next/AssignmentPolicy/components/BaseInfo.vue';
|
||||
import RadioCard from 'dashboard/components-next/AssignmentPolicy/components/RadioCard.vue';
|
||||
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
|
||||
import FairDistribution from 'dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue';
|
||||
import DataTable from 'dashboard/components-next/AssignmentPolicy/components/DataTable.vue';
|
||||
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
|
||||
|
||||
@@ -45,7 +45,10 @@ const records = computed(() =>
|
||||
const filteredRecords = computed(() => {
|
||||
const query = searchQuery.value.trim();
|
||||
if (!query) return records.value;
|
||||
return picoSearch(records.value, query, ['short_code', 'content']);
|
||||
return picoSearch(records.value, query, [
|
||||
{ name: 'short_code', weight: 4 },
|
||||
'content',
|
||||
]);
|
||||
});
|
||||
const uiFlags = computed(() => getters.getUIFlags.value);
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import BotConfiguration from './components/BotConfiguration.vue';
|
||||
import AccountHealth from './components/AccountHealth.vue';
|
||||
import { FEATURE_FLAGS } from '../../../../featureFlags';
|
||||
import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue';
|
||||
import LockToSingleConversationPreview from './components/LockToSingleConversationPreview.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import SpinnerLoader from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
@@ -53,6 +54,7 @@ export default {
|
||||
SettingsAccordion,
|
||||
WeeklyAvailability,
|
||||
SenderNameExamplePreview,
|
||||
LockToSingleConversationPreview,
|
||||
MicrosoftReauthorize,
|
||||
GoogleReauthorize,
|
||||
NextButton,
|
||||
@@ -246,6 +248,9 @@ export default {
|
||||
this.isAWhatsAppChannel ||
|
||||
this.isAFacebookInbox ||
|
||||
this.isAPIInbox ||
|
||||
this.isAnInstagramChannel ||
|
||||
this.isALineChannel ||
|
||||
this.isATiktokChannel ||
|
||||
this.isATelegramChannel
|
||||
);
|
||||
},
|
||||
@@ -536,6 +541,9 @@ export default {
|
||||
hideBusinessNameInput() {
|
||||
this.showBusinessNameInput = false;
|
||||
},
|
||||
toggleLockToSingleConversation(value) {
|
||||
this.locktoSingleConversation = value;
|
||||
},
|
||||
},
|
||||
validations: {
|
||||
webhookUrl: {
|
||||
@@ -731,6 +739,21 @@ export default {
|
||||
/>
|
||||
</SettingsFieldSection>
|
||||
|
||||
<SettingsFieldSection
|
||||
v-if="canLocktoSingleConversation"
|
||||
:label="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.LOCK_TO_SINGLE_CONVERSATION')
|
||||
"
|
||||
class="[&>div>div]:justify-end [&>div>div]:flex lg:[&>div:first-child]:h-12 [&>div:first-child]:h-16"
|
||||
>
|
||||
<template #extra>
|
||||
<LockToSingleConversationPreview
|
||||
:lock-to-single-conversation="locktoSingleConversation"
|
||||
@update="toggleLockToSingleConversation"
|
||||
/>
|
||||
</template>
|
||||
</SettingsFieldSection>
|
||||
|
||||
<SettingsFieldSection
|
||||
v-if="isAWebWidgetInbox || isAnEmailChannel"
|
||||
:label="$t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.TITLE')"
|
||||
@@ -1074,19 +1097,6 @@ export default {
|
||||
)
|
||||
"
|
||||
/>
|
||||
|
||||
<SettingsToggleSection
|
||||
v-if="canLocktoSingleConversation"
|
||||
v-model="locktoSingleConversation"
|
||||
:header="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.LOCK_TO_SINGLE_CONVERSATION')
|
||||
"
|
||||
:description="
|
||||
$t(
|
||||
'INBOX_MGMT.SETTINGS_POPUP.LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</SettingsAccordion>
|
||||
|
||||
<div class="w-full flex justify-end items-center py-4 mt-2">
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<script setup>
|
||||
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
|
||||
|
||||
defineProps({
|
||||
lockToSingleConversation: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(['update']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col sm:flex-row md:flex-col xl:flex-row items-start gap-4 mt-3 min-w-0"
|
||||
>
|
||||
<RadioCard
|
||||
id="disabled"
|
||||
:label="$t('INBOX_MGMT.EDIT.LOCK_TO_SINGLE_CONVERSATION.DISABLED')"
|
||||
:description="
|
||||
$t('INBOX_MGMT.EDIT.LOCK_TO_SINGLE_CONVERSATION.DISABLED_DESCRIPTION')
|
||||
"
|
||||
:is-active="!lockToSingleConversation"
|
||||
class="flex-1"
|
||||
@select="$emit('update', false)"
|
||||
/>
|
||||
|
||||
<RadioCard
|
||||
id="enabled"
|
||||
:label="$t('INBOX_MGMT.EDIT.LOCK_TO_SINGLE_CONVERSATION.ENABLED')"
|
||||
:description="
|
||||
$t('INBOX_MGMT.EDIT.LOCK_TO_SINGLE_CONVERSATION.ENABLED_DESCRIPTION')
|
||||
"
|
||||
:is-active="lockToSingleConversation"
|
||||
class="flex-1"
|
||||
@select="$emit('update', true)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import RadioCard from 'dashboard/components-next/AssignmentPolicy/components/RadioCard.vue';
|
||||
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
|
||||
|
||||
const props = defineProps({
|
||||
senderNameType: {
|
||||
|
||||
+151
-1
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useInbox } from 'dashboard/composables/useInbox';
|
||||
import { useCaptain } from 'dashboard/composables/useCaptain';
|
||||
import { CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
@@ -26,6 +27,7 @@ const props = defineProps({
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const labels = useMapGetter('labels/getLabels');
|
||||
const { captainEnabled } = useCaptain();
|
||||
|
||||
const { isAWhatsAppChannel, isATwilioWhatsAppChannel } = useInbox(
|
||||
props.inbox?.id
|
||||
@@ -37,6 +39,8 @@ const isAnyWhatsAppChannel = computed(
|
||||
);
|
||||
|
||||
const isUpdating = ref(false);
|
||||
const utilityAnalysisLoading = ref(false);
|
||||
const utilityAnalysisResult = ref(null);
|
||||
const selectedLabelValues = ref([]);
|
||||
const currentLabel = ref('');
|
||||
|
||||
@@ -46,7 +50,7 @@ const state = reactive({
|
||||
message: '',
|
||||
templateButtonText: 'Please rate us',
|
||||
surveyRuleOperator: 'contains',
|
||||
templateLanguage: '',
|
||||
templateLanguage: 'en',
|
||||
});
|
||||
|
||||
const templateStatus = ref(null);
|
||||
@@ -89,6 +93,9 @@ const messagePreviewData = computed(() => ({
|
||||
const shouldShowTemplateStatus = computed(
|
||||
() => templateStatus.value && !templateLoading.value
|
||||
);
|
||||
const showUtilityAnalyzer = computed(
|
||||
() => isAnyWhatsAppChannel.value && captainEnabled.value
|
||||
);
|
||||
|
||||
const templateApprovalStatus = computed(() => {
|
||||
const statusMap = {
|
||||
@@ -218,6 +225,85 @@ const updateDisplayType = type => {
|
||||
state.displayType = type;
|
||||
};
|
||||
|
||||
const resetUtilityAnalysis = () => {
|
||||
utilityAnalysisResult.value = null;
|
||||
};
|
||||
|
||||
const analyzeTemplateUtility = async () => {
|
||||
if (!showUtilityAnalyzer.value || !state.message?.trim()) return;
|
||||
|
||||
utilityAnalysisLoading.value = true;
|
||||
resetUtilityAnalysis();
|
||||
|
||||
try {
|
||||
const response = await store.dispatch(
|
||||
'inboxes/analyzeCSATTemplateUtility',
|
||||
{
|
||||
inboxId: props.inbox.id,
|
||||
template: {
|
||||
message: state.message,
|
||||
button_text: state.templateButtonText,
|
||||
language: state.templateLanguage,
|
||||
},
|
||||
}
|
||||
);
|
||||
utilityAnalysisResult.value = response;
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error.response?.data?.error ||
|
||||
t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.ERROR_MESSAGE');
|
||||
useAlert(errorMessage);
|
||||
} finally {
|
||||
utilityAnalysisLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const applyUtilitySuggestion = () => {
|
||||
const suggestion = utilityAnalysisResult.value?.optimized_message;
|
||||
if (!suggestion) return;
|
||||
|
||||
state.message = suggestion;
|
||||
resetUtilityAnalysis();
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [state.message, state.templateButtonText, state.templateLanguage],
|
||||
(newValues, oldValues) => {
|
||||
if (!oldValues || !utilityAnalysisResult.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const changed = newValues.some(
|
||||
(value, index) => value !== oldValues[index]
|
||||
);
|
||||
if (changed) {
|
||||
resetUtilityAnalysis();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const getUtilityClassificationLabel = classification => {
|
||||
if (classification === 'LIKELY_UTILITY') {
|
||||
return t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.CLASSIFICATION.LIKELY_UTILITY');
|
||||
}
|
||||
if (classification === 'LIKELY_MARKETING') {
|
||||
return t(
|
||||
'INBOX_MGMT.CSAT.UTILITY_ANALYZER.CLASSIFICATION.LIKELY_MARKETING'
|
||||
);
|
||||
}
|
||||
return t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.CLASSIFICATION.UNCLEAR');
|
||||
};
|
||||
|
||||
const getUtilityClassificationClass = classification => {
|
||||
if (classification === 'LIKELY_UTILITY') {
|
||||
return 'bg-n-teal-3 text-n-teal-11';
|
||||
}
|
||||
if (classification === 'LIKELY_MARKETING') {
|
||||
return 'bg-n-ruby-3 text-n-ruby-11';
|
||||
}
|
||||
return 'bg-n-amber-3 text-n-amber-11';
|
||||
};
|
||||
|
||||
const updateSurveyRuleOperator = operator => {
|
||||
state.surveyRuleOperator = operator;
|
||||
};
|
||||
@@ -450,6 +536,70 @@ const handleConfirmTemplateUpdate = async () => {
|
||||
class="w-full"
|
||||
/>
|
||||
</WithLabel>
|
||||
<div v-if="showUtilityAnalyzer" class="flex flex-col gap-2">
|
||||
<NextButton
|
||||
sm
|
||||
slate
|
||||
:label="$t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.ACTION')"
|
||||
:is-loading="utilityAnalysisLoading"
|
||||
:disabled="!state.message?.trim()"
|
||||
@click="analyzeTemplateUtility"
|
||||
/>
|
||||
<p class="text-xs text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.HELPER_NOTE') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="utilityAnalysisResult"
|
||||
class="flex flex-col gap-3 p-3 rounded-xl outline outline-1 outline-n-weak bg-n-alpha-1"
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ $t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.RESULT_LABEL') }}
|
||||
</span>
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs font-medium rounded-full"
|
||||
:class="
|
||||
getUtilityClassificationClass(
|
||||
utilityAnalysisResult.classification
|
||||
)
|
||||
"
|
||||
>
|
||||
{{
|
||||
getUtilityClassificationLabel(
|
||||
utilityAnalysisResult.classification
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.GUIDANCE_NOTE') }}
|
||||
</p>
|
||||
<div
|
||||
v-if="
|
||||
utilityAnalysisResult.optimized_message &&
|
||||
utilityAnalysisResult.classification !== 'LIKELY_UTILITY'
|
||||
"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<p class="text-xs font-medium text-n-slate-12">
|
||||
{{
|
||||
$t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.SUGGESTION_LABEL')
|
||||
}}
|
||||
</p>
|
||||
<p class="text-sm text-n-slate-12">
|
||||
{{ utilityAnalysisResult.optimized_message }}
|
||||
</p>
|
||||
<NextButton
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
:label="$t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.APPLY')"
|
||||
@click="applyUtilitySuggestion"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
v-model="state.templateButtonText"
|
||||
:label="$t('INBOX_MGMT.CSAT.BUTTON_TEXT.LABEL')"
|
||||
|
||||
@@ -58,6 +58,7 @@ export default {
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('integrations/get', 'webhook');
|
||||
this.$store.dispatch('webhooks/get');
|
||||
},
|
||||
methods: {
|
||||
|
||||
+86
-48
@@ -1,60 +1,98 @@
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import WebhookForm from './WebhookForm.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: { WebhookForm },
|
||||
props: {
|
||||
onClose: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { replaceInstallationName } = useBranding();
|
||||
return {
|
||||
replaceInstallationName,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
uiFlags: 'webhooks/getUIFlags',
|
||||
}),
|
||||
},
|
||||
methods: {
|
||||
async onSubmit(webhook) {
|
||||
try {
|
||||
await this.$store.dispatch('webhooks/create', { webhook });
|
||||
useAlert(
|
||||
this.$t('INTEGRATION_SETTINGS.WEBHOOK.ADD.API.SUCCESS_MESSAGE')
|
||||
);
|
||||
this.onClose();
|
||||
} catch (error) {
|
||||
const message =
|
||||
error.response.data.message ||
|
||||
this.$t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.API.ERROR_MESSAGE');
|
||||
useAlert(message);
|
||||
}
|
||||
},
|
||||
const props = defineProps({
|
||||
onClose: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const { replaceInstallationName } = useBranding();
|
||||
|
||||
const createdWebhook = ref(null);
|
||||
|
||||
const uiFlags = computed(() => store.getters['webhooks/getUIFlags']);
|
||||
|
||||
const onSubmit = async webhook => {
|
||||
try {
|
||||
const result = await store.dispatch('webhooks/create', { webhook });
|
||||
createdWebhook.value = result;
|
||||
} catch (error) {
|
||||
const message =
|
||||
error.response.data.message ||
|
||||
t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.API.ERROR_MESSAGE');
|
||||
useAlert(message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopySecret = async () => {
|
||||
await copyTextToClipboard(createdWebhook.value.secret);
|
||||
useAlert(t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.COPY_SUCCESS'));
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-auto overflow-auto flex flex-col">
|
||||
<woot-modal-header
|
||||
:header-title="$t('INTEGRATION_SETTINGS.WEBHOOK.ADD.TITLE')"
|
||||
:header-content="
|
||||
replaceInstallationName($t('INTEGRATION_SETTINGS.WEBHOOK.FORM.DESC'))
|
||||
"
|
||||
/>
|
||||
<WebhookForm
|
||||
:is-submitting="uiFlags.creatingItem"
|
||||
:submit-label="$t('INTEGRATION_SETTINGS.WEBHOOK.FORM.ADD_SUBMIT')"
|
||||
@submit="onSubmit"
|
||||
@cancel="onClose"
|
||||
/>
|
||||
<template v-if="createdWebhook">
|
||||
<woot-modal-header
|
||||
:header-title="
|
||||
t('INTEGRATION_SETTINGS.WEBHOOK.ADD.API.SUCCESS_MESSAGE')
|
||||
"
|
||||
/>
|
||||
<div class="px-8 pb-6">
|
||||
<p class="text-sm text-n-slate-11 mb-4">
|
||||
{{ t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.CREATED_DESC') }}
|
||||
</p>
|
||||
<label>
|
||||
{{ t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.LABEL') }}
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
:value="createdWebhook.secret"
|
||||
type="text"
|
||||
readonly
|
||||
class="!mb-0 font-mono"
|
||||
/>
|
||||
<NextButton
|
||||
v-tooltip.top="t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.COPY')"
|
||||
icon="i-lucide-copy"
|
||||
slate
|
||||
faded
|
||||
@click="handleCopySecret"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<div class="flex justify-end mt-4">
|
||||
<NextButton
|
||||
blue
|
||||
:label="t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.DONE')"
|
||||
@click="props.onClose()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<woot-modal-header
|
||||
:header-title="t('INTEGRATION_SETTINGS.WEBHOOK.ADD.TITLE')"
|
||||
:header-content="
|
||||
replaceInstallationName(t('INTEGRATION_SETTINGS.WEBHOOK.FORM.DESC'))
|
||||
"
|
||||
/>
|
||||
<WebhookForm
|
||||
:is-submitting="uiFlags.creatingItem"
|
||||
:submit-label="t('INTEGRATION_SETTINGS.WEBHOOK.FORM.ADD_SUBMIT')"
|
||||
@submit="onSubmit"
|
||||
@cancel="props.onClose()"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+39
@@ -3,6 +3,8 @@ import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, url, minLength } from '@vuelidate/validators';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { getI18nKey } from 'dashboard/routes/dashboard/settings/helper/settingsHelper';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const { EXAMPLE_WEBHOOK_URL } = wootConstants;
|
||||
@@ -57,10 +59,14 @@ export default {
|
||||
url: this.value.url || '',
|
||||
name: this.value.name || '',
|
||||
subscriptions: this.value.subscriptions || [],
|
||||
secretVisible: false,
|
||||
supportedWebhookEvents: SUPPORTED_WEBHOOK_EVENTS,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
hasSecret() {
|
||||
return !!this.value.secret;
|
||||
},
|
||||
webhookURLInputPlaceholder() {
|
||||
return this.$t(
|
||||
'INTEGRATION_SETTINGS.WEBHOOK.FORM.END_POINT.PLACEHOLDER',
|
||||
@@ -81,6 +87,10 @@ export default {
|
||||
subscriptions: this.subscriptions,
|
||||
});
|
||||
},
|
||||
async copySecret() {
|
||||
await copyTextToClipboard(this.value.secret);
|
||||
useAlert(this.$t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.COPY_SUCCESS'));
|
||||
},
|
||||
getI18nKey,
|
||||
},
|
||||
};
|
||||
@@ -111,6 +121,35 @@ export default {
|
||||
:placeholder="webhookNameInputPlaceholder"
|
||||
/>
|
||||
</label>
|
||||
<label v-if="hasSecret" class="mb-4">
|
||||
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.LABEL') }}
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
:value="
|
||||
secretVisible ? value.secret : '••••••••••••••••••••••••••••••••'
|
||||
"
|
||||
type="text"
|
||||
readonly
|
||||
class="!mb-0 font-mono"
|
||||
/>
|
||||
<NextButton
|
||||
v-tooltip.top="$t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.TOGGLE')"
|
||||
type="button"
|
||||
:icon="secretVisible ? 'i-lucide-eye-off' : 'i-lucide-eye'"
|
||||
slate
|
||||
faded
|
||||
@click="secretVisible = !secretVisible"
|
||||
/>
|
||||
<NextButton
|
||||
v-tooltip.top="$t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.COPY')"
|
||||
type="button"
|
||||
icon="i-lucide-copy"
|
||||
slate
|
||||
faded
|
||||
@click="copySecret"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<label :class="{ error: v$.url.$error }" class="mb-2">
|
||||
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.FORM.SUBSCRIPTIONS.LABEL') }}
|
||||
</label>
|
||||
|
||||
@@ -32,7 +32,10 @@ const records = computed(() => getters['labels/getLabels'].value);
|
||||
const filteredRecords = computed(() => {
|
||||
const query = searchQuery.value.trim();
|
||||
if (!query) return records.value;
|
||||
return picoSearch(records.value, query, ['title', 'description']);
|
||||
return picoSearch(records.value, query, [
|
||||
{ name: 'title', weight: 4 },
|
||||
'description',
|
||||
]);
|
||||
});
|
||||
const uiFlags = computed(() => getters['labels/getUIFlags'].value);
|
||||
|
||||
|
||||
@@ -457,11 +457,7 @@ const actions = {
|
||||
},
|
||||
|
||||
sendEmailTranscript: async (_, { conversationId, email }) => {
|
||||
try {
|
||||
await ConversationApi.sendEmailTranscript({ conversationId, email });
|
||||
} catch (error) {
|
||||
throw new Error(error);
|
||||
}
|
||||
await ConversationApi.sendEmailTranscript({ conversationId, email });
|
||||
},
|
||||
|
||||
updateCustomAttributes: async (
|
||||
|
||||
@@ -116,6 +116,7 @@ const SORT_OPTIONS = {
|
||||
priority_desc: ['sortOnPriority', 'desc'],
|
||||
waiting_since_asc: ['sortOnWaitingSince', 'asc'],
|
||||
waiting_since_desc: ['sortOnWaitingSince', 'desc'],
|
||||
priority_desc_created_at_asc: ['sortOnPriorityCreatedAt', 'desc'],
|
||||
};
|
||||
const sortAscending = (valueA, valueB) => valueA - valueB;
|
||||
const sortDescending = (valueA, valueB) => valueB - valueA;
|
||||
@@ -139,6 +140,14 @@ const sortConfig = {
|
||||
return getSortOrderFunction(sortDirection)(p1, p2);
|
||||
},
|
||||
|
||||
sortOnPriorityCreatedAt: (a, b) => {
|
||||
const DEFAULT_FOR_NULL = 0;
|
||||
const p1 = CONVERSATION_PRIORITY_ORDER[a.priority] || DEFAULT_FOR_NULL;
|
||||
const p2 = CONVERSATION_PRIORITY_ORDER[b.priority] || DEFAULT_FOR_NULL;
|
||||
if (p1 !== p2) return p2 - p1;
|
||||
return a.created_at - b.created_at;
|
||||
},
|
||||
|
||||
sortOnWaitingSince: (a, b, sortDirection) => {
|
||||
const sortFunc = getSortOrderFunction(sortDirection);
|
||||
if (!a.waiting_since || !b.waiting_since) {
|
||||
|
||||
@@ -167,7 +167,17 @@ export const actions = {
|
||||
return fileUrl;
|
||||
},
|
||||
|
||||
reorder: async (_, { portalSlug, categorySlug, reorderedGroup }) => {
|
||||
reorder: async (
|
||||
{ commit, state },
|
||||
{ portalSlug, categorySlug, reorderedGroup }
|
||||
) => {
|
||||
// Save old positions so we can rollback on failure
|
||||
const oldPositions = Object.keys(reorderedGroup).reduce((map, id) => {
|
||||
map[id] = state.articles.byId[id]?.position;
|
||||
return map;
|
||||
}, {});
|
||||
// Update positions in the store immediately so subsequent mutations preserve correct positions
|
||||
commit(types.SET_ARTICLE_POSITIONS, reorderedGroup);
|
||||
try {
|
||||
await articlesAPI.reorderArticles({
|
||||
portalSlug,
|
||||
@@ -175,9 +185,8 @@ export const actions = {
|
||||
categorySlug,
|
||||
});
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
commit(types.SET_ARTICLE_POSITIONS, oldPositions);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return '';
|
||||
},
|
||||
};
|
||||
|
||||
@@ -22,6 +22,16 @@ export const getters = {
|
||||
.filter(article => article !== undefined);
|
||||
return articles;
|
||||
},
|
||||
allArticlesSortedByPosition: (...getterArguments) => {
|
||||
const [state, _getters] = getterArguments;
|
||||
const articles = state.articles.allIds
|
||||
.map(id => _getters.articleById(id))
|
||||
.filter(article => article !== undefined);
|
||||
// Sort by position so reordered articles stay in correct order after store updates
|
||||
return articles.sort(
|
||||
(a, b) => (a.position ?? Infinity) - (b.position ?? Infinity)
|
||||
);
|
||||
},
|
||||
articleStatus:
|
||||
(...getterArguments) =>
|
||||
articleId => {
|
||||
|
||||
@@ -64,6 +64,18 @@ export const mutations = {
|
||||
...uiFlags,
|
||||
};
|
||||
},
|
||||
[types.SET_ARTICLE_POSITIONS]: ($state, positionsHash) => {
|
||||
const { byId, allIds } = $state.articles;
|
||||
// Update position on each article record
|
||||
Object.entries(positionsHash).forEach(([id, position]) => {
|
||||
if (byId[id]) byId[id] = { ...byId[id], position };
|
||||
});
|
||||
// Re-sort allIds so every consumer sees the new order
|
||||
allIds.sort(
|
||||
(a, b) =>
|
||||
(byId[a]?.position ?? Infinity) - (byId[b]?.position ?? Infinity)
|
||||
);
|
||||
},
|
||||
[types.UPDATE_ARTICLE]: ($state, updatedArticle) => {
|
||||
const articleId = updatedArticle.id;
|
||||
if ($state.articles.byId[articleId]) {
|
||||
|
||||
@@ -279,4 +279,63 @@ describe('#actions', () => {
|
||||
).rejects.toThrow('Upload failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#reorder', () => {
|
||||
const state = {
|
||||
articles: {
|
||||
byId: {
|
||||
1: { id: 1, title: 'Article 1', position: 10 },
|
||||
2: { id: 2, title: 'Article 2', position: 20 },
|
||||
3: { id: 3, title: 'Article 3', position: 30 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it('commits SET_ARTICLE_POSITIONS and calls API when reorder is successful', async () => {
|
||||
axios.post.mockResolvedValue({ data: {} });
|
||||
const reorderedGroup = { 1: 1, 2: 2, 3: 3 };
|
||||
|
||||
await actions.reorder(
|
||||
{ commit, state },
|
||||
{
|
||||
portalSlug: 'test-portal',
|
||||
categorySlug: 'test-category',
|
||||
reorderedGroup,
|
||||
}
|
||||
);
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(
|
||||
types.default.SET_ARTICLE_POSITIONS,
|
||||
reorderedGroup
|
||||
);
|
||||
expect(axios.post).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/portals/test-portal/articles/reorder'),
|
||||
{ positions_hash: reorderedGroup, category_slug: 'test-category' }
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back positions and throws when API call fails', async () => {
|
||||
axios.post.mockRejectedValue({ message: 'Network error' });
|
||||
const reorderedGroup = { 1: 1, 2: 2 };
|
||||
|
||||
await expect(
|
||||
actions.reorder(
|
||||
{ commit, state },
|
||||
{
|
||||
portalSlug: 'test-portal',
|
||||
reorderedGroup,
|
||||
}
|
||||
)
|
||||
).rejects.toEqual({ message: 'Network error' });
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(
|
||||
types.default.SET_ARTICLE_POSITIONS,
|
||||
reorderedGroup
|
||||
);
|
||||
expect(commit).toHaveBeenCalledWith(types.default.SET_ARTICLE_POSITIONS, {
|
||||
1: 10,
|
||||
2: 20,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,4 +41,82 @@ describe('#getters', () => {
|
||||
it('isFetchingArticles', () => {
|
||||
expect(getters.isFetching(state)).toEqual(true);
|
||||
});
|
||||
|
||||
describe('allArticlesSortedByPosition', () => {
|
||||
it('returns articles sorted by position in ascending order', () => {
|
||||
const stateWithPositions = {
|
||||
...state,
|
||||
articles: {
|
||||
...state.articles,
|
||||
byId: {
|
||||
1: { id: 1, title: 'Article 1', position: 3 },
|
||||
2: { id: 2, title: 'Article 2', position: 1 },
|
||||
3: { id: 3, title: 'Article 3', position: 2 },
|
||||
},
|
||||
allIds: [1, 2, 3],
|
||||
},
|
||||
};
|
||||
const boundGetters = {
|
||||
articleById: getters.articleById(stateWithPositions),
|
||||
};
|
||||
|
||||
const result = getters.allArticlesSortedByPosition(
|
||||
stateWithPositions,
|
||||
boundGetters
|
||||
);
|
||||
|
||||
expect(result.map(a => a.id)).toEqual([2, 3, 1]);
|
||||
expect(result.map(a => a.position)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('places articles with null position at the end', () => {
|
||||
const stateWithNullPositions = {
|
||||
...state,
|
||||
articles: {
|
||||
...state.articles,
|
||||
byId: {
|
||||
1: { id: 1, title: 'Article 1', position: 1 },
|
||||
2: { id: 2, title: 'Article 2', position: null },
|
||||
3: { id: 3, title: 'Article 3', position: 2 },
|
||||
},
|
||||
allIds: [1, 2, 3],
|
||||
},
|
||||
};
|
||||
const boundGetters = {
|
||||
articleById: getters.articleById(stateWithNullPositions),
|
||||
};
|
||||
|
||||
const result = getters.allArticlesSortedByPosition(
|
||||
stateWithNullPositions,
|
||||
boundGetters
|
||||
);
|
||||
|
||||
expect(result.map(a => a.id)).toEqual([1, 3, 2]);
|
||||
});
|
||||
|
||||
it('handles articles with undefined position', () => {
|
||||
const stateWithUndefinedPositions = {
|
||||
...state,
|
||||
articles: {
|
||||
...state.articles,
|
||||
byId: {
|
||||
1: { id: 1, title: 'Article 1', position: 1 },
|
||||
2: { id: 2, title: 'Article 2' },
|
||||
3: { id: 3, title: 'Article 3', position: 2 },
|
||||
},
|
||||
allIds: [1, 2, 3],
|
||||
},
|
||||
};
|
||||
const boundGetters = {
|
||||
articleById: getters.articleById(stateWithUndefinedPositions),
|
||||
};
|
||||
|
||||
const result = getters.allArticlesSortedByPosition(
|
||||
stateWithUndefinedPositions,
|
||||
boundGetters
|
||||
);
|
||||
|
||||
expect(result.map(a => a.id)).toEqual([1, 3, 2]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import types from '../../../mutation-types';
|
||||
describe('#mutations', () => {
|
||||
let state = {};
|
||||
beforeEach(() => {
|
||||
state = article;
|
||||
state = JSON.parse(JSON.stringify(article));
|
||||
});
|
||||
|
||||
describe('#SET_UI_FLAG', () => {
|
||||
@@ -93,9 +93,9 @@ describe('#mutations', () => {
|
||||
mutations[types.ADD_ARTICLE_ID](state, 3);
|
||||
expect(state.articles.allIds).toEqual([1, 2, 3]);
|
||||
});
|
||||
it('Does not invalid article with empty data passed', () => {
|
||||
mutations[types.ADD_ARTICLE_ID](state, {});
|
||||
expect(state).toEqual(article);
|
||||
it('does not add duplicate article id to state', () => {
|
||||
mutations[types.ADD_ARTICLE_ID](state, 1);
|
||||
expect(state.articles.allIds).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -154,4 +154,53 @@ describe('#mutations', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#SET_ARTICLE_POSITIONS', () => {
|
||||
it('updates positions for articles in the store', () => {
|
||||
const positionsHash = { 1: 1, 2: 2 };
|
||||
mutations[types.SET_ARTICLE_POSITIONS](state, positionsHash);
|
||||
|
||||
expect(state.articles.byId[1].position).toEqual(1);
|
||||
expect(state.articles.byId[2].position).toEqual(2);
|
||||
});
|
||||
|
||||
it('does not update articles that are not in the store', () => {
|
||||
const positionsHash = { 999: 5 };
|
||||
mutations[types.SET_ARTICLE_POSITIONS](state, positionsHash);
|
||||
|
||||
expect(state.articles.byId[999]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves other article properties when updating position', () => {
|
||||
const originalTitle = state.articles.byId[1].title;
|
||||
const positionsHash = { 1: 3 };
|
||||
mutations[types.SET_ARTICLE_POSITIONS](state, positionsHash);
|
||||
|
||||
expect(state.articles.byId[1].position).toEqual(3);
|
||||
expect(state.articles.byId[1].title).toEqual(originalTitle);
|
||||
});
|
||||
|
||||
it('re-sorts allIds by position after update', () => {
|
||||
state.articles.byId[1].position = 1;
|
||||
state.articles.byId[2].position = 2;
|
||||
state.articles.allIds = [1, 2];
|
||||
|
||||
mutations[types.SET_ARTICLE_POSITIONS](state, { 1: 3, 2: 1 });
|
||||
|
||||
expect(state.articles.allIds).toEqual([2, 1]);
|
||||
});
|
||||
|
||||
it('UPDATE_ARTICLE preserves reordered position after SET_ARTICLE_POSITIONS', () => {
|
||||
mutations[types.SET_ARTICLE_POSITIONS](state, { 2: 1 });
|
||||
expect(state.articles.byId[2].position).toEqual(1);
|
||||
|
||||
mutations[types.UPDATE_ARTICLE](state, {
|
||||
id: 2,
|
||||
title: 'Updated Title',
|
||||
status: 'published',
|
||||
});
|
||||
expect(state.articles.byId[2].position).toEqual(1);
|
||||
expect(state.articles.byId[2].title).toEqual('Updated Title');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,4 +92,23 @@ export const actions = {
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
reorder: async ({ commit, state }, { portalSlug, reorderedGroup }) => {
|
||||
// Save old positions so we can rollback on failure
|
||||
const oldPositions = Object.keys(reorderedGroup).reduce((map, id) => {
|
||||
map[id] = state.categories.byId[id]?.position;
|
||||
return map;
|
||||
}, {});
|
||||
// Update positions in the store immediately so subsequent mutations preserve correct positions
|
||||
commit(types.SET_CATEGORY_POSITIONS, reorderedGroup);
|
||||
try {
|
||||
await categoriesAPI.reorder({
|
||||
portalSlug,
|
||||
reorderedGroup,
|
||||
});
|
||||
} catch (error) {
|
||||
commit(types.SET_CATEGORY_POSITIONS, oldPositions);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -21,6 +21,16 @@ export const getters = {
|
||||
});
|
||||
return categories;
|
||||
},
|
||||
allCategoriesSortedByPosition: (...getterArguments) => {
|
||||
const [state, _getters] = getterArguments;
|
||||
const categories = state.categories.allIds
|
||||
.map(id => _getters.categoryById(id))
|
||||
.filter(category => category !== undefined);
|
||||
// Sort by position so reordered categories stay in correct order after store updates
|
||||
return categories.sort(
|
||||
(a, b) => (a.position ?? Infinity) - (b.position ?? Infinity)
|
||||
);
|
||||
},
|
||||
categoriesByLocaleCode:
|
||||
(...getterArguments) =>
|
||||
localeCode => {
|
||||
|
||||
@@ -49,6 +49,18 @@ export const mutations = {
|
||||
...uiFlags,
|
||||
};
|
||||
},
|
||||
[types.SET_CATEGORY_POSITIONS]: ($state, positionsHash) => {
|
||||
const { byId, allIds } = $state.categories;
|
||||
// Update position on each category record
|
||||
Object.entries(positionsHash).forEach(([id, position]) => {
|
||||
if (byId[id]) byId[id] = { ...byId[id], position };
|
||||
});
|
||||
// Re-sort allIds so every consumer sees the new order
|
||||
allIds.sort(
|
||||
(a, b) =>
|
||||
(byId[a]?.position ?? Infinity) - (byId[b]?.position ?? Infinity)
|
||||
);
|
||||
},
|
||||
[types.UPDATE_CATEGORY]($state, category) {
|
||||
const categoryId = category.id;
|
||||
|
||||
|
||||
@@ -161,4 +161,63 @@ describe('#actions', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#reorder', () => {
|
||||
const state = {
|
||||
categories: {
|
||||
byId: {
|
||||
1: { id: 1, name: 'Category 1', position: 10 },
|
||||
2: { id: 2, name: 'Category 2', position: 20 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it('commits SET_CATEGORY_POSITIONS and calls API when reorder is successful', async () => {
|
||||
axios.post.mockResolvedValue({ data: {} });
|
||||
const reorderedGroup = { 2: 1, 1: 2 };
|
||||
|
||||
await actions.reorder(
|
||||
{ commit, state },
|
||||
{
|
||||
portalSlug: 'room-rental',
|
||||
reorderedGroup,
|
||||
}
|
||||
);
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(
|
||||
types.default.SET_CATEGORY_POSITIONS,
|
||||
reorderedGroup
|
||||
);
|
||||
expect(axios.post).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/portals/room-rental/categories/reorder'),
|
||||
{
|
||||
positions_hash: { 2: 1, 1: 2 },
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back positions and throws when API call fails', async () => {
|
||||
axios.post.mockRejectedValue({ message: 'Incorrect header' });
|
||||
const reorderedGroup = { 2: 1, 1: 2 };
|
||||
|
||||
await expect(
|
||||
actions.reorder(
|
||||
{ commit, state },
|
||||
{
|
||||
portalSlug: 'room-rental',
|
||||
reorderedGroup,
|
||||
}
|
||||
)
|
||||
).rejects.toEqual({ message: 'Incorrect header' });
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(
|
||||
types.default.SET_CATEGORY_POSITIONS,
|
||||
reorderedGroup
|
||||
);
|
||||
expect(commit).toHaveBeenCalledWith(
|
||||
types.default.SET_CATEGORY_POSITIONS,
|
||||
{ 1: 10, 2: 20 }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,4 +25,82 @@ describe('#getters', () => {
|
||||
it('isFetchingCategories', () => {
|
||||
expect(getters.isFetching(state)).toEqual(true);
|
||||
});
|
||||
|
||||
describe('allCategoriesSortedByPosition', () => {
|
||||
it('returns categories sorted by position in ascending order', () => {
|
||||
const stateWithPositions = {
|
||||
...state,
|
||||
categories: {
|
||||
...state.categories,
|
||||
byId: {
|
||||
1: { id: 1, name: 'Category 1', position: 3 },
|
||||
2: { id: 2, name: 'Category 2', position: 1 },
|
||||
3: { id: 3, name: 'Category 3', position: 2 },
|
||||
},
|
||||
allIds: [1, 2, 3],
|
||||
},
|
||||
};
|
||||
const boundGetters = {
|
||||
categoryById: getters.categoryById(stateWithPositions),
|
||||
};
|
||||
|
||||
const result = getters.allCategoriesSortedByPosition(
|
||||
stateWithPositions,
|
||||
boundGetters
|
||||
);
|
||||
|
||||
expect(result.map(c => c.id)).toEqual([2, 3, 1]);
|
||||
expect(result.map(c => c.position)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('places categories with null position at the end', () => {
|
||||
const stateWithNullPositions = {
|
||||
...state,
|
||||
categories: {
|
||||
...state.categories,
|
||||
byId: {
|
||||
1: { id: 1, name: 'Category 1', position: 1 },
|
||||
2: { id: 2, name: 'Category 2', position: null },
|
||||
3: { id: 3, name: 'Category 3', position: 2 },
|
||||
},
|
||||
allIds: [1, 2, 3],
|
||||
},
|
||||
};
|
||||
const boundGetters = {
|
||||
categoryById: getters.categoryById(stateWithNullPositions),
|
||||
};
|
||||
|
||||
const result = getters.allCategoriesSortedByPosition(
|
||||
stateWithNullPositions,
|
||||
boundGetters
|
||||
);
|
||||
|
||||
expect(result.map(c => c.id)).toEqual([1, 3, 2]);
|
||||
});
|
||||
|
||||
it('handles categories with undefined position', () => {
|
||||
const stateWithUndefinedPositions = {
|
||||
...state,
|
||||
categories: {
|
||||
...state.categories,
|
||||
byId: {
|
||||
1: { id: 1, name: 'Category 1', position: 1 },
|
||||
2: { id: 2, name: 'Category 2' },
|
||||
3: { id: 3, name: 'Category 3', position: 2 },
|
||||
},
|
||||
allIds: [1, 2, 3],
|
||||
},
|
||||
};
|
||||
const boundGetters = {
|
||||
categoryById: getters.categoryById(stateWithUndefinedPositions),
|
||||
};
|
||||
|
||||
const result = getters.allCategoriesSortedByPosition(
|
||||
stateWithUndefinedPositions,
|
||||
boundGetters
|
||||
);
|
||||
|
||||
expect(result.map(c => c.id)).toEqual([1, 3, 2]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+39
-3
@@ -4,7 +4,7 @@ import { categoriesState, categoriesPayload } from './fixtures';
|
||||
describe('#mutations', () => {
|
||||
let state = {};
|
||||
beforeEach(() => {
|
||||
state = categoriesState;
|
||||
state = JSON.parse(JSON.stringify(categoriesState));
|
||||
});
|
||||
|
||||
describe('#SET_UI_FLAG', () => {
|
||||
@@ -53,9 +53,9 @@ describe('#mutations', () => {
|
||||
mutations[types.ADD_CATEGORY_ID](state, 3);
|
||||
expect(state.categories.allIds).toEqual([1, 2, 3]);
|
||||
});
|
||||
it('Does not invalid category with empty data passed', () => {
|
||||
it('pushes the given id to allIds', () => {
|
||||
mutations[types.ADD_CATEGORY_ID](state, {});
|
||||
expect(state).toEqual(categoriesState);
|
||||
expect(state.categories.allIds).toEqual([1, 2, {}]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,4 +98,40 @@ describe('#mutations', () => {
|
||||
// expect(state.categories.uiFlags).toEqual({});
|
||||
// });
|
||||
// });
|
||||
|
||||
describe('#SET_CATEGORY_POSITIONS', () => {
|
||||
it('updates positions for categories in the store', () => {
|
||||
const positionsHash = { 1: 1, 2: 2 };
|
||||
mutations[types.SET_CATEGORY_POSITIONS](state, positionsHash);
|
||||
|
||||
expect(state.categories.byId[1].position).toEqual(1);
|
||||
expect(state.categories.byId[2].position).toEqual(2);
|
||||
});
|
||||
|
||||
it('does not update categories that are not in the store', () => {
|
||||
const positionsHash = { 999: 5 };
|
||||
mutations[types.SET_CATEGORY_POSITIONS](state, positionsHash);
|
||||
|
||||
expect(state.categories.byId[999]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves other category properties when updating position', () => {
|
||||
const originalName = state.categories.byId[1].name;
|
||||
const positionsHash = { 1: 3 };
|
||||
mutations[types.SET_CATEGORY_POSITIONS](state, positionsHash);
|
||||
|
||||
expect(state.categories.byId[1].position).toEqual(3);
|
||||
expect(state.categories.byId[1].name).toEqual(originalName);
|
||||
});
|
||||
|
||||
it('re-sorts allIds by position after update', () => {
|
||||
state.categories.byId[1].position = 1;
|
||||
state.categories.byId[2].position = 2;
|
||||
state.categories.allIds = [1, 2];
|
||||
|
||||
mutations[types.SET_CATEGORY_POSITIONS](state, { 1: 3, 2: 1 });
|
||||
|
||||
expect(state.categories.allIds).toEqual([2, 1]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -360,6 +360,13 @@ export const actions = {
|
||||
const response = await InboxesAPI.getCSATTemplateStatus(inboxId);
|
||||
return response.data;
|
||||
},
|
||||
analyzeCSATTemplateUtility: async (_, { inboxId, template }) => {
|
||||
const response = await InboxesAPI.analyzeCSATTemplateUtility(
|
||||
inboxId,
|
||||
template
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export const mutations = {
|
||||
|
||||
@@ -42,6 +42,7 @@ export const actions = {
|
||||
} = response.data;
|
||||
commit(types.default.ADD_WEBHOOK, webhook);
|
||||
commit(types.default.SET_WEBHOOK_UI_FLAG, { creatingItem: false });
|
||||
return webhook;
|
||||
} catch (error) {
|
||||
commit(types.default.SET_WEBHOOK_UI_FLAG, { creatingItem: false });
|
||||
throw error;
|
||||
|
||||
@@ -290,6 +290,7 @@ export default {
|
||||
REMOVE_ARTICLE: 'REMOVE_ARTICLE',
|
||||
REMOVE_ARTICLE_ID: 'REMOVE_ARTICLE_ID',
|
||||
SET_UI_FLAG: 'SET_UI_FLAG',
|
||||
SET_ARTICLE_POSITIONS: 'SET_ARTICLE_POSITIONS',
|
||||
|
||||
// Help Center -- Categories
|
||||
ADD_CATEGORY: 'ADD_CATEGORY',
|
||||
@@ -301,6 +302,7 @@ export default {
|
||||
UPDATE_CATEGORY: 'UPDATE_CATEGORY',
|
||||
REMOVE_CATEGORY: 'REMOVE_CATEGORY',
|
||||
REMOVE_CATEGORY_ID: 'REMOVE_CATEGORY_ID',
|
||||
SET_CATEGORY_POSITIONS: 'SET_CATEGORY_POSITIONS',
|
||||
|
||||
// Agent Bots
|
||||
SET_AGENT_BOT_UI_FLAG: 'SET_AGENT_BOT_UI_FLAG',
|
||||
|
||||
@@ -46,9 +46,7 @@ export default {
|
||||
filteredActiveLabels() {
|
||||
if (!this.search) return this.accountLabels;
|
||||
|
||||
return picoSearch(this.accountLabels, this.search, ['title'], {
|
||||
threshold: 0.9,
|
||||
});
|
||||
return picoSearch(this.accountLabels, this.search, ['title']);
|
||||
},
|
||||
|
||||
noResult() {
|
||||
|
||||
@@ -72,6 +72,10 @@ export default {
|
||||
return this.message.sender.available_name || this.message.sender.name;
|
||||
}
|
||||
|
||||
if (this.message.additional_attributes?.sender_name) {
|
||||
return this.message.additional_attributes.sender_name;
|
||||
}
|
||||
|
||||
if (this.useInboxAvatarForBot) {
|
||||
return this.channelConfig.websiteName;
|
||||
}
|
||||
@@ -87,9 +91,13 @@ export default {
|
||||
return displayImage;
|
||||
}
|
||||
|
||||
return this.message.sender
|
||||
? this.message.sender.avatar_url
|
||||
: displayImage;
|
||||
if (this.message.sender) {
|
||||
return this.message.sender.avatar_url;
|
||||
}
|
||||
|
||||
return (
|
||||
this.message.additional_attributes?.sender_avatar_url || displayImage
|
||||
);
|
||||
},
|
||||
hasRecordedResponse() {
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
class AgentBots::WebhookJob < WebhookJob
|
||||
queue_as :high
|
||||
retry_on RestClient::TooManyRequests, RestClient::InternalServerError, wait: 3.seconds, attempts: 3 do |job, error|
|
||||
url, payload, webhook_type = job.arguments
|
||||
Webhooks::Trigger.new(url, payload, webhook_type || :agent_bot_webhook).handle_failure(error)
|
||||
end
|
||||
|
||||
def perform(url, payload, webhook_type = :agent_bot_webhook)
|
||||
super(url, payload, webhook_type)
|
||||
rescue RestClient::TooManyRequests, RestClient::InternalServerError => e
|
||||
Rails.logger.warn("[AgentBots::WebhookJob] attempt #{executions} failed #{e.class.name}")
|
||||
raise
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
class Avatar::AvatarFromFaviconJob < ApplicationJob
|
||||
queue_as :purgable
|
||||
|
||||
def perform(company)
|
||||
return if company.domain.blank?
|
||||
return if company.avatar.attached?
|
||||
|
||||
favicon_url = "https://www.google.com/s2/favicons?domain=#{company.domain}&sz=256"
|
||||
Avatar::AvatarFromUrlJob.perform_now(company, favicon_url)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,17 @@
|
||||
class Companies::FetchAvatarsJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(account_id)
|
||||
account = Account.find(account_id)
|
||||
companies = account.companies.where.not(domain: [nil, ''])
|
||||
.left_joins(:avatar_attachment)
|
||||
.where(active_storage_attachments: { id: nil })
|
||||
|
||||
total_companies = companies.count
|
||||
companies.find_each do |company|
|
||||
Avatar::AvatarFromFaviconJob.perform_later(company)
|
||||
end
|
||||
|
||||
Rails.logger.info "Queued #{total_companies} companies from account #{account_id} for favicon fetch"
|
||||
end
|
||||
end
|
||||
@@ -1,7 +1,7 @@
|
||||
class WebhookJob < ApplicationJob
|
||||
queue_as :medium
|
||||
# There are 3 types of webhooks, account, inbox and agent_bot
|
||||
def perform(url, payload, webhook_type = :account_webhook)
|
||||
Webhooks::Trigger.execute(url, payload, webhook_type)
|
||||
def perform(url, payload, webhook_type = :account_webhook, secret: nil, delivery_id: nil)
|
||||
Webhooks::Trigger.execute(url, payload, webhook_type, secret: secret, delivery_id: delivery_id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class Webhooks::WhatsappEventsJob < ApplicationJob
|
||||
class Webhooks::WhatsappEventsJob < MutexApplicationJob
|
||||
queue_as :low
|
||||
retry_on LockAcquisitionError, wait: 1.second, attempts: 8
|
||||
|
||||
def perform(params = {})
|
||||
channel = find_channel_from_whatsapp_business_payload(params)
|
||||
@@ -9,10 +10,14 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
|
||||
return
|
||||
end
|
||||
|
||||
if message_echo_event?(params)
|
||||
handle_message_echo(channel, params)
|
||||
sender_id = extract_sender_id(params)
|
||||
if sender_id
|
||||
key = format(::Redis::Alfred::WHATSAPP_MESSAGE_MUTEX, sender_id: sender_id, phone_number: channel.phone_number)
|
||||
with_lock(key, 30.seconds) do
|
||||
process_events(channel, params)
|
||||
end
|
||||
else
|
||||
handle_message_events(channel, params)
|
||||
process_events(channel, params)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -54,8 +59,14 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
|
||||
params.dig(:entry, 0, :changes, 0, :field) == 'smb_message_echoes'
|
||||
end
|
||||
|
||||
def handle_message_echo(channel, params)
|
||||
Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: channel.inbox, params: params, outgoing_echo: true).perform
|
||||
private
|
||||
|
||||
def process_events(channel, params)
|
||||
if message_echo_event?(params)
|
||||
Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: channel.inbox, params: params, outgoing_echo: true).perform
|
||||
else
|
||||
handle_message_events(channel, params)
|
||||
end
|
||||
end
|
||||
|
||||
def handle_message_events(channel, params)
|
||||
@@ -67,7 +78,16 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
# Extracts the contact's phone number from the webhook payload for use as a mutex key.
|
||||
# For incoming messages, the contact is the sender (from field).
|
||||
# For echo messages, the contact is the recipient (to field).
|
||||
# For status updates, returns nil (no lock needed as they don't create conversations).
|
||||
def extract_sender_id(params)
|
||||
value = params.dig(:entry, 0, :changes, 0, :value)
|
||||
return unless value
|
||||
|
||||
value.dig(:messages, 0, :from) || value.dig(:contacts, 0, :wa_id) || value.dig(:message_echoes, 0, :to)
|
||||
end
|
||||
|
||||
def channel_is_inactive?(channel)
|
||||
return true if channel.blank?
|
||||
|
||||
@@ -180,8 +180,14 @@ class ActionCableListener < BaseListener
|
||||
end
|
||||
|
||||
def typing_event_listener_tokens(account, conversation, user)
|
||||
current_user_token = user.is_a?(Contact) ? conversation.contact_inbox.pubsub_token : user.pubsub_token
|
||||
(user_tokens(account, conversation.inbox.members) + [conversation.contact_inbox.pubsub_token]) - [current_user_token]
|
||||
current_user_token = if user.is_a?(Contact)
|
||||
conversation.contact_inbox.pubsub_token
|
||||
elsif user.respond_to?(:pubsub_token)
|
||||
user.pubsub_token
|
||||
end
|
||||
|
||||
tokens = user_tokens(account, conversation.inbox.members) + [conversation.contact_inbox.pubsub_token]
|
||||
current_user_token.present? ? tokens - [current_user_token] : tokens
|
||||
end
|
||||
|
||||
def user_tokens(account, agents)
|
||||
|
||||
@@ -111,7 +111,9 @@ class WebhookListener < BaseListener
|
||||
account.webhooks.account_type.each do |webhook|
|
||||
next unless webhook.subscriptions.include?(payload[:event])
|
||||
|
||||
WebhookJob.perform_later(webhook.url, payload)
|
||||
WebhookJob.perform_later(webhook.url, payload, :account_webhook,
|
||||
secret: webhook.secret,
|
||||
delivery_id: SecureRandom.uuid)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -119,7 +121,8 @@ class WebhookListener < BaseListener
|
||||
return unless inbox.channel_type == 'Channel::Api'
|
||||
return if inbox.channel.webhook_url.blank?
|
||||
|
||||
WebhookJob.perform_later(inbox.channel.webhook_url, payload, :api_inbox_webhook)
|
||||
WebhookJob.perform_later(inbox.channel.webhook_url, payload, :api_inbox_webhook,
|
||||
delivery_id: SecureRandom.uuid)
|
||||
end
|
||||
|
||||
def deliver_webhook_payloads(payload, inbox)
|
||||
|
||||
@@ -41,6 +41,7 @@ class Account < ApplicationRecord
|
||||
'audio_transcriptions': { 'type': %w[boolean null] },
|
||||
'auto_resolve_label': { 'type': %w[string null] },
|
||||
'keep_pending_on_bot_failure': { 'type': %w[boolean null] },
|
||||
'captain_disable_auto_resolve': { 'type': %w[boolean null] },
|
||||
'conversation_required_attributes': {
|
||||
'type': %w[array null],
|
||||
'items': { 'type': 'string' }
|
||||
@@ -90,6 +91,7 @@ class Account < ApplicationRecord
|
||||
store_accessor :settings, :audio_transcriptions, :auto_resolve_label
|
||||
store_accessor :settings, :captain_models, :captain_features
|
||||
store_accessor :settings, :keep_pending_on_bot_failure
|
||||
store_accessor :settings, :captain_disable_auto_resolve
|
||||
|
||||
has_many :account_users, dependent: :destroy_async
|
||||
has_many :agent_bot_inboxes, dependent: :destroy_async
|
||||
|
||||
@@ -132,11 +132,13 @@ class Article < ApplicationRecord
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
end
|
||||
|
||||
def self.update_positions(positions_hash)
|
||||
positions_hash.each do |article_id, new_position|
|
||||
# Find the article by its ID and update its position
|
||||
article = Article.find(article_id)
|
||||
article.update!(position: new_position)
|
||||
def self.update_positions(portal:, positions_hash:)
|
||||
return if positions_hash.blank?
|
||||
|
||||
transaction do
|
||||
positions_hash.each do |article_id, new_position|
|
||||
portal.articles.find(article_id).update!(position: new_position)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ class Attachment < ApplicationRecord
|
||||
when :embed
|
||||
embed_data
|
||||
else
|
||||
file_metadata
|
||||
file.attached? ? file_metadata : { data_url: external_url, thumb_url: '' }
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -73,6 +73,16 @@ class Category < ApplicationRecord
|
||||
params[:page] || 1
|
||||
end
|
||||
|
||||
def self.update_positions(portal:, positions_hash:)
|
||||
return if positions_hash.blank?
|
||||
|
||||
transaction do
|
||||
positions_hash.each do |category_id, new_position|
|
||||
portal.categories.find(category_id).update!(position: new_position)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_account_id
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
module ActivityMessageHandler
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
include AssigneeActivityMessageHandler
|
||||
include PriorityActivityMessageHandler
|
||||
include LabelActivityMessageHandler
|
||||
include SlaActivityMessageHandler
|
||||
@@ -104,27 +105,6 @@ module ActivityMessageHandler
|
||||
content = I18n.t("conversations.activity.#{change_type}", user_name: Current.user.name)
|
||||
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
|
||||
end
|
||||
|
||||
def generate_assignee_change_activity_content(user_name)
|
||||
params = { assignee_name: assignee&.name || '', user_name: user_name }
|
||||
key = assignee_id ? 'assigned' : 'removed'
|
||||
key = 'self_assigned' if self_assign? assignee_id
|
||||
I18n.t("conversations.activity.assignee.#{key}", **params)
|
||||
end
|
||||
|
||||
def create_assignee_change_activity(user_name)
|
||||
user_name = activity_message_owner(user_name)
|
||||
|
||||
return unless user_name
|
||||
|
||||
content = generate_assignee_change_activity_content(user_name)
|
||||
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
|
||||
end
|
||||
|
||||
def activity_message_owner(user_name)
|
||||
user_name = I18n.t('automation.system_name') if !user_name && Current.executed_by.present?
|
||||
user_name
|
||||
end
|
||||
end
|
||||
|
||||
ActivityMessageHandler.prepend_mod_with('ActivityMessageHandler')
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user