Compare commits

..
Author SHA1 Message Date
Shivam Mishra 9a813a67e6 test: fallback references condition 2025-10-30 16:25:42 +05:30
Shivam Mishra a08d44b82d Merge branch 'hotfix/mailbox-process-references' of github.com:chatwoot/chatwoot into hotfix/mailbox-process-references 2025-10-30 16:24:35 +05:30
Shivam Mishra 5dc888c573 feat: check for references if there's no conversation 2025-10-30 16:24:20 +05:30
Shivam MishraandGitHub 3817fad3fe Merge branch 'develop' into hotfix/mailbox-process-references 2025-10-30 15:56:40 +05:30
159c810117 feat: Bulk actions for contacts (#12763)
Introduces APIs and UI for bulk actions in contacts table. The initial
action available will be assign labels

Fixes: #8536 #12253 

## Screens

<img width="1350" height="747" alt="Screenshot 2025-10-29 at 4 05 08 PM"
src="https://github.com/user-attachments/assets/0792dff5-0371-4b2e-bdfb-cd32db773402"
/>
<img width="1345" height="717" alt="Screenshot 2025-10-29 at 4 05 19 PM"
src="https://github.com/user-attachments/assets/ae510404-c6de-4c15-a720-f6d10cdac25b"
/>

---------

Co-authored-by: Muhsin <muhsinkeramam@gmail.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2025-10-30 15:28:28 +05:30
Shivam Mishra 21457368d7 feat: use reference headers to find email 2025-10-30 15:08:23 +05:30
ce400a36d7 feat: Always process email content (#12734)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-10-30 13:36:39 +05:30
Shivam MishraandGitHub d20de25da1 fix: run captain v2 outside the transaction (#12756) 2025-10-30 13:35:20 +05:30
Sivin VargheseandGitHub c31d693add chore: Improvements in pending FAQs (#12755)
# Pull Request Template

## Description

**This PR includes:**

1. Added URL-based filter persistence for the responses pages, including
page and search parameters.
2. Introduced a new empty state variant for pending FAQs — without a
backdrop and with a “Clear Filters” option.
3. Made the actions, filter, and search row remain fixed at the top
while scrolling.

Fixes
https://linear.app/chatwoot/issue/CW-5852/improvements-in-pending-faqs

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

### Loom video
https://www.loom.com/share/1d9eee68c0684f0ab05e08b4ca1e0ce9


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-10-29 14:34:28 -07:00
Vishnu NarayananandGitHub 31497d9c63 fix: update omniauth to latest to resolve heroku deployment issues (#12749)
# Pull Request Template

## Description

Fixes https://github.com/chatwoot/chatwoot/issues/12553

Heroku build was failing due to `omniauth` version mismatch. Also, added
`NODE_OPTIONS=--max-old-space-size=4096` to handle OOM during Vite
build.

## Type of change

Please delete options that are not relevant.

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

- Tested on heroku

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2025-10-29 08:54:29 -07:00
a35c3e4c06 feat: Template types components (#12714)
# Pull Request Template

## Description

Fixes
https://linear.app/chatwoot/issue/CW-5806/create-the-story-book-components-for-template-typestext-media-list

**Pending**
Need to standardize the structure to match the template/campaigns.


## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

### Screenshots

<img width="669" height="179" alt="image"
src="https://github.com/user-attachments/assets/42efd292-8520-4b05-81ec-8bc526fc12db"
/>
<img width="646" height="304" alt="image"
src="https://github.com/user-attachments/assets/431dd964-006c-4877-a693-dae39b90df4c"
/>
<img width="646" height="380" alt="image"
src="https://github.com/user-attachments/assets/9052e31f-9292-4afb-8897-13931655fa00"
/>
<img width="646" height="272" alt="image"
src="https://github.com/user-attachments/assets/873d2488-e856-4a0d-8579-cc1bcc61cc8e"
/>
<img width="646" height="490" alt="image"
src="https://github.com/user-attachments/assets/14c2aa42-bf27-475f-aa70-fe59c1d00e9b"
/>
<img width="646" height="281" alt="image"
src="https://github.com/user-attachments/assets/1f42408e-03e8-4863-b4c7-715d13d67686"
/>



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-10-29 17:06:32 +05:30
Muhsin KelothandGitHub 344e8d5016 fix: Exclude authentication templates from WhatsApp template selection (#12753)
This PR add the changes for excluding the authentication templates from
the WhatsApp template selection in the frontend, as these templates are
not supported at the moment. Reference:
https://www.chatwoot.com/hc/user-guide/articles/1754940076-whatsapp-templates#what-is-not-supported
2025-10-29 14:03:43 +05:30
3e27e28848 chore: Update captain pending FAQ interface (#12752)
# Pull Request Template

## Description

**This PR includes,**
- Added new pending FAQs view with approve/edit/delete actions for each
response.
- Implemented banner notification showing pending FAQ count on main
approved responses page.
- Created dedicated route for pending FAQs review at
/captain/responses/pending.
- Added automatic pending count updates when switching assistants or
routes.
- Modified ResponseCard component to show action buttons instead of
dropdown in pending view.

Fixes
https://linear.app/chatwoot/issue/CW-5833/pending-faqs-in-a-different-ux

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

### Loom video
https://www.loom.com/share/5fe8f79b04cd4681b9360c48710b9373


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
2025-10-28 20:47:42 -07:00
38af08534c fix: Captain response builder not getting triggered (#12729)
## Summary
- Fix captain response builder not getting triggered for cases where
responses are created as completed.

## Testing Instructions 
- Test articles with firecrawl
- Test articles without firecrawl
- Test PDF documents

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
2025-10-28 18:31:08 -07:00
72 changed files with 2268 additions and 1337 deletions
-1
View File
@@ -21,7 +21,6 @@ gem 'telephone_number'
gem 'time_diff'
gem 'tzinfo-data'
gem 'valid_email2'
gem 'octokit'
# compress javascript config.assets.js_compressor
gem 'uglifier'
##-- used for single column multiple binary flags in notification settings/feature flagging --##
+2 -9
View File
@@ -591,13 +591,10 @@ GEM
rack (>= 1.2, < 4)
snaky_hash (~> 2.0)
version_gem (~> 1.1)
octokit (10.0.0)
faraday (>= 1, < 3)
sawyer (~> 0.9)
oj (3.16.10)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
omniauth (2.1.3)
omniauth (2.1.4)
hashie (>= 3.4.6)
logger
rack (>= 2.2.3)
@@ -656,7 +653,7 @@ GEM
rack (>= 2.0.0)
rack-mini-profiler (3.2.0)
rack (>= 1.2.0)
rack-protection (4.1.1)
rack-protection (4.2.1)
base64 (>= 0.1.0)
logger (>= 1.6.0)
rack (>= 3.0.0, < 4)
@@ -820,9 +817,6 @@ GEM
sprockets (> 3.0)
sprockets-rails
tilt
sawyer (0.9.2)
addressable (>= 2.3.5)
faraday (>= 0.17.3, < 3)
scout_apm (5.3.3)
parser
scss_lint (0.60.0)
@@ -1063,7 +1057,6 @@ DEPENDENCIES
net-smtp (~> 0.3.4)
newrelic-sidekiq-metrics (>= 1.6.2)
newrelic_rpm
octokit
omniauth (>= 2.1.2)
omniauth-google-oauth2 (>= 1.1.3)
omniauth-oauth2
+4
View File
@@ -36,6 +36,10 @@
"REDIS_OPENSSL_VERIFY_MODE":{
"description": "OpenSSL verification mode for Redis connections. ref https://help.heroku.com/HC0F8CUS/redis-connection-issues",
"value": "none"
},
"NODE_OPTIONS": {
"description": "Increase V8 heap for Vite build to avoid OOM",
"value": "--max-old-space-size=4096"
}
},
"formation": {
+1 -1
View File
@@ -23,7 +23,7 @@ class Messages::MessageBuilder
process_emails
# When the message has no quoted content, it will just be rendered as a regular message
# The frontend is equipped to handle this case
process_email_content if @account.feature_enabled?(:quoted_email_reply)
process_email_content
@message.save!
@message
end
@@ -1,13 +1,11 @@
class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseController
before_action :type_matches?
def create
if type_matches?
::BulkActionsJob.perform_later(
account: @current_account,
user: current_user,
params: permitted_params
)
case normalized_type
when 'Conversation'
enqueue_conversation_job
head :ok
when 'Contact'
enqueue_contact_job
head :ok
else
render json: { success: false }, status: :unprocessable_entity
@@ -16,11 +14,34 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseControll
private
def type_matches?
['Conversation'].include?(params[:type])
def normalized_type
params[:type].to_s.camelize
end
def permitted_params
def enqueue_conversation_job
::BulkActionsJob.perform_later(
account: @current_account,
user: current_user,
params: conversation_params
)
end
def enqueue_contact_job
Contacts::BulkActionJob.perform_later(
@current_account.id,
current_user.id,
contact_params
)
end
def conversation_params
params.permit(:type, :snoozed_until, ids: [], fields: [:status, :assignee_id, :team_id], labels: [add: [], remove: []])
end
def contact_params
params.require(:ids)
permitted = params.permit(:type, ids: [], labels: [add: []])
permitted[:ids] = permitted[:ids].map(&:to_i) if permitted[:ids].present?
permitted
end
end
@@ -1,155 +0,0 @@
class Api::V1::Accounts::Integrations::GithubController < Api::V1::Accounts::BaseController
before_action :fetch_hook
before_action :ensure_hook_exists, except: [:destroy]
def destroy
@hook.destroy!
head :ok
end
def repositories
repositories = github_service.repositories
filtered_repos = repositories.map do |repo|
{
full_name: repo[:full_name] || repo.full_name,
private: repo[:private] || repo.private
}
end
render json: filtered_repos
end
def search_repositories
repositories = github_service.search_repositories(params[:q])
filtered_repos = repositories.map do |repo|
{
full_name: repo[:full_name] || repo.full_name,
private: repo[:private] || repo.private
}
end
render json: filtered_repos
end
def assignees
assignees = github_service.assignees(repo_full_name)
filtered_assignees = assignees.map do |assignee|
{
login: assignee.login,
avatar_url: assignee.avatar_url
}
end
render json: filtered_assignees
end
def labels
labels = github_service.labels(repo_full_name)
filtered_labels = labels.map do |label|
{
name: label.name,
color: label.color
}
end
render json: filtered_labels
end
def search_issues
issues = github_service.search_issues(repo_full_name, params[:q])
filtered_issues = issues.map do |issue|
{
number: issue.number,
title: issue.title,
html_url: issue.html_url
}
end
render json: filtered_issues
end
def create_issue
issue_data = github_service.create_issue(
repo_full_name,
params[:title],
params[:body],
issue_options
)
github_issue = create_linked_issue(issue_data)
render json: issue_response(github_issue), status: :created
end
def link_issue
issue_data = github_service.issue(repo_full_name, params[:issue_number])
github_issue = create_linked_issue(issue_data)
render json: issue_response(github_issue), status: :created
end
def linked_issues
issues = GithubIssue.where(conversation_id: params[:conversation_id])
render json: issues.map do |issue|
{
id: issue.id,
issue_number: issue.issue_number,
title: issue.issue_title,
html_url: issue.html_url,
repo_full_name: issue.repo_full_name,
linked_by: {
id: issue.linked_by.id,
name: issue.linked_by.name
},
created_at: issue.created_at
}
end
end
def unlink_issue
github_issue = GithubIssue.find(params[:id])
github_issue.destroy!
head :ok
end
private
def fetch_hook
@hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'github')
end
def ensure_hook_exists
render json: { error: 'GitHub integration not configured' }, status: :unauthorized unless @hook
end
def github_service
@github_service ||= Github::GithubService.new(hook: @hook)
end
def repo_full_name
params[:repo_full_name] || "#{params[:owner]}/#{params[:repo]}"
end
def issue_options
options = {}
options[:assignees] = params[:assignees] if params[:assignees].present?
options[:labels] = params[:labels] if params[:labels].present?
options
end
def create_linked_issue(issue_data)
GithubIssue.create!(
conversation_id: params[:conversation_id],
account: Current.account,
repo_full_name: repo_full_name,
issue_number: issue_data.number,
issue_title: issue_data.title,
html_url: issue_data.html_url,
linked_by: Current.user
)
end
def issue_response(github_issue)
{
id: github_issue.id,
issue_number: github_issue.issue_number,
title: github_issue.issue_title,
html_url: github_issue.html_url,
repo_full_name: github_issue.repo_full_name
}
end
end
@@ -1,160 +0,0 @@
class Github::CallbacksController < ApplicationController
include Github::IntegrationHelper
def show
# Validate account context early for all flows that require it
account if params[:code].present?
if params[:installation_id].present? && params[:code].present?
# Both installation and OAuth code present - handle both
handle_installation_with_oauth
elsif params[:installation_id].present?
# Only installation_id present - redirect to OAuth
handle_installation
else
# Only OAuth code present - handle authorization
handle_authorization
end
rescue StandardError => e
Rails.logger.error("Github callback error: #{e.message}")
redirect_to fallback_redirect_uri
end
private
def handle_installation_with_oauth
# Handle both installation and OAuth in one go
installation_id = params[:installation_id]
@response = oauth_client.auth_code.get_token(
params[:code],
redirect_uri: "#{base_url}/github/callback"
)
handle_response(installation_id)
end
def handle_installation
if params[:setup_action] == 'install'
installation_id = params[:installation_id]
redirect_to build_oauth_url(installation_id)
else
Rails.logger.error("Unknown setup_action: #{params[:setup_action]}")
redirect_to github_integration_settings_url
end
end
def handle_authorization
@response = oauth_client.auth_code.get_token(
params[:code],
redirect_uri: "#{base_url}/github/callback"
)
handle_response
end
def build_oauth_url(installation_id)
GlobalConfigService.load('GITHUB_CLIENT_ID', nil)
# Store installation_id in session for later use
session[:github_installation_id] = installation_id
# For now, redirect to a page that will initiate OAuth with proper account context
# This is a temporary solution until we have a proper account-agnostic setup
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/1/settings/integrations/github?setup_action=install&installation_id=#{installation_id}"
end
def oauth_client
app_id = GlobalConfigService.load('GITHUB_CLIENT_ID', nil)
app_secret = GlobalConfigService.load('GITHUB_CLIENT_SECRET', nil)
OAuth2::Client.new(
app_id,
app_secret,
{
site: 'https://github.com',
token_url: '/login/oauth/access_token',
authorize_url: '/login/oauth/authorize'
}
)
end
def handle_response(installation_id = nil)
settings = build_hook_settings(installation_id)
hook = create_integration_hook(settings)
hook.save!
cleanup_session_data
redirect_to github_redirect_uri
rescue StandardError => e
Rails.logger.error("Github callback error: #{e.message}")
redirect_to fallback_redirect_uri
end
def build_hook_settings(installation_id)
settings = {
token_type: parsed_body['token_type'],
scope: parsed_body['scope']
}
settings[:installation_id] = installation_id || session[:github_installation_id]
settings.compact
end
def create_integration_hook(settings)
account.hooks.new(
access_token: parsed_body['access_token'],
status: 'enabled',
app_id: 'github',
settings: settings
)
end
def cleanup_session_data
session.delete(:github_installation_id)
end
def account
@account ||= account_from_state
end
def account_from_state
raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank?
# Try signed GlobalID first (installation flow)
account = GlobalID::Locator.locate_signed(params[:state])
return account if account
# Fallback to JWT token (direct OAuth flow)
account_id = verify_github_token(params[:state])
return Account.find(account_id) if account_id
raise 'Invalid or expired state'
rescue StandardError
raise ActionController::BadRequest, 'Invalid account context'
end
def github_redirect_uri
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/github"
end
def github_integration_settings_url
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/1/settings/integrations/github"
end
def fallback_redirect_uri
github_redirect_uri
rescue StandardError
# Fallback if no account context available
"#{ENV.fetch('FRONTEND_URL', nil)}/app/settings/integrations"
end
def parsed_body
@parsed_body ||= @response.response.parsed
end
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
end
@@ -42,8 +42,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI],
'github' => %w[GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET]
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI]
}
@allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS])
-47
View File
@@ -1,47 +0,0 @@
module Github::IntegrationHelper
# Generates a signed JWT token for Github integration
#
# @param account_id [Integer] The account ID to encode in the token
# @return [String, nil] The encoded JWT token or nil if client secret is missing
def generate_github_token(account_id)
return if github_client_secret.blank?
JWT.encode(github_token_payload(account_id), github_client_secret, 'HS256')
rescue StandardError => e
Rails.logger.error("Failed to generate Github token: #{e.message}")
nil
end
def github_token_payload(account_id)
{
sub: account_id,
iat: Time.current.to_i
}
end
# Verifies and decodes a Github JWT token
#
# @param token [String] The JWT token to verify
# @return [Integer, nil] The account ID from the token or nil if invalid
def verify_github_token(token)
return if token.blank? || github_client_secret.blank?
github_decode_token(token, github_client_secret)
end
private
def github_client_secret
@github_client_secret ||= GlobalConfigService.load('GITHUB_CLIENT_SECRET', nil)
end
def github_decode_token(token, secret)
JWT.decode(token, secret, true, {
algorithm: 'HS256',
verify_expiration: true
}).first['sub']
rescue StandardError => e
Rails.logger.error("Unexpected error verifying Github token: #{e.message}")
nil
end
end
@@ -8,6 +8,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Flag from 'dashboard/components-next/flag/Flag.vue';
import ContactDeleteSection from 'dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import countries from 'shared/constants/countries';
const props = defineProps({
@@ -20,9 +21,17 @@ const props = defineProps({
availabilityStatus: { type: String, default: null },
isExpanded: { type: Boolean, default: false },
isUpdating: { type: Boolean, default: false },
selectable: { type: Boolean, default: false },
isSelected: { type: Boolean, default: false },
});
const emit = defineEmits(['toggle', 'updateContact', 'showContact']);
const emit = defineEmits([
'toggle',
'updateContact',
'showContact',
'select',
'avatarHover',
]);
const { t } = useI18n();
@@ -88,111 +97,148 @@ const onClickExpand = () => {
};
const onClickViewDetails = () => emit('showContact', props.id);
const toggleSelect = checked => {
emit('select', checked);
};
const handleAvatarHover = isHovered => {
emit('avatarHover', isHovered);
};
</script>
<template>
<CardLayout :key="id" layout="row">
<div class="flex items-center justify-start flex-1 gap-4">
<Avatar
:name="name"
:src="thumbnail"
:size="48"
:status="availabilityStatus"
hide-offline-status
rounded-full
/>
<div class="flex flex-col gap-0.5 flex-1">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
<span class="text-base font-medium truncate text-n-slate-12">
{{ name }}
</span>
<span class="inline-flex items-center gap-1">
<span
v-if="additionalAttributes?.companyName"
class="i-ph-building-light size-4 text-n-slate-10 mb-0.5"
/>
<span
v-if="additionalAttributes?.companyName"
class="text-sm truncate text-n-slate-11"
>
{{ additionalAttributes.companyName }}
</span>
</span>
</div>
<div class="flex flex-wrap items-center justify-start gap-x-3 gap-y-1">
<div v-if="email" class="truncate max-w-72" :title="email">
<span class="text-sm text-n-slate-11">
{{ email }}
</span>
</div>
<div v-if="email" class="w-px h-3 truncate bg-n-slate-6" />
<span v-if="phoneNumber" class="text-sm truncate text-n-slate-11">
{{ phoneNumber }}
</span>
<div v-if="phoneNumber" class="w-px h-3 truncate bg-n-slate-6" />
<span
v-if="countryDetails"
class="inline-flex items-center gap-2 text-sm truncate text-n-slate-11"
<div class="relative">
<CardLayout
:key="id"
layout="row"
:class="{
'outline-n-weak !bg-n-slate-3 dark:!bg-n-solid-3': isSelected,
}"
>
<div class="flex items-center justify-start flex-1 gap-4">
<div
class="relative"
@mouseenter="handleAvatarHover(true)"
@mouseleave="handleAvatarHover(false)"
>
<Avatar
:name="name"
:src="thumbnail"
:size="48"
:status="availabilityStatus"
hide-offline-status
rounded-full
>
<Flag :country="countryDetails.countryCode" class="size-3.5" />
{{ formattedLocation }}
</span>
<div v-if="countryDetails" class="w-px h-3 truncate bg-n-slate-6" />
<Button
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')"
variant="link"
size="xs"
@click="onClickViewDetails"
/>
<template v-if="selectable" #overlay="{ size }">
<label
class="flex items-center justify-center rounded-full cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px] border border-n-weak"
:style="{ width: `${size}px`, height: `${size}px` }"
@click.stop
>
<Checkbox
:model-value="isSelected"
@change="event => toggleSelect(event.target.checked)"
/>
</label>
</template>
</Avatar>
</div>
</div>
</div>
<Button
icon="i-lucide-chevron-down"
variant="ghost"
color="slate"
size="xs"
:class="{ 'rotate-180': isExpanded }"
@click="onClickExpand"
/>
<template #after>
<div
class="transition-all duration-500 ease-in-out grid overflow-hidden"
:class="
isExpanded
? 'grid-rows-[1fr] opacity-100'
: 'grid-rows-[0fr] opacity-0'
"
>
<div class="overflow-hidden">
<div class="flex flex-col gap-6 p-6 border-t border-n-strong">
<ContactsForm
ref="contactsFormRef"
:contact-data="contactData"
@update="handleFormUpdate"
/>
<div>
<Button
:label="
t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')
"
size="sm"
:is-loading="isUpdating"
:disabled="isUpdating || isFormInvalid"
@click="handleUpdateContact"
<div class="flex flex-col gap-0.5 flex-1">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
<span class="text-base font-medium truncate text-n-slate-12">
{{ name }}
</span>
<span class="inline-flex items-center gap-1">
<span
v-if="additionalAttributes?.companyName"
class="i-ph-building-light size-4 text-n-slate-10 mb-0.5"
/>
<span
v-if="additionalAttributes?.companyName"
class="text-sm truncate text-n-slate-11"
>
{{ additionalAttributes.companyName }}
</span>
</span>
</div>
<div
class="flex flex-wrap items-center justify-start gap-x-3 gap-y-1"
>
<div v-if="email" class="truncate max-w-72" :title="email">
<span class="text-sm text-n-slate-11">
{{ email }}
</span>
</div>
<div v-if="email" class="w-px h-3 truncate bg-n-slate-6" />
<span v-if="phoneNumber" class="text-sm truncate text-n-slate-11">
{{ phoneNumber }}
</span>
<div v-if="phoneNumber" class="w-px h-3 truncate bg-n-slate-6" />
<span
v-if="countryDetails"
class="inline-flex items-center gap-2 text-sm truncate text-n-slate-11"
>
<Flag :country="countryDetails.countryCode" class="size-3.5" />
{{ formattedLocation }}
</span>
<div v-if="countryDetails" class="w-px h-3 truncate bg-n-slate-6" />
<Button
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')"
variant="link"
size="xs"
@click="onClickViewDetails"
/>
</div>
<ContactDeleteSection
:selected-contact="{
id: props.id,
name: props.name,
}"
/>
</div>
</div>
</template>
</CardLayout>
<Button
icon="i-lucide-chevron-down"
variant="ghost"
color="slate"
size="xs"
:class="{ 'rotate-180': isExpanded }"
@click="onClickExpand"
/>
<template #after>
<div
class="transition-all duration-500 ease-in-out grid overflow-hidden"
:class="
isExpanded
? 'grid-rows-[1fr] opacity-100'
: 'grid-rows-[0fr] opacity-0'
"
>
<div class="overflow-hidden">
<div class="flex flex-col gap-6 p-6 border-t border-n-strong">
<ContactsForm
ref="contactsFormRef"
:contact-data="contactData"
@update="handleFormUpdate"
/>
<div>
<Button
:label="
t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')
"
size="sm"
:is-loading="isUpdating"
:disabled="isUpdating || isFormInvalid"
@click="handleUpdateContact"
/>
</div>
</div>
<ContactDeleteSection
:selected-contact="{
id: props.id,
name: props.name,
}"
/>
</div>
</div>
</template>
</CardLayout>
</div>
</template>
@@ -10,7 +10,15 @@ import {
} from 'shared/helpers/CustomErrors';
import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue';
defineProps({ contacts: { type: Array, required: true } });
const props = defineProps({
contacts: { type: Array, required: true },
selectedContactIds: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['toggleContact']);
const { t } = useI18n();
const store = useStore();
@@ -20,6 +28,9 @@ const route = useRoute();
const uiFlags = useMapGetter('contacts/getUIFlags');
const isUpdating = computed(() => uiFlags.value.isUpdating);
const expandedCardId = ref(null);
const hoveredAvatarId = ref(null);
const selectedIdsSet = computed(() => new Set(props.selectedContactIds || []));
const updateContact = async updatedData => {
try {
@@ -58,25 +69,43 @@ const onClickViewDetails = async id => {
const toggleExpanded = id => {
expandedCardId.value = expandedCardId.value === id ? null : id;
};
const isSelected = id => selectedIdsSet.value.has(id);
const shouldShowSelection = id => {
return hoveredAvatarId.value === id || isSelected(id);
};
const handleSelect = (id, value) => {
emit('toggleContact', { id, value });
};
const handleAvatarHover = (id, isHovered) => {
hoveredAvatarId.value = isHovered ? id : null;
};
</script>
<template>
<div class="flex flex-col gap-4 px-6 pt-4 pb-6">
<ContactsCard
v-for="contact in contacts"
:id="contact.id"
:key="contact.id"
:name="contact.name"
:email="contact.email"
:thumbnail="contact.thumbnail"
:phone-number="contact.phoneNumber"
:additional-attributes="contact.additionalAttributes"
:availability-status="contact.availabilityStatus"
:is-expanded="expandedCardId === contact.id"
:is-updating="isUpdating"
@toggle="toggleExpanded(contact.id)"
@update-contact="updateContact"
@show-contact="onClickViewDetails"
/>
<div class="flex flex-col gap-4">
<div v-for="contact in contacts" :key="contact.id" class="relative">
<ContactsCard
:id="contact.id"
:name="contact.name"
:email="contact.email"
:thumbnail="contact.thumbnail"
:phone-number="contact.phoneNumber"
:additional-attributes="contact.additionalAttributes"
:availability-status="contact.availabilityStatus"
:is-expanded="expandedCardId === contact.id"
:is-updating="isUpdating"
:selectable="shouldShowSelection(contact.id)"
:is-selected="isSelected(contact.id)"
@toggle="toggleExpanded(contact.id)"
@update-contact="updateContact"
@show-contact="onClickViewDetails"
@select="value => handleSelect(contact.id, value)"
@avatar-hover="value => handleAvatarHover(contact.id, value)"
/>
</div>
</div>
</template>
@@ -14,6 +14,10 @@ defineProps({
type: Array,
default: () => [],
},
showBackdrop: {
type: Boolean,
default: true,
},
});
</script>
@@ -25,14 +29,24 @@ defineProps({
class="relative w-full max-w-[60rem] mx-auto overflow-hidden h-full max-h-[28rem]"
>
<div
v-if="showBackdrop"
class="w-full h-full space-y-4 overflow-y-hidden opacity-50 pointer-events-none"
>
<slot name="empty-state-item" />
</div>
<div
class="absolute inset-x-0 bottom-0 flex flex-col items-center justify-end w-full h-full pb-20 bg-gradient-to-t from-n-background from-25% dark:from-n-background to-transparent"
class="flex flex-col items-center justify-end w-full h-full pb-20"
:class="{
'absolute inset-x-0 bottom-0 bg-gradient-to-t from-n-background from-25% dark:from-n-background to-transparent':
showBackdrop,
}"
>
<div class="flex flex-col items-center justify-center gap-6">
<div
class="flex flex-col items-center justify-center gap-6"
:class="{
'mt-48': !showBackdrop,
}"
>
<div class="flex flex-col items-center justify-center gap-3">
<h2
class="text-3xl font-medium text-center text-n-slate-12 font-interDisplay"
@@ -40,6 +54,7 @@ defineProps({
{{ title }}
</h2>
<p
v-if="subtitle"
class="max-w-xl text-base text-center text-n-slate-11 font-interDisplay tracking-[0.3px]"
>
{{ subtitle }}
@@ -114,6 +114,7 @@ const handlePageChange = event => {
<slot name="action" />
</div>
</div>
<slot name="subHeader" />
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto">
@@ -61,23 +61,26 @@ const bulkCheckboxState = computed({
>
<div
v-if="hasSelected"
class="flex items-center gap-3 py-1 ltr:pl-3 rtl:pr-3 ltr:pr-4 rtl:pl-4 rounded-lg bg-n-solid-2 outline outline-1 outline-n-container shadow"
class="flex items-center justify-between gap-3 py-1 ltr:pl-3 rtl:pr-3 ltr:pr-4 rtl:pl-4 rounded-lg bg-n-solid-2 outline outline-1 outline-n-container shadow"
>
<div class="flex items-center gap-3">
<div class="flex items-center gap-1.5">
<div class="flex items-center gap-1.5 min-w-0">
<Checkbox
v-model="bulkCheckboxState"
:indeterminate="isIndeterminate"
/>
<span class="text-sm font-medium text-n-slate-12 tabular-nums">
<span
class="text-sm font-medium truncate text-n-slate-12 tabular-nums"
>
{{ selectAllLabel }}
</span>
</div>
<span class="text-sm text-n-slate-10 tabular-nums">
<span class="text-sm text-n-slate-10 truncate tabular-nums">
{{ selectedCountLabel }}
</span>
<div class="h-4 w-px bg-n-strong" />
<slot name="secondary-actions" />
</div>
<div class="h-4 w-px bg-n-strong" />
<div class="flex items-center gap-3">
<slot name="actions" :selected-count="selectedCount">
<Button
@@ -9,6 +9,7 @@ import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.v
import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Policy from 'dashboard/components/policy.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
id: {
@@ -59,6 +60,10 @@ const props = defineProps({
type: Boolean,
default: true,
},
showActions: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['action', 'navigate', 'select', 'hover']);
@@ -159,73 +164,116 @@ const handleDocumentableClick = () => {
<span class="text-n-slate-11 text-sm line-clamp-5">
{{ answer }}
</span>
<div v-if="!compact" class="items-center justify-between hidden lg:flex">
<div class="inline-flex items-center">
<span
class="text-sm shrink-0 truncate text-n-slate-11 inline-flex items-center gap-1"
>
<i class="i-woot-captain" />
{{ assistant?.name || '' }}
</span>
<div
v-if="documentable"
class="shrink-0 text-sm text-n-slate-11 inline-flex line-clamp-1 gap-1 ml-3"
>
<div
v-if="!compact"
class="flex items-start justify-between flex-col-reverse md:flex-row gap-3"
>
<Policy v-if="showActions" :permissions="['administrator']">
<div class="flex items-center gap-2 sm:gap-5 w-full">
<Button
v-if="status === 'pending'"
:label="$t('CAPTAIN.RESPONSES.OPTIONS.APPROVE')"
icon="i-lucide-circle-check-big"
sm
link
class="hover:!no-underline"
@click="
handleAssistantAction({ action: 'approve', value: 'approve' })
"
/>
<Button
:label="$t('CAPTAIN.RESPONSES.OPTIONS.EDIT_RESPONSE')"
icon="i-lucide-pencil-line"
sm
slate
link
class="hover:!no-underline"
@click="
handleAssistantAction({
action: 'edit',
value: 'edit',
})
"
/>
<Button
:label="$t('CAPTAIN.RESPONSES.OPTIONS.DELETE_RESPONSE')"
icon="i-lucide-trash"
sm
ruby
link
class="hover:!no-underline"
@click="
handleAssistantAction({ action: 'delete', value: 'delete' })
"
/>
</div>
</Policy>
<div
class="flex items-center gap-3"
:class="{ 'justify-between w-full': !showActions }"
>
<div class="inline-flex items-center gap-3 min-w-0">
<span
v-if="documentable.type === 'Captain::Document'"
class="inline-flex items-center gap-1 truncate over"
v-if="status === 'approved'"
class="text-sm shrink-0 truncate text-n-slate-11 inline-flex items-center gap-1"
>
<i class="i-ph-files-light text-base" />
<span class="max-w-96 truncate" :title="documentable.name">
<Icon icon="i-woot-captain" class="size-3.5" />
{{ assistant?.name || '' }}
</span>
<div
v-if="documentable"
class="text-sm text-n-slate-11 grid grid-cols-[auto_1fr] items-center gap-1 min-w-0"
>
<Icon
v-if="documentable.type === 'Captain::Document'"
icon="i-ph-files-light"
class="size-3.5"
/>
<Icon
v-else-if="documentable.type === 'User'"
icon="i-ph-user-circle-plus"
class="size-3.5"
/>
<Icon
v-else-if="documentable.type === 'Conversation'"
icon="i-ph-chat-circle-dots"
class="size-3.5"
/>
<span
v-if="documentable.type === 'Captain::Document'"
class="truncate"
:title="documentable.name"
>
{{ documentable.name }}
</span>
</span>
<span
v-if="documentable.type === 'User'"
class="inline-flex items-center gap-1"
>
<i class="i-ph-user-circle-plus text-base" />
<span
class="max-w-96 truncate"
v-else-if="documentable.type === 'User'"
class="truncate"
:title="documentable.available_name"
>
{{ documentable.available_name }}
</span>
</span>
<span
v-else-if="documentable.type === 'Conversation'"
class="inline-flex items-center gap-1 group cursor-pointer"
role="button"
@click="handleDocumentableClick"
>
<i class="i-ph-chat-circle-dots text-base" />
<span class="group-hover:underline">
<span
v-else-if="documentable.type === 'Conversation'"
class="hover:underline truncate cursor-pointer"
role="button"
@click="handleDocumentableClick"
>
{{
t(`CAPTAIN.RESPONSES.DOCUMENTABLE.CONVERSATION`, {
id: documentable.display_id,
})
}}
</span>
</span>
<span v-else />
</div>
</div>
<div
v-if="status !== 'approved'"
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1 ml-3"
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1"
>
<i
class="i-ph-stack text-base"
:title="t('CAPTAIN.RESPONSES.STATUS.TITLE')"
/>
{{ t(`CAPTAIN.RESPONSES.STATUS.${status.toUpperCase()}`) }}
<Icon icon="i-ph-calendar-dot" class="size-3.5" />
{{ timestamp }}
</div>
</div>
<div
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1 ml-3"
>
<i class="i-ph-calendar-dot" />
{{ timestamp }}
</div>
</div>
</CardLayout>
</template>
@@ -6,16 +6,39 @@ import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCa
import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue';
import { responsesList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
const emit = defineEmits(['click']);
import { computed } from 'vue';
const props = defineProps({
variant: {
type: String,
default: 'approved',
validator: value => ['approved', 'pending'].includes(value),
},
hasActiveFilters: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['click', 'clearFilters']);
const isApproved = computed(() => props.variant === 'approved');
const isPending = computed(() => props.variant === 'pending');
const { isOnChatwootCloud } = useAccount();
const onClick = () => {
emit('click');
};
const onClearFilters = () => {
emit('clearFilters');
};
</script>
<template>
<FeatureSpotlight
v-if="isApproved"
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
:note="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
fallback-thumbnail="/assets/images/dashboard/captain/faqs-light.svg"
@@ -25,11 +48,16 @@ const onClick = () => {
class="mb-8"
/>
<EmptyStateLayout
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')"
:subtitle="$t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE')"
:title="
isPending
? $t('CAPTAIN.RESPONSES.EMPTY_STATE.NO_PENDING_TITLE')
: $t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')
"
:subtitle="isApproved ? $t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE') : ''"
:action-perms="['administrator']"
:show-backdrop="isApproved"
>
<template #empty-state-item>
<template v-if="isApproved" #empty-state-item>
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
<ResponseCard
v-for="(response, index) in responsesList.slice(0, 5)"
@@ -45,11 +73,21 @@ const onClick = () => {
</div>
</template>
<template #actions>
<Button
:label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
icon="i-lucide-plus"
@click="onClick"
/>
<div class="flex flex-col items-center gap-3">
<Button
v-if="isApproved"
:label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
icon="i-lucide-plus"
@click="onClick"
/>
<Button
v-else-if="isPending && hasActiveFilters"
:label="$t('CAPTAIN.RESPONSES.EMPTY_STATE.CLEAR_SEARCH')"
variant="link"
size="sm"
@click="onClearFilters"
/>
</div>
</template>
</EmptyStateLayout>
</template>
@@ -0,0 +1,29 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div class="text-n-slate-12 max-w-80 flex flex-col gap-2.5">
<div class="p-3 bg-n-alpha-2 rounded-xl">
<span
v-dompurify-html="message.content"
class="prose prose-bubble font-medium text-sm"
/>
</div>
<div class="flex gap-2">
<Button label="Call us" slate class="!text-n-blue-text w-full" />
<Button
label="Visit our website"
slate
class="!text-n-blue-text w-full"
/>
</div>
</div>
</template>
@@ -0,0 +1,32 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
>
<div class="px-3 py-2.5">
<img :src="message.image_url" class="max-h-44 rounded-lg w-full" />
<div class="pt-2.5 flex flex-col gap-2">
<h6 class="font-semibold">{{ message.title }}</h6>
<span
v-dompurify-html="message.content"
class="prose prose-bubble text-sm"
/>
</div>
</div>
<div class="p-3 flex items-center justify-center">
<Button label="Call us to order" link class="hover:!no-underline" />
</div>
<div class="p-3 flex items-center justify-center">
<Button label="Visit our store" link class="hover:!no-underline" />
</div>
</div>
</template>
@@ -0,0 +1,25 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
>
<div class="p-3">
<span
v-dompurify-html="message.content"
class="prose prose-bubble font-medium text-sm"
/>
</div>
<div class="p-3 flex items-center justify-center">
<Button label="See options" link class="hover:!no-underline" />
</div>
</div>
</template>
@@ -0,0 +1,20 @@
<script setup>
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div
class="bg-n-alpha-2 text-n-slate-12 rounded-xl flex flex-col gap-2.5 p-3 max-w-80"
>
<img :src="message.image_url" class="max-h-44 rounded-lg w-full" />
<span
v-dompurify-html="message.content"
class="prose prose-bubble font-medium text-sm"
/>
</div>
</template>
@@ -0,0 +1,68 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
>
<div class="p-3">
<span
v-dompurify-html="message.content"
class="prose prose-bubble font-medium text-sm"
/>
</div>
<div class="p-3 flex items-center justify-center">
<Button label="No, that will be all" link class="hover:!no-underline">
<template #icon>
<svg
width="15"
height="15"
viewBox="0 0 15 15"
fill="none"
class="stroke-n-blue-text"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M.667 6.654 5.315.667v3.326c7.968 0 8.878 6.46 8.656 10.007l-.005-.027c-.334-1.79-.474-4.658-8.65-4.658v3.327z"
stroke-width="1.333"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
</Button>
</div>
<div class="p-3 flex items-center justify-center">
<Button
label="I want to talk to an agents"
link
class="hover:!no-underline"
>
<template #icon>
<svg
width="15"
height="15"
viewBox="0 0 15 15"
fill="none"
class="stroke-n-blue-text"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M.667 6.654 5.315.667v3.326c7.968 0 8.878 6.46 8.656 10.007l-.005-.027c-.334-1.79-.474-4.658-8.65-4.658v3.327z"
stroke-width="1.333"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
</Button>
</div>
</div>
</template>
@@ -0,0 +1,14 @@
<script setup>
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div class="bg-n-alpha-2 text-n-slate-12 rounded-xl p-3 max-w-80">
<span v-dompurify-html="message.content" class="prose prose-bubble" />
</div>
</template>
@@ -0,0 +1,21 @@
<script setup>
import CallToAction from '../../bubbles/Template/CallToAction.vue';
const message = {
content:
'We have super cool products going live! Pre-order and customize products. Contact us for more details',
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/CallToAction"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Call To Action">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<CallToAction :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,23 @@
<script setup>
import Card from '../../bubbles/Template/Card.vue';
const message = {
title: 'Two in one cake (1 pound)',
content: 'Customize your order for special occasions',
image_url:
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=500&h=300&fit=crop',
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/Card"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Card">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<Card :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,21 @@
<script setup>
import ListPicker from '../../bubbles/Template/ListPicker.vue';
const message = {
content: `Hey there! Thanks for reaching out to us. Could you let us know
what you need to help us better assist you? `,
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/ListPicker"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="ListPicker">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<ListPicker :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,23 @@
<script setup>
import Media from '../../bubbles/Template/Media.vue';
const message = {
content:
'Welcome to our Diwali sale! Get flat 50% off on select items. Hurry now!',
image_url:
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=500&h=300&fit=crop',
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/Media"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Image Media">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<Media :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,21 @@
<script setup>
import QuickReply from '../../bubbles/Template/QuickReply.vue';
const message = {
content: `Hey there! Thanks for reaching out to us. Could you let us know
what you need to help us better assist you?`,
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/QuickReply"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Quick Replies">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<QuickReply :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,20 @@
<script setup>
import Text from '../../bubbles/Template/Text.vue';
const message = {
content: 'Hello John, how may we assist you?',
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/Text"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Default Text">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<Text :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -1,100 +1,129 @@
<script>
import { mapGetters } from 'vuex';
import NextButton from 'dashboard/components-next/button/Button.vue';
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { vOnClickOutside } from '@vueuse/components';
export default {
components: {
NextButton,
},
emits: ['update', 'close', 'assign'],
data() {
return {
query: '',
selectedLabels: [],
};
},
computed: {
...mapGetters({ labels: 'labels/getLabels' }),
filteredLabels() {
return this.labels.filter(label =>
label.title.toLowerCase().includes(this.query.toLowerCase())
);
},
},
methods: {
isLabelSelected(label) {
return this.selectedLabels.includes(label);
},
assignLabels(key) {
this.$emit('update', key);
},
onClose() {
this.$emit('close');
},
},
import NextButton from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
const emit = defineEmits(['close', 'assign']);
const { t } = useI18n();
const labels = useMapGetter('labels/getLabels');
const query = ref('');
const selectedLabels = ref([]);
const filteredLabels = computed(() => {
if (!query.value) return labels.value;
return labels.value.filter(label =>
label.title.toLowerCase().includes(query.value.toLowerCase())
);
});
const hasLabels = computed(() => labels.value.length > 0);
const hasFilteredLabels = computed(() => filteredLabels.value.length > 0);
const isLabelSelected = label => {
return selectedLabels.value.includes(label);
};
const onClose = () => {
emit('close');
};
const handleAssign = () => {
if (selectedLabels.value.length > 0) {
emit('assign', selectedLabels.value);
}
};
</script>
<template>
<div v-on-clickaway="onClose" class="labels-container">
<div
v-on-click-outside="onClose"
class="absolute ltr:right-2 rtl:left-2 top-12 origin-top-right z-20 w-60 bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md"
role="dialog"
aria-labelledby="label-dialog-title"
>
<div class="triangle">
<svg height="12" viewBox="0 0 24 12" width="24">
<path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" />
</svg>
</div>
<div class="flex items-center justify-between header">
<span>{{ $t('BULK_ACTION.LABELS.ASSIGN_LABELS') }}</span>
<div class="flex items-center justify-between p-2.5">
<span class="text-sm font-medium">{{
t('BULK_ACTION.LABELS.ASSIGN_LABELS')
}}</span>
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" />
</div>
<div class="labels-list">
<header class="labels-list__header">
<div
class="flex items-center justify-between h-8 gap-2 label-list-search"
>
<fluent-icon icon="search" class="search-icon" size="16" />
<input
v-model="query"
type="search"
:placeholder="$t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
class="reset-base !outline-0 !text-sm label--search_input"
/>
</div>
<div class="flex flex-col max-h-60 min-h-0">
<header class="py-2 px-2.5">
<Input
v-model="query"
type="search"
:placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
icon-left="i-lucide-search"
size="sm"
class="w-full"
:aria-label="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
/>
</header>
<ul class="labels-list__body">
<ul
v-if="hasLabels"
class="flex-1 overflow-y-auto m-0 list-none"
role="listbox"
:aria-label="t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
>
<li v-if="!hasFilteredLabels" class="p-2 text-center">
<span class="text-sm text-n-slate-11">{{
t('BULK_ACTION.LABELS.NO_LABELS_FOUND')
}}</span>
</li>
<li
v-for="label in filteredLabels"
:key="label.id"
class="label__list-item"
class="my-1 mx-0 py-0 px-2.5"
role="option"
:aria-selected="isLabelSelected(label.title)"
>
<label
class="item"
:class="{ 'label-selected': isLabelSelected(label.title) }"
class="items-center rounded-md cursor-pointer flex py-1 px-2.5 hover:bg-n-slate-3 dark:hover:bg-n-solid-3 has-[:checked]:bg-n-slate-2"
>
<input
v-model="selectedLabels"
type="checkbox"
:value="label.title"
class="label-checkbox"
class="my-0 ltr:mr-2.5 rtl:ml-2.5"
:aria-label="label.title"
/>
<span
class="overflow-hidden label-title whitespace-nowrap text-ellipsis"
class="overflow-hidden flex-grow w-full text-sm whitespace-nowrap text-ellipsis"
>
{{ label.title }}
</span>
<span
class="label-pill"
class="rounded-md h-3 w-3 flex-shrink-0 border border-solid border-n-weak"
:style="{ backgroundColor: label.color }"
/>
</label>
</li>
</ul>
<footer class="labels-list__footer">
<div v-else class="p-2 text-center">
<span class="text-sm text-n-slate-11">{{
t('CONTACTS_BULK_ACTIONS.NO_LABELS_FOUND')
}}</span>
</div>
<footer class="p-2">
<NextButton
sm
type="submit"
:label="$t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
class="w-full"
:label="t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
:disabled="!selectedLabels.length"
@click="$emit('assign', selectedLabels)"
@click="handleAssign"
/>
</footer>
</div>
@@ -102,107 +131,11 @@ export default {
</template>
<style scoped lang="scss">
.labels-list {
@apply flex flex-col max-h-[15rem] min-h-[auto];
.triangle {
@apply block z-10 absolute text-left -top-3 ltr:right-[--triangle-position] rtl:left-[--triangle-position];
.labels-list__header {
@apply bg-n-alpha-3 backdrop-blur-[100px] py-0 px-2.5;
svg path {
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak;
}
.labels-list__body {
@apply flex-1 overflow-y-auto py-2.5 mx-0;
}
.labels-list__footer {
@apply p-2;
button {
@apply w-full;
.button__content {
@apply text-center;
}
}
}
}
.label-list-search {
@apply bg-n-alpha-black2 py-0 px-2.5 border border-solid border-n-strong rounded-md;
.search-icon {
@apply text-n-slate-10;
}
.label--search_input {
@apply border-0 text-xs m-0 dark:bg-transparent bg-transparent h-[unset] w-full;
}
}
.labels-container {
@apply absolute ltr:right-2 rtl:left-2 top-12 origin-top-right w-auto z-20 max-w-[15rem] min-w-[15rem] bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md;
.header {
@apply p-2.5;
span {
@apply text-sm font-medium;
}
}
.container {
@apply max-h-[15rem] overflow-y-auto;
.label__list-container {
@apply h-full;
}
}
.triangle {
@apply block z-10 absolute text-left -top-3 ltr:right-[--triangle-position] rtl:left-[--triangle-position];
svg path {
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak;
}
}
}
ul {
@apply m-0 list-none;
}
.labels-placeholder {
@apply p-2;
}
.label__list-item {
@apply my-1 mx-0 py-0 px-2.5;
.item {
@apply items-center rounded-md cursor-pointer flex py-1 px-2.5 hover:bg-n-slate-3 dark:hover:bg-n-solid-3;
&.label-selected {
@apply bg-n-slate-2;
}
span {
@apply text-sm;
}
.label-checkbox {
@apply my-0 ltr:mr-2.5 rtl:ml-2.5;
}
.label-title {
@apply flex-grow w-full;
}
.label-pill {
@apply rounded-md h-3 w-3 flex-shrink-0 border border-solid border-n-weak;
}
}
}
.search-container {
@apply bg-n-alpha-3 backdrop-blur-[100px] py-0 px-2.5 sticky top-0 z-20;
}
</style>
@@ -24,7 +24,7 @@
},
"LABELS": {
"ASSIGN_LABELS": "Assign labels",
"NO_LABELS_FOUND": "No labels found for",
"NO_LABELS_FOUND": "No labels found",
"ASSIGN_SELECTED_LABELS": "Assign selected labels",
"ASSIGN_SUCCESFUL": "Labels assigned successfully.",
"ASSIGN_FAILED": "Failed to assign labels. Please try again."
@@ -572,6 +572,16 @@
"ACTIVE_EMPTY_STATE_TITLE": "No contacts are active at the moment 🌙"
}
},
"CONTACTS_BULK_ACTIONS": {
"ASSIGN_LABELS": "Assign Labels",
"ASSIGN_LABELS_SUCCESS": "Labels assigned successfully.",
"ASSIGN_LABELS_FAILED": "Failed to assign labels",
"DESCRIPTION": "Select the labels you want to add to the selected contacts.",
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -15,20 +15,6 @@
},
"ERROR": "There was an error connecting to Shopify. Please try again or contact support if the issue persists."
},
"GITHUB": {
"TITLE": "Connect Github",
"LABEL": "Github Client ID",
"PLACEHOLDER": "Enter your Github Client ID",
"HELP": "Enter your Github Client ID",
"CANCEL": "Cancel",
"SUBMIT": "Connect",
"DELETE": {
"TITLE": "Are you sure you want to delete the integration?",
"MESSAGE": "Are you sure you want to delete the integration?",
"CONFIRM": "Yes, delete",
"CANCEL": "Cancel"
}
},
"HEADER": "Integrations",
"DESCRIPTION": "Chatwoot integrates with multiple tools and services to improve your team's efficiency. Explore the list below to configure your favorite apps.",
"LEARN_MORE": "Learn more about integrations",
@@ -352,7 +338,6 @@
}
}
},
"CAPTAIN": {
"NAME": "Captain",
"HEADER_KNOW_MORE": "Know more",
@@ -876,6 +861,7 @@
},
"RESPONSES": {
"HEADER": "FAQs",
"PENDING_FAQS": "Pending FAQs",
"ADD_NEW": "Create new FAQ",
"DOCUMENTABLE": {
"CONVERSATION": "Conversation #{id}"
@@ -915,6 +901,10 @@
"APPROVED": "Approved",
"ALL": "All"
},
"PENDING_BANNER": {
"TITLE": "Captain has found some FAQs your customers were looking for.",
"ACTION": "Click here to review"
},
"FORM_DESCRIPTION": "Add a question and its corresponding answer to the knowledge base and select the assistant it should be associated with.",
"CREATE": {
"TITLE": "Add an FAQ",
@@ -946,13 +936,15 @@
"APPROVE_SUCCESS_MESSAGE": "The FAQ was marked as approved"
},
"OPTIONS": {
"APPROVE": "Mark as approved",
"EDIT_RESPONSE": "Edit FAQ",
"DELETE_RESPONSE": "Delete FAQ"
"APPROVE": "Approve",
"EDIT_RESPONSE": "Edit",
"DELETE_RESPONSE": "Delete"
},
"EMPTY_STATE": {
"TITLE": "No FAQs Found",
"NO_PENDING_TITLE": "There are no more pending FAQs to review",
"SUBTITLE": "FAQs help your assistant provide quick and accurate answers to questions from your customers. They can be generated automatically from your content or can be added manually.",
"CLEAR_SEARCH": "Clear active filters",
"FEATURE_SPOTLIGHT": {
"TITLE": "Captain FAQ",
"NOTE": "Captain FAQs detects common customer questions—whether missing from your knowledge base or frequently asked—and generates relevant FAQs to improve support. You can review each suggestion and decide whether to approve or reject it."
@@ -10,6 +10,7 @@ import AssistantGuidelinesIndex from './assistants/guidelines/Index.vue';
import AssistantScenariosIndex from './assistants/scenarios/Index.vue';
import DocumentsIndex from './documents/Index.vue';
import ResponsesIndex from './responses/Index.vue';
import ResponsesPendingIndex from './responses/Pending.vue';
import CustomToolsIndex from './tools/Index.vue';
export const routes = [
@@ -125,6 +126,19 @@ export const routes = [
],
},
},
{
path: frontendURL('accounts/:accountId/captain/responses/pending'),
component: ResponsesPendingIndex,
name: 'captain_responses_pending',
meta: {
permissions: ['administrator', 'agent'],
featureFlag: FEATURE_FLAGS.CAPTAIN,
installationTypes: [
INSTALLATION_TYPES.CLOUD,
INSTALLATION_TYPES.ENTERPRISE,
],
},
},
{
path: frontendURL('accounts/:accountId/captain/tools'),
component: CustomToolsIndex,
@@ -1,17 +1,15 @@
<script setup>
import { computed, onMounted, ref, nextTick } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { OnClickOutside } from '@vueuse/components';
import { useRouter } from 'vue-router';
import { useRouter, useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { debounce } from '@chatwoot/utils';
import { useAccount } from 'dashboard/composables/useAccount';
import Banner from 'dashboard/components-next/banner/Banner.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
@@ -25,6 +23,7 @@ import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight
import LimitBanner from 'dashboard/components-next/captain/pageComponents/response/LimitBanner.vue';
const router = useRouter();
const route = useRoute();
const store = useStore();
const { isOnChatwootCloud } = useAccount();
const uiFlags = useMapGetter('captainResponses/getUIFlags');
@@ -37,7 +36,6 @@ const selectedResponse = ref(null);
const deleteDialog = ref(null);
const bulkDeleteDialog = ref(null);
const selectedStatus = ref('all');
const selectedAssistant = ref('all');
const dialogType = ref('');
const searchQuery = ref('');
@@ -45,54 +43,17 @@ const { t } = useI18n();
const createDialog = ref(null);
const isStatusFilterOpen = ref(false);
const shouldShowDropdown = computed(() => {
if (assistants.value.length === 0) return false;
return !isFetching.value;
});
const statusOptions = computed(() =>
['all', 'pending', 'approved'].map(key => ({
label: t(`CAPTAIN.RESPONSES.STATUS.${key.toUpperCase()}`),
value: key,
action: 'filter',
}))
);
const filteredResponses = computed(() => {
return selectedStatus.value === 'pending'
? responses.value.filter(r => r.status === 'pending')
: responses.value;
});
const selectedStatusLabel = computed(() => {
const status = statusOptions.value.find(
option => option.value === selectedStatus.value
);
return t('CAPTAIN.RESPONSES.FILTER.STATUS', {
selected: status ? status.label : '',
});
});
const pendingCount = useMapGetter('captainResponses/getPendingCount');
const handleDelete = () => {
deleteDialog.value.dialogRef.open();
};
const handleAccept = async () => {
try {
await store.dispatch('captainResponses/update', {
id: selectedResponse.value.id,
status: 'approved',
});
useAlert(t(`CAPTAIN.RESPONSES.EDIT.APPROVE_SUCCESS_MESSAGE`));
} catch (error) {
const errorMessage =
error?.message || t(`CAPTAIN.RESPONSES.EDIT.ERROR_MESSAGE`);
useAlert(errorMessage);
} finally {
selectedResponse.value = null;
}
};
const handleCreate = () => {
dialogType.value = 'create';
@@ -105,9 +66,7 @@ const handleEdit = () => {
};
const handleAction = ({ action, id }) => {
selectedResponse.value = filteredResponses.value.find(
response => id === response.id
);
selectedResponse.value = responses.value.find(response => id === response.id);
nextTick(() => {
if (action === 'delete') {
handleDelete();
@@ -115,9 +74,6 @@ const handleAction = ({ action, id }) => {
if (action === 'edit') {
handleEdit();
}
if (action === 'approve') {
handleAccept();
}
});
};
@@ -135,17 +91,31 @@ const handleCreateClose = () => {
selectedResponse.value = null;
};
const fetchResponses = (page = 1) => {
const filterParams = { page };
if (selectedStatus.value !== 'all') {
filterParams.status = selectedStatus.value;
const updateURLWithFilters = (page, search) => {
const query = {
page: page || 1,
};
if (search) {
query.search = search;
}
router.replace({ query });
};
const fetchResponses = (page = 1) => {
const filterParams = { page, status: 'approved' };
if (selectedAssistant.value !== 'all') {
filterParams.assistantId = selectedAssistant.value;
}
if (searchQuery.value) {
filterParams.search = searchQuery.value;
}
// Update URL with current filters
updateURLWithFilters(page, searchQuery.value);
store.dispatch('captainResponses/get', filterParams);
};
@@ -155,7 +125,7 @@ const hoveredCard = ref(null);
const bulkSelectionState = computed(() => {
const selectedCount = bulkSelectedIds.value.size;
const totalCount = filteredResponses.value?.length || 0;
const totalCount = responses.value?.length || 0;
return {
hasSelected: selectedCount > 0,
@@ -168,13 +138,13 @@ const bulkCheckbox = computed({
get: () => bulkSelectionState.value.allSelected,
set: value => {
bulkSelectedIds.value = value
? new Set(filteredResponses.value.map(r => r.id))
? new Set(responses.value.map(r => r.id))
: new Set();
},
});
const buildSelectedCountLabel = computed(() => {
const count = filteredResponses.value?.length || 0;
const count = responses.value?.length || 0;
return bulkSelectionState.value.allSelected
? t('CAPTAIN.RESPONSES.UNSELECT_ALL', { count })
: t('CAPTAIN.RESPONSES.SELECT_ALL', { count });
@@ -191,7 +161,7 @@ const handleCardSelect = id => {
};
const fetchResponseAfterBulkAction = () => {
const hasNoResponsesLeft = filteredResponses.value?.length === 0;
const hasNoResponsesLeft = responses.value?.length === 0;
const currentPage = responseMeta.value?.page;
if (hasNoResponsesLeft) {
@@ -208,22 +178,6 @@ const fetchResponseAfterBulkAction = () => {
bulkSelectedIds.value = new Set();
};
const handleBulkApprove = async () => {
try {
await store.dispatch(
'captainBulkActions/handleBulkApprove',
Array.from(bulkSelectedIds.value)
);
fetchResponseAfterBulkAction();
useAlert(t('CAPTAIN.RESPONSES.BULK_APPROVE.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(
error?.message || t('CAPTAIN.RESPONSES.BULK_APPROVE.ERROR_MESSAGE')
);
}
};
const onPageChange = page => {
// Store current selection state before fetching new page
const wasAllPageSelected = bulkSelectionState.value.allSelected;
@@ -238,7 +192,7 @@ const onPageChange = page => {
};
const onDeleteSuccess = () => {
if (filteredResponses.value?.length === 0 && responseMeta.value?.page > 1) {
if (responses.value?.length === 0 && responseMeta.value?.page > 1) {
onPageChange(responseMeta.value.page - 1);
}
};
@@ -247,24 +201,31 @@ const onBulkDeleteSuccess = () => {
fetchResponseAfterBulkAction();
};
const handleStatusFilterChange = ({ value }) => {
selectedStatus.value = value;
isStatusFilterOpen.value = false;
fetchResponses();
};
const handleAssistantFilterChange = assistant => {
selectedAssistant.value = assistant;
fetchResponses();
fetchResponses(1);
};
const debouncedSearch = debounce(async () => {
fetchResponses();
fetchResponses(1);
}, 500);
const initializeFromURL = () => {
if (route.query.search) {
searchQuery.value = route.query.search;
}
const pageFromURL = parseInt(route.query.page, 10) || 1;
fetchResponses(pageFromURL);
};
const navigateToPendingFAQs = () => {
router.push({ name: 'captain_responses_pending' });
};
onMounted(() => {
store.dispatch('captainAssistants/get');
fetchResponses();
initializeFromURL();
store.dispatch('captainResponses/fetchPendingCount');
});
</script>
@@ -276,8 +237,8 @@ onMounted(() => {
:header-title="$t('CAPTAIN.RESPONSES.HEADER')"
:button-label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
:is-fetching="isFetching"
:is-empty="!filteredResponses.length"
:show-pagination-footer="!isFetching && !!filteredResponses.length"
:is-empty="!responses.length"
:show-pagination-footer="!isFetching && !!responses.length"
:feature-flag="FEATURE_FLAGS.CAPTAIN"
@update:current-page="onPageChange"
@click="handleCreate"
@@ -294,18 +255,10 @@ onMounted(() => {
/>
</template>
<template #emptyState>
<ResponsePageEmptyState @click="handleCreate" />
</template>
<template #paywall>
<CaptainPaywall />
</template>
<template #controls>
<template #subHeader>
<div
v-if="shouldShowDropdown"
class="mb-4 -mt-3 flex justify-between items-center py-1"
class="mb-2 flex justify-between items-center py-1"
:class="{
'ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 rounded-lg outline outline-1 outline-n-weak bg-n-solid-3 w-fit':
bulkSelectionState.hasSelected,
@@ -315,25 +268,7 @@ onMounted(() => {
v-if="!bulkSelectionState.hasSelected"
class="flex gap-3 justify-between w-full items-center"
>
<div class="flex gap-3">
<OnClickOutside @trigger="isStatusFilterOpen = false">
<Button
:label="selectedStatusLabel"
icon="i-lucide-chevron-down"
size="sm"
color="slate"
trailing-icon
class="max-w-48"
@click="isStatusFilterOpen = !isStatusFilterOpen"
/>
<DropdownMenu
v-if="isStatusFilterOpen"
:menu-items="statusOptions"
class="mt-2"
@action="handleStatusFilterChange"
/>
</OnClickOutside>
<div class="flex items-center gap-3">
<AssistantSelector
:assistant-id="selectedAssistant"
@update="handleAssistantFilterChange"
@@ -344,6 +279,7 @@ onMounted(() => {
:placeholder="$t('CAPTAIN.RESPONSES.SEARCH_PLACEHOLDER')"
class="w-64"
size="sm"
type="search"
autofocus
@input="debouncedSearch"
/>
@@ -380,15 +316,6 @@ onMounted(() => {
</div>
<div class="h-4 w-px bg-n-strong" />
<div class="flex gap-3 items-center">
<Button
:label="$t('CAPTAIN.RESPONSES.BULK_APPROVE_BUTTON')"
sm
ghost
icon="i-lucide-check"
class="!px-1.5"
@click="handleBulkApprove"
/>
<div class="h-4 w-px bg-n-strong" />
<Button
:label="$t('CAPTAIN.RESPONSES.BULK_DELETE_BUTTON')"
sm
@@ -404,12 +331,29 @@ onMounted(() => {
</div>
</template>
<template #emptyState>
<ResponsePageEmptyState @click="handleCreate" />
</template>
<template #paywall>
<CaptainPaywall />
</template>
<template #body>
<LimitBanner class="mb-5" />
<Banner
v-if="pendingCount > 0"
color="blue"
class="mb-4 -mt-3"
:action-label="$t('CAPTAIN.RESPONSES.PENDING_BANNER.ACTION')"
@action="navigateToPendingFAQs"
>
{{ $t('CAPTAIN.RESPONSES.PENDING_BANNER.TITLE') }}
</Banner>
<div class="flex flex-col gap-4">
<ResponseCard
v-for="response in filteredResponses"
v-for="response in responses"
:id="response.id"
:key="response.id"
:question="response.question"
@@ -422,6 +366,7 @@ onMounted(() => {
:is-selected="bulkSelectedIds.has(response.id)"
:selectable="hoveredCard === response.id || bulkSelectedIds.size > 0"
:show-menu="!bulkSelectedIds.has(response.id)"
:show-actions="false"
@action="handleAction"
@navigate="handleNavigationAction"
@select="handleCardSelect"
@@ -0,0 +1,451 @@
<script setup>
import { computed, onMounted, ref, nextTick } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { useRouter, useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { debounce } from '@chatwoot/utils';
import { useAccount } from 'dashboard/composables/useAccount';
import { frontendURL } from 'dashboard/helper/URLHelper';
import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
import AssistantSelector from 'dashboard/components-next/captain/pageComponents/AssistantSelector.vue';
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
import CreateResponseDialog from 'dashboard/components-next/captain/pageComponents/response/CreateResponseDialog.vue';
import ResponsePageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/ResponsePageEmptyState.vue';
import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight/FeatureSpotlightPopover.vue';
import LimitBanner from 'dashboard/components-next/captain/pageComponents/response/LimitBanner.vue';
const router = useRouter();
const route = useRoute();
const store = useStore();
const { isOnChatwootCloud } = useAccount();
const uiFlags = useMapGetter('captainResponses/getUIFlags');
const assistants = useMapGetter('captainAssistants/getRecords');
const responseMeta = useMapGetter('captainResponses/getMeta');
const responses = useMapGetter('captainResponses/getRecords');
const isFetching = computed(() => uiFlags.value.fetchingList);
const selectedResponse = ref(null);
const deleteDialog = ref(null);
const bulkDeleteDialog = ref(null);
const selectedAssistant = ref('all');
const dialogType = ref('');
const searchQuery = ref('');
const { t } = useI18n();
const createDialog = ref(null);
const shouldShowDropdown = computed(() => {
if (assistants.value.length === 0) return false;
return !isFetching.value;
});
const backUrl = computed(() =>
frontendURL(`accounts/${route.params.accountId}/captain/responses`)
);
// Filter out approved responses in pending view
const filteredResponses = computed(() =>
responses.value.filter(response => response.status !== 'approved')
);
const handleDelete = () => {
deleteDialog.value.dialogRef.open();
};
const handleAccept = async () => {
try {
await store.dispatch('captainResponses/update', {
id: selectedResponse.value.id,
status: 'approved',
});
useAlert(t(`CAPTAIN.RESPONSES.EDIT.APPROVE_SUCCESS_MESSAGE`));
} catch (error) {
const errorMessage =
error?.message || t(`CAPTAIN.RESPONSES.EDIT.ERROR_MESSAGE`);
useAlert(errorMessage);
} finally {
selectedResponse.value = null;
}
};
const handleCreate = () => {
dialogType.value = 'create';
nextTick(() => createDialog.value.dialogRef.open());
};
const handleEdit = () => {
dialogType.value = 'edit';
nextTick(() => createDialog.value.dialogRef.open());
};
const handleAction = ({ action, id }) => {
selectedResponse.value = filteredResponses.value.find(
response => id === response.id
);
nextTick(() => {
if (action === 'delete') {
handleDelete();
}
if (action === 'edit') {
handleEdit();
}
if (action === 'approve') {
handleAccept();
}
});
};
const handleNavigationAction = ({ id, type }) => {
if (type === 'Conversation') {
router.push({
name: 'inbox_conversation',
params: { conversation_id: id },
});
}
};
const handleCreateClose = () => {
dialogType.value = '';
selectedResponse.value = null;
};
const updateURLWithFilters = (page, search) => {
const query = {
page: page || 1,
};
if (search) {
query.search = search;
}
router.replace({ query });
};
const fetchResponses = (page = 1) => {
const filterParams = { page, status: 'pending' };
if (selectedAssistant.value !== 'all') {
filterParams.assistantId = selectedAssistant.value;
}
if (searchQuery.value) {
filterParams.search = searchQuery.value;
}
// Update URL with current filters
updateURLWithFilters(page, searchQuery.value);
store.dispatch('captainResponses/get', filterParams);
};
// Bulk action
const bulkSelectedIds = ref(new Set());
const hoveredCard = ref(null);
const bulkSelectionState = computed(() => {
const selectedCount = bulkSelectedIds.value.size;
const totalCount = filteredResponses.value?.length || 0;
return {
hasSelected: selectedCount > 0,
isIndeterminate: selectedCount > 0 && selectedCount < totalCount,
allSelected: totalCount > 0 && selectedCount === totalCount,
};
});
const bulkCheckbox = computed({
get: () => bulkSelectionState.value.allSelected,
set: value => {
bulkSelectedIds.value = value
? new Set(filteredResponses.value.map(r => r.id))
: new Set();
},
});
const buildSelectedCountLabel = computed(() => {
const count = filteredResponses.value?.length || 0;
return bulkSelectionState.value.allSelected
? t('CAPTAIN.RESPONSES.UNSELECT_ALL', { count })
: t('CAPTAIN.RESPONSES.SELECT_ALL', { count });
});
const handleCardHover = (isHovered, id) => {
hoveredCard.value = isHovered ? id : null;
};
const handleCardSelect = id => {
const selected = new Set(bulkSelectedIds.value);
selected[selected.has(id) ? 'delete' : 'add'](id);
bulkSelectedIds.value = selected;
};
const fetchResponseAfterBulkAction = () => {
const hasNoResponsesLeft = filteredResponses.value?.length === 0;
const currentPage = responseMeta.value?.page;
if (hasNoResponsesLeft) {
const pageToFetch = currentPage > 1 ? currentPage - 1 : currentPage;
fetchResponses(pageToFetch);
} else {
fetchResponses(currentPage);
}
bulkSelectedIds.value = new Set();
};
const handleBulkApprove = async () => {
try {
await store.dispatch(
'captainBulkActions/handleBulkApprove',
Array.from(bulkSelectedIds.value)
);
fetchResponseAfterBulkAction();
useAlert(t('CAPTAIN.RESPONSES.BULK_APPROVE.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(
error?.message || t('CAPTAIN.RESPONSES.BULK_APPROVE.ERROR_MESSAGE')
);
}
};
const onPageChange = page => {
const wasAllPageSelected = bulkSelectionState.value.allSelected;
const hadPartialSelection = bulkSelectedIds.value.size > 0;
fetchResponses(page);
if (wasAllPageSelected || hadPartialSelection) {
bulkSelectedIds.value = new Set();
}
};
const onDeleteSuccess = () => {
if (filteredResponses.value?.length === 0 && responseMeta.value?.page > 1) {
onPageChange(responseMeta.value.page - 1);
}
};
const onBulkDeleteSuccess = () => {
fetchResponseAfterBulkAction();
};
const handleAssistantFilterChange = assistant => {
selectedAssistant.value = assistant;
fetchResponses(1);
};
const debouncedSearch = debounce(async () => {
fetchResponses(1);
}, 500);
const hasActiveFilters = computed(() => {
return Boolean(searchQuery.value || selectedAssistant.value !== 'all');
});
const clearFilters = () => {
searchQuery.value = '';
selectedAssistant.value = 'all';
fetchResponses(1);
};
const initializeFromURL = () => {
if (route.query.search) {
searchQuery.value = route.query.search;
}
const pageFromURL = parseInt(route.query.page, 10) || 1;
fetchResponses(pageFromURL);
};
onMounted(() => {
store.dispatch('captainAssistants/get');
initializeFromURL();
});
</script>
<template>
<PageLayout
:total-count="responseMeta.totalCount"
:current-page="responseMeta.page"
:button-policy="['administrator']"
:header-title="$t('CAPTAIN.RESPONSES.PENDING_FAQS')"
:button-label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
:is-fetching="isFetching"
:is-empty="!filteredResponses.length"
:show-pagination-footer="!isFetching && !!filteredResponses.length"
:feature-flag="FEATURE_FLAGS.CAPTAIN"
:back-url="backUrl"
@update:current-page="onPageChange"
@click="handleCreate"
>
<template #knowMore>
<FeatureSpotlightPopover
:button-label="$t('CAPTAIN.HEADER_KNOW_MORE')"
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
:note="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
:hide-actions="!isOnChatwootCloud"
fallback-thumbnail="/assets/images/dashboard/captain/faqs-popover-light.svg"
fallback-thumbnail-dark="/assets/images/dashboard/captain/faqs-popover-dark.svg"
learn-more-url="https://chwt.app/captain-faq"
/>
</template>
<template #subHeader>
<div
v-if="shouldShowDropdown"
class="mb-2 flex justify-between items-center py-1"
:class="{
'ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 rounded-lg outline outline-1 outline-n-weak bg-n-solid-3 w-fit':
bulkSelectionState.hasSelected,
}"
>
<div
v-if="!bulkSelectionState.hasSelected"
class="flex gap-3 justify-between w-full items-center"
>
<div class="flex items-center gap-3">
<AssistantSelector
:assistant-id="selectedAssistant"
@update="handleAssistantFilterChange"
/>
</div>
<Input
v-model="searchQuery"
:placeholder="$t('CAPTAIN.RESPONSES.SEARCH_PLACEHOLDER')"
class="w-64"
size="sm"
type="search"
autofocus
@input="debouncedSearch"
/>
</div>
<transition
name="slide-fade"
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 transform ltr:-translate-x-4 rtl:translate-x-4"
enter-to-class="opacity-100 transform translate-x-0"
leave-active-class="hidden opacity-0"
>
<div
v-if="bulkSelectionState.hasSelected"
class="flex items-center gap-3"
>
<div class="flex items-center gap-3">
<div class="flex items-center gap-1.5">
<Checkbox
v-model="bulkCheckbox"
:indeterminate="bulkSelectionState.isIndeterminate"
/>
<span class="text-sm text-n-slate-12 font-medium tabular-nums">
{{ buildSelectedCountLabel }}
</span>
</div>
<span class="text-sm text-n-slate-10 tabular-nums">
{{
$t('CAPTAIN.RESPONSES.SELECTED', {
count: bulkSelectedIds.size,
})
}}
</span>
</div>
<div class="h-4 w-px bg-n-strong" />
<div class="flex gap-3 items-center">
<Button
:label="$t('CAPTAIN.RESPONSES.BULK_APPROVE_BUTTON')"
sm
ghost
icon="i-lucide-check"
class="!px-1.5"
@click="handleBulkApprove"
/>
<div class="h-4 w-px bg-n-strong" />
<Button
:label="$t('CAPTAIN.RESPONSES.BULK_DELETE_BUTTON')"
sm
ruby
ghost
class="!px-1.5"
icon="i-lucide-trash"
@click="bulkDeleteDialog.dialogRef.open()"
/>
</div>
</div>
</transition>
</div>
</template>
<template #emptyState>
<ResponsePageEmptyState
variant="pending"
:has-active-filters="hasActiveFilters"
@click="handleCreate"
@clear-filters="clearFilters"
/>
</template>
<template #paywall>
<CaptainPaywall />
</template>
<template #body>
<LimitBanner class="mb-5" />
<div class="flex flex-col gap-4">
<ResponseCard
v-for="response in filteredResponses"
:id="response.id"
:key="response.id"
:question="response.question"
:answer="response.answer"
:assistant="response.assistant"
:documentable="response.documentable"
:status="response.status"
:created-at="response.created_at"
:updated-at="response.updated_at"
:is-selected="bulkSelectedIds.has(response.id)"
:selectable="hoveredCard === response.id || bulkSelectedIds.size > 0"
:show-menu="false"
:show-actions="!bulkSelectedIds.has(response.id)"
@action="handleAction"
@navigate="handleNavigationAction"
@select="handleCardSelect"
@hover="isHovered => handleCardHover(isHovered, response.id)"
/>
</div>
</template>
<DeleteDialog
v-if="selectedResponse"
ref="deleteDialog"
:entity="selectedResponse"
type="Responses"
@delete-success="onDeleteSuccess"
/>
<BulkDeleteDialog
v-if="bulkSelectedIds"
ref="bulkDeleteDialog"
:bulk-ids="bulkSelectedIds"
type="Responses"
@delete-success="onBulkDeleteSuccess"
/>
<CreateResponseDialog
v-if="dialogType"
ref="createDialog"
:type="dialogType"
:selected-response="selectedResponse"
@close="handleCreateClose"
/>
</PageLayout>
</template>
@@ -0,0 +1,142 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import LabelActions from 'dashboard/components/widgets/conversation/conversationBulkActions/LabelActions.vue';
const props = defineProps({
visibleContactIds: {
type: Array,
default: () => [],
},
selectedContactIds: {
type: Array,
default: () => [],
},
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['clearSelection', 'assignLabels', 'toggleAll']);
const { t } = useI18n();
const selectedCount = computed(() => props.selectedContactIds.length);
const totalVisibleContacts = computed(() => props.visibleContactIds.length);
const showLabelSelector = ref(false);
const selectAllLabel = computed(() => {
if (!totalVisibleContacts.value) {
return '';
}
return t('CONTACTS_BULK_ACTIONS.SELECT_ALL', {
count: totalVisibleContacts.value,
});
});
const selectedCountLabel = computed(() =>
t('CONTACTS_BULK_ACTIONS.SELECTED_COUNT', {
count: selectedCount.value,
})
);
const allItems = computed(() =>
props.visibleContactIds.map(id => ({
id,
}))
);
const selectionModel = computed({
get: () => new Set(props.selectedContactIds),
set: newSet => {
if (!props.visibleContactIds.length) {
emit('toggleAll', false);
return;
}
const shouldSelectAll =
newSet.size === props.visibleContactIds.length && newSet.size > 0;
emit('toggleAll', shouldSelectAll);
},
});
const emitClearSelection = () => {
showLabelSelector.value = false;
emit('clearSelection');
};
const toggleLabelSelector = () => {
if (!selectedCount.value || props.isLoading) return;
showLabelSelector.value = !showLabelSelector.value;
};
const closeLabelSelector = () => {
showLabelSelector.value = false;
};
const handleAssignLabels = labels => {
emit('assignLabels', labels);
closeLabelSelector();
};
</script>
<template>
<BulkSelectBar
v-model="selectionModel"
:all-items="allItems"
:select-all-label="selectAllLabel"
:selected-count-label="selectedCountLabel"
class="py-2 ltr:!pr-3 rtl:!pl-3"
>
<template #secondary-actions>
<Button
sm
ghost
slate
:label="t('CONTACTS_BULK_ACTIONS.CLEAR_SELECTION')"
class="!px-1.5"
@click="emitClearSelection"
/>
</template>
<template #actions>
<div class="flex items-center gap-2 ml-auto">
<div
v-on-click-outside="closeLabelSelector"
class="relative flex items-center"
>
<Button
sm
faded
slate
icon="i-lucide-tags"
:label="t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS')"
:disabled="!selectedCount || isLoading"
:is-loading="isLoading"
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
@click="toggleLabelSelector"
/>
<transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<LabelActions
v-if="showLabelSelector"
class="[&>.triangle]:!hidden [&>div>button]:!hidden ltr:!right-0 rtl:!left-0 top-8 mt-0.5"
@assign="handleAssignLabels"
/>
</transition>
</div>
</div>
</template>
</BulkSelectBar>
</template>
@@ -3,14 +3,17 @@ import { onMounted, computed, ref, reactive, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { debounce } from '@chatwoot/utils';
import { useUISettings } from 'dashboard/composables/useUISettings';
import filterQueryGenerator from 'dashboard/helper/filterQueryGenerator';
import ContactsListLayout from 'dashboard/components-next/Contacts/ContactsListLayout.vue';
import ContactsList from 'dashboard/components-next/Contacts/Pages/ContactsList.vue';
import ContactEmptyState from 'dashboard/components-next/Contacts/EmptyState/ContactEmptyState.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ContactsList from 'dashboard/components-next/Contacts/Pages/ContactsList.vue';
import ContactsBulkActionBar from '../components/ContactsBulkActionBar.vue';
import BulkActionsAPI from 'dashboard/api/bulkActions';
const DEFAULT_SORT_FIELD = 'last_activity_at';
const DEBOUNCE_DELAY = 300;
@@ -58,6 +61,10 @@ const isFetchingList = computed(
);
const currentPage = computed(() => Number(meta.value?.currentPage));
const totalItems = computed(() => meta.value?.count);
const selectedContactIds = ref([]);
const isBulkActionLoading = ref(false);
const hasSelection = computed(() => selectedContactIds.value.length > 0);
const activeSegment = computed(() => {
if (!activeSegmentId.value) return undefined;
return segments.value.find(view => view.id === Number(activeSegmentId.value));
@@ -105,6 +112,31 @@ const emptyStateMessage = computed(() => {
return t('CONTACTS_LAYOUT.EMPTY_STATE.SEARCH_EMPTY_STATE_TITLE');
});
const visibleContactIds = computed(() =>
contacts.value.map(contact => contact.id)
);
const clearSelection = () => {
selectedContactIds.value = [];
};
const toggleSelectAll = shouldSelect => {
selectedContactIds.value = shouldSelect ? [...visibleContactIds.value] : [];
};
const toggleContactSelection = ({ id, value }) => {
const isAlreadySelected = selectedContactIds.value.includes(id);
const shouldSelect = value ?? !isAlreadySelected;
if (shouldSelect && !isAlreadySelected) {
selectedContactIds.value = [...selectedContactIds.value, id];
} else if (!shouldSelect && isAlreadySelected) {
selectedContactIds.value = selectedContactIds.value.filter(
contactId => contactId !== id
);
}
};
const updatePageParam = (page, search = '') => {
const query = {
...route.query,
@@ -129,6 +161,7 @@ const getCommonFetchParams = (page = 1) => ({
});
const fetchContacts = async (page = 1) => {
clearSelection();
await store.dispatch('contacts/clearContactFilters');
await store.dispatch('contacts/get', getCommonFetchParams(page));
updatePageParam(page);
@@ -136,6 +169,7 @@ const fetchContacts = async (page = 1) => {
const fetchSavedOrAppliedFilteredContact = async (payload, page = 1) => {
if (!activeSegmentId.value && !hasAppliedFilters.value) return;
clearSelection();
await store.dispatch('contacts/filter', {
...getCommonFetchParams(page),
queryPayload: payload,
@@ -144,6 +178,7 @@ const fetchSavedOrAppliedFilteredContact = async (payload, page = 1) => {
};
const fetchActiveContacts = async (page = 1) => {
clearSelection();
await store.dispatch('contacts/clearContactFilters');
await store.dispatch('contacts/active', {
page,
@@ -153,6 +188,7 @@ const fetchActiveContacts = async (page = 1) => {
};
const searchContacts = debounce(async (value, page = 1) => {
clearSelection();
await store.dispatch('contacts/clearContactFilters');
searchValue.value = value;
@@ -170,6 +206,7 @@ const searchContacts = debounce(async (value, page = 1) => {
}, DEBOUNCE_DELAY);
const fetchContactsBasedOnContext = async page => {
clearSelection();
updatePageParam(page, searchValue.value);
if (isFetchingList.value) return;
if (searchQuery.value) {
@@ -197,6 +234,28 @@ const fetchContactsBasedOnContext = async page => {
await fetchContacts(page);
};
const assignLabels = async labels => {
if (!labels.length || !selectedContactIds.value.length) {
return;
}
isBulkActionLoading.value = true;
try {
await BulkActionsAPI.create({
type: 'Contact',
ids: selectedContactIds.value,
labels: { add: labels },
});
useAlert(t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS_SUCCESS'));
clearSelection();
await fetchContactsBasedOnContext(pageNumber.value);
} catch (error) {
useAlert(t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS_FAILED'));
} finally {
isBulkActionLoading.value = false;
}
};
const handleSort = async ({ sort, order }) => {
Object.assign(sortState, { activeSort: sort, activeOrdering: order });
@@ -227,6 +286,17 @@ const createContact = async contact => {
await store.dispatch('contacts/create', contact);
};
watch(
contacts,
newContacts => {
const idsOnPage = newContacts.map(contact => contact.id);
selectedContactIds.value = selectedContactIds.value.filter(id =>
idsOnPage.includes(id)
);
},
{ deep: true }
);
watch(
() => uiSettings.value?.contacts_sort_by,
newSortBy => {
@@ -331,7 +401,23 @@ onMounted(async () => {
</span>
</div>
<ContactsList v-else :contacts="contacts" />
<div v-else class="flex flex-col gap-4 px-6 pt-4 pb-6">
<div v-if="hasSelection">
<ContactsBulkActionBar
:visible-contact-ids="visibleContactIds"
:selected-contact-ids="selectedContactIds"
:is-loading="isBulkActionLoading"
@toggle-all="toggleSelectAll"
@clear-selection="clearSelection"
@assign-labels="assignLabels"
/>
</div>
<ContactsList
:contacts="contacts"
:selected-contact-ids="selectedContactIds"
@toggle-contact="toggleContactSelection"
/>
</div>
</template>
</ContactsListLayout>
</div>
@@ -1,57 +0,0 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import {
useFunctionGetter,
useMapGetter,
useStore,
} from 'dashboard/composables/store';
import Integration from './Integration.vue';
import Spinner from 'shared/components/Spinner.vue';
const store = useStore();
const integrationLoaded = ref(false);
const integration = useFunctionGetter('integrations/getIntegration', 'github');
const uiFlags = useMapGetter('integrations/getUIFlags');
const integrationAction = computed(() => {
if (integration.value.enabled) {
return 'disconnect';
}
return integration.value.action;
});
const initializeGithubIntegration = async () => {
await store.dispatch('integrations/get', 'github');
integrationLoaded.value = true;
};
onMounted(() => {
initializeGithubIntegration();
});
</script>
<template>
<div class="flex-grow flex-shrink max-w-6xl p-4 mx-auto overflow-auto">
<div v-if="integrationLoaded && !uiFlags.isCreatingGithub">
<Integration
:integration-id="integration.id"
:integration-logo="integration.logo"
:integration-name="integration.name"
:integration-description="integration.description"
:integration-enabled="integration.enabled"
:integration-action="integrationAction"
:delete-confirmation-text="{
title: $t('INTEGRATION_SETTINGS.GITHUB.DELETE.TITLE'),
message: $t('INTEGRATION_SETTINGS.GITHUB.DELETE.MESSAGE'),
}"
/>
</div>
<div v-else class="flex items-center justify-center flex-1">
<Spinner size="" color-scheme="primary" />
</div>
</div>
</template>
@@ -10,7 +10,7 @@ import SettingsContent from '../Wrapper.vue';
import Linear from './Linear.vue';
import Notion from './Notion.vue';
import Shopify from './Shopify.vue';
import Github from './Github.vue';
export default {
routes: [
{
@@ -100,16 +100,6 @@ export default {
},
props: route => ({ code: route.query.code }),
},
{
path: 'github',
name: 'settings_integrations_github',
component: Github,
meta: {
featureFlag: FEATURE_FLAGS.INTEGRATIONS,
permissions: ['administrator'],
},
props: route => ({ error: route.query.error }),
},
{
path: 'shopify',
name: 'settings_integrations_shopify',
@@ -1,9 +1,22 @@
import CaptainResponseAPI from 'dashboard/api/captain/response';
import { createStore } from './storeFactory';
const SET_PENDING_COUNT = 'SET_PENDING_COUNT';
export default createStore({
name: 'CaptainResponse',
API: CaptainResponseAPI,
getters: {
getPendingCount: state => state.meta.pendingCount || 0,
},
mutations: {
[SET_PENDING_COUNT](state, count) {
state.meta = {
...state.meta,
pendingCount: Number(count),
};
},
},
actions: mutations => ({
removeBulkResponses: ({ commit, state }, ids) => {
const updatedRecords = state.records.filter(
@@ -28,5 +41,18 @@ export default createStore({
commit(mutations.SET, updatedRecords);
},
fetchPendingCount: async ({ commit }, assistantId) => {
try {
const response = await CaptainResponseAPI.get({
status: 'pending',
page: 1,
assistantId,
});
const count = response.data?.meta?.total_count || 0;
commit(SET_PENDING_COUNT, count);
} catch (error) {
commit(SET_PENDING_COUNT, 0);
}
},
}),
});
@@ -49,6 +49,7 @@ export const createMutations = mutationTypes => ({
},
[mutationTypes.SET_META](state, meta) {
state.meta = {
...state.meta,
totalCount: Number(meta.total_count),
page: Number(meta.page),
};
@@ -69,7 +70,7 @@ export const createCrudActions = (API, mutationTypes) => ({
});
export const createStore = options => {
const { name, API, actions, getters } = options;
const { name, API, actions, getters, mutations } = options;
const mutationTypes = generateMutationTypes(name);
const customActions = actions ? actions(mutationTypes) : {};
@@ -81,7 +82,10 @@ export const createStore = options => {
...createGetters(),
...(getters || {}),
},
mutations: createMutations(mutationTypes),
mutations: {
...createMutations(mutationTypes),
...(mutations || {}),
},
actions: {
...createCrudActions(API, mutationTypes),
...customActions,
@@ -78,6 +78,11 @@ export const getters = {
return false;
}
// Filter out authentication templates
if (template.category === 'AUTHENTICATION') {
return false;
}
// Filter out interactive templates (LIST, PRODUCT, CATALOG), location templates, and call permission templates
const hasUnsupportedComponents = template.components.some(
component =>
@@ -265,6 +265,39 @@ describe('#getters', () => {
expect(result[0].name).toBe('regular_template');
});
it('filters out authentication templates', () => {
const authenticationTemplates = [
{
name: 'auth_template',
status: 'approved',
category: 'AUTHENTICATION',
components: [
{ type: 'BODY', text: 'Your verification code is {{1}}' },
],
},
{
name: 'regular_template',
status: 'approved',
category: 'MARKETING',
components: [{ type: 'BODY', text: 'Regular message' }],
},
];
const state = {
records: [
{
id: 1,
channel_type: 'Channel::Whatsapp',
message_templates: authenticationTemplates,
},
],
};
const result = getters.getFilteredWhatsAppTemplates(state)(1);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('regular_template');
});
it('returns valid templates from fixture data', () => {
const state = {
records: [
+14
View File
@@ -0,0 +1,14 @@
class Contacts::BulkActionJob < ApplicationJob
queue_as :medium
def perform(account_id, user_id, params)
account = Account.find(account_id)
user = User.find(user_id)
Contacts::BulkActionService.new(
account: account,
user: user,
params: params
).perform
end
end
+16 -2
View File
@@ -5,6 +5,7 @@ class ApplicationMailbox < ActionMailbox::Base
# Eg: email should be something like : reply+6bdc3f4d-0bec-4515-a284-5d916fdde489@domain.com
REPLY_EMAIL_UUID_PATTERN = /^reply\+([0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12})$/i
CONVERSATION_MESSAGE_ID_PATTERN = %r{conversation/([a-zA-Z0-9-]*?)/messages/(\d+?)@(\w+\.\w+)}
CONVERSATION_FALLBACK_ID_PATTERN = %r{account/(\d+)/conversation/([a-zA-Z0-9-]+)@}
# routes as a reply to existing conversations
routing(
@@ -24,14 +25,27 @@ class ApplicationMailbox < ActionMailbox::Base
# <account/#{@account.id}/conversation/#{@conversation.uuid}@#{@account.inbound_email_domain}>
def in_reply_to_mail?(inbound_mail)
in_reply_to = inbound_mail.mail.in_reply_to
references = inbound_mail.mail.references
in_reply_to.present? && (
# Check in_reply_to first
return true if in_reply_to.present? && (
in_reply_to_matches?(in_reply_to) || Message.exists?(source_id: in_reply_to)
)
# Fallback to checking references header
references.present? && references_match?(references)
end
def in_reply_to_matches?(in_reply_to)
Array.wrap(in_reply_to).any? { it.match?(CONVERSATION_MESSAGE_ID_PATTERN) }
Array.wrap(in_reply_to).any? { |id| id.match?(CONVERSATION_MESSAGE_ID_PATTERN) || id.match?(CONVERSATION_FALLBACK_ID_PATTERN) }
end
def references_match?(references)
Array.wrap(references).any? do |reference|
reference.match?(CONVERSATION_MESSAGE_ID_PATTERN) ||
reference.match?(CONVERSATION_FALLBACK_ID_PATTERN) ||
Message.exists?(source_id: reference)
end
end
# checks if follow this pattern send it to reply_mailbox
+53 -2
View File
@@ -21,8 +21,10 @@ class ReplyMailbox < ApplicationMailbox
def find_relative_conversation
if @conversation_uuid
find_conversation_with_uuid
elsif mail.in_reply_to.present?
find_conversation_with_in_reply_to
elsif mail.in_reply_to.present? || mail.references.present?
find_conversation_with_in_reply_to if mail.in_reply_to.present?
# If still no conversation found and references exist, try references as fallback
find_conversation_with_references if @conversation.blank? && mail.references.present?
end
end
@@ -79,6 +81,55 @@ class ReplyMailbox < ApplicationMailbox
find_conversation_by_message_id(in_reply_to_addresses) if @conversation.blank?
end
# Find conversation from References header as fallback
# Extract conversation UUID from any reference that matches our patterns
def find_conversation_with_references
references_addresses = mail.references
references_addresses = [references_addresses] if references_addresses.is_a?(String)
references_addresses.each do |reference|
conversation = find_conversation_from_reference(reference)
next unless conversation && conversation_belongs_to_channel?(conversation)
@conversation = conversation
@conversation_uuid = conversation.uuid
break
end
end
def find_conversation_from_reference(reference)
# Try message-specific pattern: conversation/{uuid}/messages/{id}@domain
message_match = reference.match(::ApplicationMailbox::CONVERSATION_MESSAGE_ID_PATTERN)
if message_match
uuid = message_match.captures[0]
conversation = Conversation.find_by(uuid: uuid)
return conversation if conversation.present?
end
# Try conversation fallback pattern: account/{id}/conversation/{uuid}@domain
fallback_match = reference.match(::ApplicationMailbox::CONVERSATION_FALLBACK_ID_PATTERN)
if fallback_match
uuid = fallback_match.captures[1]
conversation = Conversation.find_by(uuid: uuid)
return conversation if conversation.present?
end
# Try finding by message source_id
message = Message.find_by(source_id: reference)
message&.conversation
end
def conversation_belongs_to_channel?(conversation)
return true unless conversation
# Get the channel from the email's To/CC addresses
channel = EmailChannelFinder.new(mail).perform
return false unless channel
# Check if the conversation's inbox matches the channel
conversation.inbox.channel_id == channel.id
end
def validate_resource(resource)
Rails.logger.error "[App::Mailboxes::ReplyMailbox] Email conversation with uuid: #{conversation_uuid} not found" if resource.nil?
+3 -32
View File
@@ -1,6 +1,5 @@
class Integrations::App
include Linear::IntegrationHelper
include Github::IntegrationHelper
attr_accessor :params
def initialize(params)
@@ -31,15 +30,10 @@ class Integrations::App
params[:fields]
end
# There is no way to get the account_id from the linear/github callback
# so we are using the generate token method to generate a token and encode it in the state parameter
# There is no way to get the account_id from the linear callback
# so we are using the generate_linear_token method to generate a token and encode it in the state parameter
def encode_state
case params[:id]
when 'linear'
generate_linear_token(Current.account.id)
when 'github'
generate_github_token(Current.account.id)
end
generate_linear_token(Current.account.id)
end
def action
@@ -49,8 +43,6 @@ class Integrations::App
"#{params[:action]}&client_id=#{client_id}&redirect_uri=#{self.class.slack_integration_url}"
when 'linear'
build_linear_action
when 'github'
build_github_action
else
params[:action]
end
@@ -62,8 +54,6 @@ class Integrations::App
GlobalConfigService.load('SLACK_CLIENT_SECRET', nil).present?
when 'linear'
GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
when 'github'
github_enabled?
when 'shopify'
shopify_enabled?(account)
when 'leadsquared'
@@ -88,17 +78,6 @@ class Integrations::App
].join('&')
end
def build_github_action
GlobalConfigService.load('GITHUB_CLIENT_ID', nil)
# For GitHub Apps, redirect to installation page
# The standard /installations/new URL should show org/user selection if the app is properly configured
# Include state parameter with signed account ID for account context
github_app_name = GlobalConfigService.load('GITHUB_APP_NAME', 'chatwoot-qa')
state = Current.account.to_signed_global_id(expires_in: 1.hour)
"https://github.com/apps/#{github_app_name}/installations/new?state=#{state}"
end
def enabled?(account)
case params[:id]
when 'webhook'
@@ -122,10 +101,6 @@ class Integrations::App
"#{ENV.fetch('FRONTEND_URL', nil)}/linear/callback"
end
def self.github_integration_url
"#{ENV.fetch('FRONTEND_URL', nil)}/github/callback"
end
class << self
def apps
Hashie::Mash.new(APPS_CONFIG)
@@ -144,10 +119,6 @@ class Integrations::App
private
def github_enabled?
GlobalConfigService.load('GITHUB_CLIENT_ID', nil).present? && GlobalConfigService.load('GITHUB_CLIENT_SECRET', nil).present?
end
def shopify_enabled?(account)
account.feature_enabled?('shopify_integration') && GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil).present?
end
@@ -0,0 +1,32 @@
class Contacts::BulkActionService
def initialize(account:, user:, params:)
@account = account
@user = user
@params = params.deep_symbolize_keys
end
def perform
return assign_labels if labels_to_add.any?
Rails.logger.warn("Unknown contact bulk operation payload: #{@params.keys}")
{ success: false, error: 'unknown_operation' }
end
private
def assign_labels
Contacts::BulkAssignLabelsService.new(
account: @account,
contact_ids: ids,
labels: labels_to_add
).perform
end
def ids
Array(@params[:ids]).compact
end
def labels_to_add
@labels_to_add ||= Array(@params.dig(:labels, :add)).reject(&:blank?)
end
end
@@ -0,0 +1,19 @@
class Contacts::BulkAssignLabelsService
def initialize(account:, contact_ids:, labels:)
@account = account
@contact_ids = Array(contact_ids)
@labels = Array(labels).compact_blank
end
def perform
return { success: true, updated_contact_ids: [] } if @contact_ids.blank? || @labels.blank?
contacts = @account.contacts.where(id: @contact_ids)
contacts.find_each do |contact|
contact.add_labels(@labels)
end
{ success: true, updated_contact_ids: contacts.pluck(:id) }
end
end
-60
View File
@@ -1,60 +0,0 @@
class Github::GithubService
attr_reader :hook
def initialize(hook:)
@hook = hook
end
def repositories
client.repositories(nil, type: 'all')
rescue Octokit::Error => e
Rails.logger.error("GitHub API error fetching repositories: #{e.message}")
raise_api_error(e)
end
def assignees(repo_full_name)
client.repository_assignees(repo_full_name)
rescue Octokit::Error => e
Rails.logger.error("GitHub API error fetching assignees for #{repo_full_name}: #{e.message}")
raise_api_error(e)
end
def search_repositories(query)
return repositories if query.blank?
# Get all user's repositories first
user_repos = repositories
# Filter repositories by query string (case-insensitive)
user_repos.select do |repo|
repo.full_name.downcase.include?(query.downcase) ||
(repo.description&.downcase&.include?(query.downcase))
end
rescue Octokit::Error => e
Rails.logger.error("GitHub API error searching repositories with query '#{query}': #{e.message}")
raise_api_error(e)
end
private
def client
@client ||= Octokit::Client.new(
access_token: hook.access_token,
auto_paginate: true,
per_page: 100
)
end
def raise_api_error(error)
case error.response_status
when 401
raise CustomExceptions::Base.new('GitHub authentication failed', 401)
when 403
raise CustomExceptions::Base.new('GitHub API rate limit exceeded or insufficient permissions', 403)
when 404
raise CustomExceptions::Base.new('GitHub resource not found', 404)
else
raise CustomExceptions::Base.new("GitHub API error: #{error.message}", error.response_status || 500)
end
end
end
@@ -169,11 +169,7 @@
<symbol id="icon-shopify" viewBox="0 0 32 32">
<path fill="currentColor" d="m20.448 31.974l9.625-2.083s-3.474-23.484-3.5-23.641s-.156-.255-.281-.255c-.13 0-2.573-.182-2.573-.182s-1.703-1.698-1.922-1.88a.4.4 0 0 0-.161-.099l-1.219 28.141zm-4.833-16.901s-1.083-.563-2.365-.563c-1.932 0-2.005 1.203-2.005 1.521c0 1.641 4.318 2.286 4.318 6.172c0 3.057-1.922 5.01-4.542 5.01c-3.141 0-4.719-1.953-4.719-1.953l.859-2.781s1.661 1.422 3.042 1.422c.901 0 1.302-.724 1.302-1.245c0-2.156-3.542-2.255-3.542-5.807c-.047-2.984 2.094-5.891 6.438-5.891c1.677 0 2.5.479 2.5.479l-1.26 3.625zm-.719-13.969c.177 0 .359.052.536.182c-1.313.62-2.75 2.188-3.344 5.323a76 76 0 0 1-2.516.771c.688-2.38 2.359-6.26 5.323-6.26zm1.646 3.932v.182c-1.005.307-2.115.646-3.193.979c.62-2.37 1.776-3.526 2.781-3.958c.255.667.411 1.568.411 2.797zm.718-2.973c.922.094 1.521 1.151 1.901 2.339c-.464.151-.979.307-1.542.484v-.333c0-1.005-.13-1.828-.359-2.495zm3.99 1.718c-.031 0-.083.026-.104.026c-.026 0-.385.099-.953.281C19.63 2.442 18.625.927 16.849.927h-.156C16.183.281 15.558 0 15.021 0c-4.141 0-6.12 5.172-6.74 7.797c-1.594.484-2.75.844-2.88.896c-.901.286-.927.313-1.031 1.161c-.099.615-2.438 18.75-2.438 18.75L20.01 32z"/>
</symbol>
<symbol id="icon-github" viewBox="0 0 24 24">
<path fill="currentColor" d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</symbol>
<symbol id="icon-slack" viewBox="0 0 24 24">
<path fill="currentColor" d="M6.527 14.514A1.973 1.973 0 0 1 4.56 16.48a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h1.967zm.992 0c0-1.083.885-1.968 1.968-1.968s1.967.885 1.967 1.968v4.927a1.973 1.973 0 0 1-1.967 1.968a1.973 1.973 0 0 1-1.968-1.968zm1.968-7.987A1.973 1.973 0 0 1 7.519 4.56c0-1.083.885-1.967 1.968-1.967s1.967.884 1.967 1.967v1.968zm0 .992c1.083 0 1.967.884 1.967 1.967a1.973 1.973 0 0 1-1.967 1.968H4.56a1.973 1.973 0 0 1-1.968-1.968c0-1.083.885-1.967 1.968-1.967zm7.986 1.967c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967a1.973 1.973 0 0 1-1.968 1.968h-1.968zm-.991 0a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968V4.56c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967zm-1.968 7.987c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968v-1.968zm0-.992a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h4.927c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.967z"/>
</symbol>

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 43 KiB

-3
View File
@@ -176,9 +176,6 @@
- name: notion_integration
display_name: Notion Integration
enabled: false
- name: github_integration
display_name: Github Integration
enabled: false
- name: captain_integration_v2
display_name: Captain V2
enabled: false
-19
View File
@@ -451,22 +451,3 @@
locked: false
description: 'Token expiry in days'
## ------ End of Customizations for Customers ------ ##
# ------- Github Integration Config ------- #
- name: GITHUB_CLIENT_ID
display_title: 'Github Client ID'
value:
locked: false
description: 'Github client ID'
- name: GITHUB_CLIENT_SECRET
display_title: 'Github Client Secret'
value:
locked: false
description: 'Github client secret'
type: secret
- name: GITHUB_APP_NAME
display_title: 'Github App Name'
value:
locked: false
description: 'Github App name for installation URLs (e.g., "chatwoot-qa")'
## ------ End of Github Integration Config ------- #
-8
View File
@@ -284,11 +284,3 @@ leadsquared:
'conversation_activity_score',
'transcript_activity_score',
]
github:
id: github
logo: github.png
i18n_key: github
hook_type: account
action: https://github.com/login/oauth/authorize
allow_multiple_hooks: false
-4
View File
@@ -312,10 +312,6 @@ en:
name: 'LeadSquared'
short_description: 'Sync your contacts and conversations with LeadSquared CRM.'
description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.'
github:
name: 'GitHub'
short_description: 'Create and manage GitHub issues directly from conversations.'
description: 'Connect your GitHub repositories to create issues directly from customer conversations. This integration helps you track bugs, feature requests, and development tasks seamlessly.'
captain:
copilot_message_required: Message is required
copilot_error: 'Please connect an assistant to this inbox to use Copilot'
-12
View File
@@ -302,14 +302,6 @@ Rails.application.routes.draw do
delete :destroy
end
end
resource :github, controller: 'github', only: [] do
collection do
delete :destroy
get :repositories
get :search_repositories
get 'repositories/:owner/:repo/assignees', to: 'github#assignees', as: :repository_assignees
end
end
end
resources :working_hours, only: [:update]
@@ -557,10 +549,6 @@ Rails.application.routes.draw do
end
end
namespace :github do
get :callback, to: 'callbacks#show'
end
get 'microsoft/callback', to: 'microsoft/callbacks#show'
get 'google/callback', to: 'google/callbacks#show'
get 'instagram/callback', to: 'instagram/callbacks#show'
@@ -10,8 +10,12 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
Current.executed_by = @assistant
ActiveRecord::Base.transaction do
generate_and_process_response
if captain_v2_enabled?
generate_response_with_v2
else
ActiveRecord::Base.transaction do
generate_and_process_response
end
end
rescue StandardError => e
raise e if e.is_a?(ActiveStorage::FileNotFoundError) || e.is_a?(Faraday::BadRequestError)
@@ -26,16 +30,20 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
delegate :account, :inbox, to: :@conversation
def generate_and_process_response
@response = if captain_v2_enabled?
Captain::Assistant::AgentRunnerService.new(assistant: @assistant, conversation: @conversation).generate_response(
message_history: collect_previous_messages
)
else
Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
message_history: collect_previous_messages
)
end
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
message_history: collect_previous_messages
)
process_response
end
def generate_response_with_v2
@response = Captain::Assistant::AgentRunnerService.new(assistant: @assistant, conversation: @conversation).generate_response(
message_history: collect_previous_messages
)
process_response
end
def process_response
return process_action('handoff') if handoff_requested?
create_messages
@@ -123,6 +131,6 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
end
def captain_v2_enabled?
return account.feature_enabled?('captain_integration_v2')
account.feature_enabled?('captain_integration_v2')
end
end
@@ -5,11 +5,13 @@ class Captain::Tools::FirecrawlParserJob < ApplicationJob
assistant = Captain::Assistant.find(assistant_id)
metadata = payload[:metadata]
canonical_url = normalize_link(metadata['url'])
document = assistant.documents.find_or_initialize_by(
external_link: metadata['url']
external_link: canonical_url
)
document.update!(
external_link: canonical_url,
content: payload[:markdown],
name: metadata['title'],
status: :available
@@ -17,4 +19,10 @@ class Captain::Tools::FirecrawlParserJob < ApplicationJob
rescue StandardError => e
raise "Failed to parse FireCrawl data: #{e.message}"
end
private
def normalize_link(raw_url)
raw_url.to_s.delete_suffix('/')
end
end
@@ -15,11 +15,11 @@ class Captain::Tools::SimplePageCrawlParserJob < ApplicationJob
page_title = crawler.page_title || ''
content = crawler.body_text_content || ''
document = assistant.documents.find_or_initialize_by(
external_link: page_link
)
normalized_link = normalize_link(page_link)
document = assistant.documents.find_or_initialize_by(external_link: normalized_link)
document.update!(
external_link: normalized_link,
name: page_title[0..254], content: content[0..14_999], status: :available
)
rescue StandardError => e
@@ -28,6 +28,10 @@ class Captain::Tools::SimplePageCrawlParserJob < ApplicationJob
private
def normalize_link(raw_link)
raw_link.to_s.delete_suffix('/')
end
def limit_exceeded?(account)
limits = account.usage_limits[:captain][:documents]
limits[:current_available].negative? || limits[:current_available].zero?
+16 -5
View File
@@ -37,6 +37,7 @@ class Captain::Document < ApplicationRecord
validate :validate_file_attachment, if: -> { pdf_file.attached? }
before_validation :ensure_account_id
before_validation :set_external_link_for_pdf
before_validation :normalize_external_link
enum status: {
in_progress: 0,
@@ -47,7 +48,7 @@ class Captain::Document < ApplicationRecord
after_create_commit :enqueue_crawl_job
after_create_commit :update_document_usage
after_destroy :update_document_usage
after_commit :enqueue_response_builder_job, on: :update, if: :should_enqueue_response_builder?
after_commit :enqueue_response_builder_job
scope :ordered, -> { order(created_at: :desc) }
scope :for_account, ->(account_id) { where(account_id: account_id) }
@@ -94,15 +95,18 @@ class Captain::Document < ApplicationRecord
end
def enqueue_response_builder_job
return if status != 'available'
return unless should_enqueue_response_builder?
Captain::Documents::ResponseBuilderJob.perform_later(self)
end
def should_enqueue_response_builder?
# Only enqueue when status changes to available
# Avoid re-enqueueing when metadata is updated by the job itself
saved_change_to_status? && status == 'available'
return false if destroyed?
return false unless available?
return saved_change_to_status? if pdf_document?
(saved_change_to_status? || saved_change_to_content?) && content.present?
end
def update_document_usage
@@ -140,4 +144,11 @@ class Captain::Document < ApplicationRecord
timestamp = Time.current.strftime('%Y%m%d%H%M%S')
self.external_link = "PDF: #{pdf_file.filename.base}_#{timestamp}"
end
def normalize_external_link
return if external_link.blank?
return if pdf_document?
self.external_link = external_link.delete_suffix('/')
end
end
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

@@ -199,19 +199,6 @@ describe Messages::MessageBuilder do
expect(message.content_attributes.dig('email', 'text_content', 'reply')).to eq 'Regular message content'
end
it 'does not process custom email content when quoted_email_reply feature is disabled' do
account.disable_features('quoted_email_reply')
params = ActionController::Parameters.new({
content: 'Regular message content',
email_html_content: '<p>Custom HTML content</p>'
})
message = described_class.new(user, conversation, params).perform
expect(message.content_attributes.dig('email', 'html_content')).to be_nil
expect(message.content_attributes.dig('email', 'text_content')).to be_nil
end
it 'does not process custom email content for private messages' do
params = ActionController::Parameters.new({
content: 'Regular message content',
@@ -195,6 +195,37 @@ RSpec.describe 'Api::V1::Accounts::BulkActionsController', type: :request do
expect(Conversation.first.label_list).to contain_exactly('support', 'priority_customer')
expect(Conversation.second.label_list).to contain_exactly('support', 'priority_customer')
end
it 'enqueues contact bulk action job with permitted params' do
contact_one = create(:contact, account: account)
contact_two = create(:contact, account: account)
previous_adapter = ActiveJob::Base.queue_adapter
ActiveJob::Base.queue_adapter = :test
expect do
post "/api/v1/accounts/#{account.id}/bulk_actions",
headers: agent.create_new_auth_token,
params: {
type: 'Contact',
ids: [contact_one.id, contact_two.id],
labels: { add: %w[vip support] },
extra: 'ignored'
}
end.to have_enqueued_job(Contacts::BulkActionJob).with(
account.id,
agent.id,
hash_including(
'ids' => [contact_one.id, contact_two.id],
'labels' => hash_including('add' => %w[vip support])
)
)
expect(response).to have_http_status(:success)
ensure
ActiveJob::Base.queue_adapter = previous_adapter
clear_enqueued_jobs
end
end
end
@@ -1,165 +0,0 @@
require 'rails_helper'
RSpec.describe Github::CallbacksController, type: :controller do
let(:account) { create(:account) }
let(:signed_account_id) { account.to_signed_global_id(expires_in: 1.hour) }
before do
# Clear any existing GitHub hooks for the account
account.hooks.where(app_id: 'github').destroy_all
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return('test_client_id')
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_SECRET', nil).and_return('test_client_secret')
end
describe 'route accessibility' do
it 'is accessible via the github callback route' do
expect(get: '/github/callback').to route_to(
controller: 'github/callbacks',
action: 'show'
)
end
end
describe 'helper methods' do
describe '#account' do
it 'resolves account from valid signed global id' do
controller.params = { state: signed_account_id }
expect(controller.send(:account)).to eq(account)
end
it 'raises error for missing state' do
controller.params = {}
expect { controller.send(:account) }.to raise_error(ActionController::BadRequest, 'Invalid account context')
end
it 'raises error for invalid state' do
controller.params = { state: 'invalid_state' }
expect { controller.send(:account) }.to raise_error(ActionController::BadRequest, 'Invalid account context')
end
end
describe '#oauth_client' do
it 'creates OAuth2 client with correct configuration' do
oauth_client = controller.send(:oauth_client)
expect(oauth_client).to be_a(OAuth2::Client)
expect(oauth_client.id).to eq('test_client_id')
expect(oauth_client.secret).to eq('test_client_secret')
expect(oauth_client.site).to eq('https://github.com')
expect(oauth_client.options[:token_url]).to eq('/login/oauth/access_token')
expect(oauth_client.options[:authorize_url]).to eq('/login/oauth/authorize')
end
end
describe '#github_redirect_uri' do
before do
allow(controller).to receive(:account).and_return(account)
end
it 'generates correct GitHub redirect URI' do
with_modified_env FRONTEND_URL: 'http://localhost:3000' do
uri = controller.send(:github_redirect_uri)
expect(uri).to eq("http://localhost:3000/app/accounts/#{account.id}/settings/integrations/github")
end
end
end
describe '#fallback_redirect_uri' do
it 'generates fallback redirect URI when account is unavailable' do
allow(controller).to receive(:account).and_raise(StandardError)
with_modified_env FRONTEND_URL: 'http://localhost:3000' do
uri = controller.send(:fallback_redirect_uri)
expect(uri).to eq('http://localhost:3000/app/settings/integrations')
end
end
end
describe '#build_hook_settings' do
let(:mock_parsed_body) do
{
'access_token' => 'test_token',
'token_type' => 'bearer',
'scope' => 'repo,read:org'
}
end
before do
allow(controller).to receive(:parsed_body).and_return(mock_parsed_body)
end
it 'builds hook settings with installation_id' do
settings = controller.send(:build_hook_settings, '12345')
expect(settings).to include(
token_type: 'bearer',
scope: 'repo,read:org',
installation_id: '12345'
)
end
it 'builds hook settings without installation_id when nil' do
settings = controller.send(:build_hook_settings, nil)
expect(settings).to include(
token_type: 'bearer',
scope: 'repo,read:org'
)
expect(settings).not_to have_key(:installation_id)
end
it 'removes nil installation_id values' do
settings = controller.send(:build_hook_settings, nil)
expect(settings.keys).not_to include(:installation_id)
end
end
describe '#create_integration_hook' do
let(:settings) do
{
token_type: 'bearer',
scope: 'repo,read:org',
installation_id: '12345'
}
end
before do
allow(controller).to receive(:account).and_return(account)
allow(controller).to receive(:parsed_body).and_return({
'access_token' => 'test_token'
})
end
it 'creates integration hook with correct attributes' do
hook = controller.send(:create_integration_hook, settings)
expect(hook.access_token).to eq('test_token')
expect(hook.status).to eq('enabled')
expect(hook.app_id).to eq('github')
expect(hook.settings).to eq(settings.stringify_keys)
expect(hook.account).to eq(account)
end
end
describe '#build_oauth_url' do
it 'builds OAuth URL with installation context' do
with_modified_env FRONTEND_URL: 'http://localhost:3000' do
url = controller.send(:build_oauth_url, '12345')
expect(url).to include('github?setup_action=install&installation_id=12345')
expect(controller.session[:github_installation_id]).to eq('12345')
end
end
end
describe '#base_url' do
it 'returns FRONTEND_URL when set' do
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
expect(controller.send(:base_url)).to eq('https://app.chatwoot.com')
end
end
it 'returns default localhost when FRONTEND_URL not set' do
with_modified_env FRONTEND_URL: nil do
expect(controller.send(:base_url)).to eq('http://localhost:3000')
end
end
end
end
end
@@ -23,7 +23,7 @@ RSpec.describe Captain::Tools::FirecrawlParserJob, type: :job do
expect(document).to have_attributes(
content: payload[:markdown],
name: payload[:metadata]['title'],
external_link: payload[:metadata]['url'],
external_link: 'https://www.firecrawl.dev',
status: 'available'
)
end
@@ -32,7 +32,7 @@ RSpec.describe Captain::Tools::FirecrawlParserJob, type: :job do
existing_document = create(:captain_document,
assistant: assistant,
account: assistant.account,
external_link: payload[:metadata]['url'],
external_link: 'https://www.firecrawl.dev',
content: 'old content',
name: 'old title',
status: :in_progress)
@@ -42,7 +42,9 @@ RSpec.describe Captain::Tools::FirecrawlParserJob, type: :job do
end.not_to change(assistant.documents, :count)
existing_document.reload
# Payload URL ends with '/', but we persist the canonical URL without it.
expect(existing_document).to have_attributes(
external_link: 'https://www.firecrawl.dev',
content: payload[:markdown],
name: payload[:metadata]['title'],
status: 'available'
@@ -3,7 +3,7 @@ require 'rails_helper'
RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
describe '#perform' do
let(:assistant) { create(:captain_assistant) }
let(:page_link) { 'https://example.com/page' }
let(:page_link) { 'https://example.com/page/' }
let(:page_title) { 'Example Page Title' }
let(:content) { 'Some page content here' }
let(:crawler) { instance_double(Captain::Tools::SimplePageCrawlService) }
@@ -24,7 +24,7 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
end.to change(assistant.documents, :count).by(1)
document = assistant.documents.last
expect(document.external_link).to eq(page_link)
expect(document.external_link).to eq('https://example.com/page')
expect(document.name).to eq(page_title)
expect(document.content).to eq(content)
expect(document.status).to eq('available')
@@ -33,7 +33,7 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
it 'updates existing document if one exists' do
existing_document = create(:captain_document,
assistant: assistant,
external_link: page_link,
external_link: 'https://example.com/page',
name: 'Old Title',
content: 'Old content')
@@ -4,6 +4,17 @@ RSpec.describe Captain::Document, type: :model do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
describe 'URL normalization' do
it 'removes a trailing slash before validation' do
document = create(:captain_document,
assistant: assistant,
account: account,
external_link: 'https://example.com/path/')
expect(document.external_link).to eq('https://example.com/path')
end
end
describe 'PDF support' do
let(:pdf_document) do
doc = build(:captain_document, assistant: assistant, account: account)
@@ -82,4 +93,161 @@ RSpec.describe Captain::Document, type: :model do
end
end
end
describe 'response builder job callback' do
before { clear_enqueued_jobs }
describe 'non-PDF documents' do
it 'enqueues when created with available status and content' do
expect do
create(:captain_document, assistant: assistant, account: account, status: :available)
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
end
it 'does not enqueue when created available without content' do
expect do
create(:captain_document, assistant: assistant, account: account, status: :available, content: nil)
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
end
it 'enqueues when status transitions to available with existing content' do
document = create(:captain_document, assistant: assistant, account: account, status: :in_progress)
expect do
document.update!(status: :available)
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
end
it 'does not enqueue when status transitions to available without content' do
document = create(
:captain_document,
assistant: assistant,
account: account,
status: :in_progress,
content: nil
)
expect do
document.update!(status: :available)
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
end
it 'enqueues when content is populated on an available document' do
document = create(
:captain_document,
assistant: assistant,
account: account,
status: :available,
content: nil
)
clear_enqueued_jobs
expect do
document.update!(content: 'Fresh content from crawl')
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
end
it 'enqueues when content changes on an available document' do
document = create(
:captain_document,
assistant: assistant,
account: account,
status: :available,
content: 'Initial content'
)
clear_enqueued_jobs
expect do
document.update!(content: 'Updated crawl content')
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
end
it 'does not enqueue when content is cleared on an available document' do
document = create(
:captain_document,
assistant: assistant,
account: account,
status: :available,
content: 'Initial content'
)
clear_enqueued_jobs
expect do
document.update!(content: nil)
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
end
it 'does not enqueue for metadata-only updates' do
document = create(:captain_document, assistant: assistant, account: account, status: :available)
clear_enqueued_jobs
expect do
document.update!(metadata: { 'title' => 'Updated Again' })
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
end
it 'does not enqueue while document remains in progress' do
document = create(:captain_document, assistant: assistant, account: account, status: :in_progress)
expect do
document.update!(metadata: { 'title' => 'Updated' })
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
end
end
describe 'PDF documents' do
def build_pdf_document(status:, content:)
build(
:captain_document,
assistant: assistant,
account: account,
status: status,
content: content
).tap do |doc|
doc.pdf_file.attach(
io: StringIO.new('PDF content'),
filename: 'sample.pdf',
content_type: 'application/pdf'
)
end
end
it 'enqueues when created available without content' do
document = build_pdf_document(status: :available, content: nil)
expect do
document.save!
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
end
it 'enqueues when status transitions to available' do
document = build_pdf_document(status: :in_progress, content: nil)
document.save!
clear_enqueued_jobs
expect do
document.update!(status: :available)
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
end
it 'does not enqueue when content updates without status change' do
document = build_pdf_document(status: :available, content: nil)
document.save!
clear_enqueued_jobs
expect do
document.update!(content: 'Extracted PDF text')
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
end
end
it 'does not enqueue when the document is destroyed' do
document = create(:captain_document, assistant: assistant, account: account, status: :available)
clear_enqueued_jobs
expect do
document.destroy!
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
end
end
end
@@ -0,0 +1,22 @@
require 'rails_helper'
RSpec.describe Contacts::BulkActionJob, type: :job do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:params) { { 'ids' => [1], 'labels' => { 'add' => ['vip'] } } }
it 'invokes the bulk action service with account and user' do
service_instance = instance_double(Contacts::BulkActionService, perform: true)
allow(Contacts::BulkActionService).to receive(:new).and_return(service_instance)
described_class.perform_now(account.id, user.id, params)
expect(Contacts::BulkActionService).to have_received(:new).with(
account: account,
user: user,
params: params
)
expect(service_instance).to have_received(:perform)
end
end
+138
View File
@@ -248,5 +248,143 @@ RSpec.describe ReplyMailbox do
)
end
end
context 'with references header' do
let(:reply_mail_with_references) { create_inbound_email_from_fixture('reply_mail_without_uuid.eml') }
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
let(:conversation_1) do
create(
:conversation,
assignee: agent,
inbox: email_channel.inbox,
account: account,
additional_attributes: { mail_subject: "Discussion: Let's debate these attachments" }
)
end
before do
conversation_1.update!(uuid: '6bdc3f4d-0bec-4515-a284-5d916fdde489')
end
context 'with message-specific pattern in references' do
before do
reply_mail_with_references.mail['References'] = '<conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123@test.com>'
end
it 'finds conversation from references header with message pattern' do
described_class.receive reply_mail_with_references
expect(conversation_1.messages.last.content).to include("Let's talk about these images:")
end
end
context 'with conversation fallback pattern in references' do
before do
reply_mail_with_references.mail['References'] = "<account/#{account.id}/conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489@test.com>"
end
it 'finds conversation from references header with fallback pattern' do
described_class.receive reply_mail_with_references
expect(conversation_1.messages.last.content).to include("Let's talk about these images:")
end
end
context 'with multiple references including conversation pattern' do
before do
reply_mail_with_references.mail['References'] = [
'<some-random-message-id@gmail.com>',
"<account/#{account.id}/conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489@test.com>",
'<another-random-message-id@outlook.com>'
].join("\r\n ")
end
it 'finds conversation from any reference in the chain' do
described_class.receive reply_mail_with_references
expect(conversation_1.messages.last.content).to include("Let's talk about these images:")
end
end
context 'with message source_id in references' do
before do
conversation_1.messages.create!(
source_id: 'original-message-id@test.com',
account_id: account.id,
message_type: 'outgoing',
inbox_id: email_channel.inbox.id,
content: 'Original message'
)
reply_mail_with_references.mail['References'] = '<original-message-id@test.com>'
end
it 'finds conversation from message source_id in references' do
described_class.receive reply_mail_with_references
expect(conversation_1.messages.last.content).to include("Let's talk about these images:")
end
end
context 'with conversation from different channel in references' do
let(:other_email_channel) { create(:channel_email, email: 'other@example.com', account: account) }
let(:other_conversation) do
create(
:conversation,
assignee: agent,
inbox: other_email_channel.inbox,
account: account
)
end
before do
other_conversation.update!(uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
reply_mail_with_references.mail['References'] = '<conversation/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/messages/456@test.com>'
end
it 'does not use conversation from different channel' do
described_class.receive reply_mail_with_references
expect(other_conversation.messages.count).to eq(0)
end
end
context 'when in_reply_to fails but references has valid conversation' do
before do
reply_mail_with_references.mail['In-Reply-To'] = '<non-existent-message-id@test.com>'
reply_mail_with_references.mail['References'] = "<account/#{account.id}/conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489@test.com>"
end
it 'falls back to references header when in_reply_to lookup fails' do
described_class.receive reply_mail_with_references
expect(conversation_1.messages.last.content).to include("Let's talk about these images:")
end
end
context 'when in_reply_to fails and references has message pattern' do
before do
reply_mail_with_references.mail['In-Reply-To'] = '<random-invalid-id@gmail.com>'
reply_mail_with_references.mail['References'] = '<conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/789@test.com>'
end
it 'falls back to references with message pattern when in_reply_to lookup fails' do
described_class.receive reply_mail_with_references
expect(conversation_1.messages.last.content).to include("Let's talk about these images:")
end
end
context 'when in_reply_to fails and references has message source_id' do
before do
conversation_1.messages.create!(
source_id: 'valid-message-id@test.com',
account_id: account.id,
message_type: 'outgoing',
inbox_id: email_channel.inbox.id,
content: 'Previous message'
)
reply_mail_with_references.mail['In-Reply-To'] = '<wrong-message-id@test.com>'
reply_mail_with_references.mail['References'] = '<valid-message-id@test.com>'
end
it 'falls back to references with source_id when in_reply_to lookup fails' do
described_class.receive reply_mail_with_references
expect(conversation_1.messages.last.content).to include("Let's talk about these images:")
end
end
end
end
end
-47
View File
@@ -38,25 +38,6 @@ RSpec.describe Integrations::App do
end
end
end
context 'when the app is github' do
let(:app_name) { 'github' }
it 'returns the GitHub App installation URL with state parameter' do
with_modified_env GITHUB_CLIENT_ID: 'dummy_client_id', GITHUB_APP_NAME: 'test-app' do
expect(app.action).to include('https://github.com/apps/test-app/installations/new')
expect(app.action).to include('state=')
end
end
it 'uses default app name when GITHUB_APP_NAME is not set' do
with_modified_env GITHUB_CLIENT_ID: 'dummy_client_id' do
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return('dummy_client_id')
allow(GlobalConfigService).to receive(:load).with('GITHUB_APP_NAME', 'chatwoot-qa').and_return('chatwoot-qa')
expect(app.action).to include('https://github.com/apps/chatwoot-qa/installations/new')
end
end
end
end
describe '#active?' do
@@ -106,34 +87,6 @@ RSpec.describe Integrations::App do
end
end
context 'when the app is github' do
let(:app_name) { 'github' }
it 'returns false if github credentials are not present' do
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return(nil)
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_SECRET', nil).and_return(nil)
expect(app.active?(account)).to be false
end
it 'returns false if only client_id is present' do
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return('client_id')
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_SECRET', nil).and_return(nil)
expect(app.active?(account)).to be false
end
it 'returns false if only client_secret is present' do
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return(nil)
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_SECRET', nil).and_return('client_secret')
expect(app.active?(account)).to be false
end
it 'returns true if both client_id and client_secret are present' do
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return('client_id')
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_SECRET', nil).and_return('client_secret')
expect(app.active?(account)).to be true
end
end
context 'when other apps are queried' do
let(:app_name) { 'webhook' }
@@ -0,0 +1,48 @@
require 'rails_helper'
RSpec.describe Contacts::BulkAssignLabelsService do
subject(:service) do
described_class.new(
account: account,
contact_ids: [contact_one.id, contact_two.id, other_contact.id],
labels: labels
)
end
let(:account) { create(:account) }
let!(:contact_one) { create(:contact, account: account) }
let!(:contact_two) { create(:contact, account: account) }
let!(:other_contact) { create(:contact) }
let(:labels) { %w[vip support] }
it 'assigns labels to the contacts that belong to the account' do
service.perform
expect(contact_one.reload.label_list).to include(*labels)
expect(contact_two.reload.label_list).to include(*labels)
end
it 'does not assign labels to contacts outside the account' do
service.perform
expect(other_contact.reload.label_list).to be_empty
end
it 'returns ids of contacts that were updated' do
result = service.perform
expect(result[:success]).to be(true)
expect(result[:updated_contact_ids]).to contain_exactly(contact_one.id, contact_two.id)
end
it 'returns success with no updates when labels are blank' do
result = described_class.new(
account: account,
contact_ids: [contact_one.id],
labels: []
).perform
expect(result).to eq(success: true, updated_contact_ids: [])
expect(contact_one.reload.label_list).to be_empty
end
end