Compare commits

..
Author SHA1 Message Date
Pranav 859ab6777c rich seed data 2026-02-08 13:49:24 -08:00
347 changed files with 9330 additions and 10236 deletions
-3
View File
@@ -94,8 +94,6 @@ yarn-debug.log*
.vscode .vscode
.claude/settings.local.json .claude/settings.local.json
.cursor .cursor
.codex/
.claude/
CLAUDE.local.md CLAUDE.local.md
# Histoire deployment # Histoire deployment
@@ -103,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'
+20 -22
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)
@@ -583,14 +582,14 @@ GEM
newrelic_rpm (9.6.0) newrelic_rpm (9.6.0)
base64 base64
nio4r (2.7.3) nio4r (2.7.3)
nokogiri (1.19.1) nokogiri (1.18.9)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.1-arm64-darwin) nokogiri (1.18.9-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.1-x86_64-darwin) nokogiri (1.18.9-x86_64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.1-x86_64-linux-gnu) nokogiri (1.18.9-x86_64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
oauth (1.1.0) oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1) oauth-tty (~> 1.0, >= 1.0.1)
@@ -677,7 +676,7 @@ GEM
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
raabro (1.4.0) raabro (1.4.0)
racc (1.8.1) racc (1.8.1)
rack (3.2.5) rack (3.2.3)
rack-attack (6.7.0) rack-attack (6.7.0)
rack (>= 1.0, < 4) rack (>= 1.0, < 4)
rack-contrib (2.5.0) rack-contrib (2.5.0)
@@ -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.0 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
+6
View File
@@ -164,4 +164,10 @@ export default {
.v-popper--theme-tooltip .v-popper__arrow-container { .v-popper--theme-tooltip .v-popper__arrow-container {
display: none; display: none;
} }
.multiselect__input {
margin-bottom: 0px !important;
}
</style> </style>
<style src="vue-multiselect/dist/vue-multiselect.css"></style>
@@ -66,7 +66,7 @@ textarea {
// Field base styles (Input, TextArea, Select) // Field base styles (Input, TextArea, Select)
@layer components { @layer components {
.field-base { .field-base {
@apply block box-border w-full transition-colors duration-[0.25s] ease-[ease-in-out] focus:outline-n-brand dark:focus:outline-n-brand appearance-none mx-0 mt-0 mb-4 py-2 px-3 rounded-lg text-sm font-normal bg-n-alpha-black2 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 border-none outline outline-1 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6; @apply block box-border w-full transition-colors duration-[0.25s] ease-[ease-in-out] focus:outline-n-brand dark:focus:outline-n-brand appearance-none mx-0 mt-0 mb-4 py-2 px-3 rounded-lg text-base font-normal bg-n-alpha-black2 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 border-none outline outline-1 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6;
} }
.field-disabled { .field-disabled {
@@ -78,7 +78,7 @@ textarea {
} }
} }
$form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not([type='radio']):not([type='range']):not([type='button']):not([type='submit']):not([type='reset']):not([type='color']):not([type='image']):not([type='hidden']):not(.reset-base):not(.no-margin)"; $form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not([type='radio']):not([type='range']):not([type='button']):not([type='submit']):not([type='reset']):not([type='color']):not([type='image']):not([type='hidden']):not(.reset-base):not(.multiselect__input):not(.no-margin)";
#{$form-input-selector} { #{$form-input-selector} {
@apply field-base h-10; @apply field-base h-10;
@@ -92,7 +92,7 @@ $form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not
} }
} }
input[type='file'] { input[type='file']:not(.multiselect__input) {
@apply leading-[1.15] mb-4 border-0 bg-transparent text-sm; @apply leading-[1.15] mb-4 border-0 bg-transparent text-sm;
} }
@@ -126,6 +126,13 @@ label:has(.help-text) {
} }
} }
// Error handling
.has-multi-select-error {
div.multiselect {
@apply mb-1;
}
}
// FormKit support // FormKit support
.formkit-outer[data-invalid='true'] { .formkit-outer[data-invalid='true'] {
#{$form-input-selector}, #{$form-input-selector},
@@ -143,7 +150,9 @@ label:has(.help-text) {
#{$form-input-selector}, #{$form-input-selector},
input:not([type]), input:not([type]),
textarea, textarea,
select { select,
.multiselect > .multiselect__tags,
.multiselect:not(.no-margin) {
@apply field-error; @apply field-error;
} }
@@ -13,6 +13,7 @@
@import 'base'; @import 'base';
// Plugins // Plugins
@import 'plugins/multiselect';
@import 'plugins/date-picker'; @import 'plugins/date-picker';
html, html,
@@ -65,84 +66,4 @@ body {
-ms-overflow-style: none; /* IE and Edge */ -ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */ scrollbar-width: none; /* Firefox */
} }
/**
* ============================================================================
* TYPOGRAPHY UTILITIES
* ============================================================================
*
* | Class | Use Case |
* |--------------------|----------------------------------------------------|
* | .text-body-main | <p>, <span>, general body text |
* | .text-body-para | <p> for paragraphs, larger text blocks |
* | .text-heading-1 | <h1>, page titles, panel headers |
* | .text-heading-2 | <h2>, section headings, card titles |
* | .text-heading-3 | <h3>, card headings, breadcrumbs, subsections |
* | .text-label | <label>, form labels, field names |
* | .text-label-small | <small>, footnotes, tags, badges, captions |
* | .text-button | <button>, standard button text |
* | .text-button-small | <button>, small/compact button text |
*/
/* body-text-main: Main text style for general body text */
.text-body-main {
@apply font-inter text-sm font-420;
line-height: 21px; /* 150% */
letter-spacing: -0.28px;
}
/* body-text-paragraph: For paragraphs or larger blocks of text */
.text-body-para {
@apply font-inter text-sm font-420;
line-height: 21px; /* 150% */
letter-spacing: -0.21px;
}
/* heading-1: Large heading for pages and panels */
.text-heading-1 {
@apply font-inter text-lg font-520;
line-height: 24px; /* 133.333% */
letter-spacing: -0.27px;
}
/* heading-2: Secondary heading for sections */
.text-heading-2 {
@apply font-inter text-base font-medium;
line-height: 24px; /* 133.333% */
letter-spacing: -0.27px;
}
/* heading-3: For card headings, breadcrumbs, subsections */
.text-heading-3 {
@apply font-inter text-sm font-medium;
line-height: 21px; /* 150% */
letter-spacing: -0.27px;
}
/* label: Standard label text for form fields */
.text-label {
@apply font-inter text-sm font-medium;
line-height: 21px; /* 150% */
}
/* label-small: Smallest font for labels, footnotes, tags */
.text-label-small {
@apply font-inter text-xs font-440;
line-height: 16px; /* 133.333% */
letter-spacing: -0.24px;
}
/* button-text: Text for standard size buttons */
.text-button {
@apply font-inter text-sm font-460;
line-height: 21px; /* 150% */
letter-spacing: -0.28px;
}
/* button-text-small: Text for smaller buttons */
.text-button-small {
@apply font-inter text-xs font-440;
line-height: 18px; /* 150% */
letter-spacing: -0.24px;
}
} }
@@ -0,0 +1,273 @@
@mixin label-multiselect-hover {
&::after {
@apply text-n-brand;
}
&:hover {
@apply bg-n-slate-3;
&::after {
@apply text-n-blue-11;
}
}
}
.multiselect {
&:not(.no-margin) {
@apply mb-4;
}
&.invalid .multiselect__tags {
@apply border-0 outline outline-1 outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9 disabled:outline-n-ruby-8 dark:disabled:outline-n-ruby-8;
}
&.multiselect--disabled {
@apply opacity-50 rounded-lg cursor-not-allowed pointer-events-auto;
.multiselect__select {
@apply cursor-not-allowed bg-transparent rounded-lg;
}
}
.multiselect--active {
> .multiselect__tags {
@apply outline-n-blue-border;
}
}
.multiselect__select {
@apply min-h-[2.875rem] p-0 right-0 top-0;
&::before {
@apply right-0;
}
}
.multiselect__content-wrapper {
@apply bg-n-alpha-black2 text-n-slate-12 backdrop-blur-[100px] border-0 border-none outline outline-1 outline-n-weak rounded-b-lg;
}
.multiselect__content {
@apply max-w-full;
.multiselect__option {
@apply text-sm font-normal flex justify-between items-center;
span {
@apply inline-block overflow-hidden text-ellipsis whitespace-nowrap w-fit;
}
p {
@apply mb-0;
}
&::after {
@apply bottom-0 flex items-center justify-center text-center relative px-1 leading-tight;
}
&.multiselect__option--highlight {
@apply bg-n-alpha-black2 text-n-slate-12;
}
&.multiselect__option--highlight:hover {
@apply bg-n-brand/10 text-n-slate-12;
&::after {
@apply bg-transparent text-center text-n-slate-12;
}
}
&.multiselect__option--highlight::after {
@apply bg-transparent text-n-slate-12;
}
&.multiselect__option--selected {
@apply bg-n-brand/20 text-n-slate-12;
&::after {
@apply bg-transparent;
}
&.multiselect__option--highlight:hover {
@apply bg-transparent;
&::after:hover {
@apply text-n-slate-12 bg-transparent;
}
}
}
}
}
.multiselect__tags {
@apply bg-n-alpha-black2 border-0 grid items-center w-full border-none outline-1 outline outline-n-weak hover:outline-n-slate-6 m-0 min-h-[2.875rem] rounded-lg pt-0;
input {
@apply border-0 border-none bg-transparent dark:bg-transparent text-n-slate-12 placeholder:text-n-slate-10;
}
}
.multiselect__spinner {
background-color: transparent;
}
.multiselect__tags-wrap {
@apply inline-block leading-none mt-1;
}
.multiselect__placeholder {
@apply text-n-slate-10 font-normal pt-3;
}
.multiselect__tag {
@apply bg-n-alpha-white mt-1 text-n-slate-12 pr-6 pl-2.5 py-1.5;
}
.multiselect__tag-icon {
@include label-multiselect-hover;
}
.multiselect__input {
@apply text-sm h-[2.875rem] mb-0 p-0 shadow-none border-transparent hover:border-transparent hover:shadow-none focus:border-transparent focus:shadow-none active:border-transparent active:shadow-none;
}
.multiselect__single {
@apply bg-transparent text-n-slate-12 inline-block mb-0 py-3 px-2.5 overflow-hidden whitespace-nowrap text-ellipsis;
}
}
.sidebar-labels-wrap {
&.has-edited,
&:hover {
.multiselect {
@apply cursor-pointer;
}
}
.multiselect {
> .multiselect__select {
@apply invisible;
}
> .multiselect__tags {
@apply outline-transparent;
}
&.multiselect--active > .multiselect__tags {
@apply outline-n-blue-border;
}
}
}
.multiselect-wrap--small {
// To be removed one SLA reports date picker is created
&.tiny {
.multiselect.no-margin {
@apply min-h-[32px];
}
.multiselect__select {
@apply min-h-[32px] h-8;
&::before {
@apply top-[60%];
}
}
.multiselect__tags {
@apply min-h-[32px] max-h-[32px];
.multiselect__single {
@apply pt-1 pb-1;
}
}
}
.multiselect__tags,
.multiselect__input,
.multiselect {
@apply text-n-slate-12 rounded-lg text-sm min-h-[2.5rem];
}
.multiselect__input {
@apply h-[2.375rem] min-h-[2.375rem];
}
.multiselect__single {
@apply items-center flex m-0 text-sm max-h-[2.375rem] bg-transparent text-n-slate-12 py-3 px-0.5;
}
.multiselect__placeholder {
@apply m-0 py-2 px-0.5;
}
.multiselect__tag {
@apply py-[6px] my-[1px];
}
.multiselect__select {
@apply min-h-[2.5rem];
}
.multiselect--disabled .multiselect__current,
.multiselect--disabled .multiselect__select {
@apply bg-transparent;
}
}
.multiselect--disabled {
background-color: rgba(var(--black-alpha-2)) !important;
.multiselect__tags {
@apply hover:outline-n-weak;
}
}
.multiselect--active {
.multiselect__select::before {
@apply top-[62%];
}
}
.multiselect__select::before {
top: 60% !important;
}
.multiselect-wrap--medium {
.multiselect__tags,
.multiselect__input {
@apply items-center flex;
}
.multiselect__tags,
.multiselect__input,
.multiselect {
@apply bg-n-alpha-black2 text-n-slate-12 text-sm h-12 min-h-[3rem];
}
.multiselect__input {
@apply h-[2.875rem] min-h-[2.875rem];
margin-bottom: 0 !important;
}
.multiselect__single {
@apply items-center flex m-0 text-sm py-1 px-0.5 bg-transparent text-n-slate-12;
}
.multiselect__placeholder {
@apply m-0 py-1 px-0.5;
}
.multiselect__select {
@apply min-h-[3rem];
}
.multiselect--disabled .multiselect__current,
.multiselect--disabled .multiselect__select {
@apply bg-transparent;
}
.multiselect__tags-wrap {
@apply flex-shrink-0;
}
}
@@ -49,7 +49,7 @@ const handleFetchUsers = () => {
<div class="flex flex-col gap-2 relative justify-between w-full"> <div class="flex flex-col gap-2 relative justify-between w-full">
<div class="flex items-center gap-3 justify-between w-full"> <div class="flex items-center gap-3 justify-between w-full">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<h3 class="text-heading-2 text-n-slate-12 line-clamp-1"> <h3 class="text-base font-medium text-n-slate-12 line-clamp-1">
{{ name }} {{ name }}
</h3> </h3>
<CardPopover <CardPopover
@@ -78,7 +78,7 @@ const handleFetchUsers = () => {
<Button icon="i-lucide-trash" sm slate ghost @click="handleDelete" /> <Button icon="i-lucide-trash" sm slate ghost @click="handleDelete" />
</div> </div>
</div> </div>
<p class="text-n-slate-11 text-body-para line-clamp-1 mb-0 py-1"> <p class="text-n-slate-11 text-sm line-clamp-1 mb-0 py-1">
{{ description }} {{ description }}
</p> </p>
</div> </div>
@@ -20,7 +20,7 @@ const handleClick = () => {
<CardLayout class="[&>div]:px-5 cursor-pointer" @click="handleClick"> <CardLayout class="[&>div]:px-5 cursor-pointer" @click="handleClick">
<div class="flex flex-col items-start gap-2"> <div class="flex flex-col items-start gap-2">
<div class="flex justify-between w-full items-center"> <div class="flex justify-between w-full items-center">
<h3 class="text-n-slate-12 text-heading-2">{{ title }}</h3> <h3 class="text-n-slate-12 text-base font-medium">{{ title }}</h3>
<Button <Button
xs xs
slate slate
@@ -29,14 +29,14 @@ const handleClick = () => {
@click.stop="handleClick" @click.stop="handleClick"
/> />
</div> </div>
<p class="text-n-slate-11 text-body-para mb-0">{{ description }}</p> <p class="text-n-slate-11 text-sm mb-0">{{ description }}</p>
</div> </div>
<ul class="flex flex-col items-start gap-3 mt-3"> <ul class="flex flex-col items-start gap-3 mt-3">
<li <li
v-for="feature in features" v-for="feature in features"
:key="feature.id" :key="feature.id"
class="flex items-center gap-3 text-body-para" class="flex items-center gap-3 text-sm"
> >
<Icon <Icon
:icon="feature.icon" :icon="feature.icon"
@@ -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 },
}); });
@@ -60,19 +61,39 @@ const handleFetchInboxes = () => {
<div class="flex flex-col gap-2 relative justify-between w-full"> <div class="flex flex-col gap-2 relative justify-between w-full">
<div class="flex items-center gap-3 justify-between w-full"> <div class="flex items-center gap-3 justify-between w-full">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<h3 class="text-heading-2 text-n-slate-12 line-clamp-1"> <h3 class="text-base font-medium text-n-slate-12 line-clamp-1">
{{ name }} {{ name }}
</h3> </h3>
<CardPopover <div class="flex items-center gap-2">
:title=" <div class="flex items-center rounded-md bg-n-alpha-2 h-6 px-2">
t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.POPOVER') <span
" class="text-xs"
icon="i-lucide-inbox" :class="enabled ? 'text-n-teal-11' : 'text-n-slate-12'"
:count="assignedInboxCount" >
:items="inboxes" {{
:is-fetching="isFetchingInboxes" enabled
@fetch="handleFetchInboxes" ? t(
/> 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.ACTIVE'
)
: t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.INACTIVE'
)
}}
</span>
</div>
<CardPopover
:title="
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.POPOVER'
)
"
icon="i-lucide-inbox"
:count="assignedInboxCount"
:items="inboxes"
:is-fetching="isFetchingInboxes"
@fetch="handleFetchInboxes"
/>
</div>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Button <Button
@@ -89,18 +110,18 @@ const handleFetchInboxes = () => {
<Button icon="i-lucide-trash" sm slate ghost @click="handleDelete" /> <Button icon="i-lucide-trash" sm slate ghost @click="handleDelete" />
</div> </div>
</div> </div>
<p class="text-n-slate-11 text-body-para line-clamp-1 mb-0 py-1"> <p class="text-n-slate-11 text-sm line-clamp-1 mb-0 py-1">
{{ description }} {{ description }}
</p> </p>
<div class="flex items-center gap-3 py-1.5"> <div class="flex items-center gap-3 py-1.5">
<span v-if="order" class="text-n-slate-11 text-body-para"> <span v-if="order" class="text-n-slate-11 text-sm">
{{ {{
`${t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.ORDER')}:` `${t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.ORDER')}:`
}} }}
<span class="text-n-slate-12">{{ order }}</span> <span class="text-n-slate-12">{{ order }}</span>
</span> </span>
<div v-if="order" class="w-px h-3 bg-n-strong" /> <div v-if="order" class="w-px h-3 bg-n-strong" />
<span v-if="priority" class="text-n-slate-11 text-body-para"> <span v-if="priority" class="text-n-slate-11 text-sm">
{{ {{
`${t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.PRIORITY')}:` `${t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.PRIORITY')}:`
}} }}
@@ -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
@@ -2,7 +2,7 @@
import { computed, ref, onMounted } from 'vue'; import { computed, ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue'; import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
import LabelItem from 'dashboard/components-next/label/LabelItem.vue'; import LabelItem from 'dashboard/components-next/Label/LabelItem.vue';
import DurationInput from 'dashboard/components-next/input/DurationInput.vue'; import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
import { DURATION_UNITS } from 'dashboard/components-next/input/constants'; import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
@@ -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 Label from 'dashboard/components-next/label/Label.vue';
const props = defineProps({ const props = defineProps({
id: { id: {
type: String, type: String,
@@ -18,24 +16,12 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
disabled: {
type: Boolean,
default: false,
},
disabledLabel: {
type: String,
default: '',
},
disabledMessage: {
type: String,
default: '',
},
}); });
const emit = defineEmits(['select']); const emit = defineEmits(['select']);
const handleChange = () => { const handleChange = () => {
if (!props.isActive && !props.disabled) { if (!props.isActive) {
emit('select', props.id); emit('select', props.id);
} }
}; };
@@ -43,37 +29,32 @@ const handleChange = () => {
<template> <template>
<div <div
class="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="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"
> >
<div class="flex flex-col gap-2 items-start"> <div class="absolute top-4 right-4">
<div class="flex items-center justify-between w-full gap-3"> <input
<div class="flex items-center gap-2"> :id="`${id}`"
<h3 class="text-heading-3 text-n-slate-12"> :checked="isActive"
{{ label }} :value="id"
</h3> :name="id"
<Label v-if="disabled" :label="disabledLabel" color="amber" compact /> type="radio"
</div> class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0"
<input @change="handleChange"
:id="`${id}`" />
:checked="isActive" </div>
:value="id"
:name="id" <!-- Content -->
:disabled="disabled" <div class="flex flex-col gap-3 items-start">
type="radio" <h3 class="text-sm font-medium text-n-slate-12">
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0 flex-shrink-0" {{ label }}
@change="handleChange" </h3>
/> <p class="text-sm text-n-slate-11">
</div> {{ description }}
<p class="text-body-main text-n-slate-11">
{{ disabled && disabledMessage ? disabledMessage : description }}
</p> </p>
<slot />
</div> </div>
</div> </div>
</template> </template>
@@ -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>
@@ -21,10 +21,10 @@ const handleButtonClick = () => {
<template> <template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1"> <section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
<header class="sticky top-0 z-10 px-6"> <header class="sticky top-0 z-10 px-6 lg:px-0">
<div class="w-full max-w-5xl mx-auto"> <div class="w-full max-w-[60rem] mx-auto">
<div class="flex items-center justify-between w-full h-20 gap-2"> <div class="flex items-center justify-between w-full h-20 gap-2">
<span class="text-heading-1 text-n-slate-12"> <span class="text-xl font-medium text-n-slate-12">
{{ headerTitle }} {{ headerTitle }}
</span> </span>
<div <div
@@ -43,8 +43,8 @@ const handleButtonClick = () => {
</div> </div>
</div> </div>
</header> </header>
<main class="flex-1 px-6 overflow-y-auto"> <main class="flex-1 px-6 overflow-y-auto lg:px-0">
<div class="w-full max-w-5xl mx-auto py-4"> <div class="w-full max-w-[60rem] mx-auto py-4">
<slot name="default" /> <slot name="default" />
</div> </div>
</main> </main>
@@ -19,7 +19,7 @@ const handleClick = () => {
<template> <template>
<div <div
class="flex flex-col w-full outline-1 outline outline-n-container -outline-offset-1 group/cardLayout rounded-xl bg-n-solid-2" class="flex flex-col w-full outline-1 outline outline-n-container group/cardLayout rounded-xl bg-n-solid-2"
> >
<div <div
class="flex w-full gap-3 py-5" class="flex w-full gap-3 py-5"
@@ -15,11 +15,11 @@ const emit = defineEmits(['search', 'update:sort']);
</script> </script>
<template> <template>
<header class="sticky top-0 z-10 px-6"> <header class="sticky top-0 z-10">
<div <div
class="flex items-start sm:items-center justify-between w-full py-6 gap-2 mx-auto max-w-5xl" class="flex items-start sm:items-center justify-between w-full py-6 px-6 gap-2 mx-auto max-w-[60rem]"
> >
<span class="text-heading-1 truncate text-n-slate-12"> <span class="text-xl font-medium truncate text-n-slate-12">
{{ headerTitle }} {{ headerTitle }}
</span> </span>
<div class="flex items-center flex-row flex-shrink-0 gap-2"> <div class="flex items-center flex-row flex-shrink-0 gap-2">
@@ -32,18 +32,17 @@ const updateCurrentPage = page => {
@search="emit('search', $event)" @search="emit('search', $event)"
@update:sort="emit('update:sort', $event)" @update:sort="emit('update:sort', $event)"
/> />
<main class="flex-1 px-6 overflow-y-auto"> <main class="flex-1 overflow-y-auto">
<div class="w-full mx-auto max-w-5xl py-4"> <div class="w-full mx-auto max-w-[60rem]">
<slot name="default" /> <slot name="default" />
</div> </div>
</main> </main>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0"> <footer v-if="showPaginationFooter" class="sticky bottom-0 z-0 px-4 pb-4">
<PaginationFooter <PaginationFooter
current-page-info="COMPANIES_LAYOUT.PAGINATION_FOOTER.SHOWING" current-page-info="COMPANIES_LAYOUT.PAGINATION_FOOTER.SHOWING"
:current-page="currentPage" :current-page="currentPage"
:total-items="totalItems" :total-items="totalItems"
:items-per-page="25" :items-per-page="25"
class="max-w-[67rem]"
@update:current-page="updateCurrentPage" @update:current-page="updateCurrentPage"
/> />
</footer> </footer>
@@ -3,8 +3,8 @@ import { computed, watch, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store'; import { useMapGetter, useStore } from 'dashboard/composables/store';
import LabelItem from 'dashboard/components-next/label/LabelItem.vue'; import LabelItem from 'dashboard/components-next/Label/LabelItem.vue';
import AddLabel from 'dashboard/components-next/label/AddLabel.vue'; import AddLabel from 'dashboard/components-next/Label/AddLabel.vue';
const props = defineProps({ const props = defineProps({
contactId: { contactId: {
@@ -32,9 +32,9 @@ const emit = defineEmits([
</script> </script>
<template> <template>
<header class="sticky top-0 z-10 px-6"> <header class="sticky top-0 z-10">
<div <div
class="flex items-start sm:items-center justify-between w-full py-6 gap-2 mx-auto max-w-5xl" class="flex items-start sm:items-center justify-between w-full py-6 px-6 gap-2 mx-auto max-w-[60rem]"
> >
<span class="text-xl font-medium truncate text-n-slate-12"> <span class="text-xl font-medium truncate text-n-slate-12">
{{ headerTitle }} {{ headerTitle }}
@@ -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 = () =>
@@ -62,7 +62,7 @@ const activeFilterQueryData = computed(() => {
t('CONTACTS_LAYOUT.FILTER.ACTIVE_FILTERS.CLEAR_FILTERS') t('CONTACTS_LAYOUT.FILTER.ACTIVE_FILTERS.CLEAR_FILTERS')
" "
:show-clear-button="!hasActiveSegments" :show-clear-button="!hasActiveSegments"
class="max-w-5xl" class="max-w-[60rem] px-6"
@open-filter="emit('openFilter')" @open-filter="emit('openFilter')"
@clear-filters="emit('clearFilters')" @clear-filters="emit('clearFilters')"
/> />
@@ -98,8 +98,8 @@ const showPagination = computed(() => {
@apply-filter="emit('applyFilter', $event)" @apply-filter="emit('applyFilter', $event)"
@clear-filters="emit('clearFilters')" @clear-filters="emit('clearFilters')"
/> />
<main class="flex-1 overflow-y-auto px-6"> <main class="flex-1 overflow-y-auto">
<div class="w-full mx-auto max-w-5xl"> <div class="w-full mx-auto max-w-[60rem]">
<ContactsActiveFiltersPreview <ContactsActiveFiltersPreview
v-if="showActiveFiltersPreview" v-if="showActiveFiltersPreview"
:active-segment="activeSegment" :active-segment="activeSegment"
@@ -114,12 +114,11 @@ const showPagination = computed(() => {
/> />
</div> </div>
</main> </main>
<footer v-if="showPagination" class="sticky bottom-0 z-0"> <footer v-if="showPagination" class="sticky bottom-0 z-0 px-4 pb-4">
<PaginationFooter <PaginationFooter
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING" current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
:current-page="currentPage" :current-page="currentPage"
:total-items="totalItems" :total-items="totalItems"
class="max-w-[67rem]"
:items-per-page="itemsPerPage" :items-per-page="itemsPerPage"
@update:current-page="updateCurrentPage" @update:current-page="updateCurrentPage"
/> />
@@ -1,10 +1,8 @@
<script setup> <script setup>
import { computed } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue'; import Icon from 'dashboard/components-next/icon/Icon.vue';
import Label from 'dashboard/components-next/label/Label.vue';
import AttributeBadge from 'dashboard/components-next/CustomAttributes/AttributeBadge.vue'; import AttributeBadge from 'dashboard/components-next/CustomAttributes/AttributeBadge.vue';
import { computed } from 'vue';
const props = defineProps({ const props = defineProps({
attribute: { attribute: {
@@ -20,7 +18,7 @@ const props = defineProps({
const emit = defineEmits(['edit', 'delete']); const emit = defineEmits(['edit', 'delete']);
const iconByType = { const iconByType = {
text: 'i-lucide-menu', text: 'i-lucide-align-justify',
checkbox: 'i-lucide-circle-check-big', checkbox: 'i-lucide-circle-check-big',
list: 'i-lucide-list', list: 'i-lucide-list',
date: 'i-lucide-calendar', date: 'i-lucide-calendar',
@@ -30,60 +28,61 @@ const iconByType = {
const attributeIcon = computed(() => { const attributeIcon = computed(() => {
const typeKey = props.attribute.type?.toLowerCase(); const typeKey = props.attribute.type?.toLowerCase();
return iconByType[typeKey] || 'i-lucide-menu'; return iconByType[typeKey] || 'i-lucide-align-justify';
}); });
</script> </script>
<template> <template>
<div class="flex flex-col py-4 min-w-0"> <div
<div class="flex justify-between flex-row items-center gap-4 min-w-0"> class="flex flex-col gap-2 p-4 bg-n-solid-1 rounded-2xl outline outline-1 outline-n-container"
<div class="flex items-center gap-4 min-w-0"> >
<div <div class="flex flex-wrap gap-2 justify-between items-center">
class="flex items-center flex-shrink-0 size-10 justify-center rounded-xl outline outline-1 outline-n-weak -outline-offset-1" <div class="flex flex-wrap gap-2 items-center min-w-0">
> <h4 class="text-sm font-medium truncate text-n-slate-12">
<Icon :icon="attributeIcon" class="size-4 text-n-slate-11" /> {{ attribute.label }}
</div> </h4>
<div class="flex flex-col gap-1.5 items-start min-w-0 overflow-hidden"> <div class="w-px h-3 bg-n-strong" />
<div class="flex items-center gap-2 min-w-0"> <div class="flex gap-2 items-center text-sm text-n-slate-11">
<h4 class="text-heading-3 truncate text-n-slate-12 min-w-0"> <div class="flex items-center gap-1.5 text-n-slate-11">
{{ attribute.label }} <Icon :icon="attributeIcon" class="size-4" />
</h4> <span class="text-sm">{{ attribute.type }}</span>
<div class="flex items-center gap-1.5">
<Label :label="attribute.type" compact />
<AttributeBadge
v-for="badge in badges"
:key="badge.type"
:type="badge.type"
/>
</div>
</div> </div>
<div class="grid grid-cols-[auto_1fr] items-center gap-1.5"> <div class="w-px h-3 bg-n-weak" />
<Icon icon="i-lucide-key-round" class="size-3.5 text-n-slate-11" /> <div class="flex items-center gap-1.5 text-n-slate-11">
<div class="flex items-center gap-2 min-w-0"> <Icon icon="i-lucide-key-round" class="size-4" />
<span class="text-body-main text-n-slate-11 truncate"> <span class="line-clamp-1 text-sm">{{ attribute.value }}</span>
{{ attribute.value }}
</span>
<template
v-if="attribute.attribute_description || attribute.description"
>
<div class="w-px h-3 rounded-lg bg-n-weak flex-shrink-0" />
<span class="text-body-main text-n-slate-11 truncate">
{{ attribute.attribute_description || attribute.description }}
</span>
</template>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="flex gap-3 justify-end flex-shrink-0"> <div class="flex gap-2 items-center">
<AttributeBadge
v-for="badge in badges"
:key="badge.type"
:type="badge.type"
/>
<div
v-if="badges.length > 0"
class="w-px h-3 bg-n-strong ltr:ml-1.5 rtl:mr-1.5"
/>
<Button <Button
icon="i-woot-edit-pen" icon="i-lucide-pencil-line"
slate size="sm"
sm color="slate"
ghost
@click="emit('edit', attribute)" @click="emit('edit', attribute)"
/> />
<Button icon="i-woot-bin" slate sm @click="emit('delete', attribute)" /> <div class="w-px h-3 bg-n-strong" />
<Button
icon="i-lucide-trash"
size="sm"
color="slate"
ghost
@click="emit('delete', attribute)"
/>
</div> </div>
</div> </div>
<p class="mb-0 text-sm text-n-slate-11">
{{ attribute.attribute_description || attribute.description || '' }}
</p>
</div> </div>
</template> </template>
@@ -35,20 +35,18 @@ const handleDelete = () => {
<template> <template>
<div class="flex justify-between items-center px-4 py-3 w-full"> <div class="flex justify-between items-center px-4 py-3 w-full">
<div class="flex gap-3 items-center"> <div class="flex gap-3 items-center">
<h5 class="text-heading-3 text-n-slate-12 line-clamp-1"> <h5 class="text-sm font-medium text-n-slate-12 line-clamp-1">
{{ attribute.label }} {{ attribute.label }}
</h5> </h5>
<div class="w-px h-2.5 bg-n-slate-5" /> <div class="w-px h-2.5 bg-n-slate-5" />
<div class="flex gap-1.5 items-center"> <div class="flex gap-1.5 items-center">
<Icon :icon="attributeIcon" class="size-4 text-n-slate-11" /> <Icon :icon="attributeIcon" class="size-4 text-n-slate-11" />
<span class="text-body-para text-n-slate-11">{{ attribute.type }}</span> <span class="text-sm text-n-slate-11">{{ attribute.type }}</span>
</div> </div>
<div class="w-px h-2.5 bg-n-slate-5" /> <div class="w-px h-2.5 bg-n-slate-5" />
<div class="flex gap-1.5 items-center"> <div class="flex gap-1.5 items-center">
<Icon icon="i-lucide-key-round" class="size-4 text-n-slate-11" /> <Icon icon="i-lucide-key-round" class="size-4 text-n-slate-11" />
<span class="text-body-para text-n-slate-11">{{ <span class="text-sm text-n-slate-11">{{ attribute.value }}</span>
attribute.value
}}</span>
</div> </div>
</div> </div>
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
@@ -129,10 +129,10 @@ const handleDelete = attribute => {
<div class="flex flex-col gap-2 items-start px-5 py-4"> <div class="flex flex-col gap-2 items-start px-5 py-4">
<div class="flex justify-between items-center w-full"> <div class="flex justify-between items-center w-full">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<h3 class="text-heading-2 text-n-slate-12"> <h3 class="text-base font-medium text-n-slate-12">
{{ $t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.TITLE') }} {{ $t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.TITLE') }}
</h3> </h3>
<p class="mb-0 text-body-para text-n-slate-11"> <p class="mb-0 text-sm text-n-slate-11">
{{ $t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.DESCRIPTION') }} {{ $t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.DESCRIPTION') }}
</p> </p>
</div> </div>
@@ -2,7 +2,6 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue'; import Icon from 'dashboard/components-next/icon/Icon.vue';
import Label from 'dashboard/components-next/label/Label.vue';
const props = defineProps({ const props = defineProps({
type: { type: {
@@ -19,13 +18,11 @@ const attributeConfig = {
colorClass: 'text-n-blue-11', colorClass: 'text-n-blue-11',
icon: 'i-lucide-message-circle', icon: 'i-lucide-message-circle',
labelKey: 'ATTRIBUTES_MGMT.BADGES.PRE_CHAT', labelKey: 'ATTRIBUTES_MGMT.BADGES.PRE_CHAT',
color: 'slate',
}, },
resolution: { resolution: {
colorClass: 'text-n-teal-11', colorClass: 'text-n-teal-11',
icon: 'i-lucide-circle-check-big', icon: 'i-lucide-circle-check-big',
labelKey: 'ATTRIBUTES_MGMT.BADGES.RESOLUTION', labelKey: 'ATTRIBUTES_MGMT.BADGES.RESOLUTION',
color: 'slate',
}, },
}; };
const config = computed( const config = computed(
@@ -34,9 +31,12 @@ const config = computed(
</script> </script>
<template> <template>
<Label :label="t(config.labelKey)" :color="config.color" compact> <div
<template #icon> class="flex gap-1 justify-center items-center px-1.5 py-1 rounded-md shadow outline-1 outline outline-n-container bg-n-solid-2"
<Icon :icon="config.icon" class="size-3.5 text-n-slate-12" /> >
</template> <Icon :icon="config.icon" class="size-4" :class="config.colorClass" />
</Label> <span class="text-xs" :class="config.colorClass">{{
t(config.labelKey)
}}</span>
</div>
</template> </template>
@@ -26,7 +26,7 @@ defineProps({
class="relative flex flex-col items-center justify-center w-full h-full overflow-hidden" class="relative flex flex-col items-center justify-center w-full h-full overflow-hidden"
> >
<div <div
class="relative w-full max-w-5xl mx-auto overflow-hidden h-full max-h-[28rem]" class="relative w-full max-w-[60rem] mx-auto overflow-hidden h-full max-h-[28rem]"
> >
<div <div
v-if="showBackdrop" v-if="showBackdrop"
@@ -59,8 +59,8 @@ const togglePortalSwitcher = () => {
<template> <template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1"> <section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
<header class="sticky top-0 z-10 px-6 pb-3"> <header class="sticky top-0 z-10 px-6 pb-3 lg:px-0">
<div class="w-full max-w-5xl mx-auto"> <div class="w-full max-w-[60rem] mx-auto lg:px-6">
<div <div
v-if="showHeaderTitle" v-if="showHeaderTitle"
class="flex items-center justify-start h-20 gap-2" class="flex items-center justify-start h-20 gap-2"
@@ -95,17 +95,16 @@ const togglePortalSwitcher = () => {
<slot name="header-actions" /> <slot name="header-actions" />
</div> </div>
</header> </header>
<main class="flex-1 px-6 overflow-y-auto"> <main class="flex-1 px-6 overflow-y-auto lg:px-0">
<div class="w-full max-w-5xl mx-auto py-3"> <div class="w-full max-w-[60rem] mx-auto py-3 lg:px-6">
<slot name="content" /> <slot name="content" />
</div> </div>
</main> </main>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10"> <footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 px-4 pb-4">
<PaginationFooter <PaginationFooter
:current-page="currentPage" :current-page="currentPage"
:total-items="totalItems" :total-items="totalItems"
:items-per-page="itemsPerPage" :items-per-page="itemsPerPage"
class="max-w-[67rem]"
@update:current-page="updateCurrentPage" @update:current-page="updateCurrentPage"
/> />
</footer> </footer>
@@ -62,7 +62,6 @@ const onCreate = async () => {
from: route.name, from: route.name,
}); });
selectedLocale.value = '';
dialogRef.value?.close(); dialogRef.value?.close();
useAlert( useAlert(
t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.API.SUCCESS_MESSAGE') t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.API.SUCCESS_MESSAGE')
@@ -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)"
@@ -70,7 +70,7 @@ describe('composeConversationHelper', () => {
const result = helpers.buildContactableInboxesList(inboxes); const result = helpers.buildContactableInboxesList(inboxes);
expect(result[0]).toMatchObject({ expect(result[0]).toMatchObject({
id: 1, id: 1,
icon: 'i-woot-mail', icon: 'i-ri-mail-line',
label: 'Email Inbox (support@example.com)', label: 'Email Inbox (support@example.com)',
action: 'inbox', action: 'inbox',
value: 1, value: 1,
@@ -1,31 +0,0 @@
<script setup>
import { useToggle } from '@vueuse/core';
const props = defineProps({
title: {
type: String,
required: true,
},
defaultOpen: {
type: Boolean,
default: true,
},
});
const [isOpen, toggle] = useToggle(props.defaultOpen);
</script>
<template>
<div
v-bind="$attrs"
class="flex items-center justify-between w-full cursor-pointer pb-2 pt-4 border-t border-n-weak"
@click="toggle()"
>
<span class="text-heading-2 text-n-slate-12 w-full">
{{ title }}
</span>
</div>
<div v-if="isOpen" class="w-full space-y-4 pt-4 mb-4">
<slot />
</div>
</template>
@@ -1,37 +0,0 @@
<script setup>
defineProps({
label: {
type: String,
required: true,
},
helpText: {
type: String,
default: '',
},
});
</script>
<template>
<div class="w-full py-2 mb-2 [interpolate-size:allow-keywords]">
<div
class="grid grid-cols-1 lg:grid-cols-8 gap-1.5 lg:gap-4 items-start lg:items-center"
>
<label class="text-heading-3 text-n-slate-12 col-span-1 lg:col-span-2">
{{ label }}
</label>
<div class="col-span-1 lg:col-span-6">
<slot />
</div>
</div>
<div v-if="helpText" class="grid grid-cols-1 lg:grid-cols-8">
<div class="col-span-1 lg:col-span-2 invisible" />
<p
v-if="helpText"
class="mt-1.5 col-span-1 lg:col-span-6 text-label-small text-n-slate-11 ltr:ml-1 rtl:mr-1"
>
{{ helpText }}
</p>
</div>
<slot name="extra" />
</div>
</template>
@@ -1,45 +0,0 @@
<script setup>
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
defineProps({
header: {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
compact: {
type: Boolean,
default: false,
},
});
const modelValue = defineModel({ type: Boolean, default: false });
</script>
<template>
<div
class="flex flex-col items-start outline outline-1 -outline-offset-1 outline-n-weak rounded-xl [interpolate-size:allow-keywords]"
>
<div class="flex flex-col gap-1 items-start w-full px-4 py-3">
<div class="flex items-center gap-3 w-full justify-between">
<span class="text-heading-3 text-n-slate-12">
{{ header }}
</span>
<ToggleSwitch v-model="modelValue" />
</div>
<span v-if="description" class="text-body-main text-n-slate-11">
{{ description }}
</span>
</div>
<div
v-if="$slots.editor"
class="w-full border-t border-n-weak"
:class="{ 'p-0': compact, 'px-4 pb-4 pt-2': !compact }"
>
<slot name="editor" />
</div>
</div>
</template>
@@ -112,19 +112,6 @@ const containerStyles = computed(() => ({
height: `${props.size}px`, height: `${props.size}px`,
})); }));
const borderRadiusClass = computed(() => {
if (props.roundedFull) {
return 'rounded-full';
}
// Approximates 25% of size
if (props.size <= 16) return 'rounded'; // 4px
if (props.size <= 24) return 'rounded-md'; // 6px
if (props.size <= 32) return 'rounded-lg'; // 8px
if (props.size <= 48) return 'rounded-xl'; // 12px
return 'rounded-2xl'; // 16px
});
const avatarStyles = computed(() => ({ const avatarStyles = computed(() => ({
...containerStyles.value, ...containerStyles.value,
backgroundColor: backgroundColor:
@@ -197,7 +184,7 @@ watch(
<template> <template>
<span <span
class="relative inline-flex group/avatar z-0 flex-shrink-0 align-middle" class="relative inline-flex group/avatar z-0 flex-shrink-0"
:style="containerStyles" :style="containerStyles"
> >
<!-- Status Badge --> <!-- Status Badge -->
@@ -229,9 +216,9 @@ watch(
<!-- Avatar Container --> <!-- Avatar Container -->
<span <span
role="img" role="img"
class="relative inline-flex items-center justify-center object-cover overflow-hidden font-medium outline outline-1 -outline-offset-1 outline-[rgb(0_0_0_/_0.03)] dark:outline-[rgb(255_255_255_/_0.04)]" class="relative inline-flex items-center justify-center object-cover overflow-hidden font-medium"
:class="[ :class="[
borderRadiusClass, roundedFull ? 'rounded-full' : 'rounded-xl',
{ {
'dark:!bg-[var(--dark-bg)] dark:!text-[var(--dark-text)]': 'dark:!bg-[var(--dark-bg)] dark:!text-[var(--dark-text)]':
!showDefaultAvatar && (!src || !isImageValid), !showDefaultAvatar && (!src || !isImageValid),
@@ -280,8 +267,7 @@ watch(
:handle-image-upload="handleImageUpload" :handle-image-upload="handleImageUpload"
> >
<div <div
class="absolute inset-0 z-10 flex items-center justify-center invisible w-full h-full transition-all duration-300 ease-in-out opacity-0 bg-n-alpha-black1 group-hover/avatar:visible group-hover/avatar:opacity-100" class="absolute inset-0 z-10 flex items-center justify-center invisible w-full h-full transition-all duration-300 ease-in-out opacity-0 rounded-xl bg-n-alpha-black1 group-hover/avatar:visible group-hover/avatar:opacity-100"
:class="borderRadiusClass"
@click="handleUploadAvatar" @click="handleUploadAvatar"
> >
<Icon <Icon
@@ -117,7 +117,7 @@ const handleCreateAssistant = () => {
<template> <template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1"> <section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
<header class="sticky top-0 z-10 px-6"> <header class="sticky top-0 z-10 px-6">
<div class="w-full max-w-5xl mx-auto"> <div class="w-full max-w-[60rem] mx-auto">
<div <div
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row" class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
> >
@@ -140,9 +140,7 @@ const handleCreateAssistant = () => {
> >
<Button <Button
icon="i-lucide-chevron-down" icon="i-lucide-chevron-down"
:variant=" variant="ghost"
showAssistantSwitcherDropdown ? 'faded' : 'ghost'
"
color="slate" color="slate"
size="xs" size="xs"
:disabled="isFetchingAssistants" :disabled="isFetchingAssistants"
@@ -206,7 +204,7 @@ const handleCreateAssistant = () => {
</div> </div>
</header> </header>
<main class="flex-1 px-6 overflow-y-auto"> <main class="flex-1 px-6 overflow-y-auto">
<div class="w-full max-w-5xl h-full mx-auto py-4"> <div class="w-full max-w-[60rem] h-full mx-auto py-4">
<slot v-if="!showPaywall" name="controls" /> <slot v-if="!showPaywall" name="controls" />
<div <div
v-if="isFetching" v-if="isFetching"
@@ -224,12 +222,11 @@ const handleCreateAssistant = () => {
<slot /> <slot />
</div> </div>
</main> </main>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10"> <footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 px-4 pb-4">
<PaginationFooter <PaginationFooter
:current-page="currentPage" :current-page="currentPage"
:total-items="totalCount" :total-items="totalCount"
:items-per-page="itemsPerPage" :items-per-page="itemsPerPage"
class="max-w-[67rem]"
@update:current-page="handlePageChange" @update:current-page="handlePageChange"
/> />
</footer> </footer>
@@ -27,7 +27,7 @@ const openBilling = () => {
<template> <template>
<div <div
class="w-full max-w-5xl mx-auto h-full max-h-[448px] grid place-content-center" class="w-full max-w-[60rem] mx-auto h-full max-h-[448px] grid place-content-center"
> >
<BasePaywallModal <BasePaywallModal
class="mx-auto" class="mx-auto"
@@ -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 = '';
}; };
@@ -79,8 +79,8 @@ defineExpose({
:aria-multiselectable="multiple" :aria-multiselectable="multiple"
> >
<li <li
v-for="(option, index) in options" v-for="option in options"
:key="`${option.value}-${index}`" :key="option.value"
class="flex items-center justify-between w-full gap-2 px-3 py-2 text-sm transition-colors duration-150 cursor-pointer hover:bg-n-alpha-2" class="flex items-center justify-between w-full gap-2 px-3 py-2 text-sm transition-colors duration-150 cursor-pointer hover:bg-n-alpha-2"
:class="{ :class="{
'bg-n-alpha-2': isSelected(option), 'bg-n-alpha-2': isSelected(option),
@@ -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,7 +50,7 @@ const openLink = link => {
]" ]"
> >
<section <section
class="absolute top-full mt-6 ltr:left-0 rtl:right-0 outline outline-1 outline-n-weak bg-n-alpha-3 backdrop-blur-[100px] rounded-xl p-4 w-80 z-20" class="absolute top-full mt-6 ltr:left-0 rtl:right-0 outline outline-1 outline-n-weak bg-n-alpha-3 backdrop-blur-[100px] rounded-xl p-4 w-80"
> >
<div <div
class="absolute -top-[0.77rem] ltr:left-12 rtl:right-12 w-6 h-6 ltr:rotate-45 rtl:-rotate-45 rtl:rounded-tr ltr:rounded-tl rtl:border-r ltr:border-l border-t border-n-weak bg-n-alpha-3 z-10" class="absolute -top-[0.77rem] ltr:left-12 rtl:right-12 w-6 h-6 ltr:rotate-45 rtl:-rotate-45 rtl:rounded-tr ltr:rounded-tl rtl:border-r ltr:border-l border-t border-n-weak bg-n-alpha-3 z-10"
@@ -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"
@@ -9,12 +9,12 @@ export function useChannelIcon(inbox) {
'Channel::Sms': 'i-woot-sms', 'Channel::Sms': 'i-woot-sms',
'Channel::Telegram': 'i-woot-telegram', 'Channel::Telegram': 'i-woot-telegram',
'Channel::TwilioSms': 'i-woot-sms', 'Channel::TwilioSms': 'i-woot-sms',
'Channel::TwitterProfile': 'i-woot-x', 'Channel::TwitterProfile': 'i-ri-twitter-x-fill',
'Channel::WebWidget': 'i-woot-website', 'Channel::WebWidget': 'i-woot-website',
'Channel::Whatsapp': 'i-woot-whatsapp', 'Channel::Whatsapp': 'i-woot-whatsapp',
'Channel::Instagram': 'i-woot-instagram', 'Channel::Instagram': 'i-woot-instagram',
'Channel::Tiktok': 'i-woot-tiktok', 'Channel::Tiktok': 'i-woot-tiktok',
'Channel::Voice': 'i-woot-voice', 'Channel::Voice': 'i-ri-phone-fill',
}; };
const providerIconMap = { const providerIconMap = {
@@ -22,7 +22,7 @@ describe('useChannelIcon', () => {
it('returns correct icon for Voice channel', () => { it('returns correct icon for Voice channel', () => {
const inbox = { channel_type: 'Channel::Voice' }; const inbox = { channel_type: 'Channel::Voice' };
const { value: icon } = useChannelIcon(inbox); const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-woot-voice'); expect(icon).toBe('i-ri-phone-fill');
}); });
it('returns correct icon for Line channel', () => { it('returns correct icon for Line channel', () => {
@@ -46,7 +46,7 @@ describe('useChannelIcon', () => {
it('returns correct icon for Twitter channel', () => { it('returns correct icon for Twitter channel', () => {
const inbox = { channel_type: 'Channel::TwitterProfile' }; const inbox = { channel_type: 'Channel::TwitterProfile' };
const { value: icon } = useChannelIcon(inbox); const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-woot-x'); expect(icon).toBe('i-ri-twitter-x-fill');
}); });
it('returns correct icon for WebWidget channel', () => { it('returns correct icon for WebWidget channel', () => {
@@ -108,7 +108,7 @@ onMounted(() => {
<label <label
v-if="label" v-if="label"
:for="uniqueId" :for="uniqueId"
class="mb-0.5 text-heading-3 text-n-slate-12" class="mb-0.5 text-sm font-medium text-n-slate-12"
> >
{{ label }} {{ label }}
</label> </label>
@@ -145,7 +145,7 @@ onMounted(() => {
/> />
<p <p
v-if="message" v-if="message"
class="min-w-0 mt-1 mb-0 text-label-small truncate transition-all duration-500 ease-in-out" class="min-w-0 mt-1 mb-0 text-xs truncate transition-all duration-500 ease-in-out"
:class="messageClass" :class="messageClass"
> >
{{ message }} {{ message }}
@@ -1,71 +0,0 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
label: {
type: [Object, String],
required: true,
},
compact: {
type: Boolean,
default: false,
},
color: {
type: String,
default: 'slate',
validator: value =>
['slate', 'amber', 'teal', 'ruby', 'blue', 'iris'].includes(value),
},
});
const COLOR_CLASSES = {
slate: 'bg-n-label-color outline-n-label-border text-n-slate-12',
amber: 'bg-n-amber-2 outline-n-amber-4 text-n-amber-11',
teal: 'bg-n-teal-2 outline-n-teal-4 text-n-teal-11',
ruby: 'bg-n-ruby-2 outline-n-ruby-4 text-n-ruby-11',
blue: 'bg-n-blue-2 outline-n-blue-4 text-n-blue-11',
iris: 'bg-n-iris-2 outline-n-iris-4 text-n-iris-11',
};
const isStringLabel = computed(() => typeof props.label === 'string');
const labelTitle = computed(() => {
return isStringLabel.value ? props.label : props.label?.title;
});
const labelDescription = computed(() => {
return (!isStringLabel.value && props.label?.description) || '';
});
const labelColor = computed(() => {
return isStringLabel.value ? null : props.label.color;
});
const colorClasses = computed(() => COLOR_CLASSES[props.color]);
</script>
<template>
<div
:title="labelDescription"
class="rounded-lg -outline-offset-1 outline outline-1 inline-flex items-center flex-shrink-0"
:class="[
colorClasses,
compact ? 'px-1.5 h-6 gap-1 rounded-md' : 'px-2.5 h-8 gap-1.5 rounded-lg',
]"
>
<span
v-if="labelColor"
class="rounded-sm flex-shrink-0"
:class="compact ? 'size-1.5' : 'size-2'"
:style="{ background: labelColor }"
/>
<slot v-else name="icon" />
<span
class="whitespace-nowrap"
:class="compact ? 'text-label-small' : 'text-label !font-420'"
>
{{ labelTitle }}
</span>
<slot name="action" />
</div>
</template>
@@ -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>
@@ -71,10 +71,10 @@ const pageInfo = computed(() => {
<template> <template>
<div <div
class="flex justify-between h-[3.375rem] w-full border-t border-n-weak mx-auto bg-n-surface-1 py-3 px-6 items-center before:absolute before:inset-x-0 before:-top-4 before:bg-gradient-to-t before:from-n-surface-1 before:from-0% before:to-transparent before:h-4 before:pointer-events-none" class="flex justify-between h-12 w-full max-w-[calc(60rem-3px)] outline outline-n-container outline-1 -outline-offset-1 mx-auto bg-n-solid-2 rounded-xl py-2 ltr:pl-4 rtl:pr-4 ltr:pr-3 rtl:pl-3 items-center before:absolute before:inset-x-0 before:-top-4 before:bg-gradient-to-t before:from-n-surface-1 before:from-10% before:dark:from-0% before:to-transparent before:h-4 before:pointer-events-none"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="min-w-0 text-body-main line-clamp-1 text-n-slate-11"> <span class="min-w-0 text-sm font-normal line-clamp-1 text-n-slate-11">
{{ currentPageInformation }} {{ currentPageInformation }}
</span> </span>
</div> </div>
@@ -97,13 +97,11 @@ const pageInfo = computed(() => {
:disabled="isFirstPage" :disabled="isFirstPage"
@click="changePage(currentPage - 1)" @click="changePage(currentPage - 1)"
/> />
<div class="inline-flex items-center gap-2 text-sm"> <div class="inline-flex items-center gap-2 text-sm text-n-slate-11">
<span <span class="px-3 tabular-nums py-0.5 bg-n-alpha-black2 rounded-md">
class="px-3 tabular-nums py-0.5 font-420 bg-n-input-background text-body-main text-n-slate-12 rounded-md"
>
{{ formatFullNumber(currentPage) }} {{ formatFullNumber(currentPage) }}
</span> </span>
<span class="truncate text-body-main text-n-slate-11"> <span class="truncate">
{{ pageInfo }} {{ pageInfo }}
</span> </span>
</div> </div>
@@ -1,97 +0,0 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({
options: {
type: Array,
default: () => [],
validator: options =>
options.every(
opt => typeof opt === 'object' && 'value' in opt && 'label' in opt
),
},
groups: {
type: Array,
default: () => [],
validator: groups =>
groups.every(
group =>
'label' in group &&
Array.isArray(group.options) &&
group.options.every(opt => 'value' in opt && 'label' in opt)
),
},
placeholder: {
type: String,
default: '',
},
disabled: {
type: Boolean,
default: false,
},
error: {
type: String,
default: '',
},
});
const modelValue = defineModel({
type: [String, Number, Boolean],
default: '',
});
</script>
<template>
<div class="w-fit relative">
<select
v-model="modelValue"
:disabled="disabled"
class="appearance-none bg-none rounded-lg border-0 outline-1 outline -outline-offset-1 transition-all duration-200 bg-n-surface-1 !mb-0 py-2 px-3 pr-10 text-sm"
:class="{
'outline-n-weak hover:outline-n-slate-6 focus:outline-n-blue-9':
!error && !disabled,
'outline-n-red-9 focus:outline-n-red-9': error && !disabled,
'outline-n-weak bg-n-slate-2 cursor-not-allowed opacity-60': disabled,
}"
>
<option v-if="placeholder" value="" disabled>
{{ placeholder }}
</option>
<template v-if="groups.length">
<optgroup
v-for="group in groups"
:key="group.label"
:label="group.label"
>
<option
v-for="option in group.options"
:key="option.value"
:value="option.value"
:disabled="option.disabled"
>
{{ option.label }}
</option>
</optgroup>
</template>
<template v-else>
<option
v-for="option in options"
:key="option.value"
:value="option.value"
:disabled="option.disabled"
>
{{ option.label }}
</option>
</template>
</select>
<div
class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none"
>
<Icon
icon="i-lucide-chevron-down"
class="size-4 text-n-slate-11"
:class="{ 'opacity-50': disabled }"
/>
</div>
</div>
</template>
@@ -25,8 +25,8 @@ const reauthorizationRequired = computed(() => {
</script> </script>
<template> <template>
<span class="size-4 grid place-content-center rounded-full"> <span class="size-5 grid place-content-center rounded-full bg-n-alpha-2">
<ChannelIcon :inbox="inbox" class="size-4" /> <ChannelIcon :inbox="inbox" class="size-3" />
</span> </span>
<div class="flex-1 truncate min-w-0">{{ label }}</div> <div class="flex-1 truncate min-w-0">{{ label }}</div>
<div <div
@@ -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');
@@ -286,7 +273,7 @@ const menuItems = computed(() => {
children: sortedInboxes.value.map(inbox => ({ children: sortedInboxes.value.map(inbox => ({
name: `${inbox.name}-${inbox.id}`, name: `${inbox.name}-${inbox.id}`,
label: inbox.name, label: inbox.name,
icon: h(ChannelIcon, { inbox, class: 'size-[16px]' }), icon: h(ChannelIcon, { inbox, class: 'size-[12px]' }),
to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }), to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }),
component: leafProps => component: leafProps =>
h(ChannelLeaf, { h(ChannelLeaf, {
@@ -595,49 +582,18 @@ const menuItems = computed(() => {
name: 'Settings Teams', name: 'Settings Teams',
label: t('SIDEBAR.TEAMS'), label: t('SIDEBAR.TEAMS'),
icon: 'i-lucide-users', icon: 'i-lucide-users',
activeOn: [
'settings_teams_list',
'settings_teams_new',
'settings_teams_finish',
'settings_teams_add_agents',
'settings_teams_show',
'settings_teams_edit',
'settings_teams_edit_members',
'settings_teams_edit_finish',
],
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', },
activeOn: [
'assignment_policy_index',
'agent_assignment_policy_index',
'agent_assignment_policy_create',
'agent_assignment_policy_edit',
'agent_capacity_policy_index',
'agent_capacity_policy_create',
'agent_capacity_policy_edit',
],
to: accountScopedRoute('assignment_policy_index'),
},
]
: []),
{ {
name: 'Settings Inboxes', name: 'Settings Inboxes',
label: t('SIDEBAR.INBOXES'), label: t('SIDEBAR.INBOXES'),
icon: 'i-lucide-inbox', icon: 'i-lucide-inbox',
activeOn: [
'settings_inbox_list',
'settings_inbox_show',
'settings_inbox_new',
'settings_inbox_finish',
'settings_inboxes_page_channel',
'settings_inboxes_add_agents',
],
to: accountScopedRoute('settings_inbox_list'), to: accountScopedRoute('settings_inbox_list'),
}, },
{ {
@@ -730,7 +686,7 @@ const menuItems = computed(() => {
closeMobileSidebar, closeMobileSidebar,
{ ignore: ['#mobile-sidebar-launcher'] }, { ignore: ['#mobile-sidebar-launcher'] },
]" ]"
class="bg-n-background flex flex-col text-sm pb-px fixed top-0 ltr:left-0 rtl:right-0 h-full z-40 w-[200px] md:w-auto md:relative md:flex-shrink-0 md:ltr:translate-x-0 md:rtl:translate-x-0 ltr:border-r rtl:border-l border-n-weak" class="bg-n-background flex flex-col text-sm pb-0.5 fixed top-0 ltr:left-0 rtl:right-0 h-full z-40 w-[200px] md:w-auto md:relative md:flex-shrink-0 md:ltr:translate-x-0 md:rtl:translate-x-0 ltr:border-r rtl:border-l border-n-weak"
:class="[ :class="[
{ {
'shadow-lg md:shadow-none': isMobileSidebarOpen, 'shadow-lg md:shadow-none': isMobileSidebarOpen,
@@ -851,7 +807,7 @@ const menuItems = computed(() => {
" "
/> />
<div <div
class="px-1 py-1.5 flex-shrink-0 flex w-full z-50 gap-2 items-center border-t border-n-weak shadow-[0px_-2px_4px_0px_rgba(27,28,29,0.02)]" class="p-1 flex-shrink-0 flex w-full z-50 gap-2 items-center border-t border-n-weak shadow-[0px_-2px_4px_0px_rgba(27,28,29,0.02)]"
:class="isEffectivelyCollapsed ? 'justify-center' : 'justify-between'" :class="isEffectivelyCollapsed ? 'justify-center' : 'justify-between'"
> >
<SidebarProfileMenu <SidebarProfileMenu
@@ -44,10 +44,8 @@ const shouldRenderComponent = computed(() => {
:active :active
/> />
<template v-else> <template v-else>
<span v-if="icon" class="size-4 grid place-content-center rounded-full"> <Icon v-if="icon" :icon="icon" class="size-4 inline-block" />
<Icon :icon="icon" class="size-4 inline-block" /> <div class="flex-1 truncate min-w-0">{{ label }}</div>
</span>
<div class="flex-1 truncate min-w-0 text-sm">{{ label }}</div>
</template> </template>
</component> </component>
</Policy> </Policy>
@@ -19,24 +19,20 @@ const updateValue = () => {
<template> <template>
<button <button
type="button" type="button"
class="group relative h-4 rounded-full w-7 flex-shrink-0 select-none focus:outline-none focus:ring-1 focus:ring-n-brand focus:ring-offset-n-slate-2 focus:ring-offset-2 transition-colors duration-200 ease-in-out" class="relative h-4 transition-colors duration-200 ease-in-out rounded-full w-7 focus:outline-none focus:ring-1 focus:ring-n-brand focus:ring-offset-n-slate-2 focus:ring-offset-2 flex-shrink-0"
:class="modelValue ? 'bg-n-brand' : 'bg-n-slate-6'" :class="modelValue ? 'bg-n-brand' : 'bg-n-slate-6 disabled:bg-n-slate-6/60'"
role="switch" role="switch"
:aria-checked="modelValue" :aria-checked="modelValue"
@click="updateValue" @click="updateValue"
> >
<span class="sr-only">{{ t('SWITCH.TOGGLE') }}</span> <span class="sr-only">{{ t('SWITCH.TOGGLE') }}</span>
<span <span
class="absolute top-1/2 ltr:left-0.5 rtl:right-0.5 -translate-y-1/2 transition-transform duration-[350ms] ease-[cubic-bezier(0.34,1.56,0.64,1)]" class="absolute top-0.5 left-0.5 h-3 w-3 transform rounded-full shadow-sm transition-transform duration-200 ease-out"
:class=" :class="
modelValue modelValue
? 'ltr:translate-x-3 rtl:-translate-x-3 group-active:ltr:translate-x-[6px] rtl:group-active:-translate-x-[6px]' ? 'translate-x-3 bg-n-background'
: 'ltr:translate-x-0 rtl:translate-x-0' : 'translate-x-0 bg-n-background'
" "
> />
<span
class="block h-3 w-3 rounded-full bg-n-background shadow-md transition-[width] duration-[180ms] ease-in-out group-active:w-[18px]"
/>
</span>
</button> </button>
</template> </template>
@@ -1,175 +0,0 @@
<script setup>
import { ref } from 'vue';
import { BaseTable, BaseTableRow, BaseTableCell } from './index';
import Button from '../button/Button.vue';
import Avatar from '../avatar/Avatar.vue';
import ToggleSwitch from '../switch/Switch.vue';
const automationData = ref([
{
id: 1,
name: 'Welcome Message',
description: 'Send welcome message to new contacts',
active: true,
createdOn: 'Apr 21, 2022',
},
{
id: 2,
name: 'Auto-assign to Sales',
description: 'Automatically assign sales conversations to sales team',
active: false,
createdOn: 'May 15, 2022',
},
{
id: 3,
name: 'Tag Premium Users',
description: 'Add premium tag to conversations from premium users',
active: true,
createdOn: 'Jun 10, 2022',
},
]);
const agentData = ref([
{
id: 1,
name: 'John Doe',
email: 'john@example.com',
role: 'Administrator',
verified: true,
avatarUrl: '',
},
{
id: 2,
name: 'Jane Smith',
email: 'jane@example.com',
role: 'Agent',
verified: true,
avatarUrl: '',
},
]);
const emptyData = ref([]);
const headers = ['Name', 'Active', 'Created on', 'Actions'];
const agentHeaders = ['Agent', 'Role', 'Verification', 'Actions'];
</script>
<template>
<Story title="Components/Table" :layout="{ type: 'grid', width: '100%' }">
<!-- Basic Table -->
<Variant title="Basic Table">
<div class="p-4 bg-n-surface-1">
<BaseTable :headers="headers" :items="automationData">
<template #row="{ items }">
<BaseTableRow
v-for="automation in items"
:key="automation.id"
:item="automation"
>
<template #default>
<BaseTableCell>
<div class="flex items-center gap-2 min-w-0 max-w-full">
<span
class="text-body-main text-n-slate-12 truncate min-w-0 flex-1"
>
{{ automation.name }}
</span>
<div class="w-px h-3 rounded-lg bg-n-weak flex-shrink-0" />
<span
class="text-body-main text-n-slate-11 truncate min-w-0 flex-1"
>
{{ automation.description }}
</span>
</div>
</BaseTableCell>
<BaseTableCell>
<div class="flex justify-center">
<ToggleSwitch v-model="automation.active" />
</div>
</BaseTableCell>
<BaseTableCell>
<span
class="text-body-main text-n-slate-12 whitespace-nowrap"
>
{{ automation.createdOn }}
</span>
</BaseTableCell>
<BaseTableCell align="end" class="w-24">
<div class="flex gap-3 justify-end flex-shrink-0">
<Button icon="i-woot-edit-pen" slate sm />
<Button icon="i-woot-bin" slate sm />
<Button icon="i-woot-clone" slate sm />
</div>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
</BaseTable>
</div>
</Variant>
<!-- Table with Avatars -->
<Variant title="Table with Avatars">
<div class="p-4 bg-n-surface-1">
<BaseTable :headers="agentHeaders" :items="agentData">
<template #row="{ items }">
<BaseTableRow v-for="agent in items" :key="agent.id" :item="agent">
<template #default>
<BaseTableCell>
<div class="flex items-center gap-3 min-w-0">
<Avatar :user="agent" :size="40" class="flex-shrink-0" />
<div class="flex flex-col min-w-0">
<span class="text-body-main text-n-slate-12 truncate">
{{ agent.name }}
</span>
<span class="text-body-main text-n-slate-11 truncate">
{{ agent.email }}
</span>
</div>
</div>
</BaseTableCell>
<BaseTableCell>
<span
class="text-body-main text-n-slate-12 whitespace-nowrap"
>
{{ agent.role }}
</span>
</BaseTableCell>
<BaseTableCell>
<span
class="text-body-main text-n-slate-12 whitespace-nowrap"
>
{{ agent.verified ? 'Verified' : 'Pending' }}
</span>
</BaseTableCell>
<BaseTableCell align="end" class="w-24">
<div class="flex gap-3 justify-end flex-shrink-0">
<Button icon="i-woot-edit-pen" slate sm />
<Button icon="i-woot-bin" slate sm />
</div>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
</BaseTable>
</div>
</Variant>
<!-- Empty State -->
<Variant title="Empty State">
<div class="p-4 bg-n-surface-1">
<BaseTable
:headers="headers"
:items="emptyData"
no-data-message="No automation rules found"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,60 +0,0 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
headers: {
type: Array,
default: () => [],
},
items: {
type: Array,
default: () => [],
},
noDataMessage: {
type: String,
default: '',
},
loading: {
type: Boolean,
default: false,
},
});
const hasHeaderSlot = computed(() => !!props.headers.length);
const showHeaders = computed(
() => hasHeaderSlot.value && props.items.length > 0
);
</script>
<template>
<div class="w-full">
<table class="min-w-full table-auto divide-y divide-n-weak">
<thead v-if="showHeaders" class="border-t border-n-weak">
<tr>
<th
v-for="(header, index) in headers"
:key="index"
class="py-4 ltr:pr-4 rtl:pl-4 text-start text-heading-3 text-n-slate-12 capitalize"
>
<slot :name="`header-${index}`" :header="header">
{{ header }}
</slot>
</th>
</tr>
</thead>
<tbody class="divide-y divide-n-weak text-n-slate-11">
<template v-if="items.length">
<slot name="row" :items="items" />
</template>
<tr v-else-if="noDataMessage && !loading">
<td
:colspan="headers.length || 1"
class="py-20 text-center text-body-main !text-base text-n-slate-11"
>
{{ noDataMessage }}
</td>
</tr>
</tbody>
</table>
</div>
</template>
@@ -1,22 +0,0 @@
<script setup>
defineProps({
align: {
type: String,
default: 'start',
validator: value => ['start', 'center', 'end'].includes(value),
},
});
</script>
<template>
<td
class="py-3 ltr:pr-4 rtl:pl-4 text-body-main"
:class="{
'text-start': align === 'start',
'text-center': align === 'center',
'text-end': align === 'end',
}"
>
<slot />
</td>
</template>
@@ -1,14 +0,0 @@
<script setup>
defineProps({
item: {
type: Object,
required: true,
},
});
</script>
<template>
<tr>
<slot :item="item" />
</tr>
</template>
@@ -1,3 +0,0 @@
export { default as BaseTable } from './BaseTable.vue';
export { default as BaseTableRow } from './BaseTableRow.vue';
export { default as BaseTableCell } from './BaseTableCell.vue';
@@ -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,
@@ -18,7 +18,7 @@ const props = defineProps({
}, },
year: { year: {
type: [Number, String], type: [Number, String],
default: '', required: true,
}, },
}); });
@@ -47,7 +47,7 @@ defineProps({
<div <div
v-if="isComingSoon" v-if="isComingSoon"
class="absolute inset-0 flex items-center justify-center backdrop-blur-[2px] rounded-2xl bg-gradient-to-br from-n-surface-1/90 via-n-surface-1/70 to-n-surface-1/95 cursor-not-allowed" class="absolute inset-0 flex items-center justify-center backdrop-blur-[2px] rounded-2xl bg-gradient-to-br from-n-background/90 via-n-background/70 to-n-background/95 cursor-not-allowed"
> >
<span class="text-n-slate-12 font-medium text-sm"> <span class="text-n-slate-12 font-medium text-sm">
{{ $t('CHANNEL_SELECTOR.COMING_SOON') }} 🚀 {{ $t('CHANNEL_SELECTOR.COMING_SOON') }} 🚀
@@ -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"
@@ -0,0 +1,31 @@
<script setup>
defineProps({
title: {
type: String,
default: '',
},
description: {
type: String,
default: '',
},
});
</script>
<template>
<div class="flex flex-col items-start w-full gap-6">
<div class="flex flex-col w-full gap-4">
<h4 v-if="title" class="text-lg font-medium text-n-slate-12">
{{ title }}
</h4>
<div class="flex flex-row items-center justify-between">
<div class="flex-grow h-px bg-n-weak" />
</div>
<p v-if="description" class="mb-0 text-sm font-normal text-n-slate-12">
{{ description }}
</p>
</div>
<div class="flex flex-col w-full gap-6">
<slot />
</div>
</div>
</template>
@@ -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>
@@ -10,9 +10,12 @@ defineProps({
</script> </script>
<template> <template>
<div :title="inbox.name" class="flex items-center gap-0.5 min-w-0"> <div class="flex items-center text-n-slate-11 text-xs min-w-0">
<ChannelIcon :inbox="inbox" class="size-4 flex-shrink-0 text-n-slate-11" /> <ChannelIcon
<span class="truncate text-label-small text-n-slate-11"> :inbox="inbox"
class="size-3 ltr:mr-1 rtl:ml-1 flex-shrink-0"
/>
<span class="truncate">
{{ inbox.name }} {{ inbox.name }}
</span> </span>
</div> </div>
@@ -11,7 +11,7 @@ defineProps({
<h6 <h6
class="flex items-center gap-3 text-base text-center w-100 text-n-slate-11" class="flex items-center gap-3 text-base text-center w-100 text-n-slate-11"
> >
<span class="text-body-main !text-base text-n-slate-12"> <span class="text-base font-medium text-n-slate-12">
{{ message }} {{ message }}
</span> </span>
<Spinner class="text-n-brand" /> <Spinner class="text-n-brand" />
@@ -15,7 +15,7 @@ export default {
<template> <template>
<div class="border-b border-solid border-n-weak/60"> <div class="border-b border-solid border-n-weak/60">
<div class="max-w-7xl w-full mx-auto pt-4 pb-0 px-6"> <div class="max-w-6xl w-full mx-auto pt-4 pb-0 px-8">
<h2 class="text-2xl text-n-slate-12 mb-1 font-medium"> <h2 class="text-2xl text-n-slate-12 mb-1 font-medium">
{{ headerTitle }} {{ headerTitle }}
</h2> </h2>
@@ -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,

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