diff --git a/.circleci/config.yml b/.circleci/config.yml index c5a6430a3..5c9b27f03 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -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 diff --git a/.codeclimate.yml b/.codeclimate.yml index 667b0f340..af0c0714f 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -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" diff --git a/.env.example b/.env.example index ef74a7c0f..36ca66be1 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.eslintrc.js b/.eslintrc.js index c5cbba917..77ea9be7c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -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'] }, diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 000000000..7774f34f5 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,5 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" +npm run eslint +bundle exec rubocop -a +git add diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 000000000..f0e139ad8 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +sh bin/validate_push diff --git a/.rubocop.yml b/.rubocop.yml index b1c69ec70..8f8473547 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -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' diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..79a2f8d21 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..bfa510323 --- /dev/null +++ b/CONTRIBUTING.md @@ -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. diff --git a/LICENSE b/LICENSE index 037ad7885..f36fc7c53 100644 --- a/LICENSE +++ b/LICENSE @@ -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 diff --git a/README.md b/README.md index 41f15553d..eb083bec3 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,10 @@

- Deploy + Deploy + + + Deploy to DO

@@ -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. + + + Deploy to DO + + ### 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 diff --git a/app/builders/contact_inbox_builder.rb b/app/builders/contact_inbox_builder.rb index 73380190d..8ee1b3fec 100644 --- a/app/builders/contact_inbox_builder.rb +++ b/app/builders/contact_inbox_builder.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/agents_controller.rb b/app/controllers/api/v1/accounts/agents_controller.rb index a406e0cf3..a666d1a67 100644 --- a/app/controllers/api/v1/accounts/agents_controller.rb +++ b/app/controllers/api/v1/accounts/agents_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/callbacks_controller.rb b/app/controllers/api/v1/accounts/callbacks_controller.rb index 9fc05d531..7c2469c05 100644 --- a/app/controllers/api/v1/accounts/callbacks_controller.rb +++ b/app/controllers/api/v1/accounts/callbacks_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/canned_responses_controller.rb b/app/controllers/api/v1/accounts/canned_responses_controller.rb index 16e16e4a6..bbfa9c4b7 100644 --- a/app/controllers/api/v1/accounts/canned_responses_controller.rb +++ b/app/controllers/api/v1/accounts/canned_responses_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index e22341d90..76fd206f4 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index 2d91e1cbd..22eee629e 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb b/app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb index 13cba46ea..419540438 100644 --- a/app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb +++ b/app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb @@ -39,7 +39,7 @@ class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Account :attribute_display_type, :attribute_key, :attribute_model, - :default_value + attribute_values: [] ) end diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index d09565da3..0c80d9983 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -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 diff --git a/app/controllers/api/v1/profiles_controller.rb b/app/controllers/api/v1/profiles_controller.rb index a92c479bc..b915f0775 100644 --- a/app/controllers/api/v1/profiles_controller.rb +++ b/app/controllers/api/v1/profiles_controller.rb @@ -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 diff --git a/app/controllers/api/v1/widget/contacts_controller.rb b/app/controllers/api/v1/widget/contacts_controller.rb index eafc220a3..d745c4153 100644 --- a/app/controllers/api/v1/widget/contacts_controller.rb +++ b/app/controllers/api/v1/widget/contacts_controller.rb @@ -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', diff --git a/app/controllers/concerns/request_exception_handler.rb b/app/controllers/concerns/request_exception_handler.rb index 3ce486012..51061017e 100644 --- a/app/controllers/concerns/request_exception_handler.rb +++ b/app/controllers/concerns/request_exception_handler.rb @@ -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 diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index ad5b0ba24..783b138af 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -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 diff --git a/app/controllers/devise_overrides/confirmations_controller.rb b/app/controllers/devise_overrides/confirmations_controller.rb index 02d412cb1..1a6dc4209 100644 --- a/app/controllers/devise_overrides/confirmations_controller.rb +++ b/app/controllers/devise_overrides/confirmations_controller.rb @@ -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 diff --git a/app/controllers/webhooks/instagram_controller.rb b/app/controllers/webhooks/instagram_controller.rb index bd6fee1f5..2338ca7d1 100644 --- a/app/controllers/webhooks/instagram_controller.rb +++ b/app/controllers/webhooks/instagram_controller.rb @@ -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 diff --git a/app/drops/conversation_drop.rb b/app/drops/conversation_drop.rb index 03bab2f58..ffd664659 100644 --- a/app/drops/conversation_drop.rb +++ b/app/drops/conversation_drop.rb @@ -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 diff --git a/app/drops/inbox_drop.rb b/app/drops/inbox_drop.rb index 7972e7325..563916d5f 100644 --- a/app/drops/inbox_drop.rb +++ b/app/drops/inbox_drop.rb @@ -1,2 +1,5 @@ class InboxDrop < BaseDrop + def name + @obj.try(:name) + end end diff --git a/app/drops/message_drop.rb b/app/drops/message_drop.rb index cbb3f1782..3423f0623 100644 --- a/app/drops/message_drop.rb +++ b/app/drops/message_drop.rb @@ -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 diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb index f2641dde0..3409a2450 100644 --- a/app/finders/conversation_finder.rb +++ b/app/finders/conversation_finder.rb @@ -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 diff --git a/app/helpers/api/v1/inboxes_helper.rb b/app/helpers/api/v1/inboxes_helper.rb new file mode 100644 index 000000000..08f000cc6 --- /dev/null +++ b/app/helpers/api/v1/inboxes_helper.rb @@ -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 diff --git a/app/helpers/message_format_helper.rb b/app/helpers/message_format_helper.rb index e27e61fce..3dd8d8f23 100644 --- a/app/helpers/message_format_helper.rb +++ b/app/helpers/message_format_helper.rb @@ -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 diff --git a/app/javascript/dashboard/api/auth.js b/app/javascript/dashboard/api/auth.js index c41a43624..e5ed77a34 100644 --- a/app/javascript/dashboard/api/auth.js +++ b/app/javascript/dashboard/api/auth.js @@ -166,4 +166,8 @@ export default { profile: { ...availabilityData }, }); }, + + deleteAvatar() { + return axios.delete(endPoints('deleteAvatar').url); + }, }; diff --git a/app/javascript/dashboard/api/contacts.js b/app/javascript/dashboard/api/contacts.js index 08c969248..8bea5f74b 100644 --- a/app/javascript/dashboard/api/contacts.js +++ b/app/javascript/dashboard/api/contacts.js @@ -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); diff --git a/app/javascript/dashboard/api/endPoints.js b/app/javascript/dashboard/api/endPoints.js index 0b801fdb3..c9d0955ef 100644 --- a/app/javascript/dashboard/api/endPoints.js +++ b/app/javascript/dashboard/api/endPoints.js @@ -36,6 +36,10 @@ const endPoints = { }, params: { omniauth_token: '' }, }, + + deleteAvatar: { + url: '/api/v1/profile/avatar', + }, }; export default page => { diff --git a/app/javascript/dashboard/api/inbox/conversation.js b/app/javascript/dashboard/api/inbox/conversation.js index 0c90f3fd4..9f6b8baf1 100644 --- a/app/javascript/dashboard/api/inbox/conversation.js +++ b/app/javascript/dashboard/api/inbox/conversation.js @@ -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, }, }); } diff --git a/app/javascript/dashboard/api/specs/contacts.spec.js b/app/javascript/dashboard/api/specs/contacts.spec.js index 08e6720d7..dfdc3a48b 100644 --- a/app/javascript/dashboard/api/specs/contacts.spec.js +++ b/app/javascript/dashboard/api/specs/contacts.spec.js @@ -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 + ); + }); }); }); diff --git a/app/javascript/dashboard/api/specs/inbox/conversation.spec.js b/app/javascript/dashboard/api/specs/inbox/conversation.spec.js index 814b83446..d276b8053 100644 --- a/app/javascript/dashboard/api/specs/inbox/conversation.spec.js +++ b/app/javascript/dashboard/api/specs/inbox/conversation.spec.js @@ -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 } } + ); + }); }); }); diff --git a/app/javascript/dashboard/assets/scss/_animations.scss b/app/javascript/dashboard/assets/scss/_animations.scss index ebb20a35e..4f5a2d43f 100644 --- a/app/javascript/dashboard/assets/scss/_animations.scss +++ b/app/javascript/dashboard/assets/scss/_animations.scss @@ -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; } diff --git a/app/javascript/dashboard/assets/scss/_foundation-custom.scss b/app/javascript/dashboard/assets/scss/_foundation-custom.scss index 1cb529b97..1cfd002cf 100644 --- a/app/javascript/dashboard/assets/scss/_foundation-custom.scss +++ b/app/javascript/dashboard/assets/scss/_foundation-custom.scss @@ -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); +} diff --git a/app/javascript/dashboard/assets/scss/_foundation-settings.scss b/app/javascript/dashboard/assets/scss/_foundation-settings.scss index c74b9f05b..13b7dfafd 100644 --- a/app/javascript/dashboard/assets/scss/_foundation-settings.scss +++ b/app/javascript/dashboard/assets/scss/_foundation-settings.scss @@ -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; diff --git a/app/javascript/dashboard/assets/scss/_utility-helpers.scss b/app/javascript/dashboard/assets/scss/_utility-helpers.scss index 1072a0878..7b528d1c2 100644 --- a/app/javascript/dashboard/assets/scss/_utility-helpers.scss +++ b/app/javascript/dashboard/assets/scss/_utility-helpers.scss @@ -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); } diff --git a/app/javascript/dashboard/assets/scss/_variables.scss b/app/javascript/dashboard/assets/scss/_variables.scss index 447e86a25..ea8334c57 100644 --- a/app/javascript/dashboard/assets/scss/_variables.scss +++ b/app/javascript/dashboard/assets/scss/_variables.scss @@ -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; diff --git a/app/javascript/dashboard/assets/scss/plugins/_dropdown.scss b/app/javascript/dashboard/assets/scss/plugins/_dropdown.scss index 7009a077f..8513053b8 100644 --- a/app/javascript/dashboard/assets/scss/plugins/_dropdown.scss +++ b/app/javascript/dashboard/assets/scss/plugins/_dropdown.scss @@ -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; diff --git a/app/javascript/dashboard/assets/scss/views/settings/inbox.scss b/app/javascript/dashboard/assets/scss/views/settings/inbox.scss index e3b45726f..d2b145286 100644 --- a/app/javascript/dashboard/assets/scss/views/settings/inbox.scss +++ b/app/javascript/dashboard/assets/scss/views/settings/inbox.scss @@ -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; diff --git a/app/javascript/dashboard/assets/scss/widgets/_buttons.scss b/app/javascript/dashboard/assets/scss/widgets/_buttons.scss index 1a72d51df..55c7bc3de 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_buttons.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_buttons.scss @@ -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; } - } diff --git a/app/javascript/dashboard/assets/scss/widgets/_conversation-card.scss b/app/javascript/dashboard/assets/scss/widgets/_conversation-card.scss index a7a256b4d..0ad6dff38 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_conversation-card.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_conversation-card.scss @@ -85,11 +85,6 @@ text-overflow: ellipsis; white-space: nowrap; width: 27rem; - - .small-icon { - font-size: $font-size-mini; - vertical-align: top; - } } .conversation--meta { diff --git a/app/javascript/dashboard/assets/scss/widgets/_conversation-view.scss b/app/javascript/dashboard/assets/scss/widgets/_conversation-view.scss index 70bf16b95..39cf6ad54 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_conversation-view.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_conversation-view.scss @@ -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; diff --git a/app/javascript/dashboard/assets/scss/widgets/_modal.scss b/app/javascript/dashboard/assets/scss/widgets/_modal.scss index a017f776a..cf48bcd6e 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_modal.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_modal.scss @@ -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, diff --git a/app/javascript/dashboard/assets/scss/widgets/_sidemenu.scss b/app/javascript/dashboard/assets/scss/widgets/_sidemenu.scss index aafff16ff..e5e9276fd 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_sidemenu.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_sidemenu.scss @@ -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; diff --git a/app/javascript/dashboard/components/Accordion/AccordionItem.vue b/app/javascript/dashboard/components/Accordion/AccordionItem.vue index bfe817285..b24b6f1be 100644 --- a/app/javascript/dashboard/components/Accordion/AccordionItem.vue +++ b/app/javascript/dashboard/components/Accordion/AccordionItem.vue @@ -1,6 +1,6 @@ @@ -46,6 +60,9 @@ import ContactInfoPanel from './ContactInfoPanel'; import CreateContact from 'dashboard/routes/dashboard/conversation/contact/CreateContact'; import TableFooter from 'dashboard/components/widgets/TableFooter'; import ImportContacts from './ImportContacts.vue'; +import ContactsAdvancedFilters from './ContactsAdvancedFilters.vue'; +import contactFilterItems from '../contactFilterItems'; +import filterQueryGenerator from '../../../../helper/filterQueryGenerator'; const DEFAULT_PAGE = 1; @@ -57,6 +74,7 @@ export default { ContactInfoPanel, CreateContact, ImportContacts, + ContactsAdvancedFilters, }, props: { label: { type: String, default: '' }, @@ -68,6 +86,13 @@ export default { showImportModal: false, selectedContactId: '', sortConfig: { name: 'asc' }, + showFiltersModal: false, + contactFilterItems: contactFilterItems.map(filter => ({ + ...filter, + attributeName: this.$t( + `CONTACTS_FILTER.ATTRIBUTES.${filter.attributeI18nKey}` + ), + })), }; }, computed: { @@ -133,7 +158,7 @@ export default { fetchContacts(page) { this.updatePageParam(page); let value = ''; - if(this.searchQuery.charAt(0) === '+') { + if (this.searchQuery.charAt(0) === '+') { value = this.searchQuery.substring(1); } else { value = this.searchQuery; @@ -188,6 +213,20 @@ export default { this.sortConfig = params; this.fetchContacts(this.meta.currentPage); }, + onToggleFilters() { + this.showFiltersModal = !this.showFiltersModal; + }, + onApplyFilter(payload) { + this.closeContactInfoPanel(); + this.$store.dispatch('contacts/filter', { + queryPayload: filterQueryGenerator(payload), + }); + this.showFiltersModal = false; + }, + clearFilters() { + this.$store.dispatch('contacts/clearContactFilters'); + this.fetchContacts(this.pageParameter); + }, }, }; diff --git a/app/javascript/dashboard/routes/dashboard/contacts/components/Header.vue b/app/javascript/dashboard/routes/dashboard/contacts/components/Header.vue index f0118e17d..2758da76d 100644 --- a/app/javascript/dashboard/routes/dashboard/contacts/components/Header.vue +++ b/app/javascript/dashboard/routes/dashboard/contacts/components/Header.vue @@ -8,7 +8,7 @@
- + {{ $t('CONTACTS_PAGE.SEARCH_BUTTON') }}
- +
+
+ + {{ $t('CONTACTS_PAGE.FILTER_CONTACTS') }} + +
@@ -38,7 +50,8 @@ {{ $t('IMPORT_CONTACTS.BUTTON_LABEL') }} @@ -49,6 +62,8 @@ @@ -155,4 +180,17 @@ export default { visibility: visible; } } +.filters__button-wrap { + position: relative; + + .filters__applied-indicator { + position: absolute; + height: var(--space-small); + width: var(--space-small); + top: var(--space-smaller); + right: var(--space-slab); + background-color: var(--s-500); + border-radius: var(--border-radius-rounded); + } +} diff --git a/app/javascript/dashboard/routes/dashboard/contacts/components/ReminderItem.vue b/app/javascript/dashboard/routes/dashboard/contacts/components/ReminderItem.vue index 49755d1d2..531e9141e 100644 --- a/app/javascript/dashboard/routes/dashboard/contacts/components/ReminderItem.vue +++ b/app/javascript/dashboard/routes/dashboard/contacts/components/ReminderItem.vue @@ -25,7 +25,7 @@
- - - + @@ -254,7 +257,7 @@ export default { .close-button { position: absolute; right: $space-two; - top: $space-slab + $space-two; + top: $space-slab; font-size: $font-size-default; color: $color-heading; z-index: 9989; diff --git a/app/javascript/dashboard/routes/dashboard/conversation/ConversationAction.vue b/app/javascript/dashboard/routes/dashboard/conversation/ConversationAction.vue index 550a155f0..d7ea2aa86 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/ConversationAction.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/ConversationAction.vue @@ -8,7 +8,8 @@