Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b03193c881 | ||
|
|
a61a8bfbc6 | ||
|
|
4cb89d0de1 | ||
|
|
259187e1fd | ||
|
|
90861f8809 | ||
|
|
c420edce58 | ||
|
|
331875cdaa | ||
|
|
5af26e45a9 | ||
|
|
f948b0b8d9 | ||
|
|
081e08c7c6 | ||
|
|
dc3a4814aa | ||
|
|
5e811eab99 | ||
|
|
9749a3dc96 | ||
|
|
8dd0d08322 | ||
|
|
7e88d44fd9 | ||
|
|
2891a72cb9 | ||
|
|
522e3c4d3f | ||
|
|
0a25a0ef66 | ||
|
|
0fccb7dacd | ||
|
|
f1a07657e9 | ||
|
|
9cb6c501b5 | ||
|
|
ab01ab7853 | ||
|
|
6d74ff9477 |
+16
-14
@@ -211,13 +211,14 @@ GEM
|
||||
activerecord (>= 5.a)
|
||||
database_cleaner-core (~> 2.0.0)
|
||||
database_cleaner-core (2.0.1)
|
||||
datadog (2.19.0)
|
||||
datadog-ruby_core_source (~> 3.4, >= 3.4.1)
|
||||
libdatadog (~> 18.1.0.1.0)
|
||||
libddwaf (~> 1.24.1.0.3)
|
||||
datadog (2.38.0)
|
||||
cgi
|
||||
datadog-ruby_core_source (~> 3.5, >= 3.5.3)
|
||||
libdatadog (~> 36.0.0.1.0)
|
||||
libddwaf (~> 1.30.0.0.0)
|
||||
logger
|
||||
msgpack
|
||||
datadog-ruby_core_source (3.4.1)
|
||||
datadog-ruby_core_source (3.5.3)
|
||||
date (3.5.1)
|
||||
debug (1.8.0)
|
||||
irb (>= 1.5.0)
|
||||
@@ -522,15 +523,16 @@ GEM
|
||||
logger (~> 1.6)
|
||||
letter_opener (1.10.0)
|
||||
launchy (>= 2.2, < 4)
|
||||
libdatadog (18.1.0.1.0)
|
||||
libdatadog (18.1.0.1.0-x86_64-linux)
|
||||
libddwaf (1.24.1.0.3)
|
||||
libdatadog (36.0.0.1.0)
|
||||
libdatadog (36.0.0.1.0-arm64-darwin)
|
||||
libdatadog (36.0.0.1.0-x86_64-linux)
|
||||
libddwaf (1.30.0.0.2)
|
||||
ffi (~> 1.0)
|
||||
libddwaf (1.24.1.0.3-arm64-darwin)
|
||||
libddwaf (1.30.0.0.2-arm64-darwin)
|
||||
ffi (~> 1.0)
|
||||
libddwaf (1.24.1.0.3-x86_64-darwin)
|
||||
libddwaf (1.30.0.0.2-x86_64-darwin)
|
||||
ffi (~> 1.0)
|
||||
libddwaf (1.24.1.0.3-x86_64-linux)
|
||||
libddwaf (1.30.0.0.2-x86_64-linux)
|
||||
ffi (~> 1.0)
|
||||
line-bot-api (1.28.0)
|
||||
lint_roller (1.1.0)
|
||||
@@ -547,7 +549,7 @@ GEM
|
||||
activesupport (>= 4)
|
||||
railties (>= 4)
|
||||
request_store (~> 1.0)
|
||||
loofah (2.23.1)
|
||||
loofah (2.25.2)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.8.1)
|
||||
@@ -740,8 +742,8 @@ GEM
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.6.1)
|
||||
loofah (~> 2.21)
|
||||
rails-html-sanitizer (1.7.1)
|
||||
loofah (~> 2.25, >= 2.25.2)
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
rails-i18n (7.0.10)
|
||||
i18n (>= 0.7, < 2)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Copyright (c) 2017-2024 Chatwoot Inc.
|
||||
Copyright (c) 2017-2026 Chatwoot Inc.
|
||||
|
||||
Portions of this software are licensed as follows:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
class Api::V1::Accounts::BrandedEmailLayoutsController < Api::V1::Accounts::BaseController
|
||||
before_action :check_admin_authorization?
|
||||
|
||||
def show
|
||||
set_branded_email_layout
|
||||
end
|
||||
|
||||
def update
|
||||
unless Current.account.feature_enabled?(:branded_email_templates)
|
||||
render_could_not_create_error('Branded email templates feature is not enabled')
|
||||
return
|
||||
end
|
||||
|
||||
branded_email_layout = params[:branded_email_layout] == 'null' ? nil : params[:branded_email_layout]
|
||||
EmailTemplate.update_account_branded_layout!(account: Current.account, body: branded_email_layout) if params.key?(:branded_email_layout)
|
||||
set_branded_email_layout
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
render_could_not_create_error(e.record.errors.full_messages.join(', '))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_branded_email_layout
|
||||
@branded_email_layout = EmailTemplate.account_branded_layout_template_for(Current.account)&.body
|
||||
end
|
||||
end
|
||||
|
||||
Api::V1::Accounts::BrandedEmailLayoutsController.prepend_mod_with('Api::V1::Accounts::BrandedEmailLayoutsController')
|
||||
@@ -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
|
||||
|
||||
@@ -45,11 +45,20 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def update
|
||||
inbox_params = permitted_params.except(:channel, :csat_config)
|
||||
inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present?
|
||||
@inbox.update!(inbox_params)
|
||||
update_inbox_working_hours
|
||||
update_channel if channel_update_required?
|
||||
continue_update = false
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
continue_update = update_branded_email_layout
|
||||
raise ActiveRecord::Rollback unless continue_update
|
||||
|
||||
inbox_params = permitted_params.except(:channel, :csat_config)
|
||||
inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present?
|
||||
@inbox.update!(inbox_params)
|
||||
update_inbox_working_hours
|
||||
update_channel if channel_update_required?
|
||||
end
|
||||
|
||||
return unless continue_update
|
||||
end
|
||||
|
||||
def agent_bot
|
||||
@@ -155,6 +164,34 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
formatted['template'] = config['template'] if config['template'].present?
|
||||
end
|
||||
|
||||
def update_branded_email_layout
|
||||
return true unless params.key?(:branded_email_layout)
|
||||
|
||||
branded_email_layout = normalized_branded_email_layout
|
||||
|
||||
unless Current.account.feature_enabled?(:branded_email_templates)
|
||||
return true if branded_email_layout.blank?
|
||||
|
||||
render_could_not_create_error('Branded email templates feature is not enabled')
|
||||
return false
|
||||
end
|
||||
|
||||
unless @inbox.email?
|
||||
return true if branded_email_layout.blank?
|
||||
|
||||
render_could_not_create_error('Branded email layout is only supported for email inboxes')
|
||||
return false
|
||||
end
|
||||
|
||||
@inbox.update_branded_email_layout!(branded_email_layout)
|
||||
true
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
render_could_not_create_error(e.record.errors.full_messages.join(', '))
|
||||
false
|
||||
end
|
||||
|
||||
def normalized_branded_email_layout = params[:branded_email_layout] == 'null' ? nil : params[:branded_email_layout]
|
||||
|
||||
def inbox_attributes
|
||||
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
|
||||
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
|
||||
|
||||
@@ -81,7 +81,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
:name, :page_title, :slug, :archived,
|
||||
{ config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
|
||||
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] },
|
||||
{ locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } }] }
|
||||
{ locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } },
|
||||
{ popular_content: popular_content_keys.index_with { { category_ids: [], article_ids: [] } } }] }
|
||||
)
|
||||
end
|
||||
|
||||
@@ -89,6 +90,10 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
params.dig(:portal, :config, :locale_translations)&.keys || []
|
||||
end
|
||||
|
||||
def popular_content_keys
|
||||
params.dig(:portal, :config, :popular_content)&.keys || []
|
||||
end
|
||||
|
||||
def live_chat_widget_params
|
||||
permitted_params = params.permit(:inbox_id)
|
||||
return {} unless permitted_params.key?(:inbox_id)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -4,17 +4,53 @@ module PortalHomeData
|
||||
private
|
||||
|
||||
def load_home_data
|
||||
base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category)
|
||||
load_recommended_content
|
||||
# The classic hero only needs the recommendations above; the rest is
|
||||
# documentation-layout home data (also used on custom-domain home pages).
|
||||
return unless @portal.layout == 'documentation'
|
||||
|
||||
@visible_categories = @portal.categories
|
||||
.where(locale: @locale)
|
||||
.joins(:articles).where(articles: { status: :published })
|
||||
.order(position: :asc)
|
||||
.group('categories.id')
|
||||
@popular_topics = @visible_categories.first(3)
|
||||
@popular_topics = @recommended_categories.presence || @visible_categories.first(3)
|
||||
@featured = base_articles.order_by_views.limit(6)
|
||||
@category_contributors = build_category_contributors(@visible_categories)
|
||||
end
|
||||
|
||||
def load_recommended_content
|
||||
@recommended_categories = recommended_categories
|
||||
@recommended_articles = recommended_articles
|
||||
end
|
||||
|
||||
def base_articles
|
||||
@base_articles ||= @portal.articles.published.where(locale: @locale).includes(:author, :category)
|
||||
end
|
||||
|
||||
# Admin-recommended categories for the locale, in the chosen order. Unlike the
|
||||
# position-based fallback, published articles aren't required: the admin's pick wins.
|
||||
def recommended_categories
|
||||
ids = @portal.popular_category_ids(@locale)
|
||||
ordered_by_ids(@portal.categories.where(locale: @locale, id: ids), ids)
|
||||
end
|
||||
|
||||
# Admin-recommended articles for the locale, in the chosen order, limited to
|
||||
# published articles that still exist.
|
||||
def recommended_articles
|
||||
ids = @portal.popular_article_ids(@locale)
|
||||
ordered_by_ids(base_articles.where(id: ids), ids)
|
||||
end
|
||||
|
||||
# Loads the scope and returns its records ordered to match `ids`, dropping any
|
||||
# that no longer exist. Skips the query entirely when `ids` is blank.
|
||||
def ordered_by_ids(scope, ids)
|
||||
return [] if ids.blank?
|
||||
|
||||
by_id = scope.index_by(&:id)
|
||||
ids.filter_map { |id| by_id[id] }
|
||||
end
|
||||
|
||||
def build_category_contributors(categories)
|
||||
category_ids = categories.map(&:id)
|
||||
return {} if category_ids.empty?
|
||||
|
||||
@@ -64,10 +64,8 @@ class DashboardController < ActionController::Base
|
||||
return unless @portal
|
||||
|
||||
@locale = @portal.default_locale
|
||||
if @portal.layout == 'documentation'
|
||||
request.variant = :documentation
|
||||
load_home_data
|
||||
end
|
||||
request.variant = :documentation if @portal.layout == 'documentation'
|
||||
load_home_data
|
||||
render 'public/api/v1/portals/show', layout: 'portal', portal: @portal and return
|
||||
end
|
||||
|
||||
|
||||
@@ -24,7 +24,11 @@ class Public::Api::V1::Portals::BaseController < PublicController
|
||||
end
|
||||
|
||||
def set_view_variant
|
||||
request.variant = :documentation if @portal_layout == 'documentation' && !@is_plain_layout_enabled
|
||||
request.variant = if @is_plain_layout_enabled
|
||||
:plain
|
||||
elsif @portal_layout == 'documentation'
|
||||
:documentation
|
||||
end
|
||||
end
|
||||
|
||||
def portal
|
||||
@@ -65,6 +69,8 @@ class Public::Api::V1::Portals::BaseController < PublicController
|
||||
|
||||
def render_404
|
||||
portal
|
||||
# set_locale can render_404 before the child's set_view_variant runs; set it here so plain 404s stay chrome-less
|
||||
set_view_variant
|
||||
render 'public/api/v1/portals/error/404', status: :not_found
|
||||
end
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
|
||||
before_action :set_portal_layout
|
||||
before_action :set_view_variant
|
||||
before_action :ensure_portal_feature_enabled
|
||||
before_action :load_home_data, only: [:show], if: -> { @portal_layout == 'documentation' }
|
||||
before_action :load_home_data, only: [:show], unless: -> { @is_plain_layout_enabled }
|
||||
layout 'portal'
|
||||
|
||||
def show
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
class ConversationDrop < BaseDrop
|
||||
include MessageFormatHelper
|
||||
|
||||
def id
|
||||
@obj.try(:display_id)
|
||||
end
|
||||
|
||||
def display_id
|
||||
@obj.try(:display_id)
|
||||
end
|
||||
|
||||
@@ -2,4 +2,18 @@ class InboxDrop < BaseDrop
|
||||
def name
|
||||
@obj.try(:name)
|
||||
end
|
||||
|
||||
def business_name
|
||||
@obj.try(:sanitized_business_name)
|
||||
end
|
||||
|
||||
def avatar_url
|
||||
@obj.try(:avatar_url)
|
||||
end
|
||||
|
||||
def email
|
||||
return unless @obj.try(:email?)
|
||||
|
||||
@obj.try(:email_address).presence || @obj.try(:channel).try(:email)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -17,6 +17,7 @@ class ArticlesAPI extends PortalsAPI {
|
||||
categorySlug,
|
||||
sort,
|
||||
query,
|
||||
signal,
|
||||
}) {
|
||||
const url = getArticleSearchURL({
|
||||
pageNumber,
|
||||
@@ -30,7 +31,7 @@ class ArticlesAPI extends PortalsAPI {
|
||||
host: this.url,
|
||||
});
|
||||
|
||||
return axios.get(url);
|
||||
return axios.get(url, { signal });
|
||||
}
|
||||
|
||||
searchArticles({ portalSlug, query }) {
|
||||
|
||||
@@ -37,7 +37,8 @@ describe('#PortalAPI', () => {
|
||||
authorId: '1',
|
||||
});
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/portals/room-rental/articles?page=1&locale=en-US&status=published&author_id=1'
|
||||
'/api/v1/portals/room-rental/articles?page=1&locale=en-US&status=published&author_id=1',
|
||||
{ signal: undefined }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -56,6 +56,9 @@ const localeMenuLabels = computed(() => ({
|
||||
'customize-content': t(
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.CUSTOMIZE_CONTENT'
|
||||
),
|
||||
'select-popular-content': t(
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.SELECT_POPULAR_CONTENT'
|
||||
),
|
||||
delete: t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE'),
|
||||
}));
|
||||
|
||||
|
||||
+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')
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref } from 'vue';
|
||||
import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue';
|
||||
import LocaleContentDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleContentDialog.vue';
|
||||
import PopularContentDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/PopularContentDialog.vue';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
@@ -26,6 +27,7 @@ const route = useRoute();
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
|
||||
const contentDialogRef = ref(null);
|
||||
const popularContentDialogRef = ref(null);
|
||||
|
||||
const isLocaleDefault = code => {
|
||||
return props.portal?.meta?.default_locale === code;
|
||||
@@ -154,6 +156,8 @@ const handleAction = ({ action }, localeCode) => {
|
||||
publishLocale({ localeCode: localeCode });
|
||||
} else if (action === 'customize-content') {
|
||||
contentDialogRef.value.openForLocale(localeCode);
|
||||
} else if (action === 'select-popular-content') {
|
||||
popularContentDialogRef.value.openForLocale(localeCode);
|
||||
} else if (action === 'delete') {
|
||||
deletePortalLocale({ localeCode: localeCode });
|
||||
}
|
||||
@@ -174,5 +178,6 @@ const handleAction = ({ action }, localeCode) => {
|
||||
@action="handleAction($event, locale.code)"
|
||||
/>
|
||||
<LocaleContentDialog ref="contentDialogRef" :portal="portal" />
|
||||
<PopularContentDialog ref="popularContentDialogRef" :portal="portal" />
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useAbortableRequest } from 'dashboard/composables/useAbortableRequest';
|
||||
import categoriesAPI from 'dashboard/api/helpCenter/categories';
|
||||
import articlesAPI from 'dashboard/api/helpCenter/articles';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import ReorderableMultiSelect from 'dashboard/components-next/combobox/ReorderableMultiSelect.vue';
|
||||
|
||||
const props = defineProps({
|
||||
portal: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const MAX_CATEGORIES = 3;
|
||||
const MAX_ARTICLES = 6;
|
||||
const KEY = 'HELP_CENTER.LOCALES_PAGE.POPULAR_CONTENT_DIALOG';
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const activeLocale = ref('');
|
||||
// One flag per picker so each shows its skeleton until its own data loads.
|
||||
const categoriesLoading = ref(false);
|
||||
const { run: runArticleRequest, isPending: articlesLoading } =
|
||||
useAbortableRequest();
|
||||
|
||||
const categoryOptions = ref([]);
|
||||
const selectedCategoryIds = ref([]);
|
||||
|
||||
// Articles are searched server-side, so cache options for selected items that
|
||||
// fall outside the current results.
|
||||
const articleResults = ref([]);
|
||||
const articleOptionById = ref({});
|
||||
const selectedArticleIds = ref([]);
|
||||
|
||||
const popularContent = computed(
|
||||
() => props.portal?.config?.popular_content || {}
|
||||
);
|
||||
|
||||
const articleOptions = computed(() => {
|
||||
const options = new Map();
|
||||
selectedArticleIds.value.forEach(id => {
|
||||
if (articleOptionById.value[id])
|
||||
options.set(id, articleOptionById.value[id]);
|
||||
});
|
||||
articleResults.value.forEach(option => options.set(option.value, option));
|
||||
return [...options.values()];
|
||||
});
|
||||
|
||||
const toArticleOption = article => ({
|
||||
value: article.id,
|
||||
label: article.title,
|
||||
subtitle: t(`${KEY}.ARTICLES.IN_CATEGORY`, {
|
||||
category: article.category?.name || t(`${KEY}.ARTICLES.UNCATEGORIZED`),
|
||||
}),
|
||||
});
|
||||
|
||||
const fetchCategories = async localeCode => {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await categoriesAPI.get({
|
||||
portalSlug: props.portal?.slug,
|
||||
locale: localeCode,
|
||||
});
|
||||
return payload.map(category => ({
|
||||
value: category.id,
|
||||
label: category.name,
|
||||
subtitle: t(`${KEY}.CATEGORIES.ARTICLES_COUNT`, {
|
||||
count: category.meta?.articles_count || 0,
|
||||
}),
|
||||
icon: category.icon,
|
||||
iconColor: category.icon_color,
|
||||
}));
|
||||
};
|
||||
|
||||
const requestArticles = async (query, signal) => {
|
||||
const { data } = await articlesAPI.getArticles({
|
||||
pageNumber: 1,
|
||||
portalSlug: props.portal?.slug,
|
||||
locale: activeLocale.value,
|
||||
status: 'published',
|
||||
query,
|
||||
signal,
|
||||
});
|
||||
articleResults.value = data.payload.map(article => {
|
||||
const option = toArticleOption(article);
|
||||
articleOptionById.value[article.id] = option;
|
||||
return option;
|
||||
});
|
||||
};
|
||||
|
||||
const searchArticles = (query = '') =>
|
||||
runArticleRequest(signal => requestArticles(query, signal));
|
||||
|
||||
const onArticleSearch = useDebounceFn(searchArticles, 300);
|
||||
|
||||
// Resolve options for pre-selected articles that aren't in the current results.
|
||||
const cacheSelectedArticleOptions = async () => {
|
||||
const unknownIds = selectedArticleIds.value.filter(
|
||||
id => !articleOptionById.value[id]
|
||||
);
|
||||
await Promise.all(
|
||||
unknownIds.map(async id => {
|
||||
try {
|
||||
const { data } = await articlesAPI.getArticle({
|
||||
id,
|
||||
portalSlug: props.portal?.slug,
|
||||
});
|
||||
articleOptionById.value[id] = toArticleOption(data.payload);
|
||||
} catch {
|
||||
// Deleted since it was picked; leave it for the id fallback.
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const loadCategories = async localeCode => {
|
||||
categoriesLoading.value = true;
|
||||
try {
|
||||
const options = await fetchCategories(localeCode);
|
||||
// Reopened for another locale mid-flight; drop the stale response.
|
||||
if (localeCode !== activeLocale.value) return;
|
||||
categoryOptions.value = options;
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t(`${KEY}.API.ERROR_MESSAGE`));
|
||||
} finally {
|
||||
if (localeCode === activeLocale.value) categoriesLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadArticles = async () => {
|
||||
try {
|
||||
await runArticleRequest(signal =>
|
||||
Promise.all([cacheSelectedArticleOptions(), requestArticles('', signal)])
|
||||
);
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t(`${KEY}.API.ERROR_MESSAGE`));
|
||||
}
|
||||
};
|
||||
|
||||
const openForLocale = localeCode => {
|
||||
const existing = popularContent.value[localeCode] || {};
|
||||
activeLocale.value = localeCode;
|
||||
selectedCategoryIds.value = [...(existing.category_ids || [])];
|
||||
selectedArticleIds.value = [...(existing.article_ids || [])];
|
||||
categoryOptions.value = [];
|
||||
articleResults.value = [];
|
||||
articleOptionById.value = {};
|
||||
dialogRef.value?.open();
|
||||
|
||||
loadCategories(localeCode);
|
||||
loadArticles();
|
||||
};
|
||||
|
||||
const onConfirm = async () => {
|
||||
const updated = { ...popularContent.value };
|
||||
const entry = {
|
||||
category_ids: selectedCategoryIds.value,
|
||||
article_ids: selectedArticleIds.value,
|
||||
};
|
||||
|
||||
if (entry.category_ids.length || entry.article_ids.length) {
|
||||
updated[activeLocale.value] = entry;
|
||||
} else {
|
||||
delete updated[activeLocale.value];
|
||||
}
|
||||
|
||||
try {
|
||||
await store.dispatch('portals/update', {
|
||||
portalSlug: props.portal?.slug,
|
||||
config: { popular_content: updated },
|
||||
});
|
||||
dialogRef.value?.close();
|
||||
useAlert(t(`${KEY}.API.SUCCESS_MESSAGE`));
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t(`${KEY}.API.ERROR_MESSAGE`));
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ openForLocale });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="edit"
|
||||
:title="t(`${KEY}.TITLE`)"
|
||||
:description="t(`${KEY}.DESCRIPTION`)"
|
||||
:confirm-button-label="t(`${KEY}.CONFIRM`)"
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
<div class="flex flex-col gap-5">
|
||||
<ReorderableMultiSelect
|
||||
v-model="selectedCategoryIds"
|
||||
:options="categoryOptions"
|
||||
:max="MAX_CATEGORIES"
|
||||
:label="t(`${KEY}.CATEGORIES.LABEL`)"
|
||||
:add-label="t(`${KEY}.ADD_ANOTHER`)"
|
||||
:search-placeholder="t(`${KEY}.SEARCH`)"
|
||||
:empty-state="t(`${KEY}.EMPTY`)"
|
||||
fallback-icon="i-lucide-folder"
|
||||
:loading="categoriesLoading"
|
||||
>
|
||||
<template #counter="{ remaining }">
|
||||
{{ t(`${KEY}.SLOTS_LEFT`, { count: remaining }) }}
|
||||
</template>
|
||||
<template #note>{{ t(`${KEY}.OVERRIDING_DEFAULTS`) }}</template>
|
||||
</ReorderableMultiSelect>
|
||||
<ReorderableMultiSelect
|
||||
v-model="selectedArticleIds"
|
||||
server-search
|
||||
:options="articleOptions"
|
||||
:max="MAX_ARTICLES"
|
||||
:label="t(`${KEY}.ARTICLES.LABEL`)"
|
||||
:add-label="t(`${KEY}.ADD_ANOTHER`)"
|
||||
:search-placeholder="t(`${KEY}.SEARCH`)"
|
||||
:empty-state="t(`${KEY}.EMPTY`)"
|
||||
:loading="articlesLoading"
|
||||
@search="onArticleSearch"
|
||||
>
|
||||
<template #counter="{ remaining }">
|
||||
{{ t(`${KEY}.SLOTS_LEFT`, { count: remaining }) }}
|
||||
</template>
|
||||
<template #note>{{ t(`${KEY}.OVERRIDING_DEFAULTS`) }}</template>
|
||||
</ReorderableMultiSelect>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
+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"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
open: {
|
||||
@@ -27,6 +29,10 @@ const props = defineProps({
|
||||
type: [String, Number, Array],
|
||||
default: () => [],
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select', 'search']);
|
||||
@@ -63,13 +69,22 @@ defineExpose({
|
||||
class="absolute z-50 w-full mt-1 transition-opacity duration-200 border rounded-md shadow-lg bg-n-solid-1 border-n-strong"
|
||||
>
|
||||
<div class="relative border-b border-n-strong">
|
||||
<span class="absolute i-lucide-search top-2.5 size-4 left-3" />
|
||||
<Spinner
|
||||
v-if="loading"
|
||||
:size="16"
|
||||
class="absolute top-2.5 start-3 text-n-slate-11"
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
icon="i-lucide-search"
|
||||
class="absolute top-2.5 size-4 start-3"
|
||||
/>
|
||||
<input
|
||||
ref="searchInput"
|
||||
:value="searchValue"
|
||||
type="search"
|
||||
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
|
||||
class="reset-base w-full py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-t-md bg-n-solid-1 text-n-slate-12"
|
||||
class="reset-base w-full py-2 !ps-10 !pe-2 text-sm focus:outline-none border-none rounded-t-md bg-n-solid-1 text-n-slate-12"
|
||||
@input="onInputSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
<script setup>
|
||||
import { ref, computed, nextTick } from 'vue';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ComboBoxDropdown from 'dashboard/components-next/combobox/ComboBoxDropdown.vue';
|
||||
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
// Drag-reorderable multi-select capped at `max`. The model is the ordered list
|
||||
// of selected values.
|
||||
const props = defineProps({
|
||||
// { value, label, subtitle?, icon?, iconColor? } — icon is an emoji or an
|
||||
// icon-picker value, and falls back to `fallbackIcon`.
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
max: {
|
||||
type: Number,
|
||||
default: 3,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
addLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
searchPlaceholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
emptyState: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
fallbackIcon: {
|
||||
type: String,
|
||||
default: 'i-lucide-file-text',
|
||||
},
|
||||
// Show skeleton rows while options load, so the row height stays stable.
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// Let the parent filter `options` (via the `search` event) instead of locally.
|
||||
serverSearch: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['search']);
|
||||
|
||||
const selectedIds = defineModel({ type: Array, default: () => [] });
|
||||
|
||||
const isOpen = ref(false);
|
||||
const searchQuery = ref('');
|
||||
const dragIndex = ref(null);
|
||||
const dropdownRef = ref(null);
|
||||
|
||||
const optionsByValue = computed(
|
||||
() => new Map(props.options.map(option => [option.value, option]))
|
||||
);
|
||||
|
||||
const selectedRows = computed(() =>
|
||||
selectedIds.value.map(
|
||||
id => optionsByValue.value.get(id) || { value: id, label: String(id) }
|
||||
)
|
||||
);
|
||||
|
||||
const remaining = computed(() => props.max - selectedIds.value.length);
|
||||
|
||||
const canAddMore = computed(() => selectedIds.value.length < props.max);
|
||||
|
||||
const dropdownOptions = computed(() => {
|
||||
const available = props.options.filter(
|
||||
option => !selectedIds.value.includes(option.value)
|
||||
);
|
||||
if (props.serverSearch) return available;
|
||||
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
return available.filter(option =>
|
||||
option.label?.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
const onSearch = value => {
|
||||
searchQuery.value = value;
|
||||
emit('search', value);
|
||||
};
|
||||
|
||||
const toggleDropdown = () => {
|
||||
isOpen.value = !isOpen.value;
|
||||
if (isOpen.value) {
|
||||
onSearch('');
|
||||
nextTick(() => dropdownRef.value?.focus());
|
||||
}
|
||||
};
|
||||
|
||||
const onSelect = option => {
|
||||
if (!canAddMore.value) return;
|
||||
selectedIds.value = [...selectedIds.value, option.value];
|
||||
if (!canAddMore.value) isOpen.value = false;
|
||||
};
|
||||
|
||||
const removeItem = id => {
|
||||
selectedIds.value = selectedIds.value.filter(value => value !== id);
|
||||
};
|
||||
|
||||
const onDragStart = index => {
|
||||
dragIndex.value = index;
|
||||
};
|
||||
|
||||
const onDragOver = index => {
|
||||
if (dragIndex.value === null || dragIndex.value === index) return;
|
||||
const ids = [...selectedIds.value];
|
||||
const [moved] = ids.splice(dragIndex.value, 1);
|
||||
ids.splice(index, 0, moved);
|
||||
dragIndex.value = index;
|
||||
selectedIds.value = ids;
|
||||
};
|
||||
|
||||
const onDragEnd = () => {
|
||||
dragIndex.value = null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-if="label || $slots.counter"
|
||||
class="flex items-center justify-between mb-1.5"
|
||||
>
|
||||
<label class="text-sm font-medium text-n-slate-12">{{ label }}</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-n-slate-10">
|
||||
<slot name="counter" :remaining="remaining" :max="max" />
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<span
|
||||
v-for="slot in max"
|
||||
:key="slot"
|
||||
class="w-4 h-1 rounded-full"
|
||||
:class="slot <= selectedIds.length ? 'bg-n-brand' : 'bg-n-slate-4'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OnClickOutside @trigger="isOpen = false">
|
||||
<div
|
||||
class="flex flex-col gap-1 p-1 border rounded-xl border-n-weak bg-n-background"
|
||||
:class="{ 'opacity-50 pointer-events-none': disabled }"
|
||||
>
|
||||
<div
|
||||
v-if="loading && selectedIds.length && !isOpen"
|
||||
class="flex flex-col gap-1 overflow-y-auto max-h-[216px]"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div
|
||||
v-for="n in selectedIds.length"
|
||||
:key="n"
|
||||
class="flex items-center gap-2 px-2 py-1.5 rounded-lg bg-n-alpha-2"
|
||||
>
|
||||
<span
|
||||
class="flex-shrink-0 opacity-40 i-lucide-grip-vertical size-4 text-n-slate-9"
|
||||
/>
|
||||
<div class="flex-shrink-0 rounded-md size-6 bg-n-alpha-3" />
|
||||
<div class="flex-grow min-w-0">
|
||||
<p class="mb-0 text-sm">
|
||||
<span
|
||||
class="inline-block w-32 h-2.5 align-middle rounded bg-n-alpha-3 animate-pulse"
|
||||
/>
|
||||
</p>
|
||||
<p class="mb-0 text-xs">
|
||||
<span
|
||||
class="inline-block w-20 h-2 align-middle rounded bg-n-alpha-3 animate-pulse"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<span class="flex-shrink-0 size-6" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="selectedRows.length"
|
||||
class="flex flex-col gap-1 overflow-y-auto max-h-[216px]"
|
||||
>
|
||||
<div
|
||||
v-for="(row, index) in selectedRows"
|
||||
:key="row.value"
|
||||
draggable="true"
|
||||
class="flex items-center gap-2 px-2 py-1.5 transition-colors rounded-lg cursor-grab group/row"
|
||||
:class="
|
||||
index === dragIndex
|
||||
? 'opacity-40 bg-n-alpha-3 ring-1 ring-inset ring-n-brand'
|
||||
: 'bg-n-alpha-2 hover:bg-n-alpha-3'
|
||||
"
|
||||
@dragstart="onDragStart(index)"
|
||||
@dragover.prevent="onDragOver(index)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<span
|
||||
class="flex-shrink-0 transition-colors i-lucide-grip-vertical size-4 text-n-slate-9 group-hover/row:text-n-slate-11"
|
||||
/>
|
||||
<div
|
||||
class="flex items-center justify-center flex-shrink-0 text-sm rounded-md size-6 bg-n-alpha-3 text-n-slate-11"
|
||||
>
|
||||
<EmojiIcon
|
||||
v-if="row.icon"
|
||||
:value="row.icon"
|
||||
:color="row.iconColor"
|
||||
class="shrink-0 size-4"
|
||||
/>
|
||||
<span v-else class="size-4" :class="fallbackIcon" />
|
||||
</div>
|
||||
<div class="flex-grow min-w-0">
|
||||
<p class="mb-0 text-sm truncate text-n-slate-12">
|
||||
{{ row.label }}
|
||||
</p>
|
||||
<p
|
||||
v-if="row.subtitle"
|
||||
class="mb-0 text-xs truncate text-n-slate-10"
|
||||
>
|
||||
{{ row.subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
ghost
|
||||
slate
|
||||
xs
|
||||
no-animation
|
||||
icon="i-lucide-x"
|
||||
class="flex-shrink-0"
|
||||
@click="removeItem(row.value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="canAddMore" class="relative">
|
||||
<Button
|
||||
type="button"
|
||||
ghost
|
||||
slate
|
||||
sm
|
||||
no-animation
|
||||
justify="start"
|
||||
:label="addLabel"
|
||||
:disabled="loading && !isOpen"
|
||||
class="w-full"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<template #icon>
|
||||
<Spinner
|
||||
v-if="loading && !isOpen"
|
||||
:size="16"
|
||||
class="text-n-slate-11"
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
icon="i-lucide-search"
|
||||
class="flex-shrink-0 size-4"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
<ComboBoxDropdown
|
||||
ref="dropdownRef"
|
||||
:open="isOpen"
|
||||
:options="dropdownOptions"
|
||||
:search-value="searchQuery"
|
||||
:search-placeholder="searchPlaceholder"
|
||||
:empty-state="emptyState"
|
||||
:loading="loading"
|
||||
@update:search-value="onSearch"
|
||||
@select="onSelect"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</OnClickOutside>
|
||||
|
||||
<p
|
||||
v-if="selectedIds.length && $slots.note"
|
||||
class="flex items-center gap-1.5 mt-1.5 mb-0 text-xs text-n-slate-11"
|
||||
>
|
||||
<span class="rounded-full size-1.5 bg-n-teal-9" />
|
||||
<slot name="note" />
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { h } from 'vue';
|
||||
import ReorderableMultiSelect from '../ReorderableMultiSelect.vue';
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: 1, label: 'Getting started', subtitle: 'Guides' },
|
||||
{ value: 2, label: 'Billing', subtitle: 'Payments' },
|
||||
{ value: 3, label: 'Security' },
|
||||
{ value: 4, label: 'API', icon: '🔌', iconColor: '#000' },
|
||||
];
|
||||
|
||||
// A findable dropdown stub that exposes the `focus()` the component calls on open.
|
||||
const ComboBoxDropdownStub = {
|
||||
name: 'ComboBoxDropdown',
|
||||
props: [
|
||||
'open',
|
||||
'options',
|
||||
'searchValue',
|
||||
'searchPlaceholder',
|
||||
'emptyState',
|
||||
'loading',
|
||||
],
|
||||
emits: ['select', 'update:searchValue'],
|
||||
methods: { focus() {} },
|
||||
template: '<div class="combo-dropdown" />',
|
||||
};
|
||||
|
||||
// Renders a real <button> so clicks reach the parent handlers; `data-icon`
|
||||
// lets specs tell the remove buttons (icon="i-lucide-x") from the add trigger.
|
||||
const ButtonStub = {
|
||||
name: 'Button',
|
||||
props: ['label', 'icon', 'disabled'],
|
||||
emits: ['click'],
|
||||
template:
|
||||
'<button :data-icon="icon" :disabled="disabled" @click="$emit(\'click\')"><slot name="icon" />{{ label }}</button>',
|
||||
};
|
||||
|
||||
const mountSelect = (props = {}, slots = {}) =>
|
||||
mount(ReorderableMultiSelect, {
|
||||
props: { options: OPTIONS, max: 3, ...props },
|
||||
slots,
|
||||
global: {
|
||||
stubs: {
|
||||
Button: ButtonStub,
|
||||
ComboBoxDropdown: ComboBoxDropdownStub,
|
||||
Spinner: true,
|
||||
Icon: true,
|
||||
EmojiIcon: true,
|
||||
OnClickOutside: { template: '<div><slot /></div>' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const dropdown = wrapper => wrapper.findComponent(ComboBoxDropdownStub);
|
||||
const addTrigger = wrapper =>
|
||||
wrapper.findAll('button').find(button => !button.attributes('data-icon'));
|
||||
const removeButtons = wrapper =>
|
||||
wrapper.findAll('button[data-icon="i-lucide-x"]');
|
||||
const rows = wrapper => wrapper.findAll('[draggable="true"]');
|
||||
const lastModel = wrapper => wrapper.emitted('update:modelValue')?.at(-1)?.[0];
|
||||
|
||||
describe('ReorderableMultiSelect', () => {
|
||||
describe('rendering selected rows', () => {
|
||||
it('renders rows in model order with labels resolved from options', () => {
|
||||
const wrapper = mountSelect({ modelValue: [2, 1] });
|
||||
|
||||
const labels = rows(wrapper).map(row => row.find('p').text());
|
||||
expect(labels).toEqual(['Billing', 'Getting started']);
|
||||
});
|
||||
|
||||
it('falls back to the stringified id when an option is unknown', () => {
|
||||
const wrapper = mountSelect({ modelValue: [99] });
|
||||
|
||||
expect(rows(wrapper)[0].find('p').text()).toBe('99');
|
||||
});
|
||||
|
||||
it('renders the progress dots filled up to the selection count', () => {
|
||||
const wrapper = mountSelect({
|
||||
modelValue: [1, 2],
|
||||
max: 3,
|
||||
label: 'Tags',
|
||||
});
|
||||
|
||||
const filled = wrapper.findAll('.bg-n-brand').length;
|
||||
expect(filled).toBe(2);
|
||||
});
|
||||
|
||||
it('exposes remaining and max to the counter slot', () => {
|
||||
const wrapper = mountSelect(
|
||||
{ modelValue: [1], max: 3 },
|
||||
{ counter: ({ remaining, max }) => h('span', `${remaining}/${max}`) }
|
||||
);
|
||||
|
||||
expect(wrapper.text()).toContain('2/3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('adding options', () => {
|
||||
it('appends the chosen option to the model', () => {
|
||||
const wrapper = mountSelect({ modelValue: [1] });
|
||||
|
||||
dropdown(wrapper).vm.$emit('select', OPTIONS[1]);
|
||||
|
||||
expect(lastModel(wrapper)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('hides the add trigger once the model reaches max', () => {
|
||||
const wrapper = mountSelect({ modelValue: [1, 2], max: 2 });
|
||||
|
||||
expect(addTrigger(wrapper)).toBeUndefined();
|
||||
expect(dropdown(wrapper).exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('closes the dropdown when the last slot is filled', async () => {
|
||||
const wrapper = mountSelect({ modelValue: [1], max: 2 });
|
||||
await addTrigger(wrapper).trigger('click');
|
||||
expect(dropdown(wrapper).props('open')).toBe(true);
|
||||
|
||||
dropdown(wrapper).vm.$emit('select', OPTIONS[1]);
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
// Reaching max removes the trigger (and its dropdown) entirely.
|
||||
expect(dropdown(wrapper).exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('excludes already-selected options from the dropdown', () => {
|
||||
const wrapper = mountSelect({ modelValue: [1] });
|
||||
|
||||
const values = dropdown(wrapper)
|
||||
.props('options')
|
||||
.map(option => option.value);
|
||||
expect(values).toEqual([2, 3, 4]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removing options', () => {
|
||||
it('removes the clicked item from the model', async () => {
|
||||
const wrapper = mountSelect({ modelValue: [1, 2, 3] });
|
||||
|
||||
await removeButtons(wrapper)[1].trigger('click');
|
||||
|
||||
expect(lastModel(wrapper)).toEqual([1, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searching', () => {
|
||||
it('filters options locally by label', async () => {
|
||||
const wrapper = mountSelect({ modelValue: [] });
|
||||
|
||||
dropdown(wrapper).vm.$emit('update:searchValue', 'bill');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
const values = dropdown(wrapper)
|
||||
.props('options')
|
||||
.map(option => option.value);
|
||||
expect(values).toEqual([2]);
|
||||
});
|
||||
|
||||
it('emits search and skips local filtering when serverSearch is set', async () => {
|
||||
const wrapper = mountSelect({ modelValue: [], serverSearch: true });
|
||||
|
||||
dropdown(wrapper).vm.$emit('update:searchValue', 'bill');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.emitted('search').at(-1)).toEqual(['bill']);
|
||||
// All unselected options remain; the parent owns filtering.
|
||||
expect(dropdown(wrapper).props('options')).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('emits an empty search when the trigger opens', async () => {
|
||||
const wrapper = mountSelect({ modelValue: [1] });
|
||||
|
||||
await addTrigger(wrapper).trigger('click');
|
||||
|
||||
expect(wrapper.emitted('search').at(-1)).toEqual(['']);
|
||||
expect(dropdown(wrapper).props('open')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reordering', () => {
|
||||
it('moves a row to the dropped position within the model', async () => {
|
||||
const wrapper = mountSelect({ modelValue: [1, 2, 3] });
|
||||
|
||||
await rows(wrapper)[0].trigger('dragstart');
|
||||
await rows(wrapper)[2].trigger('dragover');
|
||||
|
||||
expect(lastModel(wrapper)).toEqual([2, 3, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading state', () => {
|
||||
it('shows skeleton rows when loading a non-empty, closed selection', () => {
|
||||
const wrapper = mountSelect({ modelValue: [1, 2], loading: true });
|
||||
|
||||
const skeleton = wrapper.find('[aria-busy="true"]');
|
||||
expect(skeleton.exists()).toBe(true);
|
||||
expect(skeleton.findAll('.animate-pulse').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('does not show skeletons when the selection is empty', () => {
|
||||
const wrapper = mountSelect({ modelValue: [], loading: true });
|
||||
|
||||
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the real rows, not skeletons, while searching in an open dropdown', async () => {
|
||||
// Open first (trigger is enabled), then a live search turns loading on.
|
||||
const wrapper = mountSelect({ modelValue: [1, 2] });
|
||||
await addTrigger(wrapper).trigger('click');
|
||||
|
||||
await wrapper.setProps({ loading: true });
|
||||
|
||||
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false);
|
||||
expect(rows(wrapper)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('forwards loading to the dropdown and disables the closed trigger', () => {
|
||||
const wrapper = mountSelect({ modelValue: [1], loading: true });
|
||||
|
||||
expect(dropdown(wrapper).props('loading')).toBe(true);
|
||||
expect(addTrigger(wrapper).attributes('disabled')).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { effectScope } from 'vue';
|
||||
import { useAbortableRequest } from '../useAbortableRequest';
|
||||
|
||||
// Resolves when the request "completes", rejects like axios does when the
|
||||
// signal is aborted mid-flight.
|
||||
const abortableRunner =
|
||||
(value, { fail = false } = {}) =>
|
||||
signal =>
|
||||
new Promise((resolve, reject) => {
|
||||
signal.addEventListener('abort', () => {
|
||||
const error = new Error('canceled');
|
||||
error.name = 'CanceledError';
|
||||
reject(error);
|
||||
});
|
||||
// Defer so a follow-up `run`/`abort` can supersede this one first.
|
||||
Promise.resolve().then(() => {
|
||||
if (signal.aborted) return;
|
||||
if (fail) {
|
||||
reject(new Error('boom'));
|
||||
return;
|
||||
}
|
||||
resolve(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useAbortableRequest', () => {
|
||||
it('passes a fresh signal to the runner and returns its result', async () => {
|
||||
const { run } = useAbortableRequest();
|
||||
let received = null;
|
||||
|
||||
const result = await run(signal => {
|
||||
received = signal;
|
||||
return Promise.resolve('ok');
|
||||
});
|
||||
|
||||
expect(received).toBeInstanceOf(AbortSignal);
|
||||
expect(received.aborted).toBe(false);
|
||||
expect(result).toBe('ok');
|
||||
});
|
||||
|
||||
it('toggles isPending around the request', async () => {
|
||||
const { run, isPending } = useAbortableRequest();
|
||||
expect(isPending.value).toBe(false);
|
||||
|
||||
const pending = run(() => Promise.resolve('done'));
|
||||
expect(isPending.value).toBe(true);
|
||||
|
||||
await pending;
|
||||
expect(isPending.value).toBe(false);
|
||||
});
|
||||
|
||||
it('aborts the previous request when a new one starts', async () => {
|
||||
const { run } = useAbortableRequest();
|
||||
|
||||
const first = run(abortableRunner('first'));
|
||||
const second = run(abortableRunner('second'));
|
||||
|
||||
await expect(first).resolves.toBeUndefined();
|
||||
await expect(second).resolves.toBe('second');
|
||||
});
|
||||
|
||||
it('returns the onAbort value when a request is superseded', async () => {
|
||||
const { run } = useAbortableRequest();
|
||||
|
||||
const first = run(abortableRunner('first'), { onAbort: null });
|
||||
const second = run(abortableRunner('second'));
|
||||
|
||||
await expect(first).resolves.toBeNull();
|
||||
await expect(second).resolves.toBe('second');
|
||||
});
|
||||
|
||||
it('abort cancels the in-flight request and clears isPending', async () => {
|
||||
const { run, abort, isPending } = useAbortableRequest();
|
||||
|
||||
const pending = run(abortableRunner('value'));
|
||||
expect(isPending.value).toBe(true);
|
||||
|
||||
abort();
|
||||
|
||||
await expect(pending).resolves.toBeUndefined();
|
||||
expect(isPending.value).toBe(false);
|
||||
});
|
||||
|
||||
it('rethrows non-abort errors and clears isPending', async () => {
|
||||
const { run, isPending } = useAbortableRequest();
|
||||
|
||||
await expect(run(abortableRunner(null, { fail: true }))).rejects.toThrow(
|
||||
'boom'
|
||||
);
|
||||
expect(isPending.value).toBe(false);
|
||||
});
|
||||
|
||||
it('aborts the in-flight request when its scope is disposed', async () => {
|
||||
const scope = effectScope();
|
||||
let request;
|
||||
scope.run(() => {
|
||||
request = useAbortableRequest();
|
||||
});
|
||||
|
||||
const pending = request.run(abortableRunner('value'));
|
||||
expect(request.isPending.value).toBe(true);
|
||||
|
||||
scope.stop();
|
||||
|
||||
await expect(pending).resolves.toBeUndefined();
|
||||
expect(request.isPending.value).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps separate controllers per instance', async () => {
|
||||
const a = useAbortableRequest();
|
||||
const b = useAbortableRequest();
|
||||
|
||||
const first = a.run(abortableRunner('a'));
|
||||
// Starting b's request must not abort a's.
|
||||
const second = b.run(abortableRunner('b'));
|
||||
|
||||
await expect(first).resolves.toBe('a');
|
||||
await expect(second).resolves.toBe('b');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { getCurrentScope, onScopeDispose, ref } from 'vue';
|
||||
|
||||
export const isAbortError = error =>
|
||||
error?.name === 'AbortError' ||
|
||||
error?.name === 'CanceledError' ||
|
||||
error?.code === 'ERR_CANCELED';
|
||||
|
||||
/**
|
||||
* Keeps only the latest request alive. Starting a new `run` (or calling
|
||||
* `abort`) cancels the previous request through its `AbortSignal`, so
|
||||
* out-of-order responses can never overwrite fresher data.
|
||||
*
|
||||
* @example
|
||||
* const { run, abort, isPending } = useAbortableRequest();
|
||||
* const results = await run(signal => api.search(query, { signal }));
|
||||
*
|
||||
* @returns {{
|
||||
* run: (runner: (signal: AbortSignal) => Promise<any>, options?: { onAbort?: any }) => Promise<any>,
|
||||
* abort: () => void,
|
||||
* isPending: import('vue').Ref<boolean>,
|
||||
* }}
|
||||
* `run` resolves with the runner's value, or `options.onAbort` (default
|
||||
* `undefined`) when the request was superseded. Non-abort errors are rethrown.
|
||||
*/
|
||||
export function useAbortableRequest() {
|
||||
const isPending = ref(false);
|
||||
let controller = null;
|
||||
|
||||
const abort = () => {
|
||||
controller?.abort();
|
||||
controller = null;
|
||||
isPending.value = false;
|
||||
};
|
||||
|
||||
const run = async (runner, { onAbort } = {}) => {
|
||||
controller?.abort();
|
||||
const currentController = new AbortController();
|
||||
controller = currentController;
|
||||
isPending.value = true;
|
||||
|
||||
try {
|
||||
return await runner(currentController.signal);
|
||||
} catch (error) {
|
||||
if (currentController.signal.aborted || isAbortError(error))
|
||||
return onAbort;
|
||||
throw error;
|
||||
} finally {
|
||||
// Only the latest run owns the shared state; a superseded run leaves it
|
||||
// for the run that replaced it.
|
||||
if (controller === currentController) {
|
||||
controller = null;
|
||||
isPending.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Cancel any in-flight request when the owning scope is disposed.
|
||||
// Guarded so the composable can also be used outside an effect scope.
|
||||
if (getCurrentScope()) onScopeDispose(abort);
|
||||
|
||||
return { run, abort, isPending };
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -7,11 +7,15 @@ export const FEATURE_FLAGS = {
|
||||
AUTOMATIONS: 'automations',
|
||||
CAMPAIGNS: 'campaigns',
|
||||
WHATSAPP_CAMPAIGNS: 'whatsapp_campaign',
|
||||
WHATSAPP_EMBEDDED_SIGNUP_INBOX_CREATION:
|
||||
'whatsapp_embedded_signup_inbox_creation',
|
||||
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);
|
||||
};
|
||||
@@ -166,6 +166,13 @@ export const LOCALE_MENU_ITEMS = {
|
||||
value: 'customize-content',
|
||||
icon: 'i-lucide-pencil',
|
||||
},
|
||||
selectPopularContent: {
|
||||
label:
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.SELECT_POPULAR_CONTENT',
|
||||
action: 'select-popular-content',
|
||||
value: 'select-popular-content',
|
||||
icon: 'i-lucide-sparkles',
|
||||
},
|
||||
delete: {
|
||||
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE',
|
||||
action: 'delete',
|
||||
@@ -185,6 +192,7 @@ export const buildLocaleMenuItems = ({ isDefault, isDraft }) => {
|
||||
LOCALE_MENU_ITEMS.moveToDraft,
|
||||
]),
|
||||
LOCALE_MENU_ITEMS.customizeContent,
|
||||
LOCALE_MENU_ITEMS.selectPopularContent,
|
||||
...disableLocaleMenuItems([LOCALE_MENU_ITEMS.delete]),
|
||||
];
|
||||
}
|
||||
@@ -193,6 +201,7 @@ export const buildLocaleMenuItems = ({ isDefault, isDraft }) => {
|
||||
return [
|
||||
LOCALE_MENU_ITEMS.publishLocale,
|
||||
LOCALE_MENU_ITEMS.customizeContent,
|
||||
LOCALE_MENU_ITEMS.selectPopularContent,
|
||||
LOCALE_MENU_ITEMS.delete,
|
||||
];
|
||||
}
|
||||
@@ -201,6 +210,7 @@ export const buildLocaleMenuItems = ({ isDefault, isDraft }) => {
|
||||
LOCALE_MENU_ITEMS.makeDefault,
|
||||
LOCALE_MENU_ITEMS.moveToDraft,
|
||||
LOCALE_MENU_ITEMS.customizeContent,
|
||||
LOCALE_MENU_ITEMS.selectPopularContent,
|
||||
LOCALE_MENU_ITEMS.delete,
|
||||
];
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -74,26 +74,34 @@ describe('PortalHelper', () => {
|
||||
});
|
||||
|
||||
describe('buildLocaleMenuItems', () => {
|
||||
it('disables other actions but keeps customize enabled for the default locale', () => {
|
||||
it('disables other actions but keeps content actions enabled for the default locale', () => {
|
||||
const items = buildLocaleMenuItems({ isDefault: true, isDraft: false });
|
||||
const customize = items.find(item => item.action === 'customize-content');
|
||||
const enabledActions = ['customize-content', 'select-popular-content'];
|
||||
|
||||
expect(customize).toBeTruthy();
|
||||
expect(customize.disabled).toBeFalsy();
|
||||
enabledActions.forEach(action => {
|
||||
expect(
|
||||
items.find(item => item.action === action)?.disabled
|
||||
).toBeFalsy();
|
||||
});
|
||||
expect(
|
||||
items
|
||||
.filter(item => item.action !== 'customize-content')
|
||||
.filter(item => !enabledActions.includes(item.action))
|
||||
.every(item => item.disabled)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns publish, customize, and delete actions for draft locales', () => {
|
||||
it('returns publish, customize, popular content, and delete actions for draft locales', () => {
|
||||
expect(
|
||||
buildLocaleMenuItems({
|
||||
isDefault: false,
|
||||
isDraft: true,
|
||||
}).map(({ action }) => action)
|
||||
).toEqual(['publish-locale', 'customize-content', 'delete']);
|
||||
).toEqual([
|
||||
'publish-locale',
|
||||
'customize-content',
|
||||
'select-popular-content',
|
||||
'delete',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns default, draft, customize, and delete actions for live locales', () => {
|
||||
@@ -106,6 +114,7 @@ describe('PortalHelper', () => {
|
||||
'change-default',
|
||||
'move-to-draft',
|
||||
'customize-content',
|
||||
'select-popular-content',
|
||||
'delete',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -484,7 +484,7 @@
|
||||
"REMANING_PARTICIPANT_TEXT": "+{count} other",
|
||||
"TOTAL_PARTICIPANTS_TEXT": "{count} people are participating.",
|
||||
"TOTAL_PARTICIPANT_TEXT": "{count} person is participating.",
|
||||
"NO_PARTICIPANTS_TEXT": "No one is participating!.",
|
||||
"NO_PARTICIPANTS_TEXT": "No one is participating!",
|
||||
"WATCH_CONVERSATION": "Join conversation",
|
||||
"YOU_ARE_WATCHING": "You are participating",
|
||||
"API": {
|
||||
|
||||
@@ -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",
|
||||
@@ -716,9 +720,33 @@
|
||||
"MOVE_TO_DRAFT": "Move to draft",
|
||||
"PUBLISH_LOCALE": "Publish locale",
|
||||
"CUSTOMIZE_CONTENT": "Localize content",
|
||||
"SELECT_POPULAR_CONTENT": "Select recommended content",
|
||||
"DELETE": "Delete"
|
||||
}
|
||||
},
|
||||
"POPULAR_CONTENT_DIALOG": {
|
||||
"TITLE": "Recommended content",
|
||||
"DESCRIPTION": "Pick up to 3 categories and 6 articles to feature on this locale's help center home page. Drag them into the order you want visitors to see.",
|
||||
"SEARCH": "Search...",
|
||||
"EMPTY": "No matching results",
|
||||
"ADD_ANOTHER": "Add another...",
|
||||
"SLOTS_LEFT": "{count} slots left",
|
||||
"OVERRIDING_DEFAULTS": "Overriding defaults for this locale",
|
||||
"CONFIRM": "Save recommendations",
|
||||
"CATEGORIES": {
|
||||
"LABEL": "Recommended categories",
|
||||
"ARTICLES_COUNT": "No articles | {count} article | {count} articles"
|
||||
},
|
||||
"ARTICLES": {
|
||||
"LABEL": "Recommended articles",
|
||||
"IN_CATEGORY": "in {category}",
|
||||
"UNCATEGORIZED": "Uncategorized"
|
||||
},
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Recommended content updated successfully",
|
||||
"ERROR_MESSAGE": "Unable to update recommended content. Try again."
|
||||
}
|
||||
},
|
||||
"CONTENT_DIALOG": {
|
||||
"TITLE": "Localize content",
|
||||
"DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.",
|
||||
@@ -763,10 +791,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"
|
||||
},
|
||||
@@ -323,8 +322,6 @@
|
||||
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured",
|
||||
"MANUAL_FALLBACK": "If your number is already connected to the WhatsApp Business Platform (API), or if you’re a tech provider onboarding your own number, please use the {link} flow",
|
||||
"MANUAL_LINK_TEXT": "manual setup flow",
|
||||
"RESTRICTED_WARNING": "WhatsApp embedded signup is temporarily unavailable due to current Meta platform restrictions. We’ll restore support as soon as possible.",
|
||||
"STATUS_LINK": "View status update",
|
||||
"CALLING_ENABLE_FAILED": "Your WhatsApp inbox is ready, but voice calling couldn't be turned on — this number isn't enrolled in the WhatsApp Business Calling API yet. Reach out to Meta or your WhatsApp Business Solution Provider to onboard it, then turn calling on from the inbox's Calls settings."
|
||||
},
|
||||
"API": {
|
||||
@@ -848,8 +845,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 +854,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",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
+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) {
|
||||
|
||||
+9
-2
@@ -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,19 +10,24 @@ 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 = {
|
||||
// WhatsApp is onboarded only via Meta embedded signup, which needs both the
|
||||
// app id (not the 'none' sentinel) and the signup configuration id.
|
||||
whatsapp: () =>
|
||||
!isOnChatwootCloud.value &&
|
||||
(!isOnChatwootCloud.value ||
|
||||
isCloudFeatureEnabled(
|
||||
FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_INBOX_CREATION
|
||||
)) &&
|
||||
Boolean(installationConfig.whatsappAppId) &&
|
||||
installationConfig.whatsappAppId !== 'none' &&
|
||||
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 },
|
||||
|
||||
+3
@@ -6,6 +6,9 @@ vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: key => key }) }));
|
||||
vi.mock('dashboard/composables/store', () => ({
|
||||
useMapGetter: () => ({ value: {} }),
|
||||
}));
|
||||
vi.mock('dashboard/composables/useAccount', () => ({
|
||||
useAccount: () => ({ isCloudFeatureEnabled: () => true }),
|
||||
}));
|
||||
vi.mock('../../inbox-setup/useChannelConnect', () => ({
|
||||
useChannelConnect: () => ({
|
||||
connectViaOAuth: vi.fn(),
|
||||
|
||||
+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',
|
||||
]);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -8,12 +8,12 @@ import CloudWhatsapp from './CloudWhatsapp.vue';
|
||||
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
|
||||
import ChannelSelector from 'dashboard/components/ChannelSelector.vue';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
const { isCloudFeatureEnabled, isOnChatwootCloud } = useAccount();
|
||||
|
||||
const PROVIDER_TYPES = {
|
||||
WHATSAPP: 'whatsapp',
|
||||
@@ -24,10 +24,6 @@ const PROVIDER_TYPES = {
|
||||
THREE_SIXTY_DIALOG: '360dialog',
|
||||
};
|
||||
|
||||
const isWhatsappEmbeddedSignupRestricted = computed(() => {
|
||||
return isOnChatwootCloud.value;
|
||||
});
|
||||
|
||||
const hasWhatsappAppId = computed(() => {
|
||||
return (
|
||||
window.chatwootConfig?.whatsappAppId &&
|
||||
@@ -41,6 +37,17 @@ const showProviderSelection = computed(() => !selectedProvider.value);
|
||||
|
||||
const showConfiguration = computed(() => Boolean(selectedProvider.value));
|
||||
|
||||
const shouldShowWhatsappEmbeddedSignup = computed(() => {
|
||||
return (
|
||||
selectedProvider.value === PROVIDER_TYPES.WHATSAPP &&
|
||||
hasWhatsappAppId.value &&
|
||||
(!isOnChatwootCloud.value ||
|
||||
isCloudFeatureEnabled(
|
||||
FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_INBOX_CREATION
|
||||
))
|
||||
);
|
||||
});
|
||||
|
||||
const availableProviders = computed(() => [
|
||||
{
|
||||
key: PROVIDER_TYPES.WHATSAPP,
|
||||
@@ -67,7 +74,8 @@ const selectProvider = providerValue => {
|
||||
const shouldShowCloudWhatsapp = provider => {
|
||||
return (
|
||||
provider === PROVIDER_TYPES.WHATSAPP_MANUAL ||
|
||||
(provider === PROVIDER_TYPES.WHATSAPP && !hasWhatsappAppId.value)
|
||||
(provider === PROVIDER_TYPES.WHATSAPP &&
|
||||
!shouldShowWhatsappEmbeddedSignup.value)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -102,17 +110,8 @@ const handleManualLinkClick = () => {
|
||||
|
||||
<div v-else-if="showConfiguration">
|
||||
<div class="px-6 py-5 rounded-2xl border border-n-weak">
|
||||
<!-- Show embedded signup if app ID is configured -->
|
||||
<div
|
||||
v-if="
|
||||
hasWhatsappAppId && selectedProvider === PROVIDER_TYPES.WHATSAPP
|
||||
"
|
||||
>
|
||||
<WhatsappEmbeddedSignup
|
||||
:is-disabled="isWhatsappEmbeddedSignupRestricted"
|
||||
:show-restriction-alert="isWhatsappEmbeddedSignupRestricted"
|
||||
:restriction-status-url="META_RESTRICTION_STATUS_URL"
|
||||
/>
|
||||
<div v-if="shouldShowWhatsappEmbeddedSignup">
|
||||
<WhatsappEmbeddedSignup />
|
||||
|
||||
<!-- Manual setup fallback option -->
|
||||
<div class="pt-6 mt-6 border-t border-n-weak">
|
||||
|
||||
+1
-44
@@ -7,7 +7,6 @@ import { useAlert } from 'dashboard/composables';
|
||||
import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import NextButton from 'next/button/Button.vue';
|
||||
import Banner from 'next/banner/Banner.vue';
|
||||
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
|
||||
import InboxesAPI from 'dashboard/api/inboxes';
|
||||
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
@@ -18,22 +17,6 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isDisabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showRestrictionAlert: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
restrictionStatusUrl: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
restrictionWarningText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
@@ -98,8 +81,6 @@ const handleSignupSuccess = async inboxData => {
|
||||
};
|
||||
|
||||
const launchEmbeddedSignup = async () => {
|
||||
if (props.isDisabled) return;
|
||||
|
||||
let credentials;
|
||||
try {
|
||||
credentials = await runEmbeddedSignup();
|
||||
@@ -193,33 +174,9 @@ const launchEmbeddedSignup = async () => {
|
||||
</I18nT>
|
||||
</div>
|
||||
|
||||
<Banner v-if="showRestrictionAlert" color="amber" class="w-full mb-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>
|
||||
{{
|
||||
restrictionWarningText ||
|
||||
$t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.RESTRICTED_WARNING')
|
||||
}}
|
||||
<a
|
||||
v-if="restrictionStatusUrl"
|
||||
:href="restrictionStatusUrl"
|
||||
class="link underline"
|
||||
rel="noopener noreferrer nofollow"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.STATUS_LINK') }}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</Banner>
|
||||
|
||||
<div class="flex mt-4">
|
||||
<NextButton
|
||||
:disabled="isAuthenticating || isDisabled"
|
||||
:disabled="isAuthenticating"
|
||||
:is-loading="isAuthenticating"
|
||||
faded
|
||||
slate
|
||||
|
||||
+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"
|
||||
/>
|
||||
|
||||
@@ -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)';
|
||||
|
||||
@@ -153,6 +153,15 @@ After`;
|
||||
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>
|
||||
|
||||
@@ -2,7 +2,9 @@ class Channels::Whatsapp::TemplatesSyncSchedulerJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform
|
||||
Channel::Whatsapp.order(Arel.sql('message_templates_last_updated IS NULL DESC, message_templates_last_updated ASC'))
|
||||
Channel::Whatsapp.joins(:account)
|
||||
.merge(Account.active)
|
||||
.order(Arel.sql('message_templates_last_updated IS NULL DESC, message_templates_last_updated ASC'))
|
||||
.where('message_templates_last_updated <= ? OR message_templates_last_updated IS NULL', 3.hours.ago)
|
||||
.limit(Limits::BULK_EXTERNAL_HTTP_CALLS_LIMIT)
|
||||
.each do |channel|
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -69,6 +69,8 @@ class ConversationReplyMailer < ApplicationMailer
|
||||
@agent = @conversation.assignee
|
||||
@inbox = @conversation.inbox
|
||||
@channel = @inbox.channel
|
||||
Current.account = @account
|
||||
Current.inbox = @inbox
|
||||
end
|
||||
|
||||
def should_use_conversation_email_address?
|
||||
@@ -200,8 +202,24 @@ class ConversationReplyMailer < ApplicationMailer
|
||||
end
|
||||
|
||||
def choose_layout
|
||||
return 'mailer/base' if branded_email_layout_action?
|
||||
return false if action_name == 'reply_without_summary' || action_name == 'email_reply'
|
||||
|
||||
'mailer/base'
|
||||
end
|
||||
|
||||
def branded_email_layout_action?
|
||||
return false unless action_name.in?(%w[email_reply reply_without_summary])
|
||||
return @inbox.branded_email_layout_available? if @inbox&.email?
|
||||
|
||||
@account&.feature_enabled?(:branded_email_templates) && EmailTemplate.account_branded_layout_template_for(@account).present?
|
||||
end
|
||||
|
||||
def liquid_droppables
|
||||
super.merge({
|
||||
agent: current_message&.sender || @agent,
|
||||
contact: @contact,
|
||||
message: @message || @messages&.last
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module InboxBrandedEmailLayoutable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def branded_email_layout
|
||||
branded_email_layout_template&.body
|
||||
end
|
||||
|
||||
def branded_email_layout_template
|
||||
email_templates.find_by(name: EmailTemplate::BRANDED_LAYOUT_NAME, template_type: :layout, locale: EmailTemplate::DEFAULT_LOCALE)
|
||||
end
|
||||
|
||||
def effective_branded_email_layout_template(locale = I18n.locale)
|
||||
EmailTemplate.branded_layout_for(inbox: self, account: account, locale: locale)
|
||||
end
|
||||
|
||||
def branded_email_layout_available?
|
||||
email? && account.feature_enabled?(:branded_email_templates) && effective_branded_email_layout_template.present?
|
||||
end
|
||||
|
||||
def update_branded_email_layout!(body)
|
||||
if body.blank?
|
||||
branded_email_layout_template&.destroy!
|
||||
return
|
||||
end
|
||||
|
||||
template = branded_email_layout_template || email_templates.new(
|
||||
name: EmailTemplate::BRANDED_LAYOUT_NAME,
|
||||
template_type: :layout,
|
||||
locale: EmailTemplate::DEFAULT_LOCALE,
|
||||
account: account
|
||||
)
|
||||
template.update!(body: body)
|
||||
end
|
||||
end
|
||||
@@ -14,6 +14,18 @@ module PortalConfigSchema
|
||||
'additionalProperties' => false
|
||||
}.freeze
|
||||
|
||||
# Per-locale recommended content for the portal home page: an ordered list of
|
||||
# `category_ids` (the hero's "Recommended topics" pills) and `article_ids` (the
|
||||
# "Recommended" articles section). When empty, the portal uses its defaults.
|
||||
POPULAR_CONTENT_SCHEMA = {
|
||||
'type' => 'object',
|
||||
'properties' => {
|
||||
'category_ids' => { 'type' => %w[array null], 'items' => { 'type' => 'integer' } },
|
||||
'article_ids' => { 'type' => %w[array null], 'items' => { 'type' => 'integer' } }
|
||||
},
|
||||
'additionalProperties' => false
|
||||
}.freeze
|
||||
|
||||
CONFIG_PARAMS_SCHEMA = {
|
||||
'type' => 'object',
|
||||
'properties' => {
|
||||
@@ -27,6 +39,10 @@ module PortalConfigSchema
|
||||
'locale_translations' => {
|
||||
'type' => %w[object null],
|
||||
'additionalProperties' => LOCALE_TRANSLATION_SCHEMA
|
||||
},
|
||||
'popular_content' => {
|
||||
'type' => %w[object null],
|
||||
'additionalProperties' => POPULAR_CONTENT_SCHEMA
|
||||
}
|
||||
},
|
||||
'required' => [],
|
||||
|
||||
@@ -10,19 +10,111 @@
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :integer
|
||||
# inbox_id :integer
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_email_templates_on_name_and_account_id (name,account_id) UNIQUE
|
||||
# index_email_templates_on_account_scope (account_id,name,template_type,locale) UNIQUE WHERE ((account_id IS NOT NULL) AND (inbox_id IS NULL))
|
||||
# index_email_templates_on_inbox_id (inbox_id)
|
||||
# index_email_templates_on_inbox_scope (inbox_id,name,template_type,locale) UNIQUE WHERE (inbox_id IS NOT NULL)
|
||||
# index_email_templates_on_installation_scope (name,template_type,locale) UNIQUE WHERE ((account_id IS NULL) AND (inbox_id IS NULL))
|
||||
#
|
||||
class EmailTemplate < ApplicationRecord
|
||||
BRANDED_LAYOUT_NAME = 'base'.freeze
|
||||
DEFAULT_LOCALE = 'en'.freeze
|
||||
CONTENT_FOR_LAYOUT_PATTERN = /\{\{\s*content_for_layout\s*\}\}/
|
||||
|
||||
enum :locale, LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h, prefix: true
|
||||
enum :template_type, { layout: 0, content: 1 }
|
||||
belongs_to :account, optional: true
|
||||
belongs_to :inbox, optional: true
|
||||
|
||||
validates :name, uniqueness: { scope: :account }
|
||||
validates :name,
|
||||
uniqueness: { scope: %i[template_type locale], conditions: -> { where(account_id: nil, inbox_id: nil) } },
|
||||
if: :installation_scoped?
|
||||
validates :name, uniqueness: { scope: %i[account_id template_type locale], conditions: -> { where(inbox_id: nil) } }, if: :account_scoped?
|
||||
validates :name, uniqueness: { scope: %i[inbox_id template_type locale] }, if: :inbox_scoped?
|
||||
validate :validate_inbox_account
|
||||
validate :validate_liquid_body
|
||||
validate :validate_layout_slot, if: :layout?
|
||||
|
||||
def self.resolver(options = {})
|
||||
::EmailTemplates::DbResolverService.using self, options
|
||||
end
|
||||
|
||||
def self.branded_layout_for(inbox:, account:, locale: I18n.locale)
|
||||
layout_template_for_scope(inbox: inbox, account: account, locale: locale)
|
||||
end
|
||||
|
||||
def self.account_branded_layout_template_for(account)
|
||||
find_by(account: account, inbox: nil, name: BRANDED_LAYOUT_NAME, template_type: :layout, locale: DEFAULT_LOCALE)
|
||||
end
|
||||
|
||||
def self.update_account_branded_layout!(account:, body:)
|
||||
if body.blank?
|
||||
account_branded_layout_template_for(account)&.destroy!
|
||||
return
|
||||
end
|
||||
|
||||
template = account_branded_layout_template_for(account) || new(
|
||||
account: account,
|
||||
name: BRANDED_LAYOUT_NAME,
|
||||
template_type: :layout,
|
||||
locale: DEFAULT_LOCALE
|
||||
)
|
||||
template.update!(body: body)
|
||||
end
|
||||
|
||||
def self.locale_candidates(locale)
|
||||
candidate = locale.to_s
|
||||
([candidate] + [DEFAULT_LOCALE]).select { |locale_key| locales.key?(locale_key) }.uniq
|
||||
end
|
||||
|
||||
def self.layout_template_for_scope(inbox:, account:, locale:)
|
||||
scoped_relations = []
|
||||
scoped_relations << where(inbox: inbox) if inbox.present?
|
||||
scoped_relations << where(account: account, inbox: nil) if account.present?
|
||||
scoped_relations << where(account: nil, inbox: nil)
|
||||
|
||||
scoped_relations.each do |relation|
|
||||
locale_candidates(locale).each do |locale_key|
|
||||
template = relation.find_by(name: BRANDED_LAYOUT_NAME, template_type: :layout, locale: locale_key)
|
||||
return template if template.present?
|
||||
end
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def installation_scoped?
|
||||
account_id.nil? && inbox_id.nil?
|
||||
end
|
||||
|
||||
def account_scoped?
|
||||
account_id.present? && inbox_id.nil?
|
||||
end
|
||||
|
||||
def inbox_scoped?
|
||||
inbox_id.present?
|
||||
end
|
||||
|
||||
def validate_inbox_account
|
||||
return if inbox.blank? || account.blank?
|
||||
return if inbox.account_id == account_id
|
||||
|
||||
errors.add(:account, 'must match inbox account')
|
||||
end
|
||||
|
||||
def validate_liquid_body
|
||||
Liquid::Template.parse(body.to_s)
|
||||
rescue Liquid::Error => e
|
||||
errors.add(:body, "has invalid Liquid syntax: #{e.message}")
|
||||
end
|
||||
|
||||
def validate_layout_slot
|
||||
return if body.to_s.match?(CONTENT_FOR_LAYOUT_PATTERN)
|
||||
|
||||
errors.add(:body, 'must include {{ content_for_layout }}')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -45,6 +45,7 @@ class Inbox < ApplicationRecord
|
||||
include OutOfOffisable
|
||||
include AccountCacheRevalidator
|
||||
include InboxAgentAvailability
|
||||
include InboxBrandedEmailLayoutable
|
||||
|
||||
# Not allowing characters:
|
||||
validates :name, presence: true
|
||||
@@ -67,6 +68,7 @@ class Inbox < ApplicationRecord
|
||||
has_many :members, through: :inbox_members, source: :user
|
||||
has_many :conversations, dependent: :destroy_async
|
||||
has_many :messages, dependent: :destroy_async
|
||||
has_many :email_templates, dependent: :destroy_async
|
||||
|
||||
has_one :inbox_assignment_policy, dependent: :destroy
|
||||
has_one :assignment_policy, through: :inbox_assignment_policy
|
||||
|
||||
+14
-1
@@ -53,7 +53,12 @@ class Portal < ApplicationRecord
|
||||
scope :active, -> { where(archived: false) }
|
||||
|
||||
# TODO: 'website_token' is an unused reserved key; remove with a migration that scrubs it from existing portals' config
|
||||
CONFIG_JSON_KEYS = %w[allowed_locales default_locale draft_locales website_token social_profiles layout locale_translations].freeze
|
||||
CONFIG_JSON_KEYS = %w[allowed_locales default_locale draft_locales website_token social_profiles layout locale_translations
|
||||
popular_content].freeze
|
||||
|
||||
# Max number of recommended categories/articles shown per locale.
|
||||
POPULAR_CATEGORY_LIMIT = 3
|
||||
POPULAR_ARTICLE_LIMIT = 6
|
||||
|
||||
def file_base_data
|
||||
{
|
||||
@@ -115,6 +120,14 @@ class Portal < ApplicationRecord
|
||||
config_value('layout').presence || 'classic'
|
||||
end
|
||||
|
||||
def popular_category_ids(locale = default_locale)
|
||||
Array(config.dig('popular_content', locale.to_s, 'category_ids')).first(POPULAR_CATEGORY_LIMIT)
|
||||
end
|
||||
|
||||
def popular_article_ids(locale = default_locale)
|
||||
Array(config.dig('popular_content', locale.to_s, 'article_ids')).first(POPULAR_ARTICLE_LIMIT)
|
||||
end
|
||||
|
||||
def social_profiles
|
||||
config_value('social_profiles') || {}
|
||||
end
|
||||
|
||||
@@ -29,12 +29,14 @@ class ::EmailTemplates::DbResolverService < ActionView::Resolver
|
||||
end
|
||||
# rubocop:enable Metrics/ParameterLists
|
||||
|
||||
# the function has to accept(name, prefix, partial, _details, _locals = [])
|
||||
# _details contain local info which we can leverage in future
|
||||
# the function has to accept(name, prefix, partial, details, locals = [])
|
||||
# details contain local info which we can leverage in future
|
||||
# cause of codeclimate issue with 4 args, relying on (*args)
|
||||
def find_templates(name, prefix, partial, *_args)
|
||||
def find_templates(name, prefix, partial, *args)
|
||||
@template_name = name
|
||||
@template_type = prefix.include?('layout') ? 'layout' : 'content'
|
||||
@template_type = prefix.to_s.include?('layout') ? 'layout' : 'content'
|
||||
@prefix = prefix
|
||||
@details = args.first if args.first.is_a?(Hash)
|
||||
@db_template = find_db_template
|
||||
|
||||
return [] if @db_template.blank?
|
||||
@@ -54,17 +56,62 @@ class ::EmailTemplates::DbResolverService < ActionView::Resolver
|
||||
private
|
||||
|
||||
def find_db_template
|
||||
find_account_template || find_installation_template
|
||||
find_inbox_template || find_account_template || find_installation_template
|
||||
end
|
||||
|
||||
def find_inbox_template
|
||||
return unless email_inbox_layout_lookup? && branded_email_templates_enabled?
|
||||
|
||||
find_template_for(@@model.where(inbox: Current.inbox))
|
||||
end
|
||||
|
||||
def find_account_template
|
||||
return unless Current.account
|
||||
return if account_layout_lookup? && !branded_email_templates_enabled?
|
||||
|
||||
@@model.find_by(name: @template_name, template_type: @template_type, account: Current.account)
|
||||
find_template_for(@@model.where(account: Current.account, inbox: nil))
|
||||
end
|
||||
|
||||
def find_installation_template
|
||||
@@model.find_by(name: @template_name, template_type: @template_type, account: nil)
|
||||
find_template_for(@@model.where(account: nil, inbox: nil))
|
||||
end
|
||||
|
||||
def account_layout_lookup?
|
||||
@template_type == 'layout' && Current.account.present?
|
||||
end
|
||||
|
||||
def email_inbox_layout_lookup?
|
||||
account_layout_lookup? && Current.inbox&.email?
|
||||
end
|
||||
|
||||
def branded_email_templates_enabled?
|
||||
Current.account&.feature_enabled?(:branded_email_templates)
|
||||
end
|
||||
|
||||
def find_template_for(relation)
|
||||
locale_candidates.each do |locale|
|
||||
template_names.each do |name|
|
||||
template = relation.find_by(name: name, template_type: @template_type, locale: locale)
|
||||
return template if template.present?
|
||||
end
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def locale_candidates
|
||||
locale = Array(@details&.dig(:locale)).first
|
||||
EmailTemplate.locale_candidates(locale.presence || EmailTemplate::DEFAULT_LOCALE)
|
||||
end
|
||||
|
||||
def template_names
|
||||
[db_template_name, @template_name].uniq
|
||||
end
|
||||
|
||||
def db_template_name
|
||||
return @template_name if @template_type == 'layout'
|
||||
|
||||
build_path(@prefix)
|
||||
end
|
||||
|
||||
# Build path with eventual prefix
|
||||
|
||||
@@ -47,7 +47,7 @@ class Whatsapp::EmbeddedSignupService
|
||||
account: @account,
|
||||
inbox_id: @inbox_id,
|
||||
phone_number_id: @phone_number_id,
|
||||
business_id: @business_id
|
||||
waba_id: @waba_id
|
||||
).perform(access_token, phone_info)
|
||||
else
|
||||
waba_info = { waba_id: @waba_id, business_name: phone_info[:business_name] }
|
||||
|
||||
@@ -94,12 +94,12 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
|
||||
|
||||
private
|
||||
|
||||
# Only credential updates on existing channels are transfer attempts; creation failures are regular setup errors. Returns false.
|
||||
# Only saves dropping the embedded_signup source marker are transfer attempts; creation/rotation failures are setup errors. Returns false.
|
||||
def log_transfer_failure(check, response)
|
||||
return false unless whatsapp_channel.persisted? && whatsapp_channel.provider_config_changed?
|
||||
return false unless whatsapp_channel.embedded_to_manual_transfer_pending?
|
||||
|
||||
error_message = response.parsed_response.is_a?(Hash) ? response.parsed_response.dig('error', 'message') : nil
|
||||
Rails.logger.warn("[WHATSAPP_MANUAL_TRANSFER] failure account_id=#{whatsapp_channel.account_id} channel_id=#{whatsapp_channel.id} " \
|
||||
Rails.logger.warn("[WHATSAPP_EMBEDDED_TO_MANUAL] failure account_id=#{whatsapp_channel.account_id} channel_id=#{whatsapp_channel.id} " \
|
||||
"check=#{check} http_status=#{response.code} meta_error=#{error_message}")
|
||||
false
|
||||
end
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
class Whatsapp::ReauthorizationService
|
||||
def initialize(account:, inbox_id:, phone_number_id:, business_id:)
|
||||
def initialize(account:, inbox_id:, phone_number_id:, waba_id:)
|
||||
@account = account
|
||||
@inbox_id = inbox_id
|
||||
@phone_number_id = phone_number_id
|
||||
@business_id = business_id
|
||||
@waba_id = waba_id
|
||||
end
|
||||
|
||||
def perform(access_token, phone_info)
|
||||
@@ -33,7 +33,7 @@ class Whatsapp::ReauthorizationService
|
||||
channel.provider_config = current_config.merge(
|
||||
'api_key' => access_token,
|
||||
'phone_number_id' => resolved_phone_number_id,
|
||||
'business_account_id' => @business_id,
|
||||
'business_account_id' => @waba_id,
|
||||
'source' => 'embedded_signup'
|
||||
)
|
||||
channel.save!
|
||||
|
||||
@@ -4,6 +4,8 @@ json.title article.title
|
||||
json.content article.content
|
||||
json.description article.description
|
||||
json.status article.status
|
||||
json.draft_title article.draft_title
|
||||
json.draft_content article.draft_content
|
||||
json.position article.position
|
||||
json.account_id article.account_id
|
||||
json.updated_at article.updated_at.to_i
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
json.branded_email_layout @branded_email_layout
|
||||
@@ -0,0 +1 @@
|
||||
json.branded_email_layout @branded_email_layout
|
||||
@@ -1 +1 @@
|
||||
json.partial! 'api/v1/models/inbox', formats: [:json], resource: @inbox
|
||||
json.partial! 'api/v1/models/inbox', formats: [:json], resource: @inbox, with_branded_email_layout: true
|
||||
|
||||
@@ -1 +1 @@
|
||||
json.partial! 'api/v1/models/inbox', formats: [:json], resource: @inbox
|
||||
json.partial! 'api/v1/models/inbox', formats: [:json], resource: @inbox, with_branded_email_layout: true
|
||||
|
||||
@@ -19,6 +19,7 @@ json.config do
|
||||
json.layout portal.layout
|
||||
json.social_profiles portal.social_profiles
|
||||
json.locale_translations portal.config['locale_translations'] || {}
|
||||
json.popular_content portal.config['popular_content'] || {}
|
||||
end
|
||||
|
||||
if portal.channel_web_widget
|
||||
|
||||
@@ -81,6 +81,10 @@ if resource.email?
|
||||
json.email resource.channel.try(:email)
|
||||
json.forwarding_enabled ENV.fetch('MAILER_INBOUND_EMAIL_DOMAIN', '').present?
|
||||
json.forward_to_email resource.channel.try(:forward_to_email) if ENV.fetch('MAILER_INBOUND_EMAIL_DOMAIN', '').present?
|
||||
if Current.account_user&.administrator? && defined?(with_branded_email_layout) && with_branded_email_layout.present? &&
|
||||
Current.account.feature_enabled?(:branded_email_templates)
|
||||
json.branded_email_layout resource.branded_email_layout
|
||||
end
|
||||
|
||||
## IMAP
|
||||
if Current.account_user&.administrator?
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
json.access_token resource.access_token.token
|
||||
json.access_token resource.accounts.any?(&:api_and_webhooks_enabled?) ? resource.access_token.token : ''
|
||||
json.account_id resource.active_account_user&.account_id
|
||||
json.available_name resource.available_name
|
||||
json.avatar_url resource.avatar_url
|
||||
@@ -31,6 +31,7 @@ json.accounts do
|
||||
# availability derived from presence
|
||||
json.availability_status account_user.availability_status
|
||||
json.auto_offline account_user.auto_offline
|
||||
json.api_and_webhooks account_user.account.feature_enabled?('api_and_webhooks')
|
||||
json.partial! 'api/v1/models/account_user', account_user: account_user if ChatwootApp.enterprise?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -67,6 +67,11 @@ html.light {
|
||||
#category-block:hover #category-name {
|
||||
color: var(--dynamic-hover-color);
|
||||
}
|
||||
/* Recommended topic pills in the classic hero */
|
||||
.recommended-pill:hover {
|
||||
border-color: var(--dynamic-hover-color);
|
||||
color: var(--dynamic-hover-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
<head>
|
||||
<%= render 'layouts/portal_head' %>
|
||||
</head>
|
||||
<body class="font-inter">
|
||||
<body class="font-inter bg-white dark:bg-slate-900">
|
||||
<div id="portal" class="antialiased">
|
||||
<main class="flex flex-col min-h-screen bg-white main-content dark:bg-slate-900" role="main">
|
||||
<main class="flex flex-col min-h-screen main-content" role="main">
|
||||
<%= render 'public/api/v1/portals/documentation_layout/topbar',
|
||||
portal: @portal, locale: @locale, article: @article, category: @category %>
|
||||
<div class="bg-white dark:bg-n-slate-2 flex-1 flex font-inter tracking-normal [font-optical-sizing:auto]">
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="<%= html_lang_attribute(I18n.locale) %>">
|
||||
<head>
|
||||
<%= render 'layouts/portal_head' %>
|
||||
</head>
|
||||
<body class="font-default bg-white dark:bg-slate-900">
|
||||
<div id="portal" class="antialiased">
|
||||
<main class="flex flex-col min-h-screen main-content" role="main">
|
||||
<%= yield %>
|
||||
</main>
|
||||
</div>
|
||||
<%= render 'layouts/portal_scripts' %>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,12 +3,12 @@
|
||||
<head>
|
||||
<%= render 'layouts/portal_head' %>
|
||||
</head>
|
||||
<body class="font-default">
|
||||
<body class="font-default bg-white dark:bg-slate-900">
|
||||
<div id="portal" class="antialiased">
|
||||
<main class="flex flex-col min-h-screen bg-white main-content dark:bg-slate-900" role="main">
|
||||
<%= render 'public/api/v1/portals/header', portal: @portal unless @is_plain_layout_enabled %>
|
||||
<main class="flex flex-col min-h-screen main-content" role="main">
|
||||
<%= render 'public/api/v1/portals/header', portal: @portal %>
|
||||
<%= yield %>
|
||||
<%= render 'public/api/v1/portals/footer' unless @is_plain_layout_enabled || @portal.account.feature_enabled?('disable_branding') %>
|
||||
<%= render 'public/api/v1/portals/footer' unless @portal.account.feature_enabled?('disable_branding') %>
|
||||
</main>
|
||||
</div>
|
||||
<%= render 'layouts/portal_scripts' %>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user