Compare commits

..
1 Commits
Author SHA1 Message Date
Pranav 859ab6777c rich seed data 2026-02-08 13:49:24 -08:00
162 changed files with 4748 additions and 5176 deletions
-2
View File
@@ -94,7 +94,6 @@ yarn-debug.log*
.vscode .vscode
.claude/settings.local.json .claude/settings.local.json
.cursor .cursor
.codex/
CLAUDE.local.md CLAUDE.local.md
# Histoire deployment # Histoire deployment
@@ -102,4 +101,3 @@ CLAUDE.local.md
.histoire .histoire
.pnpm-store/* .pnpm-store/*
local/ local/
Procfile.worktree
-16
View File
@@ -4,11 +4,6 @@
- **Setup**: `bundle install && pnpm install` - **Setup**: `bundle install && pnpm install`
- **Run Dev**: `pnpm dev` or `overmind start -f ./Procfile.dev` - **Run Dev**: `pnpm dev` or `overmind start -f ./Procfile.dev`
- **Seed Local Test Data**: `bundle exec rails db:seed` (quickly populates minimal data for standard feature verification)
- **Seed Search Test Data**: `bundle exec rails search:setup_test_data` (bulk fixture generation for search/performance/manual load scenarios)
- **Seed Account Sample Data (richer test data)**: `Seeders::AccountSeeder` is available as an internal utility and is exposed through Super Admin `Accounts#seed`, but can be used directly in dev workflows too:
- UI path: Super Admin → Accounts → Seed (enqueues `Internal::SeedAccountJob`).
- CLI path: `bundle exec rails runner "Internal::SeedAccountJob.perform_now(Account.find(<id>))"` (or call `Seeders::AccountSeeder.new(account: Account.find(<id>)).perform!` directly).
- **Lint JS/Vue**: `pnpm eslint` / `pnpm eslint:fix` - **Lint JS/Vue**: `pnpm eslint` / `pnpm eslint:fix`
- **Lint Ruby**: `bundle exec rubocop -a` - **Lint Ruby**: `bundle exec rubocop -a`
- **Test JS**: `pnpm test` or `pnpm test:watch` - **Test JS**: `pnpm test` or `pnpm test:watch`
@@ -55,13 +50,6 @@
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs - Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
- Specs in parallel/reloading environments: prefer comparing `error.class.name` over constant class equality when asserting raised errors - Specs in parallel/reloading environments: prefer comparing `error.class.name` over constant class equality when asserting raised errors
## Codex Worktree Workflow
- Use a separate git worktree + branch per task to keep changes isolated.
- Keep Codex-specific local setup under `.codex/` and use `Procfile.worktree` for worktree process orchestration.
- The setup workflow in `.codex/environments/environment.toml` should dynamically generate per-worktree DB/port values (Rails, Vite, Redis DB index) to avoid collisions.
- Start each worktree with its own Overmind socket/title so multiple instances can run at the same time.
## Commit Messages ## Commit Messages
- Prefer Conventional Commits: `type(scope): subject` (scope optional) - Prefer Conventional Commits: `type(scope): subject` (scope optional)
@@ -98,7 +86,3 @@ Practical checklist for any change impacting core logic or public APIs
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift. - When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable. - Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable.
- When modifying existing OSS features for Enterprise-only behavior, add an Enterprise module (via `prepend_mod_with`/`include_mod_with`) instead of editing OSS files directly—especially for policies, controllers, and services. For Enterprise-exclusive features, place code directly under `enterprise/`. - When modifying existing OSS features for Enterprise-only behavior, add an Enterprise module (via `prepend_mod_with`/`include_mod_with`) instead of editing OSS files directly—especially for policies, controllers, and services. For Enterprise-exclusive features, place code directly under `enterprise/`.
## Branding / White-labeling note
- For user-facing strings that currently contain "Chatwoot" but should adapt to branded/self-hosted installs, prefer applying `replaceInstallationName` from `shared/composables/useBranding` in the UI layer (for example tooltip and suggestion labels) instead of adding hardcoded brand-specific copy.
+1 -3
View File
@@ -191,14 +191,12 @@ gem 'reverse_markdown'
gem 'iso-639' gem 'iso-639'
gem 'ruby-openai' gem 'ruby-openai'
gem 'ai-agents' gem 'ai-agents', '>= 0.7.0'
# TODO: Move this gem as a dependency of ai-agents # TODO: Move this gem as a dependency of ai-agents
gem 'ruby_llm', '>= 1.8.2' gem 'ruby_llm', '>= 1.8.2'
gem 'ruby_llm-schema' gem 'ruby_llm-schema'
gem 'cld3', '~> 3.7'
# OpenTelemetry for LLM observability # OpenTelemetry for LLM observability
gem 'opentelemetry-sdk' gem 'opentelemetry-sdk'
gem 'opentelemetry-exporter-otlp' gem 'opentelemetry-exporter-otlp'
+15 -17
View File
@@ -126,8 +126,8 @@ GEM
jbuilder (~> 2) jbuilder (~> 2)
rails (>= 4.2, < 7.2) rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6) selectize-rails (~> 0.6)
ai-agents (0.9.0) ai-agents (0.7.0)
ruby_llm (~> 1.9.1) ruby_llm (~> 1.8.2)
annotaterb (4.20.0) annotaterb (4.20.0)
activerecord (>= 6.0.0) activerecord (>= 6.0.0)
activesupport (>= 6.0.0) activesupport (>= 6.0.0)
@@ -186,7 +186,6 @@ GEM
byebug (11.1.3) byebug (11.1.3)
childprocess (5.1.0) childprocess (5.1.0)
logger (~> 1.5) logger (~> 1.5)
cld3 (3.7.0)
climate_control (1.2.0) climate_control (1.2.0)
coderay (1.1.3) coderay (1.1.3)
commonmarker (0.23.10) commonmarker (0.23.10)
@@ -298,7 +297,7 @@ GEM
railties (>= 5.0.0) railties (>= 5.0.0)
faker (3.2.0) faker (3.2.0)
i18n (>= 1.8.11, < 2) i18n (>= 1.8.11, < 2)
faraday (2.14.1) faraday (2.13.1)
faraday-net_http (>= 2.0, < 3.5) faraday-net_http (>= 2.0, < 3.5)
json json
logger logger
@@ -309,12 +308,12 @@ GEM
hashie hashie
faraday-multipart (1.0.4) faraday-multipart (1.0.4)
multipart-post (~> 2) multipart-post (~> 2)
faraday-net_http (3.4.2) faraday-net_http (3.4.0)
net-http (~> 0.5) net-http (>= 0.5.0)
faraday-net_http_persistent (2.1.0) faraday-net_http_persistent (2.1.0)
faraday (~> 2.5) faraday (~> 2.5)
net-http-persistent (~> 4.0) net-http-persistent (~> 4.0)
faraday-retry (2.4.0) faraday-retry (2.2.1)
faraday (~> 2.0) faraday (~> 2.0)
faraday_middleware-aws-sigv4 (1.0.1) faraday_middleware-aws-sigv4 (1.0.1)
aws-sigv4 (~> 1.0) aws-sigv4 (~> 1.0)
@@ -465,7 +464,7 @@ GEM
rails-dom-testing (>= 1, < 3) rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0) railties (>= 4.2.0)
thor (>= 0.14, < 2.0) thor (>= 0.14, < 2.0)
json (2.18.1) json (2.13.2)
json_refs (0.1.8) json_refs (0.1.8)
hana hana
json_schemer (0.2.24) json_schemer (0.2.24)
@@ -540,7 +539,7 @@ GEM
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
marcel (1.1.0) marcel (1.0.4)
maxminddb (0.1.22) maxminddb (0.1.22)
meta_request (0.8.5) meta_request (0.8.5)
rack-contrib (>= 1.1, < 3) rack-contrib (>= 1.1, < 3)
@@ -559,12 +558,12 @@ GEM
multi_json (1.15.0) multi_json (1.15.0)
multi_xml (0.8.0) multi_xml (0.8.0)
bigdecimal (>= 3.1, < 5) bigdecimal (>= 3.1, < 5)
multipart-post (2.4.1) multipart-post (2.3.0)
mutex_m (0.3.0) mutex_m (0.3.0)
neighbor (0.2.3) neighbor (0.2.3)
activerecord (>= 5.2) activerecord (>= 5.2)
net-http (0.9.1) net-http (0.6.0)
uri (>= 0.11.1) uri
net-http-persistent (4.0.2) net-http-persistent (4.0.2)
connection_pool (~> 2.2) connection_pool (~> 2.2)
net-imap (0.4.20) net-imap (0.4.20)
@@ -825,7 +824,7 @@ GEM
ruby2ruby (2.5.0) ruby2ruby (2.5.0)
ruby_parser (~> 3.1) ruby_parser (~> 3.1)
sexp_processor (~> 4.6) sexp_processor (~> 4.6)
ruby_llm (1.9.2) ruby_llm (1.8.2)
base64 base64
event_stream_parser (~> 1) event_stream_parser (~> 1)
faraday (>= 1.10.0) faraday (>= 1.10.0)
@@ -969,7 +968,7 @@ GEM
unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4) unicode-emoji (4.0.4)
uniform_notifier (1.17.0) uniform_notifier (1.17.0)
uri (1.1.1) uri (1.0.4)
uri_template (0.7.0) uri_template (0.7.0)
valid_email2 (5.2.6) valid_email2 (5.2.6)
activemodel (>= 3.2) activemodel (>= 3.2)
@@ -1004,7 +1003,7 @@ GEM
working_hours (1.4.1) working_hours (1.4.1)
activesupport (>= 3.2) activesupport (>= 3.2)
tzinfo tzinfo
zeitwerk (2.7.4) zeitwerk (2.6.17)
PLATFORMS PLATFORMS
arm64-darwin-20 arm64-darwin-20
@@ -1024,7 +1023,7 @@ DEPENDENCIES
administrate (>= 0.20.1) administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3) administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0) administrate-field-belongs_to_search (>= 0.9.0)
ai-agents ai-agents (>= 0.7.0)
annotaterb annotaterb
attr_extras attr_extras
audited (~> 5.4, >= 5.4.1) audited (~> 5.4, >= 5.4.1)
@@ -1038,7 +1037,6 @@ DEPENDENCIES
bullet bullet
bundle-audit bundle-audit
byebug byebug
cld3 (~> 3.7)
climate_control climate_control
commonmarker commonmarker
csv-safe csv-safe
+1 -1
View File
@@ -1 +1 @@
4.11.2 4.10.1
@@ -70,7 +70,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def transcript def transcript
render json: { error: 'email param missing' }, status: :unprocessable_entity and return if params[:email].blank? render json: { error: 'email param missing' }, status: :unprocessable_entity and return if params[:email].blank?
return render_payment_required('Email transcript is not available on your plan') unless @conversation.account.email_transcript_enabled?
return head :too_many_requests unless @conversation.account.within_email_rate_limit? return head :too_many_requests unless @conversation.account.within_email_rate_limit?
ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, params[:email])&.deliver_later ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, params[:email])&.deliver_later
@@ -35,9 +35,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
end end
def transcript def transcript
return head :too_many_requests if conversation.blank? return head :too_many_requests unless conversation.present? && conversation.account.within_email_rate_limit?
return head :payment_required unless conversation.account.email_transcript_enabled?
return head :too_many_requests unless conversation.account.within_email_rate_limit?
send_transcript_email send_transcript_email
head :ok head :ok
@@ -6,8 +6,12 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
def create def create
@user = User.from_email(params[:email]) @user = User.from_email(params[:email])
@user&.send_reset_password_instructions if @user
build_response(I18n.t('messages.reset_password'), 200) @user.send_reset_password_instructions
build_response(I18n.t('messages.reset_password_success'), 200)
else
build_response(I18n.t('messages.reset_password_failure'), 404)
end
end end
def update def update
@@ -1,35 +0,0 @@
class Webhooks::ShopifyController < ActionController::API
before_action :verify_hmac!
def events
case request.headers['X-Shopify-Topic']
when 'shop/redact'
handle_shop_redact
end
head :ok
end
private
def verify_hmac!
secret = GlobalConfigService.load('SHOPIFY_CLIENT_SECRET', nil)
return head :unauthorized if secret.blank?
data = request.body.read
request.body.rewind
hmac_header = request.headers['X-Shopify-Hmac-SHA256']
return head :unauthorized if hmac_header.blank?
computed = Base64.strict_encode64(OpenSSL::HMAC.digest('SHA256', secret, data))
return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(computed, hmac_header)
end
def handle_shop_redact
shop_domain = params[:shop_domain]
return if shop_domain.blank?
Integrations::Hook.where(app_id: 'shopify', reference_id: shop_domain).destroy_all
end
end
+5 -9
View File
@@ -47,15 +47,11 @@ module Filters::FilterHelper
def handle_additional_attributes(query_hash, filter_operator_value, data_type) def handle_additional_attributes(query_hash, filter_operator_value, data_type)
if data_type == 'text_case_insensitive' if data_type == 'text_case_insensitive'
ActiveRecord::Base.sanitize_sql_array( "LOWER(#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}') " \
["LOWER(#{filter_config[:table_name]}.additional_attributes ->> ?) #{filter_operator_value} #{query_hash[:query_operator]}", "#{filter_operator_value} #{query_hash[:query_operator]}"
query_hash[:attribute_key]]
)
else else
ActiveRecord::Base.sanitize_sql_array( "#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}' " \
["#{filter_config[:table_name]}.additional_attributes ->> ? #{filter_operator_value} #{query_hash[:query_operator]} ", "#{filter_operator_value} #{query_hash[:query_operator]} "
query_hash[:attribute_key]]
)
end end
end end
@@ -74,7 +70,7 @@ module Filters::FilterHelper
def date_filter(current_filter, query_hash, filter_operator_value) def date_filter(current_filter, query_hash, filter_operator_value)
"(#{filter_config[:table_name]}.#{query_hash[:attribute_key]})::#{current_filter['data_type']} " \ "(#{filter_config[:table_name]}.#{query_hash[:attribute_key]})::#{current_filter['data_type']} " \
"#{filter_operator_value} #{query_hash[:query_operator]}" "#{filter_operator_value}#{current_filter['data_type']} #{query_hash[:query_operator]}"
end end
def text_case_insensitive_filter(query_hash, filter_operator_value) def text_case_insensitive_filter(query_hash, filter_operator_value)
@@ -39,6 +39,7 @@ const policyA = withCount({
description: 'Distributes conversations evenly among available agents', description: 'Distributes conversations evenly among available agents',
assignmentOrder: 'round_robin', assignmentOrder: 'round_robin',
conversationPriority: 'high', conversationPriority: 'high',
enabled: true,
inboxes: [mockInboxes[0], mockInboxes[1]], inboxes: [mockInboxes[0], mockInboxes[1]],
isFetchingInboxes: false, isFetchingInboxes: false,
}); });
@@ -49,6 +50,7 @@ const policyB = withCount({
description: 'Assigns based on capacity and workload', description: 'Assigns based on capacity and workload',
assignmentOrder: 'capacity_based', assignmentOrder: 'capacity_based',
conversationPriority: 'medium', conversationPriority: 'medium',
enabled: true,
inboxes: [mockInboxes[2], mockInboxes[3]], inboxes: [mockInboxes[2], mockInboxes[3]],
isFetchingInboxes: false, isFetchingInboxes: false,
}); });
@@ -59,6 +61,7 @@ const emptyPolicy = withCount({
description: 'Policy with no assigned inboxes', description: 'Policy with no assigned inboxes',
assignmentOrder: 'manual', assignmentOrder: 'manual',
conversationPriority: 'low', conversationPriority: 'low',
enabled: false,
inboxes: [], inboxes: [],
isFetchingInboxes: false, isFetchingInboxes: false,
}); });
@@ -15,6 +15,7 @@ const props = defineProps({
assignmentOrder: { type: String, default: '' }, assignmentOrder: { type: String, default: '' },
conversationPriority: { type: String, default: '' }, conversationPriority: { type: String, default: '' },
assignedInboxCount: { type: Number, default: 0 }, assignedInboxCount: { type: Number, default: 0 },
enabled: { type: Boolean, default: false },
inboxes: { type: Array, default: () => [] }, inboxes: { type: Array, default: () => [] },
isFetchingInboxes: { type: Boolean, default: false }, isFetchingInboxes: { type: Boolean, default: false },
}); });
@@ -64,6 +65,22 @@ const handleFetchInboxes = () => {
{{ name }} {{ name }}
</h3> </h3>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="flex items-center rounded-md bg-n-alpha-2 h-6 px-2">
<span
class="text-xs"
:class="enabled ? 'text-n-teal-11' : 'text-n-slate-12'"
>
{{
enabled
? t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.ACTIVE'
)
: t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.INACTIVE'
)
}}
</span>
</div>
<CardPopover <CardPopover
:title=" :title="
t( t(
@@ -19,15 +19,11 @@ defineProps({
}, },
}); });
const emit = defineEmits(['delete', 'navigate']); const emit = defineEmits(['delete']);
const handleDelete = itemId => { const handleDelete = itemId => {
emit('delete', itemId); emit('delete', itemId);
}; };
const handleNavigate = item => {
emit('navigate', item);
};
</script> </script>
<template> <template>
@@ -51,11 +47,7 @@ const handleNavigate = item => {
:key="item.id" :key="item.id"
class="grid grid-cols-4 items-center gap-3 min-w-0 w-full justify-between h-[3.25rem] ltr:pr-2 rtl:pl-2" class="grid grid-cols-4 items-center gap-3 min-w-0 w-full justify-between h-[3.25rem] ltr:pr-2 rtl:pl-2"
> >
<button <div class="flex items-center gap-2 col-span-2">
type="button"
class="flex items-center gap-2 col-span-2 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 rounded-lg py-1 px-1.5 -ml-1.5 transition-colors cursor-pointer group"
@click="handleNavigate(item)"
>
<Icon <Icon
v-if="item.icon" v-if="item.icon"
:icon="item.icon" :icon="item.icon"
@@ -69,16 +61,10 @@ const handleNavigate = item => {
:size="20" :size="20"
rounded-full rounded-full
/> />
<span <span class="text-sm text-n-slate-12 truncate min-w-0">
class="text-sm text-n-slate-12 truncate min-w-0 group-hover:text-n-blue-11 dark:group-hover:text-n-blue-10 transition-colors"
>
{{ item.name }} {{ item.name }}
</span> </span>
<Icon </div>
icon="i-lucide-external-link"
class="size-3.5 text-n-slate-10 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
/>
</button>
<div class="flex items-start gap-2 col-span-1"> <div class="flex items-start gap-2 col-span-1">
<span <span
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue'; import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import Input from 'dashboard/components-next/input/Input.vue'; import Input from 'dashboard/components-next/input/Input.vue';
import DurationInput from 'dashboard/components-next/input/DurationInput.vue'; import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
@@ -15,9 +15,6 @@ const fairDistributionLimit = defineModel('fairDistributionLimit', {
}, },
}); });
// The model value is in seconds (for the backend/DB)
// DurationInput works in minutes internally
// We need to convert between seconds and minutes
const fairDistributionWindow = defineModel('fairDistributionWindow', { const fairDistributionWindow = defineModel('fairDistributionWindow', {
type: Number, type: Number,
default: 3600, default: 3600,
@@ -28,17 +25,6 @@ const fairDistributionWindow = defineModel('fairDistributionWindow', {
const windowUnit = ref(DURATION_UNITS.MINUTES); const windowUnit = ref(DURATION_UNITS.MINUTES);
// Convert seconds to minutes for DurationInput
const windowInMinutes = computed({
get() {
return Math.floor((fairDistributionWindow.value || 0) / 60);
},
set(minutes) {
fairDistributionWindow.value = minutes * 60;
},
});
// Detect unit based on minutes (converted from seconds)
const detectUnit = minutes => { const detectUnit = minutes => {
const m = Number(minutes) || 0; const m = Number(minutes) || 0;
if (m === 0) return DURATION_UNITS.MINUTES; if (m === 0) return DURATION_UNITS.MINUTES;
@@ -48,7 +34,7 @@ const detectUnit = minutes => {
}; };
onMounted(() => { onMounted(() => {
windowUnit.value = detectUnit(windowInMinutes.value); windowUnit.value = detectUnit(fairDistributionWindow.value);
}); });
</script> </script>
@@ -87,9 +73,9 @@ onMounted(() => {
<div <div
class="flex items-center gap-2 flex-1 [&>select]:!bg-n-alpha-2 [&>select]:!outline-none [&>select]:hover:brightness-110" class="flex items-center gap-2 flex-1 [&>select]:!bg-n-alpha-2 [&>select]:!outline-none [&>select]:hover:brightness-110"
> >
<!-- allow 10 mins to 999 days (in minutes) --> <!-- allow 10 mins to 999 days -->
<DurationInput <DurationInput
v-model:model-value="windowInMinutes" v-model:model-value="fairDistributionWindow"
v-model:unit="windowUnit" v-model:unit="windowUnit"
:min="10" :min="10"
:max="1438560" :max="1438560"
@@ -1,6 +1,4 @@
<script setup> <script setup>
import { useI18n } from 'vue-i18n';
const props = defineProps({ const props = defineProps({
id: { id: {
type: String, type: String,
@@ -18,22 +16,12 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
disabled: {
type: Boolean,
default: false,
},
disabledMessage: {
type: String,
default: '',
},
}); });
const emit = defineEmits(['select']); const emit = defineEmits(['select']);
const { t } = useI18n();
const handleChange = () => { const handleChange = () => {
if (!props.isActive && !props.disabled) { if (!props.isActive) {
emit('select', props.id); emit('select', props.id);
} }
}; };
@@ -41,11 +29,9 @@ const handleChange = () => {
<template> <template>
<div <div
class="relative rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6" class="relative cursor-pointer rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6"
:class="[ :class="[
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer', isActive ? 'outline-n-blue-9' : 'outline-n-weak hover:outline-n-strong',
isActive ? 'outline-n-blue-9' : 'outline-n-weak',
!disabled && !isActive ? 'hover:outline-n-strong' : '',
]" ]"
@click="handleChange" @click="handleChange"
> >
@@ -55,7 +41,6 @@ const handleChange = () => {
:checked="isActive" :checked="isActive"
:value="id" :value="id"
:name="id" :name="id"
:disabled="disabled"
type="radio" type="radio"
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0" class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0"
@change="handleChange" @change="handleChange"
@@ -64,23 +49,11 @@ const handleChange = () => {
<!-- Content --> <!-- Content -->
<div class="flex flex-col gap-3 items-start"> <div class="flex flex-col gap-3 items-start">
<div class="flex items-center gap-2"> <h3 class="text-sm font-medium text-n-slate-12">
<h3 class="text-sm font-medium text-n-slate-12"> {{ label }}
{{ label }} </h3>
</h3>
<span
v-if="disabled"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-n-yellow-3 text-n-yellow-11"
>
{{
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.FORM.ASSIGNMENT_ORDER.BALANCED.PREMIUM_BADGE'
)
}}
</span>
</div>
<p class="text-sm text-n-slate-11"> <p class="text-sm text-n-slate-11">
{{ disabled && disabledMessage ? disabledMessage : description }} {{ description }}
</p> </p>
</div> </div>
</div> </div>
@@ -6,6 +6,7 @@ const policyName = ref('Round Robin Policy');
const description = ref( const description = ref(
'Distributes conversations evenly among available agents' 'Distributes conversations evenly among available agents'
); );
const enabled = ref(true);
</script> </script>
<template> <template>
@@ -18,10 +19,13 @@ const description = ref(
<BaseInfo <BaseInfo
v-model:policy-name="policyName" v-model:policy-name="policyName"
v-model:description="description" v-model:description="description"
v-model:enabled="enabled"
name-label="Policy Name" name-label="Policy Name"
name-placeholder="Enter policy name" name-placeholder="Enter policy name"
description-label="Description" description-label="Description"
description-placeholder="Enter policy description" description-placeholder="Enter policy description"
status-label="Status"
status-placeholder="Active"
/> />
</div> </div>
</Variant> </Variant>
@@ -68,7 +68,8 @@ const hasActiveSegments = computed(
); );
const activeSegmentName = computed(() => props.activeSegment?.name); const activeSegmentName = computed(() => props.activeSegment?.name);
const openCreateNewContactDialog = () => { const openCreateNewContactDialog = async () => {
await createNewContactDialogRef.value?.contactsFormRef.resetValidation();
createNewContactDialogRef.value?.dialogRef.open(); createNewContactDialogRef.value?.dialogRef.open();
}; };
const openContactImportDialog = () => const openContactImportDialog = () =>
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { reactive, ref, computed, onMounted, watch } from 'vue'; import { ref, computed, onMounted, watch } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store'; import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useWindowSize } from '@vueuse/core'; import { useWindowSize } from '@vueuse/core';
@@ -59,23 +59,6 @@ const isFetchingInboxes = ref(false);
const isSearching = ref(false); const isSearching = ref(false);
const showComposeNewConversation = ref(false); const showComposeNewConversation = ref(false);
const formState = reactive({
message: '',
subject: '',
ccEmails: '',
bccEmails: '',
attachedFiles: [],
});
const clearFormState = () => {
Object.assign(formState, {
subject: '',
ccEmails: '',
bccEmails: '',
attachedFiles: [],
});
};
const contactById = useMapGetter('contacts/getContactById'); const contactById = useMapGetter('contacts/getContactById');
const contactsUiFlags = useMapGetter('contacts/getUIFlags'); const contactsUiFlags = useMapGetter('contacts/getUIFlags');
const currentUser = useMapGetter('getCurrentUser'); const currentUser = useMapGetter('getCurrentUser');
@@ -157,14 +140,12 @@ const handleSelectedContact = async ({ value, action, ...rest }) => {
const handleTargetInbox = inbox => { const handleTargetInbox = inbox => {
targetInbox.value = inbox; targetInbox.value = inbox;
if (!inbox) clearFormState();
resetContacts(); resetContacts();
}; };
const clearSelectedContact = () => { const clearSelectedContact = () => {
selectedContact.value = null; selectedContact.value = null;
targetInbox.value = null; targetInbox.value = null;
clearFormState();
}; };
const closeCompose = () => { const closeCompose = () => {
@@ -179,12 +160,6 @@ const closeCompose = () => {
emit('close'); emit('close');
}; };
const discardCompose = () => {
clearFormState();
formState.message = '';
closeCompose();
};
const createConversation = async ({ payload, isFromWhatsApp }) => { const createConversation = async ({ payload, isFromWhatsApp }) => {
try { try {
const data = await store.dispatch('contactConversations/create', { const data = await store.dispatch('contactConversations/create', {
@@ -196,7 +171,7 @@ const createConversation = async ({ payload, isFromWhatsApp }) => {
to: `/app/accounts/${data.account_id}/conversations/${data.id}`, to: `/app/accounts/${data.account_id}/conversations/${data.id}`,
message: t('COMPOSE_NEW_CONVERSATION.FORM.GO_TO_CONVERSATION'), message: t('COMPOSE_NEW_CONVERSATION.FORM.GO_TO_CONVERSATION'),
}; };
discardCompose(); closeCompose();
useAlert(t('COMPOSE_NEW_CONVERSATION.FORM.SUCCESS_MESSAGE'), action); useAlert(t('COMPOSE_NEW_CONVERSATION.FORM.SUCCESS_MESSAGE'), action);
return true; // Return success return true; // Return success
} catch (error) { } catch (error) {
@@ -218,11 +193,7 @@ watch(
(currentContact, previousContact) => { (currentContact, previousContact) => {
if (currentContact && props.contactId) { if (currentContact && props.contactId) {
// Reset on contact change // Reset on contact change
if (currentContact?.id !== previousContact?.id) { if (currentContact?.id !== previousContact?.id) clearSelectedContact();
clearSelectedContact();
clearFormState();
formState.message = '';
}
// First process the contactable inboxes to get the right structure // First process the contactable inboxes to get the right structure
const processedInboxes = processContactableInboxes( const processedInboxes = processContactableInboxes(
@@ -294,7 +265,6 @@ useKeyboardEvents(keyboardEvents);
@click.self="onModalBackdropClick" @click.self="onModalBackdropClick"
> >
<ComposeNewConversationForm <ComposeNewConversationForm
:form-state="formState"
:class="[{ 'mt-2': !viewInModal }, composePopoverClass]" :class="[{ 'mt-2': !viewInModal }, composePopoverClass]"
:contacts="contacts" :contacts="contacts"
:contact-id="contactId" :contact-id="contactId"
@@ -315,7 +285,7 @@ useKeyboardEvents(keyboardEvents);
@update-target-inbox="handleTargetInbox" @update-target-inbox="handleTargetInbox"
@clear-selected-contact="clearSelectedContact" @clear-selected-contact="clearSelectedContact"
@create-conversation="createConversation" @create-conversation="createConversation"
@discard="discardCompose" @discard="closeCompose"
/> />
</div> </div>
</div> </div>
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, computed } from 'vue'; import { reactive, ref, computed } from 'vue';
import { useVuelidate } from '@vuelidate/core'; import { useVuelidate } from '@vuelidate/core';
import { required, requiredIf } from '@vuelidate/validators'; import { required, requiredIf } from '@vuelidate/validators';
import { INBOX_TYPES } from 'dashboard/helper/inbox'; import { INBOX_TYPES } from 'dashboard/helper/inbox';
@@ -37,7 +37,6 @@ const props = defineProps({
contactsUiFlags: { type: Object, default: null }, contactsUiFlags: { type: Object, default: null },
messageSignature: { type: String, default: '' }, messageSignature: { type: String, default: '' },
sendWithSignature: { type: Boolean, default: false }, sendWithSignature: { type: Boolean, default: false },
formState: { type: Object, required: true },
}); });
const emit = defineEmits([ const emit = defineEmits([
@@ -58,13 +57,13 @@ const showBccEmailsDropdown = ref(false);
const isCreating = computed(() => props.contactConversationsUiFlags.isCreating); const isCreating = computed(() => props.contactConversationsUiFlags.isCreating);
const state = props.formState || { const state = reactive({
message: '', message: '',
subject: '', subject: '',
ccEmails: '', ccEmails: '',
bccEmails: '', bccEmails: '',
attachedFiles: [], attachedFiles: [],
}; });
const inboxTypes = computed(() => ({ const inboxTypes = computed(() => ({
isEmail: props.targetInbox?.channelType === INBOX_TYPES.EMAIL, isEmail: props.targetInbox?.channelType === INBOX_TYPES.EMAIL,
@@ -95,7 +95,6 @@ const inputClass = computed(() => {
:show-dropdown="showCcEmailsDropdown" :show-dropdown="showCcEmailsDropdown"
:is-loading="isLoading" :is-loading="isLoading"
type="email" type="email"
allow-create
class="flex-1 min-h-7" class="flex-1 min-h-7"
@focus="emit('updateDropdown', 'cc', true)" @focus="emit('updateDropdown', 'cc', true)"
@input="emit('searchCcEmails', $event)" @input="emit('searchCcEmails', $event)"
@@ -128,7 +127,6 @@ const inputClass = computed(() => {
:show-dropdown="showBccEmailsDropdown" :show-dropdown="showBccEmailsDropdown"
:is-loading="isLoading" :is-loading="isLoading"
type="email" type="email"
allow-create
class="flex-1 min-h-7" class="flex-1 min-h-7"
focus-on-mount focus-on-mount
@focus="emit('updateDropdown', 'bcc', true)" @focus="emit('updateDropdown', 'bcc', true)"
@@ -56,13 +56,8 @@ const selectedLabel = computed(() => {
}); });
const selectOption = option => { const selectOption = option => {
if (selectedValue.value === option.value) { selectedValue.value = option.value;
selectedValue.value = ''; emit('update:modelValue', option.value);
emit('update:modelValue', '');
} else {
selectedValue.value = option.value;
emit('update:modelValue', option.value);
}
open.value = false; open.value = false;
search.value = ''; search.value = '';
}; };
@@ -53,11 +53,6 @@ const props = defineProps({
default: 'lg', default: 'lg',
validator: value => ['3xl', '2xl', 'xl', 'lg', 'md', 'sm'].includes(value), validator: value => ['3xl', '2xl', 'xl', 'lg', 'md', 'sm'].includes(value),
}, },
position: {
type: String,
default: 'center',
validator: value => ['center', 'top'].includes(value),
},
}); });
const emit = defineEmits(['confirm', 'close']); const emit = defineEmits(['confirm', 'close']);
@@ -66,7 +61,6 @@ const { t } = useI18n();
const dialogRef = ref(null); const dialogRef = ref(null);
const dialogContentRef = ref(null); const dialogContentRef = ref(null);
const isOpen = ref(false);
const maxWidthClass = computed(() => { const maxWidthClass = computed(() => {
const classesMap = { const classesMap = {
@@ -81,19 +75,13 @@ const maxWidthClass = computed(() => {
return classesMap[props.width] ?? 'max-w-md'; return classesMap[props.width] ?? 'max-w-md';
}); });
const positionClass = computed(() =>
props.position === 'top' ? 'dialog-position-top' : ''
);
const open = () => { const open = () => {
isOpen.value = true;
dialogRef.value?.showModal(); dialogRef.value?.showModal();
}; };
const close = () => { const close = () => {
emit('close'); emit('close');
dialogRef.value?.close(); dialogRef.value?.close();
isOpen.value = false;
}; };
const confirm = () => { const confirm = () => {
@@ -110,7 +98,6 @@ defineExpose({ open, close });
class="w-full transition-all duration-300 ease-in-out shadow-xl rounded-xl" class="w-full transition-all duration-300 ease-in-out shadow-xl rounded-xl"
:class="[ :class="[
maxWidthClass, maxWidthClass,
positionClass,
overflowYAuto ? 'overflow-y-auto' : 'overflow-visible', overflowYAuto ? 'overflow-y-auto' : 'overflow-visible',
]" ]"
@close="close" @close="close"
@@ -118,7 +105,7 @@ defineExpose({ open, close });
<OnClickOutside @trigger="close"> <OnClickOutside @trigger="close">
<form <form
ref="dialogContentRef" ref="dialogContentRef"
class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-start align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl" class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-left align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
@submit.prevent="confirm" @submit.prevent="confirm"
@click.stop @click.stop
> >
@@ -132,7 +119,7 @@ defineExpose({ open, close });
</p> </p>
</slot> </slot>
</div> </div>
<slot v-if="isOpen" /> <slot />
<!-- Dialog content will be injected here --> <!-- Dialog content will be injected here -->
<slot name="footer"> <slot name="footer">
<div <div
@@ -169,9 +156,4 @@ defineExpose({ open, close });
dialog::backdrop { dialog::backdrop {
@apply bg-n-alpha-black1 backdrop-blur-[4px]; @apply bg-n-alpha-black1 backdrop-blur-[4px];
} }
.dialog-position-top {
margin-top: clamp(2rem, 5vh, 5rem);
margin-bottom: auto;
}
</style> </style>
@@ -4,10 +4,6 @@ defineProps({
type: String, type: String,
default: '', default: '',
}, },
height: {
type: String,
default: 'max-h-96',
},
}); });
</script> </script>
@@ -19,10 +15,7 @@ defineProps({
> >
{{ title }} {{ title }}
</div> </div>
<ul <ul class="gap-2 grid reset-base list-none px-2 max-h-96 overflow-y-auto">
class="gap-2 grid reset-base list-none px-2 overflow-y-auto"
:class="height"
>
<slot /> <slot />
</ul> </ul>
</div> </div>
@@ -50,12 +50,12 @@ const currentFilter = computed(() =>
); );
const getOperator = (filter, selectedOperator) => { const getOperator = (filter, selectedOperator) => {
const operatorFromOptions = filter?.filterOperators?.find( const operatorFromOptions = filter.filterOperators.find(
operator => operator.value === selectedOperator operator => operator.value === selectedOperator
); );
if (!operatorFromOptions) { if (!operatorFromOptions) {
return filter?.filterOperators?.[0]; return filter.filterOperators[0];
} }
return operatorFromOptions; return operatorFromOptions;
@@ -138,11 +138,7 @@ const validate = () => {
return !validationError.value; return !validationError.value;
}; };
const resetValidation = () => { defineExpose({ validate });
showErrors.value = false;
};
defineExpose({ validate, resetValidation });
</script> </script>
<template> <template>
@@ -170,20 +166,18 @@ defineExpose({ validate, resetValidation });
<FilterSelect <FilterSelect
v-model="filterOperator" v-model="filterOperator"
variant="ghost" variant="ghost"
:options="currentFilter?.filterOperators" :options="currentFilter.filterOperators"
/> />
<template v-if="currentOperator?.hasInput"> <template v-if="currentOperator.hasInput">
<MultiSelect <MultiSelect
v-if="inputType === 'multiSelect'" v-if="inputType === 'multiSelect'"
v-model="values" v-model="values"
:options="currentFilter.options" :options="currentFilter.options"
dropdown-max-height="max-h-72"
/> />
<SingleSelect <SingleSelect
v-else-if="inputType === 'searchSelect'" v-else-if="inputType === 'searchSelect'"
v-model="values" v-model="values"
:options="currentFilter.options" :options="currentFilter.options"
dropdown-max-height="max-h-64"
/> />
<SingleSelect <SingleSelect
v-else-if="inputType === 'booleanSelect'" v-else-if="inputType === 'booleanSelect'"
@@ -45,7 +45,7 @@ const { height } = useWindowSize();
const { height: dropdownHeight } = useElementBounding(dropdownRef); const { height: dropdownHeight } = useElementBounding(dropdownRef);
const selectedOption = computed(() => { const selectedOption = computed(() => {
return props.options?.find(o => o.value === selected.value) || {}; return props.options.find(o => o.value === selected.value) || {};
}); });
const iconToRender = computed(() => { const iconToRender = computed(() => {
@@ -87,25 +87,18 @@ const updateSelected = newValue => {
</template> </template>
<DropdownBody <DropdownBody
ref="dropdownRef" ref="dropdownRef"
class="min-w-56 z-50" class="min-w-48 z-50"
:class="dropdownPosition" :class="dropdownPosition"
strong strong
> >
<DropdownSection class="[&>ul]:max-h-72"> <DropdownSection class="[&>ul]:max-h-80">
<template v-for="option in options" :key="option.value"> <DropdownItem
<li v-for="option in options"
v-if="option.disabled" :key="option.value"
class="px-2 py-1.5 text-xs font-medium text-n-slate-10 select-none" :label="option.label"
> :icon="option.icon"
{{ option.label }} @click="updateSelected(option.value)"
</li> />
<DropdownItem
v-else
:label="option.label"
:icon="option.icon"
@click="updateSelected(option.value)"
/>
</template>
</DropdownSection> </DropdownSection>
</DropdownBody> </DropdownBody>
</DropdownContainer> </DropdownContainer>
@@ -8,7 +8,7 @@ import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue'; import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue'; import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
const { options, maxChips, dropdownMaxHeight } = defineProps({ const { options, maxChips } = defineProps({
options: { options: {
type: Array, type: Array,
required: true, required: true,
@@ -17,10 +17,6 @@ const { options, maxChips, dropdownMaxHeight } = defineProps({
type: Number, type: Number,
default: 3, default: 3,
}, },
dropdownMaxHeight: {
type: String,
default: 'max-h-80',
},
}); });
const { t } = useI18n(); const { t } = useI18n();
@@ -127,7 +123,7 @@ const toggleOption = option => {
</Button> </Button>
</template> </template>
<DropdownBody class="top-0 min-w-48 z-50" strong> <DropdownBody class="top-0 min-w-48 z-50" strong>
<DropdownSection :height="dropdownMaxHeight"> <DropdownSection class="[&>ul]:max-h-80">
<DropdownItem <DropdownItem
v-for="option in options" v-for="option in options"
:key="option.id" :key="option.id"
@@ -12,12 +12,10 @@ import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
const { const {
options, options,
disableSearch, disableSearch,
disableDeselect,
placeholderIcon, placeholderIcon,
placeholder, placeholder,
placeholderTrailingIcon, placeholderTrailingIcon,
searchPlaceholder, searchPlaceholder,
dropdownMaxHeight,
} = defineProps({ } = defineProps({
options: { options: {
type: Array, type: Array,
@@ -43,14 +41,6 @@ const {
type: String, type: String,
default: '', default: '',
}, },
dropdownMaxHeight: {
type: String,
default: 'max-h-80',
},
disableDeselect: {
type: Boolean,
default: false,
},
}); });
const { t } = useI18n(); const { t } = useI18n();
@@ -73,8 +63,6 @@ const selectedItem = computed(() => {
const optionToSearch = Array.isArray(selected.value) const optionToSearch = Array.isArray(selected.value)
? selected.value[0] ? selected.value[0]
: selected.value; : selected.value;
if (!optionToSearch) return null;
// extract the selected item from the options array // extract the selected item from the options array
// this ensures that options like icon is also included // this ensures that options like icon is also included
return options.find(option => option.id === optionToSearch.id); return options.find(option => option.id === optionToSearch.id);
@@ -89,7 +77,7 @@ const toggleSelected = option => {
}; };
if (selected.value && selected.value.id === optionToToggle.id) { if (selected.value && selected.value.id === optionToToggle.id) {
if (!disableDeselect) selected.value = null; selected.value = null;
} else { } else {
selected.value = optionToToggle; selected.value = optionToToggle;
} }
@@ -136,7 +124,7 @@ const toggleSelected = option => {
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')" :placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
/> />
</div> </div>
<DropdownSection :height="dropdownMaxHeight"> <DropdownSection class="[&>ul]:max-h-80">
<template v-if="searchResults.length"> <template v-if="searchResults.length">
<DropdownItem <DropdownItem
v-for="option in searchResults" v-for="option in searchResults"
@@ -43,7 +43,6 @@ import VoiceCallBubble from './bubbles/VoiceCall.vue';
import MessageError from './MessageError.vue'; import MessageError from './MessageError.vue';
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue'; import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
import { useBranding } from 'shared/composables/useBranding';
/** /**
* @typedef {Object} Attachment * @typedef {Object} Attachment
@@ -144,7 +143,6 @@ const { t } = useI18n();
const route = useRoute(); const route = useRoute();
const inboxGetter = useMapGetter('inboxes/getInbox'); const inboxGetter = useMapGetter('inboxes/getInbox');
const inbox = computed(() => inboxGetter.value(props.inboxId) || {}); const inbox = computed(() => inboxGetter.value(props.inboxId) || {});
const { replaceInstallationName } = useBranding();
/** /**
* Computes the message variant based on props * Computes the message variant based on props
@@ -391,17 +389,13 @@ const shouldRenderMessage = computed(() => {
const isUnsupported = props.contentAttributes?.isUnsupported; const isUnsupported = props.contentAttributes?.isUnsupported;
const isAnIntegrationMessage = const isAnIntegrationMessage =
props.contentType === CONTENT_TYPES.INTEGRATIONS; props.contentType === CONTENT_TYPES.INTEGRATIONS;
const isFailedMessage = props.status === MESSAGE_STATUS.FAILED;
const hasExternalError = !!props.contentAttributes?.externalError;
return ( return (
hasAttachments || hasAttachments ||
props.content || props.content ||
isEmailContentType || isEmailContentType ||
isUnsupported || isUnsupported ||
isAnIntegrationMessage || isAnIntegrationMessage
isFailedMessage ||
hasExternalError
); );
}); });
@@ -478,7 +472,7 @@ const avatarInfo = computed(() => {
const avatarTooltip = computed(() => { const avatarTooltip = computed(() => {
if (props.contentAttributes?.externalEcho) { if (props.contentAttributes?.externalEcho) {
return replaceInstallationName(t('CONVERSATION.NATIVE_APP_ADVISORY')); return t('CONVERSATION.NATIVE_APP_ADVISORY');
} }
if (avatarInfo.value.name === '') return ''; if (avatarInfo.value.name === '') return '';
return `${t('CONVERSATION.SENT_BY')} ${avatarInfo.value.name}`; return `${t('CONVERSATION.SENT_BY')} ${avatarInfo.value.name}`;
@@ -12,16 +12,11 @@ defineProps({
const emit = defineEmits(['retry']); const emit = defineEmits(['retry']);
const { orientation, status, createdAt, content, attachments } = const { orientation, status, createdAt } = useMessageContext();
useMessageContext();
const { t } = useI18n(); const { t } = useI18n();
const canRetry = computed(() => { const canRetry = computed(() => !hasOneDayPassed(createdAt.value));
const hasContent = content.value !== null;
const hasAttachments = attachments.value && attachments.value.length > 0;
return !hasOneDayPassed(createdAt.value) && (hasContent || hasAttachments);
});
</script> </script>
<template> <template>
@@ -8,7 +8,6 @@ import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts'; import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import { vOnClickOutside } from '@vueuse/components'; import { vOnClickOutside } from '@vueuse/components';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useWindowSize, useEventListener } from '@vueuse/core'; import { useWindowSize, useEventListener } from '@vueuse/core';
import { emitter } from 'shared/helpers/mitt'; import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents'; import { BUS_EVENTS } from 'shared/constants/busEvents';
@@ -51,18 +50,6 @@ const isRTL = useMapGetter('accounts/isRTL');
const { width: windowWidth } = useWindowSize(); const { width: windowWidth } = useWindowSize();
const isMobile = computed(() => windowWidth.value < 768); const isMobile = computed(() => windowWidth.value < 768);
const accountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const hasAdvancedAssignment = computed(() => {
return isFeatureEnabledonAccount.value(
accountId.value,
FEATURE_FLAGS.ADVANCED_ASSIGNMENT
);
});
const toggleShortcutModalFn = show => { const toggleShortcutModalFn = show => {
if (show) { if (show) {
emit('openKeyShortcutModal'); emit('openKeyShortcutModal');
@@ -597,16 +584,12 @@ const menuItems = computed(() => {
icon: 'i-lucide-users', icon: 'i-lucide-users',
to: accountScopedRoute('settings_teams_list'), to: accountScopedRoute('settings_teams_list'),
}, },
...(hasAdvancedAssignment.value {
? [ name: 'Settings Agent Assignment',
{ label: t('SIDEBAR.AGENT_ASSIGNMENT'),
name: 'Settings Agent Assignment', icon: 'i-lucide-user-cog',
label: t('SIDEBAR.AGENT_ASSIGNMENT'), to: accountScopedRoute('assignment_policy_index'),
icon: 'i-lucide-user-cog', },
to: accountScopedRoute('assignment_policy_index'),
},
]
: []),
{ {
name: 'Settings Inboxes', name: 'Settings Inboxes',
label: t('SIDEBAR.INBOXES'), label: t('SIDEBAR.INBOXES'),
@@ -36,11 +36,6 @@ const props = defineProps({
}, },
focusOnMount: { type: Boolean, default: false }, focusOnMount: { type: Boolean, default: false },
allowCreate: { type: Boolean, default: false }, allowCreate: { type: Boolean, default: false },
// Skip label-based dedup when the consumer already filters menuItems by ID.
// Prevents removing all same-name items when one is selected (e.g. duplicate agent names).
skipLabelDedup: { type: Boolean, default: false },
// When false, the dropdown won't auto-open on mount; it opens only on click/focus.
autoOpenDropdown: { type: Boolean, default: true },
}); });
const emit = defineEmits([ const emit = defineEmits([
@@ -61,7 +56,7 @@ const modelValue = defineModel({
const tagInputRef = ref(null); const tagInputRef = ref(null);
const tags = ref(props.modelValue); const tags = ref(props.modelValue);
const newTag = ref(''); const newTag = ref('');
const isFocused = ref(props.autoOpenDropdown); const isFocused = ref(true);
const rules = computed(() => getValidationRules(props.type)); const rules = computed(() => getValidationRules(props.type));
const v$ = useVuelidate(rules, { newTag }); const v$ = useVuelidate(rules, { newTag });
@@ -79,11 +74,11 @@ const showInput = computed(() =>
const showDropdownMenu = computed(() => const showDropdownMenu = computed(() =>
props.mode === MODE.SINGLE && tags.value.length >= 1 props.mode === MODE.SINGLE && tags.value.length >= 1
? false ? false
: props.showDropdown && isFocused.value : props.showDropdown
); );
const filteredMenuItems = computed(() => { const filteredMenuItems = computed(() =>
const items = buildTagMenuItems({ buildTagMenuItems({
mode: props.mode, mode: props.mode,
tags: tags.value, tags: tags.value,
menuItems: props.menuItems, menuItems: props.menuItems,
@@ -91,14 +86,8 @@ const filteredMenuItems = computed(() => {
isLoading: props.isLoading, isLoading: props.isLoading,
type: props.type, type: props.type,
isNewTagInValidType: isNewTagInValidType.value, isNewTagInValidType: isNewTagInValidType.value,
allowCreate: props.allowCreate, })
skipLabelDedup: props.skipLabelDedup, );
});
if (props.type !== INPUT_TYPES.TEXT) return items;
const query = newTag.value?.trim()?.toLowerCase();
if (!query) return items;
return items.filter(item => item.label?.toLowerCase().includes(query));
});
const emitDataOnAdd = value => { const emitDataOnAdd = value => {
const matchingMenuItem = findMatchingMenuItem(props.menuItems, value); const matchingMenuItem = findMatchingMenuItem(props.menuItems, value);
@@ -123,13 +112,10 @@ const addTag = async () => {
return; return;
} }
const isValidatedType = [INPUT_TYPES.EMAIL, INPUT_TYPES.TEL].includes( if (
props.type [INPUT_TYPES.EMAIL, INPUT_TYPES.TEL].includes(props.type) ||
); props.allowCreate
) {
if (!isValidatedType && !props.allowCreate && props.showDropdown) return;
if (isValidatedType || props.allowCreate) {
if (!(await v$.value.$validate())) return; if (!(await v$.value.$validate())) return;
emitDataOnAdd(trimmedTag); emitDataOnAdd(trimmedTag);
} }
@@ -139,31 +125,28 @@ const addTag = async () => {
const removeTag = index => { const removeTag = index => {
tags.value.splice(index, 1); tags.value.splice(index, 1);
modelValue.value = tags.value; modelValue.value = tags.value;
emit('remove', index); emit('remove');
}; };
const handleDropdownAction = async ({ const handleDropdownAction = async ({
email: emailAddress, email: emailAddress,
phoneNumber, phoneNumber,
label,
...rest ...rest
}) => { }) => {
if (props.mode === MODE.SINGLE && tags.value.length >= 1) return; if (props.mode === MODE.SINGLE && tags.value.length >= 1) return;
if (!props.showDropdown) return; if (!props.showDropdown) return;
const isEmail = props.type === INPUT_TYPES.EMAIL; const isEmail = props.type === 'email';
const tagValue = isEmail ? emailAddress : phoneNumber || label; newTag.value = isEmail ? emailAddress : phoneNumber;
if (isEmail || props.type === INPUT_TYPES.TEL) { if (!(await v$.value.$validate())) return;
newTag.value = tagValue;
if (!(await v$.value.$validate())) return;
}
emit( const payload = isEmail
'add', ? { email: emailAddress, ...rest }
isEmail ? { email: emailAddress, ...rest } : { phoneNumber, label, ...rest } : { phoneNumber, ...rest };
);
updateValueAndFocus(tagValue); emit('add', payload);
updateValueAndFocus(emailAddress);
}; };
const handleFocus = () => { const handleFocus = () => {
@@ -180,7 +163,7 @@ const handleKeydown = event => {
}; };
const handleClickOutside = () => { const handleClickOutside = () => {
isFocused.value = false; if (tags.value.length) isFocused.value = false;
emit('onClickOutside'); emit('onClickOutside');
}; };
@@ -261,71 +261,6 @@ describe('tagInputHelper', () => {
}); });
expect(result).toEqual(menuItems); expect(result).toEqual(menuItems);
}); });
it('does not create suggestion when allowCreate is false', () => {
const result = buildTagMenuItems({
...baseParams,
type: INPUT_TYPES.EMAIL,
newTag: 'test@example.com',
allowCreate: false,
});
expect(result).toEqual([]);
});
it('still returns available menu items when allowCreate is false', () => {
const menuItems = [
{ label: 'Agent 1', value: '1' },
{ label: 'Agent 2', value: '2' },
];
const result = buildTagMenuItems({
...baseParams,
menuItems,
newTag: 'Agent',
allowCreate: false,
});
expect(result).toEqual(menuItems);
});
it('creates new item when allowCreate is true (default)', () => {
const result = buildTagMenuItems({
...baseParams,
type: INPUT_TYPES.EMAIL,
newTag: 'test@example.com',
allowCreate: true,
});
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
label: 'test@example.com',
action: 'create',
});
});
it('skips label dedup when skipLabelDedup is true', () => {
const menuItems = [
{ label: 'HDMA', value: 1 },
{ label: 'HDMA', value: 2 },
];
const result = buildTagMenuItems({
...baseParams,
tags: ['HDMA'],
menuItems,
skipLabelDedup: true,
});
expect(result).toEqual(menuItems);
});
it('filters by label when skipLabelDedup is false (default)', () => {
const menuItems = [
{ label: 'HDMA', value: 1 },
{ label: 'HDMA', value: 2 },
];
const result = buildTagMenuItems({
...baseParams,
tags: ['HDMA'],
menuItems,
});
expect(result).toEqual([]);
});
}); });
describe('canAddTag', () => { describe('canAddTag', () => {
@@ -84,29 +84,25 @@ export const buildTagMenuItems = ({
isLoading, isLoading,
type, type,
isNewTagInValidType, isNewTagInValidType,
allowCreate = true,
skipLabelDedup = false,
}) => { }) => {
if (mode === MODE.SINGLE && tags.length >= 1) return []; if (mode === MODE.SINGLE && tags.length >= 1) return [];
const availableMenuItems = skipLabelDedup const availableMenuItems = menuItems.filter(
? menuItems item => !tags.includes(item.label)
: menuItems.filter(item => !tags.includes(item.label)); );
// Show typed value as suggestion only if: // Show typed value as suggestion only if:
// 1. There's a value being typed // 1. There's a value being typed
// 2. The value isn't already in the tags // 2. The value isn't already in the tags
// 3. Validation passes (email/phone) and There are no menu items available // 3. Validation passes (email/phone) and There are no menu items available
// 4. allowCreate is enabled
const trimmedNewTag = newTag?.trim(); const trimmedNewTag = newTag?.trim();
const shouldShowCreateSuggestion = const shouldShowTypedValue =
allowCreate &&
trimmedNewTag && trimmedNewTag &&
!tags.includes(trimmedNewTag) && !tags.includes(trimmedNewTag) &&
!isLoading && !isLoading &&
!availableMenuItems.length; !availableMenuItems.length;
if (shouldShowCreateSuggestion) { if (shouldShowTypedValue) {
const { isValid, formattedValue } = validateAndFormatNewTag( const { isValid, formattedValue } = validateAndFormatNewTag(
trimmedNewTag, trimmedNewTag,
type, type,
@@ -145,7 +145,6 @@ const {
isConversationSelected, isConversationSelected,
onAssignAgent, onAssignAgent,
onAssignLabels, onAssignLabels,
onRemoveLabels,
onAssignTeamsForBulk, onAssignTeamsForBulk,
onUpdateConversations, onUpdateConversations,
} = useBulkActions(); } = useBulkActions();
@@ -860,7 +859,6 @@ provide('deSelectConversation', deSelectConversation);
provide('assignAgent', onAssignAgent); provide('assignAgent', onAssignAgent);
provide('assignTeam', onAssignTeam); provide('assignTeam', onAssignTeam);
provide('assignLabels', onAssignLabels); provide('assignLabels', onAssignLabels);
provide('removeLabels', onRemoveLabels);
provide('updateConversationStatus', handleResolveConversation); provide('updateConversationStatus', handleResolveConversation);
provide('toggleContextMenu', onContextMenuToggle); provide('toggleContextMenu', onContextMenuToggle);
provide('markAsUnread', markAsUnread); provide('markAsUnread', markAsUnread);
@@ -10,7 +10,6 @@ export default {
'assignAgent', 'assignAgent',
'assignTeam', 'assignTeam',
'assignLabels', 'assignLabels',
'removeLabels',
'updateConversationStatus', 'updateConversationStatus',
'toggleContextMenu', 'toggleContextMenu',
'markAsUnread', 'markAsUnread',
@@ -64,7 +63,6 @@ export default {
@assign-agent="assignAgent" @assign-agent="assignAgent"
@assign-team="assignTeam" @assign-team="assignTeam"
@assign-label="assignLabels" @assign-label="assignLabels"
@remove-label="removeLabels"
@update-conversation-status="updateConversationStatus" @update-conversation-status="updateConversationStatus"
@context-menu-toggle="toggleContextMenu" @context-menu-toggle="toggleContextMenu"
@mark-as-unread="markAsUnread" @mark-as-unread="markAsUnread"
@@ -3,9 +3,6 @@ import AutomationActionTeamMessageInput from './AutomationActionTeamMessageInput
import AutomationActionFileInput from './AutomationFileInput.vue'; import AutomationActionFileInput from './AutomationFileInput.vue';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue'; import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import SingleSelect from 'dashboard/components-next/filter/inputs/SingleSelect.vue';
import MultiSelect from 'dashboard/components-next/filter/inputs/MultiSelect.vue';
import NextInput from 'dashboard/components-next/input/Input.vue';
export default { export default {
components: { components: {
@@ -13,9 +10,6 @@ export default {
AutomationActionFileInput, AutomationActionFileInput,
WootMessageEditor, WootMessageEditor,
NextButton, NextButton,
SingleSelect,
MultiSelect,
NextInput,
}, },
props: { props: {
modelValue: { modelValue: {
@@ -46,10 +40,6 @@ export default {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
dropdownMaxHeight: {
type: String,
default: 'max-h-80',
},
}, },
emits: ['update:modelValue', 'input', 'removeAction', 'resetAction'], emits: ['update:modelValue', 'input', 'removeAction', 'resetAction'],
computed: { computed: {
@@ -79,21 +69,11 @@ export default {
return this.actionTypes.find(action => action.key === this.action_name) return this.actionTypes.find(action => action.key === this.action_name)
.inputType; .inputType;
}, },
actionNameAsSelectModel: { actionInputStyles() {
get() { return {
if (!this.action_name) return null; 'has-error': this.errorMessage,
const found = this.actionTypes.find(a => a.key === this.action_name); 'is-a-macro': this.isMacro,
return found ? { id: found.key, name: found.label } : null; };
},
set(value) {
this.action_name = value?.id || value;
},
},
actionTypesAsOptions() {
return this.actionTypes.map(a => ({ id: a.key, name: a.label }));
},
isVerticalLayout() {
return ['team_message', 'textarea'].includes(this.inputType);
}, },
castMessageVmodel: { castMessageVmodel: {
get() { get() {
@@ -114,89 +94,203 @@ export default {
resetAction() { resetAction() {
this.$emit('resetAction'); this.$emit('resetAction');
}, },
onActionNameChange(value) {
this.actionNameAsSelectModel = value;
this.resetAction();
},
}, },
}; };
</script> </script>
<template> <template>
<li class="list-none py-2 first:pt-0 last:pb-0"> <div class="filter" :class="actionInputStyles">
<div <div class="filter-inputs">
class="flex flex-col gap-2" <select
:class="{ 'animate-wiggle': errorMessage }" v-model="action_name"
> class="action__question"
<div class="flex items-center gap-2"> :class="{ 'full-width': !showActionInput }"
<SingleSelect @change="resetAction()"
:model-value="actionNameAsSelectModel" >
:options="actionTypesAsOptions" <option
:dropdown-max-height="dropdownMaxHeight" v-for="attribute in actionTypes"
disable-deselect :key="attribute.key"
class="flex-shrink-0" :value="attribute.key"
@update:model-value="onActionNameChange" >
/> {{ attribute.label }}
<template v-if="showActionInput && !isVerticalLayout"> </option>
<SingleSelect </select>
<div v-if="showActionInput" class="filter__answer--wrap">
<div v-if="inputType" class="w-full">
<div
v-if="inputType === 'search_select'" v-if="inputType === 'search_select'"
v-model="action_params" class="multiselect-wrap--small"
:options="dropdownValues" >
:dropdown-max-height="dropdownMaxHeight" <multiselect
/> v-model="action_params"
<MultiSelect track-by="id"
label="name"
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label=""
:max-height="160"
:options="dropdownValues"
:allow-empty="false"
:option-height="104"
>
<template #noOptions>
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
</template>
</multiselect>
</div>
<div
v-else-if="inputType === 'multi_select'" v-else-if="inputType === 'multi_select'"
v-model="action_params" class="multiselect-wrap--small"
:options="dropdownValues" >
:dropdown-max-height="dropdownMaxHeight" <multiselect
/> v-model="action_params"
<NextInput track-by="id"
label="name"
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
multiple
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label=""
:max-height="160"
:options="dropdownValues"
:allow-empty="false"
:option-height="104"
>
<template #noOptions>
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
</template>
</multiselect>
</div>
<input
v-else-if="inputType === 'email'" v-else-if="inputType === 'email'"
v-model="action_params" v-model="action_params"
type="email" type="email"
size="sm" class="answer--text-input"
:placeholder="$t('AUTOMATION.ACTION.EMAIL_INPUT_PLACEHOLDER')" :placeholder="$t('AUTOMATION.ACTION.EMAIL_INPUT_PLACEHOLDER')"
/> />
<NextInput <input
v-else-if="inputType === 'url'" v-else-if="inputType === 'url'"
v-model="action_params" v-model="action_params"
type="url" type="url"
size="sm" class="answer--text-input"
:placeholder="$t('AUTOMATION.ACTION.URL_INPUT_PLACEHOLDER')" :placeholder="$t('AUTOMATION.ACTION.URL_INPUT_PLACEHOLDER')"
/> />
<AutomationActionFileInput <AutomationActionFileInput
v-else-if="inputType === 'attachment'" v-if="inputType === 'attachment'"
v-model="action_params" v-model="action_params"
:initial-file-name="initialFileName" :initial-file-name="initialFileName"
/> />
</template> </div>
<NextButton
v-if="!isMacro"
sm
solid
slate
icon="i-lucide-trash"
class="flex-shrink-0"
@click="removeAction"
/>
</div> </div>
<AutomationActionTeamMessageInput <NextButton
v-if="inputType === 'team_message'" v-if="!isMacro"
v-model="action_params" icon="i-lucide-x"
:teams="dropdownValues" slate
:dropdown-max-height="dropdownMaxHeight" ghost
/> class="flex-shrink-0"
<WootMessageEditor @click="removeAction"
v-if="inputType === 'textarea'"
v-model="castMessageVmodel"
rows="4"
enable-variables
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
class="[&_.ProseMirror-menubar]:hidden px-3 py-1 bg-n-alpha-1 rounded-lg outline outline-1 outline-n-weak dark:outline-n-strong"
/> />
</div> </div>
<span v-if="errorMessage" class="text-sm text-n-ruby-11"> <AutomationActionTeamMessageInput
v-if="inputType === 'team_message'"
v-model="action_params"
:teams="dropdownValues"
/>
<WootMessageEditor
v-if="inputType === 'textarea'"
v-model="castMessageVmodel"
rows="4"
enable-variables
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
class="action-message"
/>
<p v-if="errorMessage" class="filter-error">
{{ errorMessage }} {{ errorMessage }}
</span> </p>
</li> </div>
</template> </template>
<style lang="scss" scoped>
.filter {
@apply bg-n-background p-2 border border-solid border-n-strong dark:border-n-strong rounded-lg mb-2;
&.is-a-macro {
@apply mb-0 bg-n-background dark:bg-n-solid-1 p-0 border-0 rounded-none;
}
}
.no-margin-bottom {
@apply mb-0;
}
.filter.has-error {
@apply bg-n-ruby-8/20 border-n-ruby-5 dark:border-n-ruby-5;
&.is-a-macro {
@apply bg-transparent;
}
}
.filter-inputs {
@apply flex gap-1;
}
.filter-error {
@apply text-n-ruby-9 dark:text-n-ruby-9 block my-1 mx-0;
}
.action__question,
.filter__operator {
@apply mb-0 mr-1;
}
.action__question {
@apply max-w-[50%];
}
.action__question.full-width {
@apply max-w-full;
}
.filter__answer--wrap {
@apply max-w-[50%] flex-grow mr-1 flex w-full items-center justify-start;
input {
@apply mb-0;
}
}
.filter__answer {
&.answer--text-input {
@apply mb-0;
}
}
.filter__join-operator-wrap {
@apply relative z-20 m-0;
}
.filter__join-operator {
@apply flex items-center justify-center relative my-2.5 mx-0;
.operator__line {
@apply absolute w-full border-b border-solid border-n-weak;
}
.operator__select {
margin-bottom: 0 !important;
@apply relative w-auto;
}
}
.multiselect {
@apply mb-0;
}
.action-message {
@apply mt-2 mx-0 mb-0;
}
// Prosemirror does not have a native way of hiding the menu bar, hence
::v-deep .ProseMirror-menubar {
@apply hidden;
}
</style>
@@ -1,14 +1,8 @@
<script> <script>
import MultiSelect from 'dashboard/components-next/filter/inputs/MultiSelect.vue';
export default { export default {
components: {
MultiSelect,
},
props: { props: {
teams: { type: Array, required: true }, teams: { type: Array, required: true },
modelValue: { type: Object, required: true }, modelValue: { type: Object, required: true },
dropdownMaxHeight: { type: String, default: 'max-h-80' },
}, },
emits: ['update:modelValue'], emits: ['update:modelValue'],
data() { data() {
@@ -18,9 +12,9 @@ export default {
}; };
}, },
mounted() { mounted() {
const { team_ids: teamIds, message } = this.modelValue || {}; const { team_ids: teamIds } = this.modelValue;
this.selectedTeams = teamIds || []; this.selectedTeams = teamIds;
this.message = message || ''; this.message = this.modelValue.message;
}, },
methods: { methods: {
updateValue() { updateValue() {
@@ -34,19 +28,37 @@ export default {
</script> </script>
<template> <template>
<div class="flex flex-col gap-2"> <div>
<MultiSelect <div class="multiselect-wrap--small flex flex-col gap-1 mt-1">
v-model="selectedTeams" <multiselect
:options="teams" v-model="selectedTeams"
:dropdown-max-height="dropdownMaxHeight" track-by="id"
@update:model-value="updateValue" label="name"
/> :placeholder="$t('AUTOMATION.ACTION.TEAM_DROPDOWN_PLACEHOLDER')"
<textarea multiple
v-model="message" selected-label
class="mb-0 !text-sm" :select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
rows="4" deselect-label=""
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')" :max-height="160"
@input="updateValue" :options="teams"
/> :allow-empty="false"
@update:model-value="updateValue"
/>
<textarea
v-model="message"
rows="4"
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
@input="updateValue"
/>
</div>
</div> </div>
</template> </template>
<style scoped>
.multiselect {
margin: 0.25rem 0;
}
textarea {
margin-bottom: 0;
}
</style>
@@ -79,7 +79,7 @@ input[type='file'] {
@apply hidden; @apply hidden;
} }
.input-wrapper { .input-wrapper {
@apply flex h-8 bg-n-background py-1 px-2 items-center text-xs cursor-pointer rounded-lg border border-dashed border-n-strong; @apply flex h-9 bg-n-background py-1 px-2 items-center text-xs cursor-pointer rounded-sm border border-dashed border-n-strong;
} }
.success-icon { .success-icon {
@apply text-n-teal-9 mr-2; @apply text-n-teal-9 mr-2;
@@ -0,0 +1,305 @@
<script>
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
name: 'FilterInput',
components: {
NextButton,
},
props: {
modelValue: {
type: Object,
default: () => {},
},
filterAttributes: {
type: Array,
default: () => [],
},
inputType: {
type: String,
default: 'plain_text',
},
operators: {
type: Array,
default: () => [],
},
dropdownValues: {
type: Array,
default: () => [],
},
showQueryOperator: {
type: Boolean,
default: false,
},
showUserInput: {
type: Boolean,
default: true,
},
groupedFilters: {
type: Boolean,
default: false,
},
filterGroups: {
type: Array,
default: () => [],
},
customAttributeType: {
type: String,
default: '',
},
errorMessage: {
type: String,
default: '',
},
},
emits: ['update:modelValue', 'removeFilter', 'resetFilter'],
computed: {
attributeKey: {
get() {
if (!this.modelValue) return null;
return this.modelValue.attribute_key;
},
set(value) {
const payload = this.modelValue || {};
this.$emit('update:modelValue', { ...payload, attribute_key: value });
},
},
filterOperator: {
get() {
if (!this.modelValue) return null;
return this.modelValue.filter_operator;
},
set(value) {
const payload = this.modelValue || {};
this.$emit('update:modelValue', { ...payload, filter_operator: value });
},
},
values: {
get() {
if (!this.modelValue) return null;
return this.modelValue.values;
},
set(value) {
const payload = this.modelValue || {};
this.$emit('update:modelValue', { ...payload, values: value });
},
},
query_operator: {
get() {
if (!this.modelValue) return null;
return this.modelValue.query_operator;
},
set(value) {
const payload = this.modelValue || {};
this.$emit('update:modelValue', { ...payload, query_operator: value });
},
},
custom_attribute_type: {
get() {
if (!this.customAttributeType) return '';
return this.customAttributeType;
},
set() {
const payload = this.modelValue || {};
this.$emit('update:modelValue', {
...payload,
custom_attribute_type: this.customAttributeType,
});
},
},
},
watch: {
customAttributeType: {
handler(value) {
if (
value === 'conversation_attribute' ||
value === 'contact_attribute'
) {
// eslint-disable-next-line vue/no-mutating-props
this.modelValue.custom_attribute_type = this.customAttributeType;
// eslint-disable-next-line vue/no-mutating-props
} else this.modelValue.custom_attribute_type = '';
},
immediate: true,
},
},
methods: {
removeFilter() {
this.$emit('removeFilter');
},
resetFilter() {
this.$emit('resetFilter');
},
getInputErrorClass(errorMessage) {
return errorMessage
? 'bg-n-ruby-8/20 border-n-ruby-5 dark:border-n-ruby-5'
: 'bg-n-background border-n-weak dark:border-n-weak';
},
},
};
</script>
<!-- eslint-disable vue/no-mutating-props -->
<template>
<div>
<div
class="p-2 border border-solid rounded-lg"
:class="getInputErrorClass(errorMessage)"
>
<div class="flex gap-1">
<select
v-if="groupedFilters"
v-model="attributeKey"
class="max-w-[30%] mb-0 mr-1"
@change="resetFilter()"
>
<optgroup
v-for="(group, i) in filterGroups"
:key="i"
:label="group.name"
>
<option
v-for="attribute in group.attributes"
:key="attribute.key"
:value="attribute.key"
:selected="true"
>
{{ attribute.name }}
</option>
</optgroup>
</select>
<select
v-else
v-model="attributeKey"
class="max-w-[30%] mb-0 mr-1"
@change="resetFilter()"
>
<option
v-for="attribute in filterAttributes"
:key="attribute.key"
:value="attribute.key"
:disabled="attribute.disabled"
>
{{ attribute.name }}
</option>
</select>
<select v-model="filterOperator" class="max-w-[20%] mb-0 mr-1">
<option
v-for="(operator, o) in operators"
:key="o"
:value="operator.value"
>
{{ $t(`FILTER.OPERATOR_LABELS.${operator.value}`) }}
</option>
</select>
<div v-if="showUserInput" class="flex-grow mr-1 filter__answer--wrap">
<div
v-if="inputType === 'multi_select'"
class="multiselect-wrap--small"
>
<multiselect
v-model="values"
track-by="id"
label="name"
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
multiple
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label=""
:max-height="160"
:options="dropdownValues"
:allow-empty="false"
>
<template #noOptions>
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
</template>
</multiselect>
</div>
<div
v-else-if="inputType === 'search_select'"
class="multiselect-wrap--small"
>
<multiselect
v-model="values"
track-by="id"
label="name"
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label=""
:max-height="160"
:options="dropdownValues"
:allow-empty="false"
:option-height="104"
>
<template #noOptions>
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
</template>
</multiselect>
</div>
<div v-else-if="inputType === 'date'" class="multiselect-wrap--small">
<input
v-model="values"
type="date"
:editable="false"
class="!mb-0 datepicker"
/>
</div>
<input
v-else
v-model="values"
type="text"
class="!mb-0"
:placeholder="$t('FILTER.INPUT_PLACEHOLDER')"
/>
</div>
<NextButton
icon="i-lucide-x"
slate
ghost
class="flex-shrink-0"
@click="removeFilter"
/>
</div>
<p v-if="errorMessage" class="filter-error">
{{ errorMessage }}
</p>
</div>
<div
v-if="showQueryOperator"
class="flex items-center justify-center relative my-2.5 mx-0"
>
<hr class="absolute w-full border-b border-solid border-n-weak" />
<select
v-model="query_operator"
class="relative w-auto mb-0 bg-n-background text-n-slate-12 border-n-weak"
>
<option value="and">
{{ $t('FILTER.QUERY_DROPDOWN_LABELS.AND') }}
</option>
<option value="or">
{{ $t('FILTER.QUERY_DROPDOWN_LABELS.OR') }}
</option>
</select>
</div>
</div>
</template>
<style lang="scss" scoped>
.filter__answer--wrap {
input {
@apply bg-n-background mb-0 text-n-slate-12 border-n-weak;
}
}
.filter-error {
@apply text-n-ruby-9 dark:text-n-ruby-9 block my-1 mx-0;
}
.multiselect {
@apply mb-0;
}
</style>
@@ -28,10 +28,7 @@ import { useAlert } from 'dashboard/composables';
import { vOnClickOutside } from '@vueuse/components'; import { vOnClickOutside } from '@vueuse/components';
import { BUS_EVENTS } from 'shared/constants/busEvents'; import { BUS_EVENTS } from 'shared/constants/busEvents';
import { import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
CONVERSATION_EVENTS,
CAPTAIN_EVENTS,
} from 'dashboard/helper/AnalyticsHelper/events';
import { MESSAGE_EDITOR_IMAGE_RESIZES } from 'dashboard/constants/editor'; import { MESSAGE_EDITOR_IMAGE_RESIZES } from 'dashboard/constants/editor';
import { import {
@@ -89,7 +86,6 @@ const props = defineProps({
// are triggered except when this flag is true // are triggered except when this flag is true
allowSignature: { type: Boolean, default: false }, allowSignature: { type: Boolean, default: false },
channelType: { type: String, default: '' }, channelType: { type: String, default: '' },
conversationId: { type: Number, default: null },
medium: { type: String, default: '' }, medium: { type: String, default: '' },
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
focusOnMount: { type: Boolean, default: true }, focusOnMount: { type: Boolean, default: true },
@@ -400,14 +396,7 @@ function openFileBrowser() {
} }
function handleCopilotClick() { function handleCopilotClick() {
const isOpening = !showSelectionMenu.value; showSelectionMenu.value = !showSelectionMenu.value;
if (isOpening) {
useTrack(CAPTAIN_EVENTS.EDITOR_AI_MENU_OPENED, {
conversationId: props.conversationId,
entryPoint: 'inline',
});
}
showSelectionMenu.value = isOpening;
} }
function handleClickOutside(event) { function handleClickOutside(event) {
@@ -830,13 +819,7 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
</script> </script>
<template> <template>
<div <div ref="editorRoot" class="relative w-full">
ref="editorRoot"
class="relative w-full"
:class="{
'opacity-50 cursor-not-allowed pointer-events-none': disabled,
}"
>
<TagAgents <TagAgents
v-if="showUserMentions && isPrivate" v-if="showUserMentions && isPrivate"
:search-key="mentionSearchKey" :search-key="mentionSearchKey"
@@ -122,10 +122,6 @@ export default {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isEditorDisabled: {
type: Boolean,
default: false,
},
}, },
emits: [ emits: [
'replaceText', 'replaceText',
@@ -134,7 +130,7 @@ export default {
'selectContentTemplate', 'selectContentTemplate',
'toggleQuotedReply', 'toggleQuotedReply',
], ],
setup(props) { setup() {
const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } = const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } =
useUISettings(); useUISettings();
@@ -143,9 +139,6 @@ export default {
const keyboardEvents = { const keyboardEvents = {
'$mod+Alt+KeyA': { '$mod+Alt+KeyA': {
action: () => { action: () => {
// Skip if editor is disabled (e.g., WhatsApp 24-hour window expired)
if (props.isEditorDisabled) return;
// TODO: This is really hacky, we need to replace the file picker component with // TODO: This is really hacky, we need to replace the file picker component with
// a custom one, where the logic and the component markup is isolated. // a custom one, where the logic and the component markup is isolated.
// Once we have the custom component, we can remove the hacky logic below. // Once we have the custom component, we can remove the hacky logic below.
@@ -153,7 +146,7 @@ export default {
const uploadTriggerButton = document.querySelector( const uploadTriggerButton = document.querySelector(
'#conversationAttachment' '#conversationAttachment'
); );
if (uploadTriggerButton) uploadTriggerButton.click(); uploadTriggerButton.click();
}, },
allowOnFocusedInput: true, allowOnFocusedInput: true,
}, },
@@ -184,11 +177,9 @@ export default {
}; };
}, },
showAttachButton() { showAttachButton() {
if (this.isEditorDisabled) return false;
return this.showFileUpload || this.isNote; return this.showFileUpload || this.isNote;
}, },
showAudioRecorderButton() { showAudioRecorderButton() {
if (this.isEditorDisabled) return false;
if (this.isALineChannel) { if (this.isALineChannel) {
return false; return false;
} }
@@ -206,7 +197,6 @@ export default {
); );
}, },
showAudioPlayStopButton() { showAudioPlayStopButton() {
if (this.isEditorDisabled) return false;
return this.showAudioRecorder && this.isRecordingAudio; return this.showAudioRecorder && this.isRecordingAudio;
}, },
isInstagramDM() { isInstagramDM() {
@@ -246,7 +236,6 @@ export default {
} }
}, },
showMessageSignatureButton() { showMessageSignatureButton() {
if (this.isEditorDisabled) return false;
return !this.isOnPrivateNote; return !this.isOnPrivateNote;
}, },
sendWithSignature() { sendWithSignature() {
@@ -291,7 +280,6 @@ export default {
<div class="flex justify-between p-3" :class="wrapClass"> <div class="flex justify-between p-3" :class="wrapClass">
<div class="left-wrap"> <div class="left-wrap">
<NextButton <NextButton
v-if="!isEditorDisabled"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_EMOJI_ICON')" v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_EMOJI_ICON')"
icon="i-ph-smiley-sticker" icon="i-ph-smiley-sticker"
slate slate
@@ -300,7 +288,6 @@ export default {
@click="toggleEmojiPicker" @click="toggleEmojiPicker"
/> />
<FileUpload <FileUpload
v-if="showAttachButton"
ref="uploadRef" ref="uploadRef"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')" v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
input-id="conversationAttachment" input-id="conversationAttachment"
@@ -2,10 +2,8 @@
import { ref } from 'vue'; import { ref } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents'; import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useCaptain } from 'dashboard/composables/useCaptain'; import { useCaptain } from 'dashboard/composables/useCaptain';
import { useTrack } from 'dashboard/composables';
import { vOnClickOutside } from '@vueuse/components'; import { vOnClickOutside } from '@vueuse/components';
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants'; import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
import { CAPTAIN_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import EditorModeToggle from './EditorModeToggle.vue'; import EditorModeToggle from './EditorModeToggle.vue';
import CopilotMenuBar from './CopilotMenuBar.vue'; import CopilotMenuBar from './CopilotMenuBar.vue';
@@ -33,14 +31,6 @@ export default {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isEditorDisabled: {
type: Boolean,
default: false,
},
conversationId: {
type: Number,
default: null,
},
isMessageLengthReachingThreshold: { isMessageLengthReachingThreshold: {
type: Boolean, type: Boolean,
default: () => false, default: () => false,
@@ -79,14 +69,7 @@ export default {
}; };
const toggleCopilotMenu = () => { const toggleCopilotMenu = () => {
const isOpening = !showCopilotMenu.value; showCopilotMenu.value = !showCopilotMenu.value;
if (isOpening) {
useTrack(CAPTAIN_EVENTS.EDITOR_AI_MENU_OPENED, {
conversationId: props.conversationId,
entryPoint: 'top_panel',
});
}
showCopilotMenu.value = isOpening;
}; };
const handleClickOutside = () => { const handleClickOutside = () => {
@@ -161,7 +144,7 @@ export default {
<div class="relative"> <div class="relative">
<NextButton <NextButton
ghost ghost
:disabled="disabled || isEditorDisabled" :disabled="disabled"
:class="{ :class="{
'text-n-violet-9 hover:enabled:!bg-n-violet-3': !showCopilotMenu, 'text-n-violet-9 hover:enabled:!bg-n-violet-3': !showCopilotMenu,
'text-n-violet-9 bg-n-violet-3': showCopilotMenu, 'text-n-violet-9 bg-n-violet-3': showCopilotMenu,
@@ -34,7 +34,6 @@ const emit = defineEmits([
'contextMenuToggle', 'contextMenuToggle',
'assignAgent', 'assignAgent',
'assignLabel', 'assignLabel',
'removeLabel',
'assignTeam', 'assignTeam',
'markAsUnread', 'markAsUnread',
'markAsRead', 'markAsRead',
@@ -204,10 +203,7 @@ const onAssignAgent = agent => {
const onAssignLabel = label => { const onAssignLabel = label => {
emit('assignLabel', [label.title], [props.chat.id]); emit('assignLabel', [label.title], [props.chat.id]);
}; closeContextMenu();
const onRemoveLabel = label => {
emit('removeLabel', [label.title], [props.chat.id]);
}; };
const onAssignTeam = team => { const onAssignTeam = team => {
@@ -383,13 +379,11 @@ const deleteConversation = () => {
:priority="chat.priority" :priority="chat.priority"
:chat-id="chat.id" :chat-id="chat.id"
:has-unread-messages="hasUnread" :has-unread-messages="hasUnread"
:conversation-labels="chat.labels"
:conversation-url="conversationPath" :conversation-url="conversationPath"
:allowed-options="allowedContextMenuOptions" :allowed-options="allowedContextMenuOptions"
@update-conversation="onUpdateConversation" @update-conversation="onUpdateConversation"
@assign-agent="onAssignAgent" @assign-agent="onAssignAgent"
@assign-label="onAssignLabel" @assign-label="onAssignLabel"
@remove-label="onRemoveLabel"
@assign-team="onAssignTeam" @assign-team="onAssignTeam"
@mark-as-unread="markAsUnread" @mark-as-unread="markAsUnread"
@mark-as-read="markAsRead" @mark-as-read="markAsRead"
@@ -41,10 +41,7 @@ import {
truncatePreviewText, truncatePreviewText,
appendQuotedTextToMessage, appendQuotedTextToMessage,
} from 'dashboard/helper/quotedEmailHelper'; } from 'dashboard/helper/quotedEmailHelper';
import { import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
CONVERSATION_EVENTS,
CAPTAIN_EVENTS,
} from '../../../helper/AnalyticsHelper/events';
import fileUploadMixin from 'dashboard/mixins/fileUploadMixin'; import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
import { import {
appendSignature, appendSignature,
@@ -139,7 +136,6 @@ export default {
newConversationModalActive: false, newConversationModalActive: false,
showArticleSearchPopover: false, showArticleSearchPopover: false,
hasRecordedAudio: false, hasRecordedAudio: false,
copilotAcceptedMessages: {},
}; };
}, },
computed: { computed: {
@@ -199,11 +195,6 @@ export default {
return this.$store.getters['inboxes/getInbox'](this.inboxId); return this.$store.getters['inboxes/getInbox'](this.inboxId);
}, },
messagePlaceHolder() { messagePlaceHolder() {
if (this.isEditorDisabled) {
return this.isAWhatsAppChannel
? this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED_WHATSAPP')
: this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED');
}
return this.isPrivate return this.isPrivate
? this.$t('CONVERSATION.FOOTER.PRIVATE_MSG_INPUT') ? this.$t('CONVERSATION.FOOTER.PRIVATE_MSG_INPUT')
: this.$t('CONVERSATION.FOOTER.MSG_INPUT'); : this.$t('CONVERSATION.FOOTER.MSG_INPUT');
@@ -215,7 +206,6 @@ export default {
return this.maxLength - this.message.length; return this.maxLength - this.message.length;
}, },
isReplyButtonDisabled() { isReplyButtonDisabled() {
if (this.isEditorDisabled) return true;
if (this.isATwitterInbox) return true; if (this.isATwitterInbox) return true;
if (this.hasAttachments || this.hasRecordedAudio) return false; if (this.hasAttachments || this.hasRecordedAudio) return false;
@@ -424,13 +414,6 @@ export default {
isDefaultEditorMode() { isDefaultEditorMode() {
return !this.showAudioRecorderEditor && !this.copilot.isActive.value; return !this.showAudioRecorderEditor && !this.copilot.isActive.value;
}, },
isEditorDisabled() {
return (
this.isAWhatsAppChannel &&
!this.isOnPrivateNote &&
!this.currentChat.can_reply
);
},
}, },
watch: { watch: {
currentChat(conversation, oldConversation) { currentChat(conversation, oldConversation) {
@@ -525,24 +508,6 @@ export default {
emitter.off(CMD_AI_ASSIST, this.executeCopilotAction); emitter.off(CMD_AI_ASSIST, this.executeCopilotAction);
}, },
methods: { methods: {
getDraftKey(
conversationId = this.conversationIdByRoute,
replyType = this.replyType
) {
return `draft-${conversationId}-${replyType}`;
},
getCopilotAcceptedMessage(replyType = this.replyType) {
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
return this.copilotAcceptedMessages[key] || '';
},
setCopilotAcceptedMessage(message, replyType = this.replyType) {
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
this.copilotAcceptedMessages[key] = trimContent(message || '');
},
clearCopilotAcceptedMessage(replyType = this.replyType) {
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
delete this.copilotAcceptedMessages[key];
},
handleInsert(article) { handleInsert(article) {
const { url, title } = article; const { url, title } = article;
// Removing empty lines from the title // Removing empty lines from the title
@@ -594,7 +559,7 @@ export default {
}, },
saveDraft(conversationId, replyType) { saveDraft(conversationId, replyType) {
if (this.message || this.message === '') { if (this.message || this.message === '') {
const key = this.getDraftKey(conversationId, replyType); const key = `draft-${conversationId}-${replyType}`;
const draftToSave = trimContent(this.message || ''); const draftToSave = trimContent(this.message || '');
this.$store.dispatch('draftMessages/set', { this.$store.dispatch('draftMessages/set', {
@@ -609,7 +574,7 @@ export default {
}, },
getFromDraft() { getFromDraft() {
if (this.conversationIdByRoute) { if (this.conversationIdByRoute) {
const key = this.getDraftKey(); const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
const messageFromStore = const messageFromStore =
this.$store.getters['draftMessages/get'](key) || ''; this.$store.getters['draftMessages/get'](key) || '';
@@ -632,7 +597,7 @@ export default {
}, },
removeFromDraft() { removeFromDraft() {
if (this.conversationIdByRoute) { if (this.conversationIdByRoute) {
const key = this.getDraftKey(); const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
this.$store.dispatch('draftMessages/delete', { key }); this.$store.dispatch('draftMessages/delete', { key });
} }
}, },
@@ -690,9 +655,6 @@ export default {
// Don't handle paste if compose new conversation modal is open // Don't handle paste if compose new conversation modal is open
if (this.newConversationModalActive) return; if (this.newConversationModalActive) return;
// Don't handle paste if editor is disabled
if (this.isEditorDisabled) return;
// Filter valid files (non-zero size) // Filter valid files (non-zero size)
Array.from(e.clipboardData.files) Array.from(e.clipboardData.files)
.filter(file => file.size > 0) .filter(file => file.size > 0)
@@ -746,7 +708,6 @@ export default {
return; return;
} }
if (!this.showMentions) { if (!this.showMentions) {
const copilotAcceptedMessage = this.getCopilotAcceptedMessage();
const isOnWhatsApp = const isOnWhatsApp =
this.isATwilioWhatsAppChannel || this.isATwilioWhatsAppChannel ||
this.isAWhatsAppCloudChannel || this.isAWhatsAppCloudChannel ||
@@ -756,17 +717,10 @@ export default {
// This can create duplicate messages in Chatwoot. To prevent this issue, we'll handle text and attachments as separate messages. // This can create duplicate messages in Chatwoot. To prevent this issue, we'll handle text and attachments as separate messages.
const isOnInstagram = this.isAnInstagramChannel; const isOnInstagram = this.isAnInstagramChannel;
if ((isOnWhatsApp || isOnInstagram) && !this.isPrivate) { if ((isOnWhatsApp || isOnInstagram) && !this.isPrivate) {
this.sendMessageAsMultipleMessages( this.sendMessageAsMultipleMessages(this.message);
this.message,
copilotAcceptedMessage
);
} else { } else {
const messagePayload = this.getMessagePayload(this.message); const messagePayload = this.getMessagePayload(this.message);
this.sendMessage( this.sendMessage(messagePayload);
messagePayload,
this.message,
copilotAcceptedMessage
);
} }
if (!this.isPrivate) { if (!this.isPrivate) {
@@ -778,53 +732,13 @@ export default {
this.$emit('update:popOutReplyBox', false); this.$emit('update:popOutReplyBox', false);
} }
}, },
sendMessageAsMultipleMessages(message, copilotAcceptedMessage = '') { sendMessageAsMultipleMessages(message) {
const messages = this.getMultipleMessagesPayload(message); const messages = this.getMultipleMessagesPayload(message);
messages.forEach(messagePayload => { messages.forEach(messagePayload => {
this.sendMessage( this.sendMessage(messagePayload);
messagePayload,
messagePayload.message || '',
copilotAcceptedMessage
);
}); });
}, },
sendMessageAnalyticsData( sendMessageAnalyticsData(isPrivate) {
isPrivate,
{ editorMessage = '', copilotAcceptedMessage = '' } = {}
) {
const normalizeForComparison = message => {
let normalizedMessage = message || '';
if (this.sendWithSignature && this.messageSignature && !isPrivate) {
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
normalizedMessage = removeSignature(
normalizedMessage,
this.messageSignature,
effectiveChannelType
);
}
return trimContent(normalizedMessage);
};
const normalizedAcceptedMessage = normalizeForComparison(
copilotAcceptedMessage
);
const normalizedEditorMessage = normalizeForComparison(editorMessage);
if (normalizedAcceptedMessage && normalizedEditorMessage) {
useTrack(CAPTAIN_EVENTS.AI_ASSISTED_MESSAGE_SENT, {
conversationId: this.conversationIdByRoute,
channelType: this.channelType,
editedBeforeSend:
normalizedAcceptedMessage !== normalizedEditorMessage,
isPrivate,
});
}
// Analytics data for message signature is enabled or not in channels // Analytics data for message signature is enabled or not in channels
return isPrivate return isPrivate
? useTrack(CONVERSATION_EVENTS.SENT_PRIVATE_NOTE) ? useTrack(CONVERSATION_EVENTS.SENT_PRIVATE_NOTE)
@@ -858,11 +772,7 @@ export default {
this.confirmOnSendReply(); this.confirmOnSendReply();
} }
}, },
async sendMessage( async sendMessage(messagePayload) {
messagePayload,
editorMessage = '',
copilotAcceptedMessage = ''
) {
try { try {
await this.$store.dispatch( await this.$store.dispatch(
'createPendingMessageAndSend', 'createPendingMessageAndSend',
@@ -871,10 +781,7 @@ export default {
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE); emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
emitter.emit(BUS_EVENTS.MESSAGE_SENT); emitter.emit(BUS_EVENTS.MESSAGE_SENT);
this.removeFromDraft(); this.removeFromDraft();
this.sendMessageAnalyticsData(messagePayload.private, { this.sendMessageAnalyticsData(messagePayload.private);
editorMessage,
copilotAcceptedMessage,
});
} catch (error) { } catch (error) {
const errorMessage = const errorMessage =
error?.response?.data?.error || this.$t('CONVERSATION.MESSAGE_ERROR'); error?.response?.data?.error || this.$t('CONVERSATION.MESSAGE_ERROR');
@@ -948,7 +855,6 @@ export default {
}, },
clearMessage() { clearMessage() {
this.message = ''; this.message = '';
this.clearCopilotAcceptedMessage();
if (this.sendWithSignature && !this.isPrivate) { if (this.sendWithSignature && !this.isPrivate) {
// if signature is enabled, append it to the message // if signature is enabled, append it to the message
const effectiveChannelType = getEffectiveChannelType( const effectiveChannelType = getEffectiveChannelType(
@@ -1213,9 +1119,7 @@ export default {
this.$emit('update:popOutReplyBox', !this.popOutReplyBox); this.$emit('update:popOutReplyBox', !this.popOutReplyBox);
}, },
onSubmitCopilotReply() { onSubmitCopilotReply() {
const acceptedMessage = this.copilot.accept(); this.message = this.copilot.accept();
this.message = acceptedMessage;
this.setCopilotAcceptedMessage(acceptedMessage);
}, },
}, },
}; };
@@ -1226,13 +1130,11 @@ export default {
<div ref="replyEditor" class="reply-box" :class="replyBoxClass"> <div ref="replyEditor" class="reply-box" :class="replyBoxClass">
<ReplyTopPanel <ReplyTopPanel
:mode="replyType" :mode="replyType"
:conversation-id="conversationId"
:is-reply-restricted="isReplyRestricted" :is-reply-restricted="isReplyRestricted"
:disabled=" :disabled="
(copilot.isActive.value && copilot.isButtonDisabled.value) || (copilot.isActive.value && copilot.isButtonDisabled.value) ||
showAudioRecorderEditor showAudioRecorderEditor
" "
:is-editor-disabled="isEditorDisabled"
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold" :is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
:characters-remaining="charactersRemaining" :characters-remaining="charactersRemaining"
:popout-reply-box="popOutReplyBox" :popout-reply-box="popOutReplyBox"
@@ -1302,14 +1204,12 @@ export default {
<WootMessageEditor <WootMessageEditor
v-else-if="!showAudioRecorderEditor" v-else-if="!showAudioRecorderEditor"
v-model="message" v-model="message"
:conversation-id="conversationId"
:editor-id="editorStateId" :editor-id="editorStateId"
class="input popover-prosemirror-menu" class="input popover-prosemirror-menu"
:is-private="isOnPrivateNote" :is-private="isOnPrivateNote"
:placeholder="messagePlaceHolder" :placeholder="messagePlaceHolder"
:update-selection-with="updateEditorSelectionWith" :update-selection-with="updateEditorSelectionWith"
:min-height="4" :min-height="4"
:disabled="isEditorDisabled"
enable-variables enable-variables
:variables="messageVariables" :variables="messageVariables"
:signature="messageSignature" :signature="messageSignature"
@@ -1385,7 +1285,6 @@ export default {
:is-recording-audio="isRecordingAudio" :is-recording-audio="isRecordingAudio"
:is-send-disabled="isReplyButtonDisabled" :is-send-disabled="isReplyButtonDisabled"
:is-note="isPrivate" :is-note="isPrivate"
:is-editor-disabled="isEditorDisabled"
:on-file-upload="onFileUpload" :on-file-upload="onFileUpload"
:on-send="onSendReply" :on-send="onSendReply"
:conversation-type="conversationType" :conversation-type="conversationType"
@@ -31,10 +31,7 @@ const assignedAgent = computed({
}, },
set(agent) { set(agent) {
const agentId = agent ? agent.id : null; const agentId = agent ? agent.id : null;
store.dispatch('setCurrentChatAssignee', { store.dispatch('setCurrentChatAssignee', agent);
conversationId: currentChat.value?.id,
assignee: agent,
});
store.dispatch('assignAgent', { store.dispatch('assignAgent', {
conversationId: currentChat.value?.id, conversationId: currentChat.value?.id,
agentId, agentId,
@@ -53,10 +53,6 @@ export default {
type: String, type: String,
default: null, default: null,
}, },
conversationLabels: {
type: Array,
default: () => [],
},
conversationUrl: { conversationUrl: {
type: String, type: String,
default: '', default: '',
@@ -74,7 +70,6 @@ export default {
'assignAgent', 'assignAgent',
'assignTeam', 'assignTeam',
'assignLabel', 'assignLabel',
'removeLabel',
'deleteConversation', 'deleteConversation',
'close', 'close',
], ],
@@ -339,16 +334,8 @@ export default {
v-for="label in labels" v-for="label in labels"
:key="label.id" :key="label.id"
:option="generateMenuLabelConfig(label, 'label')" :option="generateMenuLabelConfig(label, 'label')"
:variant=" variant="label"
conversationLabels.includes(label.title) @click.stop="$emit('assignLabel', label)"
? 'label-assigned'
: 'label'
"
@click.stop="
conversationLabels.includes(label.title)
? $emit('removeLabel', label)
: $emit('assignLabel', label)
"
/> />
</MenuItemWithSubmenu> </MenuItemWithSubmenu>
<MenuItemWithSubmenu <MenuItemWithSubmenu
@@ -1,6 +1,5 @@
<script setup> <script setup>
import Avatar from 'dashboard/components-next/avatar/Avatar.vue'; import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({ defineProps({
option: { option: {
@@ -23,9 +22,7 @@ defineProps({
class="flex-shrink-0" class="flex-shrink-0"
/> />
<span <span
v-if=" v-if="variant === 'label' && option.color"
(variant === 'label' || variant === 'label-assigned') && option.color
"
class="label-pill flex-shrink-0" class="label-pill flex-shrink-0"
:style="{ backgroundColor: option.color }" :style="{ backgroundColor: option.color }"
/> />
@@ -40,11 +37,6 @@ defineProps({
<p class="menu-label truncate min-w-0 flex-1"> <p class="menu-label truncate min-w-0 flex-1">
{{ option.label }} {{ option.label }}
</p> </p>
<Icon
v-if="variant === 'label-assigned'"
icon="i-lucide-check"
class="flex-shrink-0 size-3.5 mr-1"
/>
</div> </div>
</template> </template>
@@ -2,7 +2,6 @@
// components // components
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue'; import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import { useBranding } from 'shared/composables/useBranding';
// composables // composables
import { useCaptain } from 'dashboard/composables/useCaptain'; import { useCaptain } from 'dashboard/composables/useCaptain';
@@ -35,9 +34,8 @@ export default {
}, },
setup() { setup() {
const { captainTasksEnabled } = useCaptain(); const { captainTasksEnabled } = useCaptain();
const { replaceInstallationName } = useBranding();
return { captainTasksEnabled, replaceInstallationName }; return { captainTasksEnabled };
}, },
data() { data() {
return { return {
@@ -230,9 +228,7 @@ export default {
<div class="sender--info has-tooltip" data-original-title="null"> <div class="sender--info has-tooltip" data-original-title="null">
<Avatar <Avatar
v-tooltip.top="{ v-tooltip.top="{
content: replaceInstallationName( content: $t('LABEL_MGMT.SUGGESTIONS.POWERED_BY'),
$t('LABEL_MGMT.SUGGESTIONS.POWERED_BY')
),
delay: { show: 600, hide: 0 }, delay: { show: 600, hide: 0 },
hideOnClick: true, hideOnClick: true,
}" }"
@@ -1,12 +0,0 @@
export const CAPTAIN_ERROR_TYPES = Object.freeze({
ABORTED: 'aborted',
API_ERROR: 'api_error',
HTTP_PREFIX: 'http_',
ABORT_ERROR: 'AbortError',
CANCELED_ERROR: 'CanceledError',
});
export const CAPTAIN_GENERATION_FAILURE_REASONS = Object.freeze({
EMPTY_RESPONSE: 'empty_response',
EXCEPTION: 'exception',
});
@@ -102,28 +102,6 @@ export function useBulkActions() {
} }
} }
// Only used in context menu
async function onRemoveLabels(labelsToRemove, conversationId = null) {
try {
await store.dispatch('bulkActions/process', {
type: 'Conversation',
ids: conversationId || selectedConversations.value,
labels: {
remove: labelsToRemove,
},
});
useAlert(
t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.SUCCESFUL', {
labelName: labelsToRemove[0],
conversationId,
})
);
} catch (err) {
useAlert(t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.FAILED'));
}
}
async function onAssignTeamsForBulk(team) { async function onAssignTeamsForBulk(team) {
try { try {
await store.dispatch('bulkActions/process', { await store.dispatch('bulkActions/process', {
@@ -211,7 +189,6 @@ export function useBulkActions() {
isConversationSelected, isConversationSelected,
onAssignAgent, onAssignAgent,
onAssignLabels, onAssignLabels,
onRemoveLabels,
onAssignTeamsForBulk, onAssignTeamsForBulk,
onUpdateConversations, onUpdateConversations,
}; };
@@ -1,4 +1,4 @@
import { ref, reactive, computed } from 'vue'; import { ref, computed } from 'vue';
import { useStoreGetters } from 'dashboard/composables/store'; import { useStoreGetters } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
@@ -40,7 +40,7 @@ export function useAutomation(startValue = null) {
} = useAutomationValues(); } = useAutomationValues();
const automation = ref(startValue); const automation = ref(startValue);
const automationTypes = reactive(structuredClone(AUTOMATIONS)); const automationTypes = structuredClone(AUTOMATIONS);
const eventName = computed(() => automation.value?.event_name); const eventName = computed(() => automation.value?.event_name);
/** /**
@@ -160,24 +160,14 @@ export function useAutomation(startValue = null) {
t('AUTOMATION.CONDITION.CONTACT_CUSTOM_ATTR_LABEL') t('AUTOMATION.CONDITION.CONTACT_CUSTOM_ATTR_LABEL')
); );
const CUSTOM_ATTR_HEADER_KEYS = new Set([
'conversation_custom_attribute',
'contact_custom_attribute',
]);
[ [
'message_created', 'message_created',
'conversation_created', 'conversation_created',
'conversation_updated', 'conversation_updated',
'conversation_opened', 'conversation_opened',
].forEach(eventToUpdate => { ].forEach(eventToUpdate => {
const standardConditions = automationTypes[
eventToUpdate
].conditions.filter(
c => !c.customAttributeType && !CUSTOM_ATTR_HEADER_KEYS.has(c.key)
);
automationTypes[eventToUpdate].conditions = [ automationTypes[eventToUpdate].conditions = [
...standardConditions, ...automationTypes[eventToUpdate].conditions,
...manifestedCustomAttributes, ...manifestedCustomAttributes,
]; ];
}); });
@@ -11,7 +11,6 @@ import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { FEATURE_FLAGS } from 'dashboard/featureFlags'; import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import TasksAPI from 'dashboard/api/captain/tasks'; import TasksAPI from 'dashboard/api/captain/tasks';
import { CAPTAIN_ERROR_TYPES } from 'dashboard/composables/captain/constants';
export function useCaptain() { export function useCaptain() {
const store = useStore(); const store = useStore();
@@ -70,10 +69,7 @@ export function useCaptain() {
* @param {Error} error - The error object from the API call. * @param {Error} error - The error object from the API call.
*/ */
const handleAPIError = error => { const handleAPIError = error => {
if ( if (error.name === 'AbortError' || error.name === 'CanceledError') {
error.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR ||
error.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR
) {
return; return;
} }
const errorMessage = const errorMessage =
@@ -82,24 +78,6 @@ export function useCaptain() {
useAlert(errorMessage); useAlert(errorMessage);
}; };
/**
* Classifies API error types for downstream analytics.
* @param {Error} error
* @returns {string}
*/
const getErrorType = error => {
if (
error.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR ||
error.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR
) {
return CAPTAIN_ERROR_TYPES.ABORTED;
}
if (error.response?.status) {
return `${CAPTAIN_ERROR_TYPES.HTTP_PREFIX}${error.response.status}`;
}
return CAPTAIN_ERROR_TYPES.API_ERROR;
};
// === Task Methods === // === Task Methods ===
/** /**
* Rewrites content with a specific operation. * Rewrites content with a specific operation.
@@ -125,7 +103,7 @@ export function useCaptain() {
return { message: generatedMessage, followUpContext }; return { message: generatedMessage, followUpContext };
} catch (error) { } catch (error) {
handleAPIError(error); handleAPIError(error);
return { message: '', errorType: getErrorType(error) }; return { message: '' };
} }
}; };
@@ -147,7 +125,7 @@ export function useCaptain() {
return { message: generatedMessage, followUpContext }; return { message: generatedMessage, followUpContext };
} catch (error) { } catch (error) {
handleAPIError(error); handleAPIError(error);
return { message: '', errorType: getErrorType(error) }; return { message: '' };
} }
}; };
@@ -169,7 +147,7 @@ export function useCaptain() {
return { message: generatedMessage, followUpContext }; return { message: generatedMessage, followUpContext };
} catch (error) { } catch (error) {
handleAPIError(error); handleAPIError(error);
return { message: '', errorType: getErrorType(error) }; return { message: '' };
} }
}; };
@@ -193,11 +171,7 @@ export function useCaptain() {
return { message: generatedMessage, followUpContext: updatedContext }; return { message: generatedMessage, followUpContext: updatedContext };
} catch (error) { } catch (error) {
handleAPIError(error); handleAPIError(error);
return { return { message: '', followUpContext };
message: '',
followUpContext,
errorType: getErrorType(error),
};
} }
}; };
@@ -3,10 +3,6 @@ import { useCaptain } from 'dashboard/composables/useCaptain';
import { useUISettings } from 'dashboard/composables/useUISettings'; import { useUISettings } from 'dashboard/composables/useUISettings';
import { useTrack } from 'dashboard/composables'; import { useTrack } from 'dashboard/composables';
import { CAPTAIN_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; import { CAPTAIN_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import {
CAPTAIN_ERROR_TYPES,
CAPTAIN_GENERATION_FAILURE_REASONS,
} from 'dashboard/composables/captain/constants';
// Actions that map to REWRITE events (with operation attribute) // Actions that map to REWRITE events (with operation attribute)
const REWRITE_ACTIONS = [ const REWRITE_ACTIONS = [
@@ -56,20 +52,6 @@ function buildPayload(action, conversationId, followUpCount = undefined) {
return payload; return payload;
} }
function trackGenerationFailure({
action,
conversationId,
followUpCount = undefined,
stage,
reason,
}) {
useTrack(CAPTAIN_EVENTS.GENERATION_FAILED, {
...buildPayload(action, conversationId, followUpCount),
stage,
reason,
});
}
/** /**
* Composable for managing Copilot reply generation state and actions. * Composable for managing Copilot reply generation state and actions.
* Extracts copilot-related logic from ReplyBox for cleaner code organization. * Extracts copilot-related logic from ReplyBox for cleaner code organization.
@@ -164,8 +146,7 @@ export function useCopilotReply() {
// Reset without tracking dismiss (starting new action) // Reset without tracking dismiss (starting new action)
reset(false); reset(false);
const requestController = new AbortController(); abortController.value = new AbortController();
abortController.value = requestController;
isGenerating.value = true; isGenerating.value = true;
isContentReady.value = false; isContentReady.value = false;
currentAction.value = action; currentAction.value = action;
@@ -173,66 +154,28 @@ export function useCopilotReply() {
trackedConversationId.value = conversationId.value; trackedConversationId.value = conversationId.value;
try { try {
const { const { message: content, followUpContext: newContext } =
message: content, await processEvent(action, data, {
followUpContext: newContext, signal: abortController.value.signal,
errorType, });
} = await processEvent(action, data, {
signal: requestController.signal,
});
if (requestController.signal.aborted) return; if (!abortController.value?.signal.aborted) {
if (errorType === CAPTAIN_ERROR_TYPES.ABORTED) { generatedContent.value = content;
if (abortController.value === requestController) { followUpContext.value = newContext;
isGenerating.value = false; if (content) {
showEditor.value = true;
// Track "Used" event on successful generation
const eventKey = `${getEventPrefix(action)}_USED`;
useTrack(
CAPTAIN_EVENTS[eventKey],
buildPayload(action, trackedConversationId.value)
);
} }
return; isGenerating.value = false;
} }
} catch {
generatedContent.value = content; if (!abortController.value?.signal.aborted) {
followUpContext.value = newContext; isGenerating.value = false;
if (content) {
showEditor.value = true;
// Track "Used" event on successful generation
const eventKey = `${getEventPrefix(action)}_USED`;
useTrack(
CAPTAIN_EVENTS[eventKey],
buildPayload(action, trackedConversationId.value)
);
} else if (errorType && errorType !== CAPTAIN_ERROR_TYPES.ABORTED) {
trackGenerationFailure({
action,
conversationId: trackedConversationId.value,
stage: 'initial',
reason: errorType,
});
} else {
trackGenerationFailure({
action,
conversationId: trackedConversationId.value,
stage: 'initial',
reason: CAPTAIN_GENERATION_FAILURE_REASONS.EMPTY_RESPONSE,
});
}
isGenerating.value = false;
} catch (error) {
if (
requestController.signal.aborted ||
error?.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR ||
error?.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR
) {
return;
}
trackGenerationFailure({
action,
conversationId: trackedConversationId.value,
stage: 'initial',
reason: error?.name || CAPTAIN_GENERATION_FAILURE_REASONS.EXCEPTION,
});
isGenerating.value = false;
} finally {
if (abortController.value === requestController) {
abortController.value = null;
} }
} }
} }
@@ -244,8 +187,7 @@ export function useCopilotReply() {
async function sendFollowUp(message) { async function sendFollowUp(message) {
if (!followUpContext.value || !message.trim()) return; if (!followUpContext.value || !message.trim()) return;
const requestController = new AbortController(); abortController.value = new AbortController();
abortController.value = requestController;
isGenerating.value = true; isGenerating.value = true;
isContentReady.value = false; isContentReady.value = false;
@@ -256,65 +198,24 @@ export function useCopilotReply() {
followUpCount.value += 1; followUpCount.value += 1;
try { try {
const { const { message: content, followUpContext: updatedContext } =
message: content, await followUp({
followUpContext: updatedContext, followUpContext: followUpContext.value,
errorType, message,
} = await followUp({ signal: abortController.value.signal,
followUpContext: followUpContext.value, });
message,
signal: requestController.signal,
});
if (requestController.signal.aborted) return; if (!abortController.value?.signal.aborted) {
if (errorType === CAPTAIN_ERROR_TYPES.ABORTED) { if (content) {
if (abortController.value === requestController) { generatedContent.value = content;
isGenerating.value = false; followUpContext.value = updatedContext;
showEditor.value = true;
} }
return; isGenerating.value = false;
} }
} catch {
if (content) { if (!abortController.value?.signal.aborted) {
generatedContent.value = content; isGenerating.value = false;
followUpContext.value = updatedContext;
showEditor.value = true;
} else if (errorType && errorType !== CAPTAIN_ERROR_TYPES.ABORTED) {
trackGenerationFailure({
action: currentAction.value,
conversationId: trackedConversationId.value,
followUpCount: followUpCount.value,
stage: 'follow_up',
reason: errorType,
});
} else {
trackGenerationFailure({
action: currentAction.value,
conversationId: trackedConversationId.value,
followUpCount: followUpCount.value,
stage: 'follow_up',
reason: CAPTAIN_GENERATION_FAILURE_REASONS.EMPTY_RESPONSE,
});
}
isGenerating.value = false;
} catch (error) {
if (
requestController.signal.aborted ||
error?.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR ||
error?.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR
) {
return;
}
trackGenerationFailure({
action: currentAction.value,
conversationId: trackedConversationId.value,
followUpCount: followUpCount.value,
stage: 'follow_up',
reason: error?.name || CAPTAIN_GENERATION_FAILURE_REASONS.EXCEPTION,
});
isGenerating.value = false;
} finally {
if (abortController.value === requestController) {
abortController.value = null;
} }
} }
} }
-2
View File
@@ -2,7 +2,6 @@ export const FEATURE_FLAGS = {
AGENT_BOTS: 'agent_bots', AGENT_BOTS: 'agent_bots',
AGENT_MANAGEMENT: 'agent_management', AGENT_MANAGEMENT: 'agent_management',
ASSIGNMENT_V2: 'assignment_v2', ASSIGNMENT_V2: 'assignment_v2',
ADVANCED_ASSIGNMENT: 'advanced_assignment',
AUTO_RESOLVE_CONVERSATIONS: 'auto_resolve_conversations', AUTO_RESOLVE_CONVERSATIONS: 'auto_resolve_conversations',
AUTOMATIONS: 'automations', AUTOMATIONS: 'automations',
CAMPAIGNS: 'campaigns', CAMPAIGNS: 'campaigns',
@@ -57,5 +56,4 @@ export const PREMIUM_FEATURES = [
FEATURE_FLAGS.HELP_CENTER, FEATURE_FLAGS.HELP_CENTER,
FEATURE_FLAGS.SAML, FEATURE_FLAGS.SAML,
FEATURE_FLAGS.CONVERSATION_REQUIRED_ATTRIBUTES, FEATURE_FLAGS.CONVERSATION_REQUIRED_ATTRIBUTES,
FEATURE_FLAGS.ADVANCED_ASSIGNMENT,
]; ];
@@ -85,11 +85,6 @@ export const PORTALS_EVENTS = Object.freeze({
}); });
export const CAPTAIN_EVENTS = Object.freeze({ export const CAPTAIN_EVENTS = Object.freeze({
// Editor funnel events
EDITOR_AI_MENU_OPENED: 'Captain: Editor AI menu opened',
GENERATION_FAILED: 'Captain: Generation failed',
AI_ASSISTED_MESSAGE_SENT: 'Captain: AI-assisted message sent',
// Rewrite events (with operation attribute in payload) // Rewrite events (with operation attribute in payload)
REWRITE_USED: 'Captain: Rewrite used', REWRITE_USED: 'Captain: Rewrite used',
REWRITE_APPLIED: 'Captain: Rewrite applied', REWRITE_APPLIED: 'Captain: Rewrite applied',
@@ -169,19 +169,19 @@ export const getFileName = (action, files = []) => {
export const getDefaultConditions = eventName => { export const getDefaultConditions = eventName => {
if (eventName === 'message_created') { if (eventName === 'message_created') {
return structuredClone(DEFAULT_MESSAGE_CREATED_CONDITION); return DEFAULT_MESSAGE_CREATED_CONDITION;
} }
if ( if (
eventName === 'conversation_opened' || eventName === 'conversation_opened' ||
eventName === 'conversation_resolved' eventName === 'conversation_resolved'
) { ) {
return structuredClone(DEFAULT_CONVERSATION_CONDITION); return DEFAULT_CONVERSATION_CONDITION;
} }
return structuredClone(DEFAULT_OTHER_CONDITION); return DEFAULT_OTHER_CONDITION;
}; };
export const getDefaultActions = () => { export const getDefaultActions = () => {
return structuredClone(DEFAULT_ACTIONS); return DEFAULT_ACTIONS;
}; };
export const filterCustomAttributes = customAttributes => { export const filterCustomAttributes = customAttributes => {
@@ -174,10 +174,6 @@
"SUCCESFUL": "Assigned label #{labelName} to conversation id {conversationId}", "SUCCESFUL": "Assigned label #{labelName} to conversation id {conversationId}",
"FAILED": "Couldn't assign label. Please try again." "FAILED": "Couldn't assign label. Please try again."
}, },
"LABEL_REMOVAL": {
"SUCCESFUL": "Removed label #{labelName} from conversation id {conversationId}",
"FAILED": "Couldn't remove label. Please try again."
},
"TEAM_ASSIGNMENT": { "TEAM_ASSIGNMENT": {
"SUCCESFUL": "Assigned team \"{team}\" to conversation id {conversationId}", "SUCCESFUL": "Assigned team \"{team}\" to conversation id {conversationId}",
"FAILED": "Couldn't assign team. Please try again." "FAILED": "Couldn't assign team. Please try again."
@@ -190,8 +186,6 @@
"DISABLE_SIGN_TOOLTIP": "Disable signature", "DISABLE_SIGN_TOOLTIP": "Disable signature",
"MSG_INPUT": "Shift + enter for new line. Start with '/' to select a Canned Response.", "MSG_INPUT": "Shift + enter for new line. Start with '/' to select a Canned Response.",
"PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents", "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",
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "Message signature is not configured, please configure it in profile settings.", "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", "COPILOT_MSG_INPUT": "Give copilot additional prompts, or ask anything else... Press enter to send follow-up",
"CLICK_HERE": "Click here to update", "CLICK_HERE": "Click here to update",
@@ -766,53 +766,6 @@
"MAX_ASSIGNMENT_LIMIT_RANGE_ERROR": "Please enter a value greater than 0", "MAX_ASSIGNMENT_LIMIT_RANGE_ERROR": "Please enter a value greater than 0",
"MAX_ASSIGNMENT_LIMIT_SUB_TEXT": "Limit the maximum number of conversations from this inbox that can be auto assigned to an agent" "MAX_ASSIGNMENT_LIMIT_SUB_TEXT": "Limit the maximum number of conversations from this inbox that can be auto assigned to an agent"
}, },
"ASSIGNMENT": {
"TITLE": "Conversation Assignment",
"DESCRIPTION": "Automatically assign incoming conversations to available agents based on assignment policies",
"ENABLE_AUTO_ASSIGNMENT": "Enable automatic conversation assignment",
"DEFAULT_RULES_TITLE": "Default assignment rules",
"DEFAULT_RULES_DESCRIPTION": "Using the default assignment behavior for all conversations",
"DEFAULT_RULE_1": "Earliest created conversations first",
"DEFAULT_RULE_2": "Round robin distribution",
"CUSTOMIZE_WITH_POLICY": "Customize with assignment policy",
"USING_POLICY": "Using custom assignment policy for this inbox",
"CUSTOMIZE_POLICY": "Customize with assignment policy",
"DELETE_POLICY": "Delete policy",
"POLICY_LABEL": "Assignment policy",
"ASSIGNMENT_ORDER_LABEL": "Assignment Order",
"ASSIGNMENT_METHOD_LABEL": "Assignment Method",
"POLICY_STATUS": {
"ACTIVE": "Active",
"INACTIVE": "Inactive"
},
"PRIORITY": {
"EARLIEST_CREATED": "Earliest created",
"LONGEST_WAITING": "Longest waiting"
},
"METHOD": {
"ROUND_ROBIN": "Round robin",
"BALANCED": "Balanced assignment"
},
"UPGRADE_PROMPT": "Custom assignment policies are available on the Business plan",
"UPGRADE_TO_BUSINESS": "Upgrade to Business",
"DEFAULT_POLICY_LINKED": "Default policy linked",
"DEFAULT_POLICY_DESCRIPTION": "Link a custom assignment policy to customize how conversations are assigned to agents in this inbox.",
"LINK_EXISTING_POLICY": "Link existing policy",
"CREATE_NEW_POLICY": "Create new policy",
"NO_POLICIES": "No assignment policies found",
"VIEW_ALL_POLICIES": "View all policies",
"CURRENT_BEHAVIOR": "Currently using default assignment behavior:",
"LINK_SUCCESS": "Assignment policy linked successfully",
"LINK_ERROR": "Failed to link assignment policy"
},
"ASSIGNMENT_POLICY": {
"DELETE_CONFIRM_TITLE": "Delete assignment policy?",
"DELETE_CONFIRM_MESSAGE": "Are you sure you want to remove this assignment policy from this inbox? The inbox will revert to default assignment rules.",
"CANCEL": "Cancel",
"CONFIRM_DELETE": "Delete",
"DELETE_SUCCESS": "Assignment policy removed successfully",
"DELETE_ERROR": "Failed to remove assignment policy"
},
"FACEBOOK_REAUTHORIZE": { "FACEBOOK_REAUTHORIZE": {
"TITLE": "Reauthorize", "TITLE": "Reauthorize",
"SUBTITLE": "Your Facebook connection has expired, please reconnect your Facebook page to continue services", "SUBTITLE": "Your Facebook connection has expired, please reconnect your Facebook page to continue services",
@@ -694,8 +694,7 @@
"CREATE_BUTTON": "Create policy", "CREATE_BUTTON": "Create policy",
"API": { "API": {
"SUCCESS_MESSAGE": "Assignment policy created successfully", "SUCCESS_MESSAGE": "Assignment policy created successfully",
"ERROR_MESSAGE": "Failed to create assignment policy", "ERROR_MESSAGE": "Failed to create assignment policy"
"INBOX_LINKED": "Inbox has been linked to the policy"
} }
}, },
"EDIT": { "EDIT": {
@@ -709,12 +708,6 @@
"CONFIRM_BUTTON_LABEL": "Continue", "CONFIRM_BUTTON_LABEL": "Continue",
"CANCEL_BUTTON_LABEL": "Cancel" "CANCEL_BUTTON_LABEL": "Cancel"
}, },
"INBOX_LINK_PROMPT": {
"TITLE": "Link inbox to policy",
"DESCRIPTION": "Would you like to link this inbox to the assignment policy?",
"LINK_BUTTON": "Link inbox",
"CANCEL_BUTTON": "Skip"
},
"API": { "API": {
"SUCCESS_MESSAGE": "Assignment policy updated successfully", "SUCCESS_MESSAGE": "Assignment policy updated successfully",
"ERROR_MESSAGE": "Failed to update assignment policy" "ERROR_MESSAGE": "Failed to update assignment policy"
@@ -753,9 +746,7 @@
}, },
"BALANCED": { "BALANCED": {
"LABEL": "Balanced", "LABEL": "Balanced",
"DESCRIPTION": "Assign conversations based on available capacity.", "DESCRIPTION": "Assign conversations based on available capacity."
"PREMIUM_MESSAGE": "Upgrade to access balanced assignment and agent capacity management.",
"PREMIUM_BADGE": "Premium"
} }
}, },
"ASSIGNMENT_PRIORITY": { "ASSIGNMENT_PRIORITY": {
@@ -841,20 +832,6 @@
"SUCCESS_MESSAGE": "Agent removed from policy successfully", "SUCCESS_MESSAGE": "Agent removed from policy successfully",
"ERROR_MESSAGE": "Failed to remove agent from policy" "ERROR_MESSAGE": "Failed to remove agent from policy"
} }
},
"INBOX_LIMIT_API": {
"ADD": {
"SUCCESS_MESSAGE": "Inbox limit added successfully",
"ERROR_MESSAGE": "Failed to add inbox limit"
},
"UPDATE": {
"SUCCESS_MESSAGE": "Inbox limit updated successfully",
"ERROR_MESSAGE": "Failed to update inbox limit"
},
"DELETE": {
"SUCCESS_MESSAGE": "Inbox limit deleted successfully",
"ERROR_MESSAGE": "Failed to delete inbox limit"
}
} }
}, },
"FORM": { "FORM": {
@@ -1,101 +1,91 @@
<script setup> <script>
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { useAlert, useTrack } from 'dashboard/composables'; import { useAlert, useTrack } from 'dashboard/composables';
import { useMapGetter } from 'dashboard/composables/store';
import MergeContact from 'dashboard/modules/contact/components/MergeContact.vue'; import MergeContact from 'dashboard/modules/contact/components/MergeContact.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ContactAPI from 'dashboard/api/contacts'; import ContactAPI from 'dashboard/api/contacts';
import { mapGetters } from 'vuex';
import { CONTACTS_EVENTS } from '../../helper/AnalyticsHelper/events'; import { CONTACTS_EVENTS } from '../../helper/AnalyticsHelper/events';
const props = defineProps({ export default {
primaryContact: { components: { MergeContact },
type: Object, props: {
required: true, show: {
type: Boolean,
default: false,
},
primaryContact: {
type: Object,
required: true,
},
},
emits: ['close', 'update:show'],
data() {
return {
isSearching: false,
searchResults: [],
};
},
computed: {
...mapGetters({
uiFlags: 'contacts/getUIFlags',
}),
localShow: {
get() {
return this.show;
},
set(value) {
this.$emit('update:show', value);
},
},
}, },
});
const emit = defineEmits(['close']); methods: {
onClose() {
this.$emit('close');
},
async onContactSearch(query) {
this.isSearching = true;
this.searchResults = [];
const { t } = useI18n(); try {
const store = useStore(); const {
const uiFlags = useMapGetter('contacts/getUIFlags'); data: { payload },
} = await ContactAPI.search(query);
const dialogRef = ref(null); this.searchResults = payload.filter(
const isSearching = ref(false); contact => contact.id !== this.primaryContact.id
const searchResults = ref([]); );
} catch (error) {
watch( useAlert(this.$t('MERGE_CONTACTS.SEARCH.ERROR_MESSAGE'));
() => props.primaryContact.id, } finally {
() => { this.isSearching = false;
isSearching.value = false; }
searchResults.value = []; },
} async onMergeContacts(parentContactId) {
); useTrack(CONTACTS_EVENTS.MERGED_CONTACTS);
try {
const open = () => { await this.$store.dispatch('contacts/merge', {
dialogRef.value?.open(); childId: this.primaryContact.id,
}; parentId: parentContactId,
});
const close = () => { useAlert(this.$t('MERGE_CONTACTS.FORM.SUCCESS_MESSAGE'));
dialogRef.value?.close(); this.onClose();
}; } catch (error) {
useAlert(this.$t('MERGE_CONTACTS.FORM.ERROR_MESSAGE'));
defineExpose({ open, close }); }
},
const onClose = () => { },
close();
emit('close');
};
const onContactSearch = async query => {
isSearching.value = true;
searchResults.value = [];
try {
const {
data: { payload },
} = await ContactAPI.search(query);
searchResults.value = payload.filter(
contact => contact.id !== props.primaryContact.id
);
} catch (error) {
useAlert(t('MERGE_CONTACTS.SEARCH.ERROR_MESSAGE'));
} finally {
isSearching.value = false;
}
};
const onMergeContacts = async parentContactId => {
useTrack(CONTACTS_EVENTS.MERGED_CONTACTS);
try {
await store.dispatch('contacts/merge', {
childId: props.primaryContact.id,
parentId: parentContactId,
});
useAlert(t('MERGE_CONTACTS.FORM.SUCCESS_MESSAGE'));
close();
emit('close');
} catch (error) {
useAlert(t('MERGE_CONTACTS.FORM.ERROR_MESSAGE'));
}
}; };
</script> </script>
<template> <template>
<Dialog <woot-modal v-model:show="localShow" :on-close="onClose">
ref="dialogRef" <woot-modal-header
type="edit" :header-title="$t('MERGE_CONTACTS.TITLE')"
width="2xl" :header-content="$t('MERGE_CONTACTS.DESCRIPTION')"
:title="$t('MERGE_CONTACTS.TITLE')" />
:description="$t('MERGE_CONTACTS.DESCRIPTION')"
:show-cancel-button="false"
:show-confirm-button="false"
>
<MergeContact <MergeContact
:key="primaryContact.id"
:primary-contact="primaryContact" :primary-contact="primaryContact"
:is-searching="isSearching" :is-searching="isSearching"
:is-merging="uiFlags.isMerging" :is-merging="uiFlags.isMerging"
@@ -104,5 +94,5 @@ const onMergeContacts = async parentContactId => {
@cancel="onClose" @cancel="onClose"
@submit="onMergeContacts" @submit="onMergeContacts"
/> />
</Dialog> </woot-modal>
</template> </template>
@@ -0,0 +1,87 @@
<script setup>
import Avatar from 'next/avatar/Avatar.vue';
defineProps({
name: {
type: String,
default: '',
},
thumbnail: {
type: String,
default: '',
},
email: {
type: String,
default: '',
},
phoneNumber: {
type: String,
default: '',
},
identifier: {
type: [String, Number],
required: true,
},
});
</script>
<template>
<div class="option-item--user">
<Avatar :src="thumbnail" :size="28" :name="name" rounded-full />
<div class="option__user-data">
<h5 class="option__title">
{{ name }}
<span v-if="identifier" class="user-identifier">
{{ $t('MERGE_CONTACTS.DROPDOWN_ITEM.ID', { identifier }) }}
</span>
</h5>
<p class="option__body">
<span v-if="email" class="email-icon-wrap">
<fluent-icon class="merge-contact--icon" icon="mail" size="12" />
{{ email }}
</span>
<span v-if="phoneNumber" class="phone-icon-wrap">
<fluent-icon class="merge-contact--icon" icon="call" size="12" />
{{ phoneNumber }}
</span>
<span v-if="!phoneNumber && !email">{{ '---' }}</span>
</p>
</div>
</div>
</template>
<style lang="scss" scoped>
.option-item--user {
@apply flex items-center;
}
.user-identifier {
@apply text-xs ml-0.5 text-n-slate-12;
}
.option__user-data {
@apply flex flex-col flex-grow ml-2 mr-2;
}
.option__body,
.option__title {
@apply flex items-center justify-start leading-[1.2] text-sm;
}
.option__body .icon {
@apply relative top-px mr-0.5 rtl:mr-0 rtl:ml-0.5;
}
.option__title {
@apply text-n-slate-12 font-medium mb-0.5;
}
.option__body {
@apply text-xs text-n-slate-12 mt-1;
}
.option__user-data .option__body {
> .phone-icon-wrap,
> .email-icon-wrap {
@apply w-auto flex items-center;
}
}
.merge-contact--icon {
@apply -mb-0.5 mr-0.5;
}
</style>
@@ -1,105 +1,174 @@
<script setup> <script>
import { ref, computed } from 'vue';
import { required } from '@vuelidate/validators'; import { required } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core'; import { useVuelidate } from '@vuelidate/core';
import { useI18n } from 'vue-i18n';
import MergeContactSummary from 'dashboard/modules/contact/components/MergeContactSummary.vue'; import MergeContactSummary from 'dashboard/modules/contact/components/MergeContactSummary.vue';
import ContactMergeForm from 'dashboard/components-next/Contacts/ContactsForm/ContactMergeForm.vue'; import ContactDropdownItem from './ContactDropdownItem.vue';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({ export default {
primaryContact: { components: { MergeContactSummary, ContactDropdownItem, NextButton },
type: Object, props: {
required: true, primaryContact: {
}, type: Object,
isSearching: { required: true,
type: Boolean,
default: false,
},
isMerging: {
type: Boolean,
default: false,
},
searchResults: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['search', 'submit', 'cancel']);
const { t } = useI18n();
const parentContactId = ref(null);
const validationRules = {
parentContactId: { required },
};
const v$ = useVuelidate(validationRules, { parentContactId });
const parentContact = computed(() => {
if (!parentContactId.value) return null;
return props.searchResults.find(
contact => contact.id === parentContactId.value
);
});
const parentContactName = computed(() => {
return parentContact.value ? parentContact.value.name : '';
});
const primaryContactList = computed(() => {
return props.searchResults.map(contact => ({
id: contact.id,
label: contact.name,
value: contact.id,
meta: {
thumbnail: contact.thumbnail,
email: contact.email,
phoneNumber: contact.phone_number,
}, },
})); isSearching: {
}); type: Boolean,
default: false,
},
isMerging: {
type: Boolean,
default: false,
},
searchResults: {
type: Array,
default: () => [],
},
},
emits: ['search', 'submit', 'cancel'],
setup() {
return { v$: useVuelidate() };
},
validations: {
primaryContact: {
required,
},
parentContact: {
required,
},
},
data() {
return {
parentContact: undefined,
};
},
const hasValidationError = computed(() => v$.value.parentContactId.$error); computed: {
const validationErrorMessage = computed(() => { parentContactName() {
if (v$.value.parentContactId.$error) { return this.parentContact ? this.parentContact.name : '';
return t('MERGE_CONTACTS.FORM.CHILD_CONTACT.ERROR'); },
} },
return ''; methods: {
}); searchChange(query) {
this.$emit('search', query);
const onSearch = query => { },
emit('search', query); onSubmit() {
}; this.v$.$touch();
if (this.v$.$invalid) {
const onSubmit = () => { return;
v$.value.$touch(); }
if (v$.value.$invalid) { this.$emit('submit', this.parentContact.id);
return; },
} onCancel() {
emit('submit', parentContactId.value); this.$emit('cancel');
}; },
},
const onCancel = () => {
emit('cancel');
}; };
</script> </script>
<template> <template>
<form @submit.prevent="onSubmit"> <form @submit.prevent="onSubmit">
<ContactMergeForm <div>
:selected-contact="primaryContact" <div>
:primary-contact-id="parentContactId" <div
:primary-contact-list="primaryContactList" class="mt-1 multiselect-wrap--medium"
:is-searching="isSearching" :class="{ error: v$.parentContact.$error }"
:has-error="hasValidationError" >
:error-message="validationErrorMessage" <label class="multiselect__label">
@update:primary-contact-id="parentContactId = $event" {{ $t('MERGE_CONTACTS.PARENT.TITLE') }}
@search="onSearch" <woot-label
/> :title="$t('MERGE_CONTACTS.PARENT.HELP_LABEL')"
color-scheme="success"
small
class="ml-2"
/>
</label>
<multiselect
v-model="parentContact"
:options="searchResults"
label="name"
track-by="id"
:internal-search="false"
:clear-on-select="false"
:show-labels="false"
:placeholder="$t('MERGE_CONTACTS.PARENT.PLACEHOLDER')"
allow-empty
:loading="isSearching"
:max-height="150"
open-direction="top"
@search-change="searchChange"
>
<template #singleLabel="props">
<ContactDropdownItem
:thumbnail="props.option.thumbnail"
:identifier="props.option.id"
:name="props.option.name"
:email="props.option.email"
:phone-number="props.option.phone_number"
/>
</template>
<template #option="props">
<ContactDropdownItem
:thumbnail="props.option.thumbnail"
:identifier="props.option.id"
:name="props.option.name"
:email="props.option.email"
:phone-number="props.option.phone_number"
/>
</template>
<template #noResult>
<span>
{{ $t('AGENT_MGMT.SEARCH.NO_RESULTS') }}
</span>
</template>
</multiselect>
<span v-if="v$.parentContact.$error" class="message">
{{ $t('MERGE_CONTACTS.FORM.CHILD_CONTACT.ERROR') }}
</span>
</div>
</div>
<div class="flex multiselect-wrap--medium">
<div
class="w-8 relative text-base text-n-strong after:content-[''] after:h-12 after:w-0 ltr:after:left-4 rtl:after:right-4 after:absolute after:border-l after:border-solid after:border-n-strong before:content-[''] before:h-0 before:w-4 ltr:before:left-4 rtl:before:right-4 before:top-12 before:absolute before:border-b before:border-solid before:border-n-strong"
>
<fluent-icon
icon="arrow-up"
class="absolute -top-1 ltr:left-2 rtl:right-2"
size="17"
/>
</div>
<div class="flex flex-col w-full ltr:pl-8 rtl:pr-8">
<label class="multiselect__label">
{{ $t('MERGE_CONTACTS.PRIMARY.TITLE') }}
<woot-label
:title="$t('MERGE_CONTACTS.PRIMARY.HELP_LABEL')"
color-scheme="alert"
small
class="ml-2"
/>
</label>
<multiselect
:model-value="primaryContact"
disabled
:options="[]"
:show-labels="false"
label="name"
track-by="id"
>
<template #singleLabel="props">
<ContactDropdownItem
:thumbnail="props.option.thumbnail"
:name="props.option.name"
:identifier="props.option.id"
:email="props.option.email"
:phone-number="props.option.phoneNumber"
/>
</template>
</multiselect>
</div>
</div>
</div>
<MergeContactSummary <MergeContactSummary
:primary-contact-name="primaryContact.name" :primary-contact-name="primaryContact.name"
:parent-contact-name="parentContactName" :parent-contact-name="parentContactName"
@@ -120,3 +189,32 @@ const onCancel = () => {
</div> </div>
</form> </form>
</template> </template>
<style lang="scss" scoped>
/* TDOD: Clean errors in forms style */
.error .message {
@apply mt-0;
}
::v-deep {
.multiselect {
@apply rounded-md;
}
.multiselect--disabled {
@apply border-0;
.multiselect__tags {
@apply border;
}
}
.multiselect__tags {
@apply h-auto;
}
.multiselect__select {
@apply mt-px mr-1;
}
}
</style>
@@ -85,10 +85,7 @@ export default {
}, },
set(agent) { set(agent) {
const agentId = agent ? agent.id : null; const agentId = agent ? agent.id : null;
this.$store.dispatch('setCurrentChatAssignee', { this.$store.dispatch('setCurrentChatAssignee', agent);
conversationId: this.currentChat.id,
assignee: agent,
});
this.$store this.$store
.dispatch('assignAgent', { .dispatch('assignAgent', {
conversationId: this.currentChat.id, conversationId: this.currentChat.id,
@@ -11,13 +11,11 @@ import { isPhoneNumberValid } from 'shared/helpers/Validators';
import parsePhoneNumber from 'libphonenumber-js'; import parsePhoneNumber from 'libphonenumber-js';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import Avatar from 'next/avatar/Avatar.vue'; import Avatar from 'next/avatar/Avatar.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
export default { export default {
components: { components: {
NextButton, NextButton,
Avatar, Avatar,
ComboBox,
}, },
props: { props: {
contact: { contact: {
@@ -135,12 +133,6 @@ export default {
if (!name && !id) return ''; if (!name && !id) return '';
return `${name} (${id})`; return `${name} (${id})`;
}, },
onCountryChange(value) {
const selected = this.countries.find(c => c.id === value);
this.country = selected
? { id: selected.id, name: selected.name }
: { id: '', name: '' };
},
setDialCode() { setDialCode() {
if ( if (
this.phoneNumber !== '' && this.phoneNumber !== '' &&
@@ -371,23 +363,26 @@ export default {
:label="$t('CONTACT_FORM.FORM.COMPANY_NAME.LABEL')" :label="$t('CONTACT_FORM.FORM.COMPANY_NAME.LABEL')"
:placeholder="$t('CONTACT_FORM.FORM.COMPANY_NAME.PLACEHOLDER')" :placeholder="$t('CONTACT_FORM.FORM.COMPANY_NAME.PLACEHOLDER')"
/> />
<div class="w-full mb-4"> <div>
<label> <div class="w-full">
{{ $t('CONTACT_FORM.FORM.COUNTRY.LABEL') }} <label>
</label> {{ $t('CONTACT_FORM.FORM.COUNTRY.LABEL') }}
<ComboBox </label>
:model-value="country.id" <multiselect
:options=" v-model="country"
countries.map(c => ({ track-by="id"
value: c.id, label="name"
label: countryNameWithCode(c), :placeholder="$t('CONTACT_FORM.FORM.COUNTRY.PLACEHOLDER')"
})) selected-label
" :select-label="$t('CONTACT_FORM.FORM.COUNTRY.SELECT_PLACEHOLDER')"
class="[&>div>button]:!bg-n-alpha-black2" :deselect-label="$t('CONTACT_FORM.FORM.COUNTRY.REMOVE')"
:placeholder="$t('CONTACT_FORM.FORM.COUNTRY.PLACEHOLDER')" :custom-label="countryNameWithCode"
:search-placeholder="$t('CONTACT_FORM.FORM.COUNTRY.SELECT_PLACEHOLDER')" :max-height="160"
@update:model-value="onCountryChange" :options="countries"
/> allow-empty
:option-height="104"
/>
</div>
</div> </div>
<woot-input <woot-input
v-model="city" v-model="city"
@@ -431,3 +426,11 @@ export default {
</div> </div>
</form> </form>
</template> </template>
<style scoped lang="scss">
::v-deep {
.multiselect .multiselect__tags .multiselect__single {
@apply pl-0;
}
}
</style>
@@ -51,6 +51,7 @@ export default {
data() { data() {
return { return {
showEditModal: false, showEditModal: false,
showMergeModal: false,
showDeleteModal: false, showDeleteModal: false,
}; };
}, },
@@ -166,8 +167,11 @@ export default {
); );
} }
}, },
closeMergeModal() {
this.showMergeModal = false;
},
openMergeModal() { openMergeModal() {
this.$refs.mergeModal?.open(); this.showMergeModal = true;
}, },
}, },
}; };
@@ -320,7 +324,12 @@ export default {
:contact="contact" :contact="contact"
@cancel="toggleEditModal" @cancel="toggleEditModal"
/> />
<ContactMergeModal ref="mergeModal" :primary-contact="contact" /> <ContactMergeModal
v-if="showMergeModal"
:primary-contact="contact"
:show="showMergeModal"
@close="closeMergeModal"
/>
</div> </div>
<woot-delete-modal <woot-delete-modal
v-if="showDeleteModal" v-if="showDeleteModal"
@@ -1,81 +1,54 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRouter, useRoute } from 'vue-router'; import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue'; import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue'; import SettingsLayout from '../SettingsLayout.vue';
import AssignmentCard from 'dashboard/components-next/AssignmentPolicy/AssignmentCard/AssignmentCard.vue'; import AssignmentCard from 'dashboard/components-next/AssignmentPolicy/AssignmentCard/AssignmentCard.vue';
const router = useRouter(); const router = useRouter();
const route = useRoute();
const { t } = useI18n(); const { t } = useI18n();
const accountId = computed(() => Number(route.params.accountId)); const agentAssignments = computed(() => [
const isFeatureEnabledonAccount = useMapGetter( {
'accounts/isFeatureEnabledonAccount' key: 'agent_assignment_policy_index',
); title: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.TITLE'),
description: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.DESCRIPTION'),
const agentAssignments = computed(() => { features: [
const assignments = [ {
{ icon: 'i-lucide-circle-fading-arrow-up',
key: 'agent_assignment_policy_index', label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.0'),
title: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.TITLE'), },
description: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.DESCRIPTION'), {
features: [ icon: 'i-lucide-scale',
{ label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.1'),
icon: 'i-lucide-circle-fading-arrow-up', },
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.0'), {
}, icon: 'i-lucide-inbox',
{ label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.2'),
icon: 'i-lucide-scale', },
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.1'), ],
}, },
{ {
icon: 'i-lucide-inbox', key: 'agent_capacity_policy_index',
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.2'), title: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.TITLE'),
}, description: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.DESCRIPTION'),
], features: [
}, {
]; icon: 'i-lucide-glass-water',
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.0'),
// Only show Agent Capacity if BOTH assignment_v2 AND advanced_assignment are enabled },
// advanced_assignment identifies premium users {
const hasAssignmentV2 = isFeatureEnabledonAccount.value( icon: 'i-lucide-circle-minus',
accountId.value, label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.1'),
'assignment_v2' },
); {
const hasAdvancedAssignment = isFeatureEnabledonAccount.value( icon: 'i-lucide-users-round',
accountId.value, label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.2'),
'advanced_assignment' },
); ],
},
if (hasAssignmentV2 && hasAdvancedAssignment) { ]);
assignments.push({
key: 'agent_capacity_policy_index',
title: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.TITLE'),
description: t(
'ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.DESCRIPTION'
),
features: [
{
icon: 'i-lucide-glass-water',
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.0'),
},
{
icon: 'i-lucide-circle-minus',
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.1'),
},
{
icon: 'i-lucide-users-round',
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.2'),
},
],
});
}
return assignments;
});
const handleClick = key => { const handleClick = key => {
router.push({ name: key }); router.push({ name: key });
@@ -62,7 +62,7 @@ export default {
name: 'agent_capacity_policy_index', name: 'agent_capacity_policy_index',
component: AgentCapacityIndex, component: AgentCapacityIndex,
meta: { meta: {
featureFlag: FEATURE_FLAGS.ADVANCED_ASSIGNMENT, featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
permissions: ['administrator'], permissions: ['administrator'],
}, },
}, },
@@ -71,7 +71,7 @@ export default {
name: 'agent_capacity_policy_create', name: 'agent_capacity_policy_create',
component: AgentCapacityCreate, component: AgentCapacityCreate,
meta: { meta: {
featureFlag: FEATURE_FLAGS.ADVANCED_ASSIGNMENT, featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
permissions: ['administrator'], permissions: ['administrator'],
}, },
}, },
@@ -80,7 +80,7 @@ export default {
name: 'agent_capacity_policy_edit', name: 'agent_capacity_policy_edit',
component: AgentCapacityEdit, component: AgentCapacityEdit,
meta: { meta: {
featureFlag: FEATURE_FLAGS.ADVANCED_ASSIGNMENT, featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
permissions: ['administrator'], permissions: ['administrator'],
}, },
}, },
@@ -2,14 +2,13 @@
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store'; import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRoute, useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue'; import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue'; import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue';
import AssignmentPolicyForm from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.vue'; import AssignmentPolicyForm from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.vue';
const route = useRoute();
const router = useRouter(); const router = useRouter();
const store = useStore(); const store = useStore();
const { t } = useI18n(); const { t } = useI18n();
@@ -17,50 +16,20 @@ const { t } = useI18n();
const formRef = ref(null); const formRef = ref(null);
const uiFlags = useMapGetter('assignmentPolicies/getUIFlags'); const uiFlags = useMapGetter('assignmentPolicies/getUIFlags');
const inboxIdFromQuery = computed(() => { const breadcrumbItems = computed(() => [
const id = route.query.inboxId; {
return id ? Number(id) : null; label: t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.HEADER.TITLE'),
}); routeName: 'agent_assignment_policy_index',
},
const breadcrumbItems = computed(() => { {
if (inboxIdFromQuery.value) { label: t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.CREATE.HEADER.TITLE'),
return [ },
{ ]);
label: t('INBOX_MGMT.SETTINGS'),
routeName: 'settings_inbox_show',
params: { inboxId: inboxIdFromQuery.value },
},
{
label: t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.CREATE.HEADER.TITLE'
),
},
];
}
return [
{
label: t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.HEADER.TITLE'),
routeName: 'agent_assignment_policy_index',
},
{
label: t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.CREATE.HEADER.TITLE'),
},
];
});
const handleBreadcrumbClick = item => { const handleBreadcrumbClick = item => {
if (item.params) { router.push({
const accountId = route.params.accountId; name: item.routeName,
const inboxId = item.params.inboxId; });
// Navigate using explicit path to ensure tab parameter is included
router.push(
`/app/accounts/${accountId}/settings/inboxes/${inboxId}/collaborators`
);
} else {
router.push({
name: item.routeName,
});
}
}; };
const handleSubmit = async formState => { const handleSubmit = async formState => {
@@ -76,8 +45,6 @@ const handleSubmit = async formState => {
params: { params: {
id: policy.id, id: policy.id,
}, },
// Pass inboxId to edit page to show link prompt
query: inboxIdFromQuery.value ? { inboxId: inboxIdFromQuery.value } : {},
}); });
} catch (error) { } catch (error) {
useAlert( useAlert(
@@ -14,7 +14,6 @@ import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue'; import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue';
import AssignmentPolicyForm from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.vue'; import AssignmentPolicyForm from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.vue';
import ConfirmInboxDialog from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/ConfirmInboxDialog.vue'; import ConfirmInboxDialog from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/ConfirmInboxDialog.vue';
import InboxLinkDialog from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/InboxLinkDialog.vue';
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY'; const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY';
@@ -37,46 +36,13 @@ const confirmInboxDialogRef = ref(null);
// Store the policy linked to the inbox when adding a new inbox // Store the policy linked to the inbox when adding a new inbox
const inboxLinkedPolicy = ref(null); const inboxLinkedPolicy = ref(null);
// Inbox linking prompt from create flow const breadcrumbItems = computed(() => [
const inboxIdFromQuery = computed(() => { {
const id = route.query.inboxId; label: t(`${BASE_KEY}.INDEX.HEADER.TITLE`),
return id ? Number(id) : null; routeName: 'agent_assignment_policy_index',
}); },
{ label: t(`${BASE_KEY}.EDIT.HEADER.TITLE`) },
const suggestedInbox = computed(() => { ]);
if (!inboxIdFromQuery.value || !inboxes.value) return null;
return inboxes.value.find(inbox => inbox.id === inboxIdFromQuery.value);
});
const isLinkingInbox = ref(false);
const dismissInboxLinkPrompt = () => {
router.replace({
name: route.name,
params: route.params,
query: {},
});
};
const breadcrumbItems = computed(() => {
if (inboxIdFromQuery.value) {
return [
{
label: t('INBOX_MGMT.SETTINGS'),
routeName: 'settings_inbox_show',
params: { inboxId: inboxIdFromQuery.value },
},
{ label: t(`${BASE_KEY}.EDIT.HEADER.TITLE`) },
];
}
return [
{
label: t(`${BASE_KEY}.INDEX.HEADER.TITLE`),
routeName: 'agent_assignment_policy_index',
},
{ label: t(`${BASE_KEY}.EDIT.HEADER.TITLE`) },
];
});
const buildInboxList = allInboxes => const buildInboxList = allInboxes =>
allInboxes?.map(({ name, id, email, phoneNumber, channelType, medium }) => ({ allInboxes?.map(({ name, id, email, phoneNumber, channelType, medium }) => ({
@@ -100,48 +66,22 @@ const inboxList = computed(() =>
const formData = computed(() => ({ const formData = computed(() => ({
name: selectedPolicy.value?.name || '', name: selectedPolicy.value?.name || '',
description: selectedPolicy.value?.description || '', description: selectedPolicy.value?.description || '',
enabled: true, enabled: selectedPolicy.value?.enabled || false,
assignmentOrder: selectedPolicy.value?.assignmentOrder || ROUND_ROBIN, assignmentOrder: selectedPolicy.value?.assignmentOrder || ROUND_ROBIN,
conversationPriority: conversationPriority:
selectedPolicy.value?.conversationPriority || EARLIEST_CREATED, selectedPolicy.value?.conversationPriority || EARLIEST_CREATED,
fairDistributionLimit: selectedPolicy.value?.fairDistributionLimit || 100, fairDistributionLimit: selectedPolicy.value?.fairDistributionLimit || 10,
fairDistributionWindow: selectedPolicy.value?.fairDistributionWindow || 3600, fairDistributionWindow: selectedPolicy.value?.fairDistributionWindow || 60,
})); }));
const handleDeleteInbox = async inboxId => { const handleDeleteInbox = inboxId =>
try { store.dispatch('assignmentPolicies/removeInboxPolicy', {
await store.dispatch('assignmentPolicies/removeInboxPolicy', { policyId: selectedPolicy.value?.id,
policyId: selectedPolicy.value?.id, inboxId,
inboxId,
});
useAlert(t(`${BASE_KEY}.EDIT.INBOX_API.REMOVE.SUCCESS_MESSAGE`));
} catch {
useAlert(t(`${BASE_KEY}.EDIT.INBOX_API.REMOVE.ERROR_MESSAGE`));
}
};
const handleBreadcrumbClick = ({ routeName, params }) => {
if (params) {
const accountId = route.params.accountId;
const inboxId = params.inboxId;
// Navigate using explicit path to ensure tab parameter is included
router.push(
`/app/accounts/${accountId}/settings/inboxes/${inboxId}/collaborators`
);
} else {
router.push({ name: routeName });
}
};
const handleNavigateToInbox = inbox => {
router.push({
name: 'settings_inbox_show',
params: {
accountId: route.params.accountId,
inboxId: inbox.id,
},
}); });
};
const handleBreadcrumbClick = ({ routeName }) =>
router.push({ name: routeName });
const setInboxPolicy = async (inboxId, policyId) => { const setInboxPolicy = async (inboxId, policyId) => {
try { try {
@@ -182,26 +122,6 @@ const handleAddInbox = async inbox => {
await setInboxPolicy(inbox?.id, selectedPolicy.value?.id); await setInboxPolicy(inbox?.id, selectedPolicy.value?.id);
}; };
const handleLinkSuggestedInbox = async () => {
if (!suggestedInbox.value) return;
isLinkingInbox.value = true;
const inbox = {
id: suggestedInbox.value.id,
name: suggestedInbox.value.name,
};
await handleAddInbox(inbox);
// Clear the query param after linking
router.replace({
name: route.name,
params: route.params,
query: {},
});
isLinkingInbox.value = false;
};
const handleConfirmAddInbox = async inboxId => { const handleConfirmAddInbox = async inboxId => {
const success = await setInboxPolicy(inboxId, selectedPolicy.value?.id); const success = await setInboxPolicy(inboxId, selectedPolicy.value?.id);
@@ -235,11 +155,6 @@ const handleSubmit = async formState => {
const fetchPolicyData = async () => { const fetchPolicyData = async () => {
if (!routeId.value) return; if (!routeId.value) return;
// Fetch inboxes if not already loaded (needed for inbox link prompt)
if (!inboxes.value?.length) {
store.dispatch('inboxes/get');
}
// Fetch policy if not available // Fetch policy if not available
if (!selectedPolicy.value?.id) if (!selectedPolicy.value?.id)
await store.dispatch('assignmentPolicies/show', routeId.value); await store.dispatch('assignmentPolicies/show', routeId.value);
@@ -271,7 +186,6 @@ watch(routeId, fetchPolicyData, { immediate: true });
@submit="handleSubmit" @submit="handleSubmit"
@add-inbox="handleAddInbox" @add-inbox="handleAddInbox"
@delete-inbox="handleDeleteInbox" @delete-inbox="handleDeleteInbox"
@navigate-to-inbox="handleNavigateToInbox"
/> />
</template> </template>
@@ -279,12 +193,5 @@ watch(routeId, fetchPolicyData, { immediate: true });
ref="confirmInboxDialogRef" ref="confirmInboxDialogRef"
@add="handleConfirmAddInbox" @add="handleConfirmAddInbox"
/> />
<InboxLinkDialog
:inbox="suggestedInbox"
:is-linking="isLinkingInbox"
@link="handleLinkSuggestedInbox"
@dismiss="dismissInboxLinkPrompt"
/>
</SettingsLayout> </SettingsLayout>
</template> </template>
@@ -92,68 +92,43 @@ const formData = computed(() => ({
const handleBreadcrumbClick = ({ routeName }) => const handleBreadcrumbClick = ({ routeName }) =>
router.push({ name: routeName }); router.push({ name: routeName });
const handleDeleteUser = async agentId => { const handleDeleteUser = agentId => {
try { store.dispatch('agentCapacityPolicies/removeUser', {
await store.dispatch('agentCapacityPolicies/removeUser', { policyId: selectedPolicyId.value,
policyId: selectedPolicyId.value, userId: agentId,
userId: agentId, });
});
useAlert(t(`${BASE_KEY}.EDIT.AGENT_API.REMOVE.SUCCESS_MESSAGE`));
} catch {
useAlert(t(`${BASE_KEY}.EDIT.AGENT_API.REMOVE.ERROR_MESSAGE`));
}
}; };
const handleAddUser = async agent => { const handleAddUser = agent => {
try { store.dispatch('agentCapacityPolicies/addUser', {
await store.dispatch('agentCapacityPolicies/addUser', { policyId: selectedPolicyId.value,
policyId: selectedPolicyId.value, userData: { id: agent.id, capacity: 20 },
userData: { id: agent.id, capacity: 20 }, });
});
useAlert(t(`${BASE_KEY}.EDIT.AGENT_API.ADD.SUCCESS_MESSAGE`));
} catch {
useAlert(t(`${BASE_KEY}.EDIT.AGENT_API.ADD.ERROR_MESSAGE`));
}
}; };
const handleDeleteInboxLimit = async limitId => { const handleDeleteInboxLimit = limitId => {
try { store.dispatch('agentCapacityPolicies/deleteInboxLimit', {
await store.dispatch('agentCapacityPolicies/deleteInboxLimit', { policyId: selectedPolicyId.value,
policyId: selectedPolicyId.value, limitId,
limitId, });
});
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.DELETE.SUCCESS_MESSAGE`));
} catch {
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.DELETE.ERROR_MESSAGE`));
}
}; };
const handleAddInboxLimit = async limit => { const handleAddInboxLimit = limit => {
try { store.dispatch('agentCapacityPolicies/createInboxLimit', {
await store.dispatch('agentCapacityPolicies/createInboxLimit', { policyId: selectedPolicyId.value,
policyId: selectedPolicyId.value, limitData: {
limitData: { inboxId: limit.inboxId,
inboxId: limit.inboxId, conversationLimit: limit.conversationLimit,
conversationLimit: limit.conversationLimit, },
}, });
});
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.ADD.SUCCESS_MESSAGE`));
} catch {
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.ADD.ERROR_MESSAGE`));
}
}; };
const handleLimitChange = async limit => { const handleLimitChange = limit => {
try { store.dispatch('agentCapacityPolicies/updateInboxLimit', {
await store.dispatch('agentCapacityPolicies/updateInboxLimit', { policyId: selectedPolicyId.value,
policyId: selectedPolicyId.value, limitId: limit.id,
limitId: limit.id, limitData: { conversationLimit: limit.conversationLimit },
limitData: { conversationLimit: limit.conversationLimit }, });
});
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.UPDATE.SUCCESS_MESSAGE`));
} catch {
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.UPDATE.ERROR_MESSAGE`));
}
}; };
const handleSubmit = async formState => { const handleSubmit = async formState => {
@@ -1,8 +1,7 @@
<script setup> <script setup>
import { computed, reactive, ref, watch } from 'vue'; import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router'; import { useConfig } from 'dashboard/composables/useConfig';
import { useMapGetter } from 'dashboard/composables/store';
import BaseInfo from 'dashboard/components-next/AssignmentPolicy/components/BaseInfo.vue'; 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/AssignmentPolicy/components/RadioCard.vue';
import FairDistribution from 'dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue'; import FairDistribution from 'dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue';
@@ -24,6 +23,7 @@ const props = defineProps({
default: () => ({ default: () => ({
name: '', name: '',
description: '', description: '',
enabled: false,
assignmentOrder: ROUND_ROBIN, assignmentOrder: ROUND_ROBIN,
conversationPriority: EARLIEST_CREATED, conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT, fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
@@ -61,24 +61,18 @@ const emit = defineEmits([
'submit', 'submit',
'addInbox', 'addInbox',
'deleteInbox', 'deleteInbox',
'navigateToInbox',
'validationChange', 'validationChange',
]); ]);
const { t } = useI18n(); const { t } = useI18n();
const route = useRoute(); const { isEnterprise } = useConfig();
const accountId = computed(() => Number(route.params.accountId));
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY'; const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY';
const state = reactive({ const state = reactive({
name: '', name: '',
description: '', description: '',
enabled: true, enabled: false,
assignmentOrder: ROUND_ROBIN, assignmentOrder: ROUND_ROBIN,
conversationPriority: EARLIEST_CREATED, conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT, fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
@@ -89,42 +83,20 @@ const validationState = ref({
isValid: false, isValid: false,
}); });
const createOption = ( const createOption = (type, key, stateKey) => ({
type,
key,
stateKey,
disabled = false,
disabledMessage = ''
) => ({
key, key,
label: t(`${BASE_KEY}.FORM.${type}.${key.toUpperCase()}.LABEL`), label: t(`${BASE_KEY}.FORM.${type}.${key.toUpperCase()}.LABEL`),
description: t(`${BASE_KEY}.FORM.${type}.${key.toUpperCase()}.DESCRIPTION`), description: t(`${BASE_KEY}.FORM.${type}.${key.toUpperCase()}.DESCRIPTION`),
isActive: state[stateKey] === key, isActive: state[stateKey] === key,
disabled,
disabledMessage,
}); });
const assignmentOrderOptions = computed(() => { const assignmentOrderOptions = computed(() => {
const hasAdvancedAssignment = isFeatureEnabledonAccount.value( const options = OPTIONS.ORDER.filter(
accountId.value, key => isEnterprise || key !== 'balanced'
'advanced_assignment' );
return options.map(key =>
createOption('ASSIGNMENT_ORDER', key, 'assignmentOrder')
); );
return OPTIONS.ORDER.map(key => {
const isBalanced = key === 'balanced';
const disabled = isBalanced && !hasAdvancedAssignment;
const disabledMessage = disabled
? t(`${BASE_KEY}.FORM.ASSIGNMENT_ORDER.BALANCED.PREMIUM_MESSAGE`)
: '';
return createOption(
'ASSIGNMENT_ORDER',
key,
'assignmentOrder',
disabled,
disabledMessage
);
});
}); });
const assignmentPriorityOptions = computed(() => const assignmentPriorityOptions = computed(() =>
@@ -159,7 +131,7 @@ const resetForm = () => {
Object.assign(state, { Object.assign(state, {
name: '', name: '',
description: '', description: '',
enabled: true, enabled: false,
assignmentOrder: ROUND_ROBIN, assignmentOrder: ROUND_ROBIN,
conversationPriority: EARLIEST_CREATED, conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT, fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
@@ -190,10 +162,15 @@ defineExpose({
<BaseInfo <BaseInfo
v-model:policy-name="state.name" v-model:policy-name="state.name"
v-model:description="state.description" v-model:description="state.description"
v-model:enabled="state.enabled"
:name-label="t(`${BASE_KEY}.FORM.NAME.LABEL`)" :name-label="t(`${BASE_KEY}.FORM.NAME.LABEL`)"
:name-placeholder="t(`${BASE_KEY}.FORM.NAME.PLACEHOLDER`)" :name-placeholder="t(`${BASE_KEY}.FORM.NAME.PLACEHOLDER`)"
:description-label="t(`${BASE_KEY}.FORM.DESCRIPTION.LABEL`)" :description-label="t(`${BASE_KEY}.FORM.DESCRIPTION.LABEL`)"
:description-placeholder="t(`${BASE_KEY}.FORM.DESCRIPTION.PLACEHOLDER`)" :description-placeholder="t(`${BASE_KEY}.FORM.DESCRIPTION.PLACEHOLDER`)"
:status-label="t(`${BASE_KEY}.FORM.STATUS.LABEL`)"
:status-placeholder="
t(`${BASE_KEY}.FORM.STATUS.${state.enabled ? 'ACTIVE' : 'INACTIVE'}`)
"
@validation-change="handleValidationChange" @validation-change="handleValidationChange"
/> />
@@ -216,8 +193,6 @@ defineExpose({
:label="option.label" :label="option.label"
:description="option.description" :description="option.description"
:is-active="option.isActive" :is-active="option.isActive"
:disabled="option.disabled"
:disabled-message="option.disabledMessage"
@select="state[section.key] = $event" @select="state[section.key] = $event"
/> />
</div> </div>
@@ -276,7 +251,6 @@ defineExpose({
:is-fetching="isInboxLoading" :is-fetching="isInboxLoading"
:empty-state-message="t(`${BASE_KEY}.FORM.INBOXES.EMPTY_STATE`)" :empty-state-message="t(`${BASE_KEY}.FORM.INBOXES.EMPTY_STATE`)"
@delete="$emit('deleteInbox', $event)" @delete="$emit('deleteInbox', $event)"
@navigate="$emit('navigateToInbox', $event)"
/> />
</div> </div>
</form> </form>
@@ -1,116 +0,0 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
inbox: {
type: Object,
default: null,
},
isLinking: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['link', 'dismiss']);
const { t } = useI18n();
const dialogRef = ref(null);
const inboxName = computed(() => props.inbox?.name || '');
const inboxIcon = computed(() => {
if (!props.inbox) return 'i-lucide-inbox';
return getInboxIconByType(
props.inbox.channelType,
props.inbox.medium,
'line'
);
});
const openDialog = () => {
dialogRef.value?.open();
};
const closeDialog = () => {
dialogRef.value?.close();
};
const handleConfirm = () => {
emit('link');
};
const handleClose = () => {
emit('dismiss');
};
watch(
() => props.inbox,
async newInbox => {
if (newInbox) {
await nextTick();
openDialog();
} else {
closeDialog();
}
},
{ immediate: true }
);
defineExpose({ openDialog, closeDialog });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.INBOX_LINK_PROMPT.TITLE'
)
"
:confirm-button-label="
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.INBOX_LINK_PROMPT.LINK_BUTTON'
)
"
:cancel-button-label="
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.INBOX_LINK_PROMPT.CANCEL_BUTTON'
)
"
:is-loading="isLinking"
@confirm="handleConfirm"
@close="handleClose"
>
<template #description>
<p class="text-sm text-n-slate-11">
{{
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.INBOX_LINK_PROMPT.DESCRIPTION'
)
}}
</p>
</template>
<div
class="flex items-center gap-3 p-3 rounded-xl border border-n-weak bg-n-alpha-1"
>
<div
class="flex-shrink-0 size-10 rounded-lg bg-n-alpha-2 flex items-center justify-center"
>
<i :class="inboxIcon" class="text-lg text-n-slate-11" />
</div>
<div class="flex flex-col min-w-0">
<span class="text-sm font-medium text-n-slate-12 truncate">
{{ inboxName }}
</span>
</div>
</div>
</Dialog>
</template>
@@ -7,12 +7,10 @@ import { convertToAttributeSlug } from 'dashboard/helper/commons.js';
import { ATTRIBUTE_MODELS, ATTRIBUTE_TYPES } from './constants'; import { ATTRIBUTE_MODELS, ATTRIBUTE_TYPES } from './constants';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
export default { export default {
components: { components: {
NextButton, NextButton,
TagInput,
}, },
props: { props: {
onClose: { onClose: {
@@ -43,8 +41,9 @@ export default {
regexCue: null, regexCue: null,
regexEnabled: false, regexEnabled: false,
values: [], values: [],
options: [],
show: true, show: true,
tagInputTouched: false, isTouched: false,
}; };
}, },
@@ -64,21 +63,21 @@ export default {
option: this.$t(`ATTRIBUTES_MGMT.ATTRIBUTE_TYPES.${item.key}`), option: this.$t(`ATTRIBUTES_MGMT.ATTRIBUTE_TYPES.${item.key}`),
})); }));
}, },
isTagInputEmpty() { isMultiselectInvalid() {
return this.isAttributeTypeList && this.values.length === 0; return this.isTouched && this.values.length === 0;
}, },
isTagInputInvalid() { isTagInputInvalid() {
return this.tagInputTouched && this.isTagInputEmpty; return this.isAttributeTypeList && this.values.length === 0;
}, },
attributeListValues() { attributeListValues() {
return this.values; return this.values.map(item => item.name);
}, },
isButtonDisabled() { isButtonDisabled() {
return ( return (
this.v$.displayName.$invalid || this.v$.displayName.$invalid ||
this.v$.description.$invalid || this.v$.description.$invalid ||
this.uiFlags.isCreating || this.uiFlags.isCreating ||
this.isTagInputEmpty this.isTagInputInvalid
); );
}, },
keyErrorMessage() { keyErrorMessage() {
@@ -120,14 +119,17 @@ export default {
}, },
}, },
watch: {
attributeType() {
this.tagInputTouched = false;
this.values = [];
},
},
methods: { methods: {
addTagValue(tagValue) {
const tag = {
name: tagValue,
};
this.values.push(tag);
this.$refs.tagInput.$el.focus();
},
onTouch() {
this.isTouched = true;
},
onDisplayNameChange() { onDisplayNameChange() {
this.attributeKey = convertToAttributeSlug(this.displayName); this.attributeKey = convertToAttributeSlug(this.displayName);
}, },
@@ -235,25 +237,27 @@ export default {
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.ERROR') }} {{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.ERROR') }}
</span> </span>
</label> </label>
<div v-if="isAttributeTypeList" class="mb-4"> <div v-if="isAttributeTypeList" class="multiselect--wrap">
<label class="mb-1 block"> <label>
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.LABEL') }} {{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.LABEL') }}
</label> </label>
<div <multiselect
class="rounded-xl border px-3 py-2" ref="tagInput"
:class="isTagInputInvalid ? 'border-n-ruby-9' : 'border-n-weak'" v-model="values"
> :placeholder="
<TagInput $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.PLACEHOLDER')
v-model="values" "
:placeholder=" label="name"
$t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.PLACEHOLDER') track-by="name"
" :class="{ invalid: isMultiselectInvalid }"
allow-create :options="options"
@blur="tagInputTouched = true" multiple
/> taggable
</div> @close="onTouch"
@tag="addTagValue"
/>
<label <label
v-show="isTagInputInvalid" v-show="isMultiselectInvalid"
class="text-n-ruby-9 dark:text-n-ruby-9 text-sm font-normal mt-1" class="text-n-ruby-9 dark:text-n-ruby-9 text-sm font-normal mt-1"
> >
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.ERROR') }} {{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.ERROR') }}
@@ -308,4 +312,22 @@ export default {
padding: 0 0.5rem 0.5rem 0; padding: 0 0.5rem 0.5rem 0;
font-family: monospace; font-family: monospace;
} }
.multiselect--wrap {
margin-bottom: 1rem;
}
::v-deep {
.multiselect {
margin-bottom: 0;
}
.multiselect__content-wrapper {
display: none;
}
.multiselect--active .multiselect__tags {
border-radius: 0.3125rem;
}
}
</style> </style>
@@ -5,12 +5,10 @@ import { required, minLength } from '@vuelidate/validators';
import { getRegexp } from 'shared/helpers/Validators'; import { getRegexp } from 'shared/helpers/Validators';
import { ATTRIBUTE_TYPES } from './constants'; import { ATTRIBUTE_TYPES } from './constants';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
export default { export default {
components: { components: {
NextButton, NextButton,
TagInput,
}, },
props: { props: {
selectedAttribute: { selectedAttribute: {
@@ -37,7 +35,8 @@ export default {
show: true, show: true,
attributeKey: '', attributeKey: '',
values: [], values: [],
tagInputTouched: false, options: [],
isTouched: true,
}; };
}, },
validations: { validations: {
@@ -66,19 +65,20 @@ export default {
})); }));
}, },
setAttributeListValue() { setAttributeListValue() {
return this.selectedAttribute.attribute_values || []; return this.selectedAttribute.attribute_values.map(values => ({
name: values,
}));
}, },
updatedAttributeListValues() { updatedAttributeListValues() {
return this.values; return this.values.map(item => item.name);
}, },
isButtonDisabled() { isButtonDisabled() {
return this.v$.description.$invalid || this.isTagInputEmpty; return this.v$.description.$invalid || this.isMultiselectInvalid;
}, },
isTagInputEmpty() { isMultiselectInvalid() {
return this.isAttributeTypeList && this.values.length === 0; return (
}, this.isAttributeTypeList && this.isTouched && this.values.length === 0
isTagInputInvalid() { );
return this.tagInputTouched && this.isTagInputEmpty;
}, },
pageTitle() { pageTitle() {
@@ -116,6 +116,13 @@ export default {
onClose() { onClose() {
this.$emit('onClose'); this.$emit('onClose');
}, },
addTagValue(tagValue) {
const tag = {
name: tagValue,
};
this.values.push(tag);
this.$refs.tagInput.$el.focus();
},
setFormValues() { setFormValues() {
const regexPattern = this.selectedAttribute.regex_pattern const regexPattern = this.selectedAttribute.regex_pattern
? getRegexp(this.selectedAttribute.regex_pattern).source ? getRegexp(this.selectedAttribute.regex_pattern).source
@@ -218,25 +225,24 @@ export default {
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.ERROR') }} {{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.ERROR') }}
</span> </span>
</label> </label>
<div v-if="isAttributeTypeList" class="mb-4"> <div v-if="isAttributeTypeList" class="multiselect--wrap">
<label class="mb-1 block"> <label>
{{ $t('ATTRIBUTES_MGMT.EDIT.TYPE.LIST.LABEL') }} {{ $t('ATTRIBUTES_MGMT.EDIT.TYPE.LIST.LABEL') }}
</label> </label>
<div <multiselect
class="rounded-xl border px-3 py-2" ref="tagInput"
:class="isTagInputInvalid ? 'border-n-ruby-9' : 'border-n-weak'" v-model="values"
> :placeholder="$t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.PLACEHOLDER')"
<TagInput label="name"
v-model="values" track-by="name"
:placeholder=" :class="{ invalid: isMultiselectInvalid }"
$t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.PLACEHOLDER') :options="options"
" multiple
allow-create taggable
@blur="tagInputTouched = true" @tag="addTagValue"
/> />
</div>
<label <label
v-show="isTagInputInvalid" v-show="isMultiselectInvalid"
class="text-n-ruby-9 dark:text-n-ruby-9 text-sm font-normal mt-1" class="text-n-ruby-9 dark:text-n-ruby-9 text-sm font-normal mt-1"
> >
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.ERROR') }} {{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.ERROR') }}
@@ -291,4 +297,22 @@ export default {
padding: 0 0.5rem 0.5rem 0; padding: 0 0.5rem 0.5rem 0;
font-family: monospace; font-family: monospace;
} }
.multiselect--wrap {
margin-bottom: 1rem;
}
::v-deep {
.multiselect {
margin-bottom: 0;
}
.multiselect__content-wrapper {
display: none;
}
.multiselect--active .multiselect__tags {
border-radius: 0.3125rem;
}
}
</style> </style>
@@ -1,12 +1,21 @@
<script setup> <script>
import { ref, onMounted } from 'vue'; import { mapGetters } from 'vuex';
import { useStore } from 'dashboard/composables/store'; import FilterInputBox from 'dashboard/components/widgets/FilterInput/Index.vue';
import AutomationActionInput from 'dashboard/components/widgets/AutomationActionInput.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { useAutomation } from 'dashboard/composables/useAutomation'; import { useAutomation } from 'dashboard/composables/useAutomation';
import AutomationRuleForm from './AutomationRuleForm.vue'; import { validateAutomation } from 'dashboard/helper/validations';
import {
generateAutomationPayload,
getAttributes,
getInputType,
getOperators,
getCustomAttributeType,
showActionInput,
} from 'dashboard/helper/automationHelper';
import { AUTOMATION_RULE_EVENTS, AUTOMATION_ACTION_TYPES } from './constants';
const emit = defineEmits(['saveAutomation']); const start_value = {
const START_VALUE = {
name: null, name: null,
description: null, description: null,
event_name: 'conversation_created', event_name: 'conversation_created',
@@ -27,60 +36,318 @@ const START_VALUE = {
], ],
}; };
const store = useStore(); export default {
const formRef = ref(null); components: {
FilterInputBox,
AutomationActionInput,
NextButton,
},
props: {
onClose: {
type: Function,
default: () => {},
},
},
emits: ['saveAutomation'],
setup() {
const {
automation,
automationTypes,
onEventChange,
getConditionDropdownValues,
appendNewCondition,
appendNewAction,
removeFilter,
removeAction,
resetFilter,
resetAction,
getActionDropdownValues,
manifestCustomAttributes,
} = useAutomation(start_value);
return {
automation,
automationTypes,
onEventChange,
getConditionDropdownValues,
appendNewCondition,
appendNewAction,
removeFilter,
removeAction,
resetFilter,
resetAction,
getActionDropdownValues,
manifestCustomAttributes,
};
},
data() {
return {
automationRuleEvent: AUTOMATION_RULE_EVENTS[0].key,
automationMutated: false,
show: true,
showDeleteConfirmationModal: false,
allCustomAttributes: [],
mode: 'create',
errors: {},
};
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
automationRuleEvents() {
return AUTOMATION_RULE_EVENTS.map(event => ({
...event,
value: this.$t(`AUTOMATION.EVENTS.${event.value}`),
}));
},
hasAutomationMutated() {
if (
this.automation.conditions[0].values ||
this.automation.actions[0].action_params.length
)
return true;
return false;
},
automationActionTypes() {
const actionTypes = this.isFeatureEnabled('sla')
? AUTOMATION_ACTION_TYPES
: AUTOMATION_ACTION_TYPES.filter(({ key }) => key !== 'add_sla');
const { return actionTypes.map(action => ({
automation, ...action,
automationTypes, label: this.$t(`AUTOMATION.ACTIONS.${action.label}`),
onEventChange, }));
getConditionDropdownValues, },
appendNewCondition, },
appendNewAction, mounted() {
removeFilter, this.$store.dispatch('inboxes/get');
removeAction, this.$store.dispatch('agents/get');
resetAction, this.$store.dispatch('contacts/get');
getActionDropdownValues, this.$store.dispatch('teams/get');
manifestCustomAttributes, this.$store.dispatch('labels/get');
} = useAutomation(START_VALUE); this.$store.dispatch('campaigns/get');
this.allCustomAttributes = this.$store.getters['attributes/getAttributes'];
this.manifestCustomAttributes();
},
methods: {
getAttributes,
getInputType,
getOperators,
getCustomAttributeType,
showActionInput,
isFeatureEnabled(flag) {
return this.isFeatureEnabledonAccount(this.accountId, flag);
},
emitSaveAutomation() {
this.errors = validateAutomation(this.automation);
if (Object.keys(this.errors).length === 0) {
const automation = generateAutomationPayload(this.automation);
this.$emit('saveAutomation', automation, this.mode);
}
},
getTranslatedAttributes(type, event) {
return getAttributes(type, event).map(attribute => {
// Skip translation
// 1. If customAttributeType key is present then its rendering attributes from API
// 2. If contact_custom_attribute or conversation_custom_attribute is present then its rendering section title
const skipTranslation =
attribute.customAttributeType ||
[
'contact_custom_attribute',
'conversation_custom_attribute',
].includes(attribute.key);
const open = () => { return {
automation.value = structuredClone(START_VALUE); ...attribute,
manifestCustomAttributes(); name: skipTranslation
formRef.value?.open(); ? attribute.name
: this.$t(`AUTOMATION.ATTRIBUTES.${attribute.name}`),
};
});
},
},
}; };
const close = () => formRef.value?.close();
const onSave = (payload, mode) => {
emit('saveAutomation', payload, mode);
};
onMounted(() => {
store.dispatch('inboxes/get');
store.dispatch('agents/get');
store.dispatch('contacts/get');
store.dispatch('teams/get');
store.dispatch('labels/get');
store.dispatch('campaigns/get');
});
defineExpose({ open, close });
</script> </script>
<template> <template>
<AutomationRuleForm <div>
ref="formRef" <woot-modal-header :header-title="$t('AUTOMATION.ADD.TITLE')" />
v-model:automation="automation" <div class="flex flex-col modal-content">
mode="create" <div class="w-full">
:automation-types="automationTypes" <woot-input
:get-condition-dropdown-values="getConditionDropdownValues" v-model="automation.name"
:get-action-dropdown-values="getActionDropdownValues" :label="$t('AUTOMATION.ADD.FORM.NAME.LABEL')"
:append-new-condition="appendNewCondition" type="text"
:append-new-action="appendNewAction" :class="{ error: errors.name }"
:remove-filter="removeFilter" :error="errors.name ? $t('AUTOMATION.ADD.FORM.NAME.ERROR') : ''"
:remove-action="removeAction" :placeholder="$t('AUTOMATION.ADD.FORM.NAME.PLACEHOLDER')"
:reset-action="resetAction" />
:on-event-change="onEventChange" <woot-input
@save="onSave" v-model="automation.description"
/> :label="$t('AUTOMATION.ADD.FORM.DESC.LABEL')"
type="text"
:class="{ error: errors.description }"
:error="
errors.description ? $t('AUTOMATION.ADD.FORM.DESC.ERROR') : ''
"
:placeholder="$t('AUTOMATION.ADD.FORM.DESC.PLACEHOLDER')"
/>
<div class="mb-6">
<label :class="{ error: errors.event_name }">
{{ $t('AUTOMATION.ADD.FORM.EVENT.LABEL') }}
<select
v-model="automation.event_name"
class="m-0"
@change="onEventChange(automation)"
>
<option
v-for="event in automationRuleEvents"
:key="event.key"
:value="event.key"
>
{{ event.value }}
</option>
</select>
<span v-if="errors.event_name" class="message">
{{ $t('AUTOMATION.ADD.FORM.EVENT.ERROR') }}
</span>
</label>
<p
v-if="hasAutomationMutated"
class="text-xs text-right text-n-teal-10 pt-1"
>
{{ $t('AUTOMATION.FORM.RESET_MESSAGE') }}
</p>
</div>
<!-- // Conditions Start -->
<section>
<label>
{{ $t('AUTOMATION.ADD.FORM.CONDITIONS.LABEL') }}
</label>
<div
class="w-full p-4 mb-4 border border-solid rounded-lg bg-n-slate-2 dark:bg-n-solid-2 border-n-strong"
>
<FilterInputBox
v-for="(condition, i) in automation.conditions"
:key="i"
v-model="automation.conditions[i]"
:filter-attributes="
getTranslatedAttributes(automationTypes, automation.event_name)
"
:input-type="
getInputType(
allCustomAttributes,
automationTypes,
automation,
automation.conditions[i].attribute_key
)
"
:operators="
getOperators(
allCustomAttributes,
automationTypes,
automation,
mode,
automation.conditions[i].attribute_key
)
"
:dropdown-values="
getConditionDropdownValues(
automation.conditions[i].attribute_key
)
"
:show-query-operator="i !== automation.conditions.length - 1"
:custom-attribute-type="
getCustomAttributeType(
automationTypes,
automation,
automation.conditions[i].attribute_key
)
"
:error-message="
errors[`condition_${i}`]
? $t(`AUTOMATION.ERRORS.${errors[`condition_${i}`]}`)
: ''
"
@reset-filter="resetFilter(i, automation.conditions[i])"
@remove-filter="removeFilter(i)"
/>
<div class="mt-4">
<NextButton
icon="i-lucide-plus"
blue
faded
sm
:label="$t('AUTOMATION.ADD.CONDITION_BUTTON_LABEL')"
@click="appendNewCondition"
/>
</div>
</div>
</section>
<!-- // Conditions End -->
<!-- // Actions Start -->
<section>
<label>
{{ $t('AUTOMATION.ADD.FORM.ACTIONS.LABEL') }}
</label>
<div
class="w-full p-4 mb-4 border border-solid rounded-lg bg-n-slate-2 dark:bg-n-solid-2 border-n-strong"
>
<AutomationActionInput
v-for="(action, i) in automation.actions"
:key="i"
v-model="automation.actions[i]"
:action-types="automationActionTypes"
:dropdown-values="
getActionDropdownValues(automation.actions[i].action_name)
"
:show-action-input="
showActionInput(
automationActionTypes,
automation.actions[i].action_name
)
"
:error-message="
errors[`action_${i}`]
? $t(`AUTOMATION.ERRORS.${errors[`action_${i}`]}`)
: ''
"
@reset-action="resetAction(i)"
@remove-action="removeAction(i)"
/>
<div class="mt-4">
<NextButton
icon="i-lucide-plus"
blue
faded
sm
:label="$t('AUTOMATION.ADD.ACTION_BUTTON_LABEL')"
@click="appendNewAction"
/>
</div>
</div>
</section>
<!-- // Actions End -->
<div class="w-full">
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<NextButton
faded
slate
type="reset"
:label="$t('AUTOMATION.ADD.CANCEL_BUTTON_TEXT')"
@click.prevent="onClose"
/>
<NextButton
solid
blue
type="submit"
:label="$t('AUTOMATION.ADD.SUBMIT')"
@click="emitSaveAutomation"
/>
</div>
</div>
</div>
</div>
</div>
</template> </template>
@@ -1,414 +0,0 @@
<script setup>
import { ref, computed, h, useTemplateRef, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import { useOperators } from 'dashboard/components-next/filter/operators';
import ConditionRow from 'dashboard/components-next/filter/ConditionRow.vue';
import AutomationActionInput from 'dashboard/components/widgets/AutomationActionInput.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import {
generateAutomationPayload,
getAttributes,
getFileName,
showActionInput,
} from 'dashboard/helper/automationHelper';
import { validateAutomation } from 'dashboard/helper/validations';
import { AUTOMATION_RULE_EVENTS, AUTOMATION_ACTION_TYPES } from './constants';
const props = defineProps({
mode: {
type: String,
required: true,
validator: value => ['create', 'edit'].includes(value),
},
automationTypes: {
type: Object,
required: true,
},
getConditionDropdownValues: {
type: Function,
required: true,
},
getActionDropdownValues: {
type: Function,
required: true,
},
appendNewCondition: {
type: Function,
required: true,
},
appendNewAction: {
type: Function,
required: true,
},
removeFilter: {
type: Function,
required: true,
},
removeAction: {
type: Function,
required: true,
},
resetAction: {
type: Function,
required: true,
},
onEventChange: {
type: Function,
required: true,
},
});
const emit = defineEmits(['save']);
const automation = defineModel('automation', { type: Object, default: null });
const INPUT_TYPE_MAP = {
multi_select: 'multiSelect',
search_select: 'searchSelect',
plain_text: 'plainText',
comma_separated_plain_text: 'plainText',
date: 'date',
};
const { t } = useI18n();
const { isCloudFeatureEnabled } = useAccount();
const { operators } = useOperators();
const dialogRef = ref(null);
const conditionsRef = useTemplateRef('conditionsRef');
const errors = ref({});
const isEditMode = computed(() => props.mode === 'edit');
const titleKey = computed(() =>
isEditMode.value ? 'AUTOMATION.EDIT.TITLE' : 'AUTOMATION.ADD.TITLE'
);
const cancelKey = computed(() =>
isEditMode.value
? 'AUTOMATION.EDIT.CANCEL_BUTTON_TEXT'
: 'AUTOMATION.ADD.CANCEL_BUTTON_TEXT'
);
const submitKey = computed(() =>
isEditMode.value ? 'AUTOMATION.EDIT.SUBMIT' : 'AUTOMATION.ADD.SUBMIT'
);
const getTranslatedAttributes = (type, event) => {
return getAttributes(type, event).map(attribute => {
const skipTranslation =
attribute.customAttributeType ||
['contact_custom_attribute', 'conversation_custom_attribute'].includes(
attribute.key
);
return {
...attribute,
name: skipTranslation
? attribute.name
: t(`AUTOMATION.ATTRIBUTES.${attribute.name}`),
};
});
};
const eventName = computed(() => automation.value?.event_name);
const filterTypes = computed(() => {
const event = eventName.value;
if (!event || !props.automationTypes[event]) return [];
const attributes = getTranslatedAttributes(props.automationTypes, event);
return attributes.map(attr => {
if (attr.disabled) {
return { value: attr.key, label: attr.name, disabled: true };
}
const mappedInputType = INPUT_TYPE_MAP[attr.inputType] || 'plainText';
const options = props.getConditionDropdownValues(attr.key) || [];
const filterOperators = (attr.filterOperators || []).map(op => {
const enriched = operators.value[op.value];
if (enriched) return enriched;
return {
value: op.value,
label: t(`FILTER.OPERATOR_LABELS.${op.value}`),
hasInput: true,
inputOverride: null,
icon: h('span', { class: 'i-ph-equals-bold !text-n-blue-11' }),
};
});
return {
attributeKey: attr.key,
value: attr.key,
attributeName: attr.name,
label: attr.name,
inputType: mappedInputType,
options,
filterOperators,
dataType: 'text',
attributeModel: attr.customAttributeType || 'standard',
};
});
});
const automationRuleEvents = computed(() =>
AUTOMATION_RULE_EVENTS.map(event => ({
...event,
value: t(`AUTOMATION.EVENTS.${event.value}`),
}))
);
const hasAutomationMutated = computed(() => {
return Boolean(
automation.value?.conditions[0]?.values ||
automation.value?.actions[0]?.action_params?.length
);
});
const automationActionTypes = computed(() => {
const actionTypes = isCloudFeatureEnabled('sla')
? AUTOMATION_ACTION_TYPES
: AUTOMATION_ACTION_TYPES.filter(({ key }) => key !== 'add_sla');
return actionTypes.map(action => ({
...action,
label: t(`AUTOMATION.ACTIONS.${action.label}`),
}));
});
const hasConditionErrors = computed(() =>
Object.keys(errors.value).some(key => key.startsWith('condition_'))
);
const hasActionErrors = computed(() =>
Object.keys(errors.value).some(key => key.startsWith('action_'))
);
watch(
() => automation.value,
() => {
if (Object.keys(errors.value).length) {
errors.value = {};
}
},
{ deep: true }
);
const isConditionsValid = () => {
if (!conditionsRef.value) return true;
return conditionsRef.value.every(condition => condition.validate());
};
const resetValidation = () => {
errors.value = {};
conditionsRef.value?.forEach(c => c.resetValidation());
};
const syncCustomAttributeTypes = () => {
automation.value.conditions.forEach(condition => {
const filterType = filterTypes.value.find(
ft => ft.attributeKey === condition.attribute_key
);
condition.custom_attribute_type =
filterType?.attributeModel === 'standard'
? ''
: filterType?.attributeModel || '';
});
};
const open = () => {
resetValidation();
dialogRef.value?.open();
};
const close = () => {
resetValidation();
dialogRef.value?.close();
};
const emitSaveAutomation = () => {
syncCustomAttributeTypes();
const conditionsValid = isConditionsValid();
errors.value = validateAutomation(automation.value);
if (Object.keys(errors.value).length === 0 && conditionsValid) {
const payload = generateAutomationPayload(automation.value);
emit('save', payload, props.mode);
}
};
defineExpose({ open, close });
</script>
<template>
<Dialog
ref="dialogRef"
width="3xl"
position="top"
:title="$t(titleKey)"
:show-cancel-button="false"
:show-confirm-button="false"
overflow-y-auto
>
<div v-if="automation" class="flex flex-col w-full">
<woot-input
v-model="automation.name"
:label="$t('AUTOMATION.ADD.FORM.NAME.LABEL')"
type="text"
:class="{ error: errors.name }"
:error="errors.name ? $t('AUTOMATION.ADD.FORM.NAME.ERROR') : ''"
:placeholder="$t('AUTOMATION.ADD.FORM.NAME.PLACEHOLDER')"
/>
<woot-input
v-model="automation.description"
:label="$t('AUTOMATION.ADD.FORM.DESC.LABEL')"
type="text"
:class="{ error: errors.description }"
:error="errors.description ? $t('AUTOMATION.ADD.FORM.DESC.ERROR') : ''"
:placeholder="$t('AUTOMATION.ADD.FORM.DESC.PLACEHOLDER')"
/>
<div class="mb-6">
<label :class="{ error: errors.event_name }">
{{ $t('AUTOMATION.ADD.FORM.EVENT.LABEL') }}
<select
v-model="automation.event_name"
class="m-0"
@change="onEventChange()"
>
<option
v-for="event in automationRuleEvents"
:key="event.key"
:value="event.key"
>
{{ event.value }}
</option>
</select>
<span v-if="errors.event_name" class="message">
{{ $t('AUTOMATION.ADD.FORM.EVENT.ERROR') }}
</span>
</label>
<p
v-if="!isEditMode && hasAutomationMutated"
class="text-xs text-right text-n-teal-10 pt-1"
>
{{ $t('AUTOMATION.FORM.RESET_MESSAGE') }}
</p>
</div>
<!-- Conditions Start -->
<section class="mb-5">
<label>
{{ $t('AUTOMATION.ADD.FORM.CONDITIONS.LABEL') }}
</label>
<ul
class="grid gap-4 list-none p-3 mb-4 outline outline-1 rounded-xl -outline-offset-1"
:class="
hasConditionErrors
? 'outline-n-ruby-5 bg-n-ruby-2/50'
: 'outline-n-weak dark:outline-n-strong'
"
>
<template v-for="(condition, i) in automation.conditions" :key="i">
<ConditionRow
v-if="i === 0"
ref="conditionsRef"
v-model:attribute-key="automation.conditions[i].attribute_key"
v-model:filter-operator="automation.conditions[i].filter_operator"
v-model:values="automation.conditions[i].values"
:filter-types="filterTypes"
:show-query-operator="false"
@remove="removeFilter(i)"
/>
<ConditionRow
v-else
ref="conditionsRef"
v-model:attribute-key="automation.conditions[i].attribute_key"
v-model:filter-operator="automation.conditions[i].filter_operator"
v-model:query-operator="
automation.conditions[i - 1].query_operator
"
v-model:values="automation.conditions[i].values"
:filter-types="filterTypes"
show-query-operator
@remove="removeFilter(i)"
/>
</template>
<div>
<NextButton
icon="i-lucide-plus"
blue
faded
sm
:label="$t('AUTOMATION.ADD.CONDITION_BUTTON_LABEL')"
@click="appendNewCondition"
/>
</div>
</ul>
</section>
<!-- Conditions End -->
<!-- Actions Start -->
<section>
<label>
{{ $t('AUTOMATION.ADD.FORM.ACTIONS.LABEL') }}
</label>
<ul
class="grid list-none p-3 mb-4 outline outline-1 rounded-xl -outline-offset-1 border-solid"
:class="
hasActionErrors
? 'outline-n-ruby-5 bg-n-ruby-2/50'
: 'outline-n-weak dark:outline-n-strong'
"
>
<AutomationActionInput
v-for="(action, i) in automation.actions"
:key="i"
v-model="automation.actions[i]"
:action-types="automationActionTypes"
dropdown-max-height="max-h-[7.5rem]"
:dropdown-values="getActionDropdownValues(action.action_name)"
:show-action-input="
showActionInput(automationActionTypes, action.action_name)
"
:error-message="
errors[`action_${i}`]
? $t(`AUTOMATION.ERRORS.${errors[`action_${i}`]}`)
: ''
"
:initial-file-name="
isEditMode ? getFileName(action, automation.files) : ''
"
@reset-action="resetAction(i)"
@remove-action="removeAction(i)"
/>
<div class="pt-2">
<NextButton
icon="i-lucide-plus"
blue
faded
sm
:label="$t('AUTOMATION.ADD.ACTION_BUTTON_LABEL')"
@click="appendNewAction"
/>
</div>
</ul>
</section>
<!-- Actions End -->
<div class="w-full mt-8">
<div class="flex flex-row justify-end w-full gap-2 px-0 py-4">
<NextButton
faded
slate
type="reset"
:label="$t(cancelKey)"
@click.prevent="close"
/>
<NextButton
solid
blue
type="submit"
:label="$t(submitKey)"
@click="emitSaveAutomation"
/>
</div>
</div>
</div>
</Dialog>
</template>
@@ -1,80 +1,345 @@
<script setup> <script>
import { ref, watch } from 'vue'; import { mapGetters } from 'vuex';
import { useMapGetter } from 'dashboard/composables/store';
import { useAutomation } from 'dashboard/composables/useAutomation'; import { useAutomation } from 'dashboard/composables/useAutomation';
import { useEditableAutomation } from 'dashboard/composables/useEditableAutomation'; import { useEditableAutomation } from 'dashboard/composables/useEditableAutomation';
import AutomationRuleForm from './AutomationRuleForm.vue'; import FilterInputBox from 'dashboard/components/widgets/FilterInput/Index.vue';
import { AUTOMATION_ACTION_TYPES } from './constants'; import NextButton from 'dashboard/components-next/button/Button.vue';
import AutomationActionInput from 'dashboard/components/widgets/AutomationActionInput.vue';
import {
getFileName,
generateAutomationPayload,
getAttributes,
getInputType,
getOperators,
getCustomAttributeType,
showActionInput,
} from 'dashboard/helper/automationHelper';
import { validateAutomation } from 'dashboard/helper/validations';
const props = defineProps({ import { AUTOMATION_RULE_EVENTS, AUTOMATION_ACTION_TYPES } from './constants';
selectedResponse: {
type: Object, export default {
default: () => ({}), components: {
FilterInputBox,
NextButton,
AutomationActionInput,
}, },
}); props: {
onClose: {
const emit = defineEmits(['saveAutomation']); type: Function,
default: () => {},
const allCustomAttributes = useMapGetter('attributes/getAttributes'); },
const formRef = ref(null); selectedResponse: {
type: Object,
const { default: () => {},
automation, },
automationTypes, },
onEventChange, emits: ['saveAutomation'],
getConditionDropdownValues, setup() {
appendNewCondition, const {
appendNewAction, automation,
removeFilter,
removeAction,
resetAction,
getActionDropdownValues,
manifestCustomAttributes,
} = useAutomation();
const { formatAutomation } = useEditableAutomation();
const open = () => formRef.value?.open();
const close = () => formRef.value?.close();
const onSave = (payload, mode) => {
emit('saveAutomation', payload, mode);
};
watch(
() => props.selectedResponse,
value => {
if (!value?.conditions) return;
manifestCustomAttributes();
automation.value = formatAutomation(
value,
allCustomAttributes.value,
automationTypes, automationTypes,
AUTOMATION_ACTION_TYPES onEventChange,
getConditionDropdownValues,
appendNewCondition,
appendNewAction,
removeFilter,
removeAction,
resetFilter,
resetAction,
getActionDropdownValues,
manifestCustomAttributes,
} = useAutomation();
const { formatAutomation } = useEditableAutomation();
return {
automation,
automationTypes,
onEventChange,
getConditionDropdownValues,
appendNewCondition,
appendNewAction,
removeFilter,
removeAction,
resetFilter,
resetAction,
getActionDropdownValues,
formatAutomation,
manifestCustomAttributes,
};
},
data() {
return {
automationRuleEvent: AUTOMATION_RULE_EVENTS[0].key,
automationMutated: false,
show: true,
showDeleteConfirmationModal: false,
allCustomAttributes: [],
mode: 'edit',
errors: {},
};
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
automationRuleEvents() {
return AUTOMATION_RULE_EVENTS.map(event => ({
...event,
value: this.$t(`AUTOMATION.EVENTS.${event.value}`),
}));
},
hasAutomationMutated() {
if (
this.automation.conditions[0].values ||
this.automation.actions[0].action_params.length
)
return true;
return false;
},
automationActionTypes() {
const actionTypes = this.isFeatureEnabled('sla')
? AUTOMATION_ACTION_TYPES
: AUTOMATION_ACTION_TYPES.filter(({ key }) => key !== 'add_sla');
return actionTypes.map(action => ({
...action,
label: this.$t(`AUTOMATION.ACTIONS.${action.label}`),
}));
},
},
mounted() {
this.manifestCustomAttributes();
this.allCustomAttributes = this.$store.getters['attributes/getAttributes'];
this.automation = this.formatAutomation(
this.selectedResponse,
this.allCustomAttributes,
this.automationTypes,
this.automationActionTypes
); );
}, },
{ immediate: true } methods: {
); getFileName,
getAttributes,
getInputType,
getOperators,
getCustomAttributeType,
showActionInput,
isFeatureEnabled(flag) {
return this.isFeatureEnabledonAccount(this.accountId, flag);
},
emitSaveAutomation() {
this.errors = validateAutomation(this.automation);
if (Object.keys(this.errors).length === 0) {
const automation = generateAutomationPayload(this.automation);
this.$emit('saveAutomation', automation, this.mode);
}
},
getTranslatedAttributes(type, event) {
return getAttributes(type, event).map(attribute => {
// Skip translation
// 1. If customAttributeType key is present then its rendering attributes from API
// 2. If contact_custom_attribute or conversation_custom_attribute is present then its rendering section title
const skipTranslation =
attribute.customAttributeType ||
[
'contact_custom_attribute',
'conversation_custom_attribute',
].includes(attribute.key);
defineExpose({ open, close }); return {
...attribute,
name: skipTranslation
? attribute.name
: this.$t(`AUTOMATION.ATTRIBUTES.${attribute.name}`),
};
});
},
},
};
</script> </script>
<template> <template>
<AutomationRuleForm <div>
ref="formRef" <woot-modal-header :header-title="$t('AUTOMATION.EDIT.TITLE')" />
v-model:automation="automation" <div class="flex flex-col modal-content">
mode="edit" <div v-if="automation" class="w-full">
:automation-types="automationTypes" <woot-input
:get-condition-dropdown-values="getConditionDropdownValues" v-model="automation.name"
:get-action-dropdown-values="getActionDropdownValues" :label="$t('AUTOMATION.ADD.FORM.NAME.LABEL')"
:append-new-condition="appendNewCondition" type="text"
:append-new-action="appendNewAction" :class="{ error: errors.name }"
:remove-filter="removeFilter" :error="errors.name ? $t('AUTOMATION.ADD.FORM.NAME.ERROR') : ''"
:remove-action="removeAction" :placeholder="$t('AUTOMATION.ADD.FORM.NAME.PLACEHOLDER')"
:reset-action="resetAction" />
:on-event-change="onEventChange" <woot-input
@save="onSave" v-model="automation.description"
/> :label="$t('AUTOMATION.ADD.FORM.DESC.LABEL')"
type="text"
:class="{ error: errors.description }"
:error="
errors.description ? $t('AUTOMATION.ADD.FORM.DESC.ERROR') : ''
"
:placeholder="$t('AUTOMATION.ADD.FORM.DESC.PLACEHOLDER')"
/>
<div class="event_wrapper">
<label :class="{ error: errors.event_name }">
{{ $t('AUTOMATION.ADD.FORM.EVENT.LABEL') }}
<select
v-model="automation.event_name"
@change="onEventChange(automation)"
>
<option
v-for="event in automationRuleEvents"
:key="event.key"
:value="event.key"
>
{{ event.value }}
</option>
</select>
<span v-if="errors.event_name" class="message">
{{ $t('AUTOMATION.ADD.FORM.EVENT.ERROR') }}
</span>
</label>
</div>
<!-- // Conditions Start -->
<section>
<label>
{{ $t('AUTOMATION.ADD.FORM.CONDITIONS.LABEL') }}
</label>
<div
class="w-full p-4 mb-4 border border-solid rounded-lg bg-n-slate-2 dark:bg-n-solid-2 border-n-strong"
>
<FilterInputBox
v-for="(condition, i) in automation.conditions"
:key="i"
v-model="automation.conditions[i]"
:filter-attributes="
getTranslatedAttributes(automationTypes, automation.event_name)
"
:input-type="
getInputType(
allCustomAttributes,
automationTypes,
automation,
automation.conditions[i].attribute_key
)
"
:operators="
getOperators(
allCustomAttributes,
automationTypes,
automation,
mode,
automation.conditions[i].attribute_key
)
"
:dropdown-values="
getConditionDropdownValues(
automation.conditions[i].attribute_key
)
"
:custom-attribute-type="
getCustomAttributeType(
automationTypes,
automation,
automation.conditions[i].attribute_key
)
"
:show-query-operator="i !== automation.conditions.length - 1"
:error-message="
errors[`condition_${i}`]
? $t(`AUTOMATION.ERRORS.${errors[`condition_${i}`]}`)
: ''
"
@reset-filter="resetFilter(i, automation.conditions[i])"
@remove-filter="removeFilter(i)"
/>
<div class="mt-4">
<NextButton
icon="i-lucide-plus"
blue
faded
sm
:label="$t('AUTOMATION.ADD.CONDITION_BUTTON_LABEL')"
@click="appendNewCondition"
/>
</div>
</div>
</section>
<!-- // Conditions End -->
<!-- // Actions Start -->
<section>
<label>
{{ $t('AUTOMATION.ADD.FORM.ACTIONS.LABEL') }}
</label>
<div
class="w-full p-4 mb-4 border border-solid rounded-lg bg-n-slate-2 dark:bg-n-solid-2 border-n-strong"
>
<AutomationActionInput
v-for="(action, i) in automation.actions"
:key="i"
v-model="automation.actions[i]"
:action-types="automationActionTypes"
:dropdown-values="getActionDropdownValues(action.action_name)"
:show-action-input="
showActionInput(automationActionTypes, action.action_name)
"
:error-message="
errors[`action_${i}`]
? $t(`AUTOMATION.ERRORS.${errors[`action_${i}`]}`)
: ''
"
:initial-file-name="getFileName(action, automation.files)"
@reset-action="resetAction(i)"
@remove-action="removeAction(i)"
/>
<div class="mt-4">
<NextButton
icon="i-lucide-plus"
blue
faded
sm
:label="$t('AUTOMATION.ADD.ACTION_BUTTON_LABEL')"
@click="appendNewAction"
/>
</div>
</div>
</section>
<!-- // Actions End -->
<div class="w-full">
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<NextButton
faded
slate
type="reset"
:label="$t('AUTOMATION.EDIT.CANCEL_BUTTON_TEXT')"
@click.prevent="onClose"
/>
<NextButton
solid
blue
type="submit"
:label="$t('AUTOMATION.EDIT.SUBMIT')"
@click="emitSaveAutomation"
/>
</div>
</div>
</div>
</div>
</div>
</template> </template>
<style lang="scss" scoped>
.event_wrapper {
select {
@apply m-0;
}
.info-message {
@apply text-xs text-n-teal-10 text-right;
}
@apply mb-6;
}
</style>
@@ -16,8 +16,8 @@ const { t } = useI18n();
const confirmDialog = ref(null); const confirmDialog = ref(null);
const loading = ref({}); const loading = ref({});
const addDialogRef = ref(null); const showAddPopup = ref(false);
const editDialogRef = ref(null); const showEditPopup = ref(false);
const showDeleteConfirmationPopup = ref(false); const showDeleteConfirmationPopup = ref(false);
const selectedAutomation = ref({}); const selectedAutomation = ref({});
const toggleModalTitle = ref(t('AUTOMATION.TOGGLE.ACTIVATION_TITLE')); const toggleModalTitle = ref(t('AUTOMATION.TOGGLE.ACTIVATION_TITLE'));
@@ -57,18 +57,18 @@ onMounted(() => {
}); });
const openAddPopup = () => { const openAddPopup = () => {
addDialogRef.value?.open(); showAddPopup.value = true;
}; };
const hideAddPopup = () => { const hideAddPopup = () => {
addDialogRef.value?.close(); showAddPopup.value = false;
}; };
const openEditPopup = response => { const openEditPopup = response => {
selectedAutomation.value = { ...response }; selectedAutomation.value = response;
editDialogRef.value?.open(); showEditPopup.value = true;
}; };
const hideEditPopup = () => { const hideEditPopup = () => {
editDialogRef.value?.close(); showEditPopup.value = false;
}; };
const openDeletePopup = response => { const openDeletePopup = response => {
@@ -221,7 +221,17 @@ const tableHeaders = computed(() => {
</table> </table>
</template> </template>
<AddAutomationRule ref="addDialogRef" @save-automation="submitAutomation" /> <woot-modal
v-model:show="showAddPopup"
size="medium"
:on-close="hideAddPopup"
>
<AddAutomationRule
v-if="showAddPopup"
:on-close="hideAddPopup"
@save-automation="submitAutomation"
/>
</woot-modal>
<woot-delete-modal <woot-delete-modal
v-model:show="showDeleteConfirmationPopup" v-model:show="showDeleteConfirmationPopup"
@@ -234,11 +244,18 @@ const tableHeaders = computed(() => {
:reject-text="deleteRejectText" :reject-text="deleteRejectText"
/> />
<EditAutomationRule <woot-modal
ref="editDialogRef" v-model:show="showEditPopup"
:selected-response="selectedAutomation" size="medium"
@save-automation="submitAutomation" :on-close="hideEditPopup"
/> >
<EditAutomationRule
v-if="showEditPopup"
:on-close="hideEditPopup"
:selected-response="selectedAutomation"
@save-automation="submitAutomation"
/>
</woot-modal>
<woot-confirm-modal <woot-confirm-modal
ref="confirmDialog" ref="confirmDialog"
:title="toggleModalTitle" :title="toggleModalTitle"
@@ -5,7 +5,6 @@ import { useAlert } from 'dashboard/composables';
import InboxMembersAPI from '../../../../api/inboxMembers'; import InboxMembersAPI from '../../../../api/inboxMembers';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
import router from '../../../index'; import router from '../../../index';
import PageHeader from '../SettingsSubPageHeader.vue'; import PageHeader from '../SettingsSubPageHeader.vue';
import { useVuelidate } from '@vuelidate/core'; import { useVuelidate } from '@vuelidate/core';
@@ -14,12 +13,11 @@ export default {
components: { components: {
PageHeader, PageHeader,
NextButton, NextButton,
TagInput,
}, },
validations: { validations: {
selectedAgentIds: { selectedAgents: {
isEmpty() { isEmpty() {
return !!this.selectedAgentIds.length; return !!this.selectedAgents.length;
}, },
}, },
}, },
@@ -28,7 +26,7 @@ export default {
}, },
data() { data() {
return { return {
selectedAgentIds: [], selectedAgents: [],
isCreating: false, isCreating: false,
}; };
}, },
@@ -36,43 +34,18 @@ export default {
...mapGetters({ ...mapGetters({
agentList: 'agents/getAgents', agentList: 'agents/getAgents',
}), }),
selectedAgentNames() {
return this.selectedAgentIds.map(
id => this.agentList.find(a => a.id === id)?.name ?? ''
);
},
agentMenuItems() {
return this.agentList
.filter(({ id }) => !this.selectedAgentIds.includes(id))
.map(({ id, name, thumbnail, avatar_url }) => ({
label: name,
value: id,
action: 'select',
thumbnail: { name, src: thumbnail || avatar_url || '' },
}));
},
}, },
mounted() { mounted() {
this.$store.dispatch('agents/get'); this.$store.dispatch('agents/get');
}, },
methods: { methods: {
handleAgentAdd({ value }) {
if (!this.selectedAgentIds.includes(value)) {
this.selectedAgentIds.push(value);
}
},
handleAgentRemove(index) {
this.selectedAgentIds.splice(index, 1);
},
async addAgents() { async addAgents() {
this.isCreating = true; this.isCreating = true;
const inboxId = this.$route.params.inbox_id; const inboxId = this.$route.params.inbox_id;
const selectedAgents = this.selectedAgents.map(x => x.id);
try { try {
await InboxMembersAPI.update({ await InboxMembersAPI.update({ inboxId, agentList: selectedAgents });
inboxId,
agentList: this.selectedAgentIds,
});
router.replace({ router.replace({
name: 'settings_inbox_finish', name: 'settings_inbox_finish',
params: { params: {
@@ -99,23 +72,25 @@ export default {
/> />
</div> </div>
<div> <div>
<div class="w-full mb-4"> <div class="w-full">
<label :class="{ error: v$.selectedAgentIds.$error }"> <label :class="{ error: v$.selectedAgents.$error }">
{{ $t('INBOX_MGMT.ADD.AGENTS.TITLE') }} {{ $t('INBOX_MGMT.ADD.AGENTS.TITLE') }}
<div <multiselect
class="rounded-xl outline outline-1 -outline-offset-1 outline-n-weak hover:outline-n-strong px-2 py-2" v-model="selectedAgents"
> :options="agentList"
<TagInput track-by="id"
:model-value="selectedAgentNames" label="name"
:placeholder="$t('INBOX_MGMT.ADD.AGENTS.PICK_AGENTS')" multiple
:menu-items="agentMenuItems" :close-on-select="false"
show-dropdown :clear-on-select="false"
skip-label-dedup hide-selected
@add="handleAgentAdd" selected-label
@remove="handleAgentRemove" :select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
/> :deselect-label="$t('FORMS.MULTISELECT.ENTER_TO_REMOVE')"
</div> :placeholder="$t('INBOX_MGMT.ADD.AGENTS.PICK_AGENTS')"
<span v-if="v$.selectedAgentIds.$error" class="message"> @select="v$.selectedAgents.$touch"
/>
<span v-if="v$.selectedAgents.$error" class="message">
{{ $t('INBOX_MGMT.ADD.AGENTS.VALIDATION_ERROR') }} {{ $t('INBOX_MGMT.ADD.AGENTS.VALIDATION_ERROR') }}
</span> </span>
</label> </label>
@@ -12,7 +12,6 @@ import PageHeader from '../../SettingsSubPageHeader.vue';
import router from '../../../../index'; import router from '../../../../index';
import { useBranding } from 'shared/composables/useBranding'; import { useBranding } from 'shared/composables/useBranding';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import { loadScript } from 'dashboard/helper/DOMHelpers'; import { loadScript } from 'dashboard/helper/DOMHelpers';
import * as Sentry from '@sentry/vue'; import * as Sentry from '@sentry/vue';
@@ -22,7 +21,6 @@ export default {
LoadingState, LoadingState,
PageHeader, PageHeader,
NextButton, NextButton,
ComboBox,
}, },
setup() { setup() {
const { accountId } = useAccount(); const { accountId } = useAccount();
@@ -69,12 +67,6 @@ export default {
getSelectablePages() { getSelectablePages() {
return this.pageList.filter(item => !item.exists); return this.pageList.filter(item => !item.exists);
}, },
comboBoxPageOptions() {
return this.getSelectablePages.map(({ id, name }) => ({
value: id,
label: name,
}));
},
}, },
mounted() { mounted() {
@@ -102,16 +94,9 @@ export default {
} }
}, },
setPageName(pageId) { setPageName({ name }) {
const page = this.pageList.find(p => p.id === pageId);
if (page) {
this.selectedPage = page;
this.pageName = page.name;
} else {
this.selectedPage = { name: null, id: null };
this.pageName = '';
}
this.v$.selectedPage.$touch(); this.v$.selectedPage.$touch();
this.pageName = name;
}, },
initChannelAuth(channel) { initChannelAuth(channel) {
@@ -260,20 +245,23 @@ export default {
/> />
</div> </div>
<div class="w-3/5"> <div class="w-3/5">
<div class="w-full mb-2"> <div class="w-full">
<div class="input-wrap" :class="{ error: v$.selectedPage.$error }"> <div class="input-wrap" :class="{ error: v$.selectedPage.$error }">
<span class="text-n-slate-12 text-start"> {{ $t('INBOX_MGMT.ADD.FB.CHOOSE_PAGE') }}
{{ $t('INBOX_MGMT.ADD.FB.CHOOSE_PAGE') }} <multiselect
</span> v-model="selectedPage"
<ComboBox close-on-select
:model-value="selectedPage.id" allow-empty
:options="comboBoxPageOptions" :options="getSelectablePages"
track-by="id"
label="name"
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
:deselect-label="$t('FORMS.MULTISELECT.ENTER_TO_REMOVE')"
:placeholder="$t('INBOX_MGMT.ADD.FB.PICK_A_VALUE')" :placeholder="$t('INBOX_MGMT.ADD.FB.PICK_A_VALUE')"
:has-error="v$.selectedPage.$error" selected-label
class="[&>div>button]:!bg-n-alpha-black2 mt-1" @select="setPageName"
@update:model-value="setPageName"
/> />
<span v-if="v$.selectedPage.$error" class="message mt-0.5"> <span v-if="v$.selectedPage.$error" class="message">
{{ $t('INBOX_MGMT.ADD.FB.CHOOSE_PLACEHOLDER') }} {{ $t('INBOX_MGMT.ADD.FB.CHOOSE_PLACEHOLDER') }}
</span> </span>
</div> </div>
@@ -1,321 +1,122 @@
<script setup> <script>
import { ref, computed, watch, onMounted } from 'vue'; import { mapGetters } from 'vuex';
import { useStore } from 'vuex';
import { useRoute, useRouter } from 'vue-router';
import { vOnClickOutside } from '@vueuse/components';
import { useVuelidate } from '@vuelidate/core'; import { useVuelidate } from '@vuelidate/core';
import { minValue } from '@vuelidate/validators'; import { minValue } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import { useConfig } from 'dashboard/composables/useConfig'; import { useConfig } from 'dashboard/composables/useConfig';
import SettingsSection from '../../../../../components/SettingsSection.vue'; import SettingsSection from '../../../../../components/SettingsSection.vue';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import Switch from 'dashboard/components-next/switch/Switch.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import assignmentPoliciesAPI from 'dashboard/api/assignmentPolicies';
import { useI18n } from 'vue-i18n';
const props = defineProps({ export default {
inbox: { components: {
type: Object, SettingsSection,
default: () => ({}), NextButton,
}, },
}); props: {
inbox: {
const store = useStore(); type: Object,
const route = useRoute(); default: () => ({}),
const router = useRouter(); },
const { t } = useI18n();
const { isEnterprise } = useConfig();
const selectedAgents = ref([]);
const isAgentListUpdating = ref(false);
const enableAutoAssignment = ref(false);
const maxAssignmentLimit = ref(null);
const assignmentPolicy = ref(null);
const isLoadingPolicy = ref(false);
const isDeletingPolicy = ref(false);
const showDeleteConfirmModal = ref(false);
const availablePolicies = ref([]);
const isLoadingPolicies = ref(false);
const showPolicyDropdown = ref(false);
const isLinkingPolicy = ref(false);
const agentList = computed(() => store.getters['agents/getAgents']);
const isFeatureEnabled = feature => {
const accountId = Number(route.params.accountId);
return store.getters['accounts/isFeatureEnabledonAccount'](
accountId,
feature
);
};
const hasAdvancedAssignment = computed(() => {
return isFeatureEnabled('advanced_assignment');
});
const hasAssignmentV2 = computed(() => {
return isFeatureEnabled('assignment_v2');
});
const showAdvancedAssignmentUI = computed(() => {
return hasAdvancedAssignment.value && hasAssignmentV2.value;
});
const assignmentOrderLabel = computed(() => {
if (!assignmentPolicy.value) return '';
const priority = assignmentPolicy.value.conversation_priority;
if (priority === 'earliest_created') {
return t('INBOX_MGMT.ASSIGNMENT.PRIORITY.EARLIEST_CREATED');
}
if (priority === 'longest_waiting') {
return t('INBOX_MGMT.ASSIGNMENT.PRIORITY.LONGEST_WAITING');
}
return priority;
});
const assignmentMethodLabel = computed(() => {
if (!assignmentPolicy.value) return '';
const order = assignmentPolicy.value.assignment_order;
if (order === 'round_robin') {
return t('INBOX_MGMT.ASSIGNMENT.METHOD.ROUND_ROBIN');
}
if (order === 'balanced') {
return t('INBOX_MGMT.ASSIGNMENT.METHOD.BALANCED');
}
return order;
});
// Vuelidate validation rules
const rules = {
maxAssignmentLimit: {
minValue: minValue(1),
}, },
}; setup() {
const { isEnterprise } = useConfig();
const v$ = useVuelidate(rules, { maxAssignmentLimit }); return { v$: useVuelidate(), isEnterprise };
},
const maxAssignmentLimitErrors = computed(() => { data() {
if (v$.value.maxAssignmentLimit.$error) { return {
return t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT_RANGE_ERROR'); selectedAgents: [],
} isAgentListUpdating: false,
return ''; enableAutoAssignment: false,
}); maxAssignmentLimit: null,
const fetchAttachedAgents = async () => {
try {
const response = await store.dispatch('inboxMembers/get', {
inboxId: props.inbox.id,
});
const {
data: { payload: inboxMembers },
} = response;
selectedAgents.value = inboxMembers;
} catch (error) {
// Handle error
}
};
const fetchAssignmentPolicy = async () => {
if (!props.inbox.id) return;
isLoadingPolicy.value = true;
try {
const response = await assignmentPoliciesAPI.getInboxPolicy(props.inbox.id);
assignmentPolicy.value = response.data;
} catch (error) {
// No policy attached, which is fine
assignmentPolicy.value = null;
} finally {
isLoadingPolicy.value = false;
}
};
const fetchAvailablePolicies = async () => {
isLoadingPolicies.value = true;
try {
const response = await assignmentPoliciesAPI.get();
availablePolicies.value = response.data;
} catch (error) {
availablePolicies.value = [];
} finally {
isLoadingPolicies.value = false;
}
};
const linkPolicyToInbox = async policy => {
isLinkingPolicy.value = true;
try {
await assignmentPoliciesAPI.setInboxPolicy(props.inbox.id, policy.id);
assignmentPolicy.value = policy;
showPolicyDropdown.value = false;
useAlert(t('INBOX_MGMT.ASSIGNMENT.LINK_SUCCESS'));
} catch (error) {
useAlert(t('INBOX_MGMT.ASSIGNMENT.LINK_ERROR'));
} finally {
isLinkingPolicy.value = false;
}
};
const navigateToAssignmentPolicies = () => {
const accountId = route.params.accountId;
router.push({
name: 'agent_assignment_policy_index',
params: { accountId },
});
};
const policyMenuItems = computed(() => {
const items = availablePolicies.value.map(policy => ({
action: 'select_policy',
value: policy.id,
label: policy.name,
icon: 'i-lucide-zap',
policy,
}));
items.push({
action: 'view_all',
value: 'view_all',
label: t('INBOX_MGMT.ASSIGNMENT.VIEW_ALL_POLICIES'),
icon: 'i-lucide-arrow-right',
});
return items;
});
const handlePolicyMenuAction = ({ action, policy }) => {
if (action === 'select_policy' && policy) {
linkPolicyToInbox(policy);
} else if (action === 'view_all') {
navigateToAssignmentPolicies();
}
showPolicyDropdown.value = false;
};
const togglePolicyDropdown = () => {
if (!showPolicyDropdown.value && availablePolicies.value.length === 0) {
fetchAvailablePolicies();
}
showPolicyDropdown.value = !showPolicyDropdown.value;
};
const closePolicyDropdown = () => {
showPolicyDropdown.value = false;
};
const handleToggleAutoAssignment = async () => {
try {
const payload = {
id: props.inbox.id,
formData: false,
enable_auto_assignment: enableAutoAssignment.value,
}; };
await store.dispatch('inboxes/updateInbox', payload); },
useAlert(t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE')); computed: {
} catch (error) { ...mapGetters({
useAlert(t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE')); agentList: 'agents/getAgents',
} }),
}; maxAssignmentLimitErrors() {
if (this.v$.maxAssignmentLimit.$error) {
const updateAgents = async () => { return this.$t(
const agentListIds = selectedAgents.value.map(el => el.id); 'INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT_RANGE_ERROR'
isAgentListUpdating.value = true; );
try { }
await store.dispatch('inboxMembers/create', { return '';
inboxId: props.inbox.id, },
agentList: agentListIds, },
}); watch: {
useAlert(t('AGENT_MGMT.EDIT.API.SUCCESS_MESSAGE')); inbox() {
} catch (error) { this.setDefaults();
useAlert(t('AGENT_MGMT.EDIT.API.ERROR_MESSAGE')); },
} },
isAgentListUpdating.value = false; mounted() {
}; this.setDefaults();
},
const updateInbox = async () => { methods: {
try { setDefaults() {
const payload = { this.enableAutoAssignment = this.inbox.enable_auto_assignment;
id: props.inbox.id, this.maxAssignmentLimit =
formData: false, this.inbox?.auto_assignment_config?.max_assignment_limit || null;
enable_auto_assignment: enableAutoAssignment.value, this.fetchAttachedAgents();
auto_assignment_config: { },
max_assignment_limit: maxAssignmentLimit.value, async fetchAttachedAgents() {
try {
const response = await this.$store.dispatch('inboxMembers/get', {
inboxId: this.inbox.id,
});
const {
data: { payload: inboxMembers },
} = response;
this.selectedAgents = inboxMembers;
} catch (error) {
// Handle error
}
},
handleEnableAutoAssignment() {
this.updateInbox();
},
async updateAgents() {
const agentList = this.selectedAgents.map(el => el.id);
this.isAgentListUpdating = true;
try {
await this.$store.dispatch('inboxMembers/create', {
inboxId: this.inbox.id,
agentList,
});
useAlert(this.$t('AGENT_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('AGENT_MGMT.EDIT.API.ERROR_MESSAGE'));
}
this.isAgentListUpdating = false;
},
async updateInbox() {
try {
const payload = {
id: this.inbox.id,
formData: false,
enable_auto_assignment: this.enableAutoAssignment,
auto_assignment_config: {
max_assignment_limit: this.maxAssignmentLimit,
},
};
await this.$store.dispatch('inboxes/updateInbox', payload);
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
}
},
},
validations: {
selectedAgents: {
isEmpty() {
return !!this.selectedAgents.length;
}, },
}; },
await store.dispatch('inboxes/updateInbox', payload); maxAssignmentLimit: {
useAlert(t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE')); minValue: minValue(1),
} catch (error) { },
useAlert(t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE')); },
}
}; };
const navigateToCreatePolicy = () => {
const accountId = route.params.accountId;
router.push({
name: 'agent_assignment_policy_create',
params: { accountId },
query: { inboxId: props.inbox.id },
});
};
const navigateToAssignmentPolicyEdit = () => {
if (!assignmentPolicy.value?.id) return;
const accountId = route.params.accountId;
router.push({
name: 'agent_assignment_policy_edit',
params: { accountId, id: assignmentPolicy.value.id },
});
};
const navigateToBilling = () => {
const accountId = route.params.accountId;
router.push({
name: 'billing_settings_index',
params: { accountId },
});
};
const confirmDeletePolicy = () => {
showDeleteConfirmModal.value = true;
};
const cancelDeletePolicy = () => {
showDeleteConfirmModal.value = false;
};
const deleteAssignmentPolicy = async () => {
if (isDeletingPolicy.value) return;
isDeletingPolicy.value = true;
try {
await assignmentPoliciesAPI.removeInboxPolicy(props.inbox.id);
assignmentPolicy.value = null;
showDeleteConfirmModal.value = false;
useAlert(t('INBOX_MGMT.ASSIGNMENT_POLICY.DELETE_SUCCESS'));
} catch (error) {
useAlert(t('INBOX_MGMT.ASSIGNMENT_POLICY.DELETE_ERROR'));
} finally {
isDeletingPolicy.value = false;
}
};
const setDefaults = () => {
enableAutoAssignment.value = props.inbox.enable_auto_assignment;
maxAssignmentLimit.value =
props.inbox.auto_assignment_config?.max_assignment_limit || null;
fetchAttachedAgents();
if (showAdvancedAssignmentUI.value) {
fetchAssignmentPolicy();
fetchAvailablePolicies();
}
};
// Watch only inbox.id to avoid unnecessary refetches when other properties change
watch(() => props.inbox.id, setDefaults);
onMounted(() => {
setDefaults();
});
</script> </script>
<template> <template>
@@ -337,6 +138,7 @@ onMounted(() => {
selected-label selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')" :select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
:deselect-label="$t('FORMS.MULTISELECT.ENTER_TO_REMOVE')" :deselect-label="$t('FORMS.MULTISELECT.ENTER_TO_REMOVE')"
@select="v$.selectedAgents.$touch"
/> />
<NextButton <NextButton
@@ -350,325 +152,44 @@ onMounted(() => {
:title="$t('INBOX_MGMT.SETTINGS_POPUP.AGENT_ASSIGNMENT')" :title="$t('INBOX_MGMT.SETTINGS_POPUP.AGENT_ASSIGNMENT')"
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.AGENT_ASSIGNMENT_SUB_TEXT')" :sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.AGENT_ASSIGNMENT_SUB_TEXT')"
> >
<!-- New UI for assignment_v2 --> <label class="w-3/4 settings-item">
<template v-if="hasAssignmentV2"> <div class="flex items-center gap-2">
<div class="flex items-start gap-3"> <input
<Switch id="enableAutoAssignment"
v-model="enableAutoAssignment" v-model="enableAutoAssignment"
class="flex-shrink-0 mt-0.5" type="checkbox"
@change="handleToggleAutoAssignment" @change="handleEnableAutoAssignment"
/> />
<div class="flex-grow"> <label for="enableAutoAssignment">
<label class="text-sm text-n-slate-12 font-medium mb-1"> {{ $t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT') }}
{{ $t('INBOX_MGMT.ASSIGNMENT.ENABLE_AUTO_ASSIGNMENT') }} </label>
</label>
<p class="text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.ASSIGNMENT.DESCRIPTION') }}
</p>
</div>
</div> </div>
<Transition <p class="pb-1 text-sm not-italic text-n-slate-11">
enter-active-class="transition-all duration-200 ease-out" {{ $t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT_SUB_TEXT') }}
enter-from-class="opacity-0 -translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition-all duration-150 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-2"
>
<div v-if="enableAutoAssignment" class="mt-6">
<!-- Policy Card - When policy is attached -->
<div
v-if="showAdvancedAssignmentUI && assignmentPolicy"
class="p-4 rounded-xl outline-1 outline-n-weak outline bg-n-solid-1 dark:bg-n-slate-1"
>
<div class="flex items-start gap-4">
<div
class="flex-shrink-0 size-12 rounded-xl bg-n-slate-3 flex items-center justify-center"
>
<span class="i-lucide-zap text-xl text-n-slate-11" />
</div>
<div class="flex-grow">
<div class="flex items-start justify-between gap-4 mb-4">
<div class="flex flex-col items-start">
<span class="text-base font-medium text-n-slate-12 mb-1">
{{ assignmentPolicy.name }}
</span>
<p class="text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.ASSIGNMENT.POLICY_LABEL') }}
</p>
</div>
<NextButton
icon="i-lucide-trash-2"
ghost
ruby
sm
@click="confirmDeletePolicy"
/>
</div>
<ul class="space-y-2 mb-6">
<li class="flex items-center gap-2">
<span
class="w-1.5 h-1.5 rounded-full bg-n-slate-11 flex-shrink-0"
/>
<span class="text-sm text-n-slate-12">
{{ assignmentOrderLabel }}
</span>
</li>
<li class="flex items-center gap-2">
<span
class="w-1.5 h-1.5 rounded-full bg-n-slate-11 flex-shrink-0"
/>
<span class="text-sm text-n-slate-12">
{{ assignmentMethodLabel }}
</span>
</li>
</ul>
<div class="w-full h-px my-4 bg-n-weak" />
<NextButton
:label="$t('INBOX_MGMT.ASSIGNMENT.CUSTOMIZE_POLICY')"
icon="i-lucide-arrow-right"
trailing-icon
link
class="mb-2"
@click="navigateToAssignmentPolicyEdit"
/>
</div>
</div>
</div>
<!-- Default Policy - When no custom policy attached but feature enabled -->
<div
v-else-if="
showAdvancedAssignmentUI &&
!assignmentPolicy &&
!isLoadingPolicy
"
class="rounded-xl outline-1 outline-n-weak outline"
>
<!-- Default Policy Header -->
<div class="p-4">
<div class="flex items-start gap-4">
<div
class="flex-shrink-0 w-12 h-12 rounded-xl bg-n-slate-3 dark:bg-n-slate-4 flex items-center justify-center"
>
<i class="i-lucide-zap text-xl text-n-slate-11" />
</div>
<div class="flex-grow">
<h4 class="text-base font-medium text-n-slate-12 mb-1">
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_POLICY_LINKED') }}
</h4>
<p class="text-sm text-n-slate-11">
{{
$t('INBOX_MGMT.ASSIGNMENT.DEFAULT_POLICY_DESCRIPTION')
}}
</p>
</div>
</div>
<!-- Action Buttons -->
<div class="mt-5 flex items-center gap-3">
<div
v-if="!isLoadingPolicies && availablePolicies.length > 0"
v-on-click-outside="closePolicyDropdown"
class="relative"
>
<button
type="button"
class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-n-brand hover:bg-n-brand/90 rounded-lg transition-colors"
@click="togglePolicyDropdown"
>
<i class="i-lucide-link text-sm" />
{{ $t('INBOX_MGMT.ASSIGNMENT.LINK_EXISTING_POLICY') }}
<i
class="i-lucide-chevron-down text-sm transition-transform"
:class="{ 'rotate-180': showPolicyDropdown }"
/>
</button>
<DropdownMenu
v-if="showPolicyDropdown"
class="top-full left-0 mt-2 min-w-72"
:menu-items="policyMenuItems"
:is-searching="isLoadingPolicies"
@action="handlePolicyMenuAction"
/>
</div>
<button
type="button"
class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-n-slate-12 bg-n-slate-3 dark:bg-n-slate-4 hover:bg-n-slate-4 dark:hover:bg-n-slate-5 rounded-lg transition-colors"
@click="navigateToCreatePolicy"
>
<i class="i-lucide-plus text-sm" />
{{ $t('INBOX_MGMT.ASSIGNMENT.CREATE_NEW_POLICY') }}
</button>
</div>
</div>
<!-- Default Rules Info -->
<div class="px-4 py-4 border-t border-n-weak bg-n-slate-2">
<div class="flex items-start gap-3">
<i class="i-lucide-info text-base text-n-slate-10 mt-0.5" />
<div>
<p class="text-sm text-n-slate-11 mb-2">
{{ $t('INBOX_MGMT.ASSIGNMENT.CURRENT_BEHAVIOR') }}
</p>
<ul class="space-y-1">
<li class="flex items-center gap-2">
<span
class="w-1 h-1 rounded-full bg-n-slate-10 flex-shrink-0"
/>
<span class="text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULE_1') }}
</span>
</li>
<li class="flex items-center gap-2">
<span
class="w-1 h-1 rounded-full bg-n-slate-10 flex-shrink-0"
/>
<span class="text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULE_2') }}
</span>
</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Default Rules Card - Feature not enabled (no advanced_assignment) -->
<div
v-else-if="!showAdvancedAssignmentUI"
class="p-4 rounded-xl outline outline-1 outline-n-weak -outline-offset-1"
>
<div class="flex items-start gap-4">
<div
class="flex-shrink-0 w-12 h-12 rounded-xl bg-n-slate-3 dark:bg-n-slate-4 flex items-center justify-center"
>
<i class="i-lucide-zap text-xl text-n-slate-11" />
</div>
<div class="flex-grow">
<h4 class="text-base font-medium text-n-slate-12 mb-1">
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULES_TITLE') }}
</h4>
<p class="text-sm text-n-slate-11 mb-4">
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULES_DESCRIPTION') }}
</p>
<ul class="space-y-2 mb-6">
<li class="flex items-center gap-2">
<span
class="w-1.5 h-1.5 rounded-full bg-n-slate-11 flex-shrink-0"
/>
<span class="text-sm font-medium text-n-slate-12">
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULE_1') }}
</span>
</li>
<li class="flex items-center gap-2">
<span
class="w-1.5 h-1.5 rounded-full bg-n-slate-11 flex-shrink-0"
/>
<span class="text-sm font-medium text-n-slate-12">
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULE_2') }}
</span>
</li>
</ul>
<div class="w-full h-px bg-n-weak my-4" />
<!-- Upgrade prompt when advanced_assignment is not enabled -->
<div v-if="!hasAdvancedAssignment">
<p class="text-sm text-n-slate-11 mb-1">
{{ $t('INBOX_MGMT.ASSIGNMENT.UPGRADE_PROMPT') }}
</p>
<NextButton
:label="$t('INBOX_MGMT.ASSIGNMENT.UPGRADE_TO_BUSINESS')"
icon="i-lucide-arrow-right"
trailing-icon
link
@click="navigateToBilling"
/>
</div>
</div>
</div>
</div>
</div>
</Transition>
</template>
<!-- Old UI for non-assignment_v2 -->
<template v-else>
<label class="w-3/4 settings-item">
<div class="flex items-center gap-2">
<input
id="enableAutoAssignment"
v-model="enableAutoAssignment"
type="checkbox"
@change="handleToggleAutoAssignment"
/>
<label for="enableAutoAssignment">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT') }}
</label>
</div>
<p class="pb-1 text-sm not-italic text-n-slate-11">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT_SUB_TEXT') }}
</p>
</label>
<div v-if="enableAutoAssignment && isEnterprise" class="py-3">
<woot-input
v-model="maxAssignmentLimit"
type="number"
:class="{ error: v$.maxAssignmentLimit.$error }"
:error="maxAssignmentLimitErrors"
:label="$t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT')"
@blur="v$.maxAssignmentLimit.$touch"
/>
<p class="pb-1 text-sm not-italic text-n-slate-11">
{{ $t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT_SUB_TEXT') }}
</p>
<NextButton
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
:disabled="v$.maxAssignmentLimit.$invalid"
@click="updateInbox"
/>
</div>
</template>
</SettingsSection>
<woot-modal
v-if="showDeleteConfirmModal"
:show="showDeleteConfirmModal"
:on-close="cancelDeletePolicy"
>
<div class="p-6">
<h3 class="text-lg font-medium text-n-slate-12 mb-4">
{{ $t('INBOX_MGMT.ASSIGNMENT_POLICY.DELETE_CONFIRM_TITLE') }}
</h3>
<p class="text-sm text-n-slate-11 mb-6 ml-13">
{{ $t('INBOX_MGMT.ASSIGNMENT_POLICY.DELETE_CONFIRM_MESSAGE') }}
</p> </p>
<div class="flex justify-end gap-2"> </label>
<NextButton
color="slate" <div v-if="enableAutoAssignment && isEnterprise" class="py-3">
:label="$t('INBOX_MGMT.ASSIGNMENT_POLICY.CANCEL')" <woot-input
@click="cancelDeletePolicy" v-model="maxAssignmentLimit"
/> type="number"
<NextButton :class="{ error: v$.maxAssignmentLimit.$error }"
color="ruby" :error="maxAssignmentLimitErrors"
:label="$t('INBOX_MGMT.ASSIGNMENT_POLICY.CONFIRM_DELETE')" :label="$t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT')"
:is-loading="isDeletingPolicy" @blur="v$.maxAssignmentLimit.$touch"
@click="deleteAssignmentPolicy" />
/>
</div> <p class="pb-1 text-sm not-italic text-n-slate-11">
{{ $t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT_SUB_TEXT') }}
</p>
<NextButton
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
:disabled="v$.maxAssignmentLimit.$invalid"
@click="updateInbox"
/>
</div> </div>
</woot-modal> </SettingsSection>
</div> </div>
</template> </template>
@@ -96,7 +96,7 @@ const actions = {
data: payload, data: payload,
}); });
if (!payload.length) { if (!payload.length) {
commit(types.SET_ALL_MESSAGES_LOADED, data.conversationId); commit(types.SET_ALL_MESSAGES_LOADED);
} }
} catch (error) { } catch (error) {
// Handle error // Handle error
@@ -191,7 +191,7 @@ const actions = {
async setActiveChat({ commit, dispatch }, { data, after }) { async setActiveChat({ commit, dispatch }, { data, after }) {
commit(types.SET_CURRENT_CHAT_WINDOW, data); commit(types.SET_CURRENT_CHAT_WINDOW, data);
commit(types.CLEAR_ALL_MESSAGES_LOADED, data.id); commit(types.CLEAR_ALL_MESSAGES_LOADED);
if (data.dataFetched === undefined) { if (data.dataFetched === undefined) {
try { try {
await dispatch('fetchPreviousMessages', { await dispatch('fetchPreviousMessages', {
@@ -199,7 +199,7 @@ const actions = {
before: data.messages[0].id, before: data.messages[0].id,
conversationId: data.id, conversationId: data.id,
}); });
commit(types.SET_CHAT_DATA_FETCHED, data.id); data.dataFetched = true;
} catch (error) { } catch (error) {
// Ignore error // Ignore error
} }
@@ -212,17 +212,14 @@ const actions = {
conversationId, conversationId,
agentId, agentId,
}); });
dispatch('setCurrentChatAssignee', { dispatch('setCurrentChatAssignee', response.data);
conversationId,
assignee: response.data,
});
} catch (error) { } catch (error) {
// Handle error // Handle error
} }
}, },
setCurrentChatAssignee({ commit }, { conversationId, assignee }) { setCurrentChatAssignee({ commit }, assignee) {
commit(types.ASSIGN_AGENT, { conversationId, assignee }); commit(types.ASSIGN_AGENT, assignee);
}, },
assignTeam: async ({ dispatch }, { conversationId, teamId }) => { assignTeam: async ({ dispatch }, { conversationId, teamId }) => {
@@ -63,18 +63,14 @@ export const mutations = {
_state.allConversations = []; _state.allConversations = [];
_state.selectedChatId = null; _state.selectedChatId = null;
}, },
[types.SET_ALL_MESSAGES_LOADED](_state, conversationId) { [types.SET_ALL_MESSAGES_LOADED](_state) {
const chat = getConversationById(_state)(conversationId); const [chat] = getSelectedChatConversation(_state);
if (chat) { chat.allMessagesLoaded = true;
chat.allMessagesLoaded = true;
}
}, },
[types.CLEAR_ALL_MESSAGES_LOADED](_state, conversationId) { [types.CLEAR_ALL_MESSAGES_LOADED](_state) {
const chat = getConversationById(_state)(conversationId); const [chat] = getSelectedChatConversation(_state);
if (chat) { chat.allMessagesLoaded = false;
chat.allMessagesLoaded = false;
}
}, },
[types.CLEAR_CURRENT_CHAT_WINDOW](_state) { [types.CLEAR_CURRENT_CHAT_WINDOW](_state) {
_state.selectedChatId = null; _state.selectedChatId = null;
@@ -95,24 +91,15 @@ export const mutations = {
chat.messages = data; chat.messages = data;
}, },
[types.SET_CHAT_DATA_FETCHED](_state, conversationId) {
const chat = getConversationById(_state)(conversationId);
if (chat) {
chat.dataFetched = true;
}
},
[types.SET_CURRENT_CHAT_WINDOW](_state, activeChat) { [types.SET_CURRENT_CHAT_WINDOW](_state, activeChat) {
if (activeChat) { if (activeChat) {
_state.selectedChatId = activeChat.id; _state.selectedChatId = activeChat.id;
} }
}, },
[types.ASSIGN_AGENT](_state, { conversationId, assignee }) { [types.ASSIGN_AGENT](_state, assignee) {
const chat = getConversationById(_state)(conversationId); const [chat] = getSelectedChatConversation(_state);
if (chat) { chat.meta.assignee = assignee;
chat.meta.assignee = assignee;
}
}, },
[types.ASSIGN_TEAM](_state, { team, conversationId }) { [types.ASSIGN_TEAM](_state, { team, conversationId }) {
@@ -287,10 +274,8 @@ export const mutations = {
// Update assignee on action cable message // Update assignee on action cable message
[types.UPDATE_ASSIGNEE](_state, payload) { [types.UPDATE_ASSIGNEE](_state, payload) {
const chat = getConversationById(_state)(payload.id); const [chat] = _state.allConversations.filter(c => c.id === payload.id);
if (chat) { chat.meta.assignee = payload.assignee;
chat.meta.assignee = payload.assignee;
}
}, },
[types.UPDATE_CONVERSATION_CONTACT](_state, { conversationId, ...payload }) { [types.UPDATE_CONVERSATION_CONTACT](_state, { conversationId, ...payload }) {
@@ -355,26 +355,22 @@ describe('#actions', () => {
axios.post.mockResolvedValue({ axios.post.mockResolvedValue({
data: { id: 1, name: 'User' }, data: { id: 1, name: 'User' },
}); });
await actions.assignAgent( await actions.assignAgent({ commit }, { conversationId: 1, agentId: 1 });
{ dispatch }, expect(commit).toHaveBeenCalledTimes(0);
{ conversationId: 1, agentId: 1 } expect(commit.mock.calls).toEqual([]);
);
expect(dispatch).toHaveBeenCalledWith('setCurrentChatAssignee', {
conversationId: 1,
assignee: { id: 1, name: 'User' },
});
}); });
}); });
describe('#setCurrentChatAssignee', () => { describe('#setCurrentChatAssignee', () => {
it('sends correct mutations if assignment is successful', async () => { it('sends correct mutations if assignment is successful', async () => {
const payload = { axios.post.mockResolvedValue({
conversationId: 1, data: { id: 1, name: 'User' },
assignee: { id: 1, name: 'User' }, });
}; await actions.setCurrentChatAssignee({ commit }, { id: 1, name: 'User' });
await actions.setCurrentChatAssignee({ commit }, payload);
expect(commit).toHaveBeenCalledTimes(1); expect(commit).toHaveBeenCalledTimes(1);
expect(commit.mock.calls).toEqual([['ASSIGN_AGENT', payload]]); expect(commit.mock.calls).toEqual([
['ASSIGN_AGENT', { id: 1, name: 'User' }],
]);
}); });
}); });
@@ -720,64 +716,6 @@ describe('#addMentions', () => {
}); });
}); });
describe('#setActiveChat', () => {
it('should commit SET_CHAT_DATA_FETCHED with conversation ID after fetch', async () => {
const localCommit = vi.fn();
const localDispatch = vi.fn().mockResolvedValue();
const data = { id: 42, messages: [{ id: 100 }] };
await actions.setActiveChat(
{ commit: localCommit, dispatch: localDispatch },
{ data, after: 99 }
);
expect(localCommit.mock.calls).toEqual([
[types.SET_CURRENT_CHAT_WINDOW, data],
[types.CLEAR_ALL_MESSAGES_LOADED, 42],
[types.SET_CHAT_DATA_FETCHED, 42],
]);
expect(localDispatch).toHaveBeenCalledWith('fetchPreviousMessages', {
after: 99,
before: 100,
conversationId: 42,
});
});
it('should not dispatch fetchPreviousMessages if dataFetched is already set', async () => {
const localCommit = vi.fn();
const localDispatch = vi.fn();
const data = { id: 42, messages: [{ id: 100 }], dataFetched: true };
await actions.setActiveChat(
{ commit: localCommit, dispatch: localDispatch },
{ data }
);
expect(localCommit.mock.calls).toEqual([
[types.SET_CURRENT_CHAT_WINDOW, data],
[types.CLEAR_ALL_MESSAGES_LOADED, 42],
]);
expect(localDispatch).not.toHaveBeenCalled();
});
it('should commit SET_CHAT_DATA_FETCHED by ID, not mutate the data object directly (race condition fix)', async () => {
const localCommit = vi.fn();
const localDispatch = vi.fn().mockResolvedValue();
const data = { id: 42, messages: [{ id: 100 }] };
await actions.setActiveChat(
{ commit: localCommit, dispatch: localDispatch },
{ data }
);
// The action must NOT set dataFetched on the data object directly
expect(data.dataFetched).toBeUndefined();
// Instead it commits a mutation that finds the conversation by ID in the store
expect(localCommit).toHaveBeenCalledWith(types.SET_CHAT_DATA_FETCHED, 42);
});
});
describe('#getInboxCaptainAssistantById', () => { describe('#getInboxCaptainAssistantById', () => {
it('fetches inbox assistant by id', async () => { it('fetches inbox assistant by id', async () => {
axios.get.mockResolvedValue({ axios.get.mockResolvedValue({
@@ -570,84 +570,25 @@ describe('#mutations', () => {
}); });
}); });
describe('#SET_CHAT_DATA_FETCHED', () => { describe('#SET_ALL_MESSAGES_LOADED', () => {
it('should set dataFetched to true on the conversation by ID', () => { it('should set allMessagesLoaded to true on selected chat', () => {
const state = { const state = {
allConversations: [{ id: 1 }, { id: 2 }], allConversations: [{ id: 1, allMessagesLoaded: false }],
};
mutations[types.SET_CHAT_DATA_FETCHED](state, 1);
expect(state.allConversations[0].dataFetched).toBe(true);
expect(state.allConversations[1].dataFetched).toBeUndefined();
});
it('should do nothing if conversation is not found', () => {
const state = { allConversations: [{ id: 1 }] };
mutations[types.SET_CHAT_DATA_FETCHED](state, 999);
expect(state.allConversations[0].dataFetched).toBeUndefined();
});
it('should survive the race: SET_ALL_CONVERSATION replaces the object, then SET_CHAT_DATA_FETCHED still works', () => {
// 1. Initial state: conversation exists with dataFetched undefined
const state = {
allConversations: [{ id: 1, messages: [{ id: 'm1' }] }],
selectedChatId: 1, selectedChatId: 1,
}; };
const originalRef = state.allConversations[0]; mutations[types.SET_ALL_MESSAGES_LOADED](state);
// 2. Simulate SET_ALL_CONVERSATION replacing the object (WebSocket/polling)
// This copies dataFetched from the old object (still undefined)
mutations[types.SET_ALL_CONVERSATION](state, [
{ id: 1, name: 'refreshed', messages: [{ id: 'm2' }] },
]);
// The store now holds a NEW object, old reference is detached
const newRef = state.allConversations[0];
expect(newRef).not.toBe(originalRef);
expect(newRef.dataFetched).toBeUndefined();
// 3. SET_CHAT_DATA_FETCHED finds by ID — works on the current store object
mutations[types.SET_CHAT_DATA_FETCHED](state, 1);
expect(state.allConversations[0].dataFetched).toBe(true);
// Old detached reference is unaffected
expect(originalRef.dataFetched).toBeUndefined();
});
});
describe('#SET_ALL_MESSAGES_LOADED', () => {
it('should set allMessagesLoaded to true on the conversation by ID', () => {
const state = {
allConversations: [{ id: 1, allMessagesLoaded: false }, { id: 2 }],
};
mutations[types.SET_ALL_MESSAGES_LOADED](state, 1);
expect(state.allConversations[0].allMessagesLoaded).toBe(true); expect(state.allConversations[0].allMessagesLoaded).toBe(true);
expect(state.allConversations[1].allMessagesLoaded).toBeUndefined();
});
it('should do nothing if conversation is not found', () => {
const state = { allConversations: [{ id: 1 }] };
mutations[types.SET_ALL_MESSAGES_LOADED](state, 999);
expect(state.allConversations[0].allMessagesLoaded).toBeUndefined();
}); });
}); });
describe('#CLEAR_ALL_MESSAGES_LOADED', () => { describe('#CLEAR_ALL_MESSAGES_LOADED', () => {
it('should set allMessagesLoaded to false on the conversation by ID', () => { it('should set allMessagesLoaded to false on selected chat', () => {
const state = { const state = {
allConversations: [ allConversations: [{ id: 1, allMessagesLoaded: true }],
{ id: 1, allMessagesLoaded: true }, selectedChatId: 1,
{ id: 2, allMessagesLoaded: true },
],
}; };
mutations[types.CLEAR_ALL_MESSAGES_LOADED](state, 1); mutations[types.CLEAR_ALL_MESSAGES_LOADED](state);
expect(state.allConversations[0].allMessagesLoaded).toBe(false); expect(state.allConversations[0].allMessagesLoaded).toBe(false);
expect(state.allConversations[1].allMessagesLoaded).toBe(true);
});
it('should do nothing if conversation is not found', () => {
const state = { allConversations: [{ id: 1, allMessagesLoaded: true }] };
mutations[types.CLEAR_ALL_MESSAGES_LOADED](state, 999);
expect(state.allConversations[0].allMessagesLoaded).toBe(true);
}); });
}); });
@@ -699,22 +640,15 @@ describe('#mutations', () => {
}); });
describe('#ASSIGN_AGENT', () => { describe('#ASSIGN_AGENT', () => {
it('should assign agent to the correct conversation by ID', () => { it('should assign agent to selected conversation', () => {
const assignee = { id: 1, name: 'Agent' }; const assignee = { id: 1, name: 'Agent' };
const state = { const state = {
allConversations: [ allConversations: [{ id: 1, meta: {} }],
{ id: 1, meta: {} }, selectedChatId: 1,
{ id: 2, meta: {} },
],
selectedChatId: 2,
}; };
mutations[types.ASSIGN_AGENT](state, { mutations[types.ASSIGN_AGENT](state, assignee);
conversationId: 1,
assignee,
});
expect(state.allConversations[0].meta.assignee).toEqual(assignee); expect(state.allConversations[0].meta.assignee).toEqual(assignee);
expect(state.allConversations[1].meta.assignee).toBeUndefined();
}); });
}); });
@@ -863,34 +797,6 @@ describe('#mutations', () => {
mutations[types.UPDATE_CONVERSATION](state, conversation); mutations[types.UPDATE_CONVERSATION](state, conversation);
expect(state.allConversations[0].status).toEqual('resolved'); expect(state.allConversations[0].status).toEqual('resolved');
}); });
it('should preserve dataFetched and allMessagesLoaded during update', () => {
const state = {
allConversations: [
{
id: 1,
status: 'open',
updated_at: 100,
messages: [{ id: 'msg1' }],
dataFetched: true,
allMessagesLoaded: true,
},
],
};
const conversation = {
id: 1,
status: 'resolved',
updated_at: 200,
messages: [{ id: 'msg2' }],
};
mutations[types.UPDATE_CONVERSATION](state, conversation);
expect(state.allConversations[0].status).toEqual('resolved');
expect(state.allConversations[0].dataFetched).toBe(true);
expect(state.allConversations[0].allMessagesLoaded).toBe(true);
expect(state.allConversations[0].messages).toEqual([{ id: 'msg1' }]);
});
}); });
describe('#UPDATE_CONVERSATION_CONTACT', () => { describe('#UPDATE_CONVERSATION_CONTACT', () => {
@@ -64,7 +64,6 @@ export default {
SET_CONTEXT_MENU_CHAT_ID: 'SET_CONTEXT_MENU_CHAT_ID', SET_CONTEXT_MENU_CHAT_ID: 'SET_CONTEXT_MENU_CHAT_ID',
SET_CHAT_DATA_FETCHED: 'SET_CHAT_DATA_FETCHED',
SET_CHAT_LIST_FILTERS: 'SET_CHAT_LIST_FILTERS', SET_CHAT_LIST_FILTERS: 'SET_CHAT_LIST_FILTERS',
UPDATE_CHAT_LIST_FILTERS: 'UPDATE_CHAT_LIST_FILTERS', UPDATE_CHAT_LIST_FILTERS: 'UPDATE_CHAT_LIST_FILTERS',
@@ -24,7 +24,7 @@ describe('GoogleOAuthButton.vue', () => {
const googleAuthUrl = new URL(wrapper.vm.getGoogleAuthUrl()); const googleAuthUrl = new URL(wrapper.vm.getGoogleAuthUrl());
const params = googleAuthUrl.searchParams; const params = googleAuthUrl.searchParams;
expect(googleAuthUrl.origin).toBe('https://accounts.google.com'); expect(googleAuthUrl.origin).toBe('https://accounts.google.com');
expect(googleAuthUrl.pathname).toBe('/o/oauth2/auth'); expect(googleAuthUrl.pathname).toBe('/o/oauth2/auth/oauthchooseaccount');
expect(params.get('client_id')).toBe('clientId'); expect(params.get('client_id')).toBe('clientId');
expect(params.get('redirect_uri')).toBe( expect(params.get('redirect_uri')).toBe(
'http://localhost:3000/test-callback' 'http://localhost:3000/test-callback'
@@ -6,7 +6,8 @@ export default {
// Creating the URL manually because the devise-token-auth with // Creating the URL manually because the devise-token-auth with
// omniauth has a standing issue on redirecting the post request // omniauth has a standing issue on redirecting the post request
// https://github.com/lynndylanhurley/devise_token_auth/issues/1466 // https://github.com/lynndylanhurley/devise_token_auth/issues/1466
const baseUrl = 'https://accounts.google.com/o/oauth2/auth'; const baseUrl =
'https://accounts.google.com/o/oauth2/auth/oauthchooseaccount';
const clientId = window.chatwootConfig.googleOAuthClientId; const clientId = window.chatwootConfig.googleOAuthClientId;
const redirectUri = window.chatwootConfig.googleOAuthCallbackUrl; const redirectUri = window.chatwootConfig.googleOAuthCallbackUrl;
const responseType = 'code'; const responseType = 'code';
+1 -3
View File
@@ -27,9 +27,7 @@ class Avatar::AvatarFromUrlJob < ApplicationJob
content_type: avatar_file.content_type content_type: avatar_file.content_type
) )
rescue Down::NotFound rescue Down::NotFound, Down::Error => e
Rails.logger.info "AvatarFromUrlJob: avatar not found at #{avatar_url}"
rescue Down::Error => e
Rails.logger.error "AvatarFromUrlJob error for #{avatar_url}: #{e.class} - #{e.message}" Rails.logger.error "AvatarFromUrlJob error for #{avatar_url}: #{e.class} - #{e.message}"
ensure ensure
update_avatar_sync_attributes(avatarable, avatar_url) update_avatar_sync_attributes(avatarable, avatar_url)
+13
View File
@@ -2,6 +2,19 @@ class Internal::SeedAccountJob < ApplicationJob
queue_as :low queue_as :low
def perform(account) def perform(account)
# Reload account to ensure we have fresh data
account.reload
# Perform seeding
Seeders::AccountSeeder.new(account: account).perform! Seeders::AccountSeeder.new(account: account).perform!
# Reload again after seeding to ensure cache is fresh
account.reload
Rails.logger.info("Account seeding completed successfully for account_id: #{account.id}")
rescue StandardError => e
Rails.logger.error("Account seeding failed for account_id: #{account.id} - #{e.message}")
Rails.logger.error(e.backtrace.join("\n"))
raise
end end
end end
-2
View File
@@ -40,7 +40,6 @@ class Account < ApplicationRecord
'auto_resolve_ignore_waiting': { 'type': %w[boolean null] }, 'auto_resolve_ignore_waiting': { 'type': %w[boolean null] },
'audio_transcriptions': { 'type': %w[boolean null] }, 'audio_transcriptions': { 'type': %w[boolean null] },
'auto_resolve_label': { 'type': %w[string null] }, 'auto_resolve_label': { 'type': %w[string null] },
'keep_pending_on_bot_failure': { 'type': %w[boolean null] },
'conversation_required_attributes': { 'conversation_required_attributes': {
'type': %w[array null], 'type': %w[array null],
'items': { 'type': 'string' } 'items': { 'type': 'string' }
@@ -89,7 +88,6 @@ class Account < ApplicationRecord
store_accessor :settings, :audio_transcriptions, :auto_resolve_label store_accessor :settings, :audio_transcriptions, :auto_resolve_label
store_accessor :settings, :captain_models, :captain_features store_accessor :settings, :captain_models, :captain_features
store_accessor :settings, :keep_pending_on_bot_failure
has_many :account_users, dependent: :destroy_async has_many :account_users, dependent: :destroy_async
has_many :agent_bot_inboxes, dependent: :destroy_async has_many :agent_bot_inboxes, dependent: :destroy_async
@@ -12,10 +12,6 @@ module AccountEmailRateLimitable
Redis::Alfred.get(email_count_cache_key).to_i Redis::Alfred.get(email_count_cache_key).to_i
end end
def email_transcript_enabled?
true
end
def within_email_rate_limit? def within_email_rate_limit?
return true if emails_sent_today < email_rate_limit return true if emails_sent_today < email_rate_limit
@@ -19,18 +19,10 @@ module AutoAssignmentHandler
AutoAssignment::AssignmentJob.perform_later(inbox_id: inbox.id) AutoAssignment::AssignmentJob.perform_later(inbox_id: inbox.id)
else else
# Use legacy assignment system # Use legacy assignment system
# If conversation has a team, only consider team members for assignment AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: inbox.member_ids_with_assignment_capacity).perform
allowed_agent_ids = team_id.present? ? team_member_ids_with_capacity : inbox.member_ids_with_assignment_capacity
AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: allowed_agent_ids).perform
end end
end end
def team_member_ids_with_capacity
return [] if team.blank? || team.allow_auto_assign.blank?
inbox.member_ids_with_assignment_capacity & team.members.ids
end
def should_run_auto_assignment? def should_run_auto_assignment?
return false unless inbox.enable_auto_assignment? return false unless inbox.enable_auto_assignment?
+1 -8
View File
@@ -30,12 +30,10 @@ class CustomAttributeDefinition < ApplicationRecord
scope :with_attribute_model, ->(attribute_model) { attribute_model.presence && where(attribute_model: attribute_model) } scope :with_attribute_model, ->(attribute_model) { attribute_model.presence && where(attribute_model: attribute_model) }
validates :attribute_display_name, presence: true validates :attribute_display_name, presence: true
before_validation :normalize_attribute_fields
validates :attribute_key, validates :attribute_key,
presence: true, presence: true,
uniqueness: { scope: [:account_id, :attribute_model] }, uniqueness: { scope: [:account_id, :attribute_model] }
format: { with: /\A[\p{L}\p{N}_.\-]+\z/, message: I18n.t('errors.custom_attribute_definition.attribute_key_format') }
validates :attribute_display_type, presence: true validates :attribute_display_type, presence: true
validates :attribute_model, presence: true validates :attribute_model, presence: true
@@ -50,11 +48,6 @@ class CustomAttributeDefinition < ApplicationRecord
private private
def normalize_attribute_fields
self.attribute_key = attribute_key.strip if attribute_key.present?
self.attribute_display_name = attribute_display_name.strip if attribute_display_name.present?
end
def sync_widget_pre_chat_custom_fields def sync_widget_pre_chat_custom_fields
::Inboxes::SyncWidgetPreChatCustomFieldsJob.perform_later(account, attribute_key) ::Inboxes::SyncWidgetPreChatCustomFieldsJob.perform_later(account, attribute_key)
end end
+7 -16
View File
@@ -130,15 +130,11 @@ class MailPresenter < SimpleDelegator
end end
def sender_name def sender_name
parse_mail_address((@mail[:reply_to] || @mail[:from]).value)&.name Mail::Address.new((@mail[:reply_to] || @mail[:from]).value).name
end end
def original_sender def original_sender
[ from_email_address(@mail[:reply_to].try(:value)) || @mail['X-Original-Sender'].try(:value) || from_email_address(from.first)
@mail[:reply_to]&.value,
@mail['X-Original-Sender']&.value,
@mail[:from]&.value
].filter_map { |email| parse_mail_address(email)&.address }.first
end end
def headers_data def headers_data
@@ -151,6 +147,10 @@ class MailPresenter < SimpleDelegator
headers.presence headers.presence
end end
def from_email_address(email)
Mail::Address.new(email).address
end
def email_forwarded_for def email_forwarded_for
@mail['X-Forwarded-For'].try(:value) @mail['X-Forwarded-For'].try(:value)
end end
@@ -175,20 +175,11 @@ class MailPresenter < SimpleDelegator
def notification_email_from_chatwoot? def notification_email_from_chatwoot?
# notification emails are send via mailer sender email address. so it should match # notification emails are send via mailer sender email address. so it should match
configured_sender = Mail::Address.new(ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot <accounts@chatwoot.com>')).address original_sender == Mail::Address.new(ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot <accounts@chatwoot.com>')).address
original_sender.to_s.casecmp?(configured_sender)
end end
private private
def parse_mail_address(email)
return if email.blank?
Mail::Address.new(email)
rescue Mail::Field::ParseError, Mail::Field::IncompleteParseError
nil
end
def auto_submitted? def auto_submitted?
@mail['Auto-Submitted'].present? && @mail['Auto-Submitted'].value != 'no' @mail['Auto-Submitted'].present? && @mail['Auto-Submitted'].value != 'no'
end end
-2
View File
@@ -75,8 +75,6 @@ class ActionService
end end
def send_email_transcript(emails) def send_email_transcript(emails)
return unless @account.email_transcript_enabled?
emails = emails[0].gsub(/\s+/, '').split(',') emails = emails[0].gsub(/\s+/, '').split(',')
emails.each do |email| emails.each do |email|
@@ -3,7 +3,6 @@ class AutoAssignment::AssignmentService
def perform_bulk_assignment(limit: 100) def perform_bulk_assignment(limit: 100)
return 0 unless inbox.auto_assignment_v2_enabled? return 0 unless inbox.auto_assignment_v2_enabled?
return 0 unless inbox.enable_auto_assignment?
assigned_count = 0 assigned_count = 0
@@ -19,7 +18,7 @@ class AutoAssignment::AssignmentService
def perform_for_conversation(conversation) def perform_for_conversation(conversation)
return false unless assignable?(conversation) return false unless assignable?(conversation)
agent = find_available_agent(conversation) agent = find_available_agent
return false unless agent return false unless agent
assign_conversation(conversation, agent) assign_conversation(conversation, agent)
@@ -33,9 +32,7 @@ class AutoAssignment::AssignmentService
def unassigned_conversations(limit) def unassigned_conversations(limit)
scope = inbox.conversations.unassigned.open scope = inbox.conversations.unassigned.open
# Apply conversation priority using assignment policy if available scope = if assignment_config['conversation_priority'].to_s == 'longest_waiting'
policy = inbox.assignment_policy
scope = if policy&.longest_waiting?
scope.reorder(last_activity_at: :asc, created_at: :asc) scope.reorder(last_activity_at: :asc, created_at: :asc)
else else
scope.reorder(created_at: :asc) scope.reorder(created_at: :asc)
@@ -44,26 +41,13 @@ class AutoAssignment::AssignmentService
scope.limit(limit) scope.limit(limit)
end end
def find_available_agent(conversation = nil) def find_available_agent
agents = filter_agents_by_team(inbox.available_agents, conversation) agents = filter_agents_by_rate_limit(inbox.available_agents)
return nil if agents.nil?
agents = filter_agents_by_rate_limit(agents)
return nil if agents.empty? return nil if agents.empty?
round_robin_selector.select_agent(agents) round_robin_selector.select_agent(agents)
end end
def filter_agents_by_team(agents, conversation)
return agents if conversation&.team_id.blank?
team = conversation.team
return nil if team.blank? || team.allow_auto_assign.blank?
team_member_ids = team.members.ids
agents.where(user_id: team_member_ids)
end
def filter_agents_by_rate_limit(agents) def filter_agents_by_rate_limit(agents)
agents.select do |agent_member| agents.select do |agent_member|
rate_limiter = build_rate_limiter(agent_member.user) rate_limiter = build_rate_limiter(agent_member.user)
@@ -97,6 +81,10 @@ class AutoAssignment::AssignmentService
def round_robin_selector def round_robin_selector
@round_robin_selector ||= AutoAssignment::RoundRobinSelector.new(inbox: inbox) @round_robin_selector ||= AutoAssignment::RoundRobinSelector.new(inbox: inbox)
end end
def assignment_config
@assignment_config ||= inbox.auto_assignment_config || {}
end
end end
AutoAssignment::AssignmentService.prepend_mod_with('AutoAssignment::AssignmentService') AutoAssignment::AssignmentService.prepend_mod_with('AutoAssignment::AssignmentService')
+4 -2
View File
@@ -8,6 +8,8 @@ class AutoAssignment::RateLimiter
end end
def track_assignment(conversation) def track_assignment(conversation)
return unless enabled?
assignment_key = build_assignment_key(conversation.id) assignment_key = build_assignment_key(conversation.id)
Redis::Alfred.set(assignment_key, conversation.id.to_s, ex: window) Redis::Alfred.set(assignment_key, conversation.id.to_s, ex: window)
end end
@@ -22,11 +24,11 @@ class AutoAssignment::RateLimiter
private private
def enabled? def enabled?
config.present? && limit.positive? limit.present? && limit.positive?
end end
def limit def limit
config&.fair_distribution_limit.present? ? config.fair_distribution_limit.to_i : Float::INFINITY config&.fair_distribution_limit&.to_i || Math
end end
def window def window
+13 -47
View File
@@ -33,9 +33,9 @@ class FilterService
when 'is_not_present' when 'is_not_present'
@filter_values["value_#{current_index}"] = 'IS NULL' @filter_values["value_#{current_index}"] = 'IS NULL'
when 'is_greater_than', 'is_less_than' when 'is_greater_than', 'is_less_than'
lt_gt_filter_query(query_hash, current_index) @filter_values["value_#{current_index}"] = lt_gt_filter_values(query_hash)
when 'days_before' when 'days_before'
days_before_filter_query(query_hash, current_index) @filter_values["value_#{current_index}"] = days_before_filter_values(query_hash)
else else
@filter_values["value_#{current_index}"] = filter_values(query_hash).to_s @filter_values["value_#{current_index}"] = filter_values(query_hash).to_s
"= :value_#{current_index}" "= :value_#{current_index}"
@@ -81,29 +81,21 @@ class FilterService
query_hash['values'].downcase query_hash['values'].downcase
end end
def lt_gt_filter_query(query_hash, current_index) def lt_gt_filter_values(query_hash)
attribute_key = query_hash[:attribute_key] attribute_key = query_hash[:attribute_key]
attribute_model = query_hash['custom_attribute_type'].presence || self.class::ATTRIBUTE_MODEL attribute_model = query_hash['custom_attribute_type'].presence || self.class::ATTRIBUTE_MODEL
attribute_type = custom_attribute(attribute_key, @account, attribute_model).try(:attribute_display_type) attribute_type = custom_attribute(attribute_key, @account, attribute_model).try(:attribute_display_type)
attribute_data_type = self.class::ATTRIBUTE_TYPES[attribute_type] || standard_attribute_data_type(attribute_key) attribute_data_type = self.class::ATTRIBUTE_TYPES[attribute_type]
value = query_hash['values'][0]
@filter_values["value_#{current_index}"] = coerce_lt_gt_value(
query_hash['values'][0],
attribute_data_type,
attribute_key
)
operator = query_hash['filter_operator'] == 'is_less_than' ? '<' : '>' operator = query_hash['filter_operator'] == 'is_less_than' ? '<' : '>'
"#{operator} :value_#{current_index}" "#{operator} '#{value}'::#{attribute_data_type}"
end end
def days_before_filter_query(query_hash, current_index) def days_before_filter_values(query_hash)
date = Time.zone.today - query_hash['values'][0].to_i.days date = Time.zone.today - query_hash['values'][0].to_i.days
updated_query_hash = query_hash.with_indifferent_access.merge( query_hash['values'] = [date.strftime]
values: [date.strftime], query_hash['filter_operator'] = 'is_less_than'
filter_operator: 'is_less_than' lt_gt_filter_values(query_hash)
)
lt_gt_filter_query(updated_query_hash, current_index)
end end
def set_count_for_all_conversations def set_count_for_all_conversations
@@ -157,39 +149,15 @@ class FilterService
@attribute_data_type = self.class::ATTRIBUTE_TYPES[attribute_type] @attribute_data_type = self.class::ATTRIBUTE_TYPES[attribute_type]
end end
def standard_attribute_data_type(attribute_key)
@filters.each_value do |section|
return section.dig(attribute_key, 'data_type') if section.is_a?(Hash) && section.key?(attribute_key)
end
nil
end
def coerce_lt_gt_value(raw_value, attribute_data_type, attribute_key)
case attribute_data_type
when 'date'
Date.iso8601(raw_value.to_s)
when 'numeric'
BigDecimal(raw_value.to_s)
else
raise CustomExceptions::CustomFilter::InvalidValue.new(attribute_name: attribute_key)
end
rescue Date::Error, ArgumentError, FloatDomainError, TypeError
raise CustomExceptions::CustomFilter::InvalidValue.new(attribute_name: attribute_key)
end
def build_custom_attr_query(query_hash, current_index) def build_custom_attr_query(query_hash, current_index)
filter_operator_value = filter_operation(query_hash, current_index) filter_operator_value = filter_operation(query_hash, current_index)
query_operator = query_hash[:query_operator] query_operator = query_hash[:query_operator]
table_name = attribute_model == 'conversation_attribute' ? 'conversations' : 'contacts' table_name = attribute_model == 'conversation_attribute' ? 'conversations' : 'contacts'
query = if attribute_data_type == 'text' query = if attribute_data_type == 'text'
ActiveRecord::Base.sanitize_sql_array( "LOWER(#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} #{filter_operator_value} #{query_operator} "
["LOWER(#{table_name}.custom_attributes ->> ?)::#{attribute_data_type} #{filter_operator_value} #{query_operator} ", @attribute_key]
)
else else
ActiveRecord::Base.sanitize_sql_array( "(#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} #{filter_operator_value} #{query_operator} "
["(#{table_name}.custom_attributes ->> ?)::#{attribute_data_type} #{filter_operator_value} #{query_operator} ", @attribute_key]
)
end end
query + not_in_custom_attr_query(table_name, query_hash, attribute_data_type) query + not_in_custom_attr_query(table_name, query_hash, attribute_data_type)
@@ -206,9 +174,7 @@ class FilterService
def not_in_custom_attr_query(table_name, query_hash, attribute_data_type) def not_in_custom_attr_query(table_name, query_hash, attribute_data_type)
return '' unless query_hash[:filter_operator] == 'not_equal_to' return '' unless query_hash[:filter_operator] == 'not_equal_to'
ActiveRecord::Base.sanitize_sql_array( " OR (#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} IS NULL "
[" OR (#{table_name}.custom_attributes ->> ?)::#{attribute_data_type} IS NULL ", @attribute_key]
)
end end
def equals_to_filter_string(filter_operator, current_index) def equals_to_filter_string(filter_operator, current_index)
@@ -2,6 +2,7 @@ class MessageTemplates::HookExecutionService
pattr_initialize [:message!] pattr_initialize [:message!]
def perform def perform
return if conversation.campaign.present?
return if conversation.last_incoming_message.blank? return if conversation.last_incoming_message.blank?
return if message.auto_reply_email? return if message.auto_reply_email?
@@ -20,7 +21,6 @@ class MessageTemplates::HookExecutionService
end end
def should_send_out_of_office_message? def should_send_out_of_office_message?
return false if conversation.campaign.present?
# should not send if its a tweet message # should not send if its a tweet message
return false if conversation.tweet? return false if conversation.tweet?
# should not send for outbound messages # should not send for outbound messages
@@ -37,7 +37,6 @@ class MessageTemplates::HookExecutionService
end end
def should_send_greeting? def should_send_greeting?
return false if conversation.campaign.present?
# should not send if its a tweet message # should not send if its a tweet message
return false if conversation.tweet? return false if conversation.tweet?
@@ -50,8 +49,6 @@ class MessageTemplates::HookExecutionService
# TODO: we should be able to reduce this logic once we have a toggle for email collect messages # TODO: we should be able to reduce this logic once we have a toggle for email collect messages
def should_send_email_collect? def should_send_email_collect?
return false if conversation.campaign.present?
!contact_has_email? && inbox.web_widget? && !email_collect_was_sent? !contact_has_email? && inbox.web_widget? && !email_collect_was_sent?
end end

Some files were not shown because too many files have changed in this diff Show More