Merge branch 'release/2.8.0'

This commit is contained in:
Sojan
2022-08-16 17:33:28 +05:30
673 changed files with 31855 additions and 3757 deletions

View File

@@ -53,3 +53,4 @@ exclude_patterns:
- 'app/javascript/dashboard/i18n/index.js'
- 'app/javascript/widget/i18n/index.js'
- 'app/javascript/survey/i18n/index.js'
- 'app/javascript/shared/constants/locales.js'

View File

@@ -38,6 +38,9 @@ REDIS_SENTINEL_MASTER_NAME=
# REDIS_OPENSSL_VERIFY_MODE=none
# Postgres Database config variables
# You can leave POSTGRES_DATABASE blank. The default name of
# the database in the production environment is chatwoot_production
POSTGRES_DATABASE=
POSTGRES_HOST=postgres
POSTGRES_USERNAME=postgres
POSTGRES_PASSWORD=
@@ -48,7 +51,6 @@ RAILS_MAX_THREADS=5
# could user either `email@yourdomain.com` or `BrandName <email@yourdomain.com>`
MAILER_SENDER_EMAIL="Chatwoot <accounts@chatwoot.com>"
#SMTP domain key is set up for HELO checking
SMTP_DOMAIN=chatwoot.com
# the default value is set "mailhog" and is used by docker-compose for development environments,
@@ -93,7 +95,6 @@ AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_REGION=
# Log settings
# Disable if you want to write logs to a file
RAILS_LOG_TO_STDOUT=true
@@ -130,7 +131,6 @@ ANDROID_BUNDLE_ID=com.chatwoot.app
# https://developers.google.com/android/guides/client-auth (use keytool to print the fingerprint in the first section)
ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:D4:5D:D4:53:F8:3B:FB:D3:C6:28:64:1D:AA:08:1E:D8
### Smart App Banner
# https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/PromotingAppswithAppBanners/PromotingAppswithAppBanners.html
# You can find your app-id in https://itunesconnect.apple.com
@@ -147,8 +147,12 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
## Bot Customizations
USE_INBOX_AVATAR_FOR_BOT=true
### APM and Error Monitoring configurations
## Elastic APM
## https://www.elastic.co/guide/en/apm/agent/ruby/current/getting-started-rails.html
# ELASTIC_APM_SERVER_URL=
# ELASTIC_APM_SECRET_TOKEN=
## Sentry
# SENTRY_DSN=
@@ -169,7 +173,6 @@ USE_INBOX_AVATAR_FOR_BOT=true
## https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#environment-variables
# DD_TRACE_AGENT_URL=
## IP look up configuration
## ref https://github.com/alexreisner/geocoder/blob/master/README_API_GUIDE.md
## works only on accounts with ip look up feature enabled
@@ -181,7 +184,6 @@ USE_INBOX_AVATAR_FOR_BOT=true
## To prevent and throttle abusive requests
# ENABLE_RACK_ATTACK=true
## Running chatwoot as an API only server
## setting this value to true will disable the frontend dashboard endpoints
# CW_API_ONLY_SERVER=false
@@ -195,3 +197,11 @@ USE_INBOX_AVATAR_FOR_BOT=true
# If you want to use official mobile app,
# the notifications would be relayed via a Chatwoot server
ENABLE_PUSH_RELAY_SERVER=true
# Stripe API key
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
# Set to true if you want to upload files to cloud storage using the signed url
# Make sure to follow https://edgeguides.rubyonrails.org/active_storage_overview.html#cross-origin-resource-sharing-cors-configuration on the cloud storage after setting this to true.
DIRECT_UPLOADS_ENABLED=

36
.github/workflows/lock.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
# We often have cases where users would comment over stale closed Github Issues.
# This creates unnecessary noise for the original reporter and makes it harder for triaging.
# This action locks the closed threads once it is inactive for over a month.
name: 'Lock Threads'
on:
schedule:
- cron: '0 * * * *'
workflow_dispatch:
permissions:
issues: write
pull-requests: write
concurrency:
group: lock
jobs:
action:
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v3
with:
issue-inactive-days: '30'
issue-lock-reason: 'resolved'
issue-comment: >
This issue has been automatically locked since there
has not been any recent activity after it was closed.
Please open a new issue for related bugs.
pr-inactive-days: '30'
pr-lock-reason: 'resolved'
pr-comment: >
This pull request has been automatically locked since there
has not been any recent activity after it was closed.
Please open a new issue for related bugs.

58
.github/workflows/nightly_installer.yml vendored Normal file
View File

@@ -0,0 +1,58 @@
# #
# #
# # Linux nightly installer action
# # This action will try to install and setup
# # chatwoot on an Ubuntu 20.04 machine using
# # the linux installer script.
# #
# # This is set to run daily at midnight.
# #
name: Run Linux nightly installer
on:
schedule:
- cron: "0 0 * * *"
workflow_dispatch:
jobs:
nightly:
runs-on: ubuntu-20.04
steps:
- name: get installer
run: |
wget https://get.chatwoot.app/linux/install.sh
chmod +x install.sh
- name: create input file
run: |
echo "no" > input
echo "yes" >> input
- name: Run the installer
run: |
sudo ./install.sh --install < input
# temp fix for postgresql not starting
# automatically in gh action env
- name: start postgresql service
if: always()
run: |
sudo service postgresql start
#re-running the installer again
- name: Run the installer again
if: always()
run: |
sudo ./install.sh --install < input
# disabling http verify for now as http
# access to port 3000 fails in gh action env
# - name: Verify
# if: always()
# run: |
# sudo netstat -ntlp | grep 3000
# sudo systemctl restart chatwoot.target
# curl http://localhost:3000/api

View File

@@ -183,3 +183,4 @@ AllCops:
- db/migrate/20200503151130_add_account_feature_flag.rb
- db/migrate/20200927135222_add_last_activity_at_to_conversation.rb
- db/migrate/20210306170117_add_last_activity_at_to_contacts.rb
- db/migrate/20220809104508_revert_cascading_indexes.rb

View File

@@ -91,6 +91,7 @@ gem 'google-cloud-dialogflow'
##-- apm and error monitoring ---#
gem 'ddtrace'
gem 'elastic-apm'
gem 'newrelic_rpm'
gem 'scout_apm'
gem 'sentry-rails', '~> 5.3'
@@ -127,6 +128,9 @@ gem 'working_hours'
# full text search for articles
gem 'pg_search'
# Subscriptions, Billing
gem 'stripe'
group :production, :staging do
# we dont want request timing out in development while using byebug
gem 'rack-timeout'

View File

@@ -182,6 +182,9 @@ GEM
addressable (~> 2.8)
ecma-re-validator (0.4.0)
regexp_parser (~> 2.2)
elastic-apm (4.5.1)
concurrent-ruby (~> 1.0)
http (>= 3.0)
email_reply_trimmer (0.1.13)
erubi (1.10.0)
et-orbi (1.2.7)
@@ -226,6 +229,9 @@ GEM
faraday (>= 1.0.0, < 3.0)
googleauth (~> 1)
ffi (1.15.5)
ffi-compiler (1.0.1)
ffi (>= 1.0.0)
rake
flag_shih_tzu (0.3.23)
foreman (0.87.2)
fugit (1.5.3)
@@ -318,9 +324,15 @@ GEM
hkdf (0.3.0)
html2text (0.2.1)
nokogiri (~> 1.6)
http (5.1.0)
addressable (~> 2.8)
http-cookie (~> 1.0)
http-form_data (~> 2.2)
llhttp-ffi (~> 0.4.0)
http-accept (1.7.0)
http-cookie (1.0.5)
domain_name (~> 0.5)
http-form_data (2.3.0)
httparty (0.20.0)
mime-types (~> 3.0)
multi_xml (>= 0.5.2)
@@ -383,6 +395,9 @@ GEM
listen (3.7.1)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
llhttp-ffi (0.4.0)
ffi-compiler (~> 1.0)
rake (~> 13.0)
loofah (2.18.0)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
@@ -616,6 +631,7 @@ GEM
sprockets (>= 3.0.0)
squasher (0.6.2)
statsd-ruby (1.5.0)
stripe (6.5.0)
telephone_number (1.4.16)
thor (1.2.1)
tilt (2.0.10)
@@ -708,6 +724,7 @@ DEPENDENCIES
devise_token_auth
dotenv-rails
down (~> 5.0)
elastic-apm
email_reply_trimmer
facebook-messenger
factory_bot_rails
@@ -769,6 +786,7 @@ DEPENDENCIES
spring
spring-watcher-listen
squasher
stripe
telephone_number
time_diff
twilio-ruby (~> 5.66)
@@ -787,4 +805,4 @@ RUBY VERSION
ruby 3.0.4p208
BUNDLED WITH
2.3.16
2.3.17

View File

@@ -104,7 +104,7 @@ class ContactIdentifyAction
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
@contact.discard_invalid_attrs if discard_invalid_attrs
@contact.save!
ContactAvatarJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present?
Avatar::AvatarFromUrlJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present?
end
def merge_contact(base_contact, merge_contact)

View File

@@ -2,7 +2,7 @@
class AccountBuilder
include CustomExceptions::Account
pattr_initialize [:account_name!, :email!, :confirmed, :user, :user_full_name, :user_password, :super_admin]
pattr_initialize [:account_name!, :email!, :confirmed, :user, :user_full_name, :user_password, :super_admin, :locale]
def perform
if @user.nil?

View File

@@ -23,7 +23,7 @@ class ContactBuilder
end
def update_contact_avatar(contact)
::ContactAvatarJob.perform_later(contact, contact_attributes[:avatar_url]) if contact_attributes[:avatar_url]
::Avatar::AvatarFromUrlJob.perform_later(contact, contact_attributes[:avatar_url]) if contact_attributes[:avatar_url]
end
def create_contact

View File

@@ -58,7 +58,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
return if contact_params[:remote_avatar_url].blank?
return if @contact.avatar.attached?
ContactAvatarJob.perform_later(@contact, contact_params[:remote_avatar_url])
Avatar::AvatarFromUrlJob.perform_later(@contact, contact_params[:remote_avatar_url])
end
def conversation

View File

@@ -15,6 +15,9 @@ class NotificationBuilder
def user_subscribed_to_notification?
notification_setting = user.notification_settings.find_by(account_id: account.id)
# added for the case where an assignee might be removed from the account but remains in conversation
return if notification_setting.blank?
return true if notification_setting.public_send("email_#{notification_type}?")
return true if notification_setting.public_send("push_#{notification_type}?")

View File

@@ -72,6 +72,6 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
end
def validate_limit
render_payment_required('Account limit exceeded. Upgrade to a higher plan') if agents.count >= Current.account.usage_limits[:agents]
render_payment_required('Account limit exceeded. Please purchase more licenses') if agents.count >= Current.account.usage_limits[:agents]
end
end

View File

@@ -2,10 +2,12 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
before_action :portal
before_action :check_authorization
before_action :fetch_article, except: [:index, :create]
before_action :set_current_page, only: [:index]
def index
@articles_count = @portal.articles.count
@articles = @portal.articles
@articles = @articles.search(list_params) if params[:payload].present?
@articles = @articles.search(list_params) if list_params.present?
end
def create
@@ -45,8 +47,10 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
end
def list_params
params.require(:payload).permit(
:category_slug, :locale, :query
)
params.permit(:locale, :query, :page, :category_slug, :status, :author_id)
end
def set_current_page
@current_page = params[:page] || 1
end
end

View File

@@ -90,9 +90,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
end
def set_avatar(facebook_inbox, page_id)
avatar_file = Down.download(
"http://graph.facebook.com/#{page_id}/picture?type=large"
)
facebook_inbox.avatar.attach(io: avatar_file, filename: avatar_file.original_filename, content_type: avatar_file.content_type)
avatar_url = "https://graph.facebook.com/#{page_id}/picture?type=large"
Avatar::AvatarFromUrlJob.perform_later(facebook_inbox, avatar_url)
end
end

View File

@@ -2,6 +2,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
before_action :portal
before_action :check_authorization
before_action :fetch_category, except: [:index, :create]
before_action :set_current_page, only: [:index]
def index
@categories = @portal.categories.search(params)
@@ -49,4 +50,8 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
:name, :description, :position, :slug, :locale, :parent_category_id, :associated_category_id
)
end
def set_current_page
@current_page = params[:page] || 1
end
end

View File

@@ -25,7 +25,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
contacts = resolved_contacts.where(
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search',
search: "%#{params[:q]}%"
search: "%#{params[:q].strip}%"
)
@contacts_count = contacts.count
@contacts = fetch_contacts_with_conversation_count(contacts)
@@ -166,13 +166,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
end
def process_avatar
if permitted_params[:avatar].blank? && permitted_params[:avatar_url].present?
::ContactAvatarJob.perform_later(@contact, params[:avatar_url])
elsif permitted_params[:avatar].blank? && permitted_params[:email].present?
hash = Digest::MD5.hexdigest(params[:email])
gravatar_url = "https://www.gravatar.com/avatar/#{hash}?d=404"
::ContactAvatarJob.perform_later(@contact, gravatar_url)
end
::Avatar::AvatarFromUrlJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present?
end
def render_error(error, error_status)

View File

@@ -0,0 +1,57 @@
class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :fetch_macro, only: [:show, :update, :destroy, :execute]
def index
@macros = Macro.with_visibility(current_user, params)
end
def create
@macro = Current.account.macros.new(macros_with_user.merge(created_by_id: current_user.id))
@macro.set_visibility(current_user, permitted_params)
@macro.actions = params[:actions]
render json: { error: @macro.errors.messages }, status: :unprocessable_entity and return unless @macro.valid?
@macro.save!
end
def show; end
def destroy
@macro.destroy!
head :ok
end
def update
ActiveRecord::Base.transaction do
@macro.update!(macros_with_user)
@macro.set_visibility(current_user, permitted_params)
@macro.save!
rescue StandardError => e
Rails.logger.error e
render json: { error: @macro.errors.messages }.to_json, status: :unprocessable_entity
end
end
def execute
::MacrosExecutionJob.perform_later(@macro, conversation_ids: params[:conversation_ids], user: Current.user)
head :ok
end
def permitted_params
params.permit(
:name, :account_id, :visibility,
actions: [:action_name, { action_params: [] }]
)
end
def macros_with_user
permitted_params.merge(updated_by_id: current_user.id)
end
def fetch_macro
@macro = Current.account.macros.find_by(id: params[:id])
end
end

View File

@@ -3,6 +3,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
before_action :fetch_portal, except: [:index, :create]
before_action :check_authorization
before_action :set_current_page, only: [:index]
def index
@portals = Current.account.portals
@@ -17,8 +18,6 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def create
@portal = Current.account.portals.build(portal_params)
render json: { error: @portal.errors.messages }, status: :unprocessable_entity and return unless @portal.valid?
@portal.save!
process_attached_logo
end
@@ -66,4 +65,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def portal_member_params
params.require(:portal).permit(:account_id, member_ids: [])
end
def set_current_page
@current_page = params[:page] || 1
end
end

View File

@@ -19,6 +19,7 @@ class Api::V1::AccountsController < Api::BaseController
user_full_name: account_params[:user_full_name],
email: account_params[:email],
user_password: account_params[:password],
locale: account_params[:locale],
user: current_user
).perform
if @user

View File

@@ -36,9 +36,10 @@ class Api::V1::Widget::BaseController < ApplicationController
contact_id: @contact.id,
contact_inbox_id: @contact_inbox.id,
additional_attributes: {
browser_language: browser.accept_language&.first&.code,
browser: browser_params,
referer: permitted_params[:message][:referer_url],
initiated_at: timestamp_params
initiated_at: timestamp_params,
referer: permitted_params[:message][:referer_url]
},
custom_attributes: permitted_params[:custom_attributes].presence || {}
}

View File

@@ -17,10 +17,6 @@ class ApplicationController < ActionController::Base
Current.user = @user
end
def current_subscription
@subscription ||= Current.account.subscription
end
def pundit_user
{
user: Current.user,

View File

@@ -8,6 +8,8 @@ module EnsureCurrentAccountHelper
def ensure_current_account
account = Account.find(params[:account_id])
ensure_account_is_active?(account)
if current_user
account_accessible_for_user?(account)
elsif @resource.is_a?(AgentBot)
@@ -25,4 +27,8 @@ module EnsureCurrentAccountHelper
def account_accessible_for_bot?(account)
render_unauthorized('You are not authorized to access this account') unless @resource.agent_bot_inboxes.find_by(account_id: account.id)
end
def ensure_account_is_active?(account)
render_unauthorized('Account is suspended') unless account.active?
end
end

View File

@@ -5,7 +5,9 @@ module WebsiteTokenHelper
def set_web_widget
@web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token])
@current_account = @web_widget.account
@current_account = @web_widget.inbox.account
render json: { error: 'Account is suspended' }, status: :unauthorized unless @current_account.active?
end
def set_contact

View File

@@ -13,8 +13,7 @@ class DashboardController < ActionController::Base
def set_global_config
@global_config = GlobalConfig.get(
'LOGO',
'LOGO_THUMBNAIL',
'LOGO', 'LOGO_THUMBNAIL',
'INSTALLATION_NAME',
'WIDGET_BRAND_URL',
'TERMS_URL',
@@ -29,7 +28,8 @@ class DashboardController < ActionController::Base
'DIRECT_UPLOADS_ENABLED',
'HCAPTCHA_SITE_KEY',
'LOGOUT_REDIRECT_LINK',
'DISABLE_USER_PROFILE_UPDATE'
'DISABLE_USER_PROFILE_UPDATE',
'DEPLOYMENT_ENV'
).merge(app_config)
end

View File

@@ -27,6 +27,6 @@ class Platform::Api::V1::AccountsController < PlatformController
end
def account_params
params.permit(:name)
params.permit(:name, :locale)
end
end

View File

@@ -4,7 +4,7 @@ class Public::Api::V1::Portals::ArticlesController < ApplicationController
def index
@articles = @portal.articles
@articles = @articles.search(list_params) if params[:payload].present?
@articles = @articles.search(list_params) if list_params.present?
end
def show; end
@@ -20,6 +20,6 @@ class Public::Api::V1::Portals::ArticlesController < ApplicationController
end
def list_params
params.require(:payload).permit(:query)
params.permit(:query)
end
end

View File

@@ -4,6 +4,7 @@ class WidgetsController < ActionController::Base
before_action :set_global_config
before_action :set_web_widget
before_action :ensure_account_is_active
before_action :set_token
before_action :set_contact
before_action :build_contact
@@ -46,6 +47,10 @@ class WidgetsController < ActionController::Base
@contact = @contact_inbox.contact
end
def ensure_account_is_active
render json: { error: 'Account is suspended' }, status: :unauthorized unless @web_widget.inbox.account.active?
end
def additional_attributes
if @web_widget.inbox.account.feature_enabled?('ip_lookup')
{ created_at_ip: request.remote_ip }

View File

@@ -17,6 +17,7 @@ class AccountDashboard < Administrate::BaseDashboard
users: CountField,
conversations: CountField,
locale: Field::Select.with_options(collection: LANGUAGES_CONFIG.map { |_x, y| y[:iso_639_1_code] }),
status: Field::Select.with_options(collection: [%w[Active active], %w[Suspended suspended]]),
account_users: Field::HasMany
}.merge(enterprise_attribute_types).freeze
@@ -31,6 +32,7 @@ class AccountDashboard < Administrate::BaseDashboard
locale
users
conversations
status
].freeze
# SHOW_PAGE_ATTRIBUTES
@@ -42,6 +44,7 @@ class AccountDashboard < Administrate::BaseDashboard
created_at
updated_at
locale
status
conversations
account_users
] + enterprise_show_page_attributes).freeze
@@ -53,6 +56,7 @@ class AccountDashboard < Administrate::BaseDashboard
FORM_ATTRIBUTES = (%i[
name
locale
status
] + enterprise_form_attributes).freeze
# COLLECTION_FILTERS

View File

@@ -6,7 +6,7 @@ class ConversationDrop < BaseDrop
end
def contact_name
@obj.try(:contact).name.capitalize || 'Customer'
@obj.try(:contact).name.try(:capitalize) || 'Customer'
end
def recent_messages

View File

@@ -15,6 +15,11 @@ class ApiClient {
baseUrl() {
let url = this.apiVersion;
if (this.options.enterprise) {
url = `/enterprise${url}`;
}
if (this.options.accountScoped) {
const isInsideAccountScopedURLs = window.location.pathname.includes(
'/app/accounts'

View File

@@ -0,0 +1,18 @@
/* global axios */
import ApiClient from '../ApiClient';
class EnterpriseAccountAPI extends ApiClient {
constructor() {
super('', { accountScoped: true, enterprise: true });
}
checkout() {
return axios.post(`${this.url}checkout`);
}
subscription() {
return axios.post(`${this.url}subscription`);
}
}
export default new EnterpriseAccountAPI();

View File

@@ -0,0 +1,31 @@
import accountAPI from '../account';
import ApiClient from '../../ApiClient';
import describeWithAPIMock from '../../specs/apiSpecHelper';
describe('#enterpriseAccountAPI', () => {
it('creates correct instance', () => {
expect(accountAPI).toBeInstanceOf(ApiClient);
expect(accountAPI).toHaveProperty('get');
expect(accountAPI).toHaveProperty('show');
expect(accountAPI).toHaveProperty('create');
expect(accountAPI).toHaveProperty('update');
expect(accountAPI).toHaveProperty('delete');
expect(accountAPI).toHaveProperty('checkout');
});
describeWithAPIMock('API calls', context => {
it('#checkout', () => {
accountAPI.checkout();
expect(context.axiosMock.post).toHaveBeenCalledWith(
'/enterprise/api/v1/checkout'
);
});
it('#subscription', () => {
accountAPI.subscription();
expect(context.axiosMock.post).toHaveBeenCalledWith(
'/enterprise/api/v1/subscription'
);
});
});
});

View File

@@ -0,0 +1,26 @@
/* global axios */
import PortalsAPI from './portals';
class ArticlesAPI extends PortalsAPI {
constructor() {
super('articles', { accountScoped: true });
}
getArticles({
pageNumber,
portalSlug,
locale,
status,
author_id,
category_slug,
}) {
let baseUrl = `${this.url}/${portalSlug}/articles?page=${pageNumber}&locale=${locale}`;
if (status !== undefined) baseUrl += `&status=${status}`;
if (author_id) baseUrl += `&author_id=${author_id}`;
if (category_slug) baseUrl += `&category_slug=${category_slug}`;
return axios.get(baseUrl);
}
}
export default new ArticlesAPI();

View File

@@ -0,0 +1,27 @@
/* global axios */
import PortalsAPI from './portals';
class CategoriesAPI extends PortalsAPI {
constructor() {
super('categories', { accountScoped: true });
}
get({ portalSlug }) {
return axios.get(`${this.url}/${portalSlug}/categories`);
}
create({ portalSlug, categoryObj }) {
return axios.post(`${this.url}/${portalSlug}/categories`, categoryObj);
}
update({ portalSlug, categoryObj }) {
return axios.patch(`${this.url}/${portalSlug}/categories`, categoryObj);
}
delete({ portalSlug, categoryId }) {
return axios.delete(`${this.url}/${portalSlug}/categories/${categoryId}`);
}
}
export default new CategoriesAPI();

View File

@@ -0,0 +1,9 @@
import ApiClient from '../ApiClient';
class PortalsAPI extends ApiClient {
constructor() {
super('portals', { accountScoped: true });
}
}
export default PortalsAPI;

View File

@@ -0,0 +1,29 @@
import articlesAPI from '../helpCenter/articles';
import ApiClient from 'dashboard/api/helpCenter/portals';
import describeWithAPIMock from './apiSpecHelper';
describe('#PortalAPI', () => {
it('creates correct instance', () => {
expect(articlesAPI).toBeInstanceOf(ApiClient);
expect(articlesAPI).toHaveProperty('get');
expect(articlesAPI).toHaveProperty('show');
expect(articlesAPI).toHaveProperty('create');
expect(articlesAPI).toHaveProperty('update');
expect(articlesAPI).toHaveProperty('delete');
expect(articlesAPI).toHaveProperty('getArticles');
});
describeWithAPIMock('API calls', context => {
it('#getArticles', () => {
articlesAPI.getArticles({
pageNumber: 1,
portalSlug: 'room-rental',
locale: 'en-US',
status: 'published',
author_id: '1',
});
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v1/portals/room-rental/articles?page=1&locale=en-US&status=published&author_id=1'
);
});
});
});

View File

@@ -0,0 +1,12 @@
import categoriesAPI from '../../helpCenter/categories';
import ApiClient from '../../ApiClient';
describe('#BulkActionsAPI', () => {
it('creates correct instance', () => {
expect(categoriesAPI).toBeInstanceOf(ApiClient);
expect(categoriesAPI).toHaveProperty('get');
expect(categoriesAPI).toHaveProperty('create');
expect(categoriesAPI).toHaveProperty('update');
expect(categoriesAPI).toHaveProperty('delete');
});
});

View File

@@ -0,0 +1,13 @@
import PortalsAPI from '../helpCenter/portals';
import ApiClient from '../ApiClient';
const portalAPI = new PortalsAPI();
describe('#PortalAPI', () => {
it('creates correct instance', () => {
expect(portalAPI).toBeInstanceOf(ApiClient);
expect(portalAPI).toHaveProperty('get');
expect(portalAPI).toHaveProperty('show');
expect(portalAPI).toHaveProperty('create');
expect(portalAPI).toHaveProperty('update');
expect(portalAPI).toHaveProperty('delete');
});
});

View File

@@ -0,0 +1,3 @@
<svg width="66" height="66" viewBox="0 0 66 66" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M63 63H32.9976C16.4591 63 2.99996 49.5399 2.99996 32.9973C2.99996 16.4601 16.4591 3 32.9979 3C49.5408 3 63 16.4601 63 32.9973V63Z" fill="white" stroke="white" stroke-width="6"/>
</svg>

After

Width:  |  Height:  |  Size: 289 B

View File

@@ -1,11 +1,9 @@
/* Enter and leave animations can use different */
/* durations and timing functions. */
.slide-fade-enter-active {
transition: all .3s $ease-in-cubic;
transition: all 0.3s var(--ease-in-cubic);
}
.slide-fade-leave-active {
transition: all .3s $ease-out-cubic;
transition: all 0.3s var(--ease-out-cubic);
}
.slide-fade-enter,
@@ -24,7 +22,7 @@
.conversations-list-enter-active,
.conversations-list-leave-active {
transition: all .25s $ease-out-cubic;
transition: all 0.25s var(--ease-out-cubic);
}
.conversations-list-enter,
@@ -35,11 +33,10 @@
.menu-list-enter-active,
.menu-list-leave-active {
transition: opacity .3s $ease-out-cubic,
transform .2s $ease-out-cubic;
transition: opacity 0.3s var(--ease-out-cubic),
transform 0.2s var(--ease-out-cubic);
}
.menu-list-leave-to {
opacity: 0;
position: absolute;
@@ -52,23 +49,24 @@
}
.slide-up-enter-active {
transition: all .3s $ease-in-cubic;
transition: all 0.3s var(--ease-in-cubic);
}
.slide-up-leave-active {
transition: all .3s $ease-out-cubic;
transition: all 0.3s var(--ease-out-cubic);
}
.slide-up-enter,
.slide-up-leave-to {
transform: translateY(-$space-medium);
opacity: 0;
transform: translateY(-$space-medium);
}
.menu-slide-enter-active,
.menu-slide-leave-active {
transform: translateY(0);
transition: transform 0.25s $ease-in-cubic, opacity 0.15s $ease-in-cubic;
transition: transform 0.25s var(--ease-in-cubic),
opacity 0.15s var(--ease-in-cubic);
}
.menu-slide-enter,
@@ -77,13 +75,12 @@
transform: translateY($space-small);
}
.toast-fade-enter-active {
transition: all .3s $ease-in-sine;
transition: all 0.3s var(--ease-in-sine);
}
.toast-fade-leave-active {
transition: all .1s $ease-out-sine;
transition: all 0.1s var(--ease-out-sine);
}
.toast-fade-enter,
@@ -93,11 +90,11 @@
}
.modal-fade-enter-active {
transition: all .3s $ease-in-sine;
transition: all 0.3s var(--ease-in-sine);
}
.modal-fade-leave-active {
transition: all .1s $ease-out-sine;
transition: all 0.1s var(--ease-out-sine);
}
.modal-fade-enter,
@@ -106,15 +103,15 @@
}
.network-notification-fade-enter-active {
transition: all .1s $ease-in-sine;
transition: all 0.1s var(--ease-in-sine);
}
.network-notification-fade-leave-active {
transition: all .1s $ease-out-sine;
transition: all 0.1s var(--ease-out-sine);
}
.network-notification-fade-enter,
.network-notification-fade-leave-to {
transform: translateY(-$space-small);
opacity: 0;
transform: translateY(-$space-small);
}

View File

@@ -41,24 +41,22 @@ is-closed .app-root {
.view-box {
@include full-height;
@include margin(0);
@include space-between-column;
height: 100vh;
margin: 0;
}
.view-panel {
@include margin($zero);
@include padding($space-normal);
flex-direction: column;
margin: 0;
overflow-y: auto;
padding: $space-normal;
}
.content-box {
@include padding($space-normal);
overflow: auto;
padding: $space-normal;
}
.back-button {
@@ -91,8 +89,7 @@ is-closed .app-root {
justify-content: center;
img {
@include padding($space-one);
max-width: $space-mega;
padding: $space-one;
}
}

View File

@@ -1,4 +1,5 @@
@import 'shared/assets/fonts/inter';
@import 'shared/assets/stylesheets/animations';
@import 'shared/assets/stylesheets/colors';
@import 'shared/assets/stylesheets/spacing';
@import 'shared/assets/stylesheets/font-size';
@@ -16,7 +17,6 @@
@import 'date-picker';
@import 'foundation-sites/scss/foundation';
@import '~bourbon/core/bourbon';
@include foundation-everything($flex: true);

View File

@@ -16,11 +16,11 @@
margin-bottom: var(--space-normal);
&.multiselect--disabled {
opacity: .8;
opacity: 0.8;
}
.multiselect--active {
>.multiselect__tags {
> .multiselect__tags {
border-color: $color-woot;
}
}
@@ -96,9 +96,9 @@
}
.multiselect__tags {
@include margin(0);
border: 1px solid $color-border;
border-color: $color-border;
margin: 0;
min-height: 4.4rem;
padding-top: $zero;
}
@@ -130,10 +130,10 @@
.multiselect__input {
@include ghost-input;
@include padding($zero);
font-size: $font-size-small;
height: 4.4rem;
margin-bottom: $zero;
padding: 0;
}
.multiselect__single {
@@ -145,7 +145,6 @@
}
.sidebar-labels-wrap {
&.has-edited,
&:hover {
.multiselect {
@@ -154,15 +153,15 @@
}
.multiselect {
>.multiselect__select {
> .multiselect__select {
visibility: hidden;
}
>.multiselect__tags {
> .multiselect__tags {
border-color: transparent;
}
&.multiselect--active>.multiselect__tags {
&.multiselect--active > .multiselect__tags {
border-color: $color-woot;
}
}

View File

@@ -1,4 +1,5 @@
@import 'shared/assets/fonts/inter';
@import 'shared/assets/stylesheets/animations';
@import 'shared/assets/stylesheets/colors';
@import 'shared/assets/stylesheets/spacing';
@import 'shared/assets/stylesheets/font-size';
@@ -17,8 +18,6 @@
@import 'foundation-sites/scss/foundation';
@include foundation-prototype-spacing;
@import '~bourbon/core/bourbon';
@include foundation-everything($flex: true);
@import 'typography';

View File

@@ -1,3 +1,5 @@
$channel-hover-color: rgba(0, 0, 0, 0.1);
.channels {
margin-top: $space-medium;
@@ -7,14 +9,14 @@
.channel {
@include flex;
@include padding($space-normal $zero);
@include background-white;
@include border-light;
cursor: pointer;
flex-direction: column;
margin: -1px;
transition: all 0.200s ease-in;
padding: $space-normal $zero;
transition: all 0.2s ease-in;
&:last-child {
@include border-light;
@@ -22,16 +24,16 @@
&:hover {
border: 1px solid $primary-color;
box-shadow: 0px 2px 8px rgba(0, 0, 0, 0.1);
box-shadow: 0 2px 8px $channel-hover-color;
z-index: 999;
}
&.disabled {
opacity: .6;
opacity: 0.6;
}
img {
@include margin($space-normal auto);
margin: $space-normal auto;
width: 50%;
}
@@ -43,8 +45,8 @@
}
p {
width: 100%;
color: $medium-gray;
width: 100%;
}
}
}

View File

@@ -4,33 +4,33 @@
// Conversation header - Light BG
.settings-header {
@include padding($space-small $space-normal);
@include background-white;
@include flex;
@include flex-align($x: justify, $y: middle);
border-bottom: 1px solid var(--s-50);
height: $header-height;
min-height: $header-height;
padding: $space-small $space-normal;
// Resolve Button
.button {
@include margin(0);
margin: 0;
}
// User thumbnail and text
.page-title {
@include flex;
@include flex-align($x: center, $y: middle);
@include margin($zero);
margin: 0;
}
}
.wizard-box {
.item {
@include padding($space-normal $space-normal $space-normal $space-medium);
@include background-light;
cursor: pointer;
padding: $space-normal $space-normal $space-normal $space-medium;
position: relative;
&::before,
@@ -128,89 +128,27 @@
.wizard-body {
@include background-white;
@include padding($space-medium);
@include border-light;
@include full-height();
padding: $space-medium;
&.height-auto {
height: auto;
}
}
.inoboxes-list {
.inbox-item {
@include margin($space-normal);
@include flex;
@include flex-shrink;
@include padding($space-normal $space-normal);
@include border-light-bottom();
background: $color-white;
cursor: pointer;
flex-direction: column;
float: left;
min-height: 10rem;
width: 20%;
&:last-child {
@include border-nil;
margin-bottom: $zero;
}
&:hover {
@include background-gray;
.arrow {
opacity: 1;
transform: translateX($space-small);
}
}
.switch {
align-self: center;
margin-bottom: $zero;
margin-right: $space-normal;
}
.item--details {
@include padding($space-normal $zero);
.item--name {
font-size: $font-size-large;
line-height: 1;
}
.item--sub {
font-size: $font-size-small;
margin-bottom: 0;
}
}
.arrow {
align-self: center;
color: $medium-gray;
font-size: $font-size-small;
opacity: 0.7;
transform: translateX(0);
transition: opacity 0.1s ease-in 0s, transform 0.2s ease-in 0.03s;
}
}
}
.settings--content {
@include margin($space-small $space-large);
margin: $space-small $space-large;
.title {
font-weight: $font-weight-medium;
}
.code {
@include padding($space-one);
background: $color-background;
max-height: $space-mega;
overflow: auto;
padding: $space-one;
white-space: nowrap;
code {
@@ -225,7 +163,7 @@
text-align: center;
p {
@include padding($space-medium);
padding: $space-medium;
}
> a > img {

View File

@@ -1,5 +1,4 @@
@keyframes left-shift-animation {
0%,
100% {
transform: translateX(0);
@@ -13,15 +12,15 @@
.conversation {
@include flex;
@include flex-shrink;
@include padding(0 0 0 $space-normal);
border-bottom: 1px solid transparent;
border-left: $space-micro solid transparent;
border-top: 1px solid transparent;
cursor: pointer;
padding: 0 0 0 $space-normal;
position: relative;
&.active {
animation: left-shift-animation .25s $swift-ease-out-function;
animation: left-shift-animation 0.25s $swift-ease-out-function;
background: $color-background;
border-bottom-color: $color-border-light;
border-left-color: $color-woot;
@@ -31,7 +30,7 @@
border-top-color: transparent;
}
+.conversation .conversation--details {
+ .conversation .conversation--details {
border-top-color: transparent;
}
}
@@ -48,13 +47,12 @@
}
}
.conversation--details {
@include margin(0 0 0 $space-one);
@include border-light-bottom;
@include border-light-top;
@include padding($space-slab 0);
border-bottom-color: transparent;
margin: 0 0 0 $space-one;
padding: $space-slab 0;
}
.conversation--user {

View File

@@ -1,6 +1,8 @@
// scss-lint:disable MergeableSelector
@mixin bubble-with-types {
@include padding($space-small $space-normal);
@include margin($zero);
padding: $space-small $space-normal;
margin: 0;
background: $color-woot;
border-radius: $space-one;
color: var(--white);
@@ -37,7 +39,11 @@
}
&::before {
background-image: linear-gradient(-180deg, transparent 3%, $color-heading 130%);
background-image: linear-gradient(
-180deg,
transparent 3%,
$color-heading 130%
);
bottom: 0;
content: '';
height: 20%;
@@ -75,16 +81,15 @@
}
.conversations-list {
@include flex-weight(1);
@include scroll-on-hover;
flex: 1 1;
}
.chat-list__top {
@include flex;
@include padding($zero $zero $space-micro $zero);
align-items: center;
justify-content: space-between;
padding: $zero $zero $space-micro;
.page-title {
margin-bottom: $zero;
@@ -92,13 +97,13 @@
}
.status--filter {
@include padding($zero null $zero $space-normal);
@include margin($zero);
background-color: $color-background-light;
border: 1px solid $color-border;
float: right;
font-size: $font-size-mini;
height: $space-medium;
margin: 0;
padding: $zero $space-medium $zero $space-normal;
width: auto;
}
}
@@ -110,19 +115,19 @@
.conversation-panel {
@include flex;
@include flex-weight(1 1 1px);
@include margin($zero);
flex: 1 1 1px;
flex-direction: column;
height: 100%;
margin: 0;
overflow-y: auto;
padding-bottom: var(--space-normal);
position: relative;
}
.conversation-panel>li {
.conversation-panel > li {
@include flex;
@include flex-shrink;
@include margin($zero $zero $space-micro);
margin: $zero $zero $space-micro;
position: relative;
&:first-child {
@@ -134,11 +139,11 @@
}
&.unread--toast {
+.right {
+ .right {
margin-bottom: var(--space-micro);
}
+.left {
+ .left {
margin-bottom: 0;
}
@@ -165,9 +170,7 @@
}
}
&.left {
.bubble {
@include border-normal;
background: $white;
@@ -198,10 +201,9 @@
color: $color-primary-dark;
}
}
}
+.right {
+ .right {
margin-top: $space-one;
.bubble {
@@ -209,8 +211,8 @@
}
}
+.unread--toast {
+.right {
+ .unread--toast {
+ .right {
margin-top: $space-one;
.bubble {
@@ -218,7 +220,7 @@
}
}
+.left {
+ .left {
margin-top: 0;
}
}
@@ -264,7 +266,7 @@
}
}
+.left {
+ .left {
margin-top: $space-one;
.bubble {
@@ -272,8 +274,8 @@
}
}
+.unread--toast {
+.left {
+ .unread--toast {
+ .left {
margin-top: $space-one;
.bubble {
@@ -281,11 +283,10 @@
}
}
+.right {
+ .right {
margin-top: 0;
}
}
}
&.center {
@@ -293,10 +294,9 @@
}
.wrap {
@include margin($zero $space-normal);
--bubble-max-width: 49.6rem;
max-width: Min(var(--bubble-max-width), 85%);
margin: $zero $space-normal;
max-width: Min(var(--bubble-max-width), 84%);
.sender--name {
font-size: $font-size-mini;
@@ -320,7 +320,8 @@
font-size: var(--font-size-small);
justify-content: center;
margin: var(--space-smaller) 0;
padding: var(--space-smaller) var(--space-micro) var(--space-smaller) var(--space-one);
padding: var(--space-smaller) var(--space-micro) var(--space-smaller)
var(--space-one);
.is-text {
display: inline-flex;
@@ -371,7 +372,6 @@
}
.left .bubble .text-content {
h1,
h2,
h3,
@@ -400,7 +400,6 @@
}
.right .bubble .text-content {
h1,
h2,
h3,

View File

@@ -1,5 +1,22 @@
// scss-lint:disable QualifyingElement
.error {
#{$all-text-inputs},
input[type='color'],
input[type='date'],
input[type='datetime'],
input[type='datetime-local'],
input[type='email'],
input[type='month'],
input[type='number'],
input[type='password'],
input[type='search'],
input[type='tel'],
input[type='text'],
input[type='time'],
input[type='url'],
input[type='week'],
input:not([type]),
textarea,
select,
.multiselect > .multiselect__tags {
@include thin-border(var(--r-400));

View File

@@ -30,11 +30,9 @@
.login-box {
@include background-white;
@include border-normal;
@include border-top-radius($space-smaller);
@include border-right-radius($space-smaller);
@include border-bottom-radius($space-smaller);
@include border-left-radius($space-smaller);
@include elegant-card;
border-radius: $space-smaller;
padding: $space-large;
label {
@@ -61,7 +59,7 @@
font-size: $font-size-default;
padding: $space-medium;
>a {
> a {
font-weight: $font-weight-bold;
}
}

View File

@@ -30,7 +30,7 @@
}
.page-top-bar {
@include padding($space-large $space-large $zero);
padding: $space-large $space-large $zero;
img {
max-height: 6rem;
@@ -53,8 +53,8 @@
}
.content-box {
@include padding($zero);
height: auto;
padding: 0;
}
h2 {
@@ -64,29 +64,29 @@
}
p {
@include margin($zero);
@include padding($zero);
font-size: $font-size-small;
margin: 0;
padding: 0;
}
.content {
@include padding($space-large);
padding: $space-large;
}
form,
.modal-content {
@include padding($space-large);
align-self: center;
padding: $space-large;
a {
@include padding($space-normal);
padding: $space-normal;
}
}
.modal-footer {
@include flex;
@include flex-align($x: flex-start, $y: middle);
@include padding($space-small $zero);
padding: $space-small $zero;
button {
font-size: $font-size-small;
@@ -98,10 +98,10 @@
}
.delete-item {
@include padding($space-large);
padding: $space-large;
button {
@include margin($zero);
margin: 0;
}
}
}

View File

@@ -1,14 +1,12 @@
.reply-box {
transition: box-shadow .35s $swift-ease-out-function,
transition: box-shadow 0.35s $swift-ease-out-function,
height 2s $swift-ease-out-function;
&.is-focused {
box-shadow: var(--shadow);
}
.reply-box__top {
.icon {
color: $medium-gray;
cursor: pointer;
@@ -20,7 +18,6 @@
}
}
.attachment {
cursor: pointer;
margin-right: $space-one;
@@ -37,13 +34,12 @@
resize: none;
}
>textarea {
> textarea {
@include ghost-input();
@include margin(0);
background: transparent;
// Override min-height : 50px in foundation
//
margin: 0;
max-height: $space-mega * 2.4;
// Override min-height : 50px in foundation
min-height: 4.8rem;
padding: var(--space-normal) 0 0;
resize: none;
@@ -56,10 +52,9 @@
.reply-box__top {
background: var(--y-50);
>input {
> input {
background: var(--y-50);
}
}
}
}

View File

@@ -1,9 +1,10 @@
.report-card {
@include padding($space-normal $space-small $space-normal $space-two);
@include margin($zero);
cursor: pointer;
@include custom-border-top(3px, transparent);
cursor: pointer;
margin: 0;
padding: $space-normal $space-small $space-normal $space-two;
&.active {
@include custom-border-top(3px, $color-woot);
@include background-white;
@@ -14,12 +15,12 @@
}
.heading {
@include margin($zero);
font-size: $font-size-small;
font-weight: $font-weight-bold;
align-items: center;
color: $color-heading;
display: flex;
align-items: center;
font-size: $font-size-small;
font-weight: $font-weight-bold;
margin: 0;
}
.info-icon {
@@ -52,31 +53,31 @@
}
.desc {
@include margin($zero);
font-size: $font-size-small;
margin: 0;
text-transform: capitalize;
}
}
.report-bar {
@include margin(-1px $zero);
@include background-white;
@include border-light;
@include padding($space-small $space-medium);
margin: -1px $zero;
padding: $space-small $space-medium;
.chart-container {
@include flex;
flex-direction: column;
@include flex-align(center, middle);
flex-direction: column;
div {
width: 100%;
}
.empty-state {
@include margin($space-jumbo);
font-size: $font-size-default;
color: $color-gray;
font-size: $font-size-default;
margin: $space-jumbo;
}
.business-hours {

View File

@@ -1,17 +1,18 @@
.search {
@include flex;
@include flex-align($x: left, $y: middle);
@include padding($space-one $space-normal);
@include flex-shrink;
transition: all .3s $ease-in-out-quad;
padding: $space-one $space-normal;
transition: all 0.3s var(--ease-in-out-quad);
> .icon {
font-size: $font-size-medium;
color: $medium-gray;
font-size: $font-size-medium;
}
> input {
@include ghost-input();
@include margin(0);
margin: 0;
}
}

View File

@@ -11,8 +11,8 @@
//logo
.logo {
img {
@include padding($woot-logo-padding);
max-height: 108px;
padding: $woot-logo-padding;
}
}
@@ -36,9 +36,9 @@
.bottom-nav {
@include flex;
@include space-between-column;
@include padding($space-one $space-normal $space-one $space-one);
@include border-normal-top;
flex-direction: column;
padding: $space-one $space-normal $space-one $space-one;
position: relative;
&:hover {

View File

@@ -11,7 +11,6 @@
}
.ui-snackbar {
@include padding($space-slab $space-medium);
@include shadow;
background-color: $woot-snackbar-bg;
border-radius: $space-smaller;
@@ -20,6 +19,7 @@
max-width: 40rem;
min-height: 3rem;
min-width: 24rem;
padding: $space-slab $space-medium;
text-align: left;
}
@@ -34,12 +34,12 @@
padding-left: 3rem;
button {
@include margin(0);
@include padding(0);
background: none;
border: 0;
color: $woot-snackbar-button;
font-size: $font-size-small;
margin: 0;
padding: 0;
text-transform: uppercase;
}
}

View File

@@ -1,11 +1,10 @@
.status-bar {
@include flex;
flex-direction: column;
@include flex-align($x: center, $y: middle);
background: lighten($warning-color, 36%);
// @include elegant-card();
@include margin($zero);
@include padding($space-normal $space-smaller);
flex-direction: column;
margin: 0;
padding: $space-normal $space-smaller;
.message {
font-weight: $font-weight-medium;
@@ -13,7 +12,7 @@
}
.button {
@include margin($space-smaller $zero $zero);
margin: $space-smaller $zero $zero;
padding: $space-small $space-normal;
}
@@ -23,14 +22,18 @@
.button {
// Default and disabled states
&,
&.disabled, &[disabled],
&.disabled:hover, &[disabled]:hover,
&.disabled:focus, &[disabled]:focus {
&.disabled,
&[disabled],
&.disabled:hover,
&[disabled]:hover,
&.disabled:focus,
&[disabled]:focus {
background-color: $alert-color;
color: $color-white;
}
&:hover, &:focus {
&:hover,
&:focus {
background-color: darken($alert-color, 7%);
color: $color-white;
}

View File

@@ -7,12 +7,12 @@
}
.tabs {
@include padding($zero $space-normal);
border-left-width: 0;
border-right-width: 0;
border-top-width: 0;
display: flex;
min-width: var(--space-mega);
padding: $zero $space-normal;
}
.tabs--with-scroll {
@@ -46,8 +46,8 @@
}
.tabs-title {
@include margin($zero $space-slab);
flex-shrink: 0;
margin: $zero $space-slab;
.badge {
background: $color-background;
@@ -75,14 +75,15 @@
}
a {
@include position(relative, 1px null null null);
align-items: center;
border-bottom: 2px solid transparent;
color: $medium-gray;
display: flex;
flex-direction: row;
font-size: $font-size-small;
transition: border-color .15s $swift-ease-out-function;
position: relative;
top: 1px;
transition: border-color 0.15s $swift-ease-out-function;
}
&.is-active {

View File

@@ -15,7 +15,7 @@ table {
}
td {
@include padding($space-one $space-small);
padding: $space-one $space-small;
}
}
}
@@ -54,6 +54,6 @@ table {
}
.button {
@include margin($zero);
margin: 0;
}
}

View File

@@ -1,5 +1,11 @@
<template>
<div class="conversations-list-wrap">
<div
class="conversations-list-wrap"
:class="{
hide: !showConversationList,
'list--full-width': isOnExpandedLayout,
}"
>
<slot />
<div
class="chat-list__top"
@@ -46,7 +52,7 @@
<woot-button
v-else
v-tooltip.top-end="$t('FILTER.TOOLTIP_LABEL')"
v-tooltip.right="$t('FILTER.TOOLTIP_LABEL')"
variant="clear"
color-scheme="secondary"
icon="filter"
@@ -97,7 +103,11 @@
@update-conversations="onUpdateConversations"
@assign-labels="onAssignLabels"
/>
<div ref="activeConversation" class="conversations-list">
<div
ref="activeConversation"
class="conversations-list"
:class="{ 'is-context-menu-open': isContextMenuOpen }"
>
<conversation-card
v-for="chat in conversationList"
:key="chat.id"
@@ -110,6 +120,11 @@
:selected="isConversationSelected(chat.id)"
@select-conversation="selectConversation"
@de-select-conversation="deSelectConversation"
@assign-agent="onAssignAgent"
@assign-team="onAssignTeam"
@assign-label="onAssignLabels"
@update-conversation-status="toggleConversationStatus"
@context-menu-toggle="onContextMenuToggle"
/>
<div v-if="chatListLoading" class="text-center">
@@ -202,6 +217,14 @@ export default {
type: [String, Number],
default: 0,
},
showConversationList: {
default: true,
type: Boolean,
},
isOnExpandedLayout: {
default: false,
type: Boolean,
},
},
data() {
return {
@@ -217,6 +240,7 @@ export default {
showDeleteFoldersModal: false,
selectedConversations: [],
selectedInboxes: [],
isContextMenuOpen: false,
};
},
computed: {
@@ -584,32 +608,79 @@ export default {
this.resetBulkActions();
}
},
async onAssignAgent(agent) {
// Same method used in context menu, conversationId being passed from there.
async onAssignAgent(agent, conversationId = null) {
try {
await this.$store.dispatch('bulkActions/process', {
type: 'Conversation',
ids: this.selectedConversations,
ids: conversationId || this.selectedConversations,
fields: {
assignee_id: agent.id,
},
});
this.selectedConversations = [];
this.showAlert(this.$t('BULK_ACTION.ASSIGN_SUCCESFUL'));
if (conversationId) {
this.showAlert(
this.$t(
'CONVERSATION.CARD_CONTEXT_MENU.API.AGENT_ASSIGNMENT.SUCCESFUL',
{
agentName: agent.name,
conversationId,
}
)
);
} else {
this.showAlert(this.$t('BULK_ACTION.ASSIGN_SUCCESFUL'));
}
} catch (err) {
this.showAlert(this.$t('BULK_ACTION.ASSIGN_FAILED'));
}
},
async onAssignLabels(labels) {
async onAssignTeam(team, conversationId = null) {
try {
await this.$store.dispatch('assignTeam', {
conversationId,
teamId: team.id,
});
this.showAlert(
this.$t(
'CONVERSATION.CARD_CONTEXT_MENU.API.TEAM_ASSIGNMENT.SUCCESFUL',
{
team: team.name,
conversationId,
}
)
);
} catch (error) {
this.showAlert(
this.$t('CONVERSATION.CARD_CONTEXT_MENU.API.TEAM_ASSIGNMENT.FAILED')
);
}
},
// Same method used in context menu, conversationId being passed from there.
async onAssignLabels(labels, conversationId = null) {
try {
await this.$store.dispatch('bulkActions/process', {
type: 'Conversation',
ids: this.selectedConversations,
ids: conversationId || this.selectedConversations,
labels: {
add: labels,
},
});
this.selectedConversations = [];
this.showAlert(this.$t('BULK_ACTION.LABELS.ASSIGN_SUCCESFUL'));
if (conversationId) {
this.showAlert(
this.$t(
'CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_ASSIGNMENT.SUCCESFUL',
{
labelName: labels[0],
conversationId,
}
)
);
} else {
this.showAlert(this.$t('BULK_ACTION.LABELS.ASSIGN_SUCCESFUL'));
}
} catch (err) {
this.showAlert(this.$t('BULK_ACTION.LABELS.ASSIGN_FAILED'));
}
@@ -629,12 +700,27 @@ export default {
this.showAlert(this.$t('BULK_ACTION.UPDATE.UPDATE_FAILED'));
}
},
toggleConversationStatus(conversationId, status, snoozedUntil) {
this.$store
.dispatch('toggleStatus', {
conversationId,
status,
snoozedUntil,
})
.then(() => {
this.showAlert(this.$t('CONVERSATION.CHANGE_STATUS'));
this.isLoading = false;
});
},
allSelectedConversationsStatus(status) {
if (!this.selectedConversations.length) return false;
return this.selectedConversations.every(item => {
return this.$store.getters.getConversationById(item).status === status;
});
},
onContextMenuToggle(state) {
this.isContextMenuOpen = state;
},
},
};
</script>
@@ -647,6 +733,13 @@ export default {
margin-bottom: var(--space-normal);
}
.conversations-list {
// Prevent the list from scrolling if the submenu is opened
&.is-context-menu-open {
overflow: hidden !important;
}
}
.conversations-list-wrap {
flex-shrink: 0;
width: 34rem;
@@ -663,6 +756,17 @@ export default {
@include breakpoint(xxxlarge up) {
flex-basis: 46rem;
}
&.hide {
display: none;
}
&.list--full-width {
width: 100%;
@include breakpoint(xxxlarge up) {
flex-basis: 100%;
}
}
}
.filter--actions {
display: flex;

View File

@@ -9,7 +9,7 @@
<script>
import 'highlight.js/styles/default.css';
import copy from 'copy-text-to-clipboard';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
export default {
props: {
@@ -23,9 +23,9 @@ export default {
},
},
methods: {
onCopy(e) {
async onCopy(e) {
e.preventDefault();
copy(this.script);
await copyTextToClipboard(this.script);
bus.$emit('newToastMessage', this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
},
},

View File

@@ -12,33 +12,26 @@
</template>
<script>
import Banner from 'dashboard/components/ui/Banner.vue';
import LocalStorage from '../../helper/localStorage';
import { LocalStorage, LOCAL_STORAGE_KEYS } from '../../helper/localStorage';
import { mapGetters } from 'vuex';
import adminMixin from 'dashboard/mixins/isAdmin';
const semver = require('semver');
const dismissedUpdates = new LocalStorage('dismissedUpdates');
import { hasAnUpdateAvailable } from './versionCheckHelper';
export default {
components: {
Banner,
},
components: { Banner },
mixins: [adminMixin],
props: {
latestChatwootVersion: {
type: String,
default: '',
},
latestChatwootVersion: { type: String, default: '' },
},
data() {
return { userDismissedBanner: false };
},
computed: {
...mapGetters({ globalConfig: 'globalConfig/get' }),
hasAnUpdateAvailable() {
if (!semver.valid(this.latestChatwootVersion)) {
return false;
}
return semver.lt(
this.globalConfig.appVersion,
this.latestChatwootVersion
updateAvailable() {
return hasAnUpdateAvailable(
this.latestChatwootVersion,
this.globalConfig.appVersion
);
},
bannerMessage() {
@@ -48,8 +41,9 @@ export default {
},
shouldShowBanner() {
return (
!this.userDismissedBanner &&
this.globalConfig.displayManifest &&
this.hasAnUpdateAvailable &&
this.updateAvailable &&
!this.isVersionNotificationDismissed(this.latestChatwootVersion) &&
this.isAdmin
);
@@ -57,17 +51,23 @@ export default {
},
methods: {
isVersionNotificationDismissed(version) {
return dismissedUpdates.get().includes(version);
const dismissedVersions =
LocalStorage.get(LOCAL_STORAGE_KEYS.DISMISSED_UPDATES) || [];
return dismissedVersions.includes(version);
},
dismissUpdateBanner() {
let updatedDismissedItems = dismissedUpdates.get();
let updatedDismissedItems =
LocalStorage.get(LOCAL_STORAGE_KEYS.DISMISSED_UPDATES) || [];
if (updatedDismissedItems instanceof Array) {
updatedDismissedItems.push(this.latestChatwootVersion);
} else {
updatedDismissedItems = [this.latestChatwootVersion];
}
dismissedUpdates.store(updatedDismissedItems);
this.latestChatwootVersion = this.globalConfig.appVersion;
LocalStorage.set(
LOCAL_STORAGE_KEYS.DISMISSED_UPDATES,
updatedDismissedItems
);
this.userDismissedBanner = true;
},
},
};

View File

@@ -0,0 +1,15 @@
import { hasAnUpdateAvailable } from '../versionCheckHelper';
describe('#hasAnUpdateAvailable', () => {
it('return false if latest version is invalid', () => {
expect(hasAnUpdateAvailable('invalid', '1.0.0')).toBe(false);
expect(hasAnUpdateAvailable(null, '1.0.0')).toBe(false);
expect(hasAnUpdateAvailable(undefined, '1.0.0')).toBe(false);
expect(hasAnUpdateAvailable('', '1.0.0')).toBe(false);
});
it('return correct value if latest version is valid', () => {
expect(hasAnUpdateAvailable('1.1.0', '1.0.0')).toBe(true);
expect(hasAnUpdateAvailable('0.1.0', '1.0.0')).toBe(false);
});
});

View File

@@ -0,0 +1,8 @@
const semver = require('semver');
export const hasAnUpdateAvailable = (latestVersion, currentVersion) => {
if (!semver.valid(latestVersion)) {
return false;
}
return semver.lt(currentVersion, latestVersion);
};

View File

@@ -113,6 +113,7 @@
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin';
import snoozeTimesMixin from 'dashboard/mixins/conversation/snoozeTimesMixin.js';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import {
hasPressedAltAndEKey,
@@ -126,13 +127,6 @@ import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import WootDropdownDivider from 'shared/components/ui/dropdown/DropdownDivider';
import wootConstants from '../../constants';
import {
getUnixTime,
addHours,
addWeeks,
startOfTomorrow,
startOfWeek,
} from 'date-fns';
import {
CMD_REOPEN_CONVERSATION,
CMD_RESOLVE_CONVERSATION,
@@ -146,7 +140,7 @@ export default {
WootDropdownSubMenu,
WootDropdownDivider,
},
mixins: [clickaway, alertMixin, eventListenerMixins],
mixins: [clickaway, alertMixin, eventListenerMixins, snoozeTimesMixin],
props: { conversationId: { type: [String, Number], required: true } },
data() {
return {
@@ -178,16 +172,6 @@ export default {
showAdditionalActions() {
return !this.isPending && !this.isSnoozed;
},
snoozeTimes() {
return {
// tomorrow = 9AM next day
tomorrow: getUnixTime(addHours(startOfTomorrow(), 9)),
// next week = 9AM Monday, next week
nextWeek: getUnixTime(
addHours(startOfWeek(addWeeks(new Date(), 1), { weekStartsOn: 1 }), 9)
),
};
},
},
mounted() {
bus.$on(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);

View File

@@ -0,0 +1,34 @@
import { action } from '@storybook/addon-actions';
import EditArticle from './EditArticle.vue';
export default {
title: 'Components/Help Center',
component: EditArticle,
argTypes: {
article: {
defaultValue: {},
control: {
type: 'object',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { EditArticle },
template:
'<edit-article v-bind="$props" @focus="onFocus" @blur="onBlur"></edit-article>',
});
export const EditArticleView = Template.bind({});
EditArticleView.args = {
article: {
id: '1',
title: 'Lorem ipsum',
content:
'L**orem ipsum** dolor sit amet, consectetur adipiscing elit. Congue diam orci tellus *varius per cras turpis aliquet commodo dolor justo* rutrum lorem venenatis aliquet orci curae hac. Sagittis ultrices felis **`ante placerat condimentum parturient erat consequat`** sollicitudin *sagittis potenti sollicitudin* quis velit at placerat mi torquent. Dignissim luctus nulla suspendisse purus cras commodo ipsum orci tempus morbi metus conubia et hac potenti quam suspendisse feugiat. Turpis eros dictum tellus natoque laoreet lacus dolor cras interdum **vitae gravida tincidunt ultricies tempor convallis tortor rhoncus suspendisse.** Nisi lacinia etiam vivamus tellus sed taciti potenti quam praesent congue euismod mauris est eu risus convallis taciti etiam. Inceptos iaculis turpis leo porta pellentesque dictum `bibendum blandit parturient nulla leo pretium` rhoncus litora dapibus fringilla hac litora.',
},
onFocus: action('focus'),
onBlur: action('blur'),
};

View File

@@ -0,0 +1,113 @@
<template>
<div
class="edit-article--container"
:class="{ 'is-settings-sidebar-open': isSettingsSidebarOpen }"
>
<input
v-model="articleTitle"
type="text"
class="article-heading"
:placeholder="$t('HELP_CENTER.EDIT_ARTICLE.TITLE_PLACEHOLDER')"
@focus="onFocus"
@blur="onBlur"
@input="onTitleInput"
/>
<woot-message-editor
v-model="articleContent"
class="article-content"
:placeholder="$t('HELP_CENTER.EDIT_ARTICLE.CONTENT_PLACEHOLDER')"
:is-format-mode="true"
@focus="onFocus"
@blur="onBlur"
@input="onContentInput"
/>
</div>
</template>
<script>
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
export default {
components: {
WootMessageEditor,
},
props: {
article: {
type: Object,
default: () => ({}),
},
isSettingsSidebarOpen: {
type: Boolean,
default: false,
},
},
data() {
return {
articleTitle: '',
articleContent: '',
};
},
mounted() {
this.articleTitle = this.article.title;
this.articleContent = this.article.content;
},
methods: {
onFocus() {
this.$emit('focus');
},
onBlur() {
this.$emit('blur');
},
onTitleInput() {
this.$emit('titleInput', this.articleTitle);
},
onContentInput() {
this.$emit('contentInput', this.articleContent);
},
},
};
</script>
<style lang="scss" scoped>
.edit-article--container {
margin: var(--space-large) auto;
width: 640px;
}
.is-settings-sidebar-open {
margin: var(--space-large) var(--space-small);
}
.article-heading {
font-size: var(--font-size-giga);
font-weight: var(--font-weight-bold);
min-height: var(--space-jumbo);
max-height: var(--space-jumbo);
border: 0px solid transparent;
padding: 0;
}
::v-deep {
.ProseMirror-menubar-wrapper {
.ProseMirror-menubar .ProseMirror-menuitem {
.ProseMirror-icon {
margin-right: var(--space-normal);
font-size: var(--font-size-small);
}
}
.ProseMirror-woot-style {
min-height: var(--space-giga);
max-height: 100%;
p {
font-size: var(--font-size-default);
line-height: 1.5;
}
li::marker {
font-size: var(--font-size-default);
}
}
}
}
</style>

View File

@@ -22,6 +22,7 @@ import Tabs from './ui/Tabs/Tabs';
import TabsItem from './ui/Tabs/TabsItem';
import Thumbnail from './widgets/Thumbnail.vue';
import ConfirmModal from './widgets/modal/ConfirmationModal.vue';
import ContextMenu from './ui/ContextMenu.vue';
const WootUIKit = {
AvatarUploader,
@@ -47,6 +48,7 @@ const WootUIKit = {
TabsItem,
Thumbnail,
ConfirmModal,
ContextMenu,
install(Vue) {
const keys = Object.keys(this);
keys.pop(); // remove 'install' from keys

View File

@@ -3,6 +3,7 @@
<primary-sidebar
:logo-source="globalConfig.logoThumbnail"
:installation-name="globalConfig.installationName"
:is-a-custom-branded-instance="isACustomBrandedInstance"
:account-id="accountId"
:menu-items="primaryMenuItems"
:active-menu-item="activePrimaryMenu.key"
@@ -19,6 +20,7 @@
:custom-views="customViews"
:menu-config="activeSecondaryMenu"
:current-role="currentRole"
:is-on-chatwoot-cloud="isOnChatwootCloud"
@add-label="showAddLabelPopup"
@toggle-accounts="toggleAccountModal"
/>
@@ -67,6 +69,8 @@ export default {
...mapGetters({
currentUser: 'getCurrentUser',
globalConfig: 'globalConfig/get',
isACustomBrandedInstance: 'globalConfig/isACustomBrandedInstance',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
inboxes: 'inboxes/getInboxes',
accountId: 'getCurrentAccountId',
currentRole: 'getCurrentRole',

View File

@@ -30,6 +30,7 @@ const settings = accountId => ({
'settings_teams_edit',
'settings_teams_edit_members',
'settings_teams_edit_finish',
'billing_settings_index',
'automation_list',
],
menuItems: [
@@ -100,6 +101,14 @@ const settings = accountId => ({
toState: frontendURL(`accounts/${accountId}/settings/applications`),
toStateName: 'settings_applications',
},
{
icon: 'credit-card-person',
label: 'BILLING',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/billing`),
toStateName: 'billing_settings_index',
showOnlyOnCloud: true,
},
{
icon: 'settings',
label: 'ACCOUNT_SETTINGS',

View File

@@ -16,6 +16,13 @@
/>
</nav>
<div class="menu vertical user-menu">
<primary-nav-item
v-if="!isACustomBrandedInstance"
icon="book-open-globe"
name="DOCS"
:open-in-new-page="true"
:to="helpDocsURL"
/>
<notification-bell @open-notification-panel="openNotificationPanel" />
<agent-details @toggle-menu="toggleOptions" />
<options-menu
@@ -34,7 +41,7 @@ import PrimaryNavItem from './PrimaryNavItem';
import OptionsMenu from './OptionsMenu';
import AgentDetails from './AgentDetails';
import NotificationBell from './NotificationBell';
import wootConstants from 'dashboard/constants';
import { frontendURL } from 'dashboard/helper/URLHelper';
export default {
@@ -46,6 +53,10 @@ export default {
NotificationBell,
},
props: {
isACustomBrandedInstance: {
type: Boolean,
default: false,
},
logoSource: {
type: String,
default: '',
@@ -69,6 +80,7 @@ export default {
},
data() {
return {
helpDocsURL: wootConstants.DOCS_URL,
showOptionsMenu: false,
};
},

View File

@@ -5,6 +5,8 @@
:href="href"
class="button clear button--only-icon menu-item"
:class="{ 'is-active': isActive || isChildMenuActive }"
:rel="openInNewPage ? 'noopener noreferrer nofollow' : undefined"
:target="openInNewPage ? '_blank' : undefined"
@click="navigate"
>
<fluent-icon :icon="icon" />
@@ -36,6 +38,10 @@ export default {
type: Boolean,
default: false,
},
openInNewPage: {
type: Boolean,
default: false,
},
},
};
</script>

View File

@@ -55,6 +55,10 @@ export default {
type: String,
default: '',
},
isOnChatwootCloud: {
type: Boolean,
default: false,
},
},
computed: {
hasSecondaryMenu() {
@@ -67,12 +71,18 @@ export default {
if (!this.currentRole) {
return [];
}
return this.menuConfig.menuItems.filter(
const menuItemsFilteredByRole = this.menuConfig.menuItems.filter(
menuItem =>
window.roleWiseRoutes[this.currentRole].indexOf(
menuItem.toStateName
) > -1
);
return menuItemsFilteredByRole.filter(item => {
if (item.showOnlyOnCloud) {
return this.isOnChatwootCloud;
}
return true;
});
},
inboxSection() {
return {

View File

@@ -5,8 +5,12 @@
{{ $t(`SIDEBAR.${menuItem.label}`) }}
</span>
<div v-if="isHelpCenterSidebar" class="submenu-icons">
<fluent-icon icon="search" class="submenu-icon" size="16" />
<fluent-icon icon="add" class="submenu-icon" size="16" />
<div class="submenu-icon">
<fluent-icon icon="search" size="16" />
</div>
<div class="submenu-icon" @click="onClickOpen">
<fluent-icon icon="add" size="16" />
</div>
</div>
</div>
<router-link
@@ -71,6 +75,9 @@
</a>
</li>
</router-link>
<p v-if="isHelpCenterSidebar && isCategoryEmpty" class="empty-text">
{{ $t('SIDEBAR.HELP_CENTER.CATEGORY_EMPTY_MESSAGE') }}
</p>
</ul>
</li>
</template>
@@ -98,6 +105,10 @@ export default {
type: Boolean,
default: false,
},
isCategoryEmpty: {
type: Boolean,
default: false,
},
},
computed: {
...mapGetters({ activeInbox: 'getSelectedInbox' }),
@@ -134,6 +145,9 @@ export default {
this.menuItem.toStateName === 'settings_applications'
);
},
isArticlesView() {
return this.$store.state.route.name === this.menuItem.toStateName;
},
computedClass() {
// If active Inbox is present
@@ -151,6 +165,12 @@ export default {
}
return ' ';
}
if (this.isHelpCenterSidebar) {
if (this.isArticlesView) {
return 'is-active';
}
return ' ';
}
return '';
},
},
@@ -183,6 +203,9 @@ export default {
showItem(item) {
return this.isAdmin && item.newLink !== undefined;
},
onClickOpen() {
this.$emit('open');
},
},
};
</script>
@@ -312,4 +335,10 @@ export default {
}
}
}
.empty-text {
color: var(--s-600);
font-size: var(--font-size-small);
margin: var(--space-smaller) 0;
}
</style>

View File

@@ -0,0 +1,88 @@
<template>
<div class="announcement-popup">
<span v-if="popupMessage" class="popup-content">
{{ popupMessage }}
<span v-if="routeText" class="route-url" @click="onClickOpenPath">
{{ routeText }}
</span>
</span>
<div v-if="hasCloseButton" class="popup-close">
<woot-button
v-if="hasCloseButton"
color-scheme="primary"
variant="link"
size="small"
@click="onClickClose"
>
{{ closeButtonText }}
</woot-button>
</div>
</div>
</template>
<script>
export default {
props: {
popupMessage: {
type: String,
default: '',
},
routeText: {
type: String,
default: '',
},
hasCloseButton: {
type: Boolean,
default: true,
},
closeButtonText: {
type: String,
default: '',
},
},
methods: {
onClickOpenPath() {
this.$emit('open');
},
onClickClose(e) {
this.$emit('close', e);
},
},
};
</script>
<style lang="scss">
.announcement-popup {
max-width: 24rem;
min-width: 16rem;
display: flex;
position: absolute;
flex-direction: column;
align-items: flex-start;
height: fit-content;
background: var(--white);
padding: 0 var(--space-normal);
z-index: var(--z-index-much-higher);
box-shadow: var(--b-200) 4px 4px 16px 4px;
border-radius: var(--border-radius-normal);
.popup-content {
font-size: var(--font-size-mini);
color: var(--s-700);
padding: var(--space-one) 0;
.route-url {
font-size: var(--font-size-mini);
color: var(--s-600);
cursor: pointer;
text-decoration: underline;
}
}
.popup-close {
width: 100%;
display: flex;
align-items: center;
padding: var(--space-one) 0;
border-top: 1px solid var(--color-border-light);
}
}
</style>

View File

@@ -0,0 +1,53 @@
<template>
<div
v-show="show"
ref="context"
class="context-menu-container"
:style="style"
tabindex="0"
@blur="$emit('close')"
>
<slot />
</div>
</template>
<script>
export default {
props: {
x: {
type: Number,
default: 0,
},
y: {
type: Number,
default: 0,
},
},
data() {
return {
left: this.x,
top: this.y,
show: false,
};
},
computed: {
style() {
return {
top: this.top + 'px',
left: this.left + 'px',
};
},
},
mounted() {
this.$nextTick(() => this.$el.focus());
this.show = true;
},
};
</script>
<style>
.context-menu-container {
position: fixed;
z-index: var(--z-index-very-high);
outline: none;
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,46 @@
import { action } from '@storybook/addon-actions';
import WootAnnouncementPopup from '../AnnouncementPopup.vue';
export default {
title: 'Components/Popup/Announcement Popup',
argTypes: {
popupMessage: {
defaultValue:
'Now a new key shortcut (⌘ + ↵) is available to send messages. You can enable it in the',
control: {
type: 'text',
},
},
routeText: {
defaultValue: 'profile settings',
control: {
type: 'text',
},
},
hasCloseButton: {
defaultValue: true,
control: {
type: 'boolean',
},
},
closeButtonText: {
defaultValue: 'Got it',
control: {
type: 'text',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { WootAnnouncementPopup },
template:
'<woot-announcement-popup v-bind="$props" @open="onClickOpenPath" @close="onClickClose"></woot-announcement-popup>',
});
export const AnnouncementPopup = Template.bind({});
AnnouncementPopup.args = {
onClickOpenPath: action('opened path'),
onClickClose: action('closed the popup'),
};

View File

@@ -9,7 +9,7 @@
/>
<Avatar
v-else
:username="username"
:username="userNameWithoutEmoji"
:class="thumbnailClass"
:size="avatarSize"
:variant="variant"
@@ -86,6 +86,7 @@
* Username - User name for avatar
*/
import Avatar from './Avatar';
import { removeEmoji } from 'shared/helpers/emoji';
export default {
components: {
@@ -131,6 +132,9 @@ export default {
};
},
computed: {
userNameWithoutEmoji() {
return removeEmoji(this.username);
},
showStatusIndicator() {
if (this.shouldShowStatusAlways) return true;
return this.status === 'online' || this.status === 'busy';

View File

@@ -1,9 +1,13 @@
<template>
<div class="conversation-details-wrap">
<div
class="conversation-details-wrap"
:class="{ 'with-border-left': !isOnExpandedLayout }"
>
<conversation-header
v-if="currentChat.id"
:chat="currentChat"
:is-contact-panel-open="isContactPanelOpen"
:show-back-button="isOnExpandedLayout"
@contact-panel-toggle="onToggleContactPanel"
/>
<woot-tabs
@@ -26,7 +30,7 @@
:is-contact-panel-open="isContactPanelOpen"
@contact-panel-toggle="onToggleContactPanel"
/>
<empty-state v-else />
<empty-state v-else :is-on-expanded-layout="isOnExpandedLayout" />
<div v-show="showContactPanel" class="conversation-sidebar-wrap">
<contact-panel
v-if="showContactPanel"
@@ -71,6 +75,10 @@ export default {
type: Boolean,
default: true,
},
isOnExpandedLayout: {
type: Boolean,
default: true,
},
},
data() {
return { activeIndex: 0 };
@@ -134,8 +142,11 @@ export default {
flex-direction: column;
min-width: 0;
width: 100%;
border-left: 1px solid var(--color-border);
background: var(--color-background-light);
&.with-border-left {
border-left: 1px solid var(--color-border);
}
}
.dashboard-app--tabs {

View File

@@ -10,6 +10,7 @@
@mouseenter="onCardHover"
@mouseleave="onCardLeave"
@click="cardClick(chat)"
@contextmenu="openContextMenu($event)"
>
<label v-if="hovered || selected" class="checkbox-wrapper" @click.stop>
<input
@@ -91,6 +92,22 @@
<span class="unread">{{ unreadCount > 9 ? '9+' : unreadCount }}</span>
</div>
</div>
<woot-context-menu
v-if="showContextMenu"
ref="menu"
:x="contextMenu.x"
:y="contextMenu.y"
@close="closeContextMenu"
>
<conversation-context-menu
:status="chat.status"
:inbox-id="inbox.id"
@update-conversation="onUpdateConversation"
@assign-agent="onAssignAgent"
@assign-label="onAssignLabel"
@assign-team="onAssignTeam"
/>
</woot-context-menu>
</div>
</template>
<script>
@@ -104,6 +121,8 @@ import router from '../../../routes';
import { frontendURL, conversationUrl } from '../../../helper/URLHelper';
import InboxName from '../InboxName';
import inboxMixin from 'shared/mixins/inboxMixin';
import ConversationContextMenu from './contextMenu/Index.vue';
import alertMixin from 'shared/mixins/alertMixin';
const ATTACHMENT_ICONS = {
image: 'image',
@@ -118,9 +137,16 @@ export default {
components: {
InboxName,
Thumbnail,
ConversationContextMenu,
},
mixins: [inboxMixin, timeMixin, conversationMixin, messageFormatterMixin],
mixins: [
inboxMixin,
timeMixin,
conversationMixin,
messageFormatterMixin,
alertMixin,
],
props: {
activeLabel: {
type: String,
@@ -162,6 +188,11 @@ export default {
data() {
return {
hovered: false,
showContextMenu: false,
contextMenu: {
x: null,
y: null,
},
};
},
computed: {
@@ -292,6 +323,40 @@ export default {
const action = checked ? 'select-conversation' : 'de-select-conversation';
this.$emit(action, this.chat.id, this.inbox.id);
},
openContextMenu(e) {
e.preventDefault();
this.$emit('context-menu-toggle', true);
this.contextMenu.x = e.pageX || e.clientX;
this.contextMenu.y = e.pageY || e.clientY;
this.showContextMenu = true;
},
closeContextMenu() {
this.$emit('context-menu-toggle', false);
this.showContextMenu = false;
this.contextMenu.x = null;
this.contextMenu.y = null;
},
onUpdateConversation(status, snoozedUntil) {
this.closeContextMenu();
this.$emit(
'update-conversation-status',
this.chat.id,
status,
snoozedUntil
);
},
async onAssignAgent(agent) {
this.$emit('assign-agent', agent, [this.chat.id]);
this.closeContextMenu();
},
async onAssignLabel(label) {
this.$emit('assign-label', [label.title], [this.chat.id]);
this.closeContextMenu();
},
async onAssignTeam(team) {
this.$emit('assign-team', team, this.chat.id);
this.closeContextMenu();
},
},
};
</script>

View File

@@ -1,6 +1,7 @@
<template>
<div class="conv-header">
<div class="user">
<back-button v-if="showBackButton" :back-url="backButtonUrl" />
<Thumbnail
:src="currentContact.thumbnail"
size="40px"
@@ -47,19 +48,21 @@
</div>
</template>
<script>
import { hasPressedAltAndOKey } from 'shared/helpers/KeyboardHelpers';
import { mapGetters } from 'vuex';
import MoreActions from './MoreActions';
import Thumbnail from '../Thumbnail';
import agentMixin from '../../../mixins/agentMixin.js';
import BackButton from '../BackButton';
import differenceInHours from 'date-fns/differenceInHours';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import inboxMixin from 'shared/mixins/inboxMixin';
import { hasPressedAltAndOKey } from 'shared/helpers/KeyboardHelpers';
import wootConstants from '../../../constants';
import differenceInHours from 'date-fns/differenceInHours';
import InboxName from '../InboxName';
import MoreActions from './MoreActions';
import Thumbnail from '../Thumbnail';
import wootConstants from '../../../constants';
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
export default {
components: {
BackButton,
InboxName,
MoreActions,
Thumbnail,
@@ -74,6 +77,10 @@ export default {
type: Boolean,
default: false,
},
showBackButton: {
type: Boolean,
default: false,
},
},
computed: {
...mapGetters({
@@ -83,6 +90,19 @@ export default {
chatMetadata() {
return this.chat.meta;
},
backButtonUrl() {
const {
params: { accountId, inbox_id: inboxId, label, teamId },
name,
} = this.$route;
return conversationListPageURL({
accountId,
inboxId,
label,
teamId,
conversationType: name === 'conversation_mentions' ? 'mention' : '',
});
},
isHMACVerified() {
if (!this.isAWebWidgetInbox) {
return true;

View File

@@ -35,7 +35,7 @@
<!-- No conversation selected -->
<div v-else-if="allConversations.length && !currentChat.id">
<img src="~dashboard/assets/images/chat.svg" alt="No Chat" />
<span>{{ $t('CONVERSATION.404') }}</span>
<span>{{ conversationMissingMessage }}</span>
</div>
</div>
</div>
@@ -51,6 +51,12 @@ export default {
OnboardingView,
},
mixins: [accountMixin, adminMixin],
props: {
isOnExpandedLayout: {
type: Boolean,
default: false,
},
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
@@ -65,6 +71,12 @@ export default {
}
return this.$t('CONVERSATION.LOADING_CONVERSATIONS');
},
conversationMissingMessage() {
if (!this.isOnExpandedLayout) {
return this.$t('CONVERSATION.SELECT_A_CONVERSATION');
}
return this.$t('CONVERSATION.404');
},
newInboxURL() {
return this.addAccountScoping('settings/inboxes/new');
},

View File

@@ -109,8 +109,6 @@
</li>
</template>
<script>
import copy from 'copy-text-to-clipboard';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import timeMixin from '../../../mixins/time';
@@ -128,6 +126,7 @@ import alertMixin from 'shared/mixins/alertMixin';
import contentTypeMixin from 'shared/mixins/contentTypeMixin';
import { MESSAGE_TYPE, MESSAGE_STATUS } from 'shared/constants/messages';
import { generateBotMessageContent } from './helpers/botMessageContentHelper';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
export default {
components: {
@@ -170,7 +169,7 @@ export default {
};
},
computed: {
contentToBeParsed() {
emailMessageContent() {
const {
html_content: { full: fullHTMLContent } = {},
text_content: { full: fullTextContent } = {},
@@ -182,13 +181,19 @@ export default {
return false;
}
if (this.contentToBeParsed.includes('<blockquote')) {
if (this.emailMessageContent.includes('<blockquote')) {
return true;
}
return false;
},
message() {
// If the message is an email, emailMessageContent would be present
// In that case, we would use letter package to render the email
if (this.emailMessageContent && this.isIncoming) {
return this.emailMessageContent;
}
const botMessageContent = generateBotMessageContent(
this.contentType,
this.contentAttributes,
@@ -200,21 +205,6 @@ export default {
},
}
);
const {
email: { content_type: contentType = '' } = {},
} = this.contentAttributes;
if (this.contentToBeParsed && this.isIncoming) {
const parsedContent = this.stripStyleCharacters(this.contentToBeParsed);
if (parsedContent) {
// This is a temporary fix for line-breaks in text/plain emails
// Now, It is not rendered properly in the email preview.
// FIXME: Remove this once we have a better solution for rendering text/plain emails
return contentType.includes('text/plain')
? parsedContent.replace(/\n/g, '<br />')
: parsedContent;
}
}
return (
this.formatMessage(
this.data.content,
@@ -331,6 +321,7 @@ export default {
'activity-wrap': !this.isBubble,
'is-pending': this.isPending,
'is-failed': this.isFailed,
'is-email': this.isEmailContentType,
};
},
bubbleClass() {
@@ -342,6 +333,7 @@ export default {
'is-text': this.hasText,
'is-from-bot': this.isSentByBot,
'is-failed': this.isFailed,
'is-email': this.isEmailContentType,
};
},
isPending() {
@@ -412,8 +404,8 @@ export default {
this.showAlert(this.$t('CONVERSATION.FAIL_DELETE_MESSSAGE'));
}
},
handleCopy() {
copy(this.data.content);
async handleCopy() {
await copyTextToClipboard(this.data.content);
this.showAlert(this.$t('CONTACT_PANEL.COPY_SUCCESSFUL'));
this.showContextMenu = false;
},
@@ -518,6 +510,10 @@ export default {
}
}
.wrap.is-email {
--bubble-max-width: 84% !important;
}
.sender--info {
align-items: center;
color: var(--b-700);

View File

@@ -87,7 +87,6 @@
:selected-tweet="selectedTweet"
:popout-reply-box.sync="isPopoutReplyBox"
@click="showPopoutReplyBox"
@scrollToMessage="scrollToBottom"
/>
</div>
</div>

View File

@@ -126,6 +126,7 @@ export default {
background: transparent;
font-size: var(--font-size-mini);
font-weight: var(--font-weight-bold);
padding-left: 0;
}
.input-group-field::v-deep input {
margin-bottom: 0;

View File

@@ -1,132 +1,742 @@
const languages = [
{
id: 'eng',
name: 'English (en)',
name: 'Abkhazian',
id: 'ab',
},
{
name: 'Afar',
id: 'aa',
},
{
name: 'Afrikaans',
id: 'af',
},
{
name: 'Akan',
id: 'ak',
},
{
name: 'Albanian',
id: 'sq',
},
{
name: 'Amharic',
id: 'am',
},
{
name: 'Arabic',
id: 'ar',
name: 'العربية (ar)',
},
{
id: 'nl',
name: 'Nederlands (nl)',
name: 'Aragonese',
id: 'an',
},
{
id: 'fr',
name: 'Français (fr)',
name: 'Armenian',
id: 'hy',
},
{
id: 'de',
name: 'Deutsch (de)',
name: 'Assamese',
id: 'as',
},
{
id: 'हिन्दी (hi)',
name: 'hi',
name: 'Avaric',
id: 'av',
},
{
id: 'it',
name: 'Italiano (it)',
name: 'Avestan',
id: 'ae',
},
{
id: 'ja',
name: '日本語 (ja)',
name: 'Aymara',
id: 'ay',
},
{
id: 'ko',
name: '한국어 (ko)',
name: 'Azerbaijani',
id: 'az',
},
{
id: 'pt',
name: 'Português (pt)',
name: 'Bambara',
id: 'bm',
},
{
id: 'ru',
name: 'русский (ru)',
name: 'Bashkir',
id: 'ba',
},
{
id: 'zh',
name: '中文 (zh)',
name: 'Basque',
id: 'eu',
},
{
id: 'es',
name: 'Español (es)',
name: 'Belarusian',
id: 'be',
},
{
id: 'ml',
name: 'മലയാളം (ml)',
name: 'Bengali',
id: 'bn',
},
{
name: 'Bislama',
id: 'bi',
},
{
name: 'Bosnian',
id: 'bs',
},
{
name: 'Breton',
id: 'br',
},
{
name: 'Bulgarian',
id: 'bg',
},
{
name: 'Burmese',
id: 'my',
},
{
name: 'Catalan',
id: 'ca',
name: 'Català (ca)',
},
{
id: 'el',
name: 'ελληνικά (el)',
name: 'Chamorro',
id: 'ch',
},
{
id: 'pt-BR',
name: 'Português Brasileiro (pt-BR)',
name: 'Chechen',
id: 'ce',
},
{
id: 'ro',
name: 'Română (ro)',
name: 'Chichewa',
id: 'ny',
},
{
id: 'ta',
name: 'தமிழ் (ta)',
name: 'Chinese',
id: 'zh',
},
{
id: 'fa',
name: 'فارسی (fa)',
name: 'Church Slavonic',
id: 'cu',
},
{
id: 'zh-TW',
name: '中文 (台湾) (zh-TW)',
name: 'Chuvash',
id: 'cv',
},
{
id: 'vi',
name: 'Tiếng Việt (vi)',
name: 'Cornish',
id: 'kw',
},
{
id: 'da',
name: 'dansk (da)',
name: 'Corsican',
id: 'co',
},
{
id: 'tr',
name: 'Türkçe (tr)',
name: 'Cree',
id: 'cr',
},
{
name: 'Croatian',
id: 'hr',
},
{
name: 'Czech',
id: 'cs',
name: 'čeština (cs)',
},
{
name: 'Danish',
id: 'da',
},
{
name: 'Divehi',
id: 'dv',
},
{
name: 'Dutch',
id: 'nl',
},
{
name: 'Dzongkha',
id: 'dz',
},
{
name: 'English',
id: 'en',
},
{
name: 'Esperanto',
id: 'eo',
},
{
name: 'Estonian',
id: 'et',
},
{
name: 'Ewe',
id: 'ee',
},
{
name: 'Faroese',
id: 'fo',
},
{
name: 'Fijian',
id: 'fj',
},
{
name: 'Finnish',
id: 'fi',
name: 'suomi, suomen kieli (fi)',
},
{
id: 'id',
name: 'Bahasa Indonesia (id)',
name: 'French',
id: 'fr',
},
{
id: 'sv',
name: 'Svenska (sv)',
name: 'Western Frisian',
id: 'fy',
},
{
name: 'Fulah',
id: 'ff',
},
{
name: 'Gaelic',
id: 'gd',
},
{
name: 'Galician',
id: 'gl',
},
{
name: 'Ganda',
id: 'lg',
},
{
name: 'Georgian',
id: 'ka',
},
{
name: 'German',
id: 'de',
},
{
name: 'Greek',
id: 'el',
},
{
name: 'Kalaallisut',
id: 'kl',
},
{
name: 'Guarani',
id: 'gn',
},
{
name: 'Gujarati',
id: 'gu',
},
{
name: 'Haitian',
id: 'ht',
},
{
name: 'Hausa',
id: 'ha',
},
{
name: 'Hebrew',
id: 'he',
},
{
name: 'Herero',
id: 'hz',
},
{
name: 'Hindi',
id: 'hi',
},
{
name: 'Hiri Motu',
id: 'ho',
},
{
name: 'Hungarian',
id: 'hu',
name: 'magyar nyelv (hu)',
},
{
name: 'Icelandic',
id: 'is',
},
{
name: 'Ido',
id: 'io',
},
{
name: 'Igbo',
id: 'ig',
},
{
name: 'Indonesian',
id: 'id',
},
{
name: 'Interlingua',
id: 'ia',
},
{
name: 'Interlingue',
id: 'ie',
},
{
name: 'Inuktitut',
id: 'iu',
},
{
name: 'Inupiaq',
id: 'ik',
},
{
name: 'Irish',
id: 'ga',
},
{
name: 'Italian',
id: 'it',
},
{
name: 'Japanese',
id: 'ja',
},
{
name: 'Javanese',
id: 'jv',
},
{
name: 'Kannada',
id: 'kn',
},
{
name: 'Kanuri',
id: 'kr',
},
{
name: 'Kashmiri',
id: 'ks',
},
{
name: 'Kazakh',
id: 'kk',
},
{
name: 'Central Khmer',
id: 'km',
},
{
name: 'Kikuyu',
id: 'ki',
},
{
name: 'Kinyarwanda',
id: 'rw',
},
{
name: 'Kirghiz',
id: 'ky',
},
{
name: 'Komi',
id: 'kv',
},
{
name: 'Kongo',
id: 'kg',
},
{
name: 'Korean',
id: 'ko',
},
{
name: 'Kuanyama',
id: 'kj',
},
{
name: 'Kurdish',
id: 'ku',
},
{
name: 'Lao',
id: 'lo',
},
{
name: 'Latin',
id: 'la',
},
{
name: 'Latvian',
id: 'lv',
},
{
name: 'Limburgan',
id: 'li',
},
{
name: 'Lingala',
id: 'ln',
},
{
name: 'Lithuanian',
id: 'lt',
},
{
name: 'Luba-Katanga',
id: 'lu',
},
{
name: 'Luxembourgish',
id: 'lb',
},
{
name: 'Macedonian',
id: 'mk',
},
{
name: 'Malagasy',
id: 'mg',
},
{
name: 'Malay',
id: 'ms',
},
{
name: 'Malayalam',
id: 'ml',
},
{
name: 'Maltese',
id: 'mt',
},
{
name: 'Manx',
id: 'gv',
},
{
name: 'Maori',
id: 'mi',
},
{
name: 'Marathi',
id: 'mr',
},
{
name: 'Marshallese',
id: 'mh',
},
{
name: 'Mongolian',
id: 'mn',
},
{
name: 'Nauru',
id: 'na',
},
{
name: 'Navajo',
id: 'nv',
},
{
name: 'North Ndebele',
id: 'nd',
},
{
name: 'South Ndebele',
id: 'nr',
},
{
name: 'Ndonga',
id: 'ng',
},
{
name: 'Nepali',
id: 'ne',
},
{
name: 'Norwegian',
id: 'no',
name: 'norsk (no)',
},
{
id: 'zh-CN',
name: '中文 (zh-CN)',
name: 'Norwegian Bokmål',
id: 'nb',
},
{
name: 'Norwegian Nynorsk',
id: 'nn',
},
{
name: 'Sichuan Yi',
id: 'ii',
},
{
name: 'Occitan',
id: 'oc',
},
{
name: 'Ojibwa',
id: 'oj',
},
{
name: 'Oriya',
id: 'or',
},
{
name: 'Oromo',
id: 'om',
},
{
name: 'Ossetian',
id: 'os',
},
{
name: 'Pali',
id: 'pi',
},
{
name: 'Pashto, Pushto',
id: 'ps',
},
{
name: 'Persian',
id: 'fa',
},
{
name: 'Polish',
id: 'pl',
name: 'język polski (pl)',
},
{
name: 'Portuguese',
id: 'pt',
},
{
name: 'Punjabi',
id: 'pa',
},
{
name: 'Quechua',
id: 'qu',
},
{
name: 'Romanian',
id: 'ro',
},
{
name: 'Romansh',
id: 'rm',
},
{
name: 'Rundi',
id: 'rn',
},
{
name: 'Russian',
id: 'ru',
},
{
name: 'Northern Sami',
id: 'se',
},
{
name: 'Samoan',
id: 'sm',
},
{
name: 'Sango',
id: 'sg',
},
{
name: 'Sanskrit',
id: 'sa',
},
{
name: 'Sardinian',
id: 'sc',
},
{
name: 'Serbian',
id: 'sr',
},
{
name: 'Shona',
id: 'sn',
},
{
name: 'Sindhi',
id: 'sd',
},
{
name: 'Sinhala',
id: 'si',
},
{
name: 'Slovak',
id: 'sk',
},
{
name: 'Slovenian',
id: 'sl',
},
{
name: 'Somali',
id: 'so',
},
{
name: 'Southern Sotho',
id: 'st',
},
{
name: 'Spanish',
id: 'es',
},
{
name: 'Sundanese',
id: 'su',
},
{
name: 'Swahili',
id: 'sw',
},
{
name: 'Swati',
id: 'ss',
},
{
name: 'Swedish',
id: 'sv',
},
{
name: 'Tagalog',
id: 'tl',
},
{
name: 'Tahitian',
id: 'ty',
},
{
name: 'Tajik',
id: 'tg',
},
{
name: 'Tamil',
id: 'ta',
},
{
name: 'Tatar',
id: 'tt',
},
{
name: 'Telugu',
id: 'te',
},
{
name: 'Thai',
id: 'th',
},
{
name: 'Tibetan',
id: 'bo',
},
{
name: 'Tigrinya',
id: 'ti',
},
{
name: 'Tonga',
id: 'to',
},
{
name: 'Tsonga',
id: 'ts',
},
{
name: 'Tswana',
id: 'tn',
},
{
name: 'Turkish',
id: 'tr',
},
{
name: 'Turkmen',
id: 'tk',
},
{
name: 'Twi',
id: 'tw',
},
{
name: 'Uighur',
id: 'ug',
},
{
name: 'Ukrainian',
id: 'uk',
},
{
name: 'Urdu',
id: 'ur',
},
{
name: 'Uzbek',
id: 'uz',
},
{
name: 'Venda',
id: 've',
},
{
name: 'Vietnamese',
id: 'vi',
},
{
name: 'Volapük',
id: 'vo',
},
{
name: 'Walloon',
id: 'wa',
},
{
name: 'Welsh',
id: 'cy',
},
{
name: 'Wolof',
id: 'wo',
},
{
name: 'Xhosa',
id: 'xh',
},
{
name: 'Yiddish',
id: 'yi',
},
{
name: 'Yoruba',
id: 'yo',
},
{
name: 'Zhuang, Chuang',
id: 'za',
},
{
name: 'Zulu',
id: 'zu',
},
];
export const getLanguageName = (languageCode = '') => {
const languageObj =
languages.find(language => language.id === languageCode) || {};
return languageObj.name || '';
};
export default languages;

View File

@@ -1,5 +1,5 @@
import defaultFilters from './index';
import { filterAttributeGroups } from './index';
import defaultFilters from '../index';
import { filterAttributeGroups } from '../index';
describe('#filterItems', () => {
it('Matches the correct filterItems', () => {

View File

@@ -0,0 +1,10 @@
import { getLanguageName } from '../languages';
describe('#getLanguageName', () => {
it('Returns correct language name', () => {
expect(getLanguageName('es')).toEqual('Spanish');
expect(getLanguageName()).toEqual('');
expect(getLanguageName('rrr')).toEqual('');
expect(getLanguageName('')).toEqual('');
});
});

View File

@@ -6,7 +6,8 @@
'hide--quoted': !showQuotedContent,
}"
>
<div v-dompurify-html="message" class="text-content" />
<div v-if="!isEmail" v-dompurify-html="message" class="text-content" />
<letter v-else class="text-content" :html="message" />
<button
v-if="displayQuotedButton"
class="quoted-text--button"
@@ -25,7 +26,10 @@
</template>
<script>
import Letter from 'vue-letter';
export default {
components: { Letter },
props: {
message: {
type: String,
@@ -65,14 +69,16 @@ export default {
padding-left: var(--space-two);
}
table {
all: revert;
margin: 0;
border: 0;
td {
all: revert;
margin: 0;
border: 0;
}
tr {
all: revert;
border-bottom: 0 !important;
}
}

View File

@@ -0,0 +1,191 @@
<template>
<div class="menu-container">
<template v-for="option in statusMenuConfig">
<menu-item
v-if="show(option.key)"
:key="option.key"
:option="option"
variant="icon"
@click="toggleStatus(option.key, null)"
/>
</template>
<menu-item-with-submenu :option="snoozeMenuConfig">
<menu-item
v-for="(option, i) in snoozeMenuConfig.options"
:key="i"
:option="option"
@click="snoozeConversation(option.snoozedUntil)"
/>
</menu-item-with-submenu>
<menu-item-with-submenu :option="labelMenuConfig">
<template>
<menu-item
v-for="label in labels"
:key="label.id"
:option="generateMenuLabelConfig(label, 'label')"
variant="label"
@click="$emit('assign-label', label)"
/>
</template>
</menu-item-with-submenu>
<menu-item-with-submenu :option="agentMenuConfig">
<agent-loading-placeholder v-if="assignableAgentsUiFlags.isFetching" />
<template v-else>
<menu-item
v-for="agent in assignableAgents"
:key="agent.id"
:option="generateMenuLabelConfig(agent, 'agent')"
variant="agent"
@click="$emit('assign-agent', agent)"
/>
</template>
</menu-item-with-submenu>
<menu-item-with-submenu :option="teamMenuConfig">
<menu-item
v-for="team in teams"
:key="team.id"
:option="generateMenuLabelConfig(team, 'team')"
@click="$emit('assign-team', team)"
/>
</menu-item-with-submenu>
</div>
</template>
<script>
import MenuItem from './menuItem.vue';
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
import wootConstants from 'dashboard/constants.js';
import snoozeTimesMixin from 'dashboard/mixins/conversation/snoozeTimesMixin';
import { mapGetters } from 'vuex';
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
export default {
components: {
MenuItem,
MenuItemWithSubmenu,
AgentLoadingPlaceholder,
},
mixins: [snoozeTimesMixin],
props: {
status: {
type: String,
default: '',
},
inboxId: {
type: Number,
default: null,
},
},
data() {
return {
STATUS_TYPE: wootConstants.STATUS_TYPE,
statusMenuConfig: [
{
key: wootConstants.STATUS_TYPE.RESOLVED,
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.RESOLVED'),
icon: 'checkmark',
},
{
key: wootConstants.STATUS_TYPE.PENDING,
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.PENDING'),
icon: 'book-clock',
},
{
key: wootConstants.STATUS_TYPE.OPEN,
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.REOPEN'),
icon: 'arrow-redo',
},
],
snoozeMenuConfig: {
key: 'snooze',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.SNOOZE.TITLE'),
icon: 'snooze',
options: [
{
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.SNOOZE.NEXT_REPLY'),
key: 'next-reply',
snoozedUntil: null,
},
{
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.SNOOZE.TOMORROW'),
key: 'tomorrow',
snoozedUntil: 'tomorrow',
},
{
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.SNOOZE.NEXT_WEEK'),
key: 'next-week',
snoozedUntil: 'nextWeek',
},
],
},
labelMenuConfig: {
key: 'label',
icon: 'tag',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_LABEL'),
},
agentMenuConfig: {
key: 'agent',
icon: 'person-add',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_AGENT'),
},
teamMenuConfig: {
key: 'team',
icon: 'people-team-add',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_TEAM'),
},
};
},
computed: {
...mapGetters({
labels: 'labels/getLabels',
teams: 'teams/getTeams',
assignableAgentsUiFlags: 'inboxAssignableAgents/getUIFlags',
}),
assignableAgents() {
return this.$store.getters['inboxAssignableAgents/getAssignableAgents'](
this.inboxId
);
},
},
mounted() {
this.$store.dispatch('inboxAssignableAgents/fetch', [this.inboxId]);
},
methods: {
toggleStatus(status, snoozedUntil) {
this.$emit('update-conversation', status, snoozedUntil);
},
snoozeConversation(snoozedUntil) {
this.$emit(
'update-conversation',
this.STATUS_TYPE.SNOOZED,
this.snoozeTimes[snoozedUntil] || null
);
},
show(key) {
// If the conversation status is same as the action, then don't display the option
// i.e.: Don't show an option to resolve if the conversation is already resolved.
return this.status !== key;
},
generateMenuLabelConfig(option, type = 'text') {
return {
key: option.id,
...(type === 'icon' && { icon: option.icon }),
...(type === 'label' && { color: option.color }),
...(type === 'agent' && { thumbnail: option.thumbnail }),
...(type === 'text' && { label: option.label }),
...(type === 'label' && { label: option.title }),
...(type === 'agent' && { label: option.name }),
...(type === 'team' && { label: option.name }),
};
},
},
};
</script>
<style lang="scss" scoped>
.menu-container {
padding: var(--space-smaller);
background-color: var(--white);
box-shadow: var(--shadow-context-menu);
border-radius: var(--border-radius-normal);
}
</style>

View File

@@ -0,0 +1,31 @@
<template>
<div class="agent-placeholder">
<spinner />
<p>{{ $t('CONVERSATION.CARD_CONTEXT_MENU.AGENTS_LOADING') }}</p>
</div>
</template>
<script>
import Spinner from 'shared/components/Spinner';
export default {
components: {
Spinner,
},
};
</script>
<style scoped lang="scss">
.agent-placeholder {
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
padding: var(--space-normal) 0;
min-width: calc(var(--space-mega) * 2);
p {
margin: var(--space-small) 0 0 0;
}
}
</style>

View File

@@ -0,0 +1,83 @@
<template>
<div class="menu" @click.stop="$emit('click')">
<fluent-icon
v-if="variant === 'icon' && option.icon"
:icon="option.icon"
size="14"
class="menu-icon"
/>
<span
v-if="variant === 'label' && option.color"
class="label-pill"
:style="{ backgroundColor: option.color }"
/>
<thumbnail
v-if="variant === 'agent'"
:username="option.label"
:src="option.thumbnail"
size="20px"
class="agent-thumbnail"
/>
<p class="menu-label truncate-text">{{ option.label }}</p>
</div>
</template>
<script>
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
export default {
components: {
Thumbnail,
},
props: {
option: {
type: Object,
default: () => {},
},
variant: {
type: String,
default: 'default',
},
},
};
</script>
<style scoped lang="scss">
.menu {
display: flex;
align-items: center;
flex-wrap: nowrap;
width: calc(var(--space-mega) * 2);
padding: var(--space-smaller);
border-radius: var(--border-radius-small);
overflow: hidden;
.menu-label {
margin: 0;
font-size: var(--font-size-mini);
flex-shrink: 0;
}
.menu-icon {
margin-right: var(--space-small);
}
&:hover {
background-color: var(--w-500);
color: var(--white);
}
}
.agent-thumbnail {
margin-top: 0 !important;
margin-right: var(--space-small);
}
.label-pill {
width: var(--space-normal);
height: var(--space-normal);
border-radius: var(--border-radius-rounded);
border: 1px solid var(--s-50);
flex-shrink: 0;
margin-right: var(--space-small);
}
</style>

View File

@@ -0,0 +1,77 @@
<template>
<div class="menu-with-submenu flex-between">
<div class="menu-left">
<fluent-icon :icon="option.icon" size="14" class="menu-icon" />
<p class="menu-label">{{ option.label }}</p>
</div>
<fluent-icon icon="chevron-right" size="12" />
<div class="submenu">
<slot />
</div>
</div>
</template>
<script>
export default {
props: {
option: {
type: Object,
default: () => {},
},
},
};
</script>
<style scoped lang="scss">
.menu-with-submenu {
width: 100%;
padding: var(--space-smaller);
border-radius: var(--border-radius-small);
position: relative;
min-width: calc(var(--space-mega) * 2);
background-color: var(--white);
.menu-left {
display: flex;
align-items: center;
.menu-label {
margin: 0;
font-size: var(--font-size-mini);
}
.menu-icon {
margin-right: var(--space-small);
}
}
.submenu {
padding: var(--space-smaller);
background-color: var(--white);
box-shadow: var(--shadow-context-menu);
border-radius: var(--border-radius-normal);
position: absolute;
position: absolute;
left: 100%;
top: 0;
display: none;
}
&:hover {
background-color: var(--w-75);
.submenu {
display: block;
}
&:before {
content: '';
position: absolute;
z-index: var(--z-index-highest);
bottom: -65%;
height: 75%;
right: 0%;
width: 50%;
clip-path: polygon(100% 0, 0% 0%, 100% 100%);
}
}
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<label>
<label class="input-container">
<span v-if="label">{{ label }}</span>
<input
:value="value"
@@ -10,7 +10,7 @@
@input="onChange"
@blur="onBlur"
/>
<p v-if="helpText" class="help-text" />
<p v-if="helpText" class="help-text">{{ helpText }}</p>
<span v-if="error" class="message">
{{ error }}
</span>
@@ -46,7 +46,7 @@ export default {
},
readonly: {
type: Boolean,
deafaut: false,
default: false,
},
styles: {
type: Object,
@@ -63,3 +63,14 @@ export default {
},
};
</script>
<style scoped lang="scss">
.help-text {
margin-top: var(--space-micro);
font-size: var(--font-size-mini);
color: var(--s-600);
font-style: normal;
}
.message {
margin-top: 0 !important;
}
</style>

View File

@@ -12,5 +12,10 @@ export default {
SNOOZED: 'snoozed',
ALL: 'all',
},
LAYOUT_TYPES: {
CONDENSED: 'condensed',
EXPANDED: 'expanded',
},
DOCS_URL: '//www.chatwoot.com/docs/product/',
};
export const DEFAULT_REDIRECT_URL = '/app/';

View File

@@ -1,21 +1,35 @@
import queryString from 'query-string';
import { DEFAULT_REDIRECT_URL } from '../constants';
export const frontendURL = (path, params) => {
const stringifiedParams = params ? `?${queryString.stringify(params)}` : '';
const stringifiedParams = params ? `?${new URLSearchParams(params)}` : '';
return `/app/${path}${stringifiedParams}`;
};
export const getLoginRedirectURL = (ssoAccountId, user) => {
const getSSOAccountPath = ({ ssoAccountId, user }) => {
const { accounts = [] } = user || {};
const ssoAccount = accounts.find(
account => account.id === Number(ssoAccountId)
);
let accountPath = '';
if (ssoAccount) {
return frontendURL(`accounts/${ssoAccountId}/dashboard`);
accountPath = `accounts/${ssoAccountId}`;
} else if (accounts.length) {
accountPath = `accounts/${accounts[0].id}`;
}
if (accounts.length) {
return frontendURL(`accounts/${accounts[0].id}/dashboard`);
return accountPath;
};
export const getLoginRedirectURL = ({
ssoAccountId,
ssoConversationId,
user,
}) => {
const accountPath = getSSOAccountPath({ ssoAccountId, user });
if (accountPath) {
if (ssoConversationId) {
return frontendURL(`${accountPath}/conversations/${ssoConversationId}`);
}
return frontendURL(`${accountPath}/dashboard`);
}
return DEFAULT_REDIRECT_URL;
};
@@ -44,6 +58,26 @@ export const conversationUrl = ({
return url;
};
export const conversationListPageURL = ({
accountId,
conversationType = '',
inboxId,
label,
teamId,
}) => {
let url = `accounts/${accountId}/dashboard`;
if (label) {
url = `accounts/${accountId}/label/${label}`;
} else if (teamId) {
url = `accounts/${accountId}/team/${teamId}`;
} else if (conversationType === 'mention') {
url = `accounts/${accountId}/mentions/conversations`;
} else if (inboxId) {
url = `accounts/${accountId}/inbox/${inboxId}`;
}
return frontendURL(url);
};
export const isValidURL = value => {
/* eslint-disable no-useless-escape */
const URL_REGEX = /^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$/gm;

View File

@@ -62,9 +62,23 @@ export const createPendingMessage = data => {
return pendingMessage;
};
export const convertToSlug = text => {
export const convertToAttributeSlug = text => {
return text
.toLowerCase()
.replace(/[^\w ]+/g, '')
.replace(/ +/g, '_');
};
export const convertToCategorySlug = text => {
return text
.toLowerCase()
.replace(/[^\w ]+/g, '')
.replace(/ +/g, '-');
};
export const convertToPortalSlug = text => {
return text
.toLowerCase()
.replace(/[^\w ]+/g, '')
.replace(/ +/g, '-');
};

View File

@@ -0,0 +1,19 @@
const FLAG_OFFSET = 127397;
/**
* Gets emoji flag for given locale.
*
* @param {string} countryCode locale code
* @return {string} emoji flag
*
* @example
* getCountryFlag('cz') // '🇨🇿'
*/
export const getCountryFlag = countryCode => {
const codePoints = countryCode
.toUpperCase()
.split('')
.map(char => FLAG_OFFSET + char.charCodeAt());
return String.fromCodePoint(...codePoints);
};

View File

@@ -1,17 +1,32 @@
class LocalStorage {
constructor(key) {
this.key = key;
}
export const LOCAL_STORAGE_KEYS = {
DISMISSED_UPDATES: 'dismissedUpdates',
WIDGET_BUILDER: 'widgetBubble_',
};
store(allItems) {
localStorage.setItem(this.key, JSON.stringify(allItems));
localStorage.setItem(this.key + ':ts', Date.now());
}
export const LocalStorage = {
clearAll() {
window.localStorage.clear();
},
get() {
let stored = localStorage.getItem(this.key);
return JSON.parse(stored) || [];
}
}
get(key) {
const value = window.localStorage.getItem(key);
try {
return typeof value === 'string' ? JSON.parse(value) : value;
} catch (error) {
return value;
}
},
set(key, value) {
if (typeof value === 'object') {
window.localStorage.setItem(key, JSON.stringify(value));
} else {
window.localStorage.setItem(key, value);
}
window.localStorage.setItem(key + ':ts', Date.now());
},
export default LocalStorage;
remove(key) {
window.localStorage.removeItem(key);
window.localStorage.removeItem(key + ':ts');
},
};

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