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
docker:
# 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
# CircleCI maintains a library of pre-built images
@ -77,6 +77,18 @@ jobs:
paths:
- 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
- run: yarn install --check-files
- run: bundle exec rake db:create

View file

@ -40,3 +40,7 @@ exclude_patterns:
- "app/javascript/dashboard/i18n/locale"
- "**/*.stories.js"
- "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
# 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
# Comment out the following environment variables if required by your SMTP server
# SMTP_TLS=
# SMTP_SSL=
# Mail Incoming
# 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/no-v-html": 'off',
'vue/singleline-html-element-content-newline': 'warn',
'vue/require-default-prop': 'warn',
'vue/require-prop-types': 'warn',
'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
Metrics/ModuleLength:
Exclude:
- lib/woot_message_seeder.rb
- lib/seeders/message_seeder.rb
Rails/ApplicationController:
Exclude:
- '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.
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
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights

View file

@ -6,7 +6,10 @@
<p align="center">
<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>
</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.
### 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
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

View file

@ -4,7 +4,7 @@ class ContactInboxBuilder
def perform
@contact = Contact.find(contact_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
create_contact_inbox(source_id) if source_id.present?
@ -14,12 +14,20 @@ class ContactInboxBuilder
def generate_source_id
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 SecureRandom.uuid if @inbox.channel_type == 'Channel::Api'
nil
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
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 :check_authorization
before_action :find_user, only: [:create]
before_action :validate_limit, only: [:create]
before_action :create_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
@agents ||= Current.account.users.order_by_full_name.includes({ avatar_attachment: [:blob] })
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

View file

@ -74,7 +74,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
end
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']
rescue StandardError => e
Rails.logger.info e

View file

@ -33,7 +33,7 @@ class Api::V1::Accounts::CannedResponsesController < Api::V1::Accounts::BaseCont
def canned_responses
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
Current.account.canned_responses
end

View file

@ -11,9 +11,9 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
RESULTS_PER_PAGE = 15
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 :set_include_contact_inboxes, only: [:index, :search]
before_action :set_include_contact_inboxes, only: [:index, :search, :filter]
def index
@contacts_count = resolved_contacts.count
@ -81,11 +81,6 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def update
@contact.assign_attributes(contact_update_params)
@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
def destroy

View file

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

View file

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

View file

@ -1,4 +1,5 @@
class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
include Api::V1::InboxesHelper
before_action :fetch_inbox, except: [:index, :create]
before_action :fetch_agent_bot, only: [:set_agent_bot]
# we are already handling the authorization in fetch inbox
@ -41,12 +42,13 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
def update
@inbox.update(permitted_params.except(:channel))
@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)
# Inbox update doesn't necessarily need channel attributes
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])
update_channel_feature_flags
end

View file

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

View file

@ -1,5 +1,5 @@
class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
before_action :process_hmac
before_action :process_hmac, only: [:update]
def show; end
@ -8,7 +8,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
contact: @contact,
params: permitted_params.to_h.deep_symbolize_keys
)
render json: contact_identify_action.perform
@contact = contact_identify_action.perform
end
# 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
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?
@contact_inbox.update(hmac_verified: true)
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?
params[:identifier_hash] == OpenSSL::HMAC.hexdigest(
'sha256',

View file

@ -31,13 +31,18 @@ module RequestExceptionHandler
render json: { error: message }, status: :unprocessable_entity
end
def render_payment_required(message)
render json: { error: message }, status: :payment_required
end
def render_internal_server_error(message)
render json: { error: message }, status: :internal_server_error
end
def render_record_invalid(exception)
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
end

View file

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

View file

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

View file

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

View file

@ -1,5 +1,30 @@
class ConversationDrop < BaseDrop
include MessageFormatHelper
def display_id
@obj.try(:display_id)
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

View file

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

View file

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

View file

@ -70,7 +70,12 @@ class ConversationFinder
end
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
def filter_by_assignee_type
@ -123,6 +128,10 @@ class ConversationFinder
@conversations = @conversations.includes(
: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

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
include RegexHelper
def transform_user_mention_content(message_content)
message_content.gsub(MENTION_REGEX, '\1')
end
def render_message_content(message_content)
# rubocop:disable Rails/OutputSafety
CommonMarker.render_html(message_content).html_safe
# rubocop:enable Rails/OutputSafety
end
end

View file

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

View file

@ -53,6 +53,11 @@ class ContactAPI extends ApiClient {
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) {
const formData = new FormData();
formData.append('import_file', file);

View file

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

View file

@ -6,7 +6,15 @@ class ConversationApi extends ApiClient {
super('conversations', { accountScoped: true });
}
get({ inboxId, status, assigneeType, page, labels, teamId }) {
get({
inboxId,
status,
assigneeType,
page,
labels,
teamId,
conversationType,
}) {
return axios.get(this.url, {
params: {
inbox_id: inboxId,
@ -15,6 +23,15 @@ class ConversationApi extends ApiClient {
assignee_type: assigneeType,
page,
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 }) {
return axios.post(`${this.url}/${conversationId}/toggle_typing_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`);
}
meta({ inboxId, status, assigneeType, labels, teamId }) {
meta({ inboxId, status, assigneeType, labels, teamId, conversationType }) {
return axios.get(`${this.url}/meta`, {
params: {
inbox_id: inboxId,
@ -74,6 +91,7 @@ class ConversationApi extends ApiClient {
assignee_type: assigneeType,
labels,
team_id: teamId,
conversation_type: conversationType,
},
});
}

View file

@ -11,6 +11,7 @@ describe('#ContactsAPI', () => {
expect(contactAPI).toHaveProperty('update');
expect(contactAPI).toHaveProperty('delete');
expect(contactAPI).toHaveProperty('getConversations');
expect(contactAPI).toHaveProperty('filter');
});
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('meta');
expect(conversationAPI).toHaveProperty('sendEmailTranscript');
expect(conversationAPI).toHaveProperty('filter');
});
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 */
/* durations and timing functions. */
.slide-fade-enter-active {
@ -9,7 +8,8 @@
transition: all .3s $ease-out-cubic;
}
.slide-fade-enter, .slide-fade-leave-to {
.slide-fade-enter,
.slide-fade-leave-to {
opacity: 0;
transform: translateX(10px);
}
@ -22,22 +22,33 @@
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;
}
.conversations-list-enter, .conversations-list-leave-to /* .conversations-list-leave-active for <2.1.8 */ {
.conversations-list-enter,
.conversations-list-leave-to {
opacity: 0;
transform: translateX($space-medium);
}
.menu-list-enter-active, .menu-list-leave-active {
transition: all .2s $ease-out-cubic;
.menu-list-enter-active,
.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;
transform: translateX($space-medium);
position: absolute;
transform: translateX($space-small);
}
.menu-list-enter {
opacity: 0;
transform: translateX(-$space-small);
}
.slide-up-enter-active {
@ -48,8 +59,8 @@
transition: all .3s $ease-out-cubic;
}
.slide-up-enter, .slide-up-leave-to
/* .slide-fade-leave-active for <2.1.8 */ {
.slide-up-enter,
.slide-up-leave-to {
transform: translateY(-$space-medium);
opacity: 0;
}
@ -60,10 +71,10 @@
transition: transform 0.25s $ease-in-cubic, opacity 0.15s $ease-in-cubic;
}
.menu-slide-enter, .menu-slide-leave-to
/* .slide-fade-leave-active for <2.1.8 */ {
transform: translateY($space-small);
.menu-slide-enter,
.menu-slide-leave-to {
opacity: 0;
transform: translateY($space-small);
}
@ -75,10 +86,10 @@
transition: all .1s $ease-out-sine;
}
.toast-fade-enter, .toast-fade-leave-to
/* .toast-fade-leave-active for <2.1.8 */ {
transform: translateY(-$space-small);
.toast-fade-enter,
.toast-fade-leave-to {
opacity: 0;
transform: translateY(-$space-small);
}
.modal-fade-enter-active {
@ -89,8 +100,8 @@
transition: all .1s $ease-out-sine;
}
.modal-fade-enter, .modal-fade-leave-to
/* .slide-fade-leave-active for <2.1.8 */ {
.modal-fade-enter,
.modal-fade-leave-to {
opacity: 0;
}

View file

@ -49,7 +49,21 @@ code {
.cursor-pointer {
cursor: pointer;
}
// remove when grid gutters are fixed
.columns.with-right-space {
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-alt: $black;
$badge-palette: $foundation-palette;
$badge-padding: 0.3em;
$badge-padding: var(--space-smaller);
$badge-minwidth: 2.1em;
$badge-font-size: 0.6rem;
$badge-font-size: var(--font-size-nano);
// 10. Breadcrumbs
// ---------------
@ -400,7 +400,7 @@ $mediaobject-image-width-stacked: 100%;
$menu-margin: 0;
$menu-margin-nested: $space-medium;
$menu-item-padding: $space-one;
$menu-item-padding: $space-slab;
$menu-item-color-active: $white;
$menu-item-background-active: $color-background;
$menu-icon-spacing: 0.25rem;

View file

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

View file

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

View file

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

View file

@ -8,7 +8,7 @@
@include background-white;
@include flex;
@include flex-align($x: justify, $y: middle);
@include border-normal-bottom;
border-bottom: 1px solid var(--s-50);
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
.icon+.button__content {
padding-left: var(--space-small);
width: auto;
}
&.expanded {
@ -103,7 +104,6 @@ $default-button-height: 4.0rem;
padding: 0;
}
}

View file

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

View file

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

View file

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

View file

@ -6,12 +6,6 @@
}
.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;
//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 {
a {
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 {
cursor: pointer;
display: none;

View file

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

View file

@ -1,14 +1,39 @@
<template>
l<template>
<div class="conversations-list-wrap">
<slot></slot>
<div class="chat-list__top">
<div class="chat-list__top" :class="{ filter__applied: hasAppliedFilters }">
<h1 class="page-title text-truncate" :title="pageTitle">
{{ pageTitle }}
</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>
<chat-type-tabs
v-if="!hasAppliedFilters"
:items="assigneeTabItems"
:active-tab="activeAssigneeTab"
class="tab--chat-type"
@ -26,6 +51,7 @@
:active-label="label"
:team-id="teamId"
:chat="chat"
:conversation-type="conversationType"
:show-assignee="showAssigneeInConversationCard"
/>
@ -37,7 +63,7 @@
v-if="!hasCurrentPageEndReached && !chatListLoading"
variant="clear"
size="expanded"
@click="fetchConversations"
@click="loadMoreConversations"
>
{{ $t('CHAT_LIST.LOAD_MORE_CONVERSATIONS') }}
</woot-button>
@ -53,6 +79,18 @@
{{ $t('CHAT_LIST.EOF') }}
</p>
</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>
</template>
@ -60,12 +98,16 @@
import { mapGetters } from 'vuex';
import ChatFilter from './widgets/conversation/ChatFilter';
import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter';
import ChatTypeTabs from './widgets/ChatTypeTabs';
import ConversationCard from './widgets/conversation/ConversationCard';
import timeMixin from '../mixins/time';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import conversationMixin from '../mixins/conversations';
import wootConstants from '../constants';
import advancedFilterTypes from './widgets/conversation/advancedFilterItems';
import filterQueryGenerator from '../helper/filterQueryGenerator.js';
import {
hasPressedAltAndJKey,
hasPressedAltAndKKey,
@ -76,6 +118,7 @@ export default {
ChatTypeTabs,
ConversationCard,
ChatFilter,
ConversationAdvancedFilter,
},
mixins: [timeMixin, conversationMixin, eventListenerMixins],
props: {
@ -91,11 +134,20 @@ export default {
type: String,
default: '',
},
conversationType: {
type: String,
default: '',
},
},
data() {
return {
activeAssigneeTab: wootConstants.ASSIGNEE_TYPE.ME,
activeStatus: wootConstants.STATUS_TYPE.OPEN,
showAdvancedFilters: false,
advancedFilterTypes: advancedFilterTypes.map(filter => ({
...filter,
attributeName: this.$t(`FILTER.ATTRIBUTES.${filter.attributeI18nKey}`),
})),
};
},
computed: {
@ -109,7 +161,11 @@ export default {
currentUserID: 'getCurrentUserID',
activeInbox: 'getSelectedInbox',
conversationStats: 'conversationStats/getStats',
appliedFilters: 'getAppliedConversationFilters',
}),
hasAppliedFilters() {
return this.appliedFilters.length;
},
assigneeTabItems() {
return this.$t('CHAT_LIST.ASSIGNEE_TYPE_TABS').map(item => {
const count = this.conversationStats[item.COUNT_KEY] || 0;
@ -131,9 +187,17 @@ export default {
this.activeAssigneeTab
);
},
currentPageFilterKey() {
return this.hasAppliedFilters ? 'appliedFilters' : this.activeAssigneeTab;
},
currentFiltersPage() {
return this.$store.getters['conversationPage/getCurrentPageFilter'](
this.currentPageFilterKey
);
},
hasCurrentPageEndReached() {
return this.$store.getters['conversationPage/getHasEndReached'](
this.activeAssigneeTab
this.currentPageFilterKey
);
},
conversationFilters() {
@ -144,6 +208,9 @@ export default {
page: this.currentPage + 1,
labels: this.label ? [this.label] : undefined,
teamId: this.teamId ? this.teamId : undefined,
conversationType: this.conversationType
? this.conversationType
: undefined,
};
},
pageTitle() {
@ -156,17 +223,24 @@ export default {
if (this.label) {
return `#${this.label}`;
}
if (this.conversationType === 'mention') {
return this.$t('CHAT_LIST.MENTION_HEADING');
}
return this.$t('CHAT_LIST.TAB_HEADING');
},
conversationList() {
let conversationList = [];
const filters = this.conversationFilters;
if (this.activeAssigneeTab === 'me') {
conversationList = [...this.mineChatsList(filters)];
} else if (this.activeAssigneeTab === 'unassigned') {
conversationList = [...this.unAssignedChatsList(filters)];
if (!this.hasAppliedFilters) {
const filters = this.conversationFilters;
if (this.activeAssigneeTab === 'me') {
conversationList = [...this.mineChatsList(filters)];
} else if (this.activeAssigneeTab === 'unassigned') {
conversationList = [...this.unAssignedChatsList(filters)];
} else {
conversationList = [...this.allChatList(filters)];
}
} else {
conversationList = [...this.allChatList(filters)];
conversationList = [...this.chatLists];
}
return conversationList;
@ -188,6 +262,9 @@ export default {
label() {
this.resetAndFetchData();
},
conversationType() {
this.resetAndFetchData();
},
},
mounted() {
this.$store.dispatch('setChatFilter', this.activeStatus);
@ -198,6 +275,17 @@ export default {
});
},
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() {
const allConversations = this.$refs.activeConversation.querySelectorAll(
'div.conversations-list div.conversation'
@ -245,6 +333,7 @@ export default {
resetAndFetchData() {
this.$store.dispatch('conversationPage/reset');
this.$store.dispatch('emptyAllConversations');
this.$store.dispatch('clearConversationFilters');
this.fetchConversations();
},
fetchConversations() {
@ -252,6 +341,23 @@ export default {
.dispatch('fetchAllConversations', this.conversationFilters)
.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) {
if (this.activeAssigneeTab !== selectedTab) {
bus.$emit('clearSearchInput');
@ -273,6 +379,7 @@ export default {
<style scoped lang="scss">
@import '~dashboard/assets/scss/woot';
.spinner {
margin-top: var(--space-normal);
margin-bottom: var(--space-normal);
@ -295,4 +402,23 @@ export default {
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>

View file

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

View file

@ -7,7 +7,9 @@
@click="onBackDropClick"
>
<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 />
</div>
</div>
@ -34,6 +36,10 @@ export default {
type: String,
default: 'centered',
},
size: {
type: String,
default: '',
},
},
computed: {
modalContainerClassName() {
@ -41,7 +47,8 @@ export default {
if (this.fullWidth) {
return `${className} modal-container--full-width`;
}
return className;
return `${className} ${this.size}`;
},
modalClassName() {
const modalClassNameMap = {

View file

@ -2,29 +2,21 @@
<transition name="network-notification-fade" tag="div">
<div v-show="showNotification" class="ui-notification-container">
<div class="ui-notification">
<svg
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>
<fluent-icon icon="wifi-off" />
<p class="ui-notification-text">
{{ $t('NETWORK.NOTIFICATION.TEXT') }}
</p>
<button class="ui-refresh-button" @click="refreshPage">
<woot-button variant="clear" size="small" @click="refreshPage">
{{ $t('NETWORK.BUTTON.REFRESH') }}
</button>
<button class="ui-close-button" @click="closeNotification">
<i class="ui-close-icon icon ion-ios-close-outline" />
</button>
</woot-button>
<woot-button
variant="clear"
size="small"
color-scheme="secondary"
icon="dismiss-circle"
@click="closeNotification"
>
</woot-button>
</div>
</div>
</transition>
@ -72,52 +64,24 @@ export default {
position: absolute;
right: var(--space-normal);
top: var(--space-normal);
width: 100%;
z-index: 9999;
z-index: var(--z-index-very-high);
}
.ui-notification {
@include shadow;
align-items: center;
background-color: var(--white);
border: 1px solid var(--color-border);
border-radius: var(--space-one);
display: flex;
justify-content: space-between;
max-width: 40rem;
min-height: 3rem;
align-items: center;
width: 100%;
background-color: var(--y-100);
border-radius: var(--border-radius-medium);
box-shadow: var(--shadow-large);
min-width: 24rem;
padding: var(--space-normal) var(--space-two);
text-align: left;
padding: var(--space-normal);
}
.ui-notification-text {
margin: 0;
}
.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;
}
margin: 0 var(--space-small);
}
</style>

View file

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

View file

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

View file

@ -29,12 +29,18 @@ export default {
},
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 });
window.setTimeout(() => {
this.snackMessages.splice(0, 1);
}, this.duration);
});
},
},
};
</script>

View file

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

View file

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

View file

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

View file

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

View file

@ -1,77 +1,37 @@
<template>
<aside class="sidebar animated shrink columns">
<div class="logo">
<router-link :to="dashboardPath" replace>
<img :src="globalConfig.logo" :alt="globalConfig.installationName" />
</router-link>
</div>
<div class="main-nav">
<transition-group name="menu-list" tag="ul" class="menu vertical">
<sidebar-item
v-for="item in accessibleMenuItems"
:key="item.toState"
:menu-item="item"
/>
<sidebar-item
v-if="shouldShowTeams"
:key="teamSection.toState"
:menu-item="teamSection"
/>
<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>
<aside class="woot-sidebar">
<primary-sidebar
:logo-source="globalConfig.logo"
:installation-name="globalConfig.installationName"
:account-id="accountId"
:menu-items="primaryMenuItems"
:active-menu-item="activePrimaryMenu.key"
@toggle-accounts="toggleAccountModal"
@key-shortcut-modal="toggleKeyShortcutModal"
/>
<secondary-sidebar
:account-id="accountId"
:inboxes="inboxes"
:labels="labels"
:teams="teams"
:menu-config="activeSecondaryMenu"
:current-role="currentRole"
@add-label="showAddLabelPopup"
/>
<woot-key-shortcut-modal
v-if="showShortcutModal"
@close="closeKeyShortcutModal"
@clickaway="closeKeyShortcutModal"
/>
<account-selector
:show-account-modal="showAccountModal"
@close-account-modal="toggleAccountModal"
@show-create-account-modal="openCreateAccountModal"
/>
<add-account-modal
:show="showCreateAccountModal"
@close-account-create-modal="closeCreateAccountModal"
/>
<woot-modal :show.sync="showAddLabelModal" :on-close="hideAddLabelPopup">
<add-label-modal @close="hideAddLabelPopup" />
</woot-modal>
@ -82,17 +42,14 @@
import { mapGetters } from 'vuex';
import adminMixin from '../../mixins/isAdmin';
import SidebarItem from './SidebarItem';
import AvailabilityStatus from './AvailabilityStatus';
import { frontendURL } from '../../helper/URLHelper';
import { getSidebarItems } from '../../i18n/default-sidebar';
import { getSidebarItems } from './config/default-sidebar';
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 AddAccountModal from './sidebarComponents/AddAccountModal.vue';
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 {
hasPressedAltAndCKey,
@ -107,14 +64,11 @@ import router from '../../routes';
export default {
components: {
AgentDetails,
SidebarItem,
AvailabilityStatus,
NotificationBell,
OptionsMenu,
AccountSelector,
AddAccountModal,
AddLabelModal,
PrimarySidebar,
SecondarySidebar,
WootKeyShortcutModal,
},
mixins: [adminMixin, alertMixin, eventListenerMixins],
@ -135,129 +89,34 @@ export default {
inboxes: 'inboxes/getInboxes',
accountId: 'getCurrentAccountId',
currentRole: 'getCurrentRole',
accountLabels: 'labels/getLabelsOnSidebar',
labels: 'labels/getLabelsOnSidebar',
teams: 'teams/getMyTeams',
}),
sidemenuItems() {
sideMenuConfig() {
return getSidebarItems(this.accountId);
},
accessibleMenuItems() {
// get all keys in menuGroup
const groupKey = Object.keys(this.sidemenuItems);
primaryMenuItems() {
const menuItems = this.sideMenuConfig.primaryMenu;
return menuItems.filter(menuItem =>
menuItem.roles.includes(this.currentRole)
);
},
activeSecondaryMenu() {
const { secondaryMenu } = this.sideMenuConfig;
const { name: currentRoute } = this.$route;
let menuItems = [];
// Iterate over menuGroup to find the correct group
for (let i = 0; i < groupKey.length; i += 1) {
const groupItem = this.sidemenuItems[groupKey[i]];
// Check if current route is included
const isRouteIncluded = groupItem.routes.includes(this.currentRoute);
if (isRouteIncluded) {
menuItems = Object.values(groupItem.menuItems);
}
}
return this.filterMenuItemsByRole(menuItems);
const activeSecondaryMenu =
secondaryMenu.find(menuItem =>
menuItem.routes.includes(currentRoute)
) || {};
return activeSecondaryMenu;
},
currentRoute() {
return this.$store.state.route.name;
},
shouldShowSidebarItem() {
return this.sidemenuItems.common.routes.includes(this.currentRoute);
},
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`);
activePrimaryMenu() {
const activePrimaryMenu =
this.primaryMenuItems.find(
menuItem => menuItem.key === this.activeSecondaryMenu.parentNav
) || {};
return activePrimaryMenu;
},
},
mounted() {
@ -302,25 +161,11 @@ export default {
}
},
isCurrentRouteSameAsNavigation(routeName) {
return router.currentRoute && router.currentRoute.name === routeName;
return this.$route.name === routeName;
},
toggleSupportChatWindow() {
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() {
this.showAccountModal = !this.showAccountModal;
},
@ -341,6 +186,23 @@ export default {
};
</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">
@import '~dashboard/assets/scss/variables';
@ -354,11 +216,11 @@ export default {
cursor: pointer;
padding: $space-small $space-large;
.ion-ios-checkmark {
font-size: $font-size-big;
.selected--account {
margin-top: -$space-smaller;
& + .account--details {
padding-left: $space-normal;
padding-left: $space-normal - $space-micro;
}
}
@ -405,7 +267,7 @@ export default {
margin-top: auto;
}
.teams-sidebar-menu + .nested.vertical.menu {
padding-left: calc(var(--space-medium) - var(--space-one));
.secondary-menu .nested.vertical.menu {
margin-left: var(--space-small);
}
</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 => ({
parentNav: 'campaigns',
routes: ['settings_account_campaigns', 'one_off'],
menuItems: {
back: {
icon: 'ion-ios-arrow-back',
label: 'HOME',
hasSubMenu: false,
toStateName: 'home',
toState: frontendURL(`accounts/${accountId}/dashboard`),
},
ongoingCampaigns: {
icon: 'ion-arrow-swap',
menuItems: [
{
icon: 'arrow-swap',
label: 'ONGOING',
key: 'ongoingCampaigns',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/campaigns/ongoing`),
toStateName: 'settings_account_campaigns',
},
onOffCampaigns: {
icon: 'ion-radio-waves',
{
key: 'oneOffCampaigns',
icon: 'sound-source',
label: 'ONE_OFF',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/campaigns/one_off`),
toStateName: 'one_off',
},
},
],
});
export default campaigns;

View file

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

View file

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

View file

@ -14,7 +14,13 @@
class="account-selector"
>
<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">
<div class="account--name">{{ account.name }}</div>
<div class="account--role">{{ account.role }}</div>

View file

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

View file

@ -1,18 +1,18 @@
<template>
<div class="current-user--row">
<woot-button
v-tooltip.right="$t(`SIDEBAR.PROFILE_SETTINGS`)"
variant="link"
class="current-user"
@click="handleClick"
>
<thumbnail
:src="currentUser.avatar_url"
:username="currentUserAvailableName"
:username="currentUser.name"
:status="statusOfAgent"
should-show-status-always
size="32px"
/>
<div class="current-user--data">
<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>
</woot-button>
</template>
<script>
import { mapGetters } from 'vuex';
@ -25,39 +25,25 @@ export default {
computed: {
...mapGetters({
currentUser: 'getCurrentUser',
currentRole: 'getCurrentRole',
currentUserAvailability: 'getCurrentUserAvailability',
}),
currentUserAvailableName() {
return this.currentUser.name;
statusOfAgent() {
return this.currentUserAvailability || 'offline';
},
},
methods: {
handleClick() {
this.$emit('toggle-menu');
},
},
};
</script>
<style scoped lang="scss">
.current-user--row {
.current-user {
align-items: center;
display: flex;
}
.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;
}
border-radius: 50%;
border: 2px solid var(--white);
}
</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>
<span class="notifications icon ion-ios-bell" @click.stop="showNotification">
<span v-if="unreadCount" class="unread-badge">{{ unreadCount }}</span>
</span>
<div class="notifications-link">
<primary-nav-item
name="NOTIFICATIONS"
icon="alert"
:to="`/app/accounts/${accountId}/notifications`"
:count="unreadCount"
/>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import PrimaryNavItem from './PrimaryNavItem';
export default {
components: { PrimaryNavItem },
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
@ -13,40 +21,20 @@ export default {
}),
unreadCount() {
if (!this.notificationMetadata.unreadCount) {
return 0;
return '';
}
return this.notificationMetadata.unreadCount < 100
? this.notificationMetadata.unreadCount
? `${this.notificationMetadata.unreadCount}`
: '99+';
},
},
methods: {
showNotification() {
this.$router.push(`/app/accounts/${this.accountId}/notifications`);
},
},
methods: {},
};
</script>
<style scoped lang="scss">
.notifications {
font-size: var(--font-size-big);
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);
}
.notifications-link {
margin-bottom: var(--space-small);
}
</style>

View file

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

View file

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

View file

@ -50,8 +50,9 @@ describe('notificationBell', () => {
localVue,
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 () => {
@ -61,7 +62,7 @@ describe('notificationBell', () => {
localVue,
i18n: i18nConfig,
});
const statusViewTitle = notificationBell.find('.unread-badge');
expect(statusViewTitle.text()).toBe('99+');
const statusViewTitle = notificationBell.find('primary-nav-item-stub');
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 Vuex from 'vuex';
import VueI18n from 'vue-i18n';
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';
const localVue = createLocalVue();
localVue.use(Vuex);
localVue.use(VueI18n);
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({
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 () => {
await availabilityStatus
.find('.status-change--change-button')
.trigger('click');
await availabilityStatus
.find('.status-change li:last-child button')
await availabilityStatus;
availabilityStatus
.findAll('.status-change--dropdown-button')
.at(2)
.trigger('click');
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';
describe('SidemenuIcon', () => {
test('matches snapshot', () => {
const wrapper = mount(SidemenuIcon);
const wrapper = shallowMount(SidemenuIcon);
expect(wrapper.vm).toBeTruthy();
expect(wrapper.element).toMatchSnapshot();
});

View file

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

View file

@ -1,13 +1,23 @@
<template>
<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>
<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>
</template>
<script>
import { getContrastingTextColor } from '@chatwoot/utils';
export default {
props: {
title: {
@ -79,6 +89,8 @@ export default {
@import '~dashboard/assets/scss/variables';
.label {
display: inline-flex;
align-items: center;
font-weight: var(--font-weight-medium);
margin-right: var(--space-smaller);
margin-bottom: var(--space-smaller);
@ -89,11 +101,12 @@ export default {
.label--icon {
cursor: pointer;
margin-right: var(--space-smaller);
}
.label--icon,
.close--icon {
font-size: var(--font-size-micro);
cursor: pointer;
margin-left: var(--space-smaller);
}
&.small .label--icon,
@ -150,4 +163,8 @@ export default {
}
}
}
.label-action--button {
margin-bottom: var(--space-minus-micro);
}
</style>

View file

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

View file

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

View file

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

View file

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

View file

@ -22,7 +22,7 @@ export default {
},
color: {
type: String,
default: '#1f93ff',
default: '',
},
customStyle: {
type: Object,
@ -43,15 +43,21 @@ export default {
},
computed: {
style() {
return {
let style = {
width: `${this.size}px`,
height: `${this.size}px`,
borderRadius: this.rounded ? '50%' : 0,
lineHeight: `${this.size + Math.floor(this.size / 20)}px`,
backgroundColor: this.backgroundColor,
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() {
return this.initials || this.initial(this.username);
@ -81,6 +87,7 @@ export default {
align-items: center;
justify-content: 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>

View file

@ -1,7 +1,8 @@
<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') }}
</span>
</button>
</template>
<script>
import router from '../../routes/index';
@ -28,3 +29,4 @@ export default {
},
};
</script>
<style scoped></style>

View file

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