Merge branch 'release/2.0.0'

This commit is contained in:
Sojan Jose 2021-12-09 08:17:15 +00:00 committed by GitHub
commit a7623041bf
549 changed files with 9069 additions and 3428 deletions

View File

@ -7,7 +7,7 @@ defaults: &defaults
working_directory: ~/build working_directory: ~/build
docker: docker:
# specify the version you desire here # specify the version you desire here
- image: cimg/ruby:3.0.2-node - image: cimg/ruby:3.0.2-browsers
# Specify service dependencies here if necessary # Specify service dependencies here if necessary
# CircleCI maintains a library of pre-built images # CircleCI maintains a library of pre-built images
@ -77,6 +77,18 @@ jobs:
paths: paths:
- cc-test-reporter - cc-test-reporter
# verify swagger specification
- run:
name: Verify swagger API specification
command: |
bundle exec rake swagger:build
if [[ `git status swagger/swagger.json --porcelain` ]]
then
echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'."
exit 1
fi
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/5.3.0/openapi-generator-cli-5.3.0.jar > ~/tmp/openapi-generator-cli-5.3.0.jar
java -jar ~/tmp/openapi-generator-cli-5.3.0.jar validate -i swagger/swagger.json
# Database setup # Database setup
- run: yarn install --check-files - run: yarn install --check-files
- run: bundle exec rake db:create - run: bundle exec rake db:create

View File

@ -40,3 +40,7 @@ exclude_patterns:
- "app/javascript/dashboard/i18n/locale" - "app/javascript/dashboard/i18n/locale"
- "**/*.stories.js" - "**/*.stories.js"
- "stories/" - "stories/"
- "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js"
- "app/javascript/shared/constants/countries.js"
- "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js"
- "app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js"

View File

@ -57,6 +57,9 @@ SMTP_AUTHENTICATION=
SMTP_ENABLE_STARTTLS_AUTO=true SMTP_ENABLE_STARTTLS_AUTO=true
# Can be: 'none', 'peer', 'client_once', 'fail_if_no_peer_cert', see http://api.rubyonrails.org/classes/ActionMailer/Base.html # Can be: 'none', 'peer', 'client_once', 'fail_if_no_peer_cert', see http://api.rubyonrails.org/classes/ActionMailer/Base.html
SMTP_OPENSSL_VERIFY_MODE=peer SMTP_OPENSSL_VERIFY_MODE=peer
# Comment out the following environment variables if required by your SMTP server
# SMTP_TLS=
# SMTP_SSL=
# Mail Incoming # Mail Incoming
# This is the domain set for the reply emails when conversation continuity is enabled # This is the domain set for the reply emails when conversation continuity is enabled

View File

@ -28,6 +28,9 @@ module.exports = {
}], }],
'vue/html-self-closing': 'off', 'vue/html-self-closing': 'off',
"vue/no-v-html": 'off', "vue/no-v-html": 'off',
'vue/singleline-html-element-content-newline': 'warn',
'vue/require-default-prop': 'warn',
'vue/require-prop-types': 'warn',
'import/extensions': ['off'] 'import/extensions': ['off']
}, },

5
.husky/pre-commit Executable file
View File

@ -0,0 +1,5 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npm run eslint
bundle exec rubocop -a
git add

4
.husky/pre-push Executable file
View File

@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
sh bin/validate_push

View File

@ -57,7 +57,7 @@ Metrics/BlockLength:
- db/schema.rb - db/schema.rb
Metrics/ModuleLength: Metrics/ModuleLength:
Exclude: Exclude:
- lib/woot_message_seeder.rb - lib/seeders/message_seeder.rb
Rails/ApplicationController: Rails/ApplicationController:
Exclude: Exclude:
- 'app/controllers/api/v1/widget/messages_controller.rb' - 'app/controllers/api/v1/widget/messages_controller.rb'

128
CODE_OF_CONDUCT.md Normal file
View File

@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
hello@chatwoot.com.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

5
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,5 @@
# Contributing to Chatwoot
Thanks for taking the time to contribute! :tada::+1:
Please refer to our [Contributing Guide](https://www.chatwoot.com/docs/contributing-guide) for detailed instructions.

View File

@ -1,7 +1,11 @@
The MIT License (MIT)
Copyright (c) 2017-2021 Chatwoot Inc. Copyright (c) 2017-2021 Chatwoot Inc.
Portions of this software are licensed as follows:
* All content that resides under the "enterprise/" directory of this repository, if that directory exists, is licensed under the license defined in "enterprise/LICENSE".
* All third party components incorporated into the Chatwoot Software are licensed under the original license provided by the owner of the applicable component.
* Content outside of the above mentioned directories or restrictions above is available under the "MIT Expat" license as defined below.
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights in the Software without restriction, including without limitation the rights

View File

@ -6,7 +6,10 @@
<p align="center"> <p align="center">
<a href="https://heroku.com/deploy?template=https://github.com/chatwoot/chatwoot/tree/master" alt="Deploy to Heroku"> <a href="https://heroku.com/deploy?template=https://github.com/chatwoot/chatwoot/tree/master" alt="Deploy to Heroku">
<img alt="Deploy" src="https://www.herokucdn.com/deploy/button.svg"/> <img width="150" alt="Deploy" src="https://www.herokucdn.com/deploy/button.svg"/>
</a>
<a href="https://marketplace.digitalocean.com/apps/chatwoot?refcode=f2238426a2a8" alt="Deploy to DigitalOcean">
<img width="200" alt="Deploy to DO" src="https://www.deploytodo.com/do-btn-blue.svg"/>
</a> </a>
</p> </p>
@ -81,9 +84,18 @@ Deploying Chatwoot to Heroku is a breeze. It's as simple as clicking this button
Follow this [link](https://www.chatwoot.com/docs/environment-variables) to understand setting the correct environment variables for the app to work with all the features. There might be breakages if you do not set the relevant environment variables. Follow this [link](https://www.chatwoot.com/docs/environment-variables) to understand setting the correct environment variables for the app to work with all the features. There might be breakages if you do not set the relevant environment variables.
### DigitalOcean 1-Click Kubernetes deployment
Chatwoot now supports 1-Click deployment to DigitalOcean as a kubernetes app.
<a href="https://marketplace.digitalocean.com/apps/chatwoot?refcode=f2238426a2a8" alt="Deploy to DigitalOcean">
<img width="200" alt="Deploy to DO" src="https://www.deploytodo.com/do-btn-blue.svg"/>
</a>
### Other deployment options ### Other deployment options
Please follow [deployment architecture guide](https://www.chatwoot.com/docs/deployment/architecture) to deploy with Docker or Caprover. For other supported options, checkout our [deployment page](https://chatwoot.com/deploy).
## Security ## Security

View File

@ -4,7 +4,7 @@ class ContactInboxBuilder
def perform def perform
@contact = Contact.find(contact_id) @contact = Contact.find(contact_id)
@inbox = @contact.account.inboxes.find(inbox_id) @inbox = @contact.account.inboxes.find(inbox_id)
return unless ['Channel::TwilioSms', 'Channel::Email', 'Channel::Api'].include? @inbox.channel_type return unless ['Channel::TwilioSms', 'Channel::Email', 'Channel::Api', 'Channel::Whatsapp'].include? @inbox.channel_type
source_id = @source_id || generate_source_id source_id = @source_id || generate_source_id
create_contact_inbox(source_id) if source_id.present? create_contact_inbox(source_id) if source_id.present?
@ -14,12 +14,20 @@ class ContactInboxBuilder
def generate_source_id def generate_source_id
return twilio_source_id if @inbox.channel_type == 'Channel::TwilioSms' return twilio_source_id if @inbox.channel_type == 'Channel::TwilioSms'
return wa_source_id if @inbox.channel_type == 'Channel::Whatsapp'
return @contact.email if @inbox.channel_type == 'Channel::Email' return @contact.email if @inbox.channel_type == 'Channel::Email'
return SecureRandom.uuid if @inbox.channel_type == 'Channel::Api' return SecureRandom.uuid if @inbox.channel_type == 'Channel::Api'
nil nil
end end
def wa_source_id
return unless @contact.phone_number
# whatsapp doesn't want the + in e164 format
"#{@contact.phone_number}.delete('+')"
end
def twilio_source_id def twilio_source_id
return unless @contact.phone_number return unless @contact.phone_number

View File

@ -2,6 +2,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
before_action :fetch_agent, except: [:create, :index] before_action :fetch_agent, except: [:create, :index]
before_action :check_authorization before_action :check_authorization
before_action :find_user, only: [:create] before_action :find_user, only: [:create]
before_action :validate_limit, only: [:create]
before_action :create_user, only: [:create] before_action :create_user, only: [:create]
before_action :save_account_user, only: [:create] before_action :save_account_user, only: [:create]
@ -69,4 +70,8 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
def agents def agents
@agents ||= Current.account.users.order_by_full_name.includes({ avatar_attachment: [:blob] }) @agents ||= Current.account.users.order_by_full_name.includes({ avatar_attachment: [:blob] })
end end
def validate_limit
render_payment_required('Account limit exceeded. Upgrade to a higher plan') if agents.count >= Current.account.usage_limits[:agents]
end
end end

View File

@ -74,7 +74,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
end end
def long_lived_token(omniauth_token) def long_lived_token(omniauth_token)
koala = Koala::Facebook::OAuth.new(ENV['FB_APP_ID'], ENV['FB_APP_SECRET']) koala = Koala::Facebook::OAuth.new(GlobalConfigService.load('FB_APP_ID', ''), GlobalConfigService.load('FB_APP_SECRET', ''))
koala.exchange_access_token_info(omniauth_token)['access_token'] koala.exchange_access_token_info(omniauth_token)['access_token']
rescue StandardError => e rescue StandardError => e
Rails.logger.info e Rails.logger.info e

View File

@ -33,7 +33,7 @@ class Api::V1::Accounts::CannedResponsesController < Api::V1::Accounts::BaseCont
def canned_responses def canned_responses
if params[:search] if params[:search]
Current.account.canned_responses.where('short_code ILIKE ?', "#{params[:search]}%") Current.account.canned_responses.where('short_code ILIKE :search OR content ILIKE :search', search: "%#{params[:search]}%")
else else
Current.account.canned_responses Current.account.canned_responses
end end

View File

@ -11,9 +11,9 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
RESULTS_PER_PAGE = 15 RESULTS_PER_PAGE = 15
before_action :check_authorization before_action :check_authorization
before_action :set_current_page, only: [:index, :active, :search] before_action :set_current_page, only: [:index, :active, :search, :filter]
before_action :fetch_contact, only: [:show, :update, :destroy, :contactable_inboxes, :destroy_custom_attributes] before_action :fetch_contact, only: [:show, :update, :destroy, :contactable_inboxes, :destroy_custom_attributes]
before_action :set_include_contact_inboxes, only: [:index, :search] before_action :set_include_contact_inboxes, only: [:index, :search, :filter]
def index def index
@contacts_count = resolved_contacts.count @contacts_count = resolved_contacts.count
@ -81,11 +81,6 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def update def update
@contact.assign_attributes(contact_update_params) @contact.assign_attributes(contact_update_params)
@contact.save! @contact.save!
rescue ActiveRecord::RecordInvalid => e
render json: {
message: e.record.errors.full_messages.join(', '),
contact: Current.account.contacts.find_by(email: contact_params[:email])
}, status: :unprocessable_entity
end end
def destroy def destroy

View File

@ -74,9 +74,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end end
def update_last_seen def update_last_seen
@conversation.agent_last_seen_at = DateTime.now.utc # rubocop:disable Rails/SkipsModelValidations
@conversation.assignee_last_seen_at = DateTime.now.utc if assignee? @conversation.update_column(:agent_last_seen_at, DateTime.now.utc)
@conversation.save! @conversation.update_column(:assignee_last_seen_at, DateTime.now.utc) if assignee?
# rubocop:enable Rails/SkipsModelValidations
end end
def custom_attributes def custom_attributes

View File

@ -39,7 +39,7 @@ class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Account
:attribute_display_type, :attribute_display_type,
:attribute_key, :attribute_key,
:attribute_model, :attribute_model,
:default_value attribute_values: []
) )
end end

View File

@ -1,4 +1,5 @@
class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
include Api::V1::InboxesHelper
before_action :fetch_inbox, except: [:index, :create] before_action :fetch_inbox, except: [:index, :create]
before_action :fetch_agent_bot, only: [:set_agent_bot] before_action :fetch_agent_bot, only: [:set_agent_bot]
# we are already handling the authorization in fetch inbox # we are already handling the authorization in fetch inbox
@ -41,12 +42,13 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
def update def update
@inbox.update(permitted_params.except(:channel)) @inbox.update(permitted_params.except(:channel))
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours] @inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
channel_attributes = get_channel_attributes(@inbox.channel_type) channel_attributes = get_channel_attributes(@inbox.channel_type)
# Inbox update doesn't necessarily need channel attributes # Inbox update doesn't necessarily need channel attributes
return if permitted_params(channel_attributes)[:channel].blank? return if permitted_params(channel_attributes)[:channel].blank?
validate_email_channel(channel_attributes) if @inbox.inbox_type == 'Email'
@inbox.channel.update!(permitted_params(channel_attributes)[:channel]) @inbox.channel.update!(permitted_params(channel_attributes)[:channel])
update_channel_feature_flags update_channel_feature_flags
end end

View File

@ -13,6 +13,11 @@ class Api::V1::ProfilesController < Api::BaseController
@user.update!(profile_params) @user.update!(profile_params)
end end
def avatar
@user.avatar.attachment.destroy! if @user.avatar.attached?
head :ok
end
def availability def availability
@user.account_users.find_by!(account_id: availability_params[:account_id]).update!(availability: availability_params[:availability]) @user.account_users.find_by!(account_id: availability_params[:account_id]).update!(availability: availability_params[:availability])
end end

View File

@ -1,5 +1,5 @@
class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
before_action :process_hmac before_action :process_hmac, only: [:update]
def show; end def show; end
@ -8,7 +8,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
contact: @contact, contact: @contact,
params: permitted_params.to_h.deep_symbolize_keys params: permitted_params.to_h.deep_symbolize_keys
) )
render json: contact_identify_action.perform @contact = contact_identify_action.perform
end end
# TODO : clean up this with proper routes delete contacts/custom_attributes # TODO : clean up this with proper routes delete contacts/custom_attributes
@ -21,13 +21,22 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
private private
def process_hmac def process_hmac
return if params[:identifier_hash].blank? && !@web_widget.hmac_mandatory return unless should_verify_hmac?
render json: { error: 'HMAC failed: Invalid Identifier Hash Provided' }, status: :unauthorized unless valid_hmac? render json: { error: 'HMAC failed: Invalid Identifier Hash Provided' }, status: :unauthorized unless valid_hmac?
@contact_inbox.update(hmac_verified: true) @contact_inbox.update(hmac_verified: true)
end end
def should_verify_hmac?
return false if params[:identifier_hash].blank? && !@web_widget.hmac_mandatory
# Taking an extra caution that the hmac is triggered whenever identifier is present
return false if params[:custom_attributes].present? && params[:identifier].blank?
true
end
def valid_hmac? def valid_hmac?
params[:identifier_hash] == OpenSSL::HMAC.hexdigest( params[:identifier_hash] == OpenSSL::HMAC.hexdigest(
'sha256', 'sha256',

View File

@ -31,13 +31,18 @@ module RequestExceptionHandler
render json: { error: message }, status: :unprocessable_entity render json: { error: message }, status: :unprocessable_entity
end end
def render_payment_required(message)
render json: { error: message }, status: :payment_required
end
def render_internal_server_error(message) def render_internal_server_error(message)
render json: { error: message }, status: :internal_server_error render json: { error: message }, status: :internal_server_error
end end
def render_record_invalid(exception) def render_record_invalid(exception)
render json: { render json: {
message: exception.record.errors.full_messages.join(', ') message: exception.record.errors.full_messages.join(', '),
attributes: exception.record.errors.attribute_names
}, status: :unprocessable_entity }, status: :unprocessable_entity
end end

View File

@ -26,14 +26,17 @@ class DashboardController < ActionController::Base
'API_CHANNEL_THUMBNAIL', 'API_CHANNEL_THUMBNAIL',
'ANALYTICS_TOKEN', 'ANALYTICS_TOKEN',
'ANALYTICS_HOST' 'ANALYTICS_HOST'
).merge( ).merge(app_config)
APP_VERSION: Chatwoot.config[:version],
VAPID_PUBLIC_KEY: VapidService.public_key,
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false')
)
end end
def ensure_installation_onboarding def ensure_installation_onboarding
redirect_to '/installation/onboarding' if ::Redis::Alfred.get(::Redis::Alfred::CHATWOOT_INSTALLATION_ONBOARDING) redirect_to '/installation/onboarding' if ::Redis::Alfred.get(::Redis::Alfred::CHATWOOT_INSTALLATION_ONBOARDING)
end end
def app_config
{ APP_VERSION: Chatwoot.config[:version],
VAPID_PUBLIC_KEY: VapidService.public_key,
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', '') }
end
end end

View File

@ -28,10 +28,7 @@ class DeviseOverrides::ConfirmationsController < Devise::ConfirmationsController
end end
def create_reset_token_link(user) def create_reset_token_link(user)
raw, enc = Devise.token_generator.generate(user.class, :reset_password_token) token = user.send(:set_reset_password_token)
user.reset_password_token = enc "/app/auth/password/edit?config=default&redirect_url=&reset_password_token=#{token}"
user.reset_password_sent_at = Time.now.utc
user.save(validate: false)
"/app/auth/password/edit?config=default&redirect_url=&reset_password_token=#{raw}"
end end
end end

View File

@ -25,6 +25,6 @@ class Webhooks::InstagramController < ApplicationController
private private
def valid_instagram_token?(token) def valid_instagram_token?(token)
token == ENV['IG_VERIFY_TOKEN'] token == GlobalConfigService.load('IG_VERIFY_TOKEN', '')
end end
end end

View File

@ -1,5 +1,30 @@
class ConversationDrop < BaseDrop class ConversationDrop < BaseDrop
include MessageFormatHelper
def display_id def display_id
@obj.try(:display_id) @obj.try(:display_id)
end end
def contact_name
@obj.try(:contact).name.capitalize || 'Customer'
end
def recent_messages
@obj.try(:recent_messages).map do |message|
{
'sender' => message_sender_name(message.sender),
'content' => render_message_content(transform_user_mention_content(message.content)),
'attachments' => message.attachments.map(&:file_url)
}
end
end
private
def message_sender_name(sender)
return 'Bot' if sender.blank?
return contact_name if sender.instance_of?(Contact)
sender&.available_name || sender&.name
end
end end

View File

@ -1,2 +1,5 @@
class InboxDrop < BaseDrop class InboxDrop < BaseDrop
def name
@obj.try(:name)
end
end end

View File

@ -6,7 +6,7 @@ class MessageDrop < BaseDrop
end end
def text_content def text_content
content = @obj.try(:content) content = @obj.try(:content) || ''
transform_user_mention_content content render_message_content(transform_user_mention_content(content))
end end
end end

View File

@ -70,7 +70,12 @@ class ConversationFinder
end end
def find_all_conversations def find_all_conversations
@conversations = current_account.conversations.where(inbox_id: @inbox_ids) if params[:conversation_type] == 'mention'
conversation_ids = current_account.mentions.where(user: current_user).pluck(:conversation_id)
@conversations = current_account.conversations.where(id: conversation_ids)
else
@conversations = current_account.conversations.where(inbox_id: @inbox_ids)
end
end end
def filter_by_assignee_type def filter_by_assignee_type
@ -123,6 +128,10 @@ class ConversationFinder
@conversations = @conversations.includes( @conversations = @conversations.includes(
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :contact_inbox :taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :contact_inbox
) )
@conversations.latest.page(current_page) if params[:conversation_type] == 'mention'
@conversations.page(current_page)
else
@conversations.latest.page(current_page)
end
end end
end end

View File

@ -0,0 +1,33 @@
module Api::V1::InboxesHelper
def validate_email_channel(attributes)
channel_data = permitted_params(attributes)[:channel]
validate_imap(channel_data)
validate_smtp(channel_data)
end
private
def validate_imap(channel_data)
return unless channel_data.key?('imap_enabled') && channel_data[:imap_enabled]
Mail.defaults do
retriever_method :imap, { address: channel_data[:imap_address],
port: channel_data[:imap_port],
user_name: channel_data[:imap_email],
password: channel_data[:imap_password],
enable_ssl: channel_data[:imap_enable_ssl] }
end
Mail.connection do # rubocop:disable:block
end
end
def validate_smtp(channel_data)
return unless channel_data.key?('smtp_enabled') && channel_data[:smtp_enabled]
smtp = Net::SMTP.start(channel_data[:smtp_address], channel_data[:smtp_port], channel_data[:smtp_domain], channel_data[:smtp_email],
channel_data[:smtp_password], :login)
smtp.finish unless smtp&.nil?
end
end

View File

@ -1,6 +1,13 @@
module MessageFormatHelper module MessageFormatHelper
include RegexHelper include RegexHelper
def transform_user_mention_content(message_content) def transform_user_mention_content(message_content)
message_content.gsub(MENTION_REGEX, '\1') message_content.gsub(MENTION_REGEX, '\1')
end end
def render_message_content(message_content)
# rubocop:disable Rails/OutputSafety
CommonMarker.render_html(message_content).html_safe
# rubocop:enable Rails/OutputSafety
end
end end

View File

@ -166,4 +166,8 @@ export default {
profile: { ...availabilityData }, profile: { ...availabilityData },
}); });
}, },
deleteAvatar() {
return axios.delete(endPoints('deleteAvatar').url);
},
}; };

View File

@ -53,6 +53,11 @@ class ContactAPI extends ApiClient {
return axios.get(requestURL); return axios.get(requestURL);
} }
filter(page = 1, sortAttr = 'name', queryPayload) {
let requestURL = `${this.url}/filter?${buildContactParams(page, sortAttr)}`;
return axios.post(requestURL, queryPayload);
}
importContacts(file) { importContacts(file) {
const formData = new FormData(); const formData = new FormData();
formData.append('import_file', file); formData.append('import_file', file);

View File

@ -36,6 +36,10 @@ const endPoints = {
}, },
params: { omniauth_token: '' }, params: { omniauth_token: '' },
}, },
deleteAvatar: {
url: '/api/v1/profile/avatar',
},
}; };
export default page => { export default page => {

View File

@ -6,7 +6,15 @@ class ConversationApi extends ApiClient {
super('conversations', { accountScoped: true }); super('conversations', { accountScoped: true });
} }
get({ inboxId, status, assigneeType, page, labels, teamId }) { get({
inboxId,
status,
assigneeType,
page,
labels,
teamId,
conversationType,
}) {
return axios.get(this.url, { return axios.get(this.url, {
params: { params: {
inbox_id: inboxId, inbox_id: inboxId,
@ -15,6 +23,15 @@ class ConversationApi extends ApiClient {
assignee_type: assigneeType, assignee_type: assigneeType,
page, page,
labels, labels,
conversation_type: conversationType,
},
});
}
filter(payload) {
return axios.post(`${this.url}/filter`, payload.queryData, {
params: {
page: payload.page,
}, },
}); });
} }
@ -54,7 +71,7 @@ class ConversationApi extends ApiClient {
toggleTyping({ conversationId, status, isPrivate }) { toggleTyping({ conversationId, status, isPrivate }) {
return axios.post(`${this.url}/${conversationId}/toggle_typing_status`, { return axios.post(`${this.url}/${conversationId}/toggle_typing_status`, {
typing_status: status, typing_status: status,
is_private: isPrivate is_private: isPrivate,
}); });
} }
@ -66,7 +83,7 @@ class ConversationApi extends ApiClient {
return axios.post(`${this.url}/${conversationId}/unmute`); return axios.post(`${this.url}/${conversationId}/unmute`);
} }
meta({ inboxId, status, assigneeType, labels, teamId }) { meta({ inboxId, status, assigneeType, labels, teamId, conversationType }) {
return axios.get(`${this.url}/meta`, { return axios.get(`${this.url}/meta`, {
params: { params: {
inbox_id: inboxId, inbox_id: inboxId,
@ -74,6 +91,7 @@ class ConversationApi extends ApiClient {
assignee_type: assigneeType, assignee_type: assigneeType,
labels, labels,
team_id: teamId, team_id: teamId,
conversation_type: conversationType,
}, },
}); });
} }

View File

@ -11,6 +11,7 @@ describe('#ContactsAPI', () => {
expect(contactAPI).toHaveProperty('update'); expect(contactAPI).toHaveProperty('update');
expect(contactAPI).toHaveProperty('delete'); expect(contactAPI).toHaveProperty('delete');
expect(contactAPI).toHaveProperty('getConversations'); expect(contactAPI).toHaveProperty('getConversations');
expect(contactAPI).toHaveProperty('filter');
}); });
describeWithAPIMock('API calls', context => { describeWithAPIMock('API calls', context => {
@ -81,6 +82,24 @@ describe('#ContactsAPI', () => {
} }
); );
}); });
it('#filter', () => {
const queryPayload = {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: ['fayaz'],
query_operator: null,
},
],
};
contactAPI.filter(1, 'name', queryPayload);
expect(context.axiosMock.post).toHaveBeenCalledWith(
'/api/v1/contacts/filter?include_contact_inboxes=false&page=1&sort=name',
queryPayload
);
});
}); });
}); });

View File

@ -19,6 +19,7 @@ describe('#ConversationAPI', () => {
expect(conversationAPI).toHaveProperty('unmute'); expect(conversationAPI).toHaveProperty('unmute');
expect(conversationAPI).toHaveProperty('meta'); expect(conversationAPI).toHaveProperty('meta');
expect(conversationAPI).toHaveProperty('sendEmailTranscript'); expect(conversationAPI).toHaveProperty('sendEmailTranscript');
expect(conversationAPI).toHaveProperty('filter');
}); });
describeWithAPIMock('API calls', context => { describeWithAPIMock('API calls', context => {
@ -173,5 +174,41 @@ describe('#ConversationAPI', () => {
} }
); );
}); });
it('#filter', () => {
const payload = {
page: 1,
queryData: {
payload: [
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: ['pending', 'resolved'],
query_operator: 'and',
},
{
attribute_key: 'assignee',
filter_operator: 'equal_to',
values: [3],
query_operator: 'and',
},
{
attribute_key: 'id',
filter_operator: 'equal_to',
values: ['This is a test'],
query_operator: null,
},
],
},
};
conversationAPI.filter(payload);
expect(
context.axiosMock.post
).toHaveBeenCalledWith(
'/api/v1/conversations/filter',
payload.queryData,
{ params: { page: payload.page } }
);
});
}); });
}); });

View File

@ -1,4 +1,3 @@
/* Enter and leave animations can use different */ /* Enter and leave animations can use different */
/* durations and timing functions. */ /* durations and timing functions. */
.slide-fade-enter-active { .slide-fade-enter-active {
@ -9,7 +8,8 @@
transition: all .3s $ease-out-cubic; transition: all .3s $ease-out-cubic;
} }
.slide-fade-enter, .slide-fade-leave-to { .slide-fade-enter,
.slide-fade-leave-to {
opacity: 0; opacity: 0;
transform: translateX(10px); transform: translateX(10px);
} }
@ -22,22 +22,33 @@
transform: translateX($space-medium); transform: translateX($space-medium);
} }
.conversations-list-enter-active, .conversations-list-leave-active { .conversations-list-enter-active,
.conversations-list-leave-active {
transition: all .25s $ease-out-cubic; transition: all .25s $ease-out-cubic;
} }
.conversations-list-enter, .conversations-list-leave-to /* .conversations-list-leave-active for <2.1.8 */ { .conversations-list-enter,
.conversations-list-leave-to {
opacity: 0; opacity: 0;
transform: translateX($space-medium); transform: translateX($space-medium);
} }
.menu-list-enter-active, .menu-list-leave-active { .menu-list-enter-active,
transition: all .2s $ease-out-cubic; .menu-list-leave-active {
transition: opacity .3s $ease-out-cubic,
transform .2s $ease-out-cubic;
} }
.menu-list-enter, .menu-list-leave-to /* .conversations-list-leave-active for <2.1.8 */ {
.menu-list-leave-to {
opacity: 0; opacity: 0;
transform: translateX($space-medium); position: absolute;
transform: translateX($space-small);
}
.menu-list-enter {
opacity: 0;
transform: translateX(-$space-small);
} }
.slide-up-enter-active { .slide-up-enter-active {
@ -48,8 +59,8 @@
transition: all .3s $ease-out-cubic; transition: all .3s $ease-out-cubic;
} }
.slide-up-enter, .slide-up-leave-to .slide-up-enter,
/* .slide-fade-leave-active for <2.1.8 */ { .slide-up-leave-to {
transform: translateY(-$space-medium); transform: translateY(-$space-medium);
opacity: 0; opacity: 0;
} }
@ -60,10 +71,10 @@
transition: transform 0.25s $ease-in-cubic, opacity 0.15s $ease-in-cubic; transition: transform 0.25s $ease-in-cubic, opacity 0.15s $ease-in-cubic;
} }
.menu-slide-enter, .menu-slide-leave-to .menu-slide-enter,
/* .slide-fade-leave-active for <2.1.8 */ { .menu-slide-leave-to {
transform: translateY($space-small);
opacity: 0; opacity: 0;
transform: translateY($space-small);
} }
@ -75,10 +86,10 @@
transition: all .1s $ease-out-sine; transition: all .1s $ease-out-sine;
} }
.toast-fade-enter, .toast-fade-leave-to .toast-fade-enter,
/* .toast-fade-leave-active for <2.1.8 */ { .toast-fade-leave-to {
transform: translateY(-$space-small);
opacity: 0; opacity: 0;
transform: translateY(-$space-small);
} }
.modal-fade-enter-active { .modal-fade-enter-active {
@ -89,8 +100,8 @@
transition: all .1s $ease-out-sine; transition: all .1s $ease-out-sine;
} }
.modal-fade-enter, .modal-fade-leave-to .modal-fade-enter,
/* .slide-fade-leave-active for <2.1.8 */ { .modal-fade-leave-to {
opacity: 0; opacity: 0;
} }

View File

@ -49,7 +49,21 @@ code {
.cursor-pointer { .cursor-pointer {
cursor: pointer; cursor: pointer;
} }
// remove when grid gutters are fixed // remove when grid gutters are fixed
.columns.with-right-space { .columns.with-right-space {
padding-right: var(--space-normal); padding-right: var(--space-normal);
} }
.badge {
border-radius: var(--border-radius-normal);
}
.padding-right-small {
padding-right: var(--space-one);
}
.margin-right-small {
margin-right: var(--space-small);
}

View File

@ -219,9 +219,9 @@ $badge-background: $primary-color;
$badge-color: $white; $badge-color: $white;
$badge-color-alt: $black; $badge-color-alt: $black;
$badge-palette: $foundation-palette; $badge-palette: $foundation-palette;
$badge-padding: 0.3em; $badge-padding: var(--space-smaller);
$badge-minwidth: 2.1em; $badge-minwidth: 2.1em;
$badge-font-size: 0.6rem; $badge-font-size: var(--font-size-nano);
// 10. Breadcrumbs // 10. Breadcrumbs
// --------------- // ---------------
@ -400,7 +400,7 @@ $mediaobject-image-width-stacked: 100%;
$menu-margin: 0; $menu-margin: 0;
$menu-margin-nested: $space-medium; $menu-margin-nested: $space-medium;
$menu-item-padding: $space-one; $menu-item-padding: $space-slab;
$menu-item-color-active: $white; $menu-item-color-active: $white;
$menu-item-background-active: $color-background; $menu-item-background-active: $color-background;
$menu-icon-spacing: 0.25rem; $menu-icon-spacing: 0.25rem;

View File

@ -6,6 +6,10 @@
margin-right: var(--space-smaller); margin-right: var(--space-smaller);
} }
.margin-left-minus-slab {
margin-left: var(--space-minus-slab);
}
.fs-small { .fs-small {
font-size: var(--font-size-small); font-size: var(--font-size-small);
} }

View File

@ -44,11 +44,14 @@ $woot-logo-padding: $space-large $space-two;
$color-woot: #1f93ff; $color-woot: #1f93ff;
$color-gray: #6e6f73; $color-gray: #6e6f73;
$color-light-gray: #999a9b; $color-light-gray: #999a9b;
$color-border: #e0e6ed;
$color-border-light: #f0f4f5; $color-border: var(--s-75);
$color-border-dark: #cad0d4; $color-border-light: var(--s-50);
$color-background: #f4f6fb; $color-border-dark: var(--s-100);
$color-background-light: #f9fafc;
$color-background: var(--s-50);
$color-background-light: var(--s-25);
$color-white: #fff; $color-white: #fff;
$color-body: #3c4858; $color-body: #3c4858;
$color-heading: #1f2d3d; $color-heading: #1f2d3d;

View File

@ -2,8 +2,9 @@
@include elegant-card; @include elegant-card;
@include border-light; @include border-light;
box-sizing: content-box; box-sizing: content-box;
padding: var(--space-small);
width: fit-content; width: fit-content;
z-index: 999; z-index: var(--z-index-very-high);
&.dropdown-pane--open { &.dropdown-pane--open {
display: block; display: block;

View File

@ -8,7 +8,7 @@
@include background-white; @include background-white;
@include flex; @include flex;
@include flex-align($x: justify, $y: middle); @include flex-align($x: justify, $y: middle);
@include border-normal-bottom; border-bottom: 1px solid var(--s-50);
height: $header-height; height: $header-height;
min-height: $header-height; min-height: $header-height;

View File

@ -25,6 +25,7 @@ $default-button-height: 4.0rem;
// @TODDO - Remove after moving all buttons to woot-button // @TODDO - Remove after moving all buttons to woot-button
.icon+.button__content { .icon+.button__content {
padding-left: var(--space-small); padding-left: var(--space-small);
width: auto;
} }
&.expanded { &.expanded {
@ -103,7 +104,6 @@ $default-button-height: 4.0rem;
padding: 0; padding: 0;
} }
} }

View File

@ -85,11 +85,6 @@
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
width: 27rem; width: 27rem;
.small-icon {
font-size: $font-size-mini;
vertical-align: top;
}
} }
.conversation--meta { .conversation--meta {

View File

@ -93,7 +93,7 @@
.status--filter { .status--filter {
@include padding($zero null $zero $space-normal); @include padding($zero null $zero $space-normal);
@include margin($space-smaller $space-slab $zero $zero); @include margin($zero);
background-color: $color-background-light; background-color: $color-background-light;
border: 1px solid $color-border; border: 1px solid $color-border;
float: right; float: right;

View File

@ -19,7 +19,7 @@
cursor: pointer; cursor: pointer;
font-size: $font-size-big; font-size: $font-size-big;
line-height: $space-normal; line-height: $space-normal;
padding: $space-normal $space-two; padding: $space-normal;
position: absolute; position: absolute;
right: $space-micro; right: $space-micro;
top: $space-micro; top: $space-micro;
@ -29,7 +29,6 @@
} }
} }
.page-top-bar { .page-top-bar {
@include padding($space-large $space-large $zero); @include padding($space-large $space-large $zero);
@ -48,13 +47,16 @@
position: relative; position: relative;
width: 60rem; width: 60rem;
&.medium {
max-width: 80%;
width: 90rem;
}
.content-box { .content-box {
@include padding($zero); @include padding($zero);
height: auto; height: auto;
} }
h2 { h2 {
color: $color-heading; color: $color-heading;
font-size: $font-size-medium; font-size: $font-size-medium;
@ -89,15 +91,19 @@
button { button {
font-size: $font-size-small; font-size: $font-size-small;
} }
&.justify-content-end {
justify-content: end;
}
} }
.delete-item { .delete-item {
@include padding($space-large); @include padding($space-large);
button { button {
@include margin($zero); @include margin($zero);
} }
} }
} }
.modal-enter, .modal-enter,

View File

@ -6,12 +6,6 @@
} }
.sidebar { .sidebar {
@include border-normal-right;
@include background-white;
@include full-height;
@include margin(0);
@include space-between-column;
width: $nav-bar-width;
z-index: 1024 - 1; z-index: 1024 - 1;
//logo //logo
@ -22,26 +16,6 @@
} }
} }
.main-nav {
a {
border-radius: $space-smaller;
color: $color-gray;
font-size: $font-size-default;
font-weight: $font-weight-medium;
.wrap,
.child-icon {
&:hover {
color: $color-woot;
}
}
}
.active a .wrap {
color: $color-woot;
}
}
.nested { .nested {
a { a {
font-size: $font-size-small; font-size: $font-size-small;
@ -83,34 +57,6 @@
} }
} }
.main-nav {
@include flex-weight(1);
@include scroll-on-hover;
padding: 0 $space-medium - $space-one;
a {
&::before {
margin-right: $space-slab;
}
}
.menu-title {
color: $color-gray;
font-size: $font-size-medium;
margin-top: $space-medium;
>span {
margin-left: $space-one;
}
}
}
.menu-title+ul>li>a {
@include padding($space-micro null);
color: $medium-gray;
line-height: $global-lineheight;
}
.hamburger--menu { .hamburger--menu {
cursor: pointer; cursor: pointer;
display: none; display: none;

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="cw-accordion"> <div class="cw-accordion">
<button class="cw-accordion--title" @click="$emit('click')"> <button class="cw-accordion--title drag-handle" @click="$emit('click')">
<div class="cw-accordion--title-wrap"> <div class="cw-accordion--title-wrap">
<emoji-or-icon class="icon-or-emoji" :icon="icon" :emoji="emoji" /> <emoji-or-icon class="icon-or-emoji" :icon="icon" :emoji="emoji" />
<h5> <h5>
@ -10,8 +10,8 @@
<div class="button-icon--wrap"> <div class="button-icon--wrap">
<slot name="button" /> <slot name="button" />
<div class="chevron-icon__wrap"> <div class="chevron-icon__wrap">
<i v-if="isOpen" class="ion-minus chevron-icon"></i> <fluent-icon v-if="isOpen" size="24" icon="subtract" type="solid" />
<i v-else class="ion-plus chevron-icon"></i> <fluent-icon v-else size="24" icon="add" type="solid" />
</div> </div>
</div> </div>
</button> </button>
@ -66,10 +66,10 @@ export default {
} }
.cw-accordion--title { .cw-accordion--title {
align-items: center; align-items: center;
background: var(--b-50); background: var(--s-50);
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--s-100);
border-top: 1px solid var(--color-border); border-top: 1px solid var(--s-100);
cursor: pointer; cursor: grab;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
margin: 0; margin: 0;

View File

@ -1,14 +1,39 @@
<template> l<template>
<div class="conversations-list-wrap"> <div class="conversations-list-wrap">
<slot></slot> <slot></slot>
<div class="chat-list__top"> <div class="chat-list__top" :class="{ filter__applied: hasAppliedFilters }">
<h1 class="page-title text-truncate" :title="pageTitle"> <h1 class="page-title text-truncate" :title="pageTitle">
{{ pageTitle }} {{ pageTitle }}
</h1> </h1>
<chat-filter @statusFilterChange="updateStatusType" />
<div class="filter--actions">
<chat-filter
v-if="!hasAppliedFilters"
@statusFilterChange="updateStatusType"
/>
<woot-button
v-else
variant="clear"
color-scheme="danger"
class="btn-clear-filters"
@click="resetAndFetchData"
>
{{ $t('FILTER.CLEAR_BUTTON_LABEL') }}
</woot-button>
<woot-button
v-tooltip.top-end="$t('FILTER.TOOLTIP_LABEL')"
variant="clear"
color-scheme="secondary"
class="btn-filter"
@click="onToggleAdvanceFiltersModal"
>
<fluent-icon icon="filter" />
</woot-button>
</div>
</div> </div>
<chat-type-tabs <chat-type-tabs
v-if="!hasAppliedFilters"
:items="assigneeTabItems" :items="assigneeTabItems"
:active-tab="activeAssigneeTab" :active-tab="activeAssigneeTab"
class="tab--chat-type" class="tab--chat-type"
@ -26,6 +51,7 @@
:active-label="label" :active-label="label"
:team-id="teamId" :team-id="teamId"
:chat="chat" :chat="chat"
:conversation-type="conversationType"
:show-assignee="showAssigneeInConversationCard" :show-assignee="showAssigneeInConversationCard"
/> />
@ -37,7 +63,7 @@
v-if="!hasCurrentPageEndReached && !chatListLoading" v-if="!hasCurrentPageEndReached && !chatListLoading"
variant="clear" variant="clear"
size="expanded" size="expanded"
@click="fetchConversations" @click="loadMoreConversations"
> >
{{ $t('CHAT_LIST.LOAD_MORE_CONVERSATIONS') }} {{ $t('CHAT_LIST.LOAD_MORE_CONVERSATIONS') }}
</woot-button> </woot-button>
@ -53,6 +79,18 @@
{{ $t('CHAT_LIST.EOF') }} {{ $t('CHAT_LIST.EOF') }}
</p> </p>
</div> </div>
<woot-modal
:show.sync="showAdvancedFilters"
:on-close="onToggleAdvanceFiltersModal"
size="medium"
>
<conversation-advanced-filter
v-if="showAdvancedFilters"
:filter-types="advancedFilterTypes"
:on-close="onToggleAdvanceFiltersModal"
@applyFilter="onApplyFilter"
/>
</woot-modal>
</div> </div>
</template> </template>
@ -60,12 +98,16 @@
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import ChatFilter from './widgets/conversation/ChatFilter'; import ChatFilter from './widgets/conversation/ChatFilter';
import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter';
import ChatTypeTabs from './widgets/ChatTypeTabs'; import ChatTypeTabs from './widgets/ChatTypeTabs';
import ConversationCard from './widgets/conversation/ConversationCard'; import ConversationCard from './widgets/conversation/ConversationCard';
import timeMixin from '../mixins/time'; import timeMixin from '../mixins/time';
import eventListenerMixins from 'shared/mixins/eventListenerMixins'; import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import conversationMixin from '../mixins/conversations'; import conversationMixin from '../mixins/conversations';
import wootConstants from '../constants'; import wootConstants from '../constants';
import advancedFilterTypes from './widgets/conversation/advancedFilterItems';
import filterQueryGenerator from '../helper/filterQueryGenerator.js';
import { import {
hasPressedAltAndJKey, hasPressedAltAndJKey,
hasPressedAltAndKKey, hasPressedAltAndKKey,
@ -76,6 +118,7 @@ export default {
ChatTypeTabs, ChatTypeTabs,
ConversationCard, ConversationCard,
ChatFilter, ChatFilter,
ConversationAdvancedFilter,
}, },
mixins: [timeMixin, conversationMixin, eventListenerMixins], mixins: [timeMixin, conversationMixin, eventListenerMixins],
props: { props: {
@ -91,11 +134,20 @@ export default {
type: String, type: String,
default: '', default: '',
}, },
conversationType: {
type: String,
default: '',
},
}, },
data() { data() {
return { return {
activeAssigneeTab: wootConstants.ASSIGNEE_TYPE.ME, activeAssigneeTab: wootConstants.ASSIGNEE_TYPE.ME,
activeStatus: wootConstants.STATUS_TYPE.OPEN, activeStatus: wootConstants.STATUS_TYPE.OPEN,
showAdvancedFilters: false,
advancedFilterTypes: advancedFilterTypes.map(filter => ({
...filter,
attributeName: this.$t(`FILTER.ATTRIBUTES.${filter.attributeI18nKey}`),
})),
}; };
}, },
computed: { computed: {
@ -109,7 +161,11 @@ export default {
currentUserID: 'getCurrentUserID', currentUserID: 'getCurrentUserID',
activeInbox: 'getSelectedInbox', activeInbox: 'getSelectedInbox',
conversationStats: 'conversationStats/getStats', conversationStats: 'conversationStats/getStats',
appliedFilters: 'getAppliedConversationFilters',
}), }),
hasAppliedFilters() {
return this.appliedFilters.length;
},
assigneeTabItems() { assigneeTabItems() {
return this.$t('CHAT_LIST.ASSIGNEE_TYPE_TABS').map(item => { return this.$t('CHAT_LIST.ASSIGNEE_TYPE_TABS').map(item => {
const count = this.conversationStats[item.COUNT_KEY] || 0; const count = this.conversationStats[item.COUNT_KEY] || 0;
@ -131,9 +187,17 @@ export default {
this.activeAssigneeTab this.activeAssigneeTab
); );
}, },
currentPageFilterKey() {
return this.hasAppliedFilters ? 'appliedFilters' : this.activeAssigneeTab;
},
currentFiltersPage() {
return this.$store.getters['conversationPage/getCurrentPageFilter'](
this.currentPageFilterKey
);
},
hasCurrentPageEndReached() { hasCurrentPageEndReached() {
return this.$store.getters['conversationPage/getHasEndReached']( return this.$store.getters['conversationPage/getHasEndReached'](
this.activeAssigneeTab this.currentPageFilterKey
); );
}, },
conversationFilters() { conversationFilters() {
@ -144,6 +208,9 @@ export default {
page: this.currentPage + 1, page: this.currentPage + 1,
labels: this.label ? [this.label] : undefined, labels: this.label ? [this.label] : undefined,
teamId: this.teamId ? this.teamId : undefined, teamId: this.teamId ? this.teamId : undefined,
conversationType: this.conversationType
? this.conversationType
: undefined,
}; };
}, },
pageTitle() { pageTitle() {
@ -156,17 +223,24 @@ export default {
if (this.label) { if (this.label) {
return `#${this.label}`; return `#${this.label}`;
} }
if (this.conversationType === 'mention') {
return this.$t('CHAT_LIST.MENTION_HEADING');
}
return this.$t('CHAT_LIST.TAB_HEADING'); return this.$t('CHAT_LIST.TAB_HEADING');
}, },
conversationList() { conversationList() {
let conversationList = []; let conversationList = [];
const filters = this.conversationFilters; if (!this.hasAppliedFilters) {
if (this.activeAssigneeTab === 'me') { const filters = this.conversationFilters;
conversationList = [...this.mineChatsList(filters)]; if (this.activeAssigneeTab === 'me') {
} else if (this.activeAssigneeTab === 'unassigned') { conversationList = [...this.mineChatsList(filters)];
conversationList = [...this.unAssignedChatsList(filters)]; } else if (this.activeAssigneeTab === 'unassigned') {
conversationList = [...this.unAssignedChatsList(filters)];
} else {
conversationList = [...this.allChatList(filters)];
}
} else { } else {
conversationList = [...this.allChatList(filters)]; conversationList = [...this.chatLists];
} }
return conversationList; return conversationList;
@ -188,6 +262,9 @@ export default {
label() { label() {
this.resetAndFetchData(); this.resetAndFetchData();
}, },
conversationType() {
this.resetAndFetchData();
},
}, },
mounted() { mounted() {
this.$store.dispatch('setChatFilter', this.activeStatus); this.$store.dispatch('setChatFilter', this.activeStatus);
@ -198,6 +275,17 @@ export default {
}); });
}, },
methods: { methods: {
onApplyFilter(payload) {
if (this.$route.name !== 'home') {
this.$router.push({ name: 'home' });
}
this.$store.dispatch('conversationPage/reset');
this.$store.dispatch('emptyAllConversations');
this.fetchFilteredConversations(payload);
},
onToggleAdvanceFiltersModal() {
this.showAdvancedFilters = !this.showAdvancedFilters;
},
getKeyboardListenerParams() { getKeyboardListenerParams() {
const allConversations = this.$refs.activeConversation.querySelectorAll( const allConversations = this.$refs.activeConversation.querySelectorAll(
'div.conversations-list div.conversation' 'div.conversations-list div.conversation'
@ -245,6 +333,7 @@ export default {
resetAndFetchData() { resetAndFetchData() {
this.$store.dispatch('conversationPage/reset'); this.$store.dispatch('conversationPage/reset');
this.$store.dispatch('emptyAllConversations'); this.$store.dispatch('emptyAllConversations');
this.$store.dispatch('clearConversationFilters');
this.fetchConversations(); this.fetchConversations();
}, },
fetchConversations() { fetchConversations() {
@ -252,6 +341,23 @@ export default {
.dispatch('fetchAllConversations', this.conversationFilters) .dispatch('fetchAllConversations', this.conversationFilters)
.then(() => this.$emit('conversation-load')); .then(() => this.$emit('conversation-load'));
}, },
loadMoreConversations() {
if (!this.hasAppliedFilters) {
this.fetchConversations();
} else {
this.fetchFilteredConversations(this.appliedFilters);
}
},
fetchFilteredConversations(payload) {
let page = this.currentFiltersPage + 1;
this.$store
.dispatch('fetchFilteredConversations', {
queryData: filterQueryGenerator(payload),
page,
})
.then(() => this.$emit('conversation-load'));
this.showAdvancedFilters = false;
},
updateAssigneeTab(selectedTab) { updateAssigneeTab(selectedTab) {
if (this.activeAssigneeTab !== selectedTab) { if (this.activeAssigneeTab !== selectedTab) {
bus.$emit('clearSearchInput'); bus.$emit('clearSearchInput');
@ -273,6 +379,7 @@ export default {
<style scoped lang="scss"> <style scoped lang="scss">
@import '~dashboard/assets/scss/woot'; @import '~dashboard/assets/scss/woot';
.spinner { .spinner {
margin-top: var(--space-normal); margin-top: var(--space-normal);
margin-bottom: var(--space-normal); margin-bottom: var(--space-normal);
@ -295,4 +402,23 @@ export default {
flex-basis: 46rem; flex-basis: 46rem;
} }
} }
.filter--actions {
display: flex;
align-items: center;
.btn-filter {
cursor: pointer;
i {
font-size: var(--font-size-two);
}
}
.btn-clear-filters {
color: var(--r-500);
cursor: pointer;
}
}
.filter__applied {
padding: var(--space-slab) 0 !important;
border-bottom: 1px solid var(--color-border);
}
</style> </style>

View File

@ -2,77 +2,113 @@
<div class="custom-attribute"> <div class="custom-attribute">
<div class="title-wrap"> <div class="title-wrap">
<h4 class="text-block-title title error"> <h4 class="text-block-title title error">
<span class="attribute-name" :class="{ error: $v.editedValue.$error }"> <div v-if="isAttributeTypeCheckbox" class="checkbox-wrap">
{{ label }} <input
</span> v-model="editedValue"
class="checkbox"
type="checkbox"
@change="onUpdate"
/>
</div>
<div class="name-button__wrap">
<span
class="attribute-name"
:class="{ error: $v.editedValue.$error }"
>
{{ label }}
</span>
<woot-button
v-if="showActions"
v-tooltip.left="$t('CUSTOM_ATTRIBUTES.ACTIONS.DELETE')"
variant="link"
size="medium"
color-scheme="secondary"
icon="delete"
class-names="delete-button"
@click="onDelete"
/>
</div>
</h4> </h4>
</div> </div>
<div v-show="isEditing"> <div v-if="notAttributeTypeCheckboxAndList">
<div class="input-group small"> <div v-show="isEditing">
<input <div class="input-group small">
ref="inputfield" <input
v-model="editedValue" ref="inputfield"
:type="inputType" v-model="editedValue"
class="input-group-field" :type="inputType"
autofocus="true" class="input-group-field"
:class="{ error: $v.editedValue.$error }" autofocus="true"
@blur="$v.editedValue.$touch" :class="{ error: $v.editedValue.$error }"
@keyup.enter="onUpdate" @blur="$v.editedValue.$touch"
/> @keyup.enter="onUpdate"
<div class="input-group-button"> />
<woot-button size="small" icon="ion-checkmark" @click="onUpdate" /> <div class="input-group-button">
<woot-button size="small" icon="checkmark" @click="onUpdate" />
</div>
</div>
<span v-if="shouldShowErrorMessage" class="error-message">
{{ errorMessage }}
</span>
</div>
<div
v-show="!isEditing"
class="value--view"
:class="{ 'is-editable': showActions }"
>
<a
v-if="isAttributeTypeLink"
:href="value"
target="_blank"
rel="noopener noreferrer"
class="value"
>
{{ value || '---' }}
</a>
<p v-else class="value">
{{ displayValue || '---' }}
</p>
<div class="action-buttons__wrap">
<woot-button
v-if="showActions"
v-tooltip="$t('CUSTOM_ATTRIBUTES.ACTIONS.COPY')"
variant="link"
size="small"
color-scheme="secondary"
icon="clipboard"
class-names="edit-button"
@click="onCopy"
/>
<woot-button
v-if="showActions"
v-tooltip.right="$t('CUSTOM_ATTRIBUTES.ACTIONS.EDIT')"
variant="link"
size="small"
color-scheme="secondary"
icon="edit"
class-names="edit-button"
@click="onEdit"
/>
</div> </div>
</div> </div>
<span v-if="shouldShowErrorMessage" class="error-message">
{{ errorMessage }}
</span>
</div> </div>
<div <div v-if="isAttributeTypeList">
v-show="!isEditing" <multiselect-dropdown
class="value--view" :options="listOptions"
:class="{ 'is-editable': showActions }" :selected-item="selectedItem"
> :has-thumbnail="false"
<a :multiselector-placeholder="
v-if="isAttributeTypeLink" $t('CUSTOM_ATTRIBUTES.FORM.ATTRIBUTE_TYPE.LIST.PLACEHOLDER')
:href="value" "
target="_blank" :no-search-result="
rel="noopener noreferrer" $t('CUSTOM_ATTRIBUTES.FORM.ATTRIBUTE_TYPE.LIST.NO_RESULT')
class="value" "
> :input-placeholder="
{{ value || '---' }} $t(
</a> 'CUSTOM_ATTRIBUTES.FORM.ATTRIBUTE_TYPE.LIST.SEARCH_INPUT_PLACEHOLDER'
<p v-else class="value"> )
{{ formattedValue || '---' }} "
</p> @click="onUpdateListValue"
<woot-button
v-if="showActions"
v-tooltip="$t('CUSTOM_ATTRIBUTES.ACTIONS.COPY')"
variant="link"
size="small"
color-scheme="secondary"
icon="ion-clipboard"
class-names="edit-button"
@click="onCopy"
/>
<woot-button
v-if="showActions"
v-tooltip="$t('CUSTOM_ATTRIBUTES.ACTIONS.EDIT')"
variant="link"
size="small"
color-scheme="secondary"
icon="ion-compose"
class-names="edit-button"
@click="onEdit"
/>
<woot-button
v-if="showActions"
v-tooltip="$t('CUSTOM_ATTRIBUTES.ACTIONS.DELETE')"
variant="link"
size="small"
color-scheme="secondary"
icon="ion-trash-a"
class-names="edit-button"
@click="onDelete"
/> />
</div> </div>
</div> </div>
@ -82,13 +118,18 @@
import format from 'date-fns/format'; import format from 'date-fns/format';
import { required, url } from 'vuelidate/lib/validators'; import { required, url } from 'vuelidate/lib/validators';
import { BUS_EVENTS } from 'shared/constants/busEvents'; import { BUS_EVENTS } from 'shared/constants/busEvents';
import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown.vue';
const DATE_FORMAT = 'yyyy-MM-dd'; const DATE_FORMAT = 'yyyy-MM-dd';
export default { export default {
components: {
MultiselectDropdown,
},
props: { props: {
label: { type: String, required: true }, label: { type: String, required: true },
value: { type: [String, Number], default: '' }, values: { type: Array, default: () => [] },
value: { type: [String, Number, Boolean], default: '' },
showActions: { type: Boolean, default: false }, showActions: { type: Boolean, default: false },
attributeType: { type: String, default: 'text' }, attributeType: { type: String, default: 'text' },
attributeKey: { type: String, required: true }, attributeKey: { type: String, required: true },
@ -97,10 +138,7 @@ export default {
data() { data() {
return { return {
isEditing: false, isEditing: false,
editedValue: editedValue: null,
this.attributeType === 'date'
? format(new Date(this.value || new Date()), DATE_FORMAT)
: this.value,
}; };
}, },
validations() { validations() {
@ -115,9 +153,40 @@ export default {
}, },
computed: { computed: {
formattedValue() {
if (this.isAttributeTypeDate) {
return format(new Date(this.value || new Date()), DATE_FORMAT);
}
if (this.isAttributeTypeCheckbox) {
return this.value === 'false' ? false : this.value;
}
return this.value;
},
listOptions() {
return this.values.map((value, index) => ({
id: index + 1,
name: value,
}));
},
selectedItem() {
const id = this.values.indexOf(this.editedValue) + 1;
return { id, name: this.editedValue };
},
isAttributeTypeCheckbox() {
return this.attributeType === 'checkbox';
},
isAttributeTypeList() {
return this.attributeType === 'list';
},
isAttributeTypeLink() { isAttributeTypeLink() {
return this.attributeType === 'link'; return this.attributeType === 'link';
}, },
isAttributeTypeDate() {
return this.attributeType === 'date';
},
notAttributeTypeCheckboxAndList() {
return !this.isAttributeTypeCheckbox && !this.isAttributeTypeList;
},
inputType() { inputType() {
return this.isAttributeTypeLink ? 'url' : this.attributeType; return this.isAttributeTypeLink ? 'url' : this.attributeType;
}, },
@ -130,7 +199,7 @@ export default {
} }
return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.REQUIRED'); return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.REQUIRED');
}, },
formattedValue() { displayValue() {
if (this.attributeType === 'date') { if (this.attributeType === 'date') {
return format(new Date(this.editedValue), 'dd-MM-yyyy'); return format(new Date(this.editedValue), 'dd-MM-yyyy');
} }
@ -138,6 +207,7 @@ export default {
}, },
}, },
mounted() { mounted() {
this.editedValue = this.formattedValue;
bus.$on(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, focusAttributeKey => { bus.$on(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, focusAttributeKey => {
if (this.attributeKey === focusAttributeKey) { if (this.attributeKey === focusAttributeKey) {
this.onEdit(); this.onEdit();
@ -156,6 +226,12 @@ export default {
this.focusInput(); this.focusInput();
}); });
}, },
onUpdateListValue(value) {
if (value) {
this.editedValue = value.name;
this.onUpdate();
}
},
onUpdate() { onUpdate() {
const updatedValue = const updatedValue =
this.attributeType === 'date' this.attributeType === 'date'
@ -194,8 +270,23 @@ export default {
display: flex; display: flex;
align-items: center; align-items: center;
margin: 0; margin: 0;
width: 100%;
}
.checkbox-wrap {
display: flex;
align-items: center;
}
.checkbox {
margin: 0 var(--space-small) 0 0;
}
.name-button__wrap {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
} }
.attribute-name { .attribute-name {
width: 100%;
&.error { &.error {
color: var(--r-400); color: var(--r-400);
} }
@ -206,22 +297,34 @@ export default {
.edit-button { .edit-button {
display: none; display: none;
} }
.delete-button {
display: flex;
justify-content: flex-end;
width: var(--space-normal);
}
.value--view { .value--view {
display: flex; display: flex;
&.is-editable:hover { &.is-editable:hover {
.value { .value {
background: var(--color-background); background: var(--color-background);
margin-bottom: 0;
} }
.edit-button { .edit-button {
display: block; display: block;
} }
} }
.action-buttons__wrap {
display: flex;
max-width: var(--space-larger);
}
} }
.value { .value {
display: inline-block; display: inline-block;
min-width: var(--space-mega); min-width: var(--space-mega);
border-radius: var(--border-radius-small); border-radius: var(--border-radius-small);
margin-bottom: 0;
word-break: break-all; word-break: break-all;
padding: var(--space-micro) var(--space-smaller); padding: var(--space-micro) var(--space-smaller);
} }
@ -235,4 +338,17 @@ export default {
margin-top: -1.6rem; margin-top: -1.6rem;
width: 100%; width: 100%;
} }
::v-deep {
.selector-wrap {
margin: 0;
top: var(--space-smaller);
.selector-name {
margin-left: 0;
}
}
.name {
margin-left: 0;
}
}
</style> </style>

View File

@ -7,7 +7,9 @@
@click="onBackDropClick" @click="onBackDropClick"
> >
<div :class="modalContainerClassName" @click.stop> <div :class="modalContainerClassName" @click.stop>
<i class="ion-android-close modal--close" @click="close"></i> <button class="modal--close" @click="close">
<fluent-icon icon="dismiss" />
</button>
<slot /> <slot />
</div> </div>
</div> </div>
@ -34,6 +36,10 @@ export default {
type: String, type: String,
default: 'centered', default: 'centered',
}, },
size: {
type: String,
default: '',
},
}, },
computed: { computed: {
modalContainerClassName() { modalContainerClassName() {
@ -41,7 +47,8 @@ export default {
if (this.fullWidth) { if (this.fullWidth) {
return `${className} modal-container--full-width`; return `${className} modal-container--full-width`;
} }
return className;
return `${className} ${this.size}`;
}, },
modalClassName() { modalClassName() {
const modalClassNameMap = { const modalClassNameMap = {

View File

@ -2,29 +2,21 @@
<transition name="network-notification-fade" tag="div"> <transition name="network-notification-fade" tag="div">
<div v-show="showNotification" class="ui-notification-container"> <div v-show="showNotification" class="ui-notification-container">
<div class="ui-notification"> <div class="ui-notification">
<svg <fluent-icon icon="wifi-off" />
class="ui-notification-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414"
/>
</svg>
<p class="ui-notification-text"> <p class="ui-notification-text">
{{ $t('NETWORK.NOTIFICATION.TEXT') }} {{ $t('NETWORK.NOTIFICATION.TEXT') }}
</p> </p>
<button class="ui-refresh-button" @click="refreshPage"> <woot-button variant="clear" size="small" @click="refreshPage">
{{ $t('NETWORK.BUTTON.REFRESH') }} {{ $t('NETWORK.BUTTON.REFRESH') }}
</button> </woot-button>
<button class="ui-close-button" @click="closeNotification"> <woot-button
<i class="ui-close-icon icon ion-ios-close-outline" /> variant="clear"
</button> size="small"
color-scheme="secondary"
icon="dismiss-circle"
@click="closeNotification"
>
</woot-button>
</div> </div>
</div> </div>
</transition> </transition>
@ -72,52 +64,24 @@ export default {
position: absolute; position: absolute;
right: var(--space-normal); right: var(--space-normal);
top: var(--space-normal); top: var(--space-normal);
width: 100%; z-index: var(--z-index-very-high);
z-index: 9999;
} }
.ui-notification { .ui-notification {
@include shadow;
align-items: center;
background-color: var(--white);
border: 1px solid var(--color-border);
border-radius: var(--space-one);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
max-width: 40rem; align-items: center;
min-height: 3rem; width: 100%;
background-color: var(--y-100);
border-radius: var(--border-radius-medium);
box-shadow: var(--shadow-large);
min-width: 24rem; min-width: 24rem;
padding: var(--space-normal) var(--space-two); padding: var(--space-normal);
text-align: left;
} }
.ui-notification-text { .ui-notification-text {
margin: 0; margin: 0 var(--space-small);
}
.ui-refresh-button {
color: var(--color-woot);
font-size: var(--font-size-small);
font-weight: bold;
&:hover {
cursor: pointer;
}
}
.ui-notification-icon {
color: var(--b-600);
width: var(--font-size-mega);
}
.ui-close-icon {
color: var(--b-600);
font-size: var(--font-size-large);
}
.ui-close-button {
&:hover {
cursor: pointer;
}
} }
</style> </style>

View File

@ -1,18 +1,28 @@
<template> <template>
<i class="ion-android-menu hamburger--menu" @click="onMenuItemClick" /> <button @click="onMenuItemClick">
<fluent-icon class="hamburger--menu" icon="list" />
</button>
</template> </template>
<script> <script>
import { BUS_EVENTS } from 'shared/constants/busEvents';
export default { export default {
methods: { methods: {
onMenuItemClick() { onMenuItemClick() {
bus.$emit('sidemenu_icon_click'); bus.$emit(BUS_EVENTS.TOGGLE_SIDEMENU);
}, },
}, },
}; };
</script> </script>
<style lang="scss" scoped> <style scoped lang="scss">
.ion-android-menu { .hamburger--menu {
font-size: var(--font-size-big); cursor: pointer;
display: none;
margin-right: var(--space-normal);
@media screen and (max-width: 1200px) {
display: block;
}
} }
</style> </style>

View File

@ -1,13 +1,14 @@
<template> <template>
<div> <div>
<div class="ui-snackbar"> <div class="ui-snackbar">
<div class="ui-snackbar-text">{{ message }}</div> <div class="ui-snackbar-text">
{{ message }}
</div>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
export default { export default {
props: { props: {
message: String, message: String,
@ -22,9 +23,7 @@ export default {
toggleAfterTimeout: false, toggleAfterTimeout: false,
}; };
}, },
methods: { mounted() {},
}, methods: {},
mounted() {
},
}; };
</script> </script>

View File

@ -29,12 +29,18 @@ export default {
}, },
mounted() { mounted() {
bus.$on('newToastMessage', message => { bus.$on('newToastMessage', this.onNewToastMessage);
},
beforeDestroy() {
bus.$off('newToastMessage', this.onNewToastMessage);
},
methods: {
onNewToastMessage(message) {
this.snackMessages.push({ key: new Date().getTime(), message }); this.snackMessages.push({ key: new Date().getTime(), message });
window.setTimeout(() => { window.setTimeout(() => {
this.snackMessages.splice(0, 1); this.snackMessages.splice(0, 1);
}, this.duration); }, this.duration);
}); },
}, },
}; };
</script> </script>

View File

@ -1,9 +1,10 @@
<template> <template>
<button :type="type" class="button nice" :class="variant" @click="onClick"> <button :type="type" class="button nice" :class="variant" @click="onClick">
<i <fluent-icon
v-if="!isLoading && icon" v-if="!isLoading && icon"
class="icon" class="icon"
:class="buttonIconClass + ' ' + icon" :class="buttonIconClass"
:icon="icon"
/> />
<spinner v-if="isLoading" /> <spinner v-if="isLoading" />
<slot></slot> <slot></slot>

View File

@ -6,7 +6,7 @@
:class="computedClass" :class="computedClass"
@click="onClick" @click="onClick"
> >
<i v-if="!!iconClass" :class="iconClass" class="icon" /> <fluent-icon v-if="!!iconClass" :icon="iconClass" class="icon" />
<span>{{ buttonText }}</span> <span>{{ buttonText }}</span>
<spinner v-if="loading" /> <spinner v-if="loading" />
</button> </button>

View File

@ -5,8 +5,9 @@
v-if="isOpen" v-if="isOpen"
class-names="resolve" class-names="resolve"
color-scheme="success" color-scheme="success"
icon="ion-checkmark" icon="checkmark"
emoji="✅" emoji="✅"
icon-size="16"
:is-loading="isLoading" :is-loading="isLoading"
@click="onCmdResolveConversation" @click="onCmdResolveConversation"
> >
@ -16,8 +17,9 @@
v-else-if="isResolved" v-else-if="isResolved"
class-names="resolve" class-names="resolve"
color-scheme="warning" color-scheme="warning"
icon="ion-refresh" icon="arrow-redo"
emoji="👀" emoji="👀"
icon-size="16"
:is-loading="isLoading" :is-loading="isLoading"
@click="onCmdOpenConversation" @click="onCmdOpenConversation"
> >
@ -27,7 +29,8 @@
v-else-if="showOpenButton" v-else-if="showOpenButton"
class-names="resolve" class-names="resolve"
color-scheme="primary" color-scheme="primary"
icon="ion-person" icon="person"
icon-size="16"
:is-loading="isLoading" :is-loading="isLoading"
@click="onCmdOpenConversation" @click="onCmdOpenConversation"
> >
@ -38,7 +41,8 @@
ref="arrowDownButton" ref="arrowDownButton"
:color-scheme="buttonClass" :color-scheme="buttonClass"
:disabled="isLoading" :disabled="isLoading"
icon="ion-arrow-down-b" icon="chevron-down"
icon-size="16"
emoji="🔽" emoji="🔽"
@click="openDropdown" @click="openDropdown"
/> />

View File

@ -1,66 +1,41 @@
<template> <template>
<div class="status"> <woot-dropdown-menu>
<div class="status-view"> <woot-dropdown-header :title="$t('SIDEBAR.SET_AVAILABILITY_TITLE')" />
<availability-status-badge :status="currentUserAvailability" /> <woot-dropdown-item
<div class="status-view--title"> v-for="status in availabilityStatuses"
{{ availabilityDisplayLabel }} :key="status.value"
</div> class="status-items"
</div> >
<div class="status-change">
<transition name="menu-slide">
<div
v-if="isStatusMenuOpened"
v-on-clickaway="closeStatusMenu"
class="dropdown-pane dropdowm--top"
>
<woot-dropdown-menu>
<woot-dropdown-item
v-for="status in availabilityStatuses"
:key="status.value"
class="status-items"
>
<woot-button
variant="clear"
size="small"
color-scheme="secondary"
class-names="status-change--dropdown-button"
:is-disabled="status.disabled"
@click="
changeAvailabilityStatus(status.value, currentAccountId)
"
>
<availability-status-badge :status="status.value" />
{{ status.label }}
</woot-button>
</woot-dropdown-item>
</woot-dropdown-menu>
</div>
</transition>
<woot-button <woot-button
variant="clear" size="small"
color-scheme="secondary" :color-scheme="status.disabled ? '' : 'secondary'"
class-names="status-change--change-button link" :variant="status.disabled ? 'smooth' : 'clear'"
@click="openStatusMenu" class-names="status-change--dropdown-button"
@click="changeAvailabilityStatus(status.value)"
> >
{{ $t('SIDEBAR_ITEMS.CHANGE_AVAILABILITY_STATUS') }} <availability-status-badge :status="status.value" />
{{ status.label }}
</woot-button> </woot-button>
</div> </woot-dropdown-item>
</div> <woot-dropdown-divider />
</woot-dropdown-menu>
</template> </template>
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway'; import { mixin as clickaway } from 'vue-clickaway';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue'; import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue'; import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu';
import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader';
import WootDropdownDivider from 'shared/components/ui/dropdown/DropdownDivider';
import AvailabilityStatusBadge from '../widgets/conversation/AvailabilityStatusBadge'; import AvailabilityStatusBadge from '../widgets/conversation/AvailabilityStatusBadge';
const AVAILABILITY_STATUS_KEYS = ['online', 'busy', 'offline']; const AVAILABILITY_STATUS_KEYS = ['online', 'busy', 'offline'];
export default { export default {
components: { components: {
WootDropdownHeader,
WootDropdownDivider,
WootDropdownMenu, WootDropdownMenu,
WootDropdownItem, WootDropdownItem,
AvailabilityStatusBadge, AvailabilityStatusBadge,
@ -100,8 +75,7 @@ export default {
label: statusLabel, label: statusLabel,
value: AVAILABILITY_STATUS_KEYS[index], value: AVAILABILITY_STATUS_KEYS[index],
disabled: disabled:
this.currentUserAvailability === this.currentUserAvailability === AVAILABILITY_STATUS_KEYS[index],
AVAILABILITY_STATUS_KEYS[index],
}) })
); );
}, },
@ -114,7 +88,8 @@ export default {
closeStatusMenu() { closeStatusMenu() {
this.isStatusMenuOpened = false; this.isStatusMenuOpened = false;
}, },
changeAvailabilityStatus(availability, accountId) { changeAvailabilityStatus(availability) {
const accountId = this.currentAccountId;
if (this.isUpdating) { if (this.isUpdating) {
return; return;
} }

View File

@ -1,77 +1,37 @@
<template> <template>
<aside class="sidebar animated shrink columns"> <aside class="woot-sidebar">
<div class="logo"> <primary-sidebar
<router-link :to="dashboardPath" replace> :logo-source="globalConfig.logo"
<img :src="globalConfig.logo" :alt="globalConfig.installationName" /> :installation-name="globalConfig.installationName"
</router-link> :account-id="accountId"
</div> :menu-items="primaryMenuItems"
:active-menu-item="activePrimaryMenu.key"
<div class="main-nav"> @toggle-accounts="toggleAccountModal"
<transition-group name="menu-list" tag="ul" class="menu vertical"> @key-shortcut-modal="toggleKeyShortcutModal"
<sidebar-item />
v-for="item in accessibleMenuItems" <secondary-sidebar
:key="item.toState" :account-id="accountId"
:menu-item="item" :inboxes="inboxes"
/> :labels="labels"
<sidebar-item :teams="teams"
v-if="shouldShowTeams" :menu-config="activeSecondaryMenu"
:key="teamSection.toState" :current-role="currentRole"
:menu-item="teamSection" @add-label="showAddLabelPopup"
/> />
<sidebar-item
v-if="shouldShowSidebarItem"
:key="inboxSection.toState"
:menu-item="inboxSection"
/>
<sidebar-item
v-if="shouldShowSidebarItem"
:key="labelSection.toState"
:menu-item="labelSection"
@add-label="showAddLabelPopup"
/>
<sidebar-item
v-if="showShowContactSideMenu"
:key="contactLabelSection.key"
:menu-item="contactLabelSection"
@add-label="showAddLabelPopup"
/>
</transition-group>
</div>
<div class="bottom-nav">
<availability-status />
</div>
<div class="bottom-nav app-context-menu" @click="toggleOptions">
<agent-details @show-options="toggleOptions" />
<notification-bell />
<span class="current-user--options icon ion-android-more-vertical" />
<options-menu
:show="showOptionsMenu"
@toggle-accounts="toggleAccountModal"
@show-support-chat-window="toggleSupportChatWindow"
@key-shortcut-modal="toggleKeyShortcutModal"
@close="toggleOptions"
/>
</div>
<woot-key-shortcut-modal <woot-key-shortcut-modal
v-if="showShortcutModal" v-if="showShortcutModal"
@close="closeKeyShortcutModal" @close="closeKeyShortcutModal"
@clickaway="closeKeyShortcutModal" @clickaway="closeKeyShortcutModal"
/> />
<account-selector <account-selector
:show-account-modal="showAccountModal" :show-account-modal="showAccountModal"
@close-account-modal="toggleAccountModal" @close-account-modal="toggleAccountModal"
@show-create-account-modal="openCreateAccountModal" @show-create-account-modal="openCreateAccountModal"
/> />
<add-account-modal <add-account-modal
:show="showCreateAccountModal" :show="showCreateAccountModal"
@close-account-create-modal="closeCreateAccountModal" @close-account-create-modal="closeCreateAccountModal"
/> />
<woot-modal :show.sync="showAddLabelModal" :on-close="hideAddLabelPopup"> <woot-modal :show.sync="showAddLabelModal" :on-close="hideAddLabelPopup">
<add-label-modal @close="hideAddLabelPopup" /> <add-label-modal @close="hideAddLabelPopup" />
</woot-modal> </woot-modal>
@ -82,17 +42,14 @@
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import adminMixin from '../../mixins/isAdmin'; import adminMixin from '../../mixins/isAdmin';
import SidebarItem from './SidebarItem'; import { getSidebarItems } from './config/default-sidebar';
import AvailabilityStatus from './AvailabilityStatus';
import { frontendURL } from '../../helper/URLHelper';
import { getSidebarItems } from '../../i18n/default-sidebar';
import alertMixin from 'shared/mixins/alertMixin'; import alertMixin from 'shared/mixins/alertMixin';
import NotificationBell from './sidebarComponents/NotificationBell';
import AgentDetails from './sidebarComponents/AgentDetails.vue';
import OptionsMenu from './sidebarComponents/OptionsMenu.vue';
import AccountSelector from './sidebarComponents/AccountSelector.vue'; import AccountSelector from './sidebarComponents/AccountSelector.vue';
import AddAccountModal from './sidebarComponents/AddAccountModal.vue'; import AddAccountModal from './sidebarComponents/AddAccountModal.vue';
import AddLabelModal from '../../routes/dashboard/settings/labels/AddLabel'; import AddLabelModal from '../../routes/dashboard/settings/labels/AddLabel';
import PrimarySidebar from './sidebarComponents/Primary';
import SecondarySidebar from './sidebarComponents/Secondary';
import WootKeyShortcutModal from 'components/widgets/modal/WootKeyShortcutModal'; import WootKeyShortcutModal from 'components/widgets/modal/WootKeyShortcutModal';
import { import {
hasPressedAltAndCKey, hasPressedAltAndCKey,
@ -107,14 +64,11 @@ import router from '../../routes';
export default { export default {
components: { components: {
AgentDetails,
SidebarItem,
AvailabilityStatus,
NotificationBell,
OptionsMenu,
AccountSelector, AccountSelector,
AddAccountModal, AddAccountModal,
AddLabelModal, AddLabelModal,
PrimarySidebar,
SecondarySidebar,
WootKeyShortcutModal, WootKeyShortcutModal,
}, },
mixins: [adminMixin, alertMixin, eventListenerMixins], mixins: [adminMixin, alertMixin, eventListenerMixins],
@ -135,129 +89,34 @@ export default {
inboxes: 'inboxes/getInboxes', inboxes: 'inboxes/getInboxes',
accountId: 'getCurrentAccountId', accountId: 'getCurrentAccountId',
currentRole: 'getCurrentRole', currentRole: 'getCurrentRole',
accountLabels: 'labels/getLabelsOnSidebar', labels: 'labels/getLabelsOnSidebar',
teams: 'teams/getMyTeams', teams: 'teams/getMyTeams',
}), }),
sideMenuConfig() {
sidemenuItems() {
return getSidebarItems(this.accountId); return getSidebarItems(this.accountId);
}, },
accessibleMenuItems() { primaryMenuItems() {
// get all keys in menuGroup const menuItems = this.sideMenuConfig.primaryMenu;
const groupKey = Object.keys(this.sidemenuItems); return menuItems.filter(menuItem =>
menuItem.roles.includes(this.currentRole)
);
},
activeSecondaryMenu() {
const { secondaryMenu } = this.sideMenuConfig;
const { name: currentRoute } = this.$route;
let menuItems = []; const activeSecondaryMenu =
// Iterate over menuGroup to find the correct group secondaryMenu.find(menuItem =>
for (let i = 0; i < groupKey.length; i += 1) { menuItem.routes.includes(currentRoute)
const groupItem = this.sidemenuItems[groupKey[i]]; ) || {};
// Check if current route is included return activeSecondaryMenu;
const isRouteIncluded = groupItem.routes.includes(this.currentRoute);
if (isRouteIncluded) {
menuItems = Object.values(groupItem.menuItems);
}
}
return this.filterMenuItemsByRole(menuItems);
}, },
currentRoute() { activePrimaryMenu() {
return this.$store.state.route.name; const activePrimaryMenu =
}, this.primaryMenuItems.find(
shouldShowSidebarItem() { menuItem => menuItem.key === this.activeSecondaryMenu.parentNav
return this.sidemenuItems.common.routes.includes(this.currentRoute); ) || {};
}, return activePrimaryMenu;
showShowContactSideMenu() {
return this.sidemenuItems.contacts.routes.includes(this.currentRoute);
},
shouldShowTeams() {
return this.shouldShowSidebarItem && this.teams.length;
},
inboxSection() {
return {
icon: 'ion-folder',
label: 'INBOXES',
hasSubMenu: true,
newLink: true,
key: 'inbox',
cssClass: 'menu-title align-justify',
toState: frontendURL(`accounts/${this.accountId}/settings/inboxes`),
toStateName: 'settings_inbox_list',
newLinkRouteName: 'settings_inbox_new',
children: this.inboxes.map(inbox => ({
id: inbox.id,
label: inbox.name,
toState: frontendURL(`accounts/${this.accountId}/inbox/${inbox.id}`),
type: inbox.channel_type,
phoneNumber: inbox.phone_number,
})),
};
},
labelSection() {
return {
icon: 'ion-pound',
label: 'LABELS',
hasSubMenu: true,
newLink: true,
key: 'label',
cssClass: 'menu-title align-justify',
toState: frontendURL(`accounts/${this.accountId}/settings/labels`),
toStateName: 'labels_list',
showModalForNewItem: true,
modalName: 'AddLabel',
children: this.accountLabels.map(label => ({
id: label.id,
label: label.title,
color: label.color,
truncateLabel: true,
toState: frontendURL(
`accounts/${this.accountId}/label/${label.title}`
),
})),
};
},
contactLabelSection() {
return {
icon: 'ion-pound',
label: 'TAGGED_WITH',
hasSubMenu: true,
key: 'label',
newLink: false,
cssClass: 'menu-title align-justify',
toState: frontendURL(`accounts/${this.accountId}/settings/labels`),
toStateName: 'labels_list',
showModalForNewItem: true,
modalName: 'AddLabel',
children: this.accountLabels.map(label => ({
id: label.id,
label: label.title,
color: label.color,
truncateLabel: true,
toState: frontendURL(
`accounts/${this.accountId}/labels/${label.title}/contacts`
),
})),
};
},
teamSection() {
return {
icon: 'ion-ios-people',
label: 'TEAMS',
hasSubMenu: true,
newLink: true,
key: 'team',
cssClass: 'menu-title align-justify teams-sidebar-menu',
toState: frontendURL(`accounts/${this.accountId}/settings/teams`),
toStateName: 'teams_list',
newLinkRouteName: 'settings_teams_new',
children: this.teams.map(team => ({
id: team.id,
label: team.name,
truncateLabel: true,
toState: frontendURL(`accounts/${this.accountId}/team/${team.id}`),
})),
};
},
dashboardPath() {
return frontendURL(`accounts/${this.accountId}/dashboard`);
}, },
}, },
mounted() { mounted() {
@ -302,25 +161,11 @@ export default {
} }
}, },
isCurrentRouteSameAsNavigation(routeName) { isCurrentRouteSameAsNavigation(routeName) {
return router.currentRoute && router.currentRoute.name === routeName; return this.$route.name === routeName;
}, },
toggleSupportChatWindow() { toggleSupportChatWindow() {
window.$chatwoot.toggle(); window.$chatwoot.toggle();
}, },
filterMenuItemsByRole(menuItems) {
if (!this.currentRole) {
return [];
}
return menuItems.filter(
menuItem =>
window.roleWiseRoutes[this.currentRole].indexOf(
menuItem.toStateName
) > -1
);
},
toggleOptions() {
this.showOptionsMenu = !this.showOptionsMenu;
},
toggleAccountModal() { toggleAccountModal() {
this.showAccountModal = !this.showAccountModal; this.showAccountModal = !this.showAccountModal;
}, },
@ -341,6 +186,23 @@ export default {
}; };
</script> </script>
<style lang="scss" scoped>
.woot-sidebar {
background: var(--white);
display: flex;
}
.secondary-menu {
background: var(--white);
border-right: 1px solid var(--s-50);
height: 100vh;
width: 19rem;
flex-shrink: 0;
overflow: auto;
padding: var(--space-small);
}
</style>
<style lang="scss"> <style lang="scss">
@import '~dashboard/assets/scss/variables'; @import '~dashboard/assets/scss/variables';
@ -354,11 +216,11 @@ export default {
cursor: pointer; cursor: pointer;
padding: $space-small $space-large; padding: $space-small $space-large;
.ion-ios-checkmark { .selected--account {
font-size: $font-size-big; margin-top: -$space-smaller;
& + .account--details { & + .account--details {
padding-left: $space-normal; padding-left: $space-normal - $space-micro;
} }
} }
@ -405,7 +267,7 @@ export default {
margin-top: auto; margin-top: auto;
} }
.teams-sidebar-menu + .nested.vertical.menu { .secondary-menu .nested.vertical.menu {
padding-left: calc(var(--space-medium) - var(--space-one)); margin-left: var(--space-small);
} }
</style> </style>

View File

@ -1,154 +0,0 @@
<template>
<router-link
:to="menuItem.toState"
tag="li"
active-class="active"
:class="computedClass"
>
<a
class="sub-menu-title"
:class="getMenuItemClass"
data-tooltip
aria-haspopup="true"
:title="menuItem.toolTip"
>
<div class="wrap">
<i :class="menuItem.icon" />
{{ $t(`SIDEBAR.${menuItem.label}`) }}
</div>
<span
v-if="showItem(menuItem)"
class="child-icon ion-android-add-circle"
@click.prevent="newLinkClick(menuItem)"
/>
</a>
<ul v-if="menuItem.hasSubMenu" class="nested vertical menu">
<router-link
v-for="child in menuItem.children"
:key="child.id"
active-class="active flex-container"
tag="li"
:to="child.toState"
>
<a href="#" :class="computedChildClass(child)">
<div class="wrap">
<i
v-if="menuItem.key === 'inbox'"
class="inbox-icon"
:class="computedInboxClass(child)"
/>
<span
v-if="child.color"
class="label-color--display"
:style="{ backgroundColor: child.color }"
/>
<div
:title="computedChildTitle(child)"
:class="computedChildClass(child)"
>
{{ child.label }}
</div>
</div>
</a>
</router-link>
</ul>
</router-link>
</template>
<script>
import { mapGetters } from 'vuex';
import router from '../../routes';
import adminMixin from '../../mixins/isAdmin';
import { getInboxClassByType } from 'dashboard/helper/inbox';
export default {
mixins: [adminMixin],
props: {
menuItem: {
type: Object,
default() {
return {};
},
},
},
computed: {
...mapGetters({
activeInbox: 'getSelectedInbox',
}),
getMenuItemClass() {
return this.menuItem.cssClass
? `side-menu ${this.menuItem.cssClass}`
: 'side-menu';
},
computedClass() {
// If active Inbox is present
// donot highlight conversations
if (this.activeInbox) return ' ';
if (
this.$store.state.route.name === 'inbox_conversation' &&
this.menuItem.toStateName === 'home'
) {
return 'active';
}
return ' ';
},
},
methods: {
computedInboxClass(child) {
const { type, phoneNumber } = child;
const classByType = getInboxClassByType(type, phoneNumber);
return classByType;
},
computedChildClass(child) {
if (!child.truncateLabel) return '';
return 'text-truncate';
},
computedChildTitle(child) {
if (!child.truncateLabel) return false;
return child.label;
},
newLinkClick(item) {
if (item.newLinkRouteName) {
router.push({ name: item.newLinkRouteName, params: { page: 'new' } });
} else if (item.showModalForNewItem) {
if (item.modalName === 'AddLabel') {
this.$emit('add-label');
}
}
},
showItem(item) {
return this.isAdmin && item.newLink !== undefined;
},
},
};
</script>
<style lang="scss" scoped>
@import '~dashboard/assets/scss/variables';
.sub-menu-title {
display: flex;
justify-content: space-between;
}
.wrap {
display: flex;
align-items: center;
}
.label-color--display {
border-radius: $space-smaller;
height: $space-normal;
margin-right: $space-small;
min-width: $space-normal;
width: $space-normal;
}
.inbox-icon {
position: relative;
top: -1px;
&.ion-ios-email {
font-size: var(--font-size-medium);
}
}
</style>

View File

@ -0,0 +1,19 @@
import conversations from './sidebarItems/conversations';
import contacts from './sidebarItems/contacts';
import reports from './sidebarItems/reports';
import campaigns from './sidebarItems/campaigns';
import settings from './sidebarItems/settings';
import notifications from './sidebarItems/notifications';
import primaryMenu from './sidebarItems/primaryMenu';
export const getSidebarItems = accountId => ({
primaryMenu: primaryMenu(accountId),
secondaryMenu: [
conversations(accountId),
contacts(accountId),
reports(accountId),
campaigns(accountId),
settings(accountId),
notifications(accountId),
],
});

View File

@ -1,30 +1,26 @@
import { frontendURL } from '../../helper/URLHelper'; import { frontendURL } from '../../../../helper/URLHelper';
const campaigns = accountId => ({ const campaigns = accountId => ({
parentNav: 'campaigns',
routes: ['settings_account_campaigns', 'one_off'], routes: ['settings_account_campaigns', 'one_off'],
menuItems: { menuItems: [
back: { {
icon: 'ion-ios-arrow-back', icon: 'arrow-swap',
label: 'HOME',
hasSubMenu: false,
toStateName: 'home',
toState: frontendURL(`accounts/${accountId}/dashboard`),
},
ongoingCampaigns: {
icon: 'ion-arrow-swap',
label: 'ONGOING', label: 'ONGOING',
key: 'ongoingCampaigns',
hasSubMenu: false, hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/campaigns/ongoing`), toState: frontendURL(`accounts/${accountId}/campaigns/ongoing`),
toStateName: 'settings_account_campaigns', toStateName: 'settings_account_campaigns',
}, },
onOffCampaigns: { {
icon: 'ion-radio-waves', key: 'oneOffCampaigns',
icon: 'sound-source',
label: 'ONE_OFF', label: 'ONE_OFF',
hasSubMenu: false, hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/campaigns/one_off`), toState: frontendURL(`accounts/${accountId}/campaigns/one_off`),
toStateName: 'one_off', toStateName: 'one_off',
}, },
}, ],
}); });
export default campaigns; export default campaigns;

View File

@ -1,27 +1,21 @@
import { frontendURL } from '../../helper/URLHelper'; import { frontendURL } from '../../../../helper/URLHelper';
const contacts = accountId => ({ const contacts = accountId => ({
parentNav: 'contacts',
routes: [ routes: [
'contacts_dashboard', 'contacts_dashboard',
'contact_profile_dashboard', 'contact_profile_dashboard',
'contacts_labels_dashboard', 'contacts_labels_dashboard',
], ],
menuItems: { menuItems: [
back: { {
icon: 'ion-ios-arrow-back', icon: 'contact-card-group',
label: 'HOME',
hasSubMenu: false,
toStateName: 'home',
toState: frontendURL(`accounts/${accountId}/dashboard`),
},
contacts: {
icon: 'ion-person',
label: 'ALL_CONTACTS', label: 'ALL_CONTACTS',
hasSubMenu: false, hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/contacts`), toState: frontendURL(`accounts/${accountId}/contacts`),
toStateName: 'contacts_dashboard', toStateName: 'contacts_dashboard',
}, },
}, ],
}); });
export default contacts; export default contacts;

View File

@ -0,0 +1,37 @@
import { frontendURL } from '../../../../helper/URLHelper';
const conversations = accountId => ({
parentNav: 'conversations',
routes: [
'home',
'inbox_dashboard',
'inbox_conversation',
'conversation_through_inbox',
'notifications_dashboard',
'label_conversations',
'conversations_through_label',
'team_conversations',
'conversations_through_team',
'conversation_mentions',
'conversation_through_mentions',
],
menuItems: [
{
icon: 'chat',
label: 'ALL_CONVERSATIONS',
key: 'conversations',
toState: frontendURL(`accounts/${accountId}/dashboard`),
toolTip: 'Conversation from all subscribed inboxes',
toStateName: 'home',
},
{
icon: 'mention',
label: 'MENTIONED_CONVERSATIONS',
key: 'conversation_mentions',
toState: frontendURL(`accounts/${accountId}/mentions/conversations`),
toStateName: 'conversation_mentions',
},
],
});
export default conversations;

View File

@ -0,0 +1,7 @@
const notifications = () => ({
parentNav: 'notifications',
routes: ['notifications_index'],
menuItems: [],
});
export default notifications;

View File

@ -0,0 +1,46 @@
import { frontendURL } from '../../../../helper/URLHelper';
const primaryMenuItems = accountId => [
{
icon: 'chat',
key: 'conversations',
label: 'CONVERSATIONS',
toState: frontendURL(`accounts/${accountId}/dashboard`),
toStateName: 'home',
roles: ['administrator', 'agent'],
},
{
icon: 'book-contacts',
key: 'contacts',
label: 'CONTACTS',
toState: frontendURL(`accounts/${accountId}/contacts`),
toStateName: 'contacts_dashboard',
roles: ['administrator', 'agent'],
},
{
icon: 'arrow-trending-lines',
key: 'reports',
label: 'REPORTS',
toState: frontendURL(`accounts/${accountId}/reports`),
toStateName: 'settings_account_reports',
roles: ['administrator'],
},
{
icon: 'megaphone',
key: 'campaigns',
label: 'CAMPAIGNS',
toState: frontendURL(`accounts/${accountId}/campaigns`),
toStateName: 'settings_account_campaigns',
roles: ['administrator'],
},
{
icon: 'settings',
key: 'settings',
label: 'SETTINGS',
toState: frontendURL(`accounts/${accountId}/settings`),
toStateName: 'settings_home',
roles: ['administrator', 'agent'],
},
];
export default primaryMenuItems;

View File

@ -0,0 +1,7 @@
const profileSettings = () => ({
parentNav: 'profileSettings',
routes: ['profile_settings_index'],
menuItems: [],
});
export default profileSettings;

View File

@ -1,6 +1,7 @@
import { frontendURL } from '../../helper/URLHelper'; import { frontendURL } from '../../../../helper/URLHelper';
const reports = accountId => ({ const reports = accountId => ({
parentNav: 'reports',
routes: [ routes: [
'settings_account_reports', 'settings_account_reports',
'csat_reports', 'csat_reports',
@ -9,57 +10,50 @@ const reports = accountId => ({
'inbox_reports', 'inbox_reports',
'team_reports', 'team_reports',
], ],
menuItems: { menuItems: [
back: { {
icon: 'ion-ios-arrow-back', icon: 'arrow-trending-lines',
label: 'HOME',
hasSubMenu: false,
toStateName: 'home',
toState: frontendURL(`accounts/${accountId}/dashboard`),
},
reportOverview: {
icon: 'ion-arrow-graph-up-right',
label: 'REPORTS_OVERVIEW', label: 'REPORTS_OVERVIEW',
hasSubMenu: false, hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/overview`), toState: frontendURL(`accounts/${accountId}/reports/overview`),
toStateName: 'settings_account_reports', toStateName: 'settings_account_reports',
}, },
csatReports: { {
icon: 'ion-happy', icon: 'emoji',
label: 'CSAT', label: 'CSAT',
hasSubMenu: false, hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/csat`), toState: frontendURL(`accounts/${accountId}/reports/csat`),
toStateName: 'csat_reports', toStateName: 'csat_reports',
}, },
agentReports: { {
icon: 'ion-person-stalker', icon: 'people',
label: 'REPORTS_AGENT', label: 'REPORTS_AGENT',
hasSubMenu: false, hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/agent`), toState: frontendURL(`accounts/${accountId}/reports/agent`),
toStateName: 'agent_reports', toStateName: 'agent_reports',
}, },
labelReports: { {
icon: 'ion-pricetags', icon: 'tag',
label: 'REPORTS_LABEL', label: 'REPORTS_LABEL',
hasSubMenu: false, hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/label`), toState: frontendURL(`accounts/${accountId}/reports/label`),
toStateName: 'label_reports', toStateName: 'label_reports',
}, },
inboxReports: { {
icon: 'ion-archive', icon: 'mail-inbox-all',
label: 'REPORTS_INBOX', label: 'REPORTS_INBOX',
hasSubMenu: false, hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/inboxes`), toState: frontendURL(`accounts/${accountId}/reports/inboxes`),
toStateName: 'inbox_reports', toStateName: 'inbox_reports',
}, },
teamReports: { {
icon: 'ion-ios-people', icon: 'people-team',
label: 'REPORTS_TEAM', label: 'REPORTS_TEAM',
hasSubMenu: false, hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/teams`), toState: frontendURL(`accounts/${accountId}/reports/teams`),
toStateName: 'team_reports', toStateName: 'team_reports',
}, },
}, ],
}); });
export default reports; export default reports;

View File

@ -1,6 +1,7 @@
import { frontendURL } from '../../helper/URLHelper'; import { frontendURL } from '../../../../helper/URLHelper';
const settings = accountId => ({ const settings = accountId => ({
parentNav: 'settings',
routes: [ routes: [
'agent_list', 'agent_list',
'canned_list', 'canned_list',
@ -28,45 +29,39 @@ const settings = accountId => ({
'settings_teams_edit', 'settings_teams_edit',
'settings_teams_edit_members', 'settings_teams_edit_members',
'settings_teams_edit_finish', 'settings_teams_edit_finish',
'automation_list',
], ],
menuItems: { menuItems: [
back: { {
icon: 'ion-ios-arrow-back', icon: 'people',
label: 'HOME',
hasSubMenu: false,
toStateName: 'home',
toState: frontendURL(`accounts/${accountId}/dashboard`),
},
agents: {
icon: 'ion-person-stalker',
label: 'AGENTS', label: 'AGENTS',
hasSubMenu: false, hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/agents/list`), toState: frontendURL(`accounts/${accountId}/settings/agents/list`),
toStateName: 'agent_list', toStateName: 'agent_list',
}, },
teams: { {
icon: 'ion-ios-people', icon: 'people-team',
label: 'TEAMS', label: 'TEAMS',
hasSubMenu: false, hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/teams/list`), toState: frontendURL(`accounts/${accountId}/settings/teams/list`),
toStateName: 'settings_teams_list', toStateName: 'settings_teams_list',
}, },
inboxes: { {
icon: 'ion-archive', icon: 'mail-inbox-all',
label: 'INBOXES', label: 'INBOXES',
hasSubMenu: false, hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/inboxes/list`), toState: frontendURL(`accounts/${accountId}/settings/inboxes/list`),
toStateName: 'settings_inbox_list', toStateName: 'settings_inbox_list',
}, },
labels: { {
icon: 'ion-pricetags', icon: 'tag',
label: 'LABELS', label: 'LABELS',
hasSubMenu: false, hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/labels/list`), toState: frontendURL(`accounts/${accountId}/settings/labels/list`),
toStateName: 'labels_list', toStateName: 'labels_list',
}, },
attributes: { {
icon: 'ion-code', icon: 'code',
label: 'CUSTOM_ATTRIBUTES', label: 'CUSTOM_ATTRIBUTES',
hasSubMenu: false, hasSubMenu: false,
toState: frontendURL( toState: frontendURL(
@ -74,8 +69,15 @@ const settings = accountId => ({
), ),
toStateName: 'attributes_list', toStateName: 'attributes_list',
}, },
cannedResponses: { {
icon: 'ion-chatbox-working', icon: 'autocorrect',
label: 'AUTOMATION',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/automation/list`),
toStateName: 'automation_list',
},
{
icon: 'chat-multiple',
label: 'CANNED_RESPONSES', label: 'CANNED_RESPONSES',
hasSubMenu: false, hasSubMenu: false,
toState: frontendURL( toState: frontendURL(
@ -83,28 +85,28 @@ const settings = accountId => ({
), ),
toStateName: 'canned_list', toStateName: 'canned_list',
}, },
settings_integrations: { {
icon: 'ion-flash', icon: 'flash-on',
label: 'INTEGRATIONS', label: 'INTEGRATIONS',
hasSubMenu: false, hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/integrations`), toState: frontendURL(`accounts/${accountId}/settings/integrations`),
toStateName: 'settings_integrations', toStateName: 'settings_integrations',
}, },
settings_applications: { {
icon: 'ion-asterisk', icon: 'star-emphasis',
label: 'APPLICATIONS', label: 'APPLICATIONS',
hasSubMenu: false, hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/applications`), toState: frontendURL(`accounts/${accountId}/settings/applications`),
toStateName: 'settings_applications', toStateName: 'settings_applications',
}, },
general_settings_index: { {
icon: 'ion-gear-a', icon: 'settings',
label: 'ACCOUNT_SETTINGS', label: 'ACCOUNT_SETTINGS',
hasSubMenu: false, hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/general`), toState: frontendURL(`accounts/${accountId}/settings/general`),
toStateName: 'general_settings_index', toStateName: 'general_settings_index',
}, },
}, ],
}); });
export default settings; export default settings;

View File

@ -14,7 +14,13 @@
class="account-selector" class="account-selector"
> >
<a :href="`/app/accounts/${account.id}/dashboard`"> <a :href="`/app/accounts/${account.id}/dashboard`">
<i v-if="account.id === accountId" class="ion ion-ios-checkmark" /> <fluent-icon
v-if="account.id === accountId"
class="selected--account"
icon="checkmark-circle"
type="solid"
size="24"
/>
<label :for="account.name" class="account--details"> <label :for="account.name" class="account--details">
<div class="account--name">{{ account.name }}</div> <div class="account--name">{{ account.name }}</div>
<div class="account--role">{{ account.role }}</div> <div class="account--role">{{ account.role }}</div>

View File

@ -12,7 +12,7 @@
<div v-if="!hasAccounts" class="alert-wrap"> <div v-if="!hasAccounts" class="alert-wrap">
<div class="callout alert"> <div class="callout alert">
<div class="icon-wrap"> <div class="icon-wrap">
<i class="ion-alert-circled"></i> <fluent-icon icon="warning" />
</div> </div>
{{ $t('CREATE_ACCOUNT.NO_ACCOUNT_WARNING') }} {{ $t('CREATE_ACCOUNT.NO_ACCOUNT_WARNING') }}
</div> </div>
@ -102,18 +102,19 @@ export default {
}, },
}; };
</script> </script>
<style lang="scss"> <style lang="scss" scoped>
.alert-wrap { .alert-wrap {
margin: var(--space-zero) var(--space-large); font-size: var(--font-size-small);
margin-top: var(--space-medium); margin: var(--space-medium) var(--space-large) var(--space-zero);
.callout { .callout {
align-items: center;
border-radius: var(--border-radius-normal);
display: flex; display: flex;
justify-content: center;
} }
} }
.icon-wrap { .icon-wrap {
font-size: var(--font-size-big);
margin-left: var(--space-smaller); margin-left: var(--space-smaller);
margin-right: var(--space-slab); margin-right: var(--space-slab);
} }

View File

@ -1,18 +1,18 @@
<template> <template>
<div class="current-user--row"> <woot-button
v-tooltip.right="$t(`SIDEBAR.PROFILE_SETTINGS`)"
variant="link"
class="current-user"
@click="handleClick"
>
<thumbnail <thumbnail
:src="currentUser.avatar_url" :src="currentUser.avatar_url"
:username="currentUserAvailableName" :username="currentUser.name"
:status="statusOfAgent"
should-show-status-always
size="32px"
/> />
<div class="current-user--data"> </woot-button>
<h3 class="current-user--name text-truncate">
{{ currentUserAvailableName }}
</h3>
<h5 v-if="currentRole" class="current-user--role">
{{ $t(`AGENT_MGMT.AGENT_TYPES.${currentRole.toUpperCase()}`) }}
</h5>
</div>
</div>
</template> </template>
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
@ -25,39 +25,25 @@ export default {
computed: { computed: {
...mapGetters({ ...mapGetters({
currentUser: 'getCurrentUser', currentUser: 'getCurrentUser',
currentRole: 'getCurrentRole', currentUserAvailability: 'getCurrentUserAvailability',
}), }),
currentUserAvailableName() { statusOfAgent() {
return this.currentUser.name; return this.currentUserAvailability || 'offline';
},
},
methods: {
handleClick() {
this.$emit('toggle-menu');
}, },
}, },
}; };
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.current-user--row { .current-user {
align-items: center; align-items: center;
display: flex; display: flex;
} border-radius: 50%;
border: 2px solid var(--white);
.current-user--data {
display: flex;
flex-direction: column;
.current-user--name {
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
margin-bottom: var(--space-micro);
margin-left: var(--space-one);
max-width: 12rem;
}
.current-user--role {
color: var(--color-gray);
font-size: var(--font-size-mini);
margin-bottom: var(--zero);
margin-left: var(--space-one);
text-transform: capitalize;
}
} }
</style> </style>

View File

@ -0,0 +1,46 @@
<template>
<div class="logo">
<router-link :to="dashboardPath" replace>
<img :src="source" :alt="name" />
</router-link>
</div>
</template>
<script>
import { frontendURL } from 'dashboard/helper/URLHelper';
export default {
props: {
source: {
type: String,
default: '',
},
name: {
type: String,
default: '',
},
accountId: {
type: Number,
default: 0,
},
},
computed: {
dashboardPath() {
return frontendURL(`accounts/${this.accountId}/dashboard`);
},
},
};
</script>
<style lang="scss" scoped>
$logo-size: 32px;
.logo {
padding: var(--space-normal);
img {
width: $logo-size;
height: $logo-size;
object-fit: cover;
object-position: left center;
}
}
</style>

View File

@ -1,11 +1,19 @@
<template> <template>
<span class="notifications icon ion-ios-bell" @click.stop="showNotification"> <div class="notifications-link">
<span v-if="unreadCount" class="unread-badge">{{ unreadCount }}</span> <primary-nav-item
</span> name="NOTIFICATIONS"
icon="alert"
:to="`/app/accounts/${accountId}/notifications`"
:count="unreadCount"
/>
</div>
</template> </template>
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import PrimaryNavItem from './PrimaryNavItem';
export default { export default {
components: { PrimaryNavItem },
computed: { computed: {
...mapGetters({ ...mapGetters({
accountId: 'getCurrentAccountId', accountId: 'getCurrentAccountId',
@ -13,40 +21,20 @@ export default {
}), }),
unreadCount() { unreadCount() {
if (!this.notificationMetadata.unreadCount) { if (!this.notificationMetadata.unreadCount) {
return 0; return '';
} }
return this.notificationMetadata.unreadCount < 100 return this.notificationMetadata.unreadCount < 100
? this.notificationMetadata.unreadCount ? `${this.notificationMetadata.unreadCount}`
: '99+'; : '99+';
}, },
}, },
methods: { methods: {},
showNotification() {
this.$router.push(`/app/accounts/${this.accountId}/notifications`);
},
},
}; };
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.notifications { .notifications-link {
font-size: var(--font-size-big); margin-bottom: var(--space-small);
margin-bottom: auto;
margin-left: auto;
margin-top: auto;
position: relative;
.unread-badge {
background: var(--r-300);
border-radius: var(--space-small);
color: var(--white);
font-size: var(--font-size-micro);
font-weight: var(--font-weight-black);
left: var(--space-slab);
padding: 0 var(--space-smaller);
position: absolute;
top: var(--space-smaller);
}
} }
</style> </style>

View File

@ -2,15 +2,19 @@
<transition name="menu-slide"> <transition name="menu-slide">
<div <div
v-if="show" v-if="show"
v-on-clickaway="() => $emit('close')" v-on-clickaway="onClickAway"
class="dropdown-pane dropdowm--top" class="dropdown-pane"
:class="{ 'dropdown-pane--open': show }"
> >
<availability-status />
<li class="divider" />
<woot-dropdown-menu> <woot-dropdown-menu>
<woot-dropdown-item v-if="showChangeAccountOption"> <woot-dropdown-item v-if="showChangeAccountOption">
<woot-button <woot-button
variant="clear" variant="clear"
color-scheme="secondary"
size="small" size="small"
class=" change-accounts--button" icon="arrow-swap"
@click="$emit('toggle-accounts')" @click="$emit('toggle-accounts')"
> >
{{ $t('SIDEBAR_ITEMS.CHANGE_ACCOUNTS') }} {{ $t('SIDEBAR_ITEMS.CHANGE_ACCOUNTS') }}
@ -19,36 +23,50 @@
<woot-dropdown-item v-if="globalConfig.chatwootInboxToken"> <woot-dropdown-item v-if="globalConfig.chatwootInboxToken">
<woot-button <woot-button
variant="clear" variant="clear"
color-scheme="secondary"
size="small" size="small"
class=" change-accounts--button" icon="chat-help"
@click="$emit('show-support-chat-window')" @click="$emit('show-support-chat-window')"
> >
Contact Support {{ $t('SIDEBAR_ITEMS.CONTACT_SUPPORT') }}
</woot-button> </woot-button>
</woot-dropdown-item> </woot-dropdown-item>
<woot-dropdown-item> <woot-dropdown-item>
<woot-button <woot-button
variant="clear" variant="clear"
color-scheme="secondary"
size="small" size="small"
class=" change-accounts--button" icon="keyboard"
@click="$emit('key-shortcut-modal')" @click="handleKeyboardHelpClick"
> >
{{ $t('SIDEBAR_ITEMS.KEYBOARD_SHORTCUTS') }} {{ $t('SIDEBAR_ITEMS.KEYBOARD_SHORTCUTS') }}
</woot-button> </woot-button>
</woot-dropdown-item> </woot-dropdown-item>
<woot-dropdown-item> <woot-dropdown-item>
<router-link <router-link
v-slot="{ href, isActive, navigate }"
:to="`/app/accounts/${accountId}/profile/settings`" :to="`/app/accounts/${accountId}/profile/settings`"
class="button clear small change-accounts--button" custom
> >
{{ $t('SIDEBAR_ITEMS.PROFILE_SETTINGS') }} <a
:href="href"
class="button small clear secondary"
:class="{ 'is-active': isActive }"
@click="e => handleProfileSettingClick(e, navigate)"
>
<fluent-icon icon="person" class="icon icon--font" />
<span class="button__content">
{{ $t('SIDEBAR_ITEMS.PROFILE_SETTINGS') }}
</span>
</a>
</router-link> </router-link>
</woot-dropdown-item> </woot-dropdown-item>
<woot-dropdown-item> <woot-dropdown-item>
<woot-button <woot-button
variant="clear" variant="clear"
color-scheme="secondary"
size="small" size="small"
class=" change-accounts--button" icon="power"
@click="logout" @click="logout"
> >
{{ $t('SIDEBAR_ITEMS.LOGOUT') }} {{ $t('SIDEBAR_ITEMS.LOGOUT') }}
@ -63,13 +81,15 @@
import { mixin as clickaway } from 'vue-clickaway'; import { mixin as clickaway } from 'vue-clickaway';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import Auth from '../../../api/auth'; import Auth from '../../../api/auth';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue'; import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue'; import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu';
import AvailabilityStatus from 'dashboard/components/layout/AvailabilityStatus';
export default { export default {
components: { components: {
WootDropdownMenu, WootDropdownMenu,
WootDropdownItem, WootDropdownItem,
AvailabilityStatus,
}, },
mixins: [clickaway], mixins: [clickaway],
props: { props: {
@ -88,18 +108,34 @@ export default {
if (this.globalConfig.createNewAccountFromDashboard) { if (this.globalConfig.createNewAccountFromDashboard) {
return true; return true;
} }
return this.currentUser.accounts.length > 1;
const { accounts = [] } = this.currentUser;
return accounts.length > 1;
}, },
}, },
methods: { methods: {
handleProfileSettingClick(e, navigate) {
this.$emit('close');
navigate(e);
},
handleKeyboardHelpClick() {
this.$emit('key-shortcut-modal');
this.$emit('close');
},
logout() { logout() {
Auth.logout(); Auth.logout();
}, },
onClickAway() {
if (this.show) this.$emit('close');
},
}, },
}; };
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.dropdown-pane { .dropdown-pane {
right: 0; left: var(--space-slab);
bottom: var(--space-larger);
min-width: 16.8rem;
z-index: var(--z-index-much-higher);
} }
</style> </style>

View File

@ -0,0 +1,112 @@
<template>
<div class="primary--sidebar">
<logo
:source="logoSource"
:name="installationName"
:account-id="accountId"
/>
<nav class="menu vertical">
<primary-nav-item
v-for="menuItem in menuItems"
:key="menuItem.toState"
:icon="menuItem.icon"
:name="menuItem.label"
:to="menuItem.toState"
:is-child-menu-active="menuItem.key === activeMenuItem"
/>
</nav>
<div class="menu vertical user-menu">
<notification-bell />
<agent-details @toggle-menu="toggleOptions" />
<options-menu
:show="showOptionsMenu"
@toggle-accounts="toggleAccountModal"
@show-support-chat-window="toggleSupportChatWindow"
@key-shortcut-modal="$emit('key-shortcut-modal')"
@close="toggleOptions"
/>
</div>
</div>
</template>
<script>
import Logo from './Logo';
import PrimaryNavItem from './PrimaryNavItem';
import OptionsMenu from './OptionsMenu';
import AgentDetails from './AgentDetails';
import NotificationBell from './NotificationBell';
import { frontendURL } from 'dashboard/helper/URLHelper';
export default {
components: {
Logo,
PrimaryNavItem,
OptionsMenu,
AgentDetails,
NotificationBell,
},
props: {
logoSource: {
type: String,
default: '',
},
installationName: {
type: String,
default: '',
},
accountId: {
type: Number,
default: 0,
},
menuItems: {
type: Array,
default: () => [],
},
activeMenuItem: {
type: String,
default: '',
},
},
data() {
return {
showOptionsMenu: false,
};
},
methods: {
frontendURL,
toggleOptions() {
this.showOptionsMenu = !this.showOptionsMenu;
},
toggleAccountModal() {
this.$emit('toggle-accounts');
},
toggleSupportChatWindow() {
window.$chatwoot.toggle();
},
},
};
</script>
<style lang="scss" scoped>
.primary--sidebar {
display: flex;
flex-direction: column;
width: var(--space-jumbo);
border-right: 1px solid var(--s-50);
box-sizing: content-box;
height: 100vh;
flex-shrink: 0;
}
.menu {
align-items: center;
margin-top: var(--space-medium);
}
.user-menu {
display: flex;
flex-direction: column;
flex-grow: 1;
justify-content: flex-end;
margin-bottom: var(--space-normal);
}
</style>

View File

@ -0,0 +1,78 @@
<template>
<router-link v-slot="{ href, isActive, navigate }" :to="to" custom>
<a
v-tooltip.right="$t(`SIDEBAR.${name}`)"
:href="href"
class="button clear button--only-icon menu-item"
:class="{ 'is-active': isActive || isChildMenuActive }"
@click="navigate"
>
<fluent-icon :icon="icon" />
<span class="show-for-sr">{{ name }}</span>
<span v-if="count" class="badge warning">{{ count }}</span>
</a>
</router-link>
</template>
<script>
export default {
props: {
to: {
type: String,
default: '',
},
name: {
type: String,
default: '',
},
icon: {
type: String,
default: '',
},
count: {
type: String,
default: '',
},
isChildMenuActive: {
type: Boolean,
default: false,
},
},
};
</script>
<style lang="scss" scoped>
.button {
margin: var(--space-small) 0;
}
.menu-item {
display: inline-flex;
position: relative;
border-radius: var(--border-radius-large);
border: 1px solid transparent;
color: var(--s-600);
&:hover {
background: var(--w-25);
color: var(--s-600);
}
&:focus {
border-color: var(--w-500);
}
&.is-active {
background: var(--w-50);
color: var(--w-500);
}
}
.icon {
font-size: var(--font-size-default);
}
.badge {
position: absolute;
right: var(--space-minus-smaller);
top: var(--space-minus-smaller);
}
</style>

View File

@ -0,0 +1,170 @@
<template>
<div v-if="hasSecondaryMenu" class="main-nav secondary-menu">
<transition-group name="menu-list" tag="ul" class="menu vertical">
<secondary-nav-item
v-for="menuItem in accessibleMenuItems"
:key="menuItem.toState"
:menu-item="menuItem"
/>
<secondary-nav-item
v-for="menuItem in additionalSecondaryMenuItems[menuConfig.parentNav]"
:key="menuItem.key"
:menu-item="menuItem"
@add-label="showAddLabelPopup"
/>
</transition-group>
</div>
</template>
<script>
import { frontendURL } from '../../../helper/URLHelper';
import SecondaryNavItem from './SecondaryNavItem.vue';
export default {
components: {
SecondaryNavItem,
},
props: {
accountId: {
type: Number,
default: 0,
},
labels: {
type: Array,
default: () => [],
},
inboxes: {
type: Array,
default: () => [],
},
teams: {
type: Array,
default: () => [],
},
menuConfig: {
type: Object,
default: () => {},
},
currentRole: {
type: String,
default: '',
},
},
computed: {
hasSecondaryMenu() {
return this.menuConfig.menuItems && this.menuConfig.menuItems.length;
},
accessibleMenuItems() {
if (!this.currentRole) {
return [];
}
return this.menuConfig.menuItems.filter(
menuItem =>
window.roleWiseRoutes[this.currentRole].indexOf(
menuItem.toStateName
) > -1
);
},
inboxSection() {
return {
icon: 'folder',
label: 'INBOXES',
hasSubMenu: true,
newLink: true,
newLinkTag: 'NEW_INBOX',
key: 'inbox',
toState: frontendURL(`accounts/${this.accountId}/settings/inboxes/new`),
toStateName: 'settings_inbox_new',
newLinkRouteName: 'settings_inbox_new',
children: this.inboxes.map(inbox => ({
id: inbox.id,
label: inbox.name,
truncateLabel: true,
toState: frontendURL(`accounts/${this.accountId}/inbox/${inbox.id}`),
type: inbox.channel_type,
phoneNumber: inbox.phone_number,
})),
};
},
labelSection() {
return {
icon: 'number-symbol',
label: 'LABELS',
hasSubMenu: true,
newLink: true,
newLinkTag: 'NEW_LABEL',
key: 'label',
toState: frontendURL(`accounts/${this.accountId}/settings/labels`),
toStateName: 'labels_list',
showModalForNewItem: true,
modalName: 'AddLabel',
children: this.labels.map(label => ({
id: label.id,
label: label.title,
color: label.color,
truncateLabel: true,
toState: frontendURL(
`accounts/${this.accountId}/label/${label.title}`
),
})),
};
},
contactLabelSection() {
return {
icon: 'number-symbol',
label: 'TAGGED_WITH',
hasSubMenu: true,
key: 'label',
newLink: true,
newLinkTag: 'NEW_LABEL',
toState: frontendURL(`accounts/${this.accountId}/settings/labels`),
toStateName: 'labels_list',
showModalForNewItem: true,
modalName: 'AddLabel',
children: this.labels.map(label => ({
id: label.id,
label: label.title,
color: label.color,
truncateLabel: true,
toState: frontendURL(
`accounts/${this.accountId}/labels/${label.title}/contacts`
),
})),
};
},
teamSection() {
return {
icon: 'people-team',
label: 'TEAMS',
hasSubMenu: true,
newLink: true,
newLinkTag: 'NEW_TEAM',
key: 'team',
toState: frontendURL(`accounts/${this.accountId}/settings/teams/new`),
toStateName: 'settings_teams_new',
newLinkRouteName: 'settings_teams_new',
children: this.teams.map(team => ({
id: team.id,
label: team.name,
truncateLabel: true,
toState: frontendURL(`accounts/${this.accountId}/team/${team.id}`),
})),
};
},
additionalSecondaryMenuItems() {
let conversationMenuItems = [this.inboxSection, this.labelSection];
if (this.teams.length) {
conversationMenuItems = [this.teamSection, ...conversationMenuItems];
}
return {
conversations: conversationMenuItems,
contacts: [this.contactLabelSection],
};
},
},
methods: {
showAddLabelPopup() {
this.$emit('add-label');
},
},
};
</script>

View File

@ -0,0 +1,145 @@
<template>
<router-link
v-slot="{ href, isActive, navigate }"
:to="to"
custom
active-class="active"
>
<li :class="{ active: isActive }">
<a
:href="href"
class="button clear menu-item text-truncate"
:class="{ 'is-active': isActive, 'text-truncate': shouldTruncate }"
@click="navigate"
>
<span v-if="icon" class="badge--icon">
<fluent-icon class="inbox-icon" :icon="icon" size="12" />
</span>
<span
v-if="labelColor"
class="badge--label"
:style="{ backgroundColor: labelColor }"
/>
<span
:title="menuTitle"
class="menu-label button__content"
:class="{ 'text-truncate': shouldTruncate }"
>
{{ label }}
</span>
<span v-if="count" class="badge" :class="{ secondary: !isActive }">
{{ count }}
</span>
</a>
</li>
</router-link>
</template>
<script>
export default {
props: {
to: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
labelColor: {
type: String,
default: '',
},
shouldTruncate: {
type: Boolean,
default: false,
},
icon: {
type: String,
default: '',
},
count: {
type: String,
default: '',
},
},
computed: {
showIcon() {
return { 'text-truncate': this.shouldTruncate };
},
menuTitle() {
return this.shouldTruncate ? this.label : '';
},
},
};
</script>
<style lang="scss" scoped>
$badge-size: var(--space-normal);
$label-badge-size: var(--space-slab);
.button {
margin: var(--space-small) 0;
}
.menu-item {
display: inline-flex;
color: var(--s-600);
font-weight: var(--font-weight-medium);
width: 100%;
height: var(--space-medium);
padding: var(--space-smaller) var(--space-smaller);
margin: var(--space-smaller) 0;
text-align: left;
&:hover {
background: var(--s-25);
color: var(--s-600);
}
&:focus {
border-color: var(--w-300);
}
&.is-active {
background: var(--w-25);
color: var(--w-500);
border-color: var(--w-25);
}
}
.menu-label {
flex-grow: 1;
line-height: var(--space-two);
}
.inbox-icon {
font-size: var(--font-size-nano);
}
.badge--label,
.badge--icon {
display: inline-flex;
border-radius: var(--border-radius-small);
margin-right: var(--space-smaller);
background: var(--s-100);
}
.badge--icon {
align-items: center;
height: $badge-size;
justify-content: center;
min-width: $badge-size;
}
.badge--label {
height: $label-badge-size;
min-width: $label-badge-size;
margin-left: var(--space-smaller);
}
.badge.secondary {
min-width: unset;
background: var(--s-75);
color: var(--s-600);
font-weight: var(--font-weight-bold);
}
</style>

View File

@ -0,0 +1,190 @@
<template>
<li class="sidebar-item">
<span v-if="hasSubMenu" class="secondary-menu--title fs-small">
{{ $t(`SIDEBAR.${menuItem.label}`) }}
</span>
<router-link
v-else
class="secondary-menu--title secondary-menu--link fs-small"
:class="computedClass"
:to="menuItem.toState"
>
<fluent-icon
:icon="menuItem.icon"
class="secondary-menu--icon"
size="14"
/>
{{ $t(`SIDEBAR.${menuItem.label}`) }}
</router-link>
<ul v-if="hasSubMenu" class="nested vertical menu">
<secondary-child-nav-item
v-for="child in menuItem.children"
:key="child.id"
:to="child.toState"
:label="child.label"
:label-color="child.color"
:should-truncate="child.truncateLabel"
:icon="computedInboxClass(child)"
/>
<router-link
v-if="showItem(menuItem)"
v-slot="{ href, isActive, navigate }"
:to="menuItem.toState"
custom
>
<li>
<a
:href="href"
class="button small clear menu-item--new secondary"
:class="{ 'is-active': isActive }"
@click="e => newLinkClick(e, navigate)"
>
<fluent-icon icon="add" />
<span class="button__content">
{{ $t(`SIDEBAR.${menuItem.newLinkTag}`) }}
</span>
</a>
</li>
</router-link>
</ul>
</li>
</template>
<script>
import { mapGetters } from 'vuex';
import adminMixin from '../../../mixins/isAdmin';
import { getInboxClassByType } from 'dashboard/helper/inbox';
import SecondaryChildNavItem from './SecondaryChildNavItem';
export default {
components: { SecondaryChildNavItem },
mixins: [adminMixin],
props: {
menuItem: {
type: Object,
default: () => ({}),
},
},
computed: {
...mapGetters({ activeInbox: 'getSelectedInbox' }),
hasSubMenu() {
return !!this.menuItem.children;
},
computedClass() {
// If active Inbox is present
// donot highlight conversations
if (this.activeInbox) return ' ';
if (
this.$store.state.route.name === 'inbox_conversation' &&
this.menuItem.toStateName === 'home'
) {
return 'is-active';
}
return ' ';
},
},
methods: {
computedInboxClass(child) {
const { type, phoneNumber } = child;
if (!type) return '';
const classByType = getInboxClassByType(type, phoneNumber);
return classByType;
},
newLinkClick(e, navigate) {
if (this.menuItem.newLinkRouteName) {
navigate(e);
} else if (this.menuItem.showModalForNewItem) {
if (this.menuItem.modalName === 'AddLabel') {
e.preventDefault();
this.$emit('add-label');
}
}
},
showItem(item) {
return this.isAdmin && item.newLink !== undefined;
},
},
};
</script>
<style lang="scss" scoped>
.sidebar-item {
margin: var(--space-smaller) 0 0;
}
.secondary-menu--title {
color: var(--s-600);
display: flex;
font-weight: var(--font-weight-bold);
line-height: var(--space-two);
margin: var(--space-small) 0;
padding: 0 var(--space-small);
}
.secondary-menu--link {
display: flex;
align-items: center;
margin: 0;
padding: var(--space-small);
font-weight: var(--font-weight-medium);
border-radius: var(--border-radius-normal);
&:hover {
background: var(--s-25);
color: var(--s-600);
}
&:focus {
border-color: var(--w-300);
}
&.router-link-exact-active,
&.is-active {
background: var(--w-25);
color: var(--w-500);
border-color: var(--w-25);
}
}
.secondary-menu--icon {
margin-right: var(--space-smaller);
min-width: var(--space-normal);
}
.sub-menu-link {
color: var(--s-600);
}
.wrap {
display: flex;
align-items: center;
}
.label-color--display {
border-radius: var(--space-smaller);
height: var(--space-normal);
margin-right: var(--space-small);
min-width: var(--space-normal);
width: var(--space-normal);
}
.inbox-icon {
position: relative;
top: -1px;
}
.sidebar-item .button.menu-item--new {
display: inline-flex;
height: var(--space-medium);
margin: var(--space-smaller) 0;
padding: var(--space-smaller);
color: var(--s-500);
&:hover {
color: var(--w-500);
}
}
</style>

View File

@ -7,10 +7,12 @@ import i18n from 'dashboard/i18n';
import WootModal from 'dashboard/components/Modal'; import WootModal from 'dashboard/components/Modal';
import WootModalHeader from 'dashboard/components/ModalHeader'; import WootModalHeader from 'dashboard/components/ModalHeader';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon';
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.component('woot-modal', WootModal); localVue.component('woot-modal', WootModal);
localVue.component('woot-modal-header', WootModalHeader); localVue.component('woot-modal-header', WootModalHeader);
localVue.component('fluent-icon', FluentIcon);
localVue.use(Vuex); localVue.use(Vuex);
localVue.use(VueI18n); localVue.use(VueI18n);
@ -84,7 +86,7 @@ describe('accountSelctor', () => {
}); });
it('first account item is checked', () => { it('first account item is checked', () => {
const accountFirstItem = accountSelector.find('.account-selector .ion'); const accountFirstItem = accountSelector.find('.account-selector svg');
expect(accountFirstItem.exists()).toBe(true); expect(accountFirstItem.exists()).toBe(true);
}); });
}); });

View File

@ -2,13 +2,20 @@ import AgentDetails from '../AgentDetails';
import { createLocalVue, shallowMount } from '@vue/test-utils'; import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex'; import Vuex from 'vuex';
import VueI18n from 'vue-i18n'; import VueI18n from 'vue-i18n';
import VTooltip from 'v-tooltip';
import i18n from 'dashboard/i18n'; import i18n from 'dashboard/i18n';
import Thumbnail from 'dashboard/components/widgets/Thumbnail'; import Thumbnail from 'dashboard/components/widgets/Thumbnail';
import WootButton from 'dashboard/components/ui/WootButton';
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(Vuex); localVue.use(Vuex);
localVue.use(VueI18n); localVue.use(VueI18n);
localVue.component('thumbnail', Thumbnail); localVue.component('thumbnail', Thumbnail);
localVue.component('woot-button', WootButton);
localVue.component('woot-button', WootButton);
localVue.use(VTooltip, {
defaultHtml: false,
});
const i18nConfig = new VueI18n({ const i18nConfig = new VueI18n({
locale: 'en', locale: 'en',
@ -16,7 +23,11 @@ const i18nConfig = new VueI18n({
}); });
describe('agentDetails', () => { describe('agentDetails', () => {
const currentUser = { name: 'Neymar Junior', avatar_url: '' }; const currentUser = {
name: 'Neymar Junior',
avatar_url: '',
availability_status: 'online',
};
const currentRole = 'agent'; const currentRole = 'agent';
let store = null; let store = null;
let actions = null; let actions = null;
@ -31,6 +42,7 @@ describe('agentDetails', () => {
getters: { getters: {
getCurrentUser: () => currentUser, getCurrentUser: () => currentUser,
getCurrentRole: () => currentRole, getCurrentRole: () => currentRole,
getCurrentUserAvailability: () => currentUser.availability_status,
}, },
}, },
}; };
@ -47,14 +59,8 @@ describe('agentDetails', () => {
}); });
}); });
it('shows the agent name', () => { it(' the agent status', () => {
const agentTitle = agentDetails.find('.current-user--name'); expect(agentDetails.find('thumbnail-stub').vm.status).toBe('online');
expect(agentTitle.text()).toBe('Neymar Junior');
});
it('shows the agent role', () => {
const agentTitle = agentDetails.find('.current-user--role');
expect(agentTitle.text()).toBe('Agent');
}); });
it('agent thumbnail exists', () => { it('agent thumbnail exists', () => {

View File

@ -50,8 +50,9 @@ describe('notificationBell', () => {
localVue, localVue,
i18n: i18nConfig, i18n: i18nConfig,
}); });
const statusViewTitle = notificationBell.find('.unread-badge');
expect(statusViewTitle.text()).toBe('19'); const statusViewTitle = notificationBell.find('primary-nav-item-stub');
expect(statusViewTitle.vm.count).toBe('19');
}); });
it('it should return unread count 99+ ', async () => { it('it should return unread count 99+ ', async () => {
@ -61,7 +62,7 @@ describe('notificationBell', () => {
localVue, localVue,
i18n: i18nConfig, i18n: i18nConfig,
}); });
const statusViewTitle = notificationBell.find('.unread-badge'); const statusViewTitle = notificationBell.find('primary-nav-item-stub');
expect(statusViewTitle.text()).toBe('99+'); expect(statusViewTitle.vm.count).toBe('99+');
}); });
}); });

View File

@ -1,15 +1,23 @@
import AvailabilityStatus from '../AvailabilityStatus'; import AvailabilityStatus from '../AvailabilityStatus.vue';
import { createLocalVue, mount } from '@vue/test-utils'; import { createLocalVue, mount } from '@vue/test-utils';
import Vuex from 'vuex'; import Vuex from 'vuex';
import VueI18n from 'vue-i18n'; import VueI18n from 'vue-i18n';
import WootButton from 'dashboard/components/ui/WootButton'; import WootButton from 'dashboard/components/ui/WootButton';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu';
import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader';
import WootDropdownDivider from 'shared/components/ui/dropdown/DropdownDivider';
import i18n from 'dashboard/i18n'; import i18n from 'dashboard/i18n';
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(Vuex); localVue.use(Vuex);
localVue.use(VueI18n); localVue.use(VueI18n);
localVue.component('woot-button', WootButton); localVue.component('woot-button', WootButton);
localVue.component('woot-dropdown-header', WootDropdownHeader);
localVue.component('woot-dropdown-menu', WootDropdownMenu);
localVue.component('woot-dropdown-divider', WootDropdownDivider);
localVue.component('woot-dropdown-item', WootDropdownItem);
const i18nConfig = new VueI18n({ const i18nConfig = new VueI18n({
locale: 'en', locale: 'en',
@ -52,29 +60,11 @@ describe('AvailabilityStatus', () => {
}); });
}); });
it('shows current user status', () => {
const statusViewTitle = availabilityStatus.find('.status-view--title');
expect(statusViewTitle.text()).toBe('Online');
});
it('opens the menu when user clicks "change"', async () => {
expect(availabilityStatus.find('.dropdown-pane').exists()).toBe(false);
await availabilityStatus
.find('.status-change--change-button')
.trigger('click');
expect(availabilityStatus.find('.dropdown-pane').exists()).toBe(true);
});
it('dispatches an action when user changes status', async () => { it('dispatches an action when user changes status', async () => {
await availabilityStatus await availabilityStatus;
.find('.status-change--change-button') availabilityStatus
.trigger('click'); .findAll('.status-change--dropdown-button')
.at(2)
await availabilityStatus
.find('.status-change li:last-child button')
.trigger('click'); .trigger('click');
expect(actions.updateAvailability).toBeCalledWith( expect(actions.updateAvailability).toBeCalledWith(

View File

@ -1,9 +1,9 @@
import { mount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import SidemenuIcon from '../SidemenuIcon'; import SidemenuIcon from '../SidemenuIcon';
describe('SidemenuIcon', () => { describe('SidemenuIcon', () => {
test('matches snapshot', () => { test('matches snapshot', () => {
const wrapper = mount(SidemenuIcon); const wrapper = shallowMount(SidemenuIcon);
expect(wrapper.vm).toBeTruthy(); expect(wrapper.vm).toBeTruthy();
expect(wrapper.element).toMatchSnapshot(); expect(wrapper.element).toMatchSnapshot();
}); });

View File

@ -1,7 +1,10 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SidemenuIcon matches snapshot 1`] = ` exports[`SidemenuIcon matches snapshot 1`] = `
<i <button>
class="ion-android-menu hamburger--menu" <fluent-icon
/> class="hamburger--menu"
icon="list"
/>
</button>
`; `;

View File

@ -1,13 +1,23 @@
<template> <template>
<div :class="labelClass" :style="labelStyle" :title="description"> <div :class="labelClass" :style="labelStyle" :title="description">
<i v-if="icon" class="label--icon" :class="icon" @click="onClick" /> <button v-if="icon" class="label-action--button" @click="onClick">
<fluent-icon :icon="icon" size="12" class="label--icon" />
</button>
<span v-if="!href">{{ title }}</span> <span v-if="!href">{{ title }}</span>
<a v-else :href="href" :style="anchorStyle">{{ title }}</a> <a v-else :href="href" :style="anchorStyle">{{ title }}</a>
<i v-if="showClose" class="close--icon ion-close" @click="onClick" /> <button
v-if="showClose"
class="label-action--button"
:style="{ color: textColor }"
@click="onClick"
>
<fluent-icon icon="dismiss" size="12" class="close--icon" />
</button>
</div> </div>
</template> </template>
<script> <script>
import { getContrastingTextColor } from '@chatwoot/utils'; import { getContrastingTextColor } from '@chatwoot/utils';
export default { export default {
props: { props: {
title: { title: {
@ -79,6 +89,8 @@ export default {
@import '~dashboard/assets/scss/variables'; @import '~dashboard/assets/scss/variables';
.label { .label {
display: inline-flex;
align-items: center;
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
margin-right: var(--space-smaller); margin-right: var(--space-smaller);
margin-bottom: var(--space-smaller); margin-bottom: var(--space-smaller);
@ -89,11 +101,12 @@ export default {
.label--icon { .label--icon {
cursor: pointer; cursor: pointer;
margin-right: var(--space-smaller);
} }
.label--icon,
.close--icon { .close--icon {
font-size: var(--font-size-micro);
cursor: pointer; cursor: pointer;
margin-left: var(--space-smaller);
} }
&.small .label--icon, &.small .label--icon,
@ -150,4 +163,8 @@ export default {
} }
} }
} }
.label-action--button {
margin-bottom: var(--space-minus-micro);
}
</style> </style>

View File

@ -1,7 +1,13 @@
<template> <template>
<label class="switch" :class="classObject"> <label class="switch" :class="classObject">
<input class="switch-input" :name="name" :id="id" :disabled="disabled" v-model="value" type="checkbox"> <input
:id="id"
v-model="value"
class="switch-input"
:name="name"
:disabled="disabled"
type="checkbox"
/>
<div class="switch-paddle" :for="name"> <div class="switch-paddle" :for="name">
<span class="show-for-sr">on off</span> <span class="show-for-sr">on off</span>
</div> </div>
@ -24,12 +30,6 @@ export default {
value: null, value: null,
}; };
}, },
beforeMount() {
this.value = this.checked;
},
mounted() {
this.$emit('input', this.value = !!this.checked);
},
computed: { computed: {
classObject() { classObject() {
const { type, size, value } = this; const { type, size, value } = this;
@ -45,5 +45,11 @@ export default {
this.$emit('input', val); this.$emit('input', val);
}, },
}, },
beforeMount() {
this.value = this.checked;
},
mounted() {
this.$emit('input', (this.value = !!this.checked));
},
}; };
</script> </script>

View File

@ -14,7 +14,7 @@
<h3> <h3>
{{ item.title }} {{ item.title }}
<span v-if="isOver(item)" class="completed"> <span v-if="isOver(item)" class="completed">
<i class="ion-checkmark"></i> <fluent-icon icon="checkmark" />
</span> </span>
</h3> </h3>
<span class="step"> <span class="step">

View File

@ -11,6 +11,7 @@
class="icon" class="icon"
:emoji="emoji" :emoji="emoji"
:icon="icon" :icon="icon"
:icon-size="iconSize"
/> />
<span v-if="$slots.default" class="button__content"><slot></slot></span> <span v-if="$slots.default" class="button__content"><slot></slot></span>
</button> </button>
@ -35,6 +36,10 @@ export default {
type: String, type: String,
default: '', default: '',
}, },
iconSize: {
type: [Number, String],
default: 20,
},
emoji: { emoji: {
type: String, type: String,
default: '', default: '',

View File

@ -24,12 +24,11 @@
</span> </span>
</div> </div>
<div class="remove-file-wrap"> <div class="remove-file-wrap">
<button <woot-button
class="remove--attachment" class="remove--attachment clear secondary"
icon="dismiss"
@click="() => onRemoveAttachment(index)" @click="() => onRemoveAttachment(index)"
> />
<i class="ion-android-close"></i>
</button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -22,7 +22,7 @@ export default {
}, },
color: { color: {
type: String, type: String,
default: '#1f93ff', default: '',
}, },
customStyle: { customStyle: {
type: Object, type: Object,
@ -43,15 +43,21 @@ export default {
}, },
computed: { computed: {
style() { style() {
return { let style = {
width: `${this.size}px`, width: `${this.size}px`,
height: `${this.size}px`, height: `${this.size}px`,
borderRadius: this.rounded ? '50%' : 0, borderRadius: this.rounded ? '50%' : 0,
lineHeight: `${this.size + Math.floor(this.size / 20)}px`, lineHeight: `${this.size + Math.floor(this.size / 20)}px`,
backgroundColor: this.backgroundColor,
fontSize: `${Math.floor(this.size / 2.5)}px`, fontSize: `${Math.floor(this.size / 2.5)}px`,
color: this.color,
}; };
if (this.backgroundColor) {
style = { ...style, backgroundColor: this.backgroundColor };
}
if (this.color) {
style = { ...style, color: this.color };
}
return style;
}, },
userInitial() { userInitial() {
return this.initials || this.initial(this.username); return this.initials || this.initial(this.username);
@ -81,6 +87,7 @@ export default {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
text-align: center; text-align: center;
background-image: linear-gradient(to top, #4481eb 0%, #04befe 100%); background-image: linear-gradient(to top, var(--w-100) 0%, var(--w-75) 100%);
color: var(--w-600);
} }
</style> </style>

View File

@ -1,7 +1,8 @@
<template> <template>
<span class="back-button ion-ios-arrow-left" @click.capture="goBack"> <button class="back-button" @click.capture="goBack">
<fluent-icon icon="chevron-left" />
{{ buttonLabel || $t('GENERAL_SETTINGS.BACK') }} {{ buttonLabel || $t('GENERAL_SETTINGS.BACK') }}
</span> </button>
</template> </template>
<script> <script>
import router from '../../routes/index'; import router from '../../routes/index';
@ -28,3 +29,4 @@ export default {
}, },
}; };
</script> </script>
<style scoped></style>

View File

@ -1,7 +1,11 @@
<template> <template>
<div class="row empty-state"> <div class="row empty-state">
<h3 class="title">{{ title }}</h3> <h3 class="title">
<p class="message">{{ message }}</p> {{ title }}
</h3>
<p class="message">
{{ message }}
</p>
<slot /> <slot />
</div> </div>
</template> </template>

View File

@ -0,0 +1,263 @@
<template>
<div class="filters">
<div class="filter">
<div class="filter-inputs">
<select
v-model="attributeKey"
class="filter__question"
@change="resetFilter()"
>
<option
v-for="attribute in filterAttributes"
:key="attribute.key"
:value="attribute.key"
>
{{ attribute.name }}
</option>
</select>
<select v-model="filterOperator" class="filter__operator">
<option
v-for="(operator, o) in operators"
:key="o"
:value="operator.value"
>
{{ $t(`FILTER.OPERATOR_LABELS.${operator.value}`) }}
</option>
</select>
<div v-if="showUserInput" class="filter__answer--wrap">
<div
v-if="inputType === 'multi_select'"
class="multiselect-wrap--small"
>
<multiselect
v-model="values"
track-by="id"
label="name"
:placeholder="'Select'"
:multiple="true"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label=""
:max-height="160"
:options="dropdownValues"
:allow-empty="false"
/>
</div>
<div
v-else-if="inputType === 'search_select'"
class="multiselect-wrap--small"
>
<multiselect
v-model="values"
track-by="id"
label="name"
:placeholder="'Select'"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label=""
:max-height="160"
:options="dropdownValues"
:allow-empty="false"
:option-height="104"
/>
</div>
<input
v-else
v-model="values"
type="text"
class="answer--text-input"
placeholder="Enter value"
/>
</div>
<woot-button
icon="dismiss"
variant="clear"
color-scheme="secondary"
@click="removeFilter"
/>
</div>
<p v-if="v.values.$dirty && v.values.$error" class="filter-error">
{{ $t('FILTER.EMPTY_VALUE_ERROR') }}
</p>
</div>
<div v-if="showQueryOperator" class="filter__join-operator">
<hr class="operator__line" />
<select v-model="query_operator" class="operator__select">
<option value="and">
{{ $t('FILTER.QUERY_DROPDOWN_LABELS.AND') }}
</option>
<option value="or">
{{ $t('FILTER.QUERY_DROPDOWN_LABELS.OR') }}
</option>
</select>
</div>
</div>
</template>
<script>
export default {
props: {
value: {
type: Object,
default: () => null,
},
filterAttributes: {
type: Array,
default: () => [],
},
inputType: {
type: String,
default: 'plain_text',
},
operators: {
type: Array,
default: () => [],
},
dropdownValues: {
type: Array,
default: () => [],
},
showQueryOperator: {
type: Boolean,
default: false,
},
v: {
type: Object,
default: () => null,
},
showUserInput: {
type: Boolean,
default: true,
},
},
computed: {
attributeKey: {
get() {
if (!this.value) return null;
return this.value.attribute_key;
},
set(value) {
const payload = this.value || {};
this.$emit('input', { ...payload, attribute_key: value });
},
},
filterOperator: {
get() {
if (!this.value) return null;
return this.value.filter_operator;
},
set(value) {
const payload = this.value || {};
this.$emit('input', { ...payload, filter_operator: value });
},
},
values: {
get() {
if (!this.value) return null;
return this.value.values;
},
set(value) {
const payload = this.value || {};
this.$emit('input', { ...payload, values: value });
},
},
query_operator: {
get() {
if (!this.value) return null;
return this.value.query_operator;
},
set(value) {
const payload = this.value || {};
this.$emit('input', { ...payload, query_operator: value });
},
},
},
methods: {
removeFilter() {
this.$emit('removeFilter');
},
resetFilter() {
this.$emit('resetFilter');
},
},
};
</script>
<style lang="scss" scoped>
.filter {
background: var(--color-background);
padding: var(--space-small);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-medium);
}
.filter-inputs {
display: flex;
}
.filter-error {
color: var(--r-500);
display: block;
margin: var(--space-smaller) 0;
}
.filter__question,
.filter__operator {
margin-bottom: var(--space-zero);
margin-right: var(--space-smaller);
}
.filter__question {
max-width: 30%;
}
.filter__operator {
max-width: 20%;
}
.filter__answer--wrap {
margin-right: var(--space-smaller);
flex-grow: 1;
input {
margin-bottom: 0;
}
}
.filter__answer {
&.answer--text-input {
margin-bottom: var(--space-zero);
}
}
.filter__join-operator-wrap {
position: relative;
z-index: var(--z-index-twenty);
margin: var(--space-zero);
}
.filter__join-operator {
display: flex;
align-items: center;
justify-content: center;
position: relative;
margin: var(--space-one) var(--space-zero);
.operator__line {
position: absolute;
width: 100%;
border-bottom: 1px solid var(--color-border);
}
.operator__select {
position: relative;
width: auto;
margin-bottom: var(--space-zero) !important;
}
}
.multiselect {
margin-bottom: var(--space-zero);
}
</style>

View File

@ -1,38 +0,0 @@
<template>
<div class="inbox">
<i :class="icon" />
<span>{{ inbox.name }}</span>
</div>
</template>
<script>
import { INBOX_TYPES } from 'shared/mixins/inboxMixin';
export default {
props: {
inbox: {
type: Object,
default: () => {},
},
},
computed: {
icon() {
if (this.inbox.channel_type === INBOX_TYPES.WEB) {
return 'icon ion-earth';
}
return 'icon ion-android-textsms';
},
},
};
</script>
<style scoped lang="scss">
.inbox {
display: flex;
align-items: center;
.icon {
margin-right: var(--space-micro);
min-width: var(--space-normal);
position: relative;
top: -1px;
}
}
</style>

View File

@ -1,23 +0,0 @@
<template>
<div class="inbox-item">
<img src="~dashboard/assets/images/no_page_image.png" alt="No Page Image" />
<div class="item--details columns">
<h4 class="item--name">
{{ inbox.label }}
</h4>
<p class="item--sub">
Facebook
</p>
</div>
<!-- <span class="ion-chevron-right arrow"></span> -->
</div>
</template>
<script>
/* eslint no-console: 0 */
// import WootSwitch from '../ui/Switch';
export default {
props: ['inbox'],
created() {},
};
</script>

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