Compare commits
51
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5029b25d5c | ||
|
|
304777e728 | ||
|
|
5e811eab99 | ||
|
|
9749a3dc96 | ||
|
|
8dd0d08322 | ||
|
|
7e88d44fd9 | ||
|
|
2891a72cb9 | ||
|
|
522e3c4d3f | ||
|
|
0a25a0ef66 | ||
|
|
0fccb7dacd | ||
|
|
f1a07657e9 | ||
|
|
9cb6c501b5 | ||
|
|
ab01ab7853 | ||
|
|
6d74ff9477 | ||
|
|
354c2cab6b | ||
|
|
9c68eed676 | ||
|
|
6d38b4d39c | ||
|
|
fd625981e9 | ||
|
|
49c442751d | ||
|
|
13db36609d | ||
|
|
fef3190be5 | ||
|
|
3cd3d653a5 | ||
|
|
aae21dfbb8 | ||
|
|
2dec6ef362 | ||
|
|
cba79636cb | ||
|
|
bc79897419 | ||
|
|
71ba10d0fa | ||
|
|
b7ac1c28fb | ||
|
|
0e2d577d43 | ||
|
|
6bbb763d4a | ||
|
|
cfcbe707e3 | ||
|
|
94fe5318b4 | ||
|
|
8b4f3e226e | ||
|
|
2ac55c8728 | ||
|
|
9328f8739c | ||
|
|
3e03f8da1e | ||
|
|
280756b483 | ||
|
|
102f19fe41 | ||
|
|
1b6a80d84d | ||
|
|
9c444315a6 | ||
|
|
df7f137657 | ||
|
|
056b5eb89d | ||
|
|
08260f3be7 | ||
|
|
11c65f3b9a | ||
|
|
8fc5c7a5c8 | ||
|
|
7caa4e0bbc | ||
|
|
8def9220a9 | ||
|
|
59a88dae9f | ||
|
|
38fccdd04b | ||
|
|
9b54bc5096 | ||
|
|
39606f3944 |
@@ -77,7 +77,8 @@ jobs:
|
||||
|
||||
- node/install:
|
||||
node-version: '24.13'
|
||||
- node/install-pnpm
|
||||
- node/install-pnpm:
|
||||
version: '10.2.0'
|
||||
- node/install-packages:
|
||||
pkg-manager: pnpm
|
||||
override-ci-command: pnpm i
|
||||
@@ -118,7 +119,8 @@ jobs:
|
||||
- checkout
|
||||
- node/install:
|
||||
node-version: '24.13'
|
||||
- node/install-pnpm
|
||||
- node/install-pnpm:
|
||||
version: '10.2.0'
|
||||
- node/install-packages:
|
||||
pkg-manager: pnpm
|
||||
override-ci-command: pnpm i
|
||||
@@ -149,7 +151,8 @@ jobs:
|
||||
- checkout
|
||||
- node/install:
|
||||
node-version: '24.13'
|
||||
- node/install-pnpm
|
||||
- node/install-pnpm:
|
||||
version: '10.2.0'
|
||||
- node/install-packages:
|
||||
pkg-manager: pnpm
|
||||
override-ci-command: pnpm i
|
||||
|
||||
@@ -30,8 +30,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def update
|
||||
@article.update!(article_params) if params[:article].present?
|
||||
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
|
||||
persist_article_changes if params[:article].present?
|
||||
render json: { message: @article.errors.full_messages.to_sentence }, status: :unprocessable_entity and return unless @article.valid?
|
||||
end
|
||||
|
||||
def destroy
|
||||
@@ -67,12 +67,26 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
||||
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
|
||||
end
|
||||
|
||||
# Draft-only autosaves must not bump the public-facing updated_at, so write
|
||||
# them with update_columns (which skips the timestamp). update_columns also
|
||||
# skips validations, so assign and validate first to avoid persisting content
|
||||
# that exceeds the column length limit.
|
||||
def persist_article_changes
|
||||
keys = article_params.to_h.keys
|
||||
if keys.any? && (keys - %w[draft_title draft_content]).empty?
|
||||
@article.assign_attributes(article_params)
|
||||
@article.update_columns(article_params.to_h) if @article.valid? # rubocop:disable Rails/SkipsModelValidations
|
||||
else
|
||||
@article.update!(article_params)
|
||||
end
|
||||
end
|
||||
|
||||
def article_params
|
||||
params.require(:article).permit(
|
||||
:title, :slug, :position, :content, :description, :category_id, :author_id, :associated_article_id, :status,
|
||||
:locale, meta: [:title,
|
||||
:description,
|
||||
{ tags: [] }]
|
||||
:locale, :draft_title, :draft_content, meta: [:title,
|
||||
:description,
|
||||
{ tags: [] }]
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -2,5 +2,14 @@ class Api::V1::Accounts::BaseController < Api::BaseController
|
||||
include SwitchLocale
|
||||
include EnsureCurrentAccountHelper
|
||||
before_action :current_account
|
||||
before_action :validate_token_api_access, if: :authenticate_by_access_token?
|
||||
around_action :switch_locale_using_account_locale
|
||||
|
||||
private
|
||||
|
||||
def validate_token_api_access
|
||||
return if Current.account.api_and_webhooks_enabled?
|
||||
|
||||
render json: { error: 'API access is not enabled for this account' }, status: :forbidden
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage::DirectUploadsController
|
||||
include DeviseTokenAuth::Concerns::SetUserByToken
|
||||
include RequestExceptionHandler
|
||||
include AccessTokenAuthHelper
|
||||
include EnsureCurrentAccountHelper
|
||||
|
||||
skip_before_action :verify_authenticity_token, if: :authenticate_by_access_token?
|
||||
|
||||
around_action :handle_with_exception
|
||||
before_action :authenticate_access_token!, if: :authenticate_by_access_token?
|
||||
before_action :validate_bot_access_token!, if: :authenticate_by_access_token?
|
||||
before_action :authenticate_user!, unless: :authenticate_by_access_token?
|
||||
before_action :current_account
|
||||
before_action :validate_token_api_access, if: :authenticate_by_access_token?
|
||||
before_action :conversation
|
||||
|
||||
def create
|
||||
@@ -11,6 +22,16 @@ class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage:
|
||||
|
||||
private
|
||||
|
||||
def authenticate_by_access_token?
|
||||
request.headers[:api_access_token].present? || request.headers[:HTTP_API_ACCESS_TOKEN].present?
|
||||
end
|
||||
|
||||
def validate_token_api_access
|
||||
return if Current.account.api_and_webhooks_enabled?
|
||||
|
||||
render json: { error: 'API access is not enabled for this account' }, status: :forbidden
|
||||
end
|
||||
|
||||
def conversation
|
||||
@conversation ||= Current.account.conversations.find_by(display_id: params[:conversation_id])
|
||||
end
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
class Api::V1::Accounts::DashboardAppsController < Api::V1::Accounts::BaseController
|
||||
before_action :check_authorization
|
||||
before_action :fetch_dashboard_apps, except: [:create]
|
||||
before_action :fetch_dashboard_app, only: [:show, :update, :destroy]
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
require 'csv'
|
||||
|
||||
class Api::V1::Accounts::DataImportsController < Api::V1::Accounts::BaseController
|
||||
DATA_IMPORT_FEATURE = 'data_import'.freeze
|
||||
|
||||
before_action :ensure_data_import_feature_enabled
|
||||
before_action :set_data_import, only: [:show, :start, :abandon, :error_logs, :skip_logs]
|
||||
before_action :check_authorization
|
||||
|
||||
def index
|
||||
@data_imports = policy_scope(Current.account.data_imports).includes(:initiated_by).order(created_at: :desc)
|
||||
data_import_ids = @data_imports.map(&:id)
|
||||
@import_errors_counts = DataImportError.non_skip_logs.where(data_import_id: data_import_ids).group(:data_import_id).count
|
||||
@skip_logs_counts = DataImportError.skip_logs.where(data_import_id: data_import_ids).group(:data_import_id).count
|
||||
end
|
||||
|
||||
def show
|
||||
render_show
|
||||
end
|
||||
|
||||
def validate_source
|
||||
totals = validate_intercom_source
|
||||
render json: { valid: true, totals: totals }
|
||||
rescue DataImports::Intercom::Client::AuthenticationError
|
||||
render_source_validation_error('We could not validate this Intercom access key. Check the key and its permissions.')
|
||||
rescue DataImports::Intercom::Client::Error
|
||||
render_source_validation_error('Intercom could not be reached. Please try again.')
|
||||
rescue ArgumentError => e
|
||||
render_source_validation_error(e.message)
|
||||
end
|
||||
|
||||
def create
|
||||
@data_import = creation_service.perform
|
||||
unless @data_import
|
||||
render json: { message: 'Another data import is already in progress.' }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
DataImports::Intercom::ImportJob.perform_later(@data_import, @data_import.active_intercom_import_run_id)
|
||||
render_show
|
||||
rescue DataImports::Intercom::Client::AuthenticationError
|
||||
render_source_validation_error('We could not validate this Intercom access key. Check the key and its permissions.')
|
||||
rescue DataImports::Intercom::Client::Error
|
||||
render_source_validation_error('Intercom could not be reached. Please try again.')
|
||||
rescue ArgumentError => e
|
||||
render_source_validation_error(e.message)
|
||||
end
|
||||
|
||||
def start
|
||||
restart_service = DataImports::Intercom::RestartService.new(account: Current.account, data_import: @data_import)
|
||||
restart_result = restart_service.perform
|
||||
@data_import = restart_service.data_import
|
||||
if restart_result == :access_token_missing
|
||||
render json: { message: 'The Intercom access key for this import is unavailable.' }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
DataImports::Intercom::ImportJob.perform_later(@data_import, @data_import.active_intercom_import_run_id) if restart_result == :enqueue
|
||||
render_show
|
||||
end
|
||||
|
||||
def abandon
|
||||
@data_import.abandon!
|
||||
render_show
|
||||
end
|
||||
|
||||
def skip_logs
|
||||
send_data(
|
||||
skip_logs_csv,
|
||||
filename: "data-import-#{@data_import.id}-skip-logs.csv",
|
||||
type: 'text/csv'
|
||||
)
|
||||
end
|
||||
|
||||
def error_logs
|
||||
send_data(
|
||||
error_logs_csv,
|
||||
filename: "data-import-#{@data_import.id}-error-logs.csv",
|
||||
type: 'text/csv'
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_data_import_feature_enabled
|
||||
raise Pundit::NotAuthorizedError unless Current.account.feature_enabled?(DATA_IMPORT_FEATURE)
|
||||
end
|
||||
|
||||
def set_data_import
|
||||
@data_import = Current.account.data_imports.find(params[:id])
|
||||
end
|
||||
|
||||
def check_authorization
|
||||
authorize(@data_import || DataImport)
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:name, :source_provider, :access_token, import_types: [])
|
||||
end
|
||||
|
||||
def creation_service
|
||||
DataImports::Intercom::CreationService.new(
|
||||
account: Current.account,
|
||||
initiated_by: Current.user,
|
||||
source_params: permitted_params.to_h
|
||||
)
|
||||
end
|
||||
|
||||
def import_types
|
||||
return DataImports::Intercom::Importer::DEFAULT_IMPORT_TYPES unless permitted_params.key?(:import_types)
|
||||
|
||||
Array(permitted_params[:import_types]).compact_blank
|
||||
end
|
||||
|
||||
def validate_intercom_source
|
||||
raise ArgumentError, 'Unsupported import source.' unless permitted_params[:source_provider] == 'intercom'
|
||||
|
||||
DataImports::Intercom::CredentialsValidator.new(
|
||||
access_token: permitted_params[:access_token],
|
||||
import_types: import_types
|
||||
).perform
|
||||
end
|
||||
|
||||
def render_source_validation_error(message)
|
||||
render json: { valid: false, message: message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def render_show
|
||||
@import_errors_finder = DataImportErrorFinder.new(@data_import)
|
||||
@skip_logs_finder = DataImportSkipLogFinder.new(@data_import, params)
|
||||
render :show
|
||||
end
|
||||
|
||||
def skip_logs_csv
|
||||
logs_csv(@data_import.import_errors.skip_logs)
|
||||
end
|
||||
|
||||
def error_logs_csv
|
||||
logs_csv(@data_import.import_errors.non_skip_logs)
|
||||
end
|
||||
|
||||
def logs_csv(logs)
|
||||
CSV.generate(headers: true) do |csv|
|
||||
csv << %w[created_at kind source_object_type source_object_id error_code message details]
|
||||
|
||||
logs.order(:created_at).find_each do |log|
|
||||
csv << [
|
||||
log.created_at.iso8601,
|
||||
log.details['kind'],
|
||||
log.source_object_type,
|
||||
log.source_object_id,
|
||||
log.error_code,
|
||||
log.message,
|
||||
log.details.to_json
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,6 @@
|
||||
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
|
||||
# Reconfiguring/reauthorizing a live inbox swaps its credentials, so restrict it to admins.
|
||||
before_action :check_admin_authorization?, if: -> { params[:inbox_id].present? }
|
||||
before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? }
|
||||
|
||||
# POST /api/v1/accounts/:account_id/whatsapp/authorization
|
||||
@@ -31,7 +33,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
|
||||
end
|
||||
|
||||
def validate_reauthorization_required
|
||||
return if @inbox.channel.reauthorization_required? || can_upgrade_to_embedded_signup?
|
||||
return if @inbox.channel.reauthorization_required? || can_reconfigure_channel?
|
||||
|
||||
render json: {
|
||||
success: false,
|
||||
@@ -39,10 +41,13 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
|
||||
}, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def can_upgrade_to_embedded_signup?
|
||||
def can_reconfigure_channel?
|
||||
channel = @inbox.channel
|
||||
return false unless channel.provider == 'whatsapp_cloud'
|
||||
|
||||
# Reconfiguring a live embedded-signup channel requires the feature flag.
|
||||
return Current.account.feature_enabled?('whatsapp_reconfigure') if channel.provider_config['source'] == 'embedded_signup'
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
before_action :ensure_account_name, only: [:create]
|
||||
before_action :validate_captcha, only: [:create]
|
||||
before_action :fetch_account, except: [:create]
|
||||
before_action :validate_token_api_access, if: :authenticate_by_access_token?, except: [:create]
|
||||
before_action :check_authorization, except: [:create]
|
||||
|
||||
rescue_from CustomExceptions::Account::InvalidEmail,
|
||||
@@ -105,6 +106,12 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
@current_account_user = @account.account_users.find_by(user_id: current_user.id)
|
||||
end
|
||||
|
||||
def validate_token_api_access
|
||||
return if @account.api_and_webhooks_enabled?
|
||||
|
||||
render json: { error: 'API access is not enabled for this account' }, status: :forbidden
|
||||
end
|
||||
|
||||
def account_params
|
||||
params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :user_full_name)
|
||||
end
|
||||
|
||||
@@ -14,6 +14,8 @@ module EnsureCurrentAccountHelper
|
||||
account_accessible_for_user?(account)
|
||||
elsif @resource.is_a?(AgentBot)
|
||||
account_accessible_for_bot?(account)
|
||||
else
|
||||
render_unauthorized(I18n.t('errors.account.not_authorized'))
|
||||
end
|
||||
account
|
||||
end
|
||||
@@ -21,7 +23,7 @@ module EnsureCurrentAccountHelper
|
||||
def account_accessible_for_user?(account)
|
||||
@current_account_user = account.account_users.find_by(user_id: current_user.id)
|
||||
Current.account_user = @current_account_user
|
||||
render_unauthorized('You are not authorized to access this account') unless @current_account_user
|
||||
render_unauthorized(I18n.t('errors.account.not_authorized')) unless @current_account_user
|
||||
end
|
||||
|
||||
def account_accessible_for_bot?(account)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
class DataImportErrorFinder
|
||||
RESULTS_LIMIT = 5
|
||||
|
||||
def initialize(data_import)
|
||||
@data_import = data_import
|
||||
end
|
||||
|
||||
def import_errors
|
||||
@data_import.import_errors.non_skip_logs.order(created_at: :desc).limit(RESULTS_LIMIT)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,35 @@
|
||||
class DataImportSkipLogFinder
|
||||
RESULTS_LIMIT = 5
|
||||
SOURCE_OBJECT_TYPES = %w[contact conversation message].freeze
|
||||
|
||||
attr_reader :selected_source_object_type
|
||||
|
||||
def initialize(data_import, params = {})
|
||||
@data_import = data_import
|
||||
@selected_source_object_type = valid_source_object_type(params[:skip_logs_type])
|
||||
end
|
||||
|
||||
def skip_logs
|
||||
filtered_scope.order(created_at: :desc).limit(RESULTS_LIMIT)
|
||||
end
|
||||
|
||||
def counts_by_type
|
||||
base_scope.group(:source_object_type).count
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def base_scope
|
||||
@base_scope ||= @data_import.import_errors.skip_logs
|
||||
end
|
||||
|
||||
def filtered_scope
|
||||
return base_scope if selected_source_object_type.blank?
|
||||
|
||||
base_scope.where(source_object_type: selected_source_object_type)
|
||||
end
|
||||
|
||||
def valid_source_object_type(source_object_type)
|
||||
source_object_type if SOURCE_OBJECT_TYPES.include?(source_object_type)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,39 @@
|
||||
/* global axios */
|
||||
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class DataImportsAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('data_imports', { accountScoped: true });
|
||||
}
|
||||
|
||||
start(id) {
|
||||
return axios.post(`${this.url}/${id}/start`);
|
||||
}
|
||||
|
||||
abandon(id) {
|
||||
return axios.post(`${this.url}/${id}/abandon`);
|
||||
}
|
||||
|
||||
show(id, params = {}) {
|
||||
return axios.get(`${this.url}/${id}`, { params });
|
||||
}
|
||||
|
||||
validateSource(payload) {
|
||||
return axios.post(`${this.url}/validate_source`, payload);
|
||||
}
|
||||
|
||||
downloadSkipLogs(id) {
|
||||
return axios.get(`${this.url}/${id}/skip_logs.csv`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
}
|
||||
|
||||
downloadErrorLogs(id) {
|
||||
return axios.get(`${this.url}/${id}/error_logs.csv`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new DataImportsAPI();
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, useTemplateRef } from 'vue';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
ARTICLE_MENU_ITEMS,
|
||||
ARTICLE_MENU_OPTIONS,
|
||||
ARTICLE_STATUSES,
|
||||
getArticleStatus,
|
||||
} from 'dashboard/helper/portalHelper';
|
||||
import ArticlePendingChangesPopover from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticlePendingChangesPopover.vue';
|
||||
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
@@ -53,6 +55,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hasPendingChanges: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
selectable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -68,12 +74,16 @@ const emit = defineEmits([
|
||||
'articleAction',
|
||||
'toggleSelect',
|
||||
'hover',
|
||||
'draftResolved',
|
||||
'draftFailed',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const [showActionsDropdown, toggleDropdown] = useToggle();
|
||||
|
||||
const pendingChangesPopoverRef = useTemplateRef('pendingChangesPopoverRef');
|
||||
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
@@ -105,7 +115,18 @@ const articleMenuItems = computed(() => {
|
||||
.filter(key => key !== 'translate' || isTranslationAvailable.value)
|
||||
.map(key => commonItems[key]);
|
||||
|
||||
return [...statusItems, commonItems.delete];
|
||||
const draftItems = props.hasPendingChanges
|
||||
? [
|
||||
{
|
||||
label: t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES'),
|
||||
value: 'discard-draft',
|
||||
action: 'discard-draft',
|
||||
icon: 'i-lucide-undo-2',
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return [...statusItems, ...draftItems, commonItems.delete];
|
||||
});
|
||||
|
||||
const statusTextColor = computed(() => {
|
||||
@@ -153,6 +174,12 @@ const lastUpdatedAt = computed(() => {
|
||||
|
||||
const handleArticleAction = ({ action, value }) => {
|
||||
toggleDropdown(false);
|
||||
// Un-publishing an article with staged edits — confirm apply/discard first;
|
||||
// the popover applies the chosen status itself.
|
||||
if (props.hasPendingChanges && (action === 'draft' || action === 'archive')) {
|
||||
pendingChangesPopoverRef.value?.open(getArticleStatus(value));
|
||||
return;
|
||||
}
|
||||
emit('articleAction', { action, value, id: props.id });
|
||||
};
|
||||
|
||||
@@ -184,6 +211,18 @@ const handleClick = id => {
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
v-if="hasPendingChanges"
|
||||
:title="
|
||||
t(
|
||||
'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.PENDING_EDITS_TOOLTIP'
|
||||
)
|
||||
"
|
||||
class="text-xs font-medium inline-flex items-center gap-1 h-6 px-2 py-0.5 rounded-md text-n-slate-11 bg-n-alpha-2 whitespace-nowrap shrink-0"
|
||||
>
|
||||
<span class="rounded-full size-1.5 bg-n-amber-9 shrink-0" />
|
||||
{{ t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.PENDING_EDITS') }}
|
||||
</span>
|
||||
<span
|
||||
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
|
||||
:class="statusTextColor"
|
||||
@@ -204,9 +243,15 @@ const handleClick = id => {
|
||||
<DropdownMenu
|
||||
v-if="showActionsDropdown"
|
||||
:menu-items="articleMenuItems"
|
||||
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:left-0 xl:rtl:right-0 top-full"
|
||||
class="mt-1 end-0 top-full w-40"
|
||||
@action="handleArticleAction($event)"
|
||||
/>
|
||||
<ArticlePendingChangesPopover
|
||||
ref="pendingChangesPopoverRef"
|
||||
:article-id="id"
|
||||
@resolved="emit('draftResolved', $event)"
|
||||
@failed="emit('draftFailed', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import MessageFormatter from 'shared/helpers/MessageFormatter';
|
||||
import {
|
||||
renderInlineDiff,
|
||||
buildDiffBlocks,
|
||||
} from 'dashboard/helper/articleDiffHelper';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||
|
||||
const props = defineProps({
|
||||
article: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const isOpen = defineModel({ type: Boolean, default: false });
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const liveTitle = computed(() => props.article?.title ?? '');
|
||||
const liveContent = computed(() => props.article?.content ?? '');
|
||||
const draftTitle = computed(() => props.article?.draftTitle ?? liveTitle.value);
|
||||
const draftContent = computed(
|
||||
() => props.article?.draftContent ?? liveContent.value
|
||||
);
|
||||
|
||||
const titleChanged = computed(() => liveTitle.value !== draftTitle.value);
|
||||
const titleDiff = computed(() =>
|
||||
renderInlineDiff(liveTitle.value, draftTitle.value)
|
||||
);
|
||||
|
||||
const contentBlocks = computed(() =>
|
||||
buildDiffBlocks(liveContent.value, draftContent.value)
|
||||
);
|
||||
const contentChanged = computed(() =>
|
||||
contentBlocks.value.some(block => block.type !== 'equal')
|
||||
);
|
||||
|
||||
// HC tables store per-column widths (px, 0 = unset) in this marker, which the
|
||||
// formatter strips. Re-apply them as a fixed-layout <colgroup>, defaulting
|
||||
// unsized columns so they don't collapse.
|
||||
const COLWIDTHS_RE = /<!--cw-colwidths:([\d,]+)-->/;
|
||||
const DEFAULT_COL_WIDTH = 50;
|
||||
|
||||
const applyColumnWidths = (html, widths) => {
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
const table = doc.body.querySelector('table');
|
||||
if (!table) return html;
|
||||
|
||||
const sized = widths.map(width => (width > 0 ? width : DEFAULT_COL_WIDTH));
|
||||
const colgroup = doc.createElement('colgroup');
|
||||
sized.forEach(width => {
|
||||
const col = doc.createElement('col');
|
||||
col.style.width = `${width}px`;
|
||||
colgroup.appendChild(col);
|
||||
});
|
||||
table.insertBefore(colgroup, table.firstChild);
|
||||
|
||||
table.style.tableLayout = 'fixed';
|
||||
table.style.width = `${sized.reduce((sum, width) => sum + width, 0)}px`;
|
||||
return doc.body.innerHTML;
|
||||
};
|
||||
|
||||
const renderMarkdown = markdown => {
|
||||
if (!markdown) return '';
|
||||
const html = new MessageFormatter(markdown).formattedMessage;
|
||||
const match = markdown.match(COLWIDTHS_RE);
|
||||
return match
|
||||
? applyColumnWidths(html, match[1].split(',').map(Number))
|
||||
: html;
|
||||
};
|
||||
|
||||
const blockClass = type => {
|
||||
if (type === 'added') {
|
||||
return 'border-n-teal-9 bg-n-teal-2';
|
||||
}
|
||||
if (type === 'removed') {
|
||||
return 'border-n-ruby-9 bg-n-ruby-2 line-through decoration-n-ruby-9/50';
|
||||
}
|
||||
return 'border-transparent';
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
isOpen.value = false;
|
||||
};
|
||||
|
||||
const dismissOnClickOutside = [close, { ignore: ['[data-diff-toggle]'] }];
|
||||
|
||||
useKeyboardEvents({ Escape: { action: close, allowOnFocusedInput: true } });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TeleportWithDirection to="body">
|
||||
<Transition
|
||||
enter-active-class="transition-transform duration-200 ease-in-out"
|
||||
leave-active-class="transition-transform duration-200 ease-in-out"
|
||||
enter-from-class="ltr:translate-x-full rtl:-translate-x-full"
|
||||
enter-to-class="ltr:translate-x-0 rtl:-translate-x-0"
|
||||
leave-from-class="ltr:translate-x-0 rtl:-translate-x-0"
|
||||
leave-to-class="ltr:translate-x-full rtl:-translate-x-full"
|
||||
>
|
||||
<aside
|
||||
v-if="isOpen"
|
||||
v-on-click-outside="dismissOnClickOutside"
|
||||
class="fixed inset-y-0 z-40 flex flex-col w-full shadow-2xl end-0 max-w-lg bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak"
|
||||
>
|
||||
<header
|
||||
class="flex items-start justify-between gap-3 px-6 py-4 border-b shrink-0 border-n-weak bg-n-solid-1"
|
||||
>
|
||||
<div class="flex flex-col gap-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="size-2 rounded-full bg-n-amber-9 shrink-0" />
|
||||
<h3 class="text-base font-medium leading-6 text-n-slate-12">
|
||||
{{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.DIFF_DIALOG.TITLE') }}
|
||||
</h3>
|
||||
</div>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.DIFF_DIALOG.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
icon="i-lucide-x"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="sm"
|
||||
class="shrink-0 hover:text-n-slate-11"
|
||||
@click="close"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div
|
||||
class="flex flex-col flex-1 min-h-0 gap-4 px-6 pt-4 pb-6 overflow-y-auto"
|
||||
>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div
|
||||
v-if="titleChanged"
|
||||
class="flex flex-col gap-1.5 border-s-[3px] border-transparent ps-3"
|
||||
>
|
||||
<span
|
||||
class="text-[11px] font-medium tracking-wide uppercase text-n-slate-10"
|
||||
>
|
||||
{{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.DIFF_DIALOG.TITLE_LABEL') }}
|
||||
</span>
|
||||
<h1
|
||||
class="text-lg font-semibold leading-snug text-n-slate-12"
|
||||
v-html="titleDiff"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="contentChanged"
|
||||
class="flex flex-col gap-1 [&_table]:w-full [&_table]:border-collapse [&_th]:border [&_td]:border [&_th]:border-n-weak [&_td]:border-n-weak [&_th]:p-2 [&_td]:p-2 [&_th]:bg-n-alpha-1 [&_th]:text-start [&_td]:align-top"
|
||||
>
|
||||
<div
|
||||
v-for="(block, index) in contentBlocks"
|
||||
:key="index"
|
||||
class="px-3 py-1.5 overflow-x-auto text-sm leading-relaxed break-words border-s-[3px] rounded-e-md text-n-slate-12 prose-sm prose dark:prose-invert max-w-none [&_p]:my-0 [&>:first-child]:mt-0 [&>:last-child]:mb-0"
|
||||
:class="blockClass(block.type)"
|
||||
v-html="renderMarkdown(block.md)"
|
||||
/>
|
||||
</div>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
</div>
|
||||
</aside>
|
||||
</Transition>
|
||||
</TeleportWithDirection>
|
||||
</template>
|
||||
+65
-17
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { ref, computed, watch, onBeforeUnmount } from 'vue';
|
||||
import { useTimeoutFn } from '@vueuse/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { ARTICLE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
|
||||
|
||||
@@ -9,6 +9,7 @@ import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
import FullEditor from 'dashboard/components/widgets/WootWriter/FullEditor.vue';
|
||||
import ArticleEditorHeader from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorHeader.vue';
|
||||
import ArticleEditorControls from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorControls.vue';
|
||||
import ArticleDiffPanel from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleDiffPanel.vue';
|
||||
|
||||
const props = defineProps({
|
||||
article: {
|
||||
@@ -38,32 +39,75 @@ const { t } = useI18n();
|
||||
|
||||
const isNewArticle = computed(() => !props.article?.id);
|
||||
|
||||
const localTitle = ref(props.article?.title ?? '');
|
||||
const localContent = ref(props.article?.content ?? '');
|
||||
// Prefer the draft; `??` keeps a deliberately-cleared empty string instead of
|
||||
// falling back to the live value.
|
||||
const effectiveTitle = () =>
|
||||
props.article?.draftTitle ?? props.article?.title ?? '';
|
||||
const effectiveContent = () =>
|
||||
props.article?.draftContent ?? props.article?.content ?? '';
|
||||
|
||||
// Sync local state when navigating to a different article or on initial fetch
|
||||
const hasPendingChanges = computed(
|
||||
() => props.article?.draftTitle != null || props.article?.draftContent != null
|
||||
);
|
||||
|
||||
const localTitle = ref(effectiveTitle());
|
||||
const localContent = ref(effectiveContent());
|
||||
|
||||
const isDiffPanelOpen = ref(false);
|
||||
|
||||
// Autosave 500ms after the last edit. It sends both title and content so an
|
||||
// edit to one never drops a recent edit to the other. `stop` cancels a queued
|
||||
// save; `isPending` tells the header to wait before allowing a publish.
|
||||
const {
|
||||
isPending: isSaving,
|
||||
start: debouncedSave,
|
||||
stop: cancelSave,
|
||||
} = useTimeoutFn(
|
||||
() =>
|
||||
emit('saveArticle', {
|
||||
title: localTitle.value,
|
||||
content: localContent.value,
|
||||
}),
|
||||
500,
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
const syncLocalState = () => {
|
||||
cancelSave();
|
||||
localTitle.value = effectiveTitle();
|
||||
localContent.value = effectiveContent();
|
||||
};
|
||||
|
||||
// Reseed on article switch or once a draft is published/discarded; close the
|
||||
// diff panel in the latter case since there's nothing left to compare.
|
||||
watch(
|
||||
() => props.article?.id,
|
||||
newId => {
|
||||
if (newId) {
|
||||
localTitle.value = props.article?.title ?? '';
|
||||
localContent.value = props.article?.content ?? '';
|
||||
}
|
||||
[() => props.article?.id, hasPendingChanges],
|
||||
([id, pending], [prevId, prevPending]) => {
|
||||
if ((id && id !== prevId) || (prevPending && !pending)) syncLocalState();
|
||||
if (prevPending && !pending) isDiffPanelOpen.value = false;
|
||||
}
|
||||
);
|
||||
|
||||
const debouncedSave = debounce(value => emit('saveArticle', value), 500, false);
|
||||
|
||||
const handleSave = value => {
|
||||
const scheduleSave = () => {
|
||||
if (isNewArticle.value) return;
|
||||
debouncedSave(value);
|
||||
debouncedSave();
|
||||
};
|
||||
|
||||
// Flush a queued save on unmount so leaving the editor doesn't drop the last edit.
|
||||
onBeforeUnmount(() => {
|
||||
if (isNewArticle.value || !isSaving.value) return;
|
||||
cancelSave();
|
||||
emit('saveArticle', {
|
||||
title: localTitle.value,
|
||||
content: localContent.value,
|
||||
});
|
||||
});
|
||||
|
||||
const articleTitle = computed({
|
||||
get: () => localTitle.value,
|
||||
set: value => {
|
||||
localTitle.value = value;
|
||||
handleSave({ title: value });
|
||||
scheduleSave();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -71,7 +115,7 @@ const articleContent = computed({
|
||||
get: () => localContent.value,
|
||||
set: content => {
|
||||
localContent.value = content;
|
||||
handleSave({ content });
|
||||
scheduleSave();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -108,9 +152,13 @@ const handleCreateArticle = event => {
|
||||
:is-saved="isSaved"
|
||||
:status="article.status"
|
||||
:article-id="article.id"
|
||||
:pending-changes="hasPendingChanges"
|
||||
:is-saving="isSaving"
|
||||
@go-back="onClickGoBack"
|
||||
@preview-article="previewArticle"
|
||||
@show-diff="isDiffPanelOpen = !isDiffPanelOpen"
|
||||
/>
|
||||
<ArticleDiffPanel v-model="isDiffPanelOpen" :article="article" />
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="flex flex-col gap-3 pl-4 mb-3 rtl:pr-3 rtl:pl-0">
|
||||
|
||||
+165
-14
@@ -1,8 +1,8 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, ref, useTemplateRef } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useStore } from 'dashboard/composables/store.js';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
@@ -17,6 +17,7 @@ import wootConstants from 'dashboard/constants/globals';
|
||||
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import ArticlePendingChangesPopover from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticlePendingChangesPopover.vue';
|
||||
|
||||
const props = defineProps({
|
||||
isUpdating: {
|
||||
@@ -35,9 +36,17 @@ const props = defineProps({
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
pendingChanges: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isSaving: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['goBack', 'previewArticle']);
|
||||
const emit = defineEmits(['goBack', 'previewArticle', 'showDiff']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
@@ -49,9 +58,30 @@ const { ARTICLE_STATUS_TYPES } = wootConstants;
|
||||
|
||||
const showArticleActionMenu = ref(false);
|
||||
|
||||
const pendingChangesPopoverRef = useTemplateRef('pendingChangesPopoverRef');
|
||||
|
||||
// Per-article update flag the store already maintains.
|
||||
const articleUiFlags = useMapGetter('articles/uiFlags');
|
||||
const isUpdatingArticle = computed(
|
||||
() => articleUiFlags.value(props.articleId).isUpdating
|
||||
);
|
||||
|
||||
// Publishing while a save is still in flight would promote a stale draft, so we show an alert
|
||||
const blockedWhileSaving = () => {
|
||||
if (!props.isSaving && !isUpdatingArticle.value) return false;
|
||||
useAlert(t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.SAVE_IN_PROGRESS'));
|
||||
return true;
|
||||
};
|
||||
|
||||
const isPublished = computed(() => props.status === ARTICLE_STATUSES.PUBLISHED);
|
||||
|
||||
const hasPendingChanges = computed(
|
||||
() => isPublished.value && props.pendingChanges
|
||||
);
|
||||
|
||||
const articleMenuItems = computed(() => {
|
||||
const statusOptions = ARTICLE_EDITOR_STATUS_OPTIONS[props.status] ?? [];
|
||||
return statusOptions.map(option => {
|
||||
const items = statusOptions.map(option => {
|
||||
const { label, value, icon } = ARTICLE_MENU_ITEMS[option];
|
||||
return {
|
||||
label: t(label),
|
||||
@@ -60,6 +90,17 @@ const articleMenuItems = computed(() => {
|
||||
icon,
|
||||
};
|
||||
});
|
||||
|
||||
if (hasPendingChanges.value) {
|
||||
items.push({
|
||||
label: t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES'),
|
||||
value: 'discard-draft',
|
||||
action: 'discard-draft',
|
||||
icon: 'i-lucide-undo-2',
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const statusText = computed(() =>
|
||||
@@ -85,8 +126,9 @@ const getStatusMessage = (status, isSuccess) => {
|
||||
: '';
|
||||
};
|
||||
|
||||
const updateArticleStatus = async ({ value }) => {
|
||||
showArticleActionMenu.value = false;
|
||||
// Pass draftAction (publishDraft/discardDraft) to resolve a draft in the same
|
||||
// update; omit it for a plain status change.
|
||||
const performStatusUpdate = async (value, draftAction) => {
|
||||
const status = getArticleStatus(value);
|
||||
if (status === ARTICLE_STATUS_TYPES.PUBLISH) {
|
||||
isArticlePublishing.value = true;
|
||||
@@ -94,7 +136,7 @@ const updateArticleStatus = async ({ value }) => {
|
||||
const { portalSlug } = route.params;
|
||||
|
||||
try {
|
||||
await store.dispatch('articles/update', {
|
||||
await store.dispatch(`articles/${draftAction ?? 'update'}`, {
|
||||
portalSlug,
|
||||
articleId: props.articleId,
|
||||
status,
|
||||
@@ -107,12 +149,100 @@ const updateArticleStatus = async ({ value }) => {
|
||||
} else if (status === ARTICLE_STATUS_TYPES.PUBLISH) {
|
||||
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
|
||||
}
|
||||
isArticlePublishing.value = false;
|
||||
} catch (error) {
|
||||
useAlert(error?.message ?? getStatusMessage(status, false));
|
||||
} finally {
|
||||
isArticlePublishing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const updateArticleStatus = ({ value }) => {
|
||||
showArticleActionMenu.value = false;
|
||||
// Leaving published with unsaved draft edits — ask whether to apply or discard
|
||||
// first; the popover applies the status itself once resolved.
|
||||
if (hasPendingChanges.value) {
|
||||
pendingChangesPopoverRef.value?.open(getArticleStatus(value));
|
||||
return;
|
||||
}
|
||||
performStatusUpdate(value);
|
||||
};
|
||||
|
||||
const publishDraftChanges = async () => {
|
||||
isArticlePublishing.value = true;
|
||||
const { portalSlug } = route.params;
|
||||
try {
|
||||
await store.dispatch('articles/publishDraft', {
|
||||
portalSlug,
|
||||
articleId: props.articleId,
|
||||
});
|
||||
useAlert(t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH_CHANGES_SUCCESS'));
|
||||
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.message ??
|
||||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH_CHANGES_ERROR')
|
||||
);
|
||||
} finally {
|
||||
isArticlePublishing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const discardDraftChanges = async () => {
|
||||
const { portalSlug } = route.params;
|
||||
try {
|
||||
await store.dispatch('articles/discardDraft', {
|
||||
portalSlug,
|
||||
articleId: props.articleId,
|
||||
});
|
||||
useAlert(t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES_SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.message ??
|
||||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES_ERROR')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onPrimaryAction = () => {
|
||||
if (blockedWhileSaving()) return;
|
||||
if (hasPendingChanges.value) {
|
||||
publishDraftChanges();
|
||||
} else if (props.pendingChanges) {
|
||||
// Promote leftover draft edits on publish instead of republishing stale content.
|
||||
performStatusUpdate(ARTICLE_STATUSES.PUBLISHED, 'publishDraft');
|
||||
} else {
|
||||
updateArticleStatus({ value: ARTICLE_STATUSES.PUBLISHED });
|
||||
}
|
||||
};
|
||||
|
||||
const onMenuAction = event => {
|
||||
showArticleActionMenu.value = false;
|
||||
// Don't resolve a draft while an autosave is still in flight — it could land
|
||||
// after and recreate the draft we just discarded/applied.
|
||||
if (blockedWhileSaving()) return;
|
||||
if (event.action === 'discard-draft') {
|
||||
discardDraftChanges();
|
||||
} else {
|
||||
updateArticleStatus(event);
|
||||
}
|
||||
};
|
||||
|
||||
// The popover applies the draft + status itself; we just surface the outcome.
|
||||
const onDraftResolved = status => {
|
||||
useAlert(getStatusMessage(status, true));
|
||||
if (status === ARTICLE_STATUS_TYPES.ARCHIVE) {
|
||||
useTrack(PORTALS_EVENTS.ARCHIVE_ARTICLE, { uiFrom: 'header' });
|
||||
} else if (status === ARTICLE_STATUS_TYPES.PUBLISH) {
|
||||
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
|
||||
}
|
||||
};
|
||||
|
||||
const onDraftFailed = error => {
|
||||
useAlert(
|
||||
error?.message ??
|
||||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH_CHANGES_ERROR')
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -127,13 +257,24 @@ const updateArticleStatus = async ({ value }) => {
|
||||
@click="onClickGoBack"
|
||||
/>
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
v-if="hasPendingChanges"
|
||||
type="button"
|
||||
data-diff-toggle
|
||||
:title="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.VIEW_CHANGES')"
|
||||
class="flex items-center gap-1.5 px-2 py-1 text-xs font-medium transition-colors rounded-lg cursor-pointer text-n-amber-11 bg-n-amber-3 outline outline-1 outline-n-amber-5 hover:bg-n-amber-4"
|
||||
@click="emit('showDiff')"
|
||||
>
|
||||
<span class="rounded-full size-1.5 bg-n-amber-9 shrink-0" />
|
||||
{{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PENDING_CHANGES') }}
|
||||
</button>
|
||||
<span
|
||||
v-if="isUpdating || isSaved"
|
||||
class="text-xs font-medium transition-all duration-300 text-n-slate-11"
|
||||
>
|
||||
{{ statusText }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative flex items-center gap-2">
|
||||
<Button
|
||||
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PREVIEW')"
|
||||
color="slate"
|
||||
@@ -143,17 +284,21 @@ const updateArticleStatus = async ({ value }) => {
|
||||
/>
|
||||
<ButtonGroup class="flex items-center">
|
||||
<Button
|
||||
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH')"
|
||||
:label="
|
||||
hasPendingChanges
|
||||
? t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH_CHANGES')
|
||||
: t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH')
|
||||
"
|
||||
size="sm"
|
||||
class="ltr:rounded-r-none rtl:rounded-l-none"
|
||||
no-animation
|
||||
:is-loading="isArticlePublishing"
|
||||
:disabled="
|
||||
status === ARTICLE_STATUSES.PUBLISHED ||
|
||||
!articleId ||
|
||||
isArticlePublishing
|
||||
isArticlePublishing ||
|
||||
(isPublished && !hasPendingChanges)
|
||||
"
|
||||
@click="updateArticleStatus({ value: ARTICLE_STATUSES.PUBLISHED })"
|
||||
@click="onPrimaryAction"
|
||||
/>
|
||||
<div class="relative">
|
||||
<OnClickOutside @trigger="showArticleActionMenu = false">
|
||||
@@ -169,11 +314,17 @@ const updateArticleStatus = async ({ value }) => {
|
||||
v-if="showArticleActionMenu"
|
||||
:menu-items="articleMenuItems"
|
||||
class="mt-2 ltr:right-0 rtl:left-0 top-full"
|
||||
@action="updateArticleStatus($event)"
|
||||
@action="onMenuAction($event)"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
</ButtonGroup>
|
||||
<ArticlePendingChangesPopover
|
||||
ref="pendingChangesPopoverRef"
|
||||
:article-id="articleId"
|
||||
@resolved="onDraftResolved"
|
||||
@failed="onDraftFailed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { onKeyStroke } from '@vueuse/core';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
articleId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['resolved', 'failed']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
|
||||
const isOpen = ref(false);
|
||||
const requestedStatus = ref(null);
|
||||
// Which button is in flight, so only that one shows the spinner.
|
||||
const activeAction = ref(null);
|
||||
|
||||
const articleUiFlags = useMapGetter('articles/uiFlags');
|
||||
const isLoading = computed(
|
||||
() => articleUiFlags.value(props.articleId).isUpdating
|
||||
);
|
||||
|
||||
// Open the confirmation for a target status; resolving it also applies that status.
|
||||
const open = status => {
|
||||
requestedStatus.value = status;
|
||||
activeAction.value = null;
|
||||
isOpen.value = true;
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
isOpen.value = false;
|
||||
};
|
||||
|
||||
// Don't let a click-outside or Escape dismiss the popover mid-action.
|
||||
const dismiss = () => {
|
||||
if (!isLoading.value) close();
|
||||
};
|
||||
|
||||
const resolve = async draftAction => {
|
||||
activeAction.value = draftAction === 'publishDraft' ? 'apply' : 'discard';
|
||||
try {
|
||||
await store.dispatch(`articles/${draftAction}`, {
|
||||
portalSlug: route.params.portalSlug,
|
||||
articleId: props.articleId,
|
||||
status: requestedStatus.value,
|
||||
});
|
||||
emit('resolved', requestedStatus.value);
|
||||
close();
|
||||
} catch (error) {
|
||||
emit('failed', error);
|
||||
}
|
||||
};
|
||||
|
||||
const onApply = () => resolve('publishDraft');
|
||||
const onDiscard = () => resolve('discardDraft');
|
||||
|
||||
onKeyStroke('Escape', () => {
|
||||
if (isOpen.value) dismiss();
|
||||
});
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-show="isOpen"
|
||||
v-on-click-outside="dismiss"
|
||||
class="absolute z-50 flex flex-col gap-4 p-4 mt-2 outline outline-1 shadow-lg w-96 end-0 top-full rounded-xl bg-n-alpha-3 backdrop-blur-[100px] outline-n-container"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-base font-medium text-n-slate-12">
|
||||
{{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.PENDING_CHANGES_POPOVER.TITLE') }}
|
||||
</h3>
|
||||
<p class="mb-0 text-sm text-n-slate-11">
|
||||
{{
|
||||
t(
|
||||
'HELP_CENTER.EDIT_ARTICLE_PAGE.PENDING_CHANGES_POPOVER.DESCRIPTION'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
icon="i-lucide-x"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="xs"
|
||||
class="shrink-0 -me-1 -mt-1"
|
||||
:disabled="isLoading"
|
||||
@click="close"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="faded"
|
||||
color="ruby"
|
||||
size="sm"
|
||||
class="flex-1"
|
||||
:is-loading="isLoading && activeAction === 'discard'"
|
||||
:disabled="isLoading"
|
||||
:label="
|
||||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.PENDING_CHANGES_POPOVER.DISCARD')
|
||||
"
|
||||
@click="onDiscard"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
color="blue"
|
||||
size="sm"
|
||||
class="flex-1"
|
||||
:is-loading="isLoading && activeAction === 'apply'"
|
||||
:disabled="isLoading"
|
||||
:label="
|
||||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.PENDING_CHANGES_POPOVER.APPLY')
|
||||
"
|
||||
@click="onApply"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+45
-9
@@ -5,8 +5,12 @@ import { useRouter, useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { getArticleStatus } from 'dashboard/helper/portalHelper.js';
|
||||
import {
|
||||
getArticleStatus,
|
||||
ARTICLE_STATUSES,
|
||||
} from 'dashboard/helper/portalHelper.js';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { hasPendingChanges } from 'dashboard/helper/articleDiffHelper';
|
||||
|
||||
import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue';
|
||||
import DraggableReorderList from 'dashboard/components-next/DraggableReorderList/DraggableReorderList.vue';
|
||||
@@ -145,6 +149,24 @@ const updateArticlesMeta = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const refreshArticleMeta = async () => {
|
||||
await updateArticlesMeta();
|
||||
await updatePortalMeta();
|
||||
};
|
||||
|
||||
// The card's pending-changes popover applies the status itself; surface the result.
|
||||
const onDraftResolved = status => {
|
||||
useAlert(getStatusMessage(status, true));
|
||||
refreshArticleMeta();
|
||||
};
|
||||
|
||||
const onDraftFailed = error => {
|
||||
useAlert(
|
||||
error?.message ||
|
||||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH_CHANGES_ERROR')
|
||||
);
|
||||
};
|
||||
|
||||
const handleArticleAction = async (action, { status, id }) => {
|
||||
const { portalSlug } = route.params;
|
||||
try {
|
||||
@@ -154,6 +176,14 @@ const handleArticleAction = async (action, { status, id }) => {
|
||||
articleId: id,
|
||||
});
|
||||
useAlert(t('HELP_CENTER.DELETE_ARTICLE.API.SUCCESS_MESSAGE'));
|
||||
} else if (action === 'discard-draft') {
|
||||
await store.dispatch('articles/discardDraft', {
|
||||
portalSlug,
|
||||
articleId: id,
|
||||
});
|
||||
useAlert(
|
||||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES_SUCCESS')
|
||||
);
|
||||
} else {
|
||||
await store.dispatch('articles/update', {
|
||||
portalSlug,
|
||||
@@ -168,15 +198,16 @@ const handleArticleAction = async (action, { status, id }) => {
|
||||
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
|
||||
}
|
||||
}
|
||||
await updateArticlesMeta();
|
||||
await updatePortalMeta();
|
||||
await refreshArticleMeta();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.message ||
|
||||
(action === 'delete'
|
||||
? t('HELP_CENTER.DELETE_ARTICLE.API.ERROR_MESSAGE')
|
||||
: getStatusMessage(status, false));
|
||||
useAlert(errorMessage);
|
||||
const fallbackMessage =
|
||||
{
|
||||
delete: t('HELP_CENTER.DELETE_ARTICLE.API.ERROR_MESSAGE'),
|
||||
'discard-draft': t(
|
||||
'HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES_ERROR'
|
||||
),
|
||||
}[action] ?? getStatusMessage(status, false);
|
||||
useAlert(error?.message || fallbackMessage);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -210,10 +241,15 @@ const updateArticle = ({ action, value, id }) => {
|
||||
:views="item.views || 0"
|
||||
:updated-at="item.updatedAt"
|
||||
:is-selected="selectedArticleIds.has(item.id)"
|
||||
:has-pending-changes="
|
||||
item.status === ARTICLE_STATUSES.PUBLISHED && hasPendingChanges(item)
|
||||
"
|
||||
selectable
|
||||
:show-selection-control="shouldShowSelectionControl(item.id)"
|
||||
@open-article="openArticle"
|
||||
@article-action="updateArticle"
|
||||
@draft-resolved="onDraftResolved"
|
||||
@draft-failed="onDraftFailed"
|
||||
@toggle-select="emit('toggleSelect', $event)"
|
||||
@hover="isHovered => handleCardHover(isHovered, item.id)"
|
||||
/>
|
||||
|
||||
+44
-7
@@ -3,10 +3,15 @@ import { ref, computed, watch } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
|
||||
import {
|
||||
ARTICLE_TABS,
|
||||
CATEGORY_ALL,
|
||||
ARTICLE_STATUSES,
|
||||
} from 'dashboard/helper/portalHelper';
|
||||
import { hasPendingChanges } from 'dashboard/helper/articleDiffHelper';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import articlesAPI from 'dashboard/api/helpCenter/articles';
|
||||
@@ -60,6 +65,7 @@ const emit = defineEmits([
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
|
||||
@@ -227,15 +233,46 @@ const onBulkActionSuccess = message => {
|
||||
};
|
||||
|
||||
const bulkUpdateStatus = async status => {
|
||||
const selectedIds = [...selectedArticleIds.value];
|
||||
const { portalSlug } = route.params;
|
||||
|
||||
const pendingIds = props.articles
|
||||
.filter(
|
||||
article =>
|
||||
selectedIds.includes(article.id) &&
|
||||
article.status === ARTICLE_STATUSES.PUBLISHED &&
|
||||
hasPendingChanges(article)
|
||||
)
|
||||
.map(article => article.id);
|
||||
|
||||
// Publish promotes each pending draft; other status changes skip them.
|
||||
const isPublishing = status === ARTICLE_STATUSES.PUBLISHED;
|
||||
const draftIds = isPublishing ? pendingIds : [];
|
||||
const skippedCount = isPublishing ? 0 : pendingIds.length;
|
||||
const articleIds = selectedIds.filter(id => !pendingIds.includes(id));
|
||||
|
||||
if (!articleIds.length && !draftIds.length) {
|
||||
useAlert(t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_SKIPPED_ALL'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await articlesAPI.bulkUpdateStatus({
|
||||
portalSlug: route.params.portalSlug,
|
||||
articleIds: [...selectedArticleIds.value],
|
||||
status,
|
||||
});
|
||||
if (articleIds.length) {
|
||||
await articlesAPI.bulkUpdateStatus({ portalSlug, articleIds, status });
|
||||
}
|
||||
await Promise.all(
|
||||
draftIds.map(articleId =>
|
||||
store.dispatch('articles/publishDraft', { portalSlug, articleId })
|
||||
)
|
||||
);
|
||||
onBulkActionSuccess(
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_SUCCESS')
|
||||
);
|
||||
if (skippedCount) {
|
||||
useAlert(
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_SKIPPED', skippedCount)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.message || t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_ERROR')
|
||||
|
||||
@@ -14,6 +14,7 @@ const props = defineProps({
|
||||
icon: { type: [String, Object, Function], default: '' },
|
||||
trailingIcon: { type: Boolean, default: false },
|
||||
isLoading: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
@@ -61,6 +62,7 @@ const handleClick = () => {
|
||||
:icon="icon"
|
||||
:trailing-icon="trailingIcon"
|
||||
:is-loading="isLoading"
|
||||
:disabled="disabled"
|
||||
@click="handleClick"
|
||||
@blur="resetConfirmMode"
|
||||
>
|
||||
|
||||
@@ -66,6 +66,10 @@ const props = defineProps({
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
responsesCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
isSelected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -112,10 +116,10 @@ const showSyncStatus = computed(() => !isPdf.value);
|
||||
const menuItems = computed(() => {
|
||||
const allOptions = [
|
||||
{
|
||||
label: t('CAPTAIN.DOCUMENTS.OPTIONS.VIEW_RELATED_RESPONSES'),
|
||||
value: 'viewRelatedQuestions',
|
||||
action: 'viewRelatedQuestions',
|
||||
icon: 'i-ph-tree-view-duotone',
|
||||
label: t('CAPTAIN.DOCUMENTS.OPTIONS.VIEW_DETAILS'),
|
||||
value: 'viewDetails',
|
||||
action: 'viewDetails',
|
||||
icon: 'i-lucide-eye',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -143,6 +147,9 @@ const menuItems = computed(() => {
|
||||
});
|
||||
|
||||
const createdAtLabel = computed(() => dynamicTime(props.createdAt));
|
||||
const responsesCountLabel = computed(() =>
|
||||
t('CAPTAIN.DOCUMENTS.FAQ_COUNT', { n: props.responsesCount })
|
||||
);
|
||||
|
||||
const displayLink = computed(() =>
|
||||
isPdf.value
|
||||
@@ -158,6 +165,10 @@ const handleAction = ({ action, value }) => {
|
||||
emit('action', { action, value, id: props.id });
|
||||
};
|
||||
|
||||
const handleViewDetails = () => {
|
||||
emit('action', { action: 'viewDetails', id: props.id });
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
emit('action', { action: 'sync', id: props.id });
|
||||
};
|
||||
@@ -177,9 +188,13 @@ const handleRetry = () => {
|
||||
<Checkbox v-model="modelValue" />
|
||||
</div>
|
||||
<div class="flex gap-1 justify-between w-full">
|
||||
<span class="text-base text-n-slate-12 line-clamp-1">
|
||||
<button
|
||||
type="button"
|
||||
class="p-0 text-base text-left bg-transparent border-0 outline-transparent text-n-slate-12 line-clamp-1 underline-offset-2 hover:underline focus-visible:underline"
|
||||
@click="handleViewDetails"
|
||||
>
|
||||
{{ name }}
|
||||
</span>
|
||||
</button>
|
||||
<div v-if="showMenu" class="flex gap-2 items-center">
|
||||
<div
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
@@ -228,6 +243,9 @@ const handleRetry = () => {
|
||||
<Icon :icon="linkIcon" class="shrink-0" />
|
||||
<span class="truncate">{{ displayLink }}</span>
|
||||
</span>
|
||||
<span class="text-sm shrink-0 text-n-slate-11">
|
||||
{{ responsesCountLabel }}
|
||||
</span>
|
||||
<DocumentSyncStatus
|
||||
v-if="showSyncStatus"
|
||||
:status="syncStatus"
|
||||
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
<script setup>
|
||||
import { ref, watch, useTemplateRef } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
|
||||
import AudienceGroup from './audience/AudienceGroup.vue';
|
||||
import { useAudienceFilterTypes } from './audience/useAudienceFilterTypes.js';
|
||||
|
||||
const props = defineProps({
|
||||
assistant: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['submit']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const { filterTypes } = useAudienceFilterTypes();
|
||||
|
||||
let uid = 0;
|
||||
const nextId = () => {
|
||||
uid += 1;
|
||||
return `audience-root-${uid}`;
|
||||
};
|
||||
|
||||
const hasConditions = node =>
|
||||
node && Object.prototype.hasOwnProperty.call(node, 'conditions');
|
||||
|
||||
const defaultRoot = () => ({ id: nextId(), operator: 'and', conditions: [] });
|
||||
|
||||
const root = ref(defaultRoot());
|
||||
const mode = ref('everyone');
|
||||
|
||||
const findOption = (filterType, value) =>
|
||||
filterType?.options?.find(option => String(option.id) === String(value));
|
||||
|
||||
const hydrateValues = (leaf, filterType) => {
|
||||
const raw = Array.isArray(leaf.values) ? leaf.values : [leaf.values];
|
||||
const inputType = filterType?.inputType;
|
||||
if (inputType === 'multiSelect') {
|
||||
return raw.map(
|
||||
value => findOption(filterType, value) ?? { id: value, name: value }
|
||||
);
|
||||
}
|
||||
if (['searchSelect', 'booleanSelect'].includes(inputType)) {
|
||||
return findOption(filterType, raw[0]) ?? { id: raw[0], name: raw[0] };
|
||||
}
|
||||
return raw[0] ?? '';
|
||||
};
|
||||
|
||||
const hydrateNode = node => {
|
||||
if (hasConditions(node)) {
|
||||
return {
|
||||
id: nextId(),
|
||||
operator: node.operator || 'and',
|
||||
conditions: (node.conditions || []).map(hydrateNode),
|
||||
};
|
||||
}
|
||||
|
||||
const filterType = filterTypes.value.find(
|
||||
type => type.attributeKey === node.attribute_key
|
||||
);
|
||||
return {
|
||||
id: nextId(),
|
||||
attributeKey: node.attribute_key,
|
||||
filterOperator: node.filter_operator,
|
||||
values: hydrateValues(node, filterType),
|
||||
attributeModel: filterType?.attributeModel || 'standard',
|
||||
};
|
||||
};
|
||||
|
||||
const hydrateRoot = audience => {
|
||||
if (!audience) return defaultRoot();
|
||||
const hydrated = hydrateNode(audience);
|
||||
return hasConditions(hydrated)
|
||||
? hydrated
|
||||
: { id: nextId(), operator: 'and', conditions: [hydrated] };
|
||||
};
|
||||
|
||||
const serializeValues = values => {
|
||||
if (Array.isArray(values)) {
|
||||
return values[0]?.id ? values.map(value => value.id) : values;
|
||||
}
|
||||
if (values && typeof values === 'object') {
|
||||
return [values.id];
|
||||
}
|
||||
if (values === '' || values === null || values === undefined) {
|
||||
return [];
|
||||
}
|
||||
return [values];
|
||||
};
|
||||
|
||||
const serializeNode = node => {
|
||||
if (hasConditions(node)) {
|
||||
return {
|
||||
operator: node.operator,
|
||||
conditions: node.conditions.map(serializeNode),
|
||||
};
|
||||
}
|
||||
return {
|
||||
attribute_key: node.attributeKey,
|
||||
filter_operator: node.filterOperator,
|
||||
values: serializeValues(node.values),
|
||||
};
|
||||
};
|
||||
|
||||
const groupRef = useTemplateRef('groupRef');
|
||||
|
||||
const handleSubmit = () => {
|
||||
const isSpecific = mode.value === 'specific';
|
||||
if (isSpecific && !groupRef.value.validate()) return;
|
||||
|
||||
const audience =
|
||||
isSpecific && root.value.conditions.length
|
||||
? serializeNode(root.value)
|
||||
: null;
|
||||
|
||||
emit('submit', {
|
||||
config: {
|
||||
...props.assistant.config,
|
||||
audience,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.assistant,
|
||||
newAssistant => {
|
||||
if (!newAssistant) return;
|
||||
const audience = newAssistant.config?.audience;
|
||||
root.value = hydrateRoot(audience);
|
||||
mode.value = audience ? 'specific' : 'everyone';
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-3">
|
||||
<RadioCard
|
||||
id="everyone"
|
||||
:label="t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.EVERYONE.LABEL')"
|
||||
:description="t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.EVERYONE.DESC')"
|
||||
:is-active="mode === 'everyone'"
|
||||
@select="mode = 'everyone'"
|
||||
/>
|
||||
<RadioCard
|
||||
id="specific"
|
||||
:label="t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.SPECIFIC.LABEL')"
|
||||
:description="t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.SPECIFIC.DESC')"
|
||||
:is-active="mode === 'specific'"
|
||||
@select="mode = 'specific'"
|
||||
>
|
||||
<AudienceGroup
|
||||
v-if="mode === 'specific'"
|
||||
ref="groupRef"
|
||||
v-model="root"
|
||||
is-root
|
||||
class="w-full mt-2"
|
||||
:filter-types="filterTypes"
|
||||
/>
|
||||
</RadioCard>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
|
||||
|
||||
const props = defineProps({
|
||||
assistant: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['submit']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const OPTIONS = ['always', 'business_hours', 'outside_business_hours'];
|
||||
|
||||
const selected = ref('always');
|
||||
|
||||
const handleSubmit = () => {
|
||||
emit('submit', {
|
||||
config: { ...props.assistant.config, response_window: selected.value },
|
||||
});
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.assistant,
|
||||
newAssistant => {
|
||||
if (newAssistant) {
|
||||
selected.value = newAssistant.config?.response_window || 'always';
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-3">
|
||||
<RadioCard
|
||||
v-for="option in OPTIONS"
|
||||
:id="option"
|
||||
:key="option"
|
||||
:label="
|
||||
t(`CAPTAIN.ASSISTANTS.FORM.SCHEDULE.${option.toUpperCase()}.LABEL`)
|
||||
"
|
||||
:description="
|
||||
t(`CAPTAIN.ASSISTANTS.FORM.SCHEDULE.${option.toUpperCase()}.DESC`)
|
||||
"
|
||||
:is-active="selected === option"
|
||||
@select="selected = $event"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ t('CAPTAIN.ASSISTANTS.FORM.SCHEDULE.HINT') }}
|
||||
</p>
|
||||
<div>
|
||||
<Button
|
||||
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
<script setup>
|
||||
import { useTemplateRef } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Button from 'next/button/Button.vue';
|
||||
import FilterSelect from 'dashboard/components-next/filter/inputs/FilterSelect.vue';
|
||||
import ConditionRow from 'dashboard/components-next/filter/ConditionRow.vue';
|
||||
|
||||
const props = defineProps({
|
||||
filterTypes: { type: Array, required: true },
|
||||
depth: { type: Number, default: 0 },
|
||||
maxDepth: { type: Number, default: 1 },
|
||||
isRoot: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['remove']);
|
||||
|
||||
const node = defineModel({ type: Object, required: true });
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
let uid = 0;
|
||||
const nextId = () => {
|
||||
uid += 1;
|
||||
return `audience-${Date.now()}-${uid}`;
|
||||
};
|
||||
|
||||
const DEFAULT_LEAF = () => ({
|
||||
id: nextId(),
|
||||
attributeKey: 'email',
|
||||
filterOperator: 'contains',
|
||||
values: '',
|
||||
attributeModel: 'standard',
|
||||
});
|
||||
|
||||
const operatorOptions = [
|
||||
{ value: 'and', label: t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.MATCH_ALL') },
|
||||
{ value: 'or', label: t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.MATCH_ANY') },
|
||||
];
|
||||
|
||||
const isGroup = child =>
|
||||
Object.prototype.hasOwnProperty.call(child, 'conditions');
|
||||
|
||||
const addCondition = () => {
|
||||
node.value.conditions.push(DEFAULT_LEAF());
|
||||
};
|
||||
|
||||
const addGroup = () => {
|
||||
node.value.conditions.push({
|
||||
id: nextId(),
|
||||
operator: 'and',
|
||||
conditions: [DEFAULT_LEAF()],
|
||||
});
|
||||
};
|
||||
|
||||
const removeChild = index => {
|
||||
node.value.conditions.splice(index, 1);
|
||||
// A nested group must always hold at least one condition; remove the whole group when emptied.
|
||||
if (!props.isRoot && node.value.conditions.length === 0) {
|
||||
emit('remove');
|
||||
}
|
||||
};
|
||||
|
||||
const leafRefs = useTemplateRef('leafRefs');
|
||||
const groupRefs = useTemplateRef('groupRefs');
|
||||
|
||||
const validate = () => {
|
||||
const leavesValid = (leafRefs.value ?? []).every(row => row.validate());
|
||||
const groupsValid = (groupRefs.value ?? []).every(group => group.validate());
|
||||
return leavesValid && groupsValid;
|
||||
};
|
||||
|
||||
defineExpose({ validate });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-4 group/audience-group"
|
||||
:class="
|
||||
isRoot
|
||||
? 'p-4 border border-n-weak rounded-xl'
|
||||
: 'p-3 border border-n-weak bg-n-alpha-1 rounded-lg'
|
||||
"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-1.5 text-sm text-n-slate-11">
|
||||
<span>{{ t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.MATCH_PREFIX') }}</span>
|
||||
<FilterSelect
|
||||
v-model="node.operator"
|
||||
variant="faded"
|
||||
hide-icon
|
||||
class="text-sm"
|
||||
:options="operatorOptions"
|
||||
/>
|
||||
<span>{{ t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.MATCH_SUFFIX') }}</span>
|
||||
</div>
|
||||
<Button
|
||||
v-if="!isRoot"
|
||||
sm
|
||||
ghost
|
||||
slate
|
||||
icon="i-lucide-trash"
|
||||
class="ml-auto flex-shrink-0 opacity-0 transition-opacity group-hover/audience-group:opacity-100"
|
||||
@click="emit('remove')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ul class="grid gap-3 list-none">
|
||||
<template v-for="(child, index) in node.conditions" :key="child.id">
|
||||
<AudienceGroup
|
||||
v-if="isGroup(child)"
|
||||
ref="groupRefs"
|
||||
v-model="node.conditions[index]"
|
||||
:filter-types="filterTypes"
|
||||
:depth="depth + 1"
|
||||
:max-depth="maxDepth"
|
||||
@remove="removeChild(index)"
|
||||
/>
|
||||
<ConditionRow
|
||||
v-else
|
||||
ref="leafRefs"
|
||||
v-model:attribute-key="child.attributeKey"
|
||||
v-model:filter-operator="child.filterOperator"
|
||||
v-model:values="child.values"
|
||||
:filter-types="filterTypes"
|
||||
:show-query-operator="false"
|
||||
searchable-attributes
|
||||
@remove="removeChild(index)"
|
||||
/>
|
||||
</template>
|
||||
</ul>
|
||||
|
||||
<div
|
||||
v-if="isRoot && !node.conditions.length"
|
||||
class="flex flex-col items-center gap-1.5 px-4 py-8 text-center border border-dashed rounded-lg border-n-weak"
|
||||
>
|
||||
<span class="text-2xl i-lucide-users-round text-n-slate-10" />
|
||||
<p class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.EMPTY_TITLE') }}
|
||||
</p>
|
||||
<p class="max-w-md text-sm text-n-slate-11">
|
||||
{{ t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.EMPTY_BODY') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
sm
|
||||
ghost
|
||||
blue
|
||||
icon="i-lucide-plus"
|
||||
class="flex-shrink-0"
|
||||
:label="t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.ADD_CONDITION')"
|
||||
@click="addCondition"
|
||||
/>
|
||||
<Button
|
||||
v-if="depth < maxDepth"
|
||||
sm
|
||||
ghost
|
||||
blue
|
||||
icon="i-lucide-plus"
|
||||
class="flex-shrink-0"
|
||||
:label="t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.ADD_GROUP')"
|
||||
@click="addGroup"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useContactFilterContext } from 'dashboard/components-next/filter/contactProvider.js';
|
||||
import { useOperators } from 'dashboard/components-next/filter/operators.js';
|
||||
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
|
||||
|
||||
const STANDARD_ICONS = {
|
||||
name: 'i-lucide-user',
|
||||
email: 'i-lucide-mail',
|
||||
phone_number: 'i-lucide-phone',
|
||||
identifier: 'i-lucide-fingerprint',
|
||||
country_code: 'i-lucide-flag',
|
||||
city: 'i-lucide-map-pin',
|
||||
company_name: 'i-lucide-building-2',
|
||||
created_at: 'i-lucide-calendar',
|
||||
last_activity_at: 'i-lucide-activity',
|
||||
blocked: 'i-lucide-ban',
|
||||
labels: 'i-lucide-tags',
|
||||
hmac_verified: 'i-lucide-user-check',
|
||||
browser_language: 'i-lucide-globe',
|
||||
conversation_language: 'i-lucide-languages',
|
||||
};
|
||||
|
||||
const CUSTOM_TYPE_ICONS = {
|
||||
text: 'i-lucide-type',
|
||||
number: 'i-lucide-hash',
|
||||
currency: 'i-lucide-banknote',
|
||||
percent: 'i-lucide-percent',
|
||||
link: 'i-lucide-link',
|
||||
date: 'i-lucide-calendar',
|
||||
list: 'i-lucide-list',
|
||||
checkbox: 'i-lucide-square-check',
|
||||
};
|
||||
|
||||
const DEFAULT_ICON = 'i-lucide-tag';
|
||||
|
||||
const header = (id, label) => ({
|
||||
value: `__group_${id}`,
|
||||
label,
|
||||
disabled: true,
|
||||
});
|
||||
|
||||
export function useAudienceFilterTypes() {
|
||||
const { t } = useI18n();
|
||||
const { filterTypes: contactFilterTypes } = useContactFilterContext();
|
||||
const { equalityOperators } = useOperators();
|
||||
const contactAttributes = useMapGetter('attributes/getContactAttributes');
|
||||
|
||||
const customTypeByKey = computed(() =>
|
||||
Object.fromEntries(
|
||||
(contactAttributes.value || []).map(attr => [
|
||||
attr.attributeKey,
|
||||
attr.attributeDisplayType,
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
const conversationOption = (key, label, options) => ({
|
||||
attributeKey: key,
|
||||
value: key,
|
||||
attributeName: label,
|
||||
label,
|
||||
icon: STANDARD_ICONS[key],
|
||||
inputType: 'searchSelect',
|
||||
options,
|
||||
dataType: 'text',
|
||||
filterOperators: equalityOperators.value,
|
||||
attributeModel: 'additional',
|
||||
});
|
||||
|
||||
const conversationTypes = computed(() => [
|
||||
conversationOption(
|
||||
'hmac_verified',
|
||||
t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.LOGGED_IN'),
|
||||
[
|
||||
{
|
||||
id: 'true',
|
||||
name: t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.LOGGED_IN_TRUE'),
|
||||
},
|
||||
{
|
||||
id: 'false',
|
||||
name: t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.LOGGED_IN_FALSE'),
|
||||
},
|
||||
]
|
||||
),
|
||||
conversationOption(
|
||||
'browser_language',
|
||||
t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.BROWSER_LANGUAGE'),
|
||||
languages
|
||||
),
|
||||
conversationOption(
|
||||
'conversation_language',
|
||||
t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.CONVERSATION_LANGUAGE'),
|
||||
languages
|
||||
),
|
||||
]);
|
||||
|
||||
const standardTypes = computed(() =>
|
||||
contactFilterTypes.value
|
||||
.filter(type => type.attributeModel !== 'customAttributes')
|
||||
.map(type => ({
|
||||
...type,
|
||||
icon: STANDARD_ICONS[type.attributeKey] || DEFAULT_ICON,
|
||||
}))
|
||||
);
|
||||
|
||||
const customTypes = computed(() =>
|
||||
contactFilterTypes.value
|
||||
.filter(type => type.attributeModel === 'customAttributes')
|
||||
.filter(type => type.attributeKey in customTypeByKey.value)
|
||||
.map(type => ({
|
||||
...type,
|
||||
icon:
|
||||
CUSTOM_TYPE_ICONS[customTypeByKey.value[type.attributeKey]] ||
|
||||
DEFAULT_ICON,
|
||||
}))
|
||||
);
|
||||
|
||||
const filterTypes = computed(() => [
|
||||
header('contact', t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.GROUP_CONTACT')),
|
||||
...standardTypes.value,
|
||||
header(
|
||||
'conversation',
|
||||
t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.GROUP_CONVERSATION')
|
||||
),
|
||||
...conversationTypes.value,
|
||||
...(customTypes.value.length
|
||||
? [
|
||||
header('custom', t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.GROUP_CUSTOM')),
|
||||
...customTypes.value,
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
|
||||
return { filterTypes };
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
|
||||
import { useAudienceFilterTypes } from './useAudienceFilterTypes';
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
contactFilterTypes: [],
|
||||
contactAttributes: [],
|
||||
equalityOperators: [{ value: 'equal_to' }, { value: 'not_equal_to' }],
|
||||
}));
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: key => key }),
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables/store.js', () => ({
|
||||
useMapGetter: () => ({ value: state.contactAttributes }),
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/components-next/filter/contactProvider.js', () => ({
|
||||
useContactFilterContext: () => ({
|
||||
filterTypes: { value: state.contactFilterTypes },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/components-next/filter/operators.js', () => ({
|
||||
useOperators: () => ({
|
||||
equalityOperators: { value: state.equalityOperators },
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('useAudienceFilterTypes', () => {
|
||||
beforeEach(() => {
|
||||
state.contactFilterTypes = [
|
||||
{ attributeKey: 'email', attributeModel: 'standard' },
|
||||
{ attributeKey: 'mystery', attributeModel: 'standard' },
|
||||
{ attributeKey: 'signed_up_on', attributeModel: 'customAttributes' },
|
||||
{ attributeKey: 'company_size', attributeModel: 'customAttributes' },
|
||||
];
|
||||
state.contactAttributes = [
|
||||
{ attributeKey: 'signed_up_on', attributeDisplayType: 'date' },
|
||||
];
|
||||
});
|
||||
|
||||
const build = () => useAudienceFilterTypes().filterTypes.value;
|
||||
|
||||
it('assembles grouped sections in order', () => {
|
||||
const keys = build().map(item => item.attributeKey ?? item.value);
|
||||
expect(keys).toEqual([
|
||||
'__group_contact',
|
||||
'email',
|
||||
'mystery',
|
||||
'__group_conversation',
|
||||
'hmac_verified',
|
||||
'browser_language',
|
||||
'conversation_language',
|
||||
'__group_custom',
|
||||
'signed_up_on',
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks headers as disabled options', () => {
|
||||
const headers = build().filter(item => item.value?.startsWith('__group_'));
|
||||
expect(headers).toHaveLength(3);
|
||||
expect(headers.every(header => header.disabled)).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes custom attributes that are not contact-model attributes', () => {
|
||||
const keys = build().map(item => item.attributeKey);
|
||||
expect(keys).not.toContain('company_size');
|
||||
});
|
||||
|
||||
it('omits the custom section when there are no contact custom attributes', () => {
|
||||
state.contactAttributes = [];
|
||||
const values = build().map(item => item.attributeKey ?? item.value);
|
||||
expect(values).not.toContain('__group_custom');
|
||||
expect(values).not.toContain('signed_up_on');
|
||||
});
|
||||
|
||||
it('assigns icons by attribute key with a default fallback', () => {
|
||||
const byKey = Object.fromEntries(
|
||||
build().map(item => [item.attributeKey, item.icon])
|
||||
);
|
||||
expect(byKey.email).toBe('i-lucide-mail');
|
||||
expect(byKey.mystery).toBe('i-lucide-tag');
|
||||
expect(byKey.signed_up_on).toBe('i-lucide-calendar');
|
||||
});
|
||||
|
||||
it('builds conversation options as searchSelects with equality operators', () => {
|
||||
const types = build();
|
||||
const hmac = types.find(item => item.attributeKey === 'hmac_verified');
|
||||
expect(hmac.inputType).toBe('searchSelect');
|
||||
expect(hmac.filterOperators).toEqual(state.equalityOperators);
|
||||
expect(hmac.options.map(option => option.id)).toEqual(['true', 'false']);
|
||||
|
||||
const browserLanguage = types.find(
|
||||
item => item.attributeKey === 'browser_language'
|
||||
);
|
||||
expect(browserLanguage.options).toEqual(languages);
|
||||
});
|
||||
});
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils';
|
||||
import DocumentDetails from './DocumentDetails.vue';
|
||||
|
||||
const { dispatch, getterValues } = vi.hoisted(() => ({
|
||||
dispatch: vi.fn(),
|
||||
getterValues: {
|
||||
'captainResponses/getUIFlags': { value: { fetchingList: false } },
|
||||
'captainResponses/getRecords': { value: [] },
|
||||
'captainResponses/getMeta': { value: { totalCount: 26, page: 1 } },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables/store', () => ({
|
||||
useStore: () => ({ dispatch }),
|
||||
useMapGetter: key => getterValues[key],
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables', () => ({ useAlert: vi.fn() }));
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: key => key }),
|
||||
}));
|
||||
|
||||
const captainDocument = {
|
||||
id: 42,
|
||||
name: 'FAQ source',
|
||||
external_link: 'https://example.com/docs',
|
||||
assistant: { id: 7 },
|
||||
content: 'Document content',
|
||||
pdf_document: false,
|
||||
};
|
||||
|
||||
const DialogStub = {
|
||||
name: 'Dialog',
|
||||
template: '<div><slot /></div>',
|
||||
};
|
||||
|
||||
const TabBarStub = {
|
||||
name: 'TabBar',
|
||||
template:
|
||||
'<button data-test="faq-tab" @click="$emit(\'tabChanged\', { key: \'faqs\' })" />',
|
||||
};
|
||||
|
||||
const PaginationFooterStub = {
|
||||
name: 'PaginationFooter',
|
||||
template:
|
||||
'<button data-test="next-page" @click="$emit(\'update:currentPage\', 2)" />',
|
||||
};
|
||||
|
||||
describe('DocumentDetails', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
dispatch.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('requests another FAQ page when the document has more than 25 FAQs', async () => {
|
||||
const wrapper = shallowMount(DocumentDetails, {
|
||||
props: { captainDocument },
|
||||
global: {
|
||||
directives: { dompurifyHtml: {} },
|
||||
stubs: {
|
||||
Dialog: DialogStub,
|
||||
TabBar: TabBarStub,
|
||||
PaginationFooter: PaginationFooterStub,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith('captainResponses/get', {
|
||||
page: 1,
|
||||
assistantId: 7,
|
||||
documentId: 42,
|
||||
});
|
||||
|
||||
await wrapper.get('[data-test="faq-tab"]').trigger('click');
|
||||
await wrapper.get('[data-test="next-page"]').trigger('click');
|
||||
|
||||
expect(dispatch).toHaveBeenLastCalledWith('captainResponses/get', {
|
||||
page: 2,
|
||||
assistantId: 7,
|
||||
documentId: 42,
|
||||
});
|
||||
});
|
||||
});
|
||||
+374
@@ -0,0 +1,374 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { messageTimestamp } from 'shared/helpers/timeHelper';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import MessageFormatter from 'shared/helpers/MessageFormatter';
|
||||
import {
|
||||
isSafeHttpLink,
|
||||
formatDocumentLink,
|
||||
getDocumentDisplayPath,
|
||||
} from 'shared/helpers/documentHelper';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
||||
import ResponseCard from '../../assistant/ResponseCard.vue';
|
||||
|
||||
const props = defineProps({
|
||||
captainDocument: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(['close']);
|
||||
const TAB_KEYS = {
|
||||
CONTENT: 'content',
|
||||
FAQS: 'faqs',
|
||||
};
|
||||
const RESPONSES_PER_PAGE = 25;
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const dialogRef = ref(null);
|
||||
const documentDetails = computed(() => props.captainDocument);
|
||||
const showRawContent = ref(false);
|
||||
const activeTabIndex = ref(0);
|
||||
|
||||
const uiFlags = useMapGetter('captainResponses/getUIFlags');
|
||||
const responses = useMapGetter('captainResponses/getRecords');
|
||||
const meta = useMapGetter('captainResponses/getMeta');
|
||||
const isFetching = computed(() => uiFlags.value.fetchingList);
|
||||
const totalCount = computed(() => meta.value.totalCount || 0);
|
||||
const currentPage = computed(() => meta.value.page || 1);
|
||||
const showPaginationFooter = computed(
|
||||
() => totalCount.value > RESPONSES_PER_PAGE
|
||||
);
|
||||
const documentContent = computed(() => documentDetails.value?.content?.trim());
|
||||
const documentContentLength = computed(
|
||||
() => documentContent.value?.length || 0
|
||||
);
|
||||
const isPdf = computed(() => documentDetails.value?.pdf_document);
|
||||
const displayUrl = computed(() => documentDetails.value?.display_url);
|
||||
const externalLink = computed(() => documentDetails.value?.external_link);
|
||||
const sourceHref = computed(() => displayUrl.value || externalLink.value);
|
||||
const hasSafeLink = computed(() => isSafeHttpLink(sourceHref.value));
|
||||
const displayLink = computed(() => {
|
||||
if (isPdf.value) return formatDocumentLink(externalLink.value);
|
||||
return getDocumentDisplayPath(displayUrl.value || externalLink.value);
|
||||
});
|
||||
const contentTabLabel = computed(() =>
|
||||
isPdf.value
|
||||
? t('CAPTAIN.DOCUMENTS.DETAILS.PDF_TAB')
|
||||
: t('CAPTAIN.DOCUMENTS.DETAILS.CONTENT_TAB')
|
||||
);
|
||||
const tabs = computed(() => [
|
||||
{ key: TAB_KEYS.CONTENT, label: contentTabLabel.value },
|
||||
{
|
||||
key: TAB_KEYS.FAQS,
|
||||
label: t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.TITLE'),
|
||||
count: totalCount.value,
|
||||
},
|
||||
]);
|
||||
const activeTabKey = computed(() => tabs.value[activeTabIndex.value]?.key);
|
||||
const isUnreadableContent = computed(() => {
|
||||
if (!documentContent.value) return false;
|
||||
|
||||
const content = documentContent.value;
|
||||
const sample = content.slice(0, 2000);
|
||||
const characters = Array.from(sample);
|
||||
const nonPrintableCharacters = characters.filter(character => {
|
||||
const charCode = character.charCodeAt(0);
|
||||
return (
|
||||
(charCode <= 31 && ![9, 10, 13].includes(charCode)) ||
|
||||
(charCode >= 127 && charCode <= 159)
|
||||
);
|
||||
});
|
||||
const nonPrintableRatio =
|
||||
nonPrintableCharacters.length / Math.max(characters.length, 1);
|
||||
const replacementCharacterRatio =
|
||||
characters.filter(character => character === '\uFFFD').length /
|
||||
Math.max(characters.length, 1);
|
||||
const hasPdfObjectMarkers =
|
||||
content.includes(' obj') &&
|
||||
content.includes(' endobj') &&
|
||||
content.includes(' stream');
|
||||
|
||||
return (
|
||||
content.startsWith('%PDF') ||
|
||||
hasPdfObjectMarkers ||
|
||||
nonPrintableRatio > 0.02 ||
|
||||
replacementCharacterRatio > 0.05
|
||||
);
|
||||
});
|
||||
const formattedDocumentContent = computed(() => {
|
||||
if (!documentContent.value || isUnreadableContent.value) return '';
|
||||
|
||||
const formatter = new MessageFormatter(documentContent.value);
|
||||
formatter.disableImageRendering();
|
||||
return formatter.formattedMessage;
|
||||
});
|
||||
const updatedAtLabel = computed(() => {
|
||||
if (!documentDetails.value?.updated_at) return null;
|
||||
return messageTimestamp(
|
||||
documentDetails.value.updated_at,
|
||||
'MMM d, yyyy h:mm a'
|
||||
);
|
||||
});
|
||||
const syncedAtLabel = computed(() => {
|
||||
if (!documentDetails.value?.last_synced_at) return null;
|
||||
return messageTimestamp(
|
||||
documentDetails.value.last_synced_at,
|
||||
'MMM d, yyyy h:mm a'
|
||||
);
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const handleCopyContent = async () => {
|
||||
try {
|
||||
await copyTextToClipboard(documentContent.value);
|
||||
useAlert(t('CAPTAIN.DOCUMENTS.DETAILS.COPY_SUCCESS'));
|
||||
} catch {
|
||||
useAlert(t('CAPTAIN.DOCUMENTS.DETAILS.COPY_ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabChanged = tab => {
|
||||
activeTabIndex.value = tabs.value.findIndex(item => item.key === tab.key);
|
||||
};
|
||||
|
||||
const fetchResponses = (page = 1) => {
|
||||
return store.dispatch('captainResponses/get', {
|
||||
page,
|
||||
assistantId: props.captainDocument.assistant.id,
|
||||
documentId: props.captainDocument.id,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePageChange = page => {
|
||||
fetchResponses(page);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchResponses();
|
||||
});
|
||||
defineExpose({ dialogRef });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="edit"
|
||||
:title="documentDetails.name || documentDetails.external_link"
|
||||
:description="t('CAPTAIN.DOCUMENTS.DETAILS.DESCRIPTION')"
|
||||
:show-cancel-button="false"
|
||||
:show-confirm-button="false"
|
||||
overflow-y-auto
|
||||
width="3xl"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div
|
||||
v-if="isFetching"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-6 min-h-48">
|
||||
<section class="flex flex-col gap-3">
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-medium uppercase text-n-slate-10">
|
||||
{{ t('CAPTAIN.DOCUMENTS.DETAILS.SOURCE') }}
|
||||
</span>
|
||||
<a
|
||||
v-if="hasSafeLink"
|
||||
:href="sourceHref"
|
||||
:title="sourceHref"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center min-w-0 gap-1 text-sm text-n-slate-12 hover:underline"
|
||||
>
|
||||
<Icon icon="i-lucide-external-link" class="size-3 shrink-0" />
|
||||
<span class="truncate">{{ displayLink }}</span>
|
||||
</a>
|
||||
<span v-else class="text-sm truncate text-n-slate-12">
|
||||
{{ displayLink }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-medium uppercase text-n-slate-10">
|
||||
{{ t('CAPTAIN.DOCUMENTS.DETAILS.GENERATED_FAQS') }}
|
||||
</span>
|
||||
<span class="text-sm text-n-slate-12">
|
||||
{{ totalCount }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-medium uppercase text-n-slate-10">
|
||||
{{ t('CAPTAIN.DOCUMENTS.DETAILS.LAST_UPDATED') }}
|
||||
</span>
|
||||
<span class="text-sm text-n-slate-12">
|
||||
{{
|
||||
syncedAtLabel ||
|
||||
updatedAtLabel ||
|
||||
t('CAPTAIN.DOCUMENTS.DETAILS.NOT_AVAILABLE')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<TabBar
|
||||
:tabs="tabs"
|
||||
:initial-active-tab="activeTabIndex"
|
||||
@tab-changed="handleTabChanged"
|
||||
/>
|
||||
|
||||
<div class="h-[32rem] overflow-y-auto">
|
||||
<section
|
||||
v-if="activeTabKey === TAB_KEYS.CONTENT"
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h4 class="text-sm font-medium text-n-slate-12">
|
||||
{{
|
||||
isPdf
|
||||
? t('CAPTAIN.DOCUMENTS.DETAILS.PDF_TITLE')
|
||||
: t('CAPTAIN.DOCUMENTS.DETAILS.CONTENT_TITLE')
|
||||
}}
|
||||
</h4>
|
||||
<span
|
||||
v-if="documentContent && !isPdf"
|
||||
class="text-xs text-n-slate-10"
|
||||
>
|
||||
{{
|
||||
t('CAPTAIN.DOCUMENTS.DETAILS.CHARACTER_COUNT', {
|
||||
count: documentContentLength.toLocaleString(),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="documentContent && !isPdf"
|
||||
class="flex flex-wrap items-center justify-end gap-4"
|
||||
>
|
||||
<Button
|
||||
:label="
|
||||
showRawContent
|
||||
? t('CAPTAIN.DOCUMENTS.DETAILS.VIEW_PREVIEW')
|
||||
: t('CAPTAIN.DOCUMENTS.DETAILS.VIEW_RAW')
|
||||
"
|
||||
sm
|
||||
slate
|
||||
link
|
||||
@click="showRawContent = !showRawContent"
|
||||
/>
|
||||
<Button
|
||||
:label="t('CAPTAIN.DOCUMENTS.DETAILS.COPY_CONTENT')"
|
||||
icon="i-lucide-copy"
|
||||
sm
|
||||
slate
|
||||
link
|
||||
@click="handleCopyContent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="isPdf"
|
||||
class="rounded-lg border border-n-weak bg-n-alpha-1 p-4 text-sm text-n-slate-11"
|
||||
>
|
||||
<p class="mb-3">
|
||||
{{ t('CAPTAIN.DOCUMENTS.DETAILS.PDF_DESCRIPTION') }}
|
||||
</p>
|
||||
<a
|
||||
v-if="hasSafeLink"
|
||||
:href="sourceHref"
|
||||
:title="sourceHref"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1 font-medium text-n-blue-11 hover:underline"
|
||||
>
|
||||
<Icon icon="i-ph-file-pdf" class="size-4" />
|
||||
{{ displayLink }}
|
||||
<Icon icon="i-lucide-external-link" class="size-3" />
|
||||
</a>
|
||||
<span v-else class="inline-flex items-center gap-1 text-n-slate-12">
|
||||
<Icon icon="i-ph-file-pdf" class="size-4" />
|
||||
{{ displayLink }}
|
||||
</span>
|
||||
</div>
|
||||
<template v-else-if="documentContent">
|
||||
<div
|
||||
v-if="isUnreadableContent && !showRawContent"
|
||||
class="rounded-lg border border-dashed border-n-weak p-4 text-sm text-n-slate-11"
|
||||
>
|
||||
{{ t('CAPTAIN.DOCUMENTS.DETAILS.UNREADABLE_CONTENT') }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="h-[26rem] overflow-y-auto rounded-lg border border-n-weak bg-n-alpha-1 p-4"
|
||||
>
|
||||
<pre
|
||||
v-if="showRawContent || isUnreadableContent"
|
||||
class="m-0 whitespace-pre-wrap break-words text-xs leading-5 text-n-slate-12"
|
||||
><code>{{ documentContent }}</code></pre>
|
||||
<div
|
||||
v-else
|
||||
v-dompurify-html="formattedDocumentContent"
|
||||
class="prose prose-sm max-w-none break-words text-n-slate-12 prose-p:my-2 prose-headings:mb-2 prose-headings:mt-4 prose-a:text-n-blue-11 prose-ul:my-2 prose-ol:my-2 prose-li:my-1 prose-img:hidden"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
class="rounded-lg border border-dashed border-n-weak p-4 text-sm text-n-slate-11"
|
||||
>
|
||||
{{ t('CAPTAIN.DOCUMENTS.DETAILS.EMPTY_CONTENT') }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="activeTabKey === TAB_KEYS.FAQS"
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<div v-if="responses.length" class="flex flex-col gap-3">
|
||||
<ResponseCard
|
||||
v-for="response in responses"
|
||||
:id="response.id"
|
||||
:key="response.id"
|
||||
:question="response.question"
|
||||
:status="response.status"
|
||||
:answer="response.answer"
|
||||
:assistant="response.assistant"
|
||||
:created-at="response.created_at"
|
||||
:updated-at="response.updated_at"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="rounded-lg border border-dashed border-n-weak p-4 text-sm text-n-slate-11"
|
||||
>
|
||||
{{ t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.EMPTY') }}
|
||||
</div>
|
||||
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10">
|
||||
<PaginationFooter
|
||||
:current-page="currentPage"
|
||||
:total-items="totalCount"
|
||||
:items-per-page="RESPONSES_PER_PAGE"
|
||||
class="!px-0"
|
||||
@update:current-page="handlePageChange"
|
||||
/>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
-71
@@ -1,71 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import ResponseCard from '../../assistant/ResponseCard.vue';
|
||||
const props = defineProps({
|
||||
captainDocument: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(['close']);
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const dialogRef = ref(null);
|
||||
|
||||
const uiFlags = useMapGetter('captainResponses/getUIFlags');
|
||||
const responses = useMapGetter('captainResponses/getRecords');
|
||||
const meta = useMapGetter('captainResponses/getMeta');
|
||||
const isFetching = computed(() => uiFlags.value.fetchingList);
|
||||
const totalCount = computed(() => meta.value.totalCount || 0);
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('captainResponses/get', {
|
||||
assistantId: props.captainDocument.assistant.id,
|
||||
documentId: props.captainDocument.id,
|
||||
});
|
||||
});
|
||||
defineExpose({ dialogRef });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="edit"
|
||||
:title="`${t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.TITLE')} (${totalCount})`"
|
||||
:description="t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.DESCRIPTION')"
|
||||
:show-cancel-button="false"
|
||||
:show-confirm-button="false"
|
||||
overflow-y-auto
|
||||
width="3xl"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div
|
||||
v-if="isFetching"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-3 min-h-48">
|
||||
<ResponseCard
|
||||
v-for="response in responses"
|
||||
:id="response.id"
|
||||
:key="response.id"
|
||||
:question="response.question"
|
||||
:status="response.status"
|
||||
:answer="response.answer"
|
||||
:assistant="response.assistant"
|
||||
:created-at="response.created_at"
|
||||
:updated-at="response.updated_at"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
+5
-2
@@ -88,7 +88,7 @@ const openCreateAssistantDialog = () => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="pt-5 pb-3 bg-n-alpha-3 backdrop-blur-[100px] outline outline-n-container outline-1 z-50 absolute w-[27.5rem] rounded-xl shadow-md flex flex-col gap-4"
|
||||
class="pt-5 bg-n-alpha-3 backdrop-blur-[100px] outline outline-n-container outline-1 z-50 absolute w-[27.5rem] max-h-96 rounded-xl shadow-md flex flex-col gap-4"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between gap-4 px-6 pb-3 border-b border-n-alpha-2"
|
||||
@@ -114,7 +114,10 @@ const openCreateAssistantDialog = () => {
|
||||
@click="openCreateAssistantDialog"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="assistants.length > 0" class="flex flex-col gap-2 px-4">
|
||||
<div
|
||||
v-if="assistants.length > 0"
|
||||
class="flex flex-col flex-1 min-h-0 gap-2 px-4 pb-3 overflow-y-auto overscroll-contain"
|
||||
>
|
||||
<Button
|
||||
v-for="assistant in assistants"
|
||||
:key="assistant.id"
|
||||
|
||||
@@ -15,6 +15,7 @@ import { validateSingleFilter } from 'dashboard/helper/validations.js';
|
||||
const { filterTypes } = defineProps({
|
||||
showQueryOperator: { type: Boolean, default: false },
|
||||
filterTypes: { type: Array, required: true },
|
||||
searchableAttributes: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['remove']);
|
||||
@@ -199,6 +200,7 @@ defineExpose({ validate, resetValidation });
|
||||
v-model="attributeKey"
|
||||
variant="faded"
|
||||
:options="filterTypes"
|
||||
:searchable="searchableAttributes"
|
||||
@update:model-value="resetModelOnAttributeKeyChange"
|
||||
/>
|
||||
<FilterSelect
|
||||
|
||||
@@ -165,16 +165,6 @@ export function useContactFilterContext() {
|
||||
filterOperators: dateOperators.value,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONTACT_ATTRIBUTES.REFERER,
|
||||
value: CONTACT_ATTRIBUTES.REFERER,
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.REFERER_LINK'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.REFERER_LINK'),
|
||||
inputType: 'plainText',
|
||||
dataType: 'text',
|
||||
filterOperators: containmentOperators.value,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONTACT_ATTRIBUTES.BLOCKED,
|
||||
value: CONTACT_ATTRIBUTES.BLOCKED,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useElementBounding, useWindowSize } from '@vueuse/core';
|
||||
import DropdownContainer from 'next/dropdown-menu/base/DropdownContainer.vue';
|
||||
import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
|
||||
@@ -7,6 +8,7 @@ import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
|
||||
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
|
||||
|
||||
import Button from 'next/button/Button.vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
// [{label, icon, value}]
|
||||
const props = defineProps({
|
||||
@@ -30,6 +32,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
searchable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const selected = defineModel({
|
||||
@@ -37,9 +43,21 @@ const selected = defineModel({
|
||||
required: true,
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const searchQuery = ref('');
|
||||
const triggerRef = ref(null);
|
||||
const dropdownRef = ref(null);
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
const query = searchQuery.value.trim().toLowerCase();
|
||||
if (!props.searchable || !query) return props.options;
|
||||
return props.options.filter(
|
||||
option =>
|
||||
!option.disabled && (option.label || '').toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
const { top } = useElementBounding(triggerRef);
|
||||
const { height } = useWindowSize();
|
||||
const { height: dropdownHeight } = useElementBounding(dropdownRef);
|
||||
@@ -65,6 +83,12 @@ const dropdownPosition = computed(() => {
|
||||
|
||||
const updateSelected = newValue => {
|
||||
selected.value = newValue;
|
||||
searchQuery.value = '';
|
||||
};
|
||||
|
||||
const handleTriggerClick = toggle => {
|
||||
searchQuery.value = '';
|
||||
toggle();
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -81,7 +105,7 @@ const updateSelected = newValue => {
|
||||
:icon="iconToRender"
|
||||
:trailing-icon="selectedOption.icon ? false : true"
|
||||
:label="label || (hideLabel ? null : selectedOption.label)"
|
||||
@click="toggle"
|
||||
@click="handleTriggerClick(toggle)"
|
||||
/>
|
||||
</slot>
|
||||
</template>
|
||||
@@ -91,8 +115,17 @@ const updateSelected = newValue => {
|
||||
:class="dropdownPosition"
|
||||
strong
|
||||
>
|
||||
<div v-if="searchable" class="relative">
|
||||
<Icon class="absolute size-4 left-2 top-2" icon="i-lucide-search" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
v-focus
|
||||
class="w-full p-1.5 pl-8 rounded-lg text-n-slate-11 bg-n-alpha-1"
|
||||
:placeholder="t('FILTER.SEARCH_PLACEHOLDER')"
|
||||
/>
|
||||
</div>
|
||||
<DropdownSection class="[&>ul]:max-h-72">
|
||||
<template v-for="option in options" :key="option.value">
|
||||
<template v-for="option in filteredOptions" :key="option.value">
|
||||
<li
|
||||
v-if="option.disabled"
|
||||
class="px-2 py-1.5 text-xs font-medium text-n-slate-10 select-none"
|
||||
@@ -106,6 +139,12 @@ const updateSelected = newValue => {
|
||||
@click="updateSelected(option.value)"
|
||||
/>
|
||||
</template>
|
||||
<li
|
||||
v-if="searchable && !filteredOptions.length"
|
||||
class="px-2 py-1.5 text-sm text-n-slate-10 select-none"
|
||||
>
|
||||
{{ t('FILTER.NO_RESULTS') }}
|
||||
</li>
|
||||
</DropdownSection>
|
||||
</DropdownBody>
|
||||
</DropdownContainer>
|
||||
|
||||
@@ -85,6 +85,13 @@ const hasFilteredUnreadCounts = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const hasDataImport = computed(() => {
|
||||
return isFeatureEnabledonAccount.value(
|
||||
accountId.value,
|
||||
FEATURE_FLAGS.DATA_IMPORT
|
||||
);
|
||||
});
|
||||
|
||||
const fetchConversationUnreadCounts = ([currentAccountId, isEnabled]) => {
|
||||
if (!currentAccountId) return;
|
||||
|
||||
@@ -856,6 +863,16 @@ const menuItems = computed(() => {
|
||||
icon: 'i-lucide-blocks',
|
||||
to: accountScopedRoute('settings_applications'),
|
||||
},
|
||||
...(hasDataImport.value
|
||||
? [
|
||||
{
|
||||
name: 'Settings Data',
|
||||
label: t('SIDEBAR.DATA'),
|
||||
icon: 'i-lucide-database',
|
||||
to: accountScopedRoute('settings_data_imports'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: 'Settings Audit Logs',
|
||||
label: t('SIDEBAR.AUDIT_LOGS'),
|
||||
|
||||
@@ -7,7 +7,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
|
||||
const { modalType, closeOnBackdropClick, onClose } = defineProps({
|
||||
closeOnBackdropClick: { type: Boolean, default: true },
|
||||
showCloseButton: { type: Boolean, default: true },
|
||||
onClose: { type: Function, required: true },
|
||||
onClose: { type: Function, default: null },
|
||||
fullWidth: { type: Boolean, default: false },
|
||||
modalType: { type: String, default: 'centered' },
|
||||
size: { type: String, default: '' },
|
||||
@@ -35,7 +35,7 @@ const handleMouseDown = () => {
|
||||
const close = () => {
|
||||
show.value = false;
|
||||
emit('close');
|
||||
onClose();
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { DirectUpload } from 'activestorage';
|
||||
import { setDirectUploadAuthHeaders } from 'dashboard/helper/directUploadsHelper';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { getMaxUploadSizeByChannel } from '@chatwoot/utils';
|
||||
import {
|
||||
@@ -21,7 +22,6 @@ export const useFileUpload = ({ inbox, attachFile, isPrivateNote = false }) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const globalConfig = useMapGetter('globalConfig/get');
|
||||
|
||||
@@ -78,10 +78,7 @@ export const useFileUpload = ({ inbox, attachFile, isPrivateNote = false }) => {
|
||||
`/api/v1/accounts/${accountId.value}/conversations/${currentChat.value.id}/direct_uploads`,
|
||||
{
|
||||
directUploadWillCreateBlobWithXHR: xhr => {
|
||||
xhr.setRequestHeader(
|
||||
'api_access_token',
|
||||
currentUser.value.access_token
|
||||
);
|
||||
setDirectUploadAuthHeaders(xhr);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -8,9 +8,12 @@ export const FEATURE_FLAGS = {
|
||||
CAMPAIGNS: 'campaigns',
|
||||
WHATSAPP_CAMPAIGNS: 'whatsapp_campaign',
|
||||
WHATSAPP_MANUAL_TRANSFER: 'whatsapp_manual_transfer',
|
||||
WHATSAPP_RECONFIGURE: 'whatsapp_reconfigure',
|
||||
CANNED_RESPONSES: 'canned_responses',
|
||||
CRM: 'crm',
|
||||
CUSTOM_ATTRIBUTES: 'custom_attributes',
|
||||
DATA_IMPORT: 'data_import',
|
||||
API_AND_WEBHOOKS: 'api_and_webhooks',
|
||||
INBOX_MANAGEMENT: 'inbox_management',
|
||||
INTEGRATIONS: 'integrations',
|
||||
LABELS: 'labels',
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// Powers the "unpublished changes" preview: marks what changed between the live
|
||||
// article and the draft — word by word in the title, block by block in the body.
|
||||
|
||||
import MarkdownIt from 'markdown-it';
|
||||
|
||||
// Matches the public renderer (CommonMark, no typographer). True when two
|
||||
// markdown strings render the same — so blank-line/spacing-only edits don't count,
|
||||
// but real changes (code indentation, smart quotes, width markers) do.
|
||||
const commonmark = MarkdownIt('commonmark');
|
||||
export const rendersIdentically = (a, b) =>
|
||||
commonmark.render(a ?? '') === commonmark.render(b ?? '');
|
||||
|
||||
const INS_CLASS = '!bg-n-teal-5 !text-n-teal-12 !no-underline rounded px-0.5';
|
||||
const DEL_CLASS = '!bg-n-ruby-5 !text-n-ruby-12 !line-through rounded px-0.5';
|
||||
|
||||
// Detailed compare gets slow on huge texts; past this, show all old as removed
|
||||
// and all new as added.
|
||||
const MAX_DIFF_TOKENS = 2000;
|
||||
|
||||
const tokenizeWords = value => (value || '').match(/\S+/g) || [];
|
||||
|
||||
const escapeHtml = value =>
|
||||
value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
// Compares two lists in order and reports what's the same (`equal`), removed
|
||||
// (`del`) or added (`ins`), keeping as much unchanged as possible. `keyOf` says
|
||||
// how to compare items (title passes words, body passes blocks).
|
||||
const diffSequence = (a, b, keyOf = item => item) => {
|
||||
const n = a.length;
|
||||
const m = b.length;
|
||||
if (n > MAX_DIFF_TOKENS || m > MAX_DIFF_TOKENS) {
|
||||
return [
|
||||
...a.map(item => ({ type: 'del', item })),
|
||||
...b.map(item => ({ type: 'ins', item })),
|
||||
];
|
||||
}
|
||||
|
||||
const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
||||
for (let i = n - 1; i >= 0; i -= 1) {
|
||||
for (let j = m - 1; j >= 0; j -= 1) {
|
||||
dp[i][j] =
|
||||
keyOf(a[i]) === keyOf(b[j])
|
||||
? dp[i + 1][j + 1] + 1
|
||||
: Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
const ops = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (keyOf(a[i]) === keyOf(b[j])) {
|
||||
ops.push({ type: 'equal', item: a[i] });
|
||||
i += 1;
|
||||
j += 1;
|
||||
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
||||
ops.push({ type: 'del', item: a[i] });
|
||||
i += 1;
|
||||
} else {
|
||||
ops.push({ type: 'ins', item: b[j] });
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
while (i < n) {
|
||||
ops.push({ type: 'del', item: a[i] });
|
||||
i += 1;
|
||||
}
|
||||
while (j < m) {
|
||||
ops.push({ type: 'ins', item: b[j] });
|
||||
j += 1;
|
||||
}
|
||||
return ops;
|
||||
};
|
||||
|
||||
const wrapDiff = {
|
||||
ins: text => `<ins class="${INS_CLASS}">${text}</ins>`,
|
||||
del: text => `<del class="${DEL_CLASS}">${text}</del>`,
|
||||
};
|
||||
|
||||
// Builds the highlighted title. Compares whole words (not single spaces) so
|
||||
// repeated words/spaces don't make the highlights jump around, then rejoins
|
||||
// with single spaces — a run of added/removed words shares one <ins>/<del> tag.
|
||||
export const renderInlineDiff = (oldValue, newValue) => {
|
||||
const ops = diffSequence(tokenizeWords(oldValue), tokenizeWords(newValue));
|
||||
|
||||
const segments = [];
|
||||
let run = [];
|
||||
let runType = null;
|
||||
const flushRun = () => {
|
||||
if (!run.length) return;
|
||||
const text = run.map(escapeHtml).join(' ');
|
||||
segments.push(wrapDiff[runType] ? wrapDiff[runType](text) : text);
|
||||
run = [];
|
||||
};
|
||||
|
||||
ops.forEach(({ type, item }) => {
|
||||
if (type !== runType) flushRun();
|
||||
runType = type;
|
||||
run.push(item);
|
||||
});
|
||||
flushRun();
|
||||
|
||||
return segments.join(' ');
|
||||
};
|
||||
|
||||
// A fenced code block opener: ``` or ~~~, indented up to 3 spaces (CommonMark).
|
||||
const FENCE_RE = /^ {0,3}(```|~~~)/;
|
||||
// A list item marker: -, *, + or "1." / "1)", indented up to 3 spaces.
|
||||
const LIST_ITEM_RE = /^ {0,3}(?:[-*+]|\d{1,9}[.)])(?:\s|$)/;
|
||||
|
||||
// Split on blank lines so each paragraph, heading or list compares as one piece.
|
||||
// Blank lines inside a fenced code block, or between items of the same list, are
|
||||
// content — splitting there would tear a code block or list apart and render it
|
||||
// with broken structure (orphaned <li>/<p>), so we keep those together.
|
||||
const splitBlocks = text => {
|
||||
const lines = (text || '').split('\n');
|
||||
const blocks = [];
|
||||
let buffer = [];
|
||||
let fence = null;
|
||||
let inList = false;
|
||||
|
||||
const flush = () => {
|
||||
const block = buffer.join('\n');
|
||||
if (block.trim()) blocks.push(block);
|
||||
buffer = [];
|
||||
inList = false;
|
||||
};
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const marker = line.match(FENCE_RE)?.[1];
|
||||
if (marker && !fence) fence = marker;
|
||||
else if (fence && line.trimStart().startsWith(fence)) fence = null;
|
||||
|
||||
if (fence) {
|
||||
buffer.push(line);
|
||||
return;
|
||||
}
|
||||
|
||||
if (LIST_ITEM_RE.test(line)) inList = true;
|
||||
|
||||
if (line.trim() !== '') {
|
||||
buffer.push(line);
|
||||
return;
|
||||
}
|
||||
|
||||
// Blank line: keep it when the current list continues on the next non-blank
|
||||
// line (another item or an indented continuation); otherwise end the block.
|
||||
const next = lines.slice(index + 1).find(other => other.trim() !== '');
|
||||
if (inList && next && (LIST_ITEM_RE.test(next) || /^\s/.test(next))) {
|
||||
buffer.push(line);
|
||||
} else {
|
||||
flush();
|
||||
}
|
||||
});
|
||||
|
||||
flush();
|
||||
return blocks;
|
||||
};
|
||||
|
||||
const BLOCK_TYPE = { equal: 'equal', del: 'removed', ins: 'added' };
|
||||
|
||||
// Diffs the body block by block. Blocks match when they render to the same HTML
|
||||
// (the check staging uses), so only edits that change the page show as a diff.
|
||||
export const buildDiffBlocks = (oldText, newText) => {
|
||||
const toBlocks = text =>
|
||||
splitBlocks(text).map(md => ({ md, key: commonmark.render(md) }));
|
||||
const ops = diffSequence(toBlocks(oldText), toBlocks(newText), b => b.key);
|
||||
return ops.map(op => ({ type: BLOCK_TYPE[op.type], md: op.item.md }));
|
||||
};
|
||||
|
||||
export const hasPendingChanges = article =>
|
||||
article?.draftTitle != null || article?.draftContent != null;
|
||||
@@ -0,0 +1,19 @@
|
||||
import Auth from 'dashboard/api/auth';
|
||||
|
||||
export const setDirectUploadAuthHeaders = xhr => {
|
||||
const {
|
||||
'access-token': accessToken,
|
||||
'token-type': tokenType,
|
||||
client,
|
||||
expiry,
|
||||
uid,
|
||||
} = Auth.getAuthData() || {};
|
||||
|
||||
if (!accessToken) return;
|
||||
|
||||
xhr.setRequestHeader('access-token', accessToken);
|
||||
xhr.setRequestHeader('token-type', tokenType);
|
||||
xhr.setRequestHeader('client', client);
|
||||
xhr.setRequestHeader('expiry', expiry);
|
||||
xhr.setRequestHeader('uid', uid);
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
import {
|
||||
renderInlineDiff,
|
||||
buildDiffBlocks,
|
||||
hasPendingChanges,
|
||||
rendersIdentically,
|
||||
} from '../articleDiffHelper';
|
||||
|
||||
describe('articleDiffHelper', () => {
|
||||
describe('renderInlineDiff', () => {
|
||||
it('returns the text unchanged when there is no difference', () => {
|
||||
const result = renderInlineDiff('hello world', 'hello world');
|
||||
expect(result).toBe('hello world');
|
||||
expect(result).not.toContain('<ins');
|
||||
expect(result).not.toContain('<del');
|
||||
});
|
||||
|
||||
it('wraps inserted words in <ins>', () => {
|
||||
const result = renderInlineDiff('hello', 'hello there');
|
||||
expect(result).toContain('hello');
|
||||
expect(result).toContain('<ins');
|
||||
expect(result).toContain('there');
|
||||
});
|
||||
|
||||
it('wraps removed words in <del>', () => {
|
||||
const result = renderInlineDiff('hello there', 'hello');
|
||||
expect(result).toContain('<del');
|
||||
expect(result).toContain('there');
|
||||
});
|
||||
|
||||
it('keeps a single removal contiguous when a word repeats', () => {
|
||||
const result = renderInlineDiff(
|
||||
'How to use Agent bots?',
|
||||
'How How to Agent bots?'
|
||||
);
|
||||
expect(result).toBe(
|
||||
'How <ins class="!bg-n-teal-5 !text-n-teal-12 !no-underline rounded px-0.5">How</ins> to <del class="!bg-n-ruby-5 !text-n-ruby-12 !line-through rounded px-0.5">use</del> Agent bots?'
|
||||
);
|
||||
});
|
||||
|
||||
it('escapes markup when diffing plain text', () => {
|
||||
const result = renderInlineDiff('a', 'a <b>');
|
||||
expect(result).toContain('<b>');
|
||||
expect(result).not.toContain('<b>');
|
||||
});
|
||||
|
||||
it('treats a cleared empty string as a full deletion', () => {
|
||||
const result = renderInlineDiff('gone', '');
|
||||
expect(result).toContain('<del');
|
||||
expect(result).toContain('gone');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDiffBlocks', () => {
|
||||
it('passes an unchanged block through as equal', () => {
|
||||
const blocks = buildDiffBlocks('same para', 'same para');
|
||||
expect(blocks).toEqual([{ type: 'equal', md: 'same para' }]);
|
||||
});
|
||||
|
||||
it('marks an appended block as added', () => {
|
||||
const blocks = buildDiffBlocks('a', 'a\n\nb');
|
||||
expect(blocks).toContainEqual({ type: 'equal', md: 'a' });
|
||||
expect(blocks).toContainEqual({ type: 'added', md: 'b' });
|
||||
});
|
||||
|
||||
it('marks a deleted block as removed', () => {
|
||||
const blocks = buildDiffBlocks('a\n\nb', 'a');
|
||||
expect(blocks).toContainEqual({ type: 'removed', md: 'b' });
|
||||
});
|
||||
|
||||
it('emits the old block then the new block for a reworded section', () => {
|
||||
const blocks = buildDiffBlocks('hello world', 'hello there');
|
||||
expect(blocks).toEqual([
|
||||
{ type: 'removed', md: 'hello world' },
|
||||
{ type: 'added', md: 'hello there' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a fenced code block whole when it contains blank lines', () => {
|
||||
const code = '```\nline one\n\nline two\n```';
|
||||
const blocks = buildDiffBlocks(code, code);
|
||||
expect(blocks).toEqual([{ type: 'equal', md: code }]);
|
||||
});
|
||||
|
||||
it('diffs an edited code block as one whole removed + added block', () => {
|
||||
const live = '```\ncode line\n```';
|
||||
const draft = '```\ncode line\n\nsd\n```';
|
||||
const blocks = buildDiffBlocks(live, draft);
|
||||
expect(blocks).toContainEqual({ type: 'removed', md: live });
|
||||
expect(blocks).toContainEqual({ type: 'added', md: draft });
|
||||
});
|
||||
|
||||
it('surfaces whitespace edits that change the rendered output', () => {
|
||||
expect(
|
||||
buildDiffBlocks('```\nx\n```', '```\n x\n```').some(
|
||||
block => block.type !== 'equal'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
buildDiffBlocks('line one\nline two', 'line one \nline two').some(
|
||||
block => block.type !== 'equal'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('surfaces an indented code block turning into a paragraph', () => {
|
||||
const blocks = buildDiffBlocks(
|
||||
' curl example.com',
|
||||
'curl example.com'
|
||||
);
|
||||
expect(blocks).toContainEqual({
|
||||
type: 'removed',
|
||||
md: ' curl example.com',
|
||||
});
|
||||
expect(blocks).toContainEqual({ type: 'added', md: 'curl example.com' });
|
||||
});
|
||||
|
||||
it('keeps spacing the renderer ignores as equal', () => {
|
||||
const blocks = buildDiffBlocks('a\nb', 'a \nb');
|
||||
expect(blocks.every(block => block.type === 'equal')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps a loose list with item descriptions as one block', () => {
|
||||
const list =
|
||||
'1. **One**\n\n First item.\n\n2. **Two**\n\n Second item.';
|
||||
const blocks = buildDiffBlocks(list, list);
|
||||
expect(blocks).toEqual([{ type: 'equal', md: list }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rendersIdentically', () => {
|
||||
it('ignores blank-line / empty-paragraph differences', () => {
|
||||
expect(rendersIdentically('a\n\nb', 'a\n\n\nb')).toBe(true);
|
||||
expect(rendersIdentically('hello', 'hello\n\n')).toBe(true);
|
||||
});
|
||||
|
||||
it('counts code-block indentation changes', () => {
|
||||
expect(rendersIdentically('```\n x\n```', '```\nx\n```')).toBe(false);
|
||||
});
|
||||
|
||||
it('counts smart vs straight quotes (no typographer)', () => {
|
||||
expect(rendersIdentically('"hi"', '“hi”')).toBe(false);
|
||||
});
|
||||
|
||||
it('counts real text changes', () => {
|
||||
expect(rendersIdentically('hello world', 'hello there')).toBe(false);
|
||||
});
|
||||
|
||||
it('treats nullish input as empty', () => {
|
||||
expect(rendersIdentically(null, '')).toBe(true);
|
||||
expect(rendersIdentically(undefined, 'x')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasPendingChanges', () => {
|
||||
it('is true when a draft title or content is staged', () => {
|
||||
expect(hasPendingChanges({ draftContent: 'edit' })).toBe(true);
|
||||
expect(hasPendingChanges({ draftTitle: 'edit' })).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a cleared empty-string draft as a pending change', () => {
|
||||
expect(hasPendingChanges({ draftTitle: '' })).toBe(true);
|
||||
});
|
||||
|
||||
it('is false with no draft columns', () => {
|
||||
expect(hasPendingChanges({ title: 'live' })).toBe(false);
|
||||
expect(hasPendingChanges({})).toBe(false);
|
||||
expect(hasPendingChanges(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { setDirectUploadAuthHeaders } from '../directUploadsHelper';
|
||||
import Auth from 'dashboard/api/auth';
|
||||
|
||||
vi.mock('dashboard/api/auth', () => ({
|
||||
default: { getAuthData: vi.fn() },
|
||||
}));
|
||||
|
||||
describe('setDirectUploadAuthHeaders', () => {
|
||||
const buildXhr = () => ({ setRequestHeader: vi.fn() });
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('sets the five session auth headers from the auth cookie', () => {
|
||||
Auth.getAuthData.mockReturnValue({
|
||||
'access-token': 'token-123',
|
||||
'token-type': 'Bearer',
|
||||
client: 'client-123',
|
||||
expiry: '9999',
|
||||
uid: 'agent@example.com',
|
||||
});
|
||||
const xhr = buildXhr();
|
||||
|
||||
setDirectUploadAuthHeaders(xhr);
|
||||
|
||||
expect(xhr.setRequestHeader).toHaveBeenCalledTimes(5);
|
||||
expect(xhr.setRequestHeader).toHaveBeenCalledWith(
|
||||
'access-token',
|
||||
'token-123'
|
||||
);
|
||||
expect(xhr.setRequestHeader).toHaveBeenCalledWith('token-type', 'Bearer');
|
||||
expect(xhr.setRequestHeader).toHaveBeenCalledWith('client', 'client-123');
|
||||
expect(xhr.setRequestHeader).toHaveBeenCalledWith('expiry', '9999');
|
||||
expect(xhr.setRequestHeader).toHaveBeenCalledWith(
|
||||
'uid',
|
||||
'agent@example.com'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not set any header when there is no auth data', () => {
|
||||
Auth.getAuthData.mockReturnValue(false);
|
||||
const xhr = buildXhr();
|
||||
|
||||
setDirectUploadAuthHeaders(xhr);
|
||||
|
||||
expect(xhr.setRequestHeader).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not set any header when the access token is missing', () => {
|
||||
Auth.getAuthData.mockReturnValue({
|
||||
client: 'client-123',
|
||||
uid: 'agent@example.com',
|
||||
});
|
||||
const xhr = buildXhr();
|
||||
|
||||
setDirectUploadAuthHeaders(xhr);
|
||||
|
||||
expect(xhr.setRequestHeader).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,8 @@
|
||||
"OR": "OR"
|
||||
},
|
||||
"INPUT_PLACEHOLDER": "Enter value",
|
||||
"SEARCH_PLACEHOLDER": "Search…",
|
||||
"NO_RESULTS": "No matches",
|
||||
"CONTACT_SEARCH_PLACEHOLDER": "Search contacts",
|
||||
"CONTACT_FALLBACK": "Contact #{id}",
|
||||
"OPERATOR_LABELS": {
|
||||
|
||||
@@ -533,6 +533,8 @@
|
||||
"PUBLISHED": "Published",
|
||||
"ARCHIVED": "Archived"
|
||||
},
|
||||
"PENDING_EDITS": "Unpublished edits",
|
||||
"PENDING_EDITS_TOOLTIP": "This published article has unpublished edits",
|
||||
"CATEGORY": {
|
||||
"UNCATEGORISED": "Uncategorised"
|
||||
}
|
||||
@@ -616,6 +618,8 @@
|
||||
"DELETE": "Delete",
|
||||
"STATUS_SUCCESS": "Articles updated successfully",
|
||||
"STATUS_ERROR": "Failed to update articles",
|
||||
"STATUS_SKIPPED": "1 article with unpublished edits was skipped — open it to publish or discard. | {count} articles with unpublished edits were skipped — open them to publish or discard.",
|
||||
"STATUS_SKIPPED_ALL": "These articles have unpublished edits — open each to publish or discard.",
|
||||
"CATEGORY_SUCCESS": "Articles moved successfully",
|
||||
"CATEGORY_ERROR": "Failed to move articles",
|
||||
"DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles",
|
||||
@@ -763,10 +767,30 @@
|
||||
},
|
||||
"PREVIEW": "Preview",
|
||||
"PUBLISH": "Publish",
|
||||
"PUBLISH_CHANGES": "Publish changes",
|
||||
"PUBLISH_CHANGES_SUCCESS": "Changes published successfully",
|
||||
"PUBLISH_CHANGES_ERROR": "Could not publish changes",
|
||||
"SAVE_IN_PROGRESS": "Still saving your latest changes — please try again in a moment.",
|
||||
"DISCARD_CHANGES": "Discard changes",
|
||||
"DISCARD_CHANGES_SUCCESS": "Changes discarded",
|
||||
"DISCARD_CHANGES_ERROR": "Could not discard changes",
|
||||
"PENDING_CHANGES": "Pending changes",
|
||||
"VIEW_CHANGES": "View unpublished changes",
|
||||
"DRAFT": "Draft",
|
||||
"ARCHIVE": "Archive",
|
||||
"BACK_TO_ARTICLES": "Back to articles"
|
||||
},
|
||||
"PENDING_CHANGES_POPOVER": {
|
||||
"TITLE": "Unpublished changes",
|
||||
"DESCRIPTION": "This article has draft changes that aren't live yet. Apply them before changing the status, or discard them?",
|
||||
"APPLY": "Apply changes",
|
||||
"DISCARD": "Discard changes"
|
||||
},
|
||||
"DIFF_DIALOG": {
|
||||
"TITLE": "Unpublished changes",
|
||||
"DESCRIPTION": "Compare your draft against the version that's currently live.",
|
||||
"TITLE_LABEL": "Title"
|
||||
},
|
||||
"EDIT_ARTICLE": {
|
||||
"MORE_PROPERTIES": "More properties",
|
||||
"UNCATEGORIZED": "Uncategorized",
|
||||
|
||||
@@ -59,7 +59,6 @@
|
||||
"ERROR_AUTH": "There was an error connecting to Instagram, please try again",
|
||||
"NEW_INBOX_SUGGESTION": "This Instagram account was previously linked to a different inbox and has now been migrated here. All new messages will appear here. The old inbox will no longer be able to send or receive messages for this account.",
|
||||
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You won’t be able to send/receive Instagram messages from this inbox anymore.",
|
||||
"RESTRICTED_WARNING": "Instagram inbox creation is temporarily unavailable due to current Instagram platform restrictions. We’ll restore support as soon as possible.",
|
||||
"SETTINGS_RESTRICTED_WARNING": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
|
||||
"STATUS_LINK": "View status update"
|
||||
},
|
||||
@@ -848,8 +847,8 @@
|
||||
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Templates sync initiated successfully. It may take a couple of minutes to update.",
|
||||
"WHATSAPP_MANUAL_MIGRATION": {
|
||||
"BANNER": {
|
||||
"TITLE": "WhatsApp setup action required",
|
||||
"DESCRIPTION": "Meta restrictions are affecting WhatsApp setup and management features. Reconnect this inbox manually to keep your WhatsApp configuration up to date.",
|
||||
"TITLE": "Manual setup recommended",
|
||||
"DESCRIPTION": "This inbox connects through the shared Meta app used for embedded signup, which recent Meta restrictions have affected. To avoid similar issues in the future, we recommend reconnecting it with your own Meta app.",
|
||||
"START": "Start manual migration",
|
||||
"GUIDE": "View guide"
|
||||
},
|
||||
@@ -857,8 +856,8 @@
|
||||
"EYEBROW": "WhatsApp manual migration",
|
||||
"TITLE": "Reconnect WhatsApp inbox",
|
||||
"CLOSE": "Close",
|
||||
"ACTION_REQUIRED_TITLE": "Action required for this WhatsApp inbox",
|
||||
"ACTION_REQUIRED_DESCRIPTION": "Meta restrictions are affecting setup and management features. This guided flow updates the WhatsApp API connection without creating a new inbox.",
|
||||
"ACTION_REQUIRED_TITLE": "Reconnect with your own Meta app",
|
||||
"ACTION_REQUIRED_DESCRIPTION": "Inboxes connected through your own Meta app are not affected by restrictions on the shared embedded signup app. This guided flow updates the WhatsApp API connection without creating a new inbox.",
|
||||
"GUIDE_LINK": "Open the manual setup guide",
|
||||
"PRESERVED_TITLE": "Preserved",
|
||||
"PRESERVED_DESCRIPTION": "Conversations, contacts, collaborators, routing, business hours, and inbox settings.",
|
||||
|
||||
@@ -31,6 +31,13 @@
|
||||
"WEBHOOK": {
|
||||
"SUBSCRIBED_EVENTS": "Subscribed Events",
|
||||
"LEARN_MORE": "Learn more about webhooks",
|
||||
"PAYWALL": {
|
||||
"TITLE": "Webhooks are available on paid plans",
|
||||
"AVAILABLE_ON": "Use webhooks to receive real-time events from your Chatwoot account.",
|
||||
"UPGRADE_PROMPT": "Upgrade to the Startups, Business, or Enterprise plan to use webhooks.",
|
||||
"UPGRADE_NOW": "Upgrade now",
|
||||
"CANCEL_ANYTIME": "Change or cancel your plan anytime."
|
||||
},
|
||||
"SECRET": {
|
||||
"LABEL": "Secret",
|
||||
"COPY": "Copy secret to clipboard",
|
||||
@@ -602,6 +609,47 @@
|
||||
"ALLOW_MEMORIES": "Capture key details as memories from customer interactions.",
|
||||
"ALLOW_CITATIONS": "Include source citations in responses",
|
||||
"ALLOW_CONTACT_ATTRIBUTES": "Allow access to contact information"
|
||||
},
|
||||
"AUDIENCE": {
|
||||
"EVERYONE": {
|
||||
"LABEL": "Everyone",
|
||||
"DESC": "Captain responds to every conversation in connected inboxes."
|
||||
},
|
||||
"SPECIFIC": {
|
||||
"LABEL": "Specific audience",
|
||||
"DESC": "Captain responds only to customers who match the conditions below."
|
||||
},
|
||||
"ADD_CONDITION": "Add condition",
|
||||
"ADD_GROUP": "Add condition group",
|
||||
"EMPTY_TITLE": "No conditions yet",
|
||||
"EMPTY_BODY": "Add a condition to target specific customers, or a condition group to combine conditions with different and/or logic.",
|
||||
"MATCH_PREFIX": "Match",
|
||||
"MATCH_SUFFIX": "of the following",
|
||||
"MATCH_ALL": "all",
|
||||
"MATCH_ANY": "any",
|
||||
"GROUP_CONTACT": "Contact attributes",
|
||||
"GROUP_CONVERSATION": "Conversation",
|
||||
"GROUP_CUSTOM": "Custom attributes",
|
||||
"BROWSER_LANGUAGE": "Browser language",
|
||||
"CONVERSATION_LANGUAGE": "Conversation language",
|
||||
"LOGGED_IN": "Logged-in user",
|
||||
"LOGGED_IN_TRUE": "Yes",
|
||||
"LOGGED_IN_FALSE": "No"
|
||||
},
|
||||
"SCHEDULE": {
|
||||
"ALWAYS": {
|
||||
"LABEL": "Anytime",
|
||||
"DESC": "Captain responds to every conversation, regardless of time."
|
||||
},
|
||||
"BUSINESS_HOURS": {
|
||||
"LABEL": "During business hours",
|
||||
"DESC": "Captain responds only within each inbox's business hours."
|
||||
},
|
||||
"OUTSIDE_BUSINESS_HOURS": {
|
||||
"LABEL": "Outside business hours",
|
||||
"DESC": "Captain responds only when each inbox is closed."
|
||||
},
|
||||
"HINT": "Inboxes without business hours configured are always covered."
|
||||
}
|
||||
},
|
||||
"EDIT": {
|
||||
@@ -620,6 +668,14 @@
|
||||
"TITLE": "System settings",
|
||||
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
|
||||
},
|
||||
"AUDIENCE": {
|
||||
"TITLE": "Audience",
|
||||
"DESCRIPTION": "Choose whether this assistant responds to everyone or only to a specific audience."
|
||||
},
|
||||
"SCHEDULE": {
|
||||
"TITLE": "Schedule",
|
||||
"DESCRIPTION": "Choose when Captain replies. Outside this window, conversations are handed to your team."
|
||||
},
|
||||
"CONTROL_ITEMS": {
|
||||
"TITLE": "The Fun Stuff",
|
||||
"DESCRIPTION": "Add more control to the assistant. (a bit more visual like a story : Query guardrail → scenarios → output) Nudges user to actually utilise these.",
|
||||
@@ -811,6 +867,7 @@
|
||||
"DOCUMENTS": {
|
||||
"HEADER": "Documents",
|
||||
"ADD_NEW": "Create a new document",
|
||||
"FAQ_COUNT": "{n} FAQ | {n} FAQs",
|
||||
"SELECTED": "{count} selected",
|
||||
"SELECT_ALL": "Select all ({count})",
|
||||
"UNSELECT_ALL": "Unselect all ({count})",
|
||||
@@ -870,7 +927,27 @@
|
||||
},
|
||||
"RELATED_RESPONSES": {
|
||||
"TITLE": "Related FAQs",
|
||||
"DESCRIPTION": "These FAQs are generated directly from the document."
|
||||
"EMPTY": "No FAQs have been generated from this document yet."
|
||||
},
|
||||
"DETAILS": {
|
||||
"DESCRIPTION": "Review the crawled content and the FAQs generated from this source.",
|
||||
"SOURCE": "Source",
|
||||
"GENERATED_FAQS": "Generated FAQs",
|
||||
"LAST_UPDATED": "Last updated",
|
||||
"NOT_AVAILABLE": "Not available",
|
||||
"CONTENT_TAB": "Crawled content",
|
||||
"PDF_TAB": "PDF details",
|
||||
"CONTENT_TITLE": "Crawled content",
|
||||
"PDF_TITLE": "PDF file",
|
||||
"PDF_DESCRIPTION": "Review the original PDF source.",
|
||||
"CHARACTER_COUNT": "{count} characters extracted",
|
||||
"COPY_CONTENT": "Copy",
|
||||
"COPY_SUCCESS": "Crawled content copied to clipboard",
|
||||
"COPY_ERROR": "Could not copy crawled content",
|
||||
"VIEW_RAW": "View raw",
|
||||
"VIEW_PREVIEW": "View preview",
|
||||
"UNREADABLE_CONTENT": "Readable content could not be extracted from this document. You can view the raw extracted content.",
|
||||
"EMPTY_CONTENT": "No crawled content is available for this document yet."
|
||||
},
|
||||
"FORM_DESCRIPTION": "Enter the URL of the document to add it as a knowledge source and choose the assistant to associate it with.",
|
||||
"CREATE": {
|
||||
@@ -911,7 +988,7 @@
|
||||
},
|
||||
|
||||
"OPTIONS": {
|
||||
"VIEW_RELATED_RESPONSES": "View Related Responses",
|
||||
"VIEW_DETAILS": "View details",
|
||||
"SYNC_NOW": "Refresh now",
|
||||
"RETRY_SYNC": "Retry refresh",
|
||||
"DELETE_DOCUMENT": "Delete Document"
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"ACCESS_TOKEN": {
|
||||
"TITLE": "Access Token",
|
||||
"NOTE": "This token can be used if you are building an API based integration",
|
||||
"PAID_PLAN_NOTE": "API access tokens are available on paid plans.",
|
||||
"COPY": "Copy",
|
||||
"RESET": "Reset",
|
||||
"CONFIRM_RESET": "Are you sure?",
|
||||
@@ -340,6 +341,7 @@
|
||||
"NOTIFICATIONS": "Notifications",
|
||||
"CANNED_RESPONSES": "Canned Responses",
|
||||
"INTEGRATIONS": "Integrations",
|
||||
"DATA": "Data",
|
||||
"PROFILE_SETTINGS": "Profile Settings",
|
||||
"ACCOUNT_SETTINGS": "Account Settings",
|
||||
"APPLICATIONS": "Applications",
|
||||
@@ -411,6 +413,104 @@
|
||||
"CAPTAIN_AI": "Captain",
|
||||
"CONVERSATION_WORKFLOW": "Conversation Workflow"
|
||||
},
|
||||
"DATA_IMPORTS": {
|
||||
"HEADER": "Data",
|
||||
"DESCRIPTION": "Bring your existing contacts and past conversations into this account from another support tool. Each import runs in the background, so you can keep working while it finishes, track its progress, and review anything that was skipped along the way.",
|
||||
"LOADING": "Fetching imports",
|
||||
"DEFAULT_IMPORT_NAME": "Intercom import",
|
||||
"TABS": {
|
||||
"IMPORT": "Import",
|
||||
"EXPORT": "Export"
|
||||
},
|
||||
"TYPES": {
|
||||
"CONTACTS": "Contacts",
|
||||
"CONVERSATIONS": "Conversations",
|
||||
"MESSAGES": "Messages"
|
||||
},
|
||||
"DRAWER": {
|
||||
"TITLE": "New import",
|
||||
"SOURCE": "Source",
|
||||
"NAME": "Import name",
|
||||
"NAME_PLACEHOLDER": "July Intercom migration",
|
||||
"ACCESS_KEY": "Intercom access key",
|
||||
"ACCESS_KEY_PLACEHOLDER": "Paste your Intercom access key",
|
||||
"DATA_TYPES": "Data to import",
|
||||
"VALIDATING": "Validating access key...",
|
||||
"VALID_KEY": "Access key validated.",
|
||||
"INVALID_KEY": "Could not validate this access key.",
|
||||
"ACTIVE_IMPORT": "Wait for the active import to finish before starting another one.",
|
||||
"CANCEL": "Cancel",
|
||||
"IMPORT": "Import"
|
||||
},
|
||||
"EXPORT": {
|
||||
"TITLE": "Exports are on the way",
|
||||
"DESCRIPTION": "Export your contacts and conversations out of this account. This workflow is coming soon.",
|
||||
"COMING_SOON": "Coming soon"
|
||||
},
|
||||
"TABLE": {
|
||||
"TITLE": "Recent imports",
|
||||
"EMPTY": "No imports yet",
|
||||
"EMPTY_DESCRIPTION": "Start an import to bring your existing customer history into this account.",
|
||||
"NEW_IMPORT": "Import",
|
||||
"COUNT": "{count} imports",
|
||||
"UNNAMED": "Untitled import",
|
||||
"IMPORTED_COUNT": "{count} imported",
|
||||
"VIEW": "View import",
|
||||
"NAME": "Name",
|
||||
"TYPE": "Type",
|
||||
"STATUS": "Status",
|
||||
"IMPORTED": "Imported",
|
||||
"CREATED": "Created",
|
||||
"ABANDON": "Abandon"
|
||||
},
|
||||
"DETAIL": {
|
||||
"BACK": "Back to imports",
|
||||
"ERRORS": "Errors",
|
||||
"SKIP_LOGS": "Skip logs",
|
||||
"SOURCE": "Source",
|
||||
"IMPORT_TYPES": "Import types",
|
||||
"CREATED": "Created",
|
||||
"DURATION": "Duration",
|
||||
"INITIATED_BY": "Started by",
|
||||
"PROGRESS": "Import progress",
|
||||
"PROGRESS_WITH_TOTAL": "{imported} of {total} imported",
|
||||
"PROGRESS_WITHOUT_TOTAL": "{imported} imported",
|
||||
"PROGRESS_OF_TOTAL": "of {total} imported",
|
||||
"PROGRESS_IMPORTED": "imported",
|
||||
"LAST_UPDATED_TOOLTIP": "Last updated {time}",
|
||||
"NO_SKIP_LOGS": "No skipped or failed records recorded.",
|
||||
"DOWNLOAD_SKIP_LOGS": "Download CSV",
|
||||
"DOWNLOAD_ERROR_LOGS": "Download CSV",
|
||||
"ALL_SKIP_LOGS": "All",
|
||||
"KIND": "Kind",
|
||||
"NO_ERRORS": "No errors recorded.",
|
||||
"ERROR_CODE": "Code",
|
||||
"SOURCE_OBJECT": "Source object",
|
||||
"MESSAGE": "Message"
|
||||
},
|
||||
"MONITOR": {
|
||||
"LIVE": "Live updates every {seconds}s",
|
||||
"LAST_UPDATED": "Last updated {time}",
|
||||
"REFRESH": "Refresh",
|
||||
"REFRESHING": "Refreshing",
|
||||
"STAGES": {
|
||||
"unknown": "Waiting for update",
|
||||
"queued": "Queued",
|
||||
"contacts": "Importing contacts",
|
||||
"conversations": "Importing conversations",
|
||||
"finalizing": "Finalizing import",
|
||||
"completed": "Completed",
|
||||
"completed_with_errors": "Completed with errors",
|
||||
"failed": "Failed",
|
||||
"abandoned": "Abandoned"
|
||||
}
|
||||
},
|
||||
"ALERTS": {
|
||||
"IMPORT_STARTED": "Intercom import has started.",
|
||||
"IMPORT_ABANDONED": "Intercom import has been abandoned.",
|
||||
"IMPORT_FAILED": "Could not start the Intercom import."
|
||||
}
|
||||
},
|
||||
"CAPTAIN_SETTINGS": {
|
||||
"TITLE": "Captain Settings",
|
||||
"DESCRIPTION": "Configure your AI models and features for Captain. Captain follows a credit based billing, you will be charged credits for every action Captain takes based on the model selected.",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useAlert } from 'dashboard/composables';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { getMaxUploadSizeByChannel } from '@chatwoot/utils';
|
||||
import { DirectUpload } from 'activestorage';
|
||||
import { setDirectUploadAuthHeaders } from 'dashboard/helper/directUploadsHelper';
|
||||
import {
|
||||
DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE,
|
||||
resolveMaximumFileUploadSize,
|
||||
@@ -77,10 +78,7 @@ export default {
|
||||
`/api/v1/accounts/${this.accountId}/conversations/${this.currentChat.id}/direct_uploads`,
|
||||
{
|
||||
directUploadWillCreateBlobWithXHR: xhr => {
|
||||
xhr.setRequestHeader(
|
||||
'api_access_token',
|
||||
this.currentUser.access_token
|
||||
);
|
||||
setDirectUploadAuthHeaders(xhr);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
+128
-68
@@ -12,7 +12,8 @@ import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||
import SettingsHeader from 'dashboard/components-next/captain/pageComponents/settings/SettingsHeader.vue';
|
||||
import AssistantBasicSettingsForm from 'dashboard/components-next/captain/pageComponents/assistant/settings/AssistantBasicSettingsForm.vue';
|
||||
import AssistantSystemSettingsForm from 'dashboard/components-next/captain/pageComponents/assistant/settings/AssistantSystemSettingsForm.vue';
|
||||
import AssistantControlItems from 'dashboard/components-next/captain/pageComponents/assistant/settings/AssistantControlItems.vue';
|
||||
import AssistantAudienceForm from 'dashboard/components-next/captain/pageComponents/assistant/settings/AssistantAudienceForm.vue';
|
||||
import AssistantScheduleForm from 'dashboard/components-next/captain/pageComponents/assistant/settings/AssistantScheduleForm.vue';
|
||||
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -35,29 +36,56 @@ const assistant = computed(() =>
|
||||
store.getters['captainAssistants/getRecord'](assistantId.value)
|
||||
);
|
||||
|
||||
const controlItems = computed(() => {
|
||||
return [
|
||||
const activeSection = ref('basic');
|
||||
|
||||
const navItems = computed(() => {
|
||||
const items = [
|
||||
{
|
||||
name: t(
|
||||
'CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.OPTIONS.GUARDRAILS.TITLE'
|
||||
),
|
||||
description: t(
|
||||
'CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.OPTIONS.GUARDRAILS.DESCRIPTION'
|
||||
),
|
||||
routeName: 'captain_assistants_guardrails_index',
|
||||
id: 'basic',
|
||||
label: t('CAPTAIN.ASSISTANTS.SETTINGS.BASIC_SETTINGS.TITLE'),
|
||||
},
|
||||
{
|
||||
name: t(
|
||||
'CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.OPTIONS.RESPONSE_GUIDELINES.TITLE'
|
||||
),
|
||||
description: t(
|
||||
'CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.OPTIONS.RESPONSE_GUIDELINES.DESCRIPTION'
|
||||
),
|
||||
routeName: 'captain_assistants_guidelines_index',
|
||||
id: 'system',
|
||||
label: t('CAPTAIN.ASSISTANTS.SETTINGS.SYSTEM_SETTINGS.TITLE'),
|
||||
},
|
||||
{ id: 'audience', label: t('CAPTAIN.ASSISTANTS.SETTINGS.AUDIENCE.TITLE') },
|
||||
{ id: 'schedule', label: t('CAPTAIN.ASSISTANTS.SETTINGS.SCHEDULE.TITLE') },
|
||||
];
|
||||
|
||||
if (isCaptainV2Enabled.value) {
|
||||
items.push(
|
||||
{
|
||||
routeName: 'captain_assistants_guardrails_index',
|
||||
label: t(
|
||||
'CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.OPTIONS.GUARDRAILS.TITLE'
|
||||
),
|
||||
},
|
||||
{
|
||||
routeName: 'captain_assistants_guidelines_index',
|
||||
label: t(
|
||||
'CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.OPTIONS.RESPONSE_GUIDELINES.TITLE'
|
||||
),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const handleNavClick = item => {
|
||||
if (item.routeName) {
|
||||
router.push({
|
||||
name: item.routeName,
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
assistantId: assistantId.value,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
activeSection.value = item.id;
|
||||
};
|
||||
|
||||
const handleSubmit = async updatedAssistant => {
|
||||
try {
|
||||
await store.dispatch('captainAssistants/update', {
|
||||
@@ -107,18 +135,30 @@ const handleDeleteSuccess = () => {
|
||||
:is-fetching="isFetching"
|
||||
:show-pagination-footer="false"
|
||||
:show-know-more="false"
|
||||
:class="{
|
||||
'[&>header>div]:max-w-[80rem] [&>main>div]:max-w-[80rem]':
|
||||
isCaptainV2Enabled,
|
||||
}"
|
||||
>
|
||||
<template #body>
|
||||
<div
|
||||
class="gap-6 lg:gap-16 pb-8"
|
||||
:class="{ 'grid grid-cols-2': isCaptainV2Enabled }"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex gap-8 pb-8">
|
||||
<nav
|
||||
class="sticky self-start flex flex-col flex-shrink-0 w-48 gap-1 top-0"
|
||||
>
|
||||
<button
|
||||
v-for="item in navItems"
|
||||
:key="item.id ?? item.routeName"
|
||||
type="button"
|
||||
class="px-3 py-2 text-sm text-left rounded-lg transition-colors"
|
||||
:class="
|
||||
activeSection === item.id
|
||||
? 'bg-n-alpha-2 text-n-slate-12 font-medium'
|
||||
: 'text-n-slate-11 hover:bg-n-alpha-1'
|
||||
"
|
||||
@click="handleNavClick(item)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="flex flex-col flex-1 min-w-0 gap-6">
|
||||
<section v-if="activeSection === 'basic'" class="flex flex-col gap-6">
|
||||
<SettingsHeader
|
||||
:heading="t('CAPTAIN.ASSISTANTS.SETTINGS.BASIC_SETTINGS.TITLE')"
|
||||
:description="
|
||||
@@ -129,9 +169,35 @@ const handleDeleteSuccess = () => {
|
||||
:assistant="assistant"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
<span class="h-px w-full bg-n-weak mt-2" />
|
||||
<div class="flex flex-col gap-6">
|
||||
<span class="w-full h-px mt-2 bg-n-weak" />
|
||||
<div class="flex items-end justify-between w-full gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h6 class="text-base font-medium text-n-slate-12">
|
||||
{{ t('CAPTAIN.ASSISTANTS.SETTINGS.DELETE.TITLE') }}
|
||||
</h6>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ t('CAPTAIN.ASSISTANTS.SETTINGS.DELETE.DESCRIPTION') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<Button
|
||||
:label="
|
||||
t('CAPTAIN.ASSISTANTS.SETTINGS.DELETE.BUTTON_TEXT', {
|
||||
assistantName: assistant.name,
|
||||
})
|
||||
"
|
||||
color="ruby"
|
||||
class="max-w-56 !w-fit"
|
||||
@click="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-else-if="activeSection === 'system'"
|
||||
class="flex flex-col gap-6"
|
||||
>
|
||||
<SettingsHeader
|
||||
:heading="t('CAPTAIN.ASSISTANTS.SETTINGS.SYSTEM_SETTINGS.TITLE')"
|
||||
:description="
|
||||
@@ -142,45 +208,39 @@ const handleDeleteSuccess = () => {
|
||||
:assistant="assistant"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
<span class="h-px w-full bg-n-weak mt-2" />
|
||||
<div class="flex items-end justify-between w-full gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h6 class="text-n-slate-12 text-base font-medium">
|
||||
{{ t('CAPTAIN.ASSISTANTS.SETTINGS.DELETE.TITLE') }}
|
||||
</h6>
|
||||
<span class="text-n-slate-11 text-sm">
|
||||
{{ t('CAPTAIN.ASSISTANTS.SETTINGS.DELETE.DESCRIPTION') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<Button
|
||||
:label="
|
||||
t('CAPTAIN.ASSISTANTS.SETTINGS.DELETE.BUTTON_TEXT', {
|
||||
assistantName: assistant.name,
|
||||
})
|
||||
"
|
||||
color="ruby"
|
||||
class="max-w-56 !w-fit"
|
||||
@click="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isCaptainV2Enabled" class="flex flex-col gap-6">
|
||||
<SettingsHeader
|
||||
:heading="t('CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.TITLE')"
|
||||
:description="
|
||||
t('CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.DESCRIPTION')
|
||||
"
|
||||
/>
|
||||
<div class="flex flex-col gap-6">
|
||||
<AssistantControlItems
|
||||
v-for="item in controlItems"
|
||||
:key="item.name"
|
||||
:control-item="item"
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-else-if="activeSection === 'audience'"
|
||||
class="flex flex-col gap-6"
|
||||
>
|
||||
<SettingsHeader
|
||||
:heading="t('CAPTAIN.ASSISTANTS.SETTINGS.AUDIENCE.TITLE')"
|
||||
:description="
|
||||
t('CAPTAIN.ASSISTANTS.SETTINGS.AUDIENCE.DESCRIPTION')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<AssistantAudienceForm
|
||||
:assistant="assistant"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-else-if="activeSection === 'schedule'"
|
||||
class="flex flex-col gap-6"
|
||||
>
|
||||
<SettingsHeader
|
||||
:heading="t('CAPTAIN.ASSISTANTS.SETTINGS.SCHEDULE.TITLE')"
|
||||
:description="
|
||||
t('CAPTAIN.ASSISTANTS.SETTINGS.SCHEDULE.DESCRIPTION')
|
||||
"
|
||||
/>
|
||||
<AssistantScheduleForm
|
||||
:assistant="assistant"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -17,7 +17,7 @@ import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
|
||||
import RelatedResponses from 'dashboard/components-next/captain/pageComponents/document/RelatedResponses.vue';
|
||||
import DocumentDetails from 'dashboard/components-next/captain/pageComponents/document/DocumentDetails.vue';
|
||||
import CreateDocumentDialog from 'dashboard/components-next/captain/pageComponents/document/CreateDocumentDialog.vue';
|
||||
import DocumentPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/DocumentPageEmptyState.vue';
|
||||
import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight/FeatureSpotlightPopover.vue';
|
||||
@@ -51,22 +51,22 @@ const handleDelete = () => {
|
||||
deleteDocumentDialog.value.dialogRef.open();
|
||||
};
|
||||
|
||||
const showRelatedResponses = ref(false);
|
||||
const showDocumentDetails = ref(false);
|
||||
const showCreateDialog = ref(false);
|
||||
const createDocumentDialog = ref(null);
|
||||
const relationQuestionDialog = ref(null);
|
||||
const documentDetailsDialog = ref(null);
|
||||
|
||||
const handleShowRelatedDocument = () => {
|
||||
showRelatedResponses.value = true;
|
||||
nextTick(() => relationQuestionDialog.value.dialogRef.open());
|
||||
const handleShowDocumentDetails = () => {
|
||||
showDocumentDetails.value = true;
|
||||
nextTick(() => documentDetailsDialog.value.dialogRef.open());
|
||||
};
|
||||
const handleCreateDocument = () => {
|
||||
showCreateDialog.value = true;
|
||||
nextTick(() => createDocumentDialog.value.dialogRef.open());
|
||||
};
|
||||
|
||||
const handleRelatedResponseClose = () => {
|
||||
showRelatedResponses.value = false;
|
||||
const handleDocumentDetailsClose = () => {
|
||||
showDocumentDetails.value = false;
|
||||
};
|
||||
|
||||
const handleCreateDialogClose = () => {
|
||||
@@ -235,8 +235,8 @@ const handleAction = ({ action, id }) => {
|
||||
nextTick(() => {
|
||||
if (action === 'delete') {
|
||||
handleDelete();
|
||||
} else if (action === 'viewRelatedQuestions') {
|
||||
handleShowRelatedDocument();
|
||||
} else if (action === 'viewDetails') {
|
||||
handleShowDocumentDetails();
|
||||
} else if (action === 'sync') {
|
||||
handleSync(id);
|
||||
}
|
||||
@@ -416,6 +416,7 @@ onUnmounted(() => {
|
||||
:last-sync-error-code="doc.last_sync_error_code"
|
||||
:sync-in-progress="doc.sync_in_progress"
|
||||
:sync-stale-after-hours="syncIntervalHours"
|
||||
:responses-count="doc.responses_count"
|
||||
:is-selected="canManageDocuments && bulkSelectedIds.has(doc.id)"
|
||||
:selectable="canManageDocuments"
|
||||
:show-selection-control="shouldShowSelectionControl(doc.id)"
|
||||
@@ -427,11 +428,11 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<RelatedResponses
|
||||
v-if="showRelatedResponses"
|
||||
ref="relationQuestionDialog"
|
||||
<DocumentDetails
|
||||
v-if="showDocumentDetails"
|
||||
ref="documentDetailsDialog"
|
||||
:captain-document="selectedDocument"
|
||||
@close="handleRelatedResponseClose"
|
||||
@close="handleDocumentDetailsClose"
|
||||
/>
|
||||
<CreateDocumentDialog
|
||||
v-if="showCreateDialog"
|
||||
|
||||
+53
-2
@@ -4,8 +4,12 @@ import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { buildPortalArticleURL } from 'dashboard/helper/portalHelper';
|
||||
import {
|
||||
buildPortalArticleURL,
|
||||
ARTICLE_STATUSES,
|
||||
} from 'dashboard/helper/portalHelper';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { rendersIdentically } from 'dashboard/helper/articleDiffHelper';
|
||||
|
||||
import ArticleEditor from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue';
|
||||
|
||||
@@ -40,13 +44,60 @@ const articleLink = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
// On a published article, title/content edits stage into draft_* columns (kept
|
||||
// off the live site). Anywhere else they save straight to the live record — and
|
||||
// we drop any leftover draft (e.g. left behind when the card/bulk menu moved a
|
||||
// published article to draft) so a later publish can't resurrect stale content.
|
||||
const stageDraftFields = values => {
|
||||
if (article.value?.status !== ARTICLE_STATUSES.PUBLISHED) {
|
||||
const hasStaleDraft =
|
||||
article.value?.draftTitle != null || article.value?.draftContent != null;
|
||||
if (!hasStaleDraft) return values;
|
||||
// The editor is showing the staged draft, so promote both fields to the live
|
||||
// record (the field being autosaved wins) before dropping the drafts —
|
||||
// otherwise saving one field would snap the other back to the old live value.
|
||||
return {
|
||||
...values,
|
||||
title: values.title ?? article.value.draftTitle ?? article.value.title,
|
||||
content:
|
||||
values.content ?? article.value.draftContent ?? article.value.content,
|
||||
draft_title: null,
|
||||
draft_content: null,
|
||||
};
|
||||
}
|
||||
|
||||
const staged = { ...values };
|
||||
['title', 'content'].forEach(field => {
|
||||
if (field in staged) {
|
||||
staged[`draft_${field}`] = staged[field];
|
||||
delete staged[field];
|
||||
}
|
||||
});
|
||||
|
||||
// Clear the draft when it matches the live version (a revert, or a body edit
|
||||
// the renderer ignores like a blank line) so it doesn't leave a "pending
|
||||
// changes" badge with nothing to compare. The title is shown as raw escaped
|
||||
// text, so compare it exactly; only the body is Markdown, so compare its render.
|
||||
const liveTitle = article.value.title ?? '';
|
||||
const liveContent = article.value.content ?? '';
|
||||
const nextTitle = staged.draft_title ?? article.value.draftTitle ?? liveTitle;
|
||||
const nextContent =
|
||||
staged.draft_content ?? article.value.draftContent ?? liveContent;
|
||||
if (nextTitle === liveTitle && rendersIdentically(liveContent, nextContent)) {
|
||||
staged.draft_title = null;
|
||||
staged.draft_content = null;
|
||||
}
|
||||
|
||||
return staged;
|
||||
};
|
||||
|
||||
const saveArticle = async ({ ...values }) => {
|
||||
isUpdating.value = true;
|
||||
try {
|
||||
await store.dispatch('articles/update', {
|
||||
portalSlug,
|
||||
articleId: articleSlug,
|
||||
...values,
|
||||
...stageDraftFields(values),
|
||||
});
|
||||
isSaved.value = true;
|
||||
} catch (error) {
|
||||
|
||||
+5
-1
@@ -1,4 +1,6 @@
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
// OAuth/SDK channels need installation-level app credentials to be usable. When
|
||||
// the credential is missing the channel is "not configured" and is hidden from
|
||||
@@ -8,6 +10,7 @@ import { useMapGetter } from 'dashboard/composables/store';
|
||||
export function useChannelConfig() {
|
||||
const globalConfig = useMapGetter('globalConfig/get');
|
||||
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
|
||||
const { isCloudFeatureEnabled } = useAccount();
|
||||
const installationConfig = window.chatwootConfig || {};
|
||||
|
||||
const CHANNEL_CONFIGURED = {
|
||||
@@ -20,7 +23,8 @@ export function useChannelConfig() {
|
||||
Boolean(installationConfig.whatsappConfigurationId),
|
||||
facebook: () => Boolean(installationConfig.fbAppId),
|
||||
instagram: () =>
|
||||
!isOnChatwootCloud.value && Boolean(installationConfig.instagramAppId),
|
||||
Boolean(installationConfig.instagramAppId) &&
|
||||
isCloudFeatureEnabled(FEATURE_FLAGS.CHANNEL_INSTAGRAM),
|
||||
tiktok: () => Boolean(installationConfig.tiktokAppId),
|
||||
gmail: () => Boolean(installationConfig.googleOAuthClientId),
|
||||
outlook: () => Boolean(globalConfig.value.azureAppId),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
|
||||
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
import googleClient from 'dashboard/api/channel/googleClient';
|
||||
@@ -24,17 +23,11 @@ export function useChannelConnect() {
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
|
||||
const connectViaOAuth = async provider => {
|
||||
const client = OAUTH_CLIENTS[provider];
|
||||
if (!client) return;
|
||||
|
||||
if (provider === 'instagram' && isOnChatwootCloud.value) {
|
||||
useAlert(t('INBOX_MGMT.ADD.INSTAGRAM.RESTRICTED_WARNING'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
data: { url },
|
||||
|
||||
+27
-3
@@ -1,11 +1,11 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { nextTick } from 'vue';
|
||||
import { createStore } from 'vuex';
|
||||
import { useRoute } from 'vue-router';
|
||||
import InboxChannelsDialog from '../../inbox-setup/InboxChannelsDialog.vue';
|
||||
|
||||
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: key => key }) }));
|
||||
vi.mock('dashboard/composables/store', () => ({
|
||||
useMapGetter: () => ({ value: {} }),
|
||||
}));
|
||||
vi.mock('vue-router');
|
||||
vi.mock('../../inbox-setup/useChannelConnect', () => ({
|
||||
useChannelConnect: () => ({
|
||||
connectViaOAuth: vi.fn(),
|
||||
@@ -13,10 +13,30 @@ vi.mock('../../inbox-setup/useChannelConnect', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const store = createStore({
|
||||
modules: {
|
||||
globalConfig: {
|
||||
namespaced: true,
|
||||
getters: {
|
||||
get: () => ({}),
|
||||
isOnChatwootCloud: () => false,
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
namespaced: true,
|
||||
getters: {
|
||||
getAccount: () => () => ({ id: 1, features: {} }),
|
||||
isFeatureEnabledonAccount: () => () => false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mountDialog = () =>
|
||||
mount(InboxChannelsDialog, {
|
||||
props: { inboxes: [] },
|
||||
global: {
|
||||
plugins: [store],
|
||||
stubs: {
|
||||
Dialog: {
|
||||
template: '<div><slot /></div>',
|
||||
@@ -31,6 +51,10 @@ const mountDialog = () =>
|
||||
});
|
||||
|
||||
describe('InboxChannelsDialog Facebook gating', () => {
|
||||
beforeEach(() => {
|
||||
useRoute.mockReturnValue({ params: { accountId: '1' } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.chatwootConfig;
|
||||
});
|
||||
|
||||
+23
-1
@@ -13,6 +13,7 @@ vi.mock('vue-router');
|
||||
// channel_type, social ordering) derived from CHANNEL_LIST.
|
||||
const mountComposable = ({
|
||||
brandInfo,
|
||||
features = { channel_instagram: true },
|
||||
inboxes = [],
|
||||
isOnChatwootCloud = false,
|
||||
} = {}) => {
|
||||
@@ -30,8 +31,11 @@ const mountComposable = ({
|
||||
getters: {
|
||||
getAccount: () => () => ({
|
||||
id: 1,
|
||||
features,
|
||||
custom_attributes: { brand_info: brandInfo },
|
||||
}),
|
||||
isFeatureEnabledonAccount: () => (_accountId, feature) =>
|
||||
Boolean(features[feature]),
|
||||
},
|
||||
},
|
||||
inboxes: {
|
||||
@@ -207,7 +211,7 @@ describe('useDetectedChannels', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('hides Instagram from onboarding on Chatwoot Cloud', () => {
|
||||
it('keeps Instagram available on Chatwoot Cloud when enabled for the account', () => {
|
||||
const { displayedChannels } = mountComposable({
|
||||
isOnChatwootCloud: true,
|
||||
brandInfo: {
|
||||
@@ -218,6 +222,24 @@ describe('useDetectedChannels', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
|
||||
'instagram',
|
||||
'tiktok',
|
||||
]);
|
||||
});
|
||||
|
||||
it('hides Instagram when disabled for the account', () => {
|
||||
const { displayedChannels } = mountComposable({
|
||||
features: { channel_instagram: false },
|
||||
isOnChatwootCloud: true,
|
||||
brandInfo: {
|
||||
socials: [
|
||||
{ type: 'instagram', url: 'https://instagram.com/acme' },
|
||||
{ type: 'tiktok', url: 'https://tiktok.com/@acme' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
|
||||
'tiktok',
|
||||
]);
|
||||
|
||||
+5
-3
@@ -52,9 +52,11 @@ const helpURL = getHelpUrlForFeature(props.featureName);
|
||||
v-if="title"
|
||||
class="flex items-center justify-between w-full gap-4 min-h-8 mb-2"
|
||||
>
|
||||
<h1 class="text-heading-1 text-n-slate-12">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<slot name="title">
|
||||
<h1 class="text-heading-1 text-n-slate-12">
|
||||
{{ title }}
|
||||
</h1>
|
||||
</slot>
|
||||
</div>
|
||||
<div
|
||||
v-if="description || $slots.description || linkText || helpURL"
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
<script setup>
|
||||
import {
|
||||
computed,
|
||||
onActivated,
|
||||
onBeforeUnmount,
|
||||
onDeactivated,
|
||||
ref,
|
||||
} from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||
import SettingsLayout from '../SettingsLayout.vue';
|
||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
import DataImportsAPI from 'dashboard/api/dataImports';
|
||||
import NewImportDialog from './NewImportDialog.vue';
|
||||
import { importSourceFor } from './importSources';
|
||||
import {
|
||||
POLL_INTERVAL_MS,
|
||||
formatDate,
|
||||
formatStatus,
|
||||
importedCount,
|
||||
isActiveImport,
|
||||
isActiveIntercomImport,
|
||||
statusDotClass,
|
||||
} from './importStatus';
|
||||
|
||||
const { t } = useI18n();
|
||||
const getters = useStoreGetters();
|
||||
const router = useRouter();
|
||||
|
||||
const dataImports = ref([]);
|
||||
const isLoading = ref(true);
|
||||
const isRefreshing = ref(false);
|
||||
const isPolling = ref(false);
|
||||
const showImportDrawer = ref(false);
|
||||
const activeTab = ref('import');
|
||||
let pollTimer;
|
||||
let isPageActive = false;
|
||||
|
||||
const accountId = getters.getCurrentAccountId;
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ key: 'import', label: t('DATA_IMPORTS.TABS.IMPORT') },
|
||||
{ key: 'export', label: t('DATA_IMPORTS.TABS.EXPORT') },
|
||||
]);
|
||||
|
||||
const activeTabIndex = computed(() =>
|
||||
tabs.value.findIndex(tab => tab.key === activeTab.value)
|
||||
);
|
||||
|
||||
const hasActiveImport = computed(() => dataImports.value.some(isActiveImport));
|
||||
const hasActiveIntercomImport = computed(() =>
|
||||
dataImports.value.some(isActiveIntercomImport)
|
||||
);
|
||||
|
||||
const dataImportRoute = dataImport => ({
|
||||
name: 'settings_data_import_show',
|
||||
params: { accountId: accountId.value, dataImportId: dataImport.id },
|
||||
});
|
||||
|
||||
const importTypesFor = dataImport =>
|
||||
dataImport.import_types?.length
|
||||
? dataImport.import_types
|
||||
: [dataImport.data_type];
|
||||
|
||||
const importTypeLabel = dataImport =>
|
||||
importTypesFor(dataImport)
|
||||
.map(type => {
|
||||
if (type === 'contacts') return t('DATA_IMPORTS.TYPES.CONTACTS');
|
||||
if (type === 'conversations') {
|
||||
return t('DATA_IMPORTS.TYPES.CONVERSATIONS');
|
||||
}
|
||||
return type;
|
||||
})
|
||||
.join(', ');
|
||||
|
||||
const fetchImports = async () => {
|
||||
const response = await DataImportsAPI.get();
|
||||
dataImports.value = response.data.payload || [];
|
||||
};
|
||||
|
||||
const stopPolling = () => {
|
||||
if (!pollTimer) return;
|
||||
|
||||
window.clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
};
|
||||
|
||||
const refreshImportsInBackground = async () => {
|
||||
if (
|
||||
!isPageActive ||
|
||||
isPolling.value ||
|
||||
!hasActiveImport.value ||
|
||||
document.hidden
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
isPolling.value = true;
|
||||
try {
|
||||
await fetchImports();
|
||||
} finally {
|
||||
isPolling.value = false;
|
||||
if (!hasActiveImport.value) stopPolling();
|
||||
}
|
||||
};
|
||||
|
||||
const startPolling = () => {
|
||||
stopPolling();
|
||||
if (!isPageActive || !hasActiveImport.value) return;
|
||||
|
||||
pollTimer = window.setInterval(refreshImportsInBackground, POLL_INTERVAL_MS);
|
||||
};
|
||||
|
||||
const refresh = async ({ showLoader = true } = {}) => {
|
||||
if (showLoader) isLoading.value = true;
|
||||
else isRefreshing.value = true;
|
||||
|
||||
try {
|
||||
await fetchImports();
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
isRefreshing.value = false;
|
||||
if (isPageActive) {
|
||||
if (hasActiveImport.value && !pollTimer) startPolling();
|
||||
if (!hasActiveImport.value) stopPolling();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const openImport = dataImport => {
|
||||
router.push(dataImportRoute(dataImport));
|
||||
};
|
||||
|
||||
const openImportDrawer = () => {
|
||||
if (!hasActiveIntercomImport.value) showImportDrawer.value = true;
|
||||
};
|
||||
|
||||
const onImportCreated = dataImportId => {
|
||||
showImportDrawer.value = false;
|
||||
router.push({
|
||||
name: 'settings_data_import_show',
|
||||
params: { accountId: accountId.value, dataImportId },
|
||||
});
|
||||
};
|
||||
|
||||
const onTabChanged = tab => {
|
||||
activeTab.value = tab.key;
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (isPageActive && !document.hidden && hasActiveImport.value) {
|
||||
refreshImportsInBackground();
|
||||
}
|
||||
};
|
||||
|
||||
onActivated(async () => {
|
||||
isPageActive = true;
|
||||
await refresh();
|
||||
if (!isPageActive) return;
|
||||
|
||||
startPolling();
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
});
|
||||
|
||||
onDeactivated(() => {
|
||||
isPageActive = false;
|
||||
stopPolling();
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
isPageActive = false;
|
||||
stopPolling();
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsLayout
|
||||
:is-loading="isLoading"
|
||||
:loading-message="$t('DATA_IMPORTS.LOADING')"
|
||||
>
|
||||
<template #header>
|
||||
<BaseSettingsHeader
|
||||
:title="$t('DATA_IMPORTS.HEADER')"
|
||||
:description="$t('DATA_IMPORTS.DESCRIPTION')"
|
||||
>
|
||||
<template #tabs>
|
||||
<TabBar
|
||||
:tabs="tabs"
|
||||
:initial-active-tab="activeTabIndex"
|
||||
@tab-changed="onTabChanged"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="activeTab === 'import' && dataImports.length" #count>
|
||||
<span class="text-body-main text-n-slate-11">
|
||||
{{ $t('DATA_IMPORTS.TABLE.COUNT', { count: dataImports.length }) }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="activeTab === 'import'" #actions>
|
||||
<span
|
||||
v-if="hasActiveImport"
|
||||
class="hidden items-center gap-1.5 text-body-main text-n-slate-11 sm:inline-flex"
|
||||
>
|
||||
<span class="size-2 rounded-full bg-n-teal-9 animate-pulse" />
|
||||
{{
|
||||
$t('DATA_IMPORTS.MONITOR.LIVE', {
|
||||
seconds: POLL_INTERVAL_MS / 1000,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<Button
|
||||
ghost
|
||||
slate
|
||||
size="sm"
|
||||
icon="i-lucide-refresh-cw"
|
||||
:is-loading="isRefreshing"
|
||||
:aria-label="$t('DATA_IMPORTS.MONITOR.REFRESH')"
|
||||
:title="$t('DATA_IMPORTS.MONITOR.REFRESH')"
|
||||
@click="refresh({ showLoader: false })"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
:label="$t('DATA_IMPORTS.TABLE.NEW_IMPORT')"
|
||||
:disabled="hasActiveIntercomImport"
|
||||
:title="
|
||||
hasActiveIntercomImport
|
||||
? $t('DATA_IMPORTS.DRAWER.ACTIVE_IMPORT')
|
||||
: undefined
|
||||
"
|
||||
@click="openImportDrawer"
|
||||
/>
|
||||
</template>
|
||||
</BaseSettingsHeader>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<div
|
||||
v-if="activeTab === 'export'"
|
||||
class="flex min-h-80 flex-col items-center justify-center gap-4 rounded-xl border border-n-weak bg-n-solid-1 px-6 py-16 text-center"
|
||||
>
|
||||
<span
|
||||
class="flex size-12 items-center justify-center rounded-full bg-n-alpha-2"
|
||||
>
|
||||
<Icon icon="i-lucide-upload" class="size-5 text-n-slate-11" />
|
||||
</span>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-heading-2 text-n-slate-12">
|
||||
{{ $t('DATA_IMPORTS.EXPORT.TITLE') }}
|
||||
</h3>
|
||||
<p class="max-w-sm text-body-main text-n-slate-11">
|
||||
{{ $t('DATA_IMPORTS.EXPORT.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-md bg-n-alpha-2 px-2 py-1 text-label-small text-n-slate-11"
|
||||
>
|
||||
<Icon icon="i-lucide-clock" class="size-3.5" />
|
||||
{{ $t('DATA_IMPORTS.EXPORT.COMING_SOON') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!dataImports.length"
|
||||
class="flex min-h-80 flex-col items-center justify-center gap-4 rounded-xl border border-n-weak bg-n-solid-1 px-6 py-16 text-center"
|
||||
>
|
||||
<span
|
||||
class="flex size-12 items-center justify-center rounded-full bg-n-alpha-2"
|
||||
>
|
||||
<Icon icon="i-lucide-database" class="size-5 text-n-slate-11" />
|
||||
</span>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-heading-2 text-n-slate-12">
|
||||
{{ $t('DATA_IMPORTS.TABLE.EMPTY') }}
|
||||
</h3>
|
||||
<p class="max-w-sm text-body-main text-n-slate-11">
|
||||
{{ $t('DATA_IMPORTS.TABLE.EMPTY_DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
icon="i-lucide-download"
|
||||
:label="$t('DATA_IMPORTS.TABLE.NEW_IMPORT')"
|
||||
@click="openImportDrawer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="divide-y divide-n-weak border-t border-n-weak">
|
||||
<div
|
||||
v-for="dataImport in dataImports"
|
||||
:key="dataImport.id"
|
||||
class="group flex cursor-pointer items-center justify-between gap-4 py-4"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="openImport(dataImport)"
|
||||
@keydown.enter="openImport(dataImport)"
|
||||
@keydown.space.prevent="openImport(dataImport)"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<img
|
||||
v-if="importSourceFor(dataImport).icon"
|
||||
v-tooltip.top="importSourceFor(dataImport).label"
|
||||
:src="importSourceFor(dataImport).icon"
|
||||
alt=""
|
||||
class="size-10 justify-center bg-n-alpha-3 rounded-xl shrink-0 object-contain border border-n-strong"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
v-tooltip.top="importSourceFor(dataImport).label"
|
||||
class="size-10 justify-center bg-n-alpha-3 rounded-xl ring ring-n-solid-1 border border-n-strong shadow-sm grid place-items-center"
|
||||
>
|
||||
<Icon
|
||||
:icon="importSourceFor(dataImport).iconClass"
|
||||
class="size-4"
|
||||
/>
|
||||
</span>
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate text-heading-3 text-n-slate-12">
|
||||
{{ dataImport.name || $t('DATA_IMPORTS.TABLE.UNNAMED') }}
|
||||
</span>
|
||||
<span class="flex shrink-0 items-center gap-1.5">
|
||||
<span
|
||||
class="size-2 rounded-full"
|
||||
:class="[
|
||||
statusDotClass(dataImport.status),
|
||||
{ 'animate-pulse': isActiveImport(dataImport) },
|
||||
]"
|
||||
/>
|
||||
<span
|
||||
class="whitespace-nowrap capitalize text-body-main text-n-slate-11"
|
||||
>
|
||||
{{ formatStatus(dataImport.status) }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2 text-body-main text-n-slate-11"
|
||||
>
|
||||
<span>{{ importTypeLabel(dataImport) }}</span>
|
||||
<div class="h-3 w-px rounded-lg bg-n-strong" />
|
||||
<span class="tabular-nums">
|
||||
{{
|
||||
$t('DATA_IMPORTS.TABLE.IMPORTED_COUNT', {
|
||||
count: importedCount(dataImport),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<div class="h-3 w-px rounded-lg bg-n-strong" />
|
||||
<span>{{ formatDate(dataImport.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
v-tooltip.top="$t('DATA_IMPORTS.TABLE.VIEW')"
|
||||
icon="i-lucide-eye"
|
||||
slate
|
||||
sm
|
||||
class="shrink-0"
|
||||
@click.stop="openImport(dataImport)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsLayout>
|
||||
|
||||
<NewImportDialog
|
||||
:show="showImportDrawer"
|
||||
:has-active-import="hasActiveIntercomImport"
|
||||
@close="showImportDrawer = false"
|
||||
@created="onImportCreated"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,210 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Select from 'dashboard/components-next/select/Select.vue';
|
||||
import DataImportsAPI from 'dashboard/api/dataImports';
|
||||
import { IMPORT_SOURCES } from './importSources';
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
hasActiveImport: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'created']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const dialogRef = ref(null);
|
||||
const sourceProvider = ref('intercom');
|
||||
const importName = ref(t('DATA_IMPORTS.DEFAULT_IMPORT_NAME'));
|
||||
const accessToken = ref('');
|
||||
const selectedImportTypes = ref(['contacts', 'conversations']);
|
||||
const validationState = ref('idle');
|
||||
const validationMessage = ref('');
|
||||
const isCreating = ref(false);
|
||||
let validationRequestId = 0;
|
||||
|
||||
const closeDrawer = () => emit('close');
|
||||
|
||||
const sourceOptions = computed(() =>
|
||||
IMPORT_SOURCES.map(({ value, label }) => ({ value, label }))
|
||||
);
|
||||
|
||||
const tokenMessageType = computed(() => {
|
||||
if (validationState.value === 'valid') return 'success';
|
||||
if (validationState.value === 'invalid') return 'error';
|
||||
return 'info';
|
||||
});
|
||||
|
||||
const canCreate = computed(
|
||||
() =>
|
||||
validationState.value === 'valid' &&
|
||||
selectedImportTypes.value.length > 0 &&
|
||||
!props.hasActiveImport &&
|
||||
!isCreating.value
|
||||
);
|
||||
|
||||
const validationPayload = () => ({
|
||||
source_provider: sourceProvider.value,
|
||||
access_token: accessToken.value.trim(),
|
||||
import_types: selectedImportTypes.value,
|
||||
});
|
||||
|
||||
const invalidateValidation = () => {
|
||||
validationRequestId += 1;
|
||||
validationState.value = 'idle';
|
||||
validationMessage.value = '';
|
||||
};
|
||||
|
||||
const validateSource = async () => {
|
||||
if (!accessToken.value.trim() || !selectedImportTypes.value.length) {
|
||||
invalidateValidation();
|
||||
return;
|
||||
}
|
||||
|
||||
validationRequestId += 1;
|
||||
const requestId = validationRequestId;
|
||||
validationState.value = 'validating';
|
||||
validationMessage.value = t('DATA_IMPORTS.DRAWER.VALIDATING');
|
||||
try {
|
||||
await DataImportsAPI.validateSource(validationPayload());
|
||||
if (requestId !== validationRequestId) return;
|
||||
|
||||
validationState.value = 'valid';
|
||||
validationMessage.value = t('DATA_IMPORTS.DRAWER.VALID_KEY');
|
||||
} catch (error) {
|
||||
if (requestId !== validationRequestId) return;
|
||||
|
||||
validationState.value = 'invalid';
|
||||
validationMessage.value =
|
||||
error?.response?.data?.message || t('DATA_IMPORTS.DRAWER.INVALID_KEY');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleImportType = type => {
|
||||
selectedImportTypes.value = selectedImportTypes.value.includes(type)
|
||||
? selectedImportTypes.value.filter(item => item !== type)
|
||||
: [...selectedImportTypes.value, type];
|
||||
};
|
||||
|
||||
const createImport = async () => {
|
||||
if (!canCreate.value) return;
|
||||
|
||||
isCreating.value = true;
|
||||
try {
|
||||
const response = await DataImportsAPI.create({
|
||||
...validationPayload(),
|
||||
name: importName.value.trim() || t('DATA_IMPORTS.DEFAULT_IMPORT_NAME'),
|
||||
});
|
||||
useAlert(t('DATA_IMPORTS.ALERTS.IMPORT_STARTED'));
|
||||
emit('created', response.data.id);
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.response?.data?.message || t('DATA_IMPORTS.ALERTS.IMPORT_FAILED')
|
||||
);
|
||||
} finally {
|
||||
isCreating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(accessToken, invalidateValidation);
|
||||
|
||||
watch(selectedImportTypes, () => {
|
||||
invalidateValidation();
|
||||
if (accessToken.value.trim() && selectedImportTypes.value.length) {
|
||||
validateSource();
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
show => {
|
||||
if (show) {
|
||||
dialogRef.value?.open();
|
||||
return;
|
||||
}
|
||||
|
||||
dialogRef.value?.close();
|
||||
accessToken.value = '';
|
||||
validationState.value = 'idle';
|
||||
validationMessage.value = '';
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
:title="$t('DATA_IMPORTS.DRAWER.TITLE')"
|
||||
:confirm-button-label="$t('DATA_IMPORTS.DRAWER.IMPORT')"
|
||||
:cancel-button-label="$t('DATA_IMPORTS.DRAWER.CANCEL')"
|
||||
:disable-confirm-button="!canCreate"
|
||||
:is-loading="isCreating || validationState === 'validating'"
|
||||
width="md"
|
||||
@confirm="createImport"
|
||||
@close="closeDrawer"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<label class="flex flex-col gap-1.5 text-heading-3 text-n-slate-12">
|
||||
{{ $t('DATA_IMPORTS.DRAWER.SOURCE') }}
|
||||
<Select
|
||||
v-model="sourceProvider"
|
||||
class="!w-full [&>select]:w-full"
|
||||
:options="sourceOptions"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<Input
|
||||
v-model="importName"
|
||||
:label="$t('DATA_IMPORTS.DRAWER.NAME')"
|
||||
:placeholder="$t('DATA_IMPORTS.DRAWER.NAME_PLACEHOLDER')"
|
||||
/>
|
||||
|
||||
<Input
|
||||
v-model="accessToken"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
:label="$t('DATA_IMPORTS.DRAWER.ACCESS_KEY')"
|
||||
:placeholder="$t('DATA_IMPORTS.DRAWER.ACCESS_KEY_PLACEHOLDER')"
|
||||
:message="validationMessage"
|
||||
:message-type="tokenMessageType"
|
||||
@blur="validateSource"
|
||||
/>
|
||||
|
||||
<fieldset class="flex flex-col gap-2.5">
|
||||
<legend class="mb-1.5 text-heading-3 text-n-slate-12">
|
||||
{{ $t('DATA_IMPORTS.DRAWER.DATA_TYPES') }}
|
||||
</legend>
|
||||
<label
|
||||
class="inline-flex cursor-pointer items-center gap-2 text-body-main text-n-slate-12"
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="selectedImportTypes.includes('contacts')"
|
||||
@change="toggleImportType('contacts')"
|
||||
/>
|
||||
{{ $t('DATA_IMPORTS.TYPES.CONTACTS') }}
|
||||
</label>
|
||||
<label
|
||||
class="inline-flex cursor-pointer items-center gap-2 text-body-main text-n-slate-12"
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="selectedImportTypes.includes('conversations')"
|
||||
@change="toggleImportType('conversations')"
|
||||
/>
|
||||
{{ $t('DATA_IMPORTS.TYPES.CONVERSATIONS') }}
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<p
|
||||
v-if="hasActiveImport"
|
||||
class="rounded-lg bg-n-amber-2 px-3 py-2 text-body-main text-n-amber-11"
|
||||
>
|
||||
{{ $t('DATA_IMPORTS.DRAWER.ACTIVE_IMPORT') }}
|
||||
</p>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,238 @@
|
||||
<script setup>
|
||||
import {
|
||||
computed,
|
||||
onActivated,
|
||||
onBeforeUnmount,
|
||||
onDeactivated,
|
||||
ref,
|
||||
} from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
import DataImportsAPI from 'dashboard/api/dataImports';
|
||||
import { POLL_INTERVAL_MS, isActiveImport } from './importStatus';
|
||||
import SettingsLayout from '../SettingsLayout.vue';
|
||||
import ImportDetailHeader from './components/ImportDetailHeader.vue';
|
||||
import ImportSummaryTiles from './components/ImportSummaryTiles.vue';
|
||||
import ImportProgress from './components/ImportProgress.vue';
|
||||
import ImportErrorsSection from './components/ImportErrorsSection.vue';
|
||||
import ImportSkipLogsSection from './components/ImportSkipLogsSection.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
const dataImport = ref(null);
|
||||
const isLoading = ref(true);
|
||||
const isRefreshing = ref(false);
|
||||
const isPolling = ref(false);
|
||||
const isAbandoning = ref(false);
|
||||
const isDownloadingErrorLogs = ref(false);
|
||||
const isDownloadingSkipLogs = ref(false);
|
||||
const isChangingSkipLogsType = ref(false);
|
||||
const selectedSkipLogsType = ref('');
|
||||
const errorsOpen = ref(true);
|
||||
const skipLogsOpen = ref(true);
|
||||
let pollTimer;
|
||||
let isPageActive = false;
|
||||
|
||||
const hasActiveImport = computed(() => isActiveImport(dataImport.value));
|
||||
|
||||
const stopPolling = () => {
|
||||
if (!pollTimer) return;
|
||||
|
||||
window.clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
};
|
||||
|
||||
const fetchImport = async ({
|
||||
showLoader = false,
|
||||
manual = false,
|
||||
requestedSkipLogsType = selectedSkipLogsType.value,
|
||||
} = {}) => {
|
||||
if (showLoader) {
|
||||
isLoading.value = true;
|
||||
} else if (manual) {
|
||||
isRefreshing.value = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await DataImportsAPI.show(route.params.dataImportId, {
|
||||
skip_logs_type: requestedSkipLogsType || undefined,
|
||||
});
|
||||
dataImport.value = response.data;
|
||||
selectedSkipLogsType.value =
|
||||
response.data.skip_logs_filters?.selected_source_object_type ||
|
||||
requestedSkipLogsType ||
|
||||
'';
|
||||
} finally {
|
||||
if (showLoader) isLoading.value = false;
|
||||
if (manual) isRefreshing.value = false;
|
||||
if (!hasActiveImport.value) stopPolling();
|
||||
}
|
||||
};
|
||||
|
||||
const changeSkipLogsType = async type => {
|
||||
if (type === selectedSkipLogsType.value || isChangingSkipLogsType.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectedSkipLogsType.value = type;
|
||||
isChangingSkipLogsType.value = true;
|
||||
try {
|
||||
await fetchImport({ requestedSkipLogsType: type });
|
||||
} finally {
|
||||
isChangingSkipLogsType.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const refreshImportInBackground = async () => {
|
||||
if (
|
||||
!isPageActive ||
|
||||
isPolling.value ||
|
||||
!hasActiveImport.value ||
|
||||
document.hidden
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
isPolling.value = true;
|
||||
try {
|
||||
await fetchImport();
|
||||
} finally {
|
||||
isPolling.value = false;
|
||||
if (!hasActiveImport.value) stopPolling();
|
||||
}
|
||||
};
|
||||
|
||||
const abandonImport = async () => {
|
||||
isAbandoning.value = true;
|
||||
try {
|
||||
const response = await DataImportsAPI.abandon(dataImport.value.id);
|
||||
dataImport.value = response.data;
|
||||
stopPolling();
|
||||
useAlert(t('DATA_IMPORTS.ALERTS.IMPORT_ABANDONED'));
|
||||
} finally {
|
||||
isAbandoning.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const downloadCsv = (response, filename) => {
|
||||
const url = window.URL.createObjectURL(
|
||||
new Blob([response.data], { type: 'text/csv' })
|
||||
);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const downloadErrorLogs = async () => {
|
||||
isDownloadingErrorLogs.value = true;
|
||||
try {
|
||||
const response = await DataImportsAPI.downloadErrorLogs(
|
||||
dataImport.value.id
|
||||
);
|
||||
downloadCsv(response, `data-import-${dataImport.value.id}-error-logs.csv`);
|
||||
} finally {
|
||||
isDownloadingErrorLogs.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const downloadSkipLogs = async () => {
|
||||
isDownloadingSkipLogs.value = true;
|
||||
try {
|
||||
const response = await DataImportsAPI.downloadSkipLogs(dataImport.value.id);
|
||||
downloadCsv(response, `data-import-${dataImport.value.id}-skip-logs.csv`);
|
||||
} finally {
|
||||
isDownloadingSkipLogs.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const startPolling = () => {
|
||||
stopPolling();
|
||||
if (!isPageActive || !hasActiveImport.value) return;
|
||||
|
||||
pollTimer = window.setInterval(refreshImportInBackground, POLL_INTERVAL_MS);
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (isPageActive && !document.hidden && hasActiveImport.value) {
|
||||
refreshImportInBackground();
|
||||
}
|
||||
};
|
||||
|
||||
onActivated(async () => {
|
||||
isPageActive = true;
|
||||
await fetchImport({ showLoader: true });
|
||||
if (!isPageActive) return;
|
||||
|
||||
// Collapse empty sections by default; expand the ones with records.
|
||||
errorsOpen.value = Boolean(dataImport.value?.import_errors_count);
|
||||
skipLogsOpen.value = Boolean(dataImport.value?.skip_logs_count);
|
||||
startPolling();
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
});
|
||||
|
||||
onDeactivated(() => {
|
||||
isPageActive = false;
|
||||
stopPolling();
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
isPageActive = false;
|
||||
stopPolling();
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsLayout
|
||||
:is-loading="isLoading"
|
||||
:loading-message="$t('DATA_IMPORTS.LOADING')"
|
||||
>
|
||||
<template #header>
|
||||
<ImportDetailHeader
|
||||
:data-import="dataImport"
|
||||
:is-refreshing="isRefreshing"
|
||||
:is-abandoning="isAbandoning"
|
||||
:is-polling="isPolling"
|
||||
@refresh="fetchImport({ manual: true })"
|
||||
@abandon="abandonImport"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<div v-if="dataImport" class="flex flex-col gap-3">
|
||||
<ImportSummaryTiles :data-import="dataImport" />
|
||||
|
||||
<ImportProgress
|
||||
v-if="dataImport.import_types?.length"
|
||||
:data-import="dataImport"
|
||||
:title="$t('DATA_IMPORTS.DETAIL.PROGRESS')"
|
||||
/>
|
||||
|
||||
<ImportErrorsSection
|
||||
:data-import="dataImport"
|
||||
:is-open="errorsOpen"
|
||||
:is-downloading="isDownloadingErrorLogs"
|
||||
@toggle="errorsOpen = !errorsOpen"
|
||||
@download="downloadErrorLogs"
|
||||
/>
|
||||
|
||||
<ImportSkipLogsSection
|
||||
:data-import="dataImport"
|
||||
:is-open="skipLogsOpen"
|
||||
:is-downloading="isDownloadingSkipLogs"
|
||||
:selected-type="selectedSkipLogsType"
|
||||
:is-changing-type="isChangingSkipLogsType"
|
||||
@toggle="skipLogsOpen = !skipLogsOpen"
|
||||
@download="downloadSkipLogs"
|
||||
@change-type="changeSkipLogsType"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsLayout>
|
||||
</template>
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
|
||||
import {
|
||||
POLL_INTERVAL_MS,
|
||||
importStageKey,
|
||||
isAbandonableImport,
|
||||
isActiveImport,
|
||||
statusDotClass as getStatusDotClass,
|
||||
} from '../importStatus';
|
||||
|
||||
const props = defineProps({
|
||||
dataImport: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
isRefreshing: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isAbandoning: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isPolling: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(['refresh', 'abandon']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const pollIntervalSeconds = POLL_INTERVAL_MS / 1000;
|
||||
|
||||
const title = computed(
|
||||
() => props.dataImport?.name || t('DATA_IMPORTS.TABLE.UNNAMED')
|
||||
);
|
||||
|
||||
const stageLabels = computed(() => ({
|
||||
unknown: t('DATA_IMPORTS.MONITOR.STAGES.unknown'),
|
||||
queued: t('DATA_IMPORTS.MONITOR.STAGES.queued'),
|
||||
contacts: t('DATA_IMPORTS.MONITOR.STAGES.contacts'),
|
||||
conversations: t('DATA_IMPORTS.MONITOR.STAGES.conversations'),
|
||||
finalizing: t('DATA_IMPORTS.MONITOR.STAGES.finalizing'),
|
||||
completed: t('DATA_IMPORTS.MONITOR.STAGES.completed'),
|
||||
completed_with_errors: t('DATA_IMPORTS.MONITOR.STAGES.completed_with_errors'),
|
||||
failed: t('DATA_IMPORTS.MONITOR.STAGES.failed'),
|
||||
abandoned: t('DATA_IMPORTS.MONITOR.STAGES.abandoned'),
|
||||
}));
|
||||
|
||||
const monitorTitle = computed(
|
||||
() =>
|
||||
stageLabels.value[importStageKey(props.dataImport)] ||
|
||||
stageLabels.value.unknown
|
||||
);
|
||||
|
||||
const statusDotClass = computed(() =>
|
||||
getStatusDotClass(props.dataImport?.status)
|
||||
);
|
||||
|
||||
const hasActiveImport = computed(() => isActiveImport(props.dataImport));
|
||||
|
||||
const canAbandonImport = computed(() => isAbandonableImport(props.dataImport));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseSettingsHeader
|
||||
:title="title"
|
||||
:back-button-label="$t('DATA_IMPORTS.DETAIL.BACK')"
|
||||
>
|
||||
<template #title>
|
||||
<div class="flex w-full items-center justify-between gap-4">
|
||||
<h1 class="min-w-0 truncate text-heading-1 text-n-slate-12">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
v-if="hasActiveImport"
|
||||
outline
|
||||
slate
|
||||
size="sm"
|
||||
icon="i-lucide-refresh-cw"
|
||||
:is-loading="isRefreshing"
|
||||
:aria-label="$t('DATA_IMPORTS.MONITOR.REFRESH')"
|
||||
:title="$t('DATA_IMPORTS.MONITOR.REFRESH')"
|
||||
@click="$emit('refresh')"
|
||||
/>
|
||||
<Button
|
||||
v-if="canAbandonImport"
|
||||
ruby
|
||||
size="sm"
|
||||
:is-loading="isAbandoning"
|
||||
:label="$t('DATA_IMPORTS.TABLE.ABANDON')"
|
||||
@click="$emit('abandon')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #description>
|
||||
<span class="inline-flex items-center gap-1.5 align-middle">
|
||||
<span
|
||||
class="size-2 rounded-full"
|
||||
:class="[statusDotClass, { 'animate-pulse': hasActiveImport }]"
|
||||
/>
|
||||
{{ monitorTitle }}
|
||||
</span>
|
||||
<template v-if="hasActiveImport">
|
||||
<span
|
||||
class="mx-2 inline-block h-3 w-px rounded-lg bg-n-strong align-middle"
|
||||
/>
|
||||
<span class="text-n-teal-11">
|
||||
{{
|
||||
isPolling
|
||||
? $t('DATA_IMPORTS.MONITOR.REFRESHING')
|
||||
: $t('DATA_IMPORTS.MONITOR.LIVE', {
|
||||
seconds: pollIntervalSeconds,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
</BaseSettingsHeader>
|
||||
</template>
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { BaseTableRow, BaseTableCell } from 'dashboard/components-next/table';
|
||||
import ImportLogSection from './ImportLogSection.vue';
|
||||
import { formatDate, sourceObjectLabel } from '../importStatus';
|
||||
|
||||
const props = defineProps({
|
||||
dataImport: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isOpen: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isDownloading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(['toggle', 'download']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const errors = computed(() => props.dataImport?.import_errors || []);
|
||||
|
||||
const headers = computed(() => [
|
||||
t('DATA_IMPORTS.DETAIL.ERROR_CODE'),
|
||||
t('DATA_IMPORTS.DETAIL.SOURCE_OBJECT'),
|
||||
t('DATA_IMPORTS.DETAIL.MESSAGE'),
|
||||
t('DATA_IMPORTS.DETAIL.CREATED'),
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ImportLogSection
|
||||
:title="$t('DATA_IMPORTS.DETAIL.ERRORS')"
|
||||
:count="dataImport.import_errors_count"
|
||||
:is-open="isOpen"
|
||||
:is-downloading="isDownloading"
|
||||
:download-label="$t('DATA_IMPORTS.DETAIL.DOWNLOAD_ERROR_LOGS')"
|
||||
:headers="headers"
|
||||
:items="errors"
|
||||
:empty-message="$t('DATA_IMPORTS.DETAIL.NO_ERRORS')"
|
||||
@toggle="$emit('toggle')"
|
||||
@download="$emit('download')"
|
||||
>
|
||||
<template #row="{ items }">
|
||||
<BaseTableRow v-for="error in items" :key="error.id" :item="error">
|
||||
<template #default>
|
||||
<BaseTableCell>
|
||||
<span class="text-body-main text-n-slate-12">
|
||||
{{ error.error_code }}
|
||||
</span>
|
||||
</BaseTableCell>
|
||||
<BaseTableCell>
|
||||
<span class="text-body-main text-n-slate-12">
|
||||
{{ sourceObjectLabel(error) }}
|
||||
</span>
|
||||
</BaseTableCell>
|
||||
<BaseTableCell>
|
||||
<span class="text-body-main text-n-slate-11">
|
||||
{{ error.message || '-' }}
|
||||
</span>
|
||||
</BaseTableCell>
|
||||
<BaseTableCell>
|
||||
<span class="whitespace-nowrap text-body-main text-n-slate-11">
|
||||
{{ formatDate(error.created_at) }}
|
||||
</span>
|
||||
</BaseTableCell>
|
||||
</template>
|
||||
</BaseTableRow>
|
||||
</template>
|
||||
</ImportLogSection>
|
||||
</template>
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import { BaseTable } from 'dashboard/components-next/table';
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
count: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
isOpen: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isDownloading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
downloadLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
headers: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
emptyMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(['toggle', 'download']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="overflow-hidden rounded-xl border border-n-weak bg-n-solid-1">
|
||||
<div class="flex items-center justify-between gap-3 px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-w-0 items-center gap-2 !p-0"
|
||||
:aria-expanded="isOpen"
|
||||
@click="$emit('toggle')"
|
||||
>
|
||||
<h2 class="text-heading-3 text-n-slate-12">{{ title }}</h2>
|
||||
<span
|
||||
v-if="count"
|
||||
class="rounded-md bg-n-alpha-2 px-1.5 text-label-small tabular-nums text-n-slate-11"
|
||||
>
|
||||
{{ count }}
|
||||
</span>
|
||||
<Icon
|
||||
icon="i-lucide-chevron-down"
|
||||
class="size-4 shrink-0 text-n-slate-10 transition-transform duration-200"
|
||||
:class="{ '-rotate-90 rtl:rotate-90': !isOpen }"
|
||||
/>
|
||||
</button>
|
||||
<Button
|
||||
ghost
|
||||
slate
|
||||
xs
|
||||
icon="i-lucide-download"
|
||||
:is-loading="isDownloading"
|
||||
:disabled="!count"
|
||||
:label="downloadLabel"
|
||||
@click="$emit('download')"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="grid transition-[grid-template-rows] duration-300 ease-in-out"
|
||||
:class="isOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'"
|
||||
>
|
||||
<div class="min-h-0 overflow-hidden">
|
||||
<div class="border-t border-n-weak">
|
||||
<slot name="filters" />
|
||||
<p
|
||||
v-if="!items.length"
|
||||
class="px-4 py-8 text-center text-body-main text-n-slate-11"
|
||||
>
|
||||
{{ emptyMessage }}
|
||||
</p>
|
||||
<div v-else class="overflow-x-auto">
|
||||
<BaseTable
|
||||
class="[&_td:first-child]:ps-4 [&_th:first-child]:ps-4 [&_th]:text-n-slate-11 [&_thead]:border-t-0"
|
||||
:headers="headers"
|
||||
:items="items"
|
||||
>
|
||||
<template #row="{ items: rows }">
|
||||
<slot name="row" :items="rows" />
|
||||
</template>
|
||||
</BaseTable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
dataImport: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const items = computed(() => {
|
||||
const importTypes = props.dataImport?.import_types || [];
|
||||
const groups = [];
|
||||
if (importTypes.includes('contacts')) {
|
||||
groups.push({ key: 'contacts', label: t('DATA_IMPORTS.TYPES.CONTACTS') });
|
||||
}
|
||||
if (importTypes.includes('conversations')) {
|
||||
groups.push(
|
||||
{ key: 'conversations', label: t('DATA_IMPORTS.TYPES.CONVERSATIONS') },
|
||||
{ key: 'messages', label: t('DATA_IMPORTS.TYPES.MESSAGES') }
|
||||
);
|
||||
}
|
||||
|
||||
return groups.map(({ key, label }) => {
|
||||
const stats = props.dataImport?.stats?.[key] || {};
|
||||
const imported = Number(stats.imported || 0);
|
||||
const hasTotal = Object.prototype.hasOwnProperty.call(stats, 'total');
|
||||
const total = hasTotal ? Number(stats.total) : null;
|
||||
const percent =
|
||||
hasTotal && total > 0
|
||||
? Math.min(100, Math.round((imported / total) * 100))
|
||||
: null;
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
percent,
|
||||
importedLabel: imported.toLocaleString(),
|
||||
caption: hasTotal
|
||||
? t('DATA_IMPORTS.DETAIL.PROGRESS_OF_TOTAL', {
|
||||
total: total.toLocaleString(),
|
||||
})
|
||||
: t('DATA_IMPORTS.DETAIL.PROGRESS_IMPORTED'),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// Fit the grid to the number of groups so no empty cells show.
|
||||
const columnsClass = computed(() => {
|
||||
if (items.value.length >= 3) return 'sm:grid-cols-3';
|
||||
if (items.value.length === 2) return 'sm:grid-cols-2';
|
||||
return 'sm:grid-cols-1';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="overflow-hidden rounded-xl border border-n-weak bg-n-solid-1">
|
||||
<h2 class="border-b border-n-weak px-4 py-3 text-heading-3 text-n-slate-12">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 gap-px bg-n-weak" :class="columnsClass">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.key"
|
||||
class="flex flex-col gap-2 bg-n-solid-1 px-4 py-3"
|
||||
>
|
||||
<span class="text-label-small text-n-slate-11">{{ item.label }}</span>
|
||||
<div class="flex items-end justify-between gap-2">
|
||||
<span
|
||||
class="text-xl font-semibold tracking-tight tabular-nums text-n-slate-12"
|
||||
>
|
||||
{{ item.importedLabel }}
|
||||
</span>
|
||||
<span
|
||||
v-if="item.percent !== null"
|
||||
class="text-label-small tabular-nums text-n-slate-11"
|
||||
>
|
||||
{{ `${item.percent}%` }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="item.percent !== null"
|
||||
class="h-1.5 w-full overflow-hidden rounded-full bg-n-alpha-2"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full bg-n-brand transition-all duration-500"
|
||||
:style="{ width: `${item.percent}%` }"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-label-small text-n-slate-10">{{ item.caption }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { BaseTableRow, BaseTableCell } from 'dashboard/components-next/table';
|
||||
import ImportLogSection from './ImportLogSection.vue';
|
||||
import { formatDate, sourceObjectLabel } from '../importStatus';
|
||||
|
||||
const props = defineProps({
|
||||
dataImport: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isOpen: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isDownloading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
selectedType: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isChangingType: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(['toggle', 'download', 'changeType']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const skipLogs = computed(() => props.dataImport?.skip_logs || []);
|
||||
|
||||
const headers = computed(() => [
|
||||
t('DATA_IMPORTS.DETAIL.KIND'),
|
||||
t('DATA_IMPORTS.DETAIL.SOURCE_OBJECT'),
|
||||
t('DATA_IMPORTS.DETAIL.MESSAGE'),
|
||||
t('DATA_IMPORTS.DETAIL.CREATED'),
|
||||
]);
|
||||
|
||||
const typeOptions = computed(() => {
|
||||
const counts = props.dataImport?.skip_logs_filters?.counts_by_type || {};
|
||||
return [
|
||||
{
|
||||
value: '',
|
||||
label: t('DATA_IMPORTS.DETAIL.ALL_SKIP_LOGS'),
|
||||
count: props.dataImport?.skip_logs_count || 0,
|
||||
},
|
||||
{
|
||||
value: 'contact',
|
||||
label: t('DATA_IMPORTS.TYPES.CONTACTS'),
|
||||
count: counts.contact || 0,
|
||||
},
|
||||
{
|
||||
value: 'conversation',
|
||||
label: t('DATA_IMPORTS.TYPES.CONVERSATIONS'),
|
||||
count: counts.conversation || 0,
|
||||
},
|
||||
{
|
||||
value: 'message',
|
||||
label: t('DATA_IMPORTS.TYPES.MESSAGES'),
|
||||
count: counts.message || 0,
|
||||
},
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ImportLogSection
|
||||
:title="$t('DATA_IMPORTS.DETAIL.SKIP_LOGS')"
|
||||
:count="dataImport.skip_logs_count"
|
||||
:is-open="isOpen"
|
||||
:is-downloading="isDownloading"
|
||||
:download-label="$t('DATA_IMPORTS.DETAIL.DOWNLOAD_SKIP_LOGS')"
|
||||
:headers="headers"
|
||||
:items="skipLogs"
|
||||
:empty-message="$t('DATA_IMPORTS.DETAIL.NO_SKIP_LOGS')"
|
||||
@toggle="$emit('toggle')"
|
||||
@download="$emit('download')"
|
||||
>
|
||||
<template v-if="dataImport.skip_logs_count" #filters>
|
||||
<div class="flex flex-wrap gap-2 border-b border-n-weak px-4 py-3">
|
||||
<Button
|
||||
v-for="option in typeOptions"
|
||||
:key="option.value || 'all'"
|
||||
:variant="option.value === selectedType ? 'solid' : 'faded'"
|
||||
color="slate"
|
||||
size="xs"
|
||||
:disabled="!option.count || isChangingType"
|
||||
:label="`${option.label} (${option.count})`"
|
||||
@click="$emit('changeType', option.value)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #row="{ items }">
|
||||
<BaseTableRow v-for="skipLog in items" :key="skipLog.id" :item="skipLog">
|
||||
<template #default>
|
||||
<BaseTableCell>
|
||||
<span class="capitalize text-body-main text-n-slate-12">
|
||||
{{ skipLog.kind || '-' }}
|
||||
</span>
|
||||
</BaseTableCell>
|
||||
<BaseTableCell>
|
||||
<span class="text-body-main text-n-slate-11">
|
||||
{{ sourceObjectLabel(skipLog) }}
|
||||
</span>
|
||||
</BaseTableCell>
|
||||
<BaseTableCell>
|
||||
<span class="text-body-main text-n-slate-11">
|
||||
{{ skipLog.message || '-' }}
|
||||
</span>
|
||||
</BaseTableCell>
|
||||
<BaseTableCell>
|
||||
<span class="whitespace-nowrap text-body-main text-n-slate-11">
|
||||
{{ formatDate(skipLog.created_at) }}
|
||||
</span>
|
||||
</BaseTableCell>
|
||||
</template>
|
||||
</BaseTableRow>
|
||||
</template>
|
||||
</ImportLogSection>
|
||||
</template>
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import formatDistanceStrict from 'date-fns/formatDistanceStrict';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import { formatDate, isActiveImport } from '../importStatus';
|
||||
import { importSourceFor } from '../importSources';
|
||||
|
||||
const props = defineProps({
|
||||
dataImport: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const importTypeLabel = type => {
|
||||
if (type === 'contacts') return t('DATA_IMPORTS.TYPES.CONTACTS');
|
||||
if (type === 'conversations') return t('DATA_IMPORTS.TYPES.CONVERSATIONS');
|
||||
return type;
|
||||
};
|
||||
|
||||
const importTypesLabel = computed(() => {
|
||||
const importTypes = props.dataImport?.import_types?.length
|
||||
? props.dataImport.import_types
|
||||
: [props.dataImport?.data_type].filter(Boolean);
|
||||
|
||||
return importTypes.map(importTypeLabel).join(', ');
|
||||
});
|
||||
|
||||
const runDuration = computed(() => {
|
||||
const startedAt =
|
||||
props.dataImport?.started_at || props.dataImport?.created_at;
|
||||
if (!startedAt) return '-';
|
||||
|
||||
const finishedAt =
|
||||
props.dataImport?.completed_at ||
|
||||
props.dataImport?.abandoned_at ||
|
||||
(isActiveImport(props.dataImport)
|
||||
? new Date()
|
||||
: props.dataImport?.updated_at);
|
||||
if (!finishedAt) return '-';
|
||||
|
||||
return formatDistanceStrict(new Date(startedAt), new Date(finishedAt));
|
||||
});
|
||||
|
||||
const items = computed(() => [
|
||||
{
|
||||
key: 'source',
|
||||
icon: 'i-lucide-plug',
|
||||
label: t('DATA_IMPORTS.DETAIL.SOURCE'),
|
||||
value: importSourceFor(props.dataImport).label,
|
||||
},
|
||||
{
|
||||
key: 'import_types',
|
||||
icon: 'i-lucide-layers',
|
||||
label: t('DATA_IMPORTS.DETAIL.IMPORT_TYPES'),
|
||||
value: importTypesLabel.value || '-',
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
icon: 'i-lucide-calendar',
|
||||
label: t('DATA_IMPORTS.DETAIL.CREATED'),
|
||||
value: formatDate(props.dataImport?.created_at),
|
||||
},
|
||||
{
|
||||
key: 'duration',
|
||||
icon: 'i-lucide-clock',
|
||||
label: t('DATA_IMPORTS.DETAIL.DURATION'),
|
||||
value: runDuration.value,
|
||||
tooltip: t('DATA_IMPORTS.DETAIL.LAST_UPDATED_TOOLTIP', {
|
||||
time: formatDate(props.dataImport?.updated_at),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 'initiated_by',
|
||||
icon: 'i-lucide-user',
|
||||
label: t('DATA_IMPORTS.DETAIL.INITIATED_BY'),
|
||||
value:
|
||||
props.dataImport?.initiated_by?.name ||
|
||||
props.dataImport?.initiated_by?.email ||
|
||||
'-',
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<dl
|
||||
class="grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-n-weak bg-n-weak sm:grid-cols-3 lg:grid-cols-5"
|
||||
>
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.key"
|
||||
class="flex min-w-0 flex-col gap-1 bg-n-solid-1 px-4 py-3"
|
||||
>
|
||||
<dt class="flex items-center gap-1.5 text-label-small text-n-slate-10">
|
||||
<Icon :icon="item.icon" class="size-3.5 shrink-0" />
|
||||
{{ item.label }}
|
||||
</dt>
|
||||
<dd
|
||||
v-tooltip.top="item.tooltip"
|
||||
class="truncate text-heading-3 text-n-slate-12"
|
||||
>
|
||||
{{ item.value }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
import { FEATURE_FLAGS } from '../../../../featureFlags';
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
import SettingsWrapper from '../SettingsWrapper.vue';
|
||||
import Index from './Index.vue';
|
||||
import Show from './Show.vue';
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/settings/data'),
|
||||
component: SettingsWrapper,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'settings_data_imports',
|
||||
component: Index,
|
||||
meta: {
|
||||
featureFlag: FEATURE_FLAGS.DATA_IMPORT,
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: ':dataImportId',
|
||||
name: 'settings_data_import_show',
|
||||
component: Show,
|
||||
meta: {
|
||||
featureFlag: FEATURE_FLAGS.DATA_IMPORT,
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
export const IMPORT_SOURCES = [
|
||||
{
|
||||
value: 'intercom',
|
||||
label: 'Intercom',
|
||||
icon: '/dashboard/images/integrations/intercom.png',
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_IMPORT_SOURCE = {
|
||||
value: 'file',
|
||||
label: 'File import',
|
||||
iconClass: 'i-lucide-file-text',
|
||||
};
|
||||
|
||||
export const importSourceFor = dataImport =>
|
||||
IMPORT_SOURCES.find(source => source.value === dataImport?.source_provider) ||
|
||||
DEFAULT_IMPORT_SOURCE;
|
||||
@@ -0,0 +1,84 @@
|
||||
export const POLL_INTERVAL_MS = 5000;
|
||||
|
||||
export const ACTIVE_IMPORT_STATUSES = ['pending', 'processing'];
|
||||
|
||||
export const isActiveImport = dataImport =>
|
||||
ACTIVE_IMPORT_STATUSES.includes(dataImport?.status);
|
||||
|
||||
export const isIntercomImport = dataImport =>
|
||||
dataImport?.data_type === 'intercom' &&
|
||||
dataImport?.source_provider === 'intercom';
|
||||
|
||||
export const isActiveIntercomImport = dataImport =>
|
||||
isIntercomImport(dataImport) && isActiveImport(dataImport);
|
||||
|
||||
export const isAbandonableImport = dataImport =>
|
||||
isActiveIntercomImport(dataImport);
|
||||
|
||||
export const importedCount = dataImport => {
|
||||
if (!isIntercomImport(dataImport)) {
|
||||
return Number(dataImport?.processed_records || 0);
|
||||
}
|
||||
|
||||
return ['contacts', 'conversations', 'messages'].reduce(
|
||||
(total, key) => total + Number(dataImport?.stats?.[key]?.imported || 0),
|
||||
0
|
||||
);
|
||||
};
|
||||
|
||||
export const importStageKey = dataImport => {
|
||||
if (!dataImport) return 'unknown';
|
||||
|
||||
if (dataImport.status === 'completed') return 'completed';
|
||||
if (dataImport.status === 'completed_with_errors') {
|
||||
return 'completed_with_errors';
|
||||
}
|
||||
if (dataImport.status === 'failed') return 'failed';
|
||||
if (dataImport.status === 'abandoned') return 'abandoned';
|
||||
if (dataImport.status === 'pending') return 'queued';
|
||||
|
||||
const importTypes = dataImport.import_types?.length
|
||||
? dataImport.import_types
|
||||
: [dataImport.data_type];
|
||||
const cursor = dataImport.cursor || {};
|
||||
|
||||
if (importTypes.includes('contacts') && !cursor.contacts?.completed) {
|
||||
return 'contacts';
|
||||
}
|
||||
|
||||
if (
|
||||
importTypes.includes('conversations') &&
|
||||
!cursor.conversations?.completed
|
||||
) {
|
||||
return 'conversations';
|
||||
}
|
||||
|
||||
return 'finalizing';
|
||||
};
|
||||
|
||||
export const formatStatus = value => value?.replaceAll('_', ' ') || '-';
|
||||
|
||||
export const sourceObjectLabel = record =>
|
||||
[record.source_object_type, record.source_object_id]
|
||||
.filter(Boolean)
|
||||
.join(': ') || '-';
|
||||
|
||||
export const formatDate = value => {
|
||||
if (!value) return '-';
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(value));
|
||||
};
|
||||
|
||||
const STATUS_DOT_CLASS = {
|
||||
pending: 'bg-n-amber-9',
|
||||
processing: 'bg-n-blue-9',
|
||||
completed: 'bg-n-teal-9',
|
||||
completed_with_errors: 'bg-n-amber-9',
|
||||
failed: 'bg-n-ruby-9',
|
||||
abandoned: 'bg-n-slate-9',
|
||||
};
|
||||
|
||||
export const statusDotClass = status =>
|
||||
STATUS_DOT_CLASS[status] || 'bg-n-slate-9';
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
formatDate,
|
||||
importedCount,
|
||||
isActiveIntercomImport,
|
||||
statusDotClass,
|
||||
} from '../importStatus';
|
||||
|
||||
describe('importStatus', () => {
|
||||
describe('isActiveIntercomImport', () => {
|
||||
it('only treats pending or processing Intercom imports as active', () => {
|
||||
expect(
|
||||
isActiveIntercomImport({
|
||||
data_type: 'intercom',
|
||||
source_provider: 'intercom',
|
||||
status: 'processing',
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
isActiveIntercomImport({
|
||||
data_type: 'contacts',
|
||||
source_provider: null,
|
||||
status: 'processing',
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
isActiveIntercomImport({
|
||||
data_type: 'intercom',
|
||||
source_provider: 'intercom',
|
||||
status: 'completed',
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('importedCount', () => {
|
||||
it('sums Intercom imported stats', () => {
|
||||
expect(
|
||||
importedCount({
|
||||
data_type: 'intercom',
|
||||
source_provider: 'intercom',
|
||||
processed_records: 20,
|
||||
stats: {
|
||||
contacts: { imported: 2 },
|
||||
conversations: { imported: 3 },
|
||||
messages: { imported: 10 },
|
||||
},
|
||||
})
|
||||
).toBe(15);
|
||||
});
|
||||
|
||||
it('uses processed records for legacy imports', () => {
|
||||
expect(
|
||||
importedCount({
|
||||
data_type: 'contacts',
|
||||
source_provider: null,
|
||||
processed_records: 7,
|
||||
stats: {},
|
||||
})
|
||||
).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('statusDotClass', () => {
|
||||
it('maps each status to its dot color class', () => {
|
||||
expect(statusDotClass('pending')).toBe('bg-n-amber-9');
|
||||
expect(statusDotClass('processing')).toBe('bg-n-blue-9');
|
||||
expect(statusDotClass('completed')).toBe('bg-n-teal-9');
|
||||
expect(statusDotClass('completed_with_errors')).toBe('bg-n-amber-9');
|
||||
expect(statusDotClass('failed')).toBe('bg-n-ruby-9');
|
||||
expect(statusDotClass('abandoned')).toBe('bg-n-slate-9');
|
||||
});
|
||||
|
||||
it('falls back to slate for unknown or missing status', () => {
|
||||
expect(statusDotClass('unknown')).toBe('bg-n-slate-9');
|
||||
expect(statusDotClass(undefined)).toBe('bg-n-slate-9');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('returns a dash for empty values', () => {
|
||||
expect(formatDate(null)).toBe('-');
|
||||
expect(formatDate('')).toBe('-');
|
||||
expect(formatDate(undefined)).toBe('-');
|
||||
});
|
||||
|
||||
it('formats a valid date into a readable string', () => {
|
||||
const formatted = formatDate('2026-07-10T18:09:00Z');
|
||||
expect(formatted).not.toBe('-');
|
||||
expect(formatted).toContain('2026');
|
||||
});
|
||||
});
|
||||
});
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import { KeepAlive, defineComponent, h, nextTick, ref } from 'vue';
|
||||
import DataImportsAPI from 'dashboard/api/dataImports';
|
||||
import Index from '../Index.vue';
|
||||
import Show from '../Show.vue';
|
||||
|
||||
vi.mock('dashboard/api/dataImports', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
show: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables/store', () => ({
|
||||
useStoreGetters: () => ({ getCurrentAccountId: { value: 1 } }),
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables', () => ({
|
||||
useAlert: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: key => key }),
|
||||
}));
|
||||
|
||||
vi.mock('vue-router', async importOriginal => ({
|
||||
...(await importOriginal()),
|
||||
useRoute: () => ({ params: { dataImportId: 1 } }),
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}));
|
||||
|
||||
const deferredRequest = () => {
|
||||
let resolve;
|
||||
const promise = new Promise(resolvePromise => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
const mountKeptAlive = component => {
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
const visible = ref(true);
|
||||
return { visible };
|
||||
},
|
||||
render() {
|
||||
return h(KeepAlive, null, {
|
||||
default: () => (this.visible ? h(component) : null),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return mount(Host, {
|
||||
global: {
|
||||
stubs: {
|
||||
SettingsLayout: true,
|
||||
BaseSettingsHeader: true,
|
||||
Button: true,
|
||||
Icon: true,
|
||||
TabBar: true,
|
||||
NewImportDialog: true,
|
||||
ImportDetailHeader: true,
|
||||
ImportSummaryTiles: true,
|
||||
ImportProgress: true,
|
||||
ImportErrorsSection: true,
|
||||
ImportSkipLogsSection: true,
|
||||
},
|
||||
mocks: {
|
||||
$t: key => key,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
describe('data import polling lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('does not start list polling after the page deactivates', async () => {
|
||||
const request = deferredRequest();
|
||||
DataImportsAPI.get.mockReturnValue(request.promise);
|
||||
const wrapper = mountKeptAlive(Index);
|
||||
await nextTick();
|
||||
|
||||
wrapper.vm.visible = false;
|
||||
await nextTick();
|
||||
request.resolve({ data: { payload: [{ status: 'processing' }] } });
|
||||
await flushPromises();
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
|
||||
expect(DataImportsAPI.get).toHaveBeenCalledTimes(1);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('does not start detail polling after the page deactivates', async () => {
|
||||
const request = deferredRequest();
|
||||
DataImportsAPI.show.mockReturnValue(request.promise);
|
||||
const wrapper = mountKeptAlive(Show);
|
||||
await nextTick();
|
||||
|
||||
wrapper.vm.visible = false;
|
||||
await nextTick();
|
||||
request.resolve({
|
||||
data: {
|
||||
status: 'processing',
|
||||
skip_logs_filters: {},
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
|
||||
expect(DataImportsAPI.show).toHaveBeenCalledTimes(1);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, onActivated, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { picoSearch } from '@scmmishra/pico-search';
|
||||
@@ -27,6 +27,10 @@ const searchQuery = ref('');
|
||||
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
|
||||
onActivated(() => {
|
||||
store.dispatch('inboxes/get');
|
||||
});
|
||||
|
||||
const inboxesList = computed(() => {
|
||||
return inboxes.value?.slice().sort((a, b) => a.name.localeCompare(b.name));
|
||||
});
|
||||
|
||||
@@ -387,12 +387,10 @@ export default {
|
||||
return this.inbox.provider_config?.source === 'embedded_signup';
|
||||
},
|
||||
whatsappUnauthorized() {
|
||||
// The manual migration banner supersedes the embedded-signup reauthorize flow when the feature is enabled.
|
||||
return (
|
||||
this.isAWhatsAppCloudChannel &&
|
||||
this.isEmbeddedSignupWhatsApp &&
|
||||
this.inbox.reauthorization_required &&
|
||||
!this.showWhatsAppManualMigration
|
||||
this.inbox.reauthorization_required
|
||||
);
|
||||
},
|
||||
whatsappRegistrationIncomplete() {
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted } from 'vue';
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import instagramClient from 'dashboard/api/channel/instagramClient';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Banner from 'dashboard/components-next/banner/Banner.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
|
||||
const hasError = ref(false);
|
||||
const errorStateMessage = ref('');
|
||||
const errorStateDescription = ref('');
|
||||
const isRequestingAuthorization = ref(false);
|
||||
const isInstagramConnectionRestricted = computed(() => {
|
||||
return isOnChatwootCloud.value;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
@@ -76,36 +68,11 @@ const requestAuthorization = async () => {
|
||||
class="text-white !rounded-full !px-6 bg-gradient-to-r from-[#833AB4] via-[#FD1D1D] to-[#FCAF45]"
|
||||
lg
|
||||
icon="i-ri-instagram-line"
|
||||
:disabled="
|
||||
isRequestingAuthorization || isInstagramConnectionRestricted
|
||||
"
|
||||
:disabled="isRequestingAuthorization"
|
||||
:is-loading="isRequestingAuthorization"
|
||||
:label="$t('INBOX_MGMT.ADD.INSTAGRAM.CONTINUE_WITH_INSTAGRAM')"
|
||||
@click="requestAuthorization()"
|
||||
/>
|
||||
<Banner
|
||||
v-if="isInstagramConnectionRestricted"
|
||||
color="amber"
|
||||
class="w-full max-w-2xl mt-6"
|
||||
>
|
||||
<div class="flex items-start gap-3 text-left">
|
||||
<Icon
|
||||
icon="i-lucide-triangle-alert"
|
||||
class="flex-shrink-0 size-4 mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.RESTRICTED_WARNING') }}
|
||||
<a
|
||||
:href="META_RESTRICTION_STATUS_URL"
|
||||
class="link underline"
|
||||
rel="noopener noreferrer nofollow"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.STATUS_LINK') }}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</Banner>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+5
-6
@@ -7,8 +7,7 @@ import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
const emit = defineEmits(['start']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const WHATSAPP_MANUAL_MIGRATION_GUIDE_URL =
|
||||
'https://www.chatwoot.com/hc/user-guide/articles/1756799850-how-to-setup-a-whats_app-channel-manual-flow';
|
||||
const WHATSAPP_MANUAL_MIGRATION_GUIDE_URL = 'https://chwt.app/migrate-whatsapp';
|
||||
|
||||
const copy = computed(() => ({
|
||||
title: t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.BANNER.TITLE'),
|
||||
@@ -21,14 +20,14 @@ const copy = computed(() => ({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Banner color="amber" :action-label="copy.start" @action="emit('start')">
|
||||
<Banner color="blue" :action-label="copy.start" @action="emit('start')">
|
||||
<div class="flex items-start gap-2">
|
||||
<Icon
|
||||
icon="i-lucide-triangle-alert"
|
||||
class="flex-shrink-0 mt-0.5 size-4 text-n-amber-11"
|
||||
icon="i-lucide-info"
|
||||
class="flex-shrink-0 mt-0.5 size-4 text-n-blue-11"
|
||||
/>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="font-medium text-n-amber-12">{{ copy.title }}</span>
|
||||
<span class="font-medium text-n-blue-12">{{ copy.title }}</span>
|
||||
<span>
|
||||
{{ copy.description }}
|
||||
<a
|
||||
|
||||
+3
-4
@@ -23,8 +23,7 @@ const emit = defineEmits(['reconnect']);
|
||||
const { t } = useI18n();
|
||||
const { replaceInstallationName } = useBranding();
|
||||
|
||||
const WHATSAPP_MANUAL_MIGRATION_GUIDE_URL =
|
||||
'https://www.chatwoot.com/hc/user-guide/articles/1756799850-how-to-setup-a-whats_app-channel-manual-flow';
|
||||
const WHATSAPP_MANUAL_MIGRATION_GUIDE_URL = 'https://chwt.app/migrate-whatsapp';
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const currentStep = ref(0);
|
||||
@@ -310,9 +309,9 @@ defineExpose({ open, close });
|
||||
class="flex gap-3 p-3 border rounded-xl border-n-weak bg-n-alpha-2"
|
||||
>
|
||||
<span
|
||||
class="grid flex-shrink-0 rounded-lg size-8 place-content-center bg-n-amber-3 text-n-amber-11"
|
||||
class="grid flex-shrink-0 rounded-lg size-8 place-content-center bg-n-blue-3 text-n-blue-11"
|
||||
>
|
||||
<Icon icon="i-lucide-triangle-alert" class="size-4" />
|
||||
<Icon icon="i-lucide-info" class="size-4" />
|
||||
</span>
|
||||
<div>
|
||||
<h4 class="mt-0 mb-1 text-base font-medium text-n-slate-12">
|
||||
|
||||
+59
-1
@@ -1,5 +1,9 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import whatsappChannel from 'dashboard/api/channel/whatsappChannel';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
|
||||
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
|
||||
@@ -30,7 +34,8 @@ export default {
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
return { v$: useVuelidate() };
|
||||
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
|
||||
return { v$: useVuelidate(), runEmbeddedSignup };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -41,15 +46,29 @@ export default {
|
||||
allowedDomains: '',
|
||||
isUpdatingAllowedDomains: false,
|
||||
isSettingDefaults: false,
|
||||
isReconfiguring: false,
|
||||
};
|
||||
},
|
||||
validations: {
|
||||
whatsAppInboxAPIKey: { required },
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
accountId: 'getCurrentAccountId',
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
}),
|
||||
isEmbeddedSignupWhatsApp() {
|
||||
return this.inbox.provider_config?.source === 'embedded_signup';
|
||||
},
|
||||
showWhatsAppReconfigure() {
|
||||
return (
|
||||
this.isEmbeddedSignupWhatsApp &&
|
||||
this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
FEATURE_FLAGS.WHATSAPP_RECONFIGURE
|
||||
)
|
||||
);
|
||||
},
|
||||
isForwardingEnabled() {
|
||||
return !!this.inbox.forwarding_enabled;
|
||||
},
|
||||
@@ -160,6 +179,28 @@ export default {
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||
}
|
||||
},
|
||||
async reconfigureWhatsApp() {
|
||||
this.isReconfiguring = true;
|
||||
try {
|
||||
const credentials = await this.runEmbeddedSignup();
|
||||
// User dismissed the Meta popup without completing signup.
|
||||
if (!credentials) return;
|
||||
|
||||
await whatsappChannel.reauthorizeWhatsApp({
|
||||
inboxId: this.inbox.id,
|
||||
...credentials,
|
||||
});
|
||||
useAlert(
|
||||
this.$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_RECONFIGURE_SUCCESS')
|
||||
);
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
this.$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_RECONFIGURE_ERROR')
|
||||
);
|
||||
} finally {
|
||||
this.isReconfiguring = false;
|
||||
}
|
||||
},
|
||||
async syncTemplates() {
|
||||
this.isSyncingTemplates = true;
|
||||
try {
|
||||
@@ -358,6 +399,23 @@ export default {
|
||||
>
|
||||
<woot-code :script="inbox.provider_config.webhook_verify_token" />
|
||||
</SettingsFieldSection>
|
||||
<SettingsFieldSection
|
||||
v-if="showWhatsAppReconfigure"
|
||||
:label="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_TITLE')
|
||||
"
|
||||
:help-text="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_DESCRIPTION')
|
||||
"
|
||||
>
|
||||
<NextButton
|
||||
:is-loading="isReconfiguring"
|
||||
:disabled="isReconfiguring"
|
||||
@click="reconfigureWhatsApp"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_RECONFIGURE_BUTTON') }}
|
||||
</NextButton>
|
||||
</SettingsFieldSection>
|
||||
</template>
|
||||
|
||||
<!-- Manual Setup Section -->
|
||||
|
||||
@@ -5,9 +5,11 @@ import { useBranding } from 'shared/composables/useBranding';
|
||||
import { picoSearch } from '@scmmishra/pico-search';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import { BaseTable } from 'dashboard/components-next/table';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import NewWebhook from './NewWebHook.vue';
|
||||
import EditWebhook from './EditWebHook.vue';
|
||||
import WebhookRow from './WebhookRow.vue';
|
||||
import WebhookPaywall from './WebhookPaywall.vue';
|
||||
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
|
||||
import SettingsLayout from '../../SettingsLayout.vue';
|
||||
|
||||
@@ -20,6 +22,7 @@ export default {
|
||||
NewWebhook,
|
||||
EditWebhook,
|
||||
WebhookRow,
|
||||
WebhookPaywall,
|
||||
},
|
||||
setup() {
|
||||
const { replaceInstallationName } = useBranding();
|
||||
@@ -39,7 +42,19 @@ export default {
|
||||
...mapGetters({
|
||||
records: 'webhooks/getWebhooks',
|
||||
uiFlags: 'webhooks/getUIFlags',
|
||||
accountId: 'getCurrentAccountId',
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
|
||||
}),
|
||||
apiAndWebhooksEnabled() {
|
||||
return (
|
||||
!this.isOnChatwootCloud ||
|
||||
this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
FEATURE_FLAGS.API_AND_WEBHOOKS
|
||||
)
|
||||
);
|
||||
},
|
||||
integration() {
|
||||
return this.$store.getters['integrations/getIntegration']('webhook');
|
||||
},
|
||||
@@ -57,9 +72,16 @@ export default {
|
||||
];
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
apiAndWebhooksEnabled: {
|
||||
immediate: true,
|
||||
handler(enabled) {
|
||||
if (enabled) this.$store.dispatch('webhooks/get');
|
||||
},
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('integrations/get', 'webhook');
|
||||
this.$store.dispatch('webhooks/get');
|
||||
},
|
||||
methods: {
|
||||
openAddPopup() {
|
||||
@@ -105,10 +127,10 @@ export default {
|
||||
|
||||
<template>
|
||||
<SettingsLayout
|
||||
:is-loading="uiFlags.fetchingList"
|
||||
:is-loading="apiAndWebhooksEnabled && uiFlags.fetchingList"
|
||||
:loading-message="$t('INTEGRATION_SETTINGS.WEBHOOK.LOADING')"
|
||||
:no-records-message="$t('INTEGRATION_SETTINGS.WEBHOOK.LIST.404')"
|
||||
:no-records-found="!records.length"
|
||||
:no-records-found="apiAndWebhooksEnabled && !records.length"
|
||||
>
|
||||
<template #header>
|
||||
<BaseSettingsHeader
|
||||
@@ -118,19 +140,21 @@ export default {
|
||||
:description="replaceInstallationName(integration.description)"
|
||||
:link-text="$t('INTEGRATION_SETTINGS.WEBHOOK.LEARN_MORE')"
|
||||
:search-placeholder="
|
||||
$t('INTEGRATION_SETTINGS.WEBHOOK.SEARCH_PLACEHOLDER')
|
||||
apiAndWebhooksEnabled
|
||||
? $t('INTEGRATION_SETTINGS.WEBHOOK.SEARCH_PLACEHOLDER')
|
||||
: ''
|
||||
"
|
||||
feature-name="webhook"
|
||||
:back-button-label="$t('INTEGRATION_SETTINGS.HEADER')"
|
||||
>
|
||||
<template v-if="records?.length" #count>
|
||||
<template v-if="apiAndWebhooksEnabled && records?.length" #count>
|
||||
<span class="text-body-main text-n-slate-11">
|
||||
{{
|
||||
$t('INTEGRATION_SETTINGS.WEBHOOK.COUNT', { n: records.length })
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<template v-if="apiAndWebhooksEnabled" #actions>
|
||||
<NextButton
|
||||
blue
|
||||
:label="$t('INTEGRATION_SETTINGS.WEBHOOK.HEADER_BTN_TXT')"
|
||||
@@ -141,7 +165,9 @@ export default {
|
||||
</BaseSettingsHeader>
|
||||
</template>
|
||||
<template #body>
|
||||
<WebhookPaywall v-if="!apiAndWebhooksEnabled" />
|
||||
<BaseTable
|
||||
v-else
|
||||
:headers="tableHeaders"
|
||||
:items="filteredRecords"
|
||||
:no-data-message="
|
||||
@@ -160,11 +186,19 @@ export default {
|
||||
</template>
|
||||
</BaseTable>
|
||||
</template>
|
||||
<woot-modal v-model:show="showAddPopup" :on-close="hideAddPopup">
|
||||
<woot-modal
|
||||
v-if="apiAndWebhooksEnabled"
|
||||
v-model:show="showAddPopup"
|
||||
:on-close="hideAddPopup"
|
||||
>
|
||||
<NewWebhook v-if="showAddPopup" :on-close="hideAddPopup" />
|
||||
</woot-modal>
|
||||
|
||||
<woot-modal v-model:show="showEditPopup" :on-close="hideEditPopup">
|
||||
<woot-modal
|
||||
v-if="apiAndWebhooksEnabled"
|
||||
v-model:show="showEditPopup"
|
||||
:on-close="hideEditPopup"
|
||||
>
|
||||
<EditWebhook
|
||||
v-if="showEditPopup"
|
||||
:id="selectedWebHook.id"
|
||||
@@ -173,6 +207,7 @@ export default {
|
||||
/>
|
||||
</woot-modal>
|
||||
<woot-delete-modal
|
||||
v-if="apiAndWebhooksEnabled"
|
||||
v-model:show="showDeleteConfirmationPopup"
|
||||
:on-close="closeDeletePopup"
|
||||
:on-confirm="confirmDeletion"
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
|
||||
const openBilling = () => {
|
||||
router.push({
|
||||
name: 'billing_settings_index',
|
||||
params: { accountId: accountId.value },
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid place-content-center w-full h-full max-h-[28rem] mx-auto">
|
||||
<BasePaywallModal
|
||||
class="mx-auto"
|
||||
feature-prefix="INTEGRATION_SETTINGS.WEBHOOK"
|
||||
i18n-key="PAYWALL"
|
||||
is-on-chatwoot-cloud
|
||||
@upgrade="openBilling"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -6,6 +6,7 @@ import ConfirmButton from 'dashboard/components-next/button/ConfirmButton.vue';
|
||||
const props = defineProps({
|
||||
value: { type: String, default: '' },
|
||||
showResetButton: { type: Boolean, default: true },
|
||||
disabled: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['onCopy', 'onReset']);
|
||||
@@ -41,12 +42,14 @@ const onReset = () => {
|
||||
}"
|
||||
:type="inputType"
|
||||
:model-value="value"
|
||||
:disabled="disabled"
|
||||
readonly
|
||||
>
|
||||
<template #masked>
|
||||
<button
|
||||
class="absolute top-0 bottom-0 ltr:right-0.5 rtl:left-0.5"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
@click="toggleMasked"
|
||||
>
|
||||
<fluent-icon :icon="maskIcon" :size="16" />
|
||||
@@ -61,6 +64,7 @@ const onReset = () => {
|
||||
type="button"
|
||||
icon="i-lucide-copy"
|
||||
class="rounded-xl"
|
||||
:disabled="disabled"
|
||||
@click="onClick"
|
||||
/>
|
||||
<ConfirmButton
|
||||
@@ -73,6 +77,7 @@ const onReset = () => {
|
||||
variant="outline"
|
||||
icon="i-lucide-key-round"
|
||||
class="rounded-xl"
|
||||
:disabled="disabled"
|
||||
@click="onReset"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -101,7 +101,24 @@ export default {
|
||||
currentUser: 'getCurrentUser',
|
||||
currentUserId: 'getCurrentUserID',
|
||||
globalConfig: 'globalConfig/get',
|
||||
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
|
||||
}),
|
||||
apiAndWebhooksEnabled() {
|
||||
if (!this.isOnChatwootCloud) return true;
|
||||
|
||||
return this.currentUser.accounts.some(
|
||||
account => account.api_and_webhooks
|
||||
);
|
||||
},
|
||||
accessTokenDescription() {
|
||||
if (!this.apiAndWebhooksEnabled) {
|
||||
return this.$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.PAID_PLAN_NOTE');
|
||||
}
|
||||
|
||||
return this.replaceInstallationName(
|
||||
this.$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.NOTE')
|
||||
);
|
||||
},
|
||||
isMfaEnabled() {
|
||||
return parseBoolean(window.chatwootConfig?.isMfaEnabled);
|
||||
},
|
||||
@@ -191,10 +208,14 @@ export default {
|
||||
useAlert(this.$t('PROFILE_SETTINGS.FORM.SEND_MESSAGE.UPDATE_SUCCESS'));
|
||||
},
|
||||
async onCopyToken(value) {
|
||||
if (!this.apiAndWebhooksEnabled) return;
|
||||
|
||||
await copyTextToClipboard(value);
|
||||
useAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
|
||||
},
|
||||
async resetAccessToken() {
|
||||
if (!this.apiAndWebhooksEnabled) return;
|
||||
|
||||
const success = await this.$store.dispatch('resetAccessToken');
|
||||
if (success) {
|
||||
useAlert(this.$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.RESET_SUCCESS'));
|
||||
@@ -339,12 +360,11 @@ export default {
|
||||
<SectionLayout
|
||||
with-border
|
||||
:title="$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.TITLE')"
|
||||
:description="
|
||||
replaceInstallationName($t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.NOTE'))
|
||||
"
|
||||
:description="accessTokenDescription"
|
||||
>
|
||||
<AccessToken
|
||||
:value="currentUser.access_token"
|
||||
:disabled="!apiAndWebhooksEnabled"
|
||||
@on-copy="onCopyToken"
|
||||
@on-reset="resetAccessToken"
|
||||
/>
|
||||
|
||||
@@ -26,6 +26,7 @@ import profile from './profile/profile.routes';
|
||||
import security from './security/security.routes';
|
||||
import conversationWorkflow from './conversationWorkflow/conversationWorkflow.routes';
|
||||
import captain from './captain/captain.routes';
|
||||
import data from './data/data.routes';
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
@@ -57,6 +58,7 @@ export default {
|
||||
...canned.routes,
|
||||
...inbox.routes,
|
||||
...integrations.routes,
|
||||
...data.routes,
|
||||
...labels.routes,
|
||||
...macros.routes,
|
||||
...reports.routes,
|
||||
|
||||
@@ -25,24 +25,32 @@ const fetchMetaData = async (commit, params) => {
|
||||
}
|
||||
};
|
||||
|
||||
const debouncedFetchMetaData = debounce(fetchMetaData, 500, false, 2000);
|
||||
const longDebouncedFetchMetaData = debounce(fetchMetaData, 5000, false, 10000);
|
||||
const debouncedFetchMetaData = debounce(fetchMetaData, 1000, false, 5000);
|
||||
const longDebouncedFetchMetaData = debounce(fetchMetaData, 7500, false, 20000);
|
||||
const superLongDebouncedFetchMetaData = debounce(
|
||||
fetchMetaData,
|
||||
10000,
|
||||
15000,
|
||||
false,
|
||||
20000
|
||||
30000
|
||||
);
|
||||
|
||||
const metaDebouncers = {
|
||||
default: debouncedFetchMetaData,
|
||||
long: longDebouncedFetchMetaData,
|
||||
superLong: superLongDebouncedFetchMetaData,
|
||||
};
|
||||
|
||||
// allCount is 0 until a meta request succeeds; under load it stays 0, so treat
|
||||
// the unknown case as a large account and poll slowest instead of fastest.
|
||||
export const getMetaDebounceKey = allCount => {
|
||||
if (allCount > 2000 || allCount === 0) return 'superLong';
|
||||
if (allCount > 100) return 'long';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
get: async ({ commit, state: $state }, params) => {
|
||||
if ($state.allCount > 2000) {
|
||||
superLongDebouncedFetchMetaData(commit, params);
|
||||
} else if ($state.allCount > 100) {
|
||||
longDebouncedFetchMetaData(commit, params);
|
||||
} else {
|
||||
debouncedFetchMetaData(commit, params);
|
||||
}
|
||||
get: ({ commit, state: $state }, params) => {
|
||||
metaDebouncers[getMetaDebounceKey($state.allCount)](commit, params);
|
||||
},
|
||||
set({ commit }, meta) {
|
||||
commit(types.SET_CONV_TAB_META, meta);
|
||||
|
||||
@@ -96,6 +96,32 @@ export const actions = {
|
||||
}
|
||||
},
|
||||
|
||||
// Push the draft to live and clear it, optionally changing status in the same
|
||||
// update. Only edited fields are sent so an untouched live value survives.
|
||||
publishDraft: ({ dispatch, state }, { portalSlug, articleId, status }) => {
|
||||
const article = state.articles.byId[articleId];
|
||||
const payload = {
|
||||
portalSlug,
|
||||
articleId,
|
||||
status,
|
||||
draft_title: null,
|
||||
draft_content: null,
|
||||
};
|
||||
if (article?.draftTitle != null) payload.title = article.draftTitle;
|
||||
if (article?.draftContent != null) payload.content = article.draftContent;
|
||||
return dispatch('update', payload);
|
||||
},
|
||||
|
||||
// Clear the draft (optionally changing status); live content is left untouched.
|
||||
discardDraft: ({ dispatch }, { portalSlug, articleId, status }) =>
|
||||
dispatch('update', {
|
||||
portalSlug,
|
||||
articleId,
|
||||
status,
|
||||
draft_title: null,
|
||||
draft_content: null,
|
||||
}),
|
||||
|
||||
updateArticleMeta: async ({ commit }, { portalSlug, locale }) => {
|
||||
try {
|
||||
const { data } = await articlesAPI.getArticles({
|
||||
|
||||
@@ -150,6 +150,101 @@ describe('#actions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#publishDraft', () => {
|
||||
const state = {
|
||||
articles: {
|
||||
byId: {
|
||||
1: {
|
||||
id: 1,
|
||||
draftTitle: 'Draft title',
|
||||
draftContent: 'Draft content',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it('dispatches update promoting the edited fields and clearing the draft', async () => {
|
||||
await actions.publishDraft(
|
||||
{ dispatch, state },
|
||||
{ portalSlug: 'room-rental', articleId: 1 }
|
||||
);
|
||||
expect(dispatch).toHaveBeenCalledWith('update', {
|
||||
portalSlug: 'room-rental',
|
||||
articleId: 1,
|
||||
status: undefined,
|
||||
draft_title: null,
|
||||
draft_content: null,
|
||||
title: 'Draft title',
|
||||
content: 'Draft content',
|
||||
});
|
||||
});
|
||||
|
||||
it('only sends the fields that were actually edited', async () => {
|
||||
const partialState = {
|
||||
articles: { byId: { 1: { id: 1, draftContent: 'Only content' } } },
|
||||
};
|
||||
await actions.publishDraft(
|
||||
{ dispatch, state: partialState },
|
||||
{ portalSlug: 'room-rental', articleId: 1 }
|
||||
);
|
||||
expect(dispatch).toHaveBeenCalledWith('update', {
|
||||
portalSlug: 'room-rental',
|
||||
articleId: 1,
|
||||
status: undefined,
|
||||
draft_title: null,
|
||||
draft_content: null,
|
||||
content: 'Only content',
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards a status to change it in the same update', async () => {
|
||||
await actions.publishDraft(
|
||||
{ dispatch, state },
|
||||
{ portalSlug: 'room-rental', articleId: 1, status: 'archived' }
|
||||
);
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
'update',
|
||||
expect.objectContaining({
|
||||
status: 'archived',
|
||||
title: 'Draft title',
|
||||
content: 'Draft content',
|
||||
draft_title: null,
|
||||
draft_content: null,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#discardDraft', () => {
|
||||
it('dispatches update clearing the draft columns', async () => {
|
||||
await actions.discardDraft(
|
||||
{ dispatch },
|
||||
{ portalSlug: 'room-rental', articleId: 1 }
|
||||
);
|
||||
expect(dispatch).toHaveBeenCalledWith('update', {
|
||||
portalSlug: 'room-rental',
|
||||
articleId: 1,
|
||||
status: undefined,
|
||||
draft_title: null,
|
||||
draft_content: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards a status to change it in the same update', async () => {
|
||||
await actions.discardDraft(
|
||||
{ dispatch },
|
||||
{ portalSlug: 'room-rental', articleId: 1, status: 'draft' }
|
||||
);
|
||||
expect(dispatch).toHaveBeenCalledWith('update', {
|
||||
portalSlug: 'room-rental',
|
||||
articleId: 1,
|
||||
status: 'draft',
|
||||
draft_title: null,
|
||||
draft_content: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#updateArticleMeta', () => {
|
||||
it('sends correct actions if API is success', async () => {
|
||||
axios.get.mockResolvedValue({
|
||||
|
||||
@@ -67,8 +67,11 @@ const createMarkdownInstance = (linkify = true) => {
|
||||
// `<!--cw-colwidths:...-->` comment before the table. It exists only for the
|
||||
// editor's markdown round-trip and must never surface as text — markdown-it runs
|
||||
// with `html: false`, which would otherwise escape it into a visible comment in
|
||||
// rendered/plain output (e.g. dashboard search snippets). Strip it on the way in.
|
||||
const COLWIDTHS_MARKER_REGEX = /<!--cw-colwidths:[\d,]+-->\r?\n?/g;
|
||||
// rendered/plain output (e.g. dashboard search snippets). Strip the whole marker
|
||||
// line, including any blockquote prefix, so a quoted table's `>` prefixes don't
|
||||
// collapse together and break table parsing.
|
||||
const COLWIDTHS_MARKER_REGEX =
|
||||
/^[ \t>]*<!--cw-colwidths:[\d,]+-->[ \t]*\r?\n?/gm;
|
||||
|
||||
const TWITTER_USERNAME_REGEX = /(^|[^@\w])@(\w{1,15})\b/g;
|
||||
const TWITTER_USERNAME_REPLACEMENT = '$1[@$2](http://twitter.com/$2)';
|
||||
@@ -104,6 +107,11 @@ class MessageFormatter {
|
||||
return this.md.render(updatedMessage);
|
||||
}
|
||||
|
||||
disableImageRendering() {
|
||||
this.md.disable(['add-image-sizing']);
|
||||
this.md.renderer.rules.image = () => '';
|
||||
}
|
||||
|
||||
get formattedMessage() {
|
||||
return this.formatMessage();
|
||||
}
|
||||
|
||||
@@ -68,6 +68,25 @@ describe('#MessageFormatter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#disableImageRendering', () => {
|
||||
it('omits nested and reference images with relative URLs', () => {
|
||||
const message = `Before ![nested [alt]](/relative.png)
|
||||
|
||||
![reference][logo]
|
||||
|
||||
[logo]: /logo.png
|
||||
|
||||
After`;
|
||||
const formatter = new MessageFormatter(message);
|
||||
|
||||
formatter.disableImageRendering();
|
||||
|
||||
expect(formatter.formattedMessage).not.toContain('<img');
|
||||
expect(formatter.formattedMessage).toContain('Before');
|
||||
expect(formatter.formattedMessage).toContain('After');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tweets', () => {
|
||||
it('should return the same string if not tags or @mentions', () => {
|
||||
const message = 'Chatwoot is an opensource tool';
|
||||
@@ -134,6 +153,15 @@ describe('#MessageFormatter', () => {
|
||||
expect(formatter.formattedMessage).not.toContain('cw-colwidths');
|
||||
expect(formatter.plainText).not.toContain('cw-colwidths');
|
||||
});
|
||||
|
||||
it('strips a blockquote-prefixed marker so the quoted table still renders', () => {
|
||||
const message =
|
||||
'> <!--cw-colwidths:120,200-->\n> | A | B |\n> | --- | --- |\n> | 1 | 2 |';
|
||||
const { formattedMessage } = new MessageFormatter(message);
|
||||
expect(formattedMessage).not.toContain('cw-colwidths');
|
||||
expect(formattedMessage).toContain('<blockquote>');
|
||||
expect(formattedMessage).toContain('<table>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#sanitize', () => {
|
||||
|
||||
@@ -66,6 +66,9 @@ export default {
|
||||
? getLanguageDirection(this.$root.$i18n.locale)
|
||||
: false;
|
||||
},
|
||||
isUnreadOrCampaignView() {
|
||||
return ['unread-messages', 'campaigns'].includes(this.$route.name);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
activeCampaign() {
|
||||
@@ -374,6 +377,7 @@ export default {
|
||||
'is-widget-right': isRightAligned,
|
||||
'is-bubble-hidden': hideMessageBubble,
|
||||
'is-flat-design': isWidgetStyleFlat,
|
||||
'bg-n-slate-2 dark:bg-n-solid-1': !isUnreadOrCampaignView,
|
||||
dark: prefersDarkMode,
|
||||
}"
|
||||
>
|
||||
|
||||
@@ -10,7 +10,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-white h-full">
|
||||
<div class="bg-n-solid-1 h-full">
|
||||
<IframeLoader :url="$route.query.link" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
class DataImports::Intercom::BaseJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
retry_on DataImports::Intercom::Client::Error, wait: 1.minute, attempts: 3 do |job, error|
|
||||
job.fail_import!(error)
|
||||
end
|
||||
|
||||
retry_on DataImports::Intercom::Client::RateLimitError, wait: 1.minute, attempts: 5 do |job, error|
|
||||
job.fail_import!(error)
|
||||
end
|
||||
|
||||
def fail_import!(error)
|
||||
data_import = arguments.first
|
||||
run_id = arguments.length > 1 ? arguments.last : nil
|
||||
return if data_import.blank? || skip_import?(data_import, run_id)
|
||||
|
||||
DataImports::Intercom::Importer.new(data_import: data_import, run_id: run_id).fail!(error)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def skip_import?(data_import, run_id = nil)
|
||||
data_import.reload
|
||||
data_import.abandoned? || data_import.failed? || data_import.completed? ||
|
||||
data_import.completed_with_errors? || stale_import_run?(data_import, run_id)
|
||||
end
|
||||
|
||||
def stale_import_run?(data_import, run_id)
|
||||
active_run_id = data_import.active_intercom_import_run_id
|
||||
active_run_id.present? && active_run_id != run_id
|
||||
end
|
||||
|
||||
def importer_for(data_import, run_id = nil)
|
||||
DataImports::Intercom::Importer.new(data_import: data_import, run_id: run_id)
|
||||
end
|
||||
|
||||
def fail_unexpected_error(importer, error)
|
||||
raise error if error.is_a?(DataImports::Intercom::Client::Error)
|
||||
|
||||
importer&.fail!(error)
|
||||
raise error
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
class DataImports::Intercom::ContactsPageJob < DataImports::Intercom::BaseJob
|
||||
def perform(data_import, starting_after = nil, run_id = nil)
|
||||
return if skip_import?(data_import, run_id)
|
||||
|
||||
importer = importer_for(data_import, run_id)
|
||||
return enqueue_conversations_or_finish(data_import, importer, run_id) if importer.contacts_completed?
|
||||
|
||||
result = importer.import_contacts_page(starting_after: starting_after)
|
||||
return if skip_import?(data_import, run_id)
|
||||
return self.class.perform_later(data_import, result.next_cursor, run_id) unless result.done?
|
||||
|
||||
enqueue_conversations_or_finish(data_import, importer, run_id)
|
||||
rescue StandardError => e
|
||||
fail_unexpected_error(importer, e)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def enqueue_conversations_or_finish(data_import, importer, run_id)
|
||||
if importer.import_conversations? && !importer.conversations_completed?
|
||||
DataImports::Intercom::ConversationsPageJob.perform_later(data_import, importer.cursor_for('conversations'), run_id)
|
||||
else
|
||||
importer.finish!
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
class DataImports::Intercom::ConversationsPageJob < DataImports::Intercom::BaseJob
|
||||
def perform(data_import, starting_after = nil, run_id = nil)
|
||||
return if skip_import?(data_import, run_id)
|
||||
|
||||
importer = importer_for(data_import, run_id)
|
||||
return importer.finish! if importer.conversations_completed?
|
||||
|
||||
result = importer.import_conversations_page(starting_after: starting_after)
|
||||
return if skip_import?(data_import, run_id)
|
||||
return self.class.perform_later(data_import, result.next_cursor, run_id) unless result.done?
|
||||
|
||||
importer.finish!
|
||||
rescue StandardError => e
|
||||
fail_unexpected_error(importer, e)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,24 @@
|
||||
class DataImports::Intercom::ImportJob < DataImports::Intercom::BaseJob
|
||||
def perform(data_import, run_id = nil)
|
||||
return if skip_import?(data_import, run_id)
|
||||
|
||||
importer = importer_for(data_import, run_id)
|
||||
return unless importer.start!
|
||||
|
||||
enqueue_next_stage(data_import, importer, run_id)
|
||||
rescue StandardError => e
|
||||
fail_unexpected_error(importer, e)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def enqueue_next_stage(data_import, importer, run_id)
|
||||
if importer.import_contacts? && !importer.contacts_completed?
|
||||
DataImports::Intercom::ContactsPageJob.perform_later(data_import, importer.cursor_for('contacts'), run_id)
|
||||
elsif importer.import_conversations? && !importer.conversations_completed?
|
||||
DataImports::Intercom::ConversationsPageJob.perform_later(data_import, importer.cursor_for('conversations'), run_id)
|
||||
else
|
||||
importer.finish!
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -108,6 +108,8 @@ class WebhookListener < BaseListener
|
||||
end
|
||||
|
||||
def deliver_account_webhooks(payload, account)
|
||||
return unless account.api_and_webhooks_enabled?
|
||||
|
||||
account.webhooks.account_type.each do |webhook|
|
||||
next unless webhook.subscriptions.include?(payload[:event])
|
||||
|
||||
|
||||
@@ -154,6 +154,10 @@ class Account < ApplicationRecord
|
||||
}
|
||||
end
|
||||
|
||||
def api_and_webhooks_enabled?
|
||||
true
|
||||
end
|
||||
|
||||
def locale_english_name
|
||||
# the locale can also be something like pt_BR, en_US, fr_FR, etc.
|
||||
# the format is `<locale_code>_<country_code>`
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
# id :bigint not null, primary key
|
||||
# content :text
|
||||
# description :text
|
||||
# draft_content :text
|
||||
# draft_title :string
|
||||
# locale :string default("en"), not null
|
||||
# meta :jsonb
|
||||
# position :integer
|
||||
|
||||
@@ -101,6 +101,13 @@ class Channel::Whatsapp < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
# Whether the pending (unsaved) provider_config change drops the embedded_signup
|
||||
# source marker, i.e. this save is an embedded signup → manual setup transfer.
|
||||
def embedded_to_manual_transfer_pending?
|
||||
before, after = provider_config_change
|
||||
before&.dig('source') == 'embedded_signup' && after['source'] != 'embedded_signup'
|
||||
end
|
||||
|
||||
def mark_message_templates_updated
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
update_column(:message_templates_last_updated, Time.zone.now)
|
||||
@@ -130,13 +137,14 @@ class Channel::Whatsapp < ApplicationRecord
|
||||
errors.add(:provider_config, 'Invalid Credentials') unless provider_service.validate_provider_config?
|
||||
end
|
||||
|
||||
# Logs only credential changes, so config-only saves (e.g. calling toggles) stay silent.
|
||||
# Logs only the embedded signup → manual migration (the save drops the
|
||||
# embedded_signup source marker), so credential rotations on inboxes that are
|
||||
# already manual stay silent.
|
||||
def log_credentials_transfer
|
||||
before, after = saved_change_to_provider_config
|
||||
keys = %w[api_key phone_number_id business_account_id]
|
||||
return if before.nil? || before.values_at(*keys) == after.values_at(*keys)
|
||||
return unless before&.dig('source') == 'embedded_signup' && after['source'] != 'embedded_signup'
|
||||
|
||||
Rails.logger.info("[WHATSAPP_MANUAL_TRANSFER] success account_id=#{account_id} channel_id=#{id}")
|
||||
Rails.logger.info("[WHATSAPP_EMBEDDED_TO_MANUAL] success account_id=#{account_id} channel_id=#{id}")
|
||||
end
|
||||
|
||||
def perform_webhook_setup
|
||||
|
||||
@@ -54,7 +54,20 @@ module ActivityMessageHandler
|
||||
user_status_change_activity_content(user_name)
|
||||
end
|
||||
|
||||
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
|
||||
return if content.blank?
|
||||
|
||||
::Conversations::ActivityMessageJob.perform_later(
|
||||
self,
|
||||
activity_message_params(
|
||||
content,
|
||||
content_attributes: {
|
||||
activity: {
|
||||
type: 'conversation_status_changed',
|
||||
status: status
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
def auto_resolve_message_key(minutes)
|
||||
@@ -87,8 +100,10 @@ module ActivityMessageHandler
|
||||
end
|
||||
end
|
||||
|
||||
def activity_message_params(content)
|
||||
{ account_id: account_id, inbox_id: inbox_id, message_type: :activity, content: content }
|
||||
def activity_message_params(content, content_attributes: nil)
|
||||
params = { account_id: account_id, inbox_id: inbox_id, message_type: :activity, content: content }
|
||||
params[:content_attributes] = content_attributes if content_attributes.present?
|
||||
params
|
||||
end
|
||||
|
||||
def create_muted_message
|
||||
|
||||
@@ -3,33 +3,115 @@
|
||||
# Table name: data_imports
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# abandoned_at :datetime
|
||||
# access_token :text
|
||||
# completed_at :datetime
|
||||
# cursor :jsonb not null
|
||||
# data_type :string not null
|
||||
# import_types :jsonb not null
|
||||
# last_error_at :datetime
|
||||
# name :string
|
||||
# processed_records :integer
|
||||
# processing_errors :text
|
||||
# source_metadata :jsonb not null
|
||||
# source_provider :string
|
||||
# source_type :string
|
||||
# started_at :datetime
|
||||
# stats :jsonb not null
|
||||
# status :integer default("pending"), not null
|
||||
# total_records :integer
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# initiated_by_id :integer
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_data_imports_on_account_id (account_id)
|
||||
# index_data_imports_on_account_id (account_id)
|
||||
# index_data_imports_on_initiated_by_id (initiated_by_id)
|
||||
# index_data_imports_on_source_provider (source_provider)
|
||||
#
|
||||
class DataImport < ApplicationRecord
|
||||
ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY = 'active_intercom_import_run_id'.freeze
|
||||
LEGACY_DATA_TYPES = ['contacts'].freeze
|
||||
INTEGRATION_DATA_TYPES = ['intercom'].freeze
|
||||
IMPORT_TYPES = %w[contacts conversations].freeze
|
||||
|
||||
belongs_to :account
|
||||
validates :data_type, inclusion: { in: ['contacts'], message: I18n.t('errors.data_import.data_type.invalid') }
|
||||
enum status: { pending: 0, processing: 1, completed: 2, failed: 3 }
|
||||
belongs_to :initiated_by, class_name: 'User', optional: true
|
||||
|
||||
encrypts :access_token if Chatwoot.encryption_configured?
|
||||
|
||||
has_many :items, class_name: 'DataImportItem', dependent: :destroy_async
|
||||
has_many :mappings, class_name: 'DataImportMapping', dependent: :destroy_async
|
||||
has_many :import_errors, class_name: 'DataImportError', dependent: :destroy_async
|
||||
|
||||
validates :data_type, inclusion: { in: LEGACY_DATA_TYPES + INTEGRATION_DATA_TYPES, message: I18n.t('errors.data_import.data_type.invalid') }
|
||||
validates :access_token, presence: true, on: :create, if: :intercom_import?
|
||||
validate :validate_import_types
|
||||
|
||||
enum status: { pending: 0, processing: 1, completed: 2, failed: 3, completed_with_errors: 6, abandoned: 7 }
|
||||
|
||||
scope :active_intercom, -> { where(data_type: 'intercom', source_provider: 'intercom', status: [:pending, :processing]) }
|
||||
|
||||
has_one_attached :import_file
|
||||
has_one_attached :failed_records
|
||||
|
||||
after_create_commit :process_data_import
|
||||
|
||||
def legacy_contacts_csv_import?
|
||||
data_type == 'contacts' && source_provider.blank?
|
||||
end
|
||||
|
||||
def intercom_import?
|
||||
data_type == 'intercom' && source_provider == 'intercom'
|
||||
end
|
||||
|
||||
def restartable?
|
||||
failed? || abandoned?
|
||||
end
|
||||
|
||||
def abandonable?
|
||||
intercom_import? && (pending? || processing?)
|
||||
end
|
||||
|
||||
def abandon!
|
||||
self.class.transaction do
|
||||
abandonable_import = self.class.lock.find_by(
|
||||
id: id,
|
||||
data_type: 'intercom',
|
||||
source_provider: 'intercom',
|
||||
status: [:pending, :processing]
|
||||
)
|
||||
abandonable_import&.update!(status: :abandoned, abandoned_at: Time.current)
|
||||
end
|
||||
reload
|
||||
end
|
||||
|
||||
def active_intercom_import_run_id
|
||||
source_metadata.to_h[ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY]
|
||||
end
|
||||
|
||||
def assign_active_intercom_import_run_id
|
||||
self.source_metadata = source_metadata.to_h.merge(ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY => SecureRandom.uuid)
|
||||
active_intercom_import_run_id
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_data_import
|
||||
return unless legacy_contacts_csv_import?
|
||||
|
||||
# we wait for the file to be uploaded to the cloud
|
||||
DataImportJob.set(wait: 1.minute).perform_later(self)
|
||||
end
|
||||
|
||||
def validate_import_types
|
||||
return if import_types.blank?
|
||||
|
||||
invalid_types = import_types - IMPORT_TYPES
|
||||
return if invalid_types.blank?
|
||||
|
||||
errors.add(:import_types, "contains unsupported values: #{invalid_types.join(', ')}")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: data_import_errors
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# details :jsonb not null
|
||||
# error_code :string not null
|
||||
# message :text
|
||||
# source_object_type :string
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# data_import_id :bigint not null
|
||||
# data_import_item_id :bigint
|
||||
# source_object_id :string
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# idx_data_import_errors_on_source (source_object_type,source_object_id)
|
||||
# index_data_import_errors_on_data_import_id (data_import_id)
|
||||
# index_data_import_errors_on_data_import_item_id (data_import_item_id)
|
||||
#
|
||||
class DataImportError < ApplicationRecord
|
||||
SKIP_LOG_KINDS = %w[failed skipped].freeze
|
||||
|
||||
belongs_to :data_import
|
||||
belongs_to :data_import_item, optional: true
|
||||
|
||||
validates :error_code, presence: true
|
||||
|
||||
scope :skip_logs, -> { where("details ->> 'kind' IN (:kinds)", kinds: SKIP_LOG_KINDS) }
|
||||
scope :failed, -> { where("details ->> 'kind' = ?", 'failed') }
|
||||
scope :non_skip_logs, -> { where("details ->> 'kind' IS NULL OR details ->> 'kind' NOT IN (:kinds)", kinds: SKIP_LOG_KINDS) }
|
||||
end
|
||||
@@ -0,0 +1,35 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: data_import_items
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# attempt_count :integer default(0), not null
|
||||
# chatwoot_record_type :string
|
||||
# last_error_code :string
|
||||
# last_error_message :text
|
||||
# metadata :jsonb not null
|
||||
# source_object_type :string not null
|
||||
# source_provider :string not null
|
||||
# status :integer default("pending"), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# chatwoot_record_id :bigint
|
||||
# data_import_id :bigint not null
|
||||
# source_object_id :string not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# idx_data_import_items_on_import_and_source (data_import_id,source_object_type,source_object_id) UNIQUE
|
||||
# idx_data_import_items_on_record (chatwoot_record_type,chatwoot_record_id)
|
||||
# idx_data_import_items_on_source (source_provider,source_object_type,source_object_id)
|
||||
# index_data_import_items_on_data_import_id (data_import_id)
|
||||
#
|
||||
class DataImportItem < ApplicationRecord
|
||||
belongs_to :data_import
|
||||
has_many :import_errors, class_name: 'DataImportError', dependent: :destroy_async
|
||||
|
||||
validates :source_provider, :source_object_type, :source_object_id, presence: true
|
||||
validates :source_object_id, uniqueness: { scope: [:data_import_id, :source_object_type] }
|
||||
|
||||
enum status: { pending: 0, processing: 1, imported: 2, skipped: 3, failed: 4 }
|
||||
end
|
||||
@@ -0,0 +1,33 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: data_import_mappings
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# chatwoot_record_type :string not null
|
||||
# metadata :jsonb not null
|
||||
# source_object_type :string not null
|
||||
# source_provider :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :integer not null
|
||||
# chatwoot_record_id :bigint not null
|
||||
# data_import_id :bigint not null
|
||||
# source_object_id :string not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# idx_data_import_mappings_on_account_and_source (account_id,source_provider,source_object_type,source_object_id) UNIQUE
|
||||
# idx_data_import_mappings_on_record (chatwoot_record_type,chatwoot_record_id)
|
||||
# index_data_import_mappings_on_data_import_id (data_import_id)
|
||||
#
|
||||
class DataImportMapping < ApplicationRecord
|
||||
belongs_to :data_import
|
||||
belongs_to :account
|
||||
|
||||
validates :source_provider, :source_object_type, :source_object_id, :chatwoot_record_type, :chatwoot_record_id, presence: true
|
||||
validates :source_object_id, uniqueness: { scope: [:account_id, :source_provider, :source_object_type] }
|
||||
|
||||
def chatwoot_record
|
||||
chatwoot_record_type.constantize.find_by(id: chatwoot_record_id)
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user