Merge branch 'develop' into CW-3013

This commit is contained in:
Muhsin Keloth
2024-02-07 12:38:11 +05:30
committed by GitHub
348 changed files with 4727 additions and 3200 deletions
+1 -10
View File
@@ -203,13 +203,4 @@ AllCops:
- 'config/environments/**/*'
- 'tmp/**/*'
- 'storage/**/*'
- 'db/migrate/20200225162150_init_schema.rb'
- 'db/migrate/20210611180222_create_active_storage_variant_records.active_storage.rb'
- 'db/migrate/20210611180221_add_service_name_to_active_storage_blobs.active_storage.rb'
- db/migrate/20200309213132_add_account_id_to_agent_bot_inboxes.rb
- db/migrate/20200331095710_add_identifier_to_contact.rb
- db/migrate/20200429082655_add_medium_to_twilio_sms.rb
- db/migrate/20200503151130_add_account_feature_flag.rb
- db/migrate/20200927135222_add_last_activity_at_to_conversation.rb
- db/migrate/20210306170117_add_last_activity_at_to_contacts.rb
- db/migrate/20220809104508_revert_cascading_indexes.rb
- 'db/migrate/20230426130150_init_schema.rb'
+6 -6
View File
@@ -277,7 +277,7 @@ GEM
gli (2.21.1)
globalid (1.2.1)
activesupport (>= 6.1)
gmail_xoauth (0.4.2)
gmail_xoauth (0.4.3)
oauth (>= 0.3.6)
google-apis-core (0.11.0)
addressable (~> 2.5, >= 2.5.1)
@@ -488,14 +488,14 @@ GEM
newrelic_rpm (9.6.0)
base64
nio4r (2.7.0)
nokogiri (1.16.0)
nokogiri (1.16.2)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.16.0-arm64-darwin)
nokogiri (1.16.2-arm64-darwin)
racc (~> 1.4)
nokogiri (1.16.0-x86_64-darwin)
nokogiri (1.16.2-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.16.0-x86_64-linux)
nokogiri (1.16.2-x86_64-linux)
racc (~> 1.4)
numo-narray (0.9.2.1)
oauth (1.1.0)
@@ -794,7 +794,7 @@ GEM
valid_email2 (4.0.6)
activemodel (>= 3.2)
mail (~> 2.5)
version_gem (1.1.2)
version_gem (1.1.3)
warden (1.2.9)
rack (>= 2.0.9)
web-console (4.2.1)
+1 -1
View File
@@ -1,4 +1,4 @@
Copyright (c) 2017-2021 Chatwoot Inc.
Copyright (c) 2017-2024 Chatwoot Inc.
Portions of this software are licensed as follows:
@@ -9,7 +9,7 @@
padding: 4px 12px;
.icon-container {
margin-right: 4px;
margin-right: 2px;
}
+13 -3
View File
@@ -2,7 +2,7 @@
class AccountBuilder
include CustomExceptions::Account
pattr_initialize [:account_name!, :email!, :confirmed, :user, :user_full_name, :user_password, :super_admin, :locale]
pattr_initialize [:account_name, :email!, :confirmed, :user, :user_full_name, :user_password, :super_admin, :locale]
def perform
if @user.nil?
@@ -21,6 +21,16 @@ class AccountBuilder
private
def user_full_name
# the empty string ensures that not-null constraint is not violated
@user_full_name || ''
end
def account_name
# the empty string ensures that not-null constraint is not violated
@account_name || ''
end
def validate_email
address = ValidEmail2::Address.new(@email)
if address.valid? # && !address.disposable?
@@ -39,7 +49,7 @@ class AccountBuilder
end
def create_account
@account = Account.create!(name: @account_name, locale: I18n.locale)
@account = Account.create!(name: account_name, locale: I18n.locale)
Current.account = @account
end
@@ -64,7 +74,7 @@ class AccountBuilder
@user = User.new(email: @email,
password: user_password,
password_confirmation: user_password,
name: @user_full_name)
name: user_full_name)
@user.type = 'SuperAdmin' if @super_admin
@user.confirm if @confirmed
@user.save!
+60
View File
@@ -0,0 +1,60 @@
# The AgentBuilder class is responsible for creating a new agent.
# It initializes with necessary attributes and provides a perform method
# to create a user and account user in a transaction.
class AgentBuilder
# Initializes an AgentBuilder with necessary attributes.
# @param email [String] the email of the user.
# @param name [String] the name of the user.
# @param role [String] the role of the user, defaults to 'agent' if not provided.
# @param inviter [User] the user who is inviting the agent (Current.user in most cases).
# @param availability [String] the availability status of the user, defaults to 'offline' if not provided.
# @param auto_offline [Boolean] the auto offline status of the user.
pattr_initialize [:email, { name: '' }, :inviter, :account, { role: :agent }, { availability: :offline }, { auto_offline: false }]
# Creates a user and account user in a transaction.
# @return [User] the created user.
def perform
ActiveRecord::Base.transaction do
@user = find_or_create_user
send_confirmation_if_required
create_account_user
end
@user
end
private
# Finds a user by email or creates a new one with a temporary password.
# @return [User] the found or created user.
def find_or_create_user
user = User.find_by(email: email)
return user if user
temp_password = "1!aA#{SecureRandom.alphanumeric(12)}"
User.create!(email: email, name: name, password: temp_password, password_confirmation: temp_password)
end
# Sends confirmation instructions if the user is persisted and not confirmed.
def send_confirmation_if_required
@user.send_confirmation_instructions if user_needs_confirmation?
end
# Checks if the user needs confirmation.
# @return [Boolean] true if the user is persisted and not confirmed, false otherwise.
def user_needs_confirmation?
@user.persisted? && !@user.confirmed?
end
# Creates an account user linking the user to the current account.
def create_account_user
AccountUser.create!({
account_id: account.id,
user_id: @user.id,
inviter_id: inviter.id
}.merge({
role: role,
availability: availability,
auto_offline: auto_offline
}.compact))
end
end
@@ -1,16 +1,26 @@
class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
before_action :fetch_agent, except: [:create, :index]
before_action :fetch_agent, except: [:create, :index, :bulk_create]
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]
before_action :validate_limit_for_bulk_create, only: [:bulk_create]
def index
@agents = agents
end
def create; end
def create
builder = AgentBuilder.new(
email: new_agent_params['email'],
name: new_agent_params['name'],
role: new_agent_params['role'],
availability: new_agent_params['availability'],
auto_offline: new_agent_params['auto_offline'],
inviter: current_user,
account: Current.account
)
builder.perform
end
def update
@agent.update!(agent_params.slice(:name).compact)
@@ -23,6 +33,21 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
head :ok
end
def bulk_create
emails = params[:emails]
emails.each do |email|
builder = AgentBuilder.new(
email: email,
name: email.split('@').first,
inviter: current_user,
account: Current.account
)
builder.perform
end
head :ok
end
private
def check_authorization
@@ -33,47 +58,34 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
@agent = agents.find(params[:id])
end
def find_user
@user = User.find_by(email: new_agent_params[:email])
end
# TODO: move this to a builder and combine the save account user method into a builder
# ensure the account user association is also created in a single transaction
def create_user
return @user.send_confirmation_instructions if @user
@user = User.create!(new_agent_params.slice(:email, :name, :password, :password_confirmation))
end
def save_account_user
AccountUser.create!({
account_id: Current.account.id,
user_id: @user.id,
inviter_id: current_user.id
}.merge({
role: new_agent_params[:role],
availability: new_agent_params[:availability],
auto_offline: new_agent_params[:auto_offline]
}.compact))
end
def agent_params
params.require(:agent).permit(:name, :email, :name, :role, :availability, :auto_offline)
end
def new_agent_params
# intial string ensures the password requirements are met
temp_password = "1!aA#{SecureRandom.alphanumeric(12)}"
params.require(:agent).permit(:email, :name, :role, :availability, :auto_offline)
.merge!(password: temp_password, password_confirmation: temp_password, inviter: current_user)
end
def agents
@agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] })
end
def validate_limit_for_bulk_create
limit_available = params[:emails].count <= available_agent_count
render_payment_required('Account limit exceeded. Please purchase more licenses') unless limit_available
end
def validate_limit
render_payment_required('Account limit exceeded. Please purchase more licenses') if agents.count >= Current.account.usage_limits[:agents]
render_payment_required('Account limit exceeded. Please purchase more licenses') unless can_add_agent?
end
def available_agent_count
Current.account.usage_limits[:agents] - agents.count
end
def can_add_agent?
available_agent_count.positive?
end
def delete_user_record(agent)
@@ -2,7 +2,7 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
RESULTS_PER_PAGE = 15
include DateRangeHelper
before_action :fetch_notification, only: [:update, :destroy, :snooze]
before_action :fetch_notification, only: [:update, :destroy, :snooze, :unread]
before_action :set_primary_actor, only: [:read_all]
before_action :set_current_page, only: [:index]
@@ -29,11 +29,25 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
render json: @notification
end
def unread
@notification.update(read_at: nil)
render json: @notification
end
def destroy
@notification.destroy
head :ok
end
def destroy_all
if params[:type] == 'read'
::Notification::DeleteNotificationJob.perform_later(Current.user, type: :read)
else
::Notification::DeleteNotificationJob.perform_later(Current.user, type: :all)
end
head :ok
end
def unread_count
@unread_count = notification_finder.unread_count
render json: @unread_count
@@ -5,11 +5,13 @@ class Api::V1::AccountsController < Api::BaseController
skip_before_action :authenticate_user!, :set_current_user, :handle_with_exception,
only: [:create], raise: false
before_action :check_signup_enabled, only: [:create]
before_action :ensure_account_name, only: [:create]
before_action :validate_captcha, only: [:create]
before_action :fetch_account, except: [:create]
before_action :check_authorization, except: [:create]
rescue_from CustomExceptions::Account::InvalidEmail,
CustomExceptions::Account::InvalidParams,
CustomExceptions::Account::UserExists,
CustomExceptions::Account::UserErrors,
with: :render_error_response
@@ -53,6 +55,17 @@ class Api::V1::AccountsController < Api::BaseController
private
def ensure_account_name
# ensure that account_name and user_full_name is present
# this is becuase the account builder and the models validations are not triggered
# this change is to align the behaviour with the v2 accounts controller
# since these values are not required directly there
return if account_params[:account_name].present?
return if account_params[:user_full_name].present?
raise CustomExceptions::Account::InvalidParams.new({})
end
def get_cache_keys
{
label: fetch_value_for_key(params[:id], Label.name.underscore),
@@ -31,6 +31,11 @@ class Api::V1::ProfilesController < Api::BaseController
head :ok
end
def resend_confirmation
@user.send_confirmation_instructions unless @user.confirmed?
head :ok
end
private
def set_user
@@ -0,0 +1,48 @@
class Api::V2::AccountsController < Api::BaseController
include AuthHelper
skip_before_action :authenticate_user!, :set_current_user, :handle_with_exception,
only: [:create], raise: false
before_action :check_signup_enabled, only: [:create]
before_action :validate_captcha, only: [:create]
before_action :fetch_account, except: [:create]
before_action :check_authorization, except: [:create]
rescue_from CustomExceptions::Account::InvalidEmail,
CustomExceptions::Account::UserExists,
CustomExceptions::Account::UserErrors,
with: :render_error_response
def create
@user, @account = AccountBuilder.new(
email: account_params[:email],
user_password: account_params[:password],
user: current_user
).perform
if @user
send_auth_headers(@user)
render 'api/v1/accounts/create', format: :json, locals: { resource: @user }
else
render_error_response(CustomExceptions::Account::SignupFailed.new({}))
end
end
private
def fetch_account
@account = current_user.accounts.find(params[:id])
@current_account_user = @account.account_users.find_by(user_id: current_user.id)
end
def account_params
params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :auto_resolve_duration, :user_full_name)
end
def check_signup_enabled
raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
end
def validate_captcha
raise ActionController::InvalidAuthenticityToken, 'Invalid Captcha' unless ChatwootCaptcha.new(params[:h_captcha_client_response]).valid?
end
end
@@ -22,19 +22,24 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
i.value = value
i.save!
end
# rubocop:disable Rails/I18nLocaleTexts
redirect_to super_admin_settings_path, notice: 'App Configs updated successfully'
# rubocop:enable Rails/I18nLocaleTexts
redirect_to super_admin_settings_path, notice: "App Configs - #{@config.titleize} updated successfully"
end
private
def set_config
@config = params[:config]
@config = params[:config] || 'general'
end
def allowed_configs
@allowed_configs = %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET]
@allowed_configs = case @config
when 'facebook'
%w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT]
when 'email'
['MAILER_INBOUND_EMAIL_DOMAIN']
else
%w[ENABLE_ACCOUNT_SIGNUP]
end
end
end
+6 -1
View File
@@ -26,6 +26,7 @@ class NotificationFinder
def set_up
find_all_notifications
filter_by_read_status
filter_by_status
end
@@ -37,11 +38,15 @@ class NotificationFinder
@notifications = @notifications.where('snoozed_until > ?', DateTime.now.utc) if params[:status] == 'snoozed'
end
def filter_by_read_status
@notifications = @notifications.where.not(read_at: nil) if params[:type] == 'read'
end
def current_page
params[:page] || 1
end
def notifications
@notifications.page(current_page).per(RESULTS_PER_PAGE).order(last_activity_at: :desc)
@notifications.page(current_page).per(RESULTS_PER_PAGE).order(last_activity_at: params[:sort_order] || :desc)
end
end
@@ -0,0 +1,9 @@
module SuperAdmin::AccountFeaturesHelper
def self.account_features
YAML.safe_load(Rails.root.join('config/features.yml').read).freeze
end
def self.account_premium_features
account_features.filter { |feature| feature['premium'] }.pluck('name')
end
end
+4 -1
View File
@@ -2,12 +2,13 @@
<div
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
id="app"
class="app-wrapper h-full flex-grow-0 min-h-0 w-full"
class="flex-grow-0 w-full h-full min-h-0 app-wrapper"
:class="{ 'app-rtl--wrapper': isRTLView }"
:dir="isRTLView ? 'rtl' : 'ltr'"
>
<update-banner :latest-chatwoot-version="latestChatwootVersion" />
<template v-if="currentAccountId">
<pending-email-verification-banner />
<payment-pending-banner />
<upgrade-banner />
</template>
@@ -32,6 +33,7 @@ import NetworkNotification from './components/NetworkNotification.vue';
import UpdateBanner from './components/app/UpdateBanner.vue';
import UpgradeBanner from './components/app/UpgradeBanner.vue';
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
import vueActionCable from './helper/actionCable';
import WootSnackbarBox from './components/SnackbarContainer.vue';
import rtlMixin from 'shared/mixins/rtlMixin';
@@ -52,6 +54,7 @@ export default {
PaymentPendingBanner,
WootSnackbarBox,
UpgradeBanner,
PendingEmailVerificationBanner,
},
mixins: [rtlMixin],
+4
View File
@@ -98,4 +98,8 @@ export default {
},
});
},
resendConfirmation() {
const urlData = endPoints('resendConfirmation');
return axios.post(urlData.url);
},
};
@@ -47,6 +47,10 @@ const endPoints = {
setActiveAccount: {
url: '/api/v1/profile/set_active_account',
},
resendConfirmation: {
url: '/api/v1/profile/resend_confirmation',
},
};
export default page => {
@@ -25,9 +25,29 @@ class NotificationsAPI extends ApiClient {
});
}
unRead(id) {
return axios.post(`${this.url}/${id}/unread`);
}
readAll() {
return axios.post(`${this.url}/read_all`);
}
delete(id) {
return axios.delete(`${this.url}/${id}`);
}
deleteAll({ type = 'all' }) {
return axios.post(`${this.url}/destroy_all`, {
type,
});
}
snooze({ id, snoozedUntil = null }) {
return axios.post(`${this.url}/${id}/snooze`, {
snoozed_until: snoozedUntil,
});
}
}
export default new NotificationsAPI();
@@ -1,6 +1,6 @@
<template>
<div
class="conversations-list-wrap flex-basis-clamp flex-shrink-0 flex-basis-custom overflow-hidden flex flex-col border-r rtl:border-r-0 rtl:border-l border-slate-50 dark:border-slate-800/50"
class="conversations-list-wrap flex-basis-clamp flex-shrink-0 overflow-hidden flex flex-col border-r rtl:border-r-0 rtl:border-l border-slate-50 dark:border-slate-800/50"
:class="{
hide: !showConversationList,
'list--full-width': isOnExpandedLayout,
@@ -0,0 +1,43 @@
<template>
<banner
v-if="shouldShowBanner"
color-scheme="alert"
:banner-message="bannerMessage"
:action-button-label="actionButtonMessage"
action-button-icon="mail"
has-action-button
@click="resendVerificationEmail"
/>
</template>
<script>
import Banner from 'dashboard/components/ui/Banner.vue';
import { mapGetters } from 'vuex';
import accountMixin from 'dashboard/mixins/account';
import alertMixin from 'shared/mixins/alertMixin';
export default {
components: { Banner },
mixins: [accountMixin, alertMixin],
computed: {
...mapGetters({
currentUser: 'getCurrentUser',
}),
bannerMessage() {
return this.$t('APP_GLOBAL.EMAIL_VERIFICATION_PENDING');
},
actionButtonMessage() {
return this.$t('APP_GLOBAL.RESEND_VERIFICATION_MAIL');
},
shouldShowBanner() {
return !this.currentUser.confirmed;
},
},
methods: {
resendVerificationEmail() {
this.$store.dispatch('resendConfirmation');
this.showAlert(this.$t('APP_GLOBAL.EMAIL_VERIFICATION_SENT'));
},
},
};
</script>
@@ -1,6 +1,6 @@
<template>
<div
class="banner flex items-center h-12 gap-4 text-white dark:text-white text-xs py-3 px-4 justify-center"
class="flex items-center justify-center h-12 gap-4 px-4 py-3 text-xs text-white banner dark:text-white"
:class="bannerClasses"
>
<span class="banner-message">
@@ -18,7 +18,7 @@
<woot-button
v-if="hasActionButton"
size="tiny"
icon="arrow-right"
:icon="actionButtonIcon"
:variant="actionButtonVariant"
color-scheme="primary"
class-names="banner-action__button"
@@ -67,6 +67,10 @@ export default {
type: String,
default: '',
},
actionButtonIcon: {
type: String,
default: 'arrow-right',
},
colorScheme: {
type: String,
default: '',
@@ -6,6 +6,7 @@
<conversation-header
v-if="currentChat.id"
:chat="currentChat"
:is-inbox-view="isInboxView"
:is-contact-panel-open="isContactPanelOpen"
:show-back-button="isOnExpandedLayout"
@contact-panel-toggle="onToggleContactPanel"
@@ -30,6 +31,7 @@
<messages-view
v-if="currentChat.id"
:inbox-id="inboxId"
:is-inbox-view="isInboxView"
:is-contact-panel-open="isContactPanelOpen"
@contact-panel-toggle="onToggleContactPanel"
/>
@@ -80,6 +82,10 @@ export default {
default: '',
required: false,
},
isInboxView: {
type: Boolean,
default: false,
},
isContactPanelOpen: {
type: Boolean,
default: true,
@@ -86,7 +86,8 @@ import MoreActions from './MoreActions.vue';
import Thumbnail from '../Thumbnail.vue';
import wootConstants from 'dashboard/constants/globals';
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { conversationReopenTime } from 'dashboard/helper/snoozeHelpers';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { frontendURL } from 'dashboard/helper/URLHelper';
export default {
components: {
@@ -109,6 +110,10 @@ export default {
type: Boolean,
default: false,
},
isInboxView: {
type: Boolean,
default: false,
},
},
computed: {
...mapGetters({
@@ -123,6 +128,9 @@ export default {
params: { accountId, inbox_id: inboxId, label, teamId },
name,
} = this.$route;
if (this.isInboxView) {
return frontendURL(`accounts/${accountId}/inbox`);
}
return conversationListPageURL({
accountId,
inboxId,
@@ -150,7 +158,7 @@ export default {
if (snoozedUntil) {
return `${this.$t(
'CONVERSATION.HEADER.SNOOZED_UNTIL'
)} ${conversationReopenTime(snoozedUntil)}`;
)} ${snoozedReopenTime(snoozedUntil)}`;
}
return this.$t('CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_REPLY');
},
@@ -12,7 +12,10 @@
variant="smooth"
size="tiny"
color-scheme="secondary"
class="rounded-bl-calc rtl:rotate-180 rounded-tl-calc fixed top-[9.5rem] md:top-[6.25rem] z-10 bg-white dark:bg-slate-700 border-slate-50 dark:border-slate-600 border-solid border border-r-0 box-border"
class="rounded-bl-calc rtl:rotate-180 rounded-tl-calc fixed z-10 bg-white dark:bg-slate-700 border-slate-50 dark:border-slate-600 border-solid border border-r-0 box-border"
:class="
isInboxView ? 'top-52 md:top-40' : 'top-[9.5rem] md:top-[6.25rem]'
"
:icon="isRightOrLeftIcon"
@click="onToggleContactPanel"
/>
@@ -142,6 +145,10 @@ export default {
type: Boolean,
default: false,
},
isInboxView: {
type: Boolean,
default: false,
},
},
data() {
+1
View File
@@ -17,4 +17,5 @@ export const FEATURE_FLAGS = {
VOICE_RECORDER: 'voice_recorder',
AUDIT_LOGS: 'audit_logs',
INSERT_ARTICLE_IN_REPLY: 'insert_article_in_reply',
INBOX_VIEW: 'inbox_view',
};
@@ -102,3 +102,12 @@ export const OPEN_AI_EVENTS = Object.freeze({
export const GENERAL_EVENTS = Object.freeze({
COMMAND_BAR: 'Used commandbar',
});
export const INBOX_EVENTS = Object.freeze({
OPEN_CONVERSATION_VIA_INBOX: 'Opened conversation via inbox',
MARK_NOTIFICATION_AS_READ: 'Marked notification as read',
MARK_ALL_NOTIFICATIONS_AS_READ: 'Marked all notifications as read',
MARK_NOTIFICATION_AS_UNREAD: 'Marked notification as unread',
DELETE_NOTIFICATION: 'Deleted notification',
DELETE_ALL_NOTIFICATIONS: 'Deleted all notifications',
});
@@ -86,3 +86,6 @@ export const getConversationDashboardRoute = routeName => {
return null;
}
};
export const isAInboxViewRoute = routeName =>
['inbox_view_conversation'].includes(routeName);
@@ -55,7 +55,7 @@ export const findSnoozeTime = (snoozeType, currentDate = new Date()) => {
return parsedDate ? getUnixTime(parsedDate) : null;
};
export const conversationReopenTime = snoozedUntil => {
export const snoozedReopenTime = snoozedUntil => {
if (!snoozedUntil) {
return null;
}
@@ -5,6 +5,7 @@ import {
isAConversationRoute,
routeIsAccessibleFor,
validateLoggedInRoutes,
isAInboxViewRoute,
} from '../routeHelpers';
describe('#getCurrentAccount', () => {
@@ -134,3 +135,10 @@ describe('getConversationDashboardRoute', () => {
expect(getConversationDashboardRoute('non_existent_route')).toBeNull();
});
});
describe('isAInboxViewRoute', () => {
it('returns true if inbox view route name is provided', () => {
expect(isAInboxViewRoute('inbox_view_conversation')).toBe(true);
expect(isAInboxViewRoute('inbox_conversation')).toBe(false);
});
});
@@ -1,6 +1,6 @@
import {
findSnoozeTime,
conversationReopenTime,
snoozedReopenTime,
findStartOfNextWeek,
findStartOfNextMonth,
findNextDay,
@@ -88,13 +88,13 @@ describe('#Snooze Helpers', () => {
});
});
describe('conversationReopenTime', () => {
describe('snoozedReopenTime', () => {
it('should return nil if snoozedUntil is nil', () => {
expect(conversationReopenTime(null)).toEqual(null);
expect(snoozedReopenTime(null)).toEqual(null);
});
it('should return formatted date if snoozedUntil is not nil', () => {
expect(conversationReopenTime('2023-06-07T09:00:00.000Z')).toEqual(
expect(snoozedReopenTime('2023-06-07T09:00:00.000Z')).toEqual(
'7 Jun, 9.00am'
);
});
@@ -112,7 +112,8 @@
"REMOVE_LABEL": "Remove label from the conversation",
"SETTINGS": "Settings",
"AI_ASSIST": "AI Assist",
"APPEARANCE": "Appearance"
"APPEARANCE": "Appearance",
"SNOOZE_NOTIFICATION": "Snooze Notification"
},
"COMMANDS": {
"GO_TO_CONVERSATION_DASHBOARD": "Go to Conversation Dashboard",
@@ -153,7 +154,8 @@
"CHANGE_APPEARANCE": "Change Appearance",
"LIGHT_MODE": "Light",
"DARK_MODE": "Dark",
"SYSTEM_MODE": "System"
"SYSTEM_MODE": "System",
"SNOOZE_NOTIFICATION": "Snooze Notification"
}
},
"DASHBOARD_APPS": {
@@ -0,0 +1,59 @@
{
"INBOX": {
"LIST": {
"TITLE": "Inbox",
"DISPLAY_DROPDOWN": "Display",
"LOADING": "Fetching notifications",
"EOF": "All notifications loaded 🎉",
"404": "There are no active notifications in this group.",
"NOTE": "Notifications from all subscribed inboxes",
"SNOOZED_UNTIL": "Snoozed until",
"SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow",
"SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week"
},
"ACTION_HEADER": {
"SNOOZE": "Snooze notification",
"DELETE": "Delete notification"
},
"TYPES": {
"CONVERSATION_MENTION": "You have been mentioned in a conversation",
"CONVERSATION_CREATION": "New conversation created",
"CONVERSATION_ASSIGNMENT": "A conversation has been assigned to you",
"ASSIGNED_CONVERSATION_NEW_MESSAGE": "New message in an assigned conversation",
"PARTICIPATING_CONVERSATION_NEW_MESSAGE": "New message in a conversation you are participating in"
},
"MENU_ITEM": {
"MARK_AS_READ": "Mark as read",
"MARK_AS_UNREAD": "Mark as unread",
"SNOOZE": "Snooze",
"DELETE": "Delete",
"MARK_ALL_READ": "Mark all as read",
"DELETE_ALL": "Delete all",
"DELETE_ALL_READ": "Delete all read"
},
"DISPLAY_MENU": {
"SORT": "Sort",
"DISPLAY": "Display :",
"SORT_OPTIONS": {
"NEWEST": "Newest",
"OLDEST": "Oldest",
"PRIORITY": "Priority"
},
"DISPLAY_OPTIONS": {
"SNOOZED": "Snoozed",
"READ": "Read",
"LABELS": "Labels",
"CONVERSATION_ID": "Conversation ID"
}
},
"ALERTS": {
"MARK_AS_READ": "Notification marked as read",
"MARK_AS_UNREAD": "Notification marked as unread",
"SNOOZE": "Notification snoozed",
"DELETE": "Notification deleted",
"MARK_ALL_READ": "All notifications marked as read",
"DELETE_ALL": "All notifications deleted",
"DELETE_ALL_READ": "All read notifications deleted"
}
}
}
@@ -29,6 +29,7 @@ import settings from './settings.json';
import signup from './signup.json';
import teamsSettings from './teamsSettings.json';
import whatsappTemplates from './whatsappTemplates.json';
import inbox from './inbox.json';
export default {
...advancedFilters,
@@ -62,4 +63,5 @@ export default {
...signup,
...teamsSettings,
...whatsappTemplates,
...inbox,
};
@@ -156,6 +156,9 @@
"TRIAL_MESSAGE": "days trial remaining.",
"TRAIL_BUTTON": "Buy Now",
"DELETED_USER": "Deleted User",
"EMAIL_VERIFICATION_PENDING": "It seems that you haven't verified your email address yet. Please check your inbox for the verification email.",
"RESEND_VERIFICATION_MAIL": "Resend verification email",
"EMAIL_VERIFICATION_SENT": "Verification email has been sent. Please check your inbox.",
"ACCOUNT_SUSPENDED": {
"TITLE": "Account Suspended",
"MESSAGE": "Your account is suspended. Please reach out to the support team for more information."
@@ -193,6 +196,7 @@
"CURRENTLY_VIEWING_ACCOUNT": "Currently viewing:",
"SWITCH": "Switch",
"CONVERSATIONS": "Conversations",
"INBOX": "Inbox",
"ALL_CONVERSATIONS": "All Conversations",
"MENTIONED_CONVERSATIONS": "Mentions",
"PARTICIPATING_CONVERSATIONS": "Participating",
@@ -52,19 +52,77 @@ describe('#dateFormat', () => {
});
describe('#shortTimestamp', () => {
it('returns correct value', () => {
// Test cases when withAgo is false or not provided
it('returns correct value without ago', () => {
expect(TimeMixin.methods.shortTimestamp('less than a minute ago')).toEqual(
'now'
);
expect(TimeMixin.methods.shortTimestamp(' minute ago')).toEqual('m');
expect(TimeMixin.methods.shortTimestamp(' minutes ago')).toEqual('m');
expect(TimeMixin.methods.shortTimestamp(' hour ago')).toEqual('h');
expect(TimeMixin.methods.shortTimestamp(' hours ago')).toEqual('h');
expect(TimeMixin.methods.shortTimestamp(' day ago')).toEqual('d');
expect(TimeMixin.methods.shortTimestamp(' days ago')).toEqual('d');
expect(TimeMixin.methods.shortTimestamp(' month ago')).toEqual('mo');
expect(TimeMixin.methods.shortTimestamp(' months ago')).toEqual('mo');
expect(TimeMixin.methods.shortTimestamp(' year ago')).toEqual('y');
expect(TimeMixin.methods.shortTimestamp(' years ago')).toEqual('y');
expect(TimeMixin.methods.shortTimestamp('1 minute ago')).toEqual('1m');
expect(TimeMixin.methods.shortTimestamp('12 minutes ago')).toEqual('12m');
expect(TimeMixin.methods.shortTimestamp('a minute ago')).toEqual('1m');
expect(TimeMixin.methods.shortTimestamp('an hour ago')).toEqual('1h');
expect(TimeMixin.methods.shortTimestamp('1 hour ago')).toEqual('1h');
expect(TimeMixin.methods.shortTimestamp('2 hours ago')).toEqual('2h');
expect(TimeMixin.methods.shortTimestamp('1 day ago')).toEqual('1d');
expect(TimeMixin.methods.shortTimestamp('a day ago')).toEqual('1d');
expect(TimeMixin.methods.shortTimestamp('3 days ago')).toEqual('3d');
expect(TimeMixin.methods.shortTimestamp('a month ago')).toEqual('1mo');
expect(TimeMixin.methods.shortTimestamp('1 month ago')).toEqual('1mo');
expect(TimeMixin.methods.shortTimestamp('2 months ago')).toEqual('2mo');
expect(TimeMixin.methods.shortTimestamp('a year ago')).toEqual('1y');
expect(TimeMixin.methods.shortTimestamp('1 year ago')).toEqual('1y');
expect(TimeMixin.methods.shortTimestamp('4 years ago')).toEqual('4y');
});
// Test cases when withAgo is true
it('returns correct value with ago', () => {
expect(
TimeMixin.methods.shortTimestamp('less than a minute ago', true)
).toEqual('now');
expect(TimeMixin.methods.shortTimestamp('1 minute ago', true)).toEqual(
'1m ago'
);
expect(TimeMixin.methods.shortTimestamp('12 minutes ago', true)).toEqual(
'12m ago'
);
expect(TimeMixin.methods.shortTimestamp('a minute ago', true)).toEqual(
'1m ago'
);
expect(TimeMixin.methods.shortTimestamp('an hour ago', true)).toEqual(
'1h ago'
);
expect(TimeMixin.methods.shortTimestamp('1 hour ago', true)).toEqual(
'1h ago'
);
expect(TimeMixin.methods.shortTimestamp('2 hours ago', true)).toEqual(
'2h ago'
);
expect(TimeMixin.methods.shortTimestamp('1 day ago', true)).toEqual(
'1d ago'
);
expect(TimeMixin.methods.shortTimestamp('a day ago', true)).toEqual(
'1d ago'
);
expect(TimeMixin.methods.shortTimestamp('3 days ago', true)).toEqual(
'3d ago'
);
expect(TimeMixin.methods.shortTimestamp('a month ago', true)).toEqual(
'1mo ago'
);
expect(TimeMixin.methods.shortTimestamp('1 month ago', true)).toEqual(
'1mo ago'
);
expect(TimeMixin.methods.shortTimestamp('2 months ago', true)).toEqual(
'2mo ago'
);
expect(TimeMixin.methods.shortTimestamp('a year ago', true)).toEqual(
'1y ago'
);
expect(TimeMixin.methods.shortTimestamp('1 year ago', true)).toEqual(
'1y ago'
);
expect(TimeMixin.methods.shortTimestamp('4 years ago', true)).toEqual(
'4y ago'
);
});
});
+28 -17
View File
@@ -28,25 +28,36 @@ export default {
const unixTime = fromUnixTime(time);
return format(unixTime, dateFormat);
},
shortTimestamp(time) {
shortTimestamp(time, withAgo = false) {
// This function takes a time string and converts it to a short time string
// with the following format: 1m, 1h, 1d, 1mo, 1y
// The function also takes an optional boolean parameter withAgo
// which will add the word "ago" to the end of the time string
const suffix = withAgo ? ' ago' : '';
const timeMappings = {
'less than a minute ago': 'now',
'a minute ago': `1m${suffix}`,
'an hour ago': `1h${suffix}`,
'a day ago': `1d${suffix}`,
'a month ago': `1mo${suffix}`,
'a year ago': `1y${suffix}`,
};
// Check if the time string is one of the specific cases
if (timeMappings[time]) {
return timeMappings[time];
}
const convertToShortTime = time
.replace(/about|over|almost|/g, '')
.replace('less than a minute ago', 'now')
.replace(' minute ago', 'm')
.replace(' minutes ago', 'm')
.replace('a minute ago', 'm')
.replace('an hour ago', 'h')
.replace(' hour ago', 'h')
.replace(' hours ago', 'h')
.replace(' day ago', 'd')
.replace('a day ago', 'd')
.replace(' days ago', 'd')
.replace('a month ago', 'mo')
.replace(' months ago', 'mo')
.replace(' month ago', 'mo')
.replace('a year ago', 'y')
.replace(' year ago', 'y')
.replace(' years ago', 'y');
.replace(' minute ago', `m${suffix}`)
.replace(' minutes ago', `m${suffix}`)
.replace(' hour ago', `h${suffix}`)
.replace(' hours ago', `h${suffix}`)
.replace(' day ago', `d${suffix}`)
.replace(' days ago', `d${suffix}`)
.replace(' month ago', `mo${suffix}`)
.replace(' months ago', `mo${suffix}`)
.replace(' year ago', `y${suffix}`)
.replace(' years ago', `y${suffix}`);
return convertToShortTime;
},
},
@@ -71,3 +71,5 @@ export const ICON_APPEARANCE = `<svg role="img" class="ninja-icon ninja-icon--fl
export const ICON_LIGHT_MODE = `<svg role="img" class="ninja-icon ninja-icon--fluent" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"><path fill="currentColor" d="M12 2a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 2Zm5 10a5 5 0 1 1-10 0a5 5 0 0 1 10 0Zm4.25.75a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5h1.5ZM12 19a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 19Zm-7.75-6.25a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5h1.5Zm-.03-8.53a.75.75 0 0 1 1.06 0l1.5 1.5a.75.75 0 0 1-1.06 1.06l-1.5-1.5a.75.75 0 0 1 0-1.06Zm1.06 15.56a.75.75 0 1 1-1.06-1.06l1.5-1.5a.75.75 0 1 1 1.06 1.06l-1.5 1.5Zm14.5-15.56a.75.75 0 0 0-1.06 0l-1.5 1.5a.75.75 0 0 0 1.06 1.06l1.5-1.5a.75.75 0 0 0 0-1.06Zm-1.06 15.56a.75.75 0 1 0 1.06-1.06l-1.5-1.5a.75.75 0 1 0-1.06 1.06l1.5 1.5Z"/></svg>`;
export const ICON_DARK_MODE = `<svg role="img" class="ninja-icon ninja-icon--fluent" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"><path fill="currentColor" d="M20.026 17.001c-2.762 4.784-8.879 6.423-13.663 3.661A9.965 9.965 0 0 1 3.13 17.68a.75.75 0 0 1 .365-1.132c3.767-1.348 5.785-2.91 6.956-5.146c1.232-2.353 1.551-4.93.689-8.463a.75.75 0 0 1 .769-.927a9.961 9.961 0 0 1 4.457 1.327c4.784 2.762 6.423 8.879 3.66 13.662Zm-8.248-4.903c-1.25 2.389-3.31 4.1-6.817 5.499a8.49 8.49 0 0 0 2.152 1.766a8.502 8.502 0 0 0 8.502-14.725a8.484 8.484 0 0 0-2.792-1.015c.647 3.384.23 6.043-1.045 8.475Z"/></svg>`;
export const ICON_SYSTEM_MODE = `<svg role="img" class="ninja-icon ninja-icon--fluent" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"><path fill="currentColor" d="M4.25 3A2.25 2.25 0 0 0 2 5.25v10.5A2.25 2.25 0 0 0 4.25 18H9.5v1.25c0 .69-.56 1.25-1.25 1.25h-.5a.75.75 0 0 0 0 1.5h8.5a.75.75 0 0 0 0-1.5h-.5c-.69 0-1.25-.56-1.25-1.25V18h5.25A2.25 2.25 0 0 0 22 15.75V5.25A2.25 2.25 0 0 0 19.75 3H4.25ZM13 18v1.25c0 .45.108.875.3 1.25h-2.6c.192-.375.3-.8.3-1.25V18h2ZM3.5 5.25a.75.75 0 0 1 .75-.75h15.5a.75.75 0 0 1 .75.75V13h-17V5.25Zm0 9.25h17v1.25a.75.75 0 0 1-.75.75H4.25a.75.75 0 0 1-.75-.75V14.5Z"/></svg>`;
export const ICON_SNOOZE_NOTIFICATION = `<svg role="img" class="ninja-icon ninja-icon--fluent" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"><path fill="currentColor" d="M12 3.5c-3.104 0-6 2.432-6 6.25v4.153L4.682 17h14.67l-1.354-3.093V11.75a.75.75 0 0 1 1.5 0v1.843l1.381 3.156a1.25 1.25 0 0 1-1.145 1.751H15a3.002 3.002 0 0 1-6.003 0H4.305a1.25 1.25 0 0 1-1.15-1.739l1.344-3.164V9.75C4.5 5.068 8.103 2 12 2c.86 0 1.705.15 2.5.432a.75.75 0 0 1-.502 1.413A5.964 5.964 0 0 0 12 3.5ZM12 20c.828 0 1.5-.671 1.501-1.5h-3.003c0 .829.673 1.5 1.502 1.5Zm3.25-13h-2.5l-.101.007A.75.75 0 0 0 12.75 8.5h1.043l-1.653 2.314l-.055.09A.75.75 0 0 0 12.75 12h2.5l.102-.007a.75.75 0 0 0-.102-1.493h-1.042l1.653-2.314l.055-.09A.75.75 0 0 0 15.25 7Zm6-5h-3.5l-.101.007A.75.75 0 0 0 17.75 3.5h2.134l-2.766 4.347l-.05.09A.75.75 0 0 0 17.75 9h3.5l.102-.007A.75.75 0 0 0 21.25 7.5h-2.133l2.766-4.347l.05-.09A.75.75 0 0 0 21.25 2Z"/></svg>`;
@@ -15,3 +15,6 @@ export const CMD_REOPEN_CONVERSATION = 'CMD_REOPEN_CONVERSATION';
export const CMD_RESOLVE_CONVERSATION = 'CMD_RESOLVE_CONVERSATION';
export const CMD_SNOOZE_CONVERSATION = 'CMD_SNOOZE_CONVERSATION';
export const CMD_AI_ASSIST = 'CMD_AI_ASSIST';
// Inbox Commands (Notifications)
export const CMD_SNOOZE_NOTIFICATION = 'CMD_SNOOZE_NOTIFICATION';
@@ -12,6 +12,7 @@
<script>
import 'ninja-keys';
import conversationHotKeysMixin from './conversationHotKeys';
import inboxHotKeysMixin from './inboxHotKeys';
import goToCommandHotKeys from './goToCommandHotKeys';
import appearanceHotKeys from './appearanceHotKeys';
import agentMixin from 'dashboard/mixins/agentMixin';
@@ -25,6 +26,7 @@ export default {
adminMixin,
agentMixin,
conversationHotKeysMixin,
inboxHotKeysMixin,
conversationLabelMixin,
conversationTeamMixin,
appearanceHotKeys,
@@ -42,6 +44,7 @@ export default {
},
hotKeys() {
return [
...this.inboxHotKeys,
...this.conversationHotKeys,
...this.goToCommandHotKeys,
...this.goToAppearanceHotKeys,
@@ -30,7 +30,10 @@ import {
UNMUTE_ACTION,
MUTE_ACTION,
} from './commandBarActions';
import { isAConversationRoute } from '../../../helper/routeHelpers';
import {
isAConversationRoute,
isAInboxViewRoute,
} from '../../../helper/routeHelpers';
export default {
mixins: [aiMixin],
watch: {
@@ -325,7 +328,10 @@ export default {
},
conversationHotKeys() {
if (isAConversationRoute(this.$route.name)) {
if (
isAConversationRoute(this.$route.name) ||
isAInboxViewRoute(this.$route.name)
) {
const defaultConversationHotKeys = [
...this.statusActions,
...this.conversationAdditionalActions,
@@ -0,0 +1,81 @@
import wootConstants from 'dashboard/constants/globals';
import { CMD_SNOOZE_NOTIFICATION } from './commandBarBusEvents';
import { ICON_SNOOZE_NOTIFICATION } from './CommandBarIcons';
import { isAInboxViewRoute } from 'dashboard/helper/routeHelpers';
const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS;
const INBOX_SNOOZE_EVENTS = [
{
id: 'snooze_notification',
title: 'COMMAND_BAR.COMMANDS.SNOOZE_NOTIFICATION',
icon: ICON_SNOOZE_NOTIFICATION,
children: Object.values(SNOOZE_OPTIONS),
},
{
id: SNOOZE_OPTIONS.AN_HOUR_FROM_NOW,
title: 'COMMAND_BAR.COMMANDS.AN_HOUR_FROM_NOW',
parent: 'snooze_notification',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
icon: ICON_SNOOZE_NOTIFICATION,
handler: () =>
bus.$emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.AN_HOUR_FROM_NOW),
},
{
id: SNOOZE_OPTIONS.UNTIL_TOMORROW,
title: 'COMMAND_BAR.COMMANDS.UNTIL_TOMORROW',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION,
handler: () =>
bus.$emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_TOMORROW),
},
{
id: SNOOZE_OPTIONS.UNTIL_NEXT_WEEK,
title: 'COMMAND_BAR.COMMANDS.UNTIL_NEXT_WEEK',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION,
handler: () =>
bus.$emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_NEXT_WEEK),
},
{
id: SNOOZE_OPTIONS.UNTIL_NEXT_MONTH,
title: 'COMMAND_BAR.COMMANDS.UNTIL_NEXT_MONTH',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION,
handler: () =>
bus.$emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_NEXT_MONTH),
},
{
id: SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME,
title: 'COMMAND_BAR.COMMANDS.CUSTOM',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION,
handler: () =>
bus.$emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME),
},
];
export default {
computed: {
inboxHotKeys() {
if (isAInboxViewRoute(this.$route.name)) {
return this.prepareActions(INBOX_SNOOZE_EVENTS);
}
return [];
},
},
methods: {
prepareActions(actions) {
return actions.map(action => ({
...action,
title: this.$t(action.title),
section: this.$t(action.section),
}));
},
},
};
@@ -1,9 +1,28 @@
/* eslint arrow-body-style: 0 */
import { frontendURL } from '../../../helper/URLHelper';
const ConversationView = () => import('./ConversationView');
const InboxView = () => import('../inbox/InboxView.vue');
export default {
routes: [
{
path: frontendURL('accounts/:accountId/inbox'),
name: 'inbox',
roles: ['administrator', 'agent'],
component: InboxView,
props: () => {
return { inboxId: 0 };
},
},
{
path: frontendURL('accounts/:accountId/inbox/:conversation_id'),
name: 'inbox_view_conversation',
roles: ['administrator', 'agent'],
component: InboxView,
props: route => {
return { inboxId: 0, conversationId: route.params.conversation_id };
},
},
{
path: frontendURL('accounts/:accountId/dashboard'),
name: 'home',
@@ -1,6 +1,6 @@
<template>
<div
class="flex px-4 items-center justify-between w-full h-16 pt-2 sticky top-0 z-10 bg-white dark:bg-slate-900"
class="flex px-4 items-center justify-between w-full h-16 pt-2 sticky top-0 z-50 bg-white dark:bg-slate-900"
>
<div class="flex items-center">
<woot-sidemenu-icon />
@@ -1,70 +0,0 @@
<script setup>
// import { defineProps } from 'vue';
import PriorityIcon from './components/PriorityIcon.vue';
import StatusIcon from './components/StatusIcon.vue';
import InboxNameAndId from './components/InboxNameAndId.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
// const props = defineProps({
// notificationItem: {
// type: Object,
// default: () => {},
// },
// });
const assigneeMeta = {
thumbnail: '',
name: 'Michael Johnson',
};
const { thumbnail, name } = assigneeMeta || {};
const status = 'open';
const priority = 'high';
const inboxTypeMessage = 'Mentioned by Michael';
const inboxMessage = 'What is the best way to get started?';
const inbox = {
inbox_id: 16787,
inbox_name: 'Chatwoot Support',
};
</script>
<template>
<div
class="flex max-w-[360px] flex-col pl-5 pr-3 gap-2.5 py-3 w-full bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-500 hover:bg-slate-25 dark:hover:bg-slate-800 cursor-pointer"
>
<div class="flex relative items-center justify-between w-full">
<div
class="absolute -left-3.5 flex w-2 h-2 rounded bg-woot-500 dark:bg-woot-500"
/>
<InboxNameAndId :inbox="inbox" />
<div class="flex gap-2">
<PriorityIcon :priority="priority" />
<StatusIcon :status="status" />
</div>
</div>
<div class="flex flex-row justify-between items-center w-full">
<div class="flex gap-1.5 items-center max-w-[80%]">
<Thumbnail
v-if="assigneeMeta"
:src="thumbnail"
:username="name"
size="20px"
/>
<div class="flex min-w-0">
<span
class="font-medium text-slate-800 dark:text-slate-100 text-xs overflow-hidden text-ellipsis whitespace-nowrap"
>
{{ inboxTypeMessage }}<span v-if="inboxTypeMessage">:</span>
<span class="font-normal">{{ inboxMessage }}</span>
</span>
</div>
</div>
<span
class="font-medium text-slate-600 dark:text-slate-300 text-xs whitespace-nowrap"
>
10h ago
</span>
</div>
</div>
</template>
@@ -0,0 +1,141 @@
<template>
<div
class="flex flex-col h-full w-full ltr:border-r border-slate-50 dark:border-slate-800/50"
:class="isOnExpandedLayout ? '' : 'min-w-[360px] max-w-[360px]'"
>
<inbox-list-header />
<div
ref="notificationList"
class="flex flex-col w-full h-[calc(100%-56px)] overflow-x-hidden overflow-y-auto"
>
<inbox-card
v-for="notificationItem in records"
:key="notificationItem.id"
:notification-item="notificationItem"
@mark-notification-as-read="markNotificationAsRead"
@mark-notification-as-unread="markNotificationAsUnRead"
@delete-notification="deleteNotification"
/>
<div v-if="uiFlags.isFetching" class="text-center">
<span class="spinner mt-4 mb-4" />
</div>
<p
v-if="showEndOfList"
class="text-center text-slate-300 dark:text-slate-400 p-4"
>
{{ $t('INBOX.LIST.EOF') }}
</p>
<intersection-observer
v-if="!showEndOfList && !uiFlags.isFetching"
:options="infiniteLoaderOptions"
@observed="loadMoreNotifications"
/>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import InboxCard from './components/InboxCard.vue';
import InboxListHeader from './components/InboxListHeader.vue';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
import alertMixin from 'shared/mixins/alertMixin';
export default {
components: {
InboxCard,
InboxListHeader,
IntersectionObserver,
},
mixins: [alertMixin],
props: {
conversationId: {
type: [String, Number],
default: 0,
},
isOnExpandedLayout: {
type: Boolean,
default: false,
},
},
data() {
return {
infiniteLoaderOptions: {
root: this.$refs.notificationList,
rootMargin: '100px 0px 100px 0px',
},
page: 1,
};
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
meta: 'notifications/getMeta',
records: 'notifications/getNotifications',
uiFlags: 'notifications/getUIFlags',
}),
showEndOfList() {
return this.uiFlags.isAllNotificationsLoaded && !this.uiFlags.isFetching;
},
},
mounted() {
this.$store.dispatch('notifications/clear');
this.$store.dispatch('notifications/index', { page: 1 });
},
methods: {
redirectToInbox() {
if (!this.conversationId) return;
if (this.$route.name === 'inbox') return;
this.$router.push({ name: 'inbox' });
},
loadMoreNotifications() {
if (this.uiFlags.isAllNotificationsLoaded) return;
this.$store.dispatch('notifications/index', { page: this.page + 1 });
this.page += 1;
},
markNotificationAsRead(notification) {
this.$track(INBOX_EVENTS.MARK_NOTIFICATION_AS_READ);
const {
id,
primary_actor_id: primaryActorId,
primary_actor_type: primaryActorType,
} = notification;
this.$store
.dispatch('notifications/read', {
id,
primaryActorId,
primaryActorType,
unreadCount: this.meta.unreadCount,
})
.then(() => {
this.showAlert(this.$t('INBOX.ALERTS.MARK_AS_READ'));
});
},
markNotificationAsUnRead(notification) {
this.$track(INBOX_EVENTS.MARK_NOTIFICATION_AS_UNREAD);
this.redirectToInbox();
const { id } = notification;
this.$store
.dispatch('notifications/unread', {
id,
})
.then(() => {
this.showAlert(this.$t('INBOX.ALERTS.MARK_AS_UNREAD'));
});
},
deleteNotification(notification) {
this.$track(INBOX_EVENTS.DELETE_NOTIFICATION);
this.redirectToInbox();
this.$store
.dispatch('notifications/delete', {
notification,
unread_count: this.meta.unreadCount,
count: this.meta.count,
})
.then(() => {
this.showAlert(this.$t('INBOX.ALERTS.DELETE'));
});
},
},
};
</script>
@@ -0,0 +1,212 @@
<template>
<section class="flex w-full h-full bg-white dark:bg-slate-900">
<inbox-list
v-show="showConversationList"
:conversation-id="conversationId"
:is-on-expanded-layout="isOnExpandedLayout"
/>
<div v-if="showInboxMessageView" class="flex flex-col w-full h-full">
<inbox-item-header
:total-length="totalNotifications"
:current-index="activeNotificationIndex"
:active-notification="activeNotification"
@next="onClickNext"
@prev="onClickPrev"
/>
<conversation-box
class="h-[calc(100%-56px)]"
is-inbox-view
:inbox-id="inboxId"
:is-contact-panel-open="isContactPanelOpen"
:is-on-expanded-layout="isOnExpandedLayout"
@contact-panel-toggle="onToggleContactPanel"
/>
</div>
<div
v-if="!showInboxMessageView && !isOnExpandedLayout"
class="text-center bg-slate-25 dark:bg-slate-800 justify-center w-full h-full flex items-center"
>
<span v-if="uiFlags.isFetching" class="spinner mt-4 mb-4" />
<div v-else class="flex flex-row items-center gap-1">
<fluent-icon
icon="mail-inbox"
size="18"
class="text-slate-700 dark:text-slate-400"
/>
<span class="text-slate-700 text-sm font-medium dark:text-slate-400">
{{ $t('INBOX.LIST.NOTE') }}
</span>
</div>
</div>
</section>
</template>
<script>
import { mapGetters } from 'vuex';
import InboxList from './InboxList.vue';
import InboxItemHeader from './components/InboxItemHeader.vue';
import ConversationBox from 'dashboard/components/widgets/conversation/ConversationBox.vue';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import wootConstants from 'dashboard/constants/globals';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
export default {
components: {
InboxList,
InboxItemHeader,
ConversationBox,
},
mixins: [uiSettingsMixin],
props: {
inboxId: {
type: [String, Number],
default: 0,
},
conversationId: {
type: [String, Number],
default: 0,
},
},
computed: {
...mapGetters({
currentAccountId: 'getCurrentAccountId',
notifications: 'notifications/getNotifications',
currentChat: 'getSelectedChat',
allConversation: 'getAllConversations',
uiFlags: 'notifications/getUIFlags',
}),
activeNotification() {
return this.notifications.find(
n => n.primary_actor.id === Number(this.conversationId)
);
},
isInboxViewEnabled() {
return this.$store.getters['accounts/isFeatureEnabledGlobally'](
this.currentAccountId,
FEATURE_FLAGS.INBOX_VIEW
);
},
showConversationList() {
return this.isOnExpandedLayout ? !this.conversationId : true;
},
isFetchingInitialData() {
return this.uiFlags.isFetching && !this.notifications.length;
},
showInboxMessageView() {
return (
Boolean(this.conversationId) &&
Boolean(this.currentChat.id) &&
!this.isFetchingInitialData
);
},
totalNotifications() {
return this.notifications?.length ?? 0;
},
activeNotificationIndex() {
const conversationId = Number(this.conversationId);
const notificationIndex = this.notifications.findIndex(
n => n.primary_actor.id === conversationId
);
return notificationIndex >= 0 ? notificationIndex + 1 : 0;
},
isOnExpandedLayout() {
const {
LAYOUT_TYPES: { CONDENSED },
} = wootConstants;
const { conversation_display_type: conversationDisplayType = CONDENSED } =
this.uiSettings;
return conversationDisplayType !== CONDENSED;
},
isContactPanelOpen() {
if (this.currentChat.id) {
const { is_contact_sidebar_open: isContactSidebarOpen } =
this.uiSettings;
return isContactSidebarOpen;
}
return false;
},
},
watch: {
conversationId: {
immediate: true,
handler() {
this.fetchConversationById();
},
},
},
mounted() {
// Open inbox view if inbox view feature is enabled, else redirect to dashboard
// TODO: Remove this code once inbox view feature is enabled for all accounts
if (!this.isInboxViewEnabled) {
this.$router.push({
name: 'home',
});
}
},
methods: {
async fetchConversationById() {
if (!this.conversationId) return;
const chat = this.findConversation();
if (!chat) {
await this.$store.dispatch('getConversation', this.conversationId);
}
this.setActiveChat();
},
setActiveChat() {
const selectedConversation = this.findConversation();
if (!selectedConversation) return;
this.$store
.dispatch('setActiveChat', { data: selectedConversation })
.then(() => {
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
});
},
findConversation() {
const conversationId = Number(this.conversationId);
return this.allConversation.find(c => c.id === conversationId);
},
navigateToConversation(activeIndex, direction) {
const indexOffset = direction === 'next' ? 0 : -2;
const targetNotification = this.notifications[activeIndex + indexOffset];
if (targetNotification) {
const {
id,
primary_actor_id: primaryActorId,
primary_actor_type: primaryActorType,
primary_actor: { id: conversationId, meta: { unreadCount } = {} },
notification_type: notificationType,
} = targetNotification;
this.$track(INBOX_EVENTS.OPEN_CONVERSATION_VIA_INBOX, {
notificationType,
});
this.$store.dispatch('notifications/read', {
id,
primaryActorId,
primaryActorType,
unreadCount,
});
this.$router.push({
name: 'inbox_view_conversation',
params: { conversation_id: conversationId },
});
}
},
onClickNext() {
this.navigateToConversation(this.activeNotificationIndex, 'next');
},
onClickPrev() {
this.navigateToConversation(this.activeNotificationIndex, 'prev');
},
onToggleContactPanel() {
this.updateUISettings({
is_contact_sidebar_open: !this.isContactPanelOpen,
});
},
},
};
</script>
@@ -0,0 +1,235 @@
<template>
<div
role="button"
class="flex flex-col pl-5 pr-3 gap-2.5 py-3 w-full border-b border-slate-50 dark:border-slate-800/50 hover:bg-slate-25 dark:hover:bg-slate-800 cursor-pointer"
:class="
isInboxCardActive
? 'bg-slate-25 dark:bg-slate-800 click-animation'
: 'bg-white dark:bg-slate-900'
"
@contextmenu="openContextMenu($event)"
@click="openConversation(notificationItem)"
>
<div class="flex relative items-center justify-between w-full">
<div
v-if="isUnread"
class="absolute -left-3.5 flex w-2 h-2 rounded bg-woot-500 dark:bg-woot-500"
/>
<InboxNameAndId :inbox="inbox" :conversation-id="primaryActor.id" />
<div class="flex gap-2">
<PriorityIcon :priority="primaryActor.priority" />
<StatusIcon :status="primaryActor.status" />
</div>
</div>
<div class="flex flex-row justify-between items-center w-full">
<div class="flex gap-1.5 items-center max-w-[calc(100%-70px)]">
<Thumbnail
v-if="assigneeMeta"
:src="assigneeMeta.thumbnail"
:username="assigneeMeta.name"
size="20px"
/>
<div class="flex min-w-0">
<span
class="text-slate-800 dark:text-slate-50 text-sm overflow-hidden text-ellipsis whitespace-nowrap"
:class="isUnread ? 'font-medium' : 'font-normal'"
>
{{ pushTitle }}
</span>
</div>
</div>
<span
class="font-medium max-w-[60px] text-slate-600 dark:text-slate-300 text-xs whitespace-nowrap"
>
{{ lastActivityAt }}
</span>
</div>
<div v-if="snoozedUntilTime" class="flex items-center">
<span class="text-woot-500 dark:text-woot-500 text-xs font-medium">
{{ snoozedDisplayText }}
</span>
</div>
<inbox-context-menu
v-if="isContextMenuOpen"
:context-menu-position="contextMenuPosition"
:menu-items="menuItems"
@close="closeContextMenu"
@click="handleAction"
/>
</div>
</template>
<script>
import PriorityIcon from './PriorityIcon.vue';
import StatusIcon from './StatusIcon.vue';
import InboxNameAndId from './InboxNameAndId.vue';
import InboxContextMenu from './InboxContextMenu.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import timeMixin from 'dashboard/mixins/time';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
export default {
components: {
PriorityIcon,
InboxContextMenu,
StatusIcon,
InboxNameAndId,
Thumbnail,
},
mixins: [timeMixin],
props: {
notificationItem: {
type: Object,
default: () => {},
},
},
data() {
return {
isContextMenuOpen: false,
contextMenuPosition: { x: null, y: null },
};
},
computed: {
primaryActor() {
return this.notificationItem?.primary_actor;
},
isInboxCardActive() {
return this.$route.params.conversation_id === this.primaryActor?.id;
},
inbox() {
return this.$store.getters['inboxes/getInbox'](
this.primaryActor.inbox_id
);
},
isUnread() {
return !this.notificationItem?.read_at;
},
meta() {
return this.primaryActor?.meta;
},
assigneeMeta() {
return this.meta?.assignee;
},
pushTitle() {
return this.$t(
`INBOX.TYPES.${this.notificationItem.notification_type.toUpperCase()}`
);
},
lastActivityAt() {
const dynamicTime = this.dynamicTime(
this.notificationItem?.last_activity_at
);
return this.shortTimestamp(dynamicTime, true);
},
menuItems() {
const items = [
{
key: 'delete',
label: this.$t('INBOX.MENU_ITEM.DELETE'),
},
];
if (!this.isUnread) {
items.push({
key: 'mark_as_unread',
label: this.$t('INBOX.MENU_ITEM.MARK_AS_UNREAD'),
});
} else {
items.push({
key: 'mark_as_read',
label: this.$t('INBOX.MENU_ITEM.MARK_AS_READ'),
});
}
return items;
},
snoozedUntilTime() {
const { snoozed_until: snoozedUntil } = this.notificationItem;
return snoozedUntil;
},
snoozedDisplayText() {
if (this.snoozedUntilTime) {
return `${this.$t('INBOX.LIST.SNOOZED_UNTIL')} ${snoozedReopenTime(
this.snoozedUntilTime
)}`;
}
return '';
},
},
unmounted() {
this.closeContextMenu();
},
methods: {
openConversation(notification) {
const {
id,
primary_actor_id: primaryActorId,
primary_actor_type: primaryActorType,
primary_actor: { id: conversationId, inbox_id: inboxId },
notification_type: notificationType,
} = notification;
if (this.$route.params.conversation_id !== conversationId) {
this.$track(INBOX_EVENTS.OPEN_CONVERSATION_VIA_INBOX, {
notificationType,
});
this.$store.dispatch('notifications/read', {
id,
primaryActorId,
primaryActorType,
unreadCount: this.meta.unreadCount,
});
this.$router.push({
name: 'inbox_view_conversation',
params: { inboxId, conversation_id: conversationId },
});
}
},
closeContextMenu() {
this.isContextMenuOpen = false;
this.contextMenuPosition = { x: null, y: null };
},
openContextMenu(e) {
this.closeContextMenu();
e.preventDefault();
this.contextMenuPosition = {
x: e.pageX || e.clientX,
y: e.pageY || e.clientY,
};
this.isContextMenuOpen = true;
},
handleAction(key) {
switch (key) {
case 'mark_as_read':
this.$emit('mark-notification-as-read', this.notificationItem);
break;
case 'mark_as_unread':
this.$emit('mark-notification-as-unread', this.notificationItem);
break;
case 'delete':
this.$emit('delete-notification', this.notificationItem);
break;
default:
}
},
},
};
</script>
<style scoped>
.click-animation {
animation: click-animation 0.3s ease-in-out;
}
@keyframes click-animation {
0% {
transform: scale(1);
}
50% {
transform: scale(0.99);
}
100% {
transform: scale(1);
}
}
</style>
@@ -0,0 +1,46 @@
<template>
<woot-context-menu
:x="contextMenuPosition.x"
:y="contextMenuPosition.y"
@close="handleClose"
>
<div
class="bg-white dark:bg-slate-900 w-40 py-1 border shadow-md border-slate-100 dark:border-slate-700/50 rounded-xl"
>
<menu-item
v-for="item in menuItems"
:key="item.key"
:label="item.label"
@click="onMenuItemClick(item.key)"
/>
</div>
</woot-context-menu>
</template>
<script>
import MenuItem from './MenuItem.vue';
export default {
components: {
MenuItem,
},
props: {
contextMenuPosition: {
type: Object,
default: () => ({}),
},
menuItems: {
type: Array,
default: () => [],
},
},
methods: {
handleClose() {
this.$emit('close');
},
onMenuItemClick(key) {
this.$emit('click', key);
this.handleClose();
},
},
};
</script>
@@ -0,0 +1,153 @@
<template>
<div
class="flex flex-col bg-white z-50 dark:bg-slate-900 w-[170px] border shadow-md border-slate-100 dark:border-slate-700/50 rounded-xl divide-y divide-slate-100 dark:divide-slate-700/50"
>
<div class="flex items-center justify-between h-11 p-3 rounded-t-lg">
<div class="flex gap-1.5">
<fluent-icon
icon="arrow-sort"
type="outline"
size="16"
class="text-slate-700 dark:text-slate-100"
/>
<span class="font-medium text-xs text-slate-800 dark:text-slate-100">
{{ $t('INBOX.DISPLAY_MENU.SORT') }}
</span>
</div>
<div class="relative">
<div
role="button"
class="border h-5 flex gap-1 rounded-md items-center pr-1.5 pl-1 py-0.5 w-[70px] justify-between border-slate-100 dark:border-slate-700/50"
@click="openSortMenu"
>
<span class="text-xs font-medium text-slate-600 dark:text-slate-300">
{{ activeSortOption }}
</span>
<fluent-icon
icon="chevron-down"
size="12"
class="text-slate-600 dark:text-slate-200"
/>
</div>
<div
v-if="showSortMenu"
class="absolute flex flex-col gap-0.5 bg-white z-60 dark:bg-slate-800 rounded-md p-0.5 top-0 w-[70px] border border-slate-100 dark:border-slate-700/50"
>
<div
v-for="option in sortOptions"
:key="option.key"
role="button"
class="flex rounded-[4px] h-5 w-full items-center justify-between p-0.5 gap-1"
:class="
activeSort === option.key ? 'bg-woot-50 dark:bg-woot-700/50' : ''
"
@click.stop="onSortOptionClick(option.key)"
>
<span
class="text-xs font-medium hover:text-woot-600 dark:hover:text-woot-600"
:class="
activeSort === option.key
? 'text-woot-600 dark:text-woot-600'
: 'text-slate-600 dark:text-slate-300'
"
>
{{ option.name }}
</span>
<fluent-icon
v-if="activeSort === option.key"
icon="checkmark"
size="14"
class="text-woot-600 dark:text-woot-600"
/>
</div>
</div>
</div>
</div>
<div>
<span
class="font-medium text-xs py-4 px-3 text-slate-400 dark:text-slate-400"
>
{{ $t('INBOX.DISPLAY_MENU.DISPLAY') }}
</span>
<div
class="flex flex-col divide-y divide-slate-100 dark:divide-slate-700/50"
>
<div
v-for="option in displayOptions"
:key="option.id"
class="flex items-center px-3 py-2 gap-1.5 h-9"
>
<input
:id="option.value"
type="checkbox"
:name="option.value"
:checked="option.selected"
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 checked:bg-woot-600 dark:checked:bg-woot-600 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-woot-700 dark:checked:border-woot-300 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
@change="updateDisplayOption(option)"
/>
<label
:for="option.value"
class="text-xs font-medium text-slate-800 !ml-0 !mr-0 dark:text-slate-100"
>
{{ option.name }}
</label>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
showSortMenu: false,
displayOptions: [
{
id: 1,
name: this.$t('INBOX.DISPLAY_MENU.DISPLAY_OPTIONS.SNOOZED'),
value: 'snoozed',
selected: false,
},
{
id: 2,
name: this.$t('INBOX.DISPLAY_MENU.DISPLAY_OPTIONS.READ'),
value: 'read',
selected: true,
},
],
sortOptions: [
{
name: this.$t('INBOX.DISPLAY_MENU.SORT_OPTIONS.NEWEST'),
key: 'newest',
},
{
name: this.$t('INBOX.DISPLAY_MENU.SORT_OPTIONS.OLDEST'),
key: 'oldest',
},
],
activeSort: 'newest',
};
},
computed: {
activeSortOption() {
return this.sortOptions.find(option => option.key === this.activeSort)
.name;
},
},
methods: {
updateDisplayOption(option) {
option.selected = !option.selected;
// TODO: Update the display options
},
openSortMenu() {
this.showSortMenu = !this.showSortMenu;
},
onSortOptionClick(key) {
this.activeSort = key;
this.showSortMenu = false;
// TODO: Update the sort options
},
},
};
</script>
@@ -0,0 +1,145 @@
<template>
<div
class="flex gap-2 py-2 pl-4 h-14 pr-2 justify-between items-center w-full border-b border-slate-50 dark:border-slate-800/50"
>
<pagination-button
v-if="totalLength > 1"
:total-length="totalLength"
:current-index="currentIndex"
@next="onClickNext"
@prev="onClickPrev"
/>
<div v-else />
<div class="flex items-center gap-2">
<woot-button
variant="hollow"
size="small"
color-scheme="secondary"
icon="snooze"
@click="openSnoozeNotificationModal"
>
{{ $t('INBOX.ACTION_HEADER.SNOOZE') }}
</woot-button>
<woot-button
icon="delete"
size="small"
color-scheme="secondary"
variant="hollow"
@click="deleteNotification"
>
{{ $t('INBOX.ACTION_HEADER.DELETE') }}
</woot-button>
</div>
<woot-modal
:show.sync="showCustomSnoozeModal"
:on-close="hideCustomSnoozeModal"
>
<custom-snooze-modal
@close="hideCustomSnoozeModal"
@choose-time="scheduleCustomSnooze"
/>
</woot-modal>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import { getUnixTime } from 'date-fns';
import { CMD_SNOOZE_NOTIFICATION } from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
import wootConstants from 'dashboard/constants/globals';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import PaginationButton from './PaginationButton.vue';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
import alertMixin from 'shared/mixins/alertMixin';
export default {
components: {
PaginationButton,
CustomSnoozeModal,
},
mixins: [alertMixin],
props: {
totalLength: {
type: Number,
default: 0,
},
currentIndex: {
type: Number,
default: 0,
},
activeNotification: {
type: Object,
default: null,
},
},
data() {
return {
showCustomSnoozeModal: false,
};
},
computed: {
...mapGetters({
meta: 'notifications/getMeta',
}),
},
mounted() {
bus.$on(CMD_SNOOZE_NOTIFICATION, this.onCmdSnoozeNotification);
},
destroyed() {
bus.$off(CMD_SNOOZE_NOTIFICATION, this.onCmdSnoozeNotification);
},
methods: {
openSnoozeNotificationModal() {
const ninja = document.querySelector('ninja-keys');
ninja.open({ parent: 'snooze_notification' });
},
hideCustomSnoozeModal() {
this.showCustomSnoozeModal = false;
},
snoozeNotification(snoozedUntil) {
this.$store
.dispatch('notifications/snooze', {
id: this.activeNotification?.id,
snoozedUntil,
})
.then(() => {
this.showAlert(this.$t('INBOX.ALERTS.SNOOZE'));
});
},
onCmdSnoozeNotification(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomSnoozeModal = true;
} else {
const snoozedUntil = findSnoozeTime(snoozeType) || null;
this.snoozeNotification(snoozedUntil);
}
},
scheduleCustomSnooze(customSnoozeTime) {
this.showCustomSnoozeModal = false;
if (customSnoozeTime) {
const snoozedUntil = getUnixTime(customSnoozeTime) || null;
this.snoozeNotification(snoozedUntil);
}
},
deleteNotification() {
this.$track(INBOX_EVENTS.DELETE_NOTIFICATION);
this.$store
.dispatch('notifications/delete', {
notification: this.activeNotification,
unread_count: this.meta.unreadCount,
count: this.meta.count,
})
.then(() => {
this.showAlert(this.$t('INBOX.ALERTS.DELETE'));
});
this.$router.push({ name: 'inbox' });
},
onClickNext() {
this.$emit('next');
},
onClickPrev() {
this.$emit('prev');
},
},
};
</script>
@@ -0,0 +1,116 @@
<template>
<div
class="flex w-full pl-4 pr-2 py-2 h-14 justify-between items-center border-b border-slate-50 dark:border-slate-800/50"
>
<div class="flex items-center gap-1.5">
<h1 class="font-medium text-slate-900 dark:text-slate-25 text-xl">
{{ $t('INBOX.LIST.TITLE') }}
</h1>
<div class="relative">
<div
role="button"
class="flex gap-1 items-center py-1 px-2 border border-slate-100 dark:border-slate-700/50 rounded-md"
@click="openInboxDisplayMenu"
>
<span
class="text-slate-600 dark:text-slate-200 text-xs text-center font-medium"
>
{{ $t('INBOX.LIST.DISPLAY_DROPDOWN') }}
</span>
<fluent-icon
icon="chevron-down"
size="12"
class="text-slate-600 dark:text-slate-200"
/>
</div>
<inbox-display-menu
v-if="showInboxDisplayMenu"
v-on-clickaway="openInboxDisplayMenu"
class="absolute top-8"
/>
</div>
</div>
<div class="flex relative gap-1 items-center">
<!-- <woot-button
variant="clear"
size="small"
color-scheme="secondary"
icon="filter"
@click="openInboxFilter"
/> -->
<woot-button
variant="clear"
size="small"
color-scheme="secondary"
icon="mail-inbox"
@click="openInboxOptionsMenu"
/>
<inbox-option-menu
v-if="showInboxOptionMenu"
v-on-clickaway="openInboxOptionsMenu"
class="absolute top-9"
@option-click="onInboxOptionMenuClick"
/>
</div>
</div>
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import InboxOptionMenu from './InboxOptionMenu.vue';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import InboxDisplayMenu from './InboxDisplayMenu.vue';
import alertMixin from 'shared/mixins/alertMixin';
export default {
components: {
InboxOptionMenu,
InboxDisplayMenu,
},
mixins: [clickaway, alertMixin],
data() {
return {
showInboxDisplayMenu: false,
showInboxOptionMenu: false,
};
},
methods: {
markAllRead() {
this.$track(INBOX_EVENTS.MARK_ALL_NOTIFICATIONS_AS_READ);
this.$store.dispatch('notifications/readAll').then(() => {
this.showAlert(this.$t('INBOX.ALERTS.MARK_ALL_READ'));
});
},
deleteAll() {
this.$store.dispatch('notifications/deleteAll').then(() => {
this.showAlert(this.$t('INBOX.ALERTS.DELETE_ALL'));
});
},
deleteAllRead() {
this.$store.dispatch('notifications/deleteAllRead').then(() => {
this.showAlert(this.$t('INBOX.ALERTS.DELETE_ALL_READ'));
});
},
openInboxDisplayMenu() {
this.showInboxDisplayMenu = !this.showInboxDisplayMenu;
},
openInboxOptionsMenu() {
this.showInboxOptionMenu = !this.showInboxOptionMenu;
},
onInboxOptionMenuClick(key) {
this.showInboxOptionMenu = false;
if (key === 'mark_all_read') {
this.markAllRead();
}
if (key === 'delete_all') {
this.deleteAll();
}
if (key === 'delete_all_read') {
this.deleteAllRead();
}
},
},
};
</script>
<style></style>
@@ -1,33 +1,43 @@
<script setup>
import { defineProps } from 'vue';
<script>
import { getInboxClassByType } from 'dashboard/helper/inbox';
const props = defineProps({
inbox: {
type: Object,
default: () => {},
export default {
props: {
inbox: {
type: Object,
default: () => {},
},
conversationId: {
type: Number,
default: 0,
},
},
});
const { inbox } = props;
const { inbox_id: inboxId, inbox_name: inboxName } = inbox;
computed: {
inboxIcon() {
const { phone_number: phoneNumber, channel_type: type } = this.inbox;
const classByType = getInboxClassByType(type, phoneNumber);
return classByType;
},
},
};
</script>
<template>
<div
class="inline-flex items-center rounded-[4px] border border-slate-100 dark:border-slate-600 divide-x divide-slate-100 dark:divide-slate-600 bg-none"
class="inline-flex items-center rounded-[4px] border border-slate-100 dark:border-slate-700/50 divide-x divide-slate-100 dark:divide-slate-700/50 bg-none"
>
<div class="flex items-center gap-0.5 py-0.5 px-1.5">
<div v-if="inbox" class="flex items-center gap-0.5 py-0.5 px-1.5">
<fluent-icon
class="text-slate-600 dark:text-slate-300"
icon="globe-desktop"
:icon="inboxIcon"
size="14"
/>
<span class="font-medium text-slate-600 dark:text-slate-300 text-xs">
{{ inboxName }}
<span class="font-medium text-slate-600 dark:text-slate-200 text-xs">
{{ inbox.name }}
</span>
</div>
<div class="flex items-center py-0.5 px-1.5">
<span class="font-medium text-slate-600 dark:text-slate-300 text-xs">
{{ inboxId }}
<span class="font-medium text-slate-600 dark:text-slate-200 text-xs">
{{ conversationId }}
</span>
</div>
</div>
@@ -0,0 +1,46 @@
<template>
<div
class="flex flex-col gap-1 bg-white z-50 dark:bg-slate-900 w-40 py-1 border shadow-md border-slate-100 dark:border-slate-700/50 rounded-xl divide-y divide-slate-100 dark:divide-slate-700/50"
>
<div class="flex flex-col">
<menu-item
v-for="item in menuItems"
:key="item.key"
:label="item.label"
@click="onClick(item.key)"
/>
</div>
</div>
</template>
<script>
import MenuItem from './MenuItem.vue';
export default {
components: {
MenuItem,
},
data() {
return {
menuItems: [
{
key: 'mark_all_read',
label: this.$t('INBOX.MENU_ITEM.MARK_ALL_READ'),
},
{
key: 'delete_all',
label: this.$t('INBOX.MENU_ITEM.DELETE_ALL'),
},
{
key: 'delete_all_read',
label: this.$t('INBOX.MENU_ITEM.DELETE_ALL_READ'),
},
],
};
},
methods: {
onClick(key) {
this.$emit('option-click', key);
},
},
};
</script>
@@ -0,0 +1,25 @@
<script setup>
import { defineProps, defineEmits } from 'vue';
defineProps({
label: {
type: String,
default: '',
},
});
const emits = defineEmits(['click']);
const onMenuItemClick = () => {
emits('click');
};
</script>
<template>
<div
role="button"
class="py-1 px-2 w-full h-8 font-medium text-xs text-slate-800 dark:text-slate-100 flex items-center whitespace-nowrap text-ellipsis overflow-hidden hover:text-woot-600 dark:hover:text-woot-500 cursor-pointer rounded-md"
@click.stop="onMenuItemClick"
>
{{ label }}
</div>
</template>
@@ -0,0 +1,70 @@
<template>
<div class="flex gap-2 items-center">
<div class="flex gap-1 items-center">
<woot-button
size="tiny"
variant="hollow"
color-scheme="secondary"
icon="chevron-up"
:disabled="isUpDisabled"
@click="handleUpClick"
/>
<woot-button
size="tiny"
variant="hollow"
color-scheme="secondary"
icon="chevron-down"
:disabled="isDownDisabled"
@click="handleDownClick"
/>
</div>
<div class="flex items-center gap-1 whitespace-nowrap">
<span class="text-sm font-medium text-gray-600">
{{ totalLength <= 1 ? '1' : currentIndex }}
</span>
<span
v-if="totalLength > 1"
class="text-sm text-slate-400 relative -top-px"
>
/
</span>
<span v-if="totalLength > 1" class="text-sm text-slate-400">
{{ totalLength }}
</span>
</div>
</div>
</template>
<script>
export default {
props: {
totalLength: {
type: Number,
default: 0,
},
currentIndex: {
type: Number,
default: 0,
},
},
computed: {
isUpDisabled() {
return this.currentIndex === 1;
},
isDownDisabled() {
return this.currentIndex === this.totalLength || this.totalLength <= 1;
},
},
methods: {
handleUpClick() {
if (this.currentIndex > 1) {
this.$emit('prev');
}
},
handleDownClick() {
if (this.currentIndex < this.totalLength) {
this.$emit('next');
}
},
},
};
</script>
@@ -1,20 +1,25 @@
<script setup>
<script>
import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
import { defineProps } from 'vue';
const props = defineProps({
priority: {
type: String,
default: '',
export default {
props: {
priority: {
type: String,
default: '',
},
},
});
data() {
return {
CONVERSATION_PRIORITY,
};
},
};
</script>
<template>
<div class="inline-flex items-center justify-center rounded-md">
<!-- High Priority -->
<svg
v-if="props.priority === CONVERSATION_PRIORITY.HIGH"
v-if="priority === CONVERSATION_PRIORITY.HIGH"
class="h-4 w-4"
width="24"
height="24"
@@ -29,7 +34,7 @@ const props = defineProps({
<!-- Low Priority -->
<svg
v-if="props.priority === CONVERSATION_PRIORITY.LOW"
v-if="priority === CONVERSATION_PRIORITY.LOW"
class="h-4 w-4"
width="24"
height="24"
@@ -44,7 +49,7 @@ const props = defineProps({
<!-- Medium Priority -->
<svg
v-if="props.priority === CONVERSATION_PRIORITY.MEDIUM"
v-if="priority === CONVERSATION_PRIORITY.MEDIUM"
class="h-4 w-4"
width="24"
height="24"
@@ -59,7 +64,7 @@ const props = defineProps({
<!-- Urgent Priority -->
<svg
v-if="props.priority === CONVERSATION_PRIORITY.URGENT"
v-if="priority === CONVERSATION_PRIORITY.URGENT"
class="h-4 w-4"
width="24"
height="24"
@@ -1,20 +1,8 @@
<script setup>
import { CONVERSATION_STATUS } from 'shared/constants/messages';
import { defineProps } from 'vue';
const props = defineProps({
status: {
type: String,
default: '',
},
});
</script>
<template>
<div class="inline-flex items-center justify-center rounded-md">
<!-- Pending -->
<svg
v-if="props.status === CONVERSATION_STATUS.PENDING"
v-if="status === CONVERSATION_STATUS.PENDING"
class="h-3.5 w-3.5"
width="18"
height="18"
@@ -29,7 +17,7 @@ const props = defineProps({
</svg>
<!-- Open -->
<svg
v-if="props.status === CONVERSATION_STATUS.OPEN"
v-if="status === CONVERSATION_STATUS.OPEN"
class="h-3.5 w-3.5"
width="19"
height="19"
@@ -45,7 +33,7 @@ const props = defineProps({
<!-- Snoozed -->
<svg
v-if="props.status === CONVERSATION_STATUS.SNOOZED"
v-if="status === CONVERSATION_STATUS.SNOOZED"
class="h-3.5 w-3.5"
width="18"
height="18"
@@ -61,7 +49,7 @@ const props = defineProps({
<!-- Resolved -->
<svg
v-if="props.status === CONVERSATION_STATUS.RESOLVED"
v-if="status === CONVERSATION_STATUS.RESOLVED"
class="h-3.5 w-3.5"
fill="none"
xmlns="http://www.w3.org/2000/svg"
@@ -78,3 +66,21 @@ const props = defineProps({
</svg>
</div>
</template>
<script>
import { CONVERSATION_STATUS } from 'shared/constants/messages';
export default {
props: {
status: {
type: String,
default: '',
},
},
data() {
return {
CONVERSATION_STATUS,
};
},
};
</script>
@@ -188,6 +188,7 @@ export default {
notificationType,
});
this.$store.dispatch('notifications/read', {
id: notification.id,
primaryActorId,
primaryActorType,
unreadCount: this.meta.unreadCount,
@@ -58,6 +58,7 @@ export default {
notificationType,
});
this.$store.dispatch('notifications/read', {
id: notification.id,
primaryActorId,
primaryActorType,
unreadCount: this.meta.unreadCount,
@@ -130,15 +130,23 @@ export default {
this.showAlert(this.$t('AGENT_MGMT.ADD.API.SUCCESS_MESSAGE'));
this.onClose();
} catch (error) {
const { response: { data: { error: errorResponse = '' } = {} } = {} } =
error;
const {
response: {
data: {
error: errorResponse = '',
attributes: attributes = [],
message: attrError = '',
} = {},
} = {},
} = error;
let errorMessage = '';
if (error.response.status === 422) {
if (error.response.status === 422 && !attributes.includes('base')) {
errorMessage = this.$t('AGENT_MGMT.ADD.API.EXIST_MESSAGE');
} else {
errorMessage = this.$t('AGENT_MGMT.ADD.API.ERROR_MESSAGE');
}
this.showAlert(errorResponse || errorMessage);
this.showAlert(errorResponse || attrError || errorMessage);
}
},
},
@@ -143,9 +143,6 @@ export default {
imap_login: this.login,
imap_password: this.password,
imap_enable_ssl: this.isSSLEnabled,
imap_inbox_synced_at: this.isIMAPEnabled
? new Date().toISOString()
: undefined,
},
};
@@ -4,6 +4,9 @@ import AccountAPI from '../../api/account';
import EnterpriseAccountAPI from '../../api/enterprise/account';
import { throwErrorMessage } from '../utils/api';
const findRecordById = ($state, id) =>
$state.records.find(record => record.id === Number(id)) || {};
const state = {
records: [],
uiFlags: {
@@ -30,10 +33,15 @@ export const getters = {
return true;
}
const { features = {} } =
$state.records.find(record => record.id === Number(id)) || {};
const { features = {} } = findRecordById($state, id);
return features[featureName] || false;
},
// There are some features which can be enabled/disabled globally
isFeatureEnabledGlobally: $state => (id, featureName) => {
const { features = {} } = findRecordById($state, id);
return features[featureName] || false;
},
};
export const actions = {
@@ -181,6 +181,14 @@ export const actions = {
// Ignore error
}
},
resendConfirmation: async () => {
try {
await authAPI.resendConfirmation();
} catch (error) {
// Ignore error
}
},
};
// mutations
@@ -18,6 +18,24 @@ export const actions = {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: false });
}
},
index: async ({ commit }, { page = 1 } = {}) => {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: true });
try {
const {
data: {
data: { payload, meta },
},
} = await NotificationsAPI.get(page);
commit(types.SET_NOTIFICATIONS, payload);
commit(types.SET_NOTIFICATIONS_META, meta);
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: false });
if (payload.length < 15) {
commit(types.SET_ALL_NOTIFICATIONS_LOADED);
}
} catch (error) {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: false });
}
},
unReadCount: async ({ commit } = {}) => {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdatingUnreadCount: true });
try {
@@ -30,14 +48,26 @@ export const actions = {
},
read: async (
{ commit },
{ primaryActorType, primaryActorId, unreadCount }
{ id, primaryActorType, primaryActorId, unreadCount }
) => {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: true });
try {
await NotificationsAPI.read(primaryActorType, primaryActorId);
commit(types.SET_NOTIFICATIONS_UNREAD_COUNT, unreadCount - 1);
commit(types.UPDATE_NOTIFICATION, primaryActorId);
commit(types.UPDATE_NOTIFICATION, { id, read_at: new Date() });
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false });
} catch (error) {
throw new Error(error);
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false });
}
},
unread: async ({ commit }, { id }) => {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: true });
try {
await NotificationsAPI.unRead(id);
commit(types.UPDATE_NOTIFICATION, { id, read_at: null });
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false });
} catch (error) {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false });
}
},
readAll: async ({ commit }) => {
@@ -53,10 +83,70 @@ export const actions = {
}
},
delete: async ({ commit }, { notification, count, unreadCount }) => {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: true });
try {
await NotificationsAPI.delete(notification.id);
commit(types.SET_NOTIFICATIONS_UNREAD_COUNT, unreadCount - 1);
commit(types.DELETE_NOTIFICATION, { notification, count, unreadCount });
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false });
} catch (error) {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false });
}
},
deleteAllRead: async ({ commit }) => {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: true });
try {
await NotificationsAPI.deleteAll({
type: 'read',
});
commit(types.DELETE_READ_NOTIFICATIONS);
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false });
} catch (error) {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false });
}
},
deleteAll: async ({ commit }) => {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: true });
try {
await NotificationsAPI.deleteAll({
type: 'all',
});
commit(types.DELETE_ALL_NOTIFICATIONS);
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false });
} catch (error) {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false });
}
},
snooze: async ({ commit }, { id, snoozedUntil }) => {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: true });
try {
const response = await NotificationsAPI.snooze({
id,
snoozedUntil,
});
const {
data: { snoozed_until = null },
} = response;
commit(types.SNOOZE_NOTIFICATION, {
id,
snoozed_until,
});
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false });
} catch (error) {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false });
}
},
addNotification({ commit }, data) {
commit(types.ADD_NOTIFICATION, data);
},
deleteNotification({ commit }, data) {
commit(types.DELETE_NOTIFICATION, data);
},
clear({ commit }) {
commit(types.CLEAR_NOTIFICATIONS);
},
};
@@ -13,7 +13,9 @@ const state = {
isFetching: false,
isFetchingItem: false,
isUpdating: false,
isDeleting: false,
isUpdatingUnreadCount: false,
isAllNotificationsLoaded: false,
},
};
@@ -10,6 +10,7 @@ export const mutations = {
},
[types.CLEAR_NOTIFICATIONS]: $state => {
Vue.set($state, 'records', {});
Vue.set($state.uiFlags, 'isAllNotificationsLoaded', false);
},
[types.SET_NOTIFICATIONS_META]: ($state, data) => {
const {
@@ -33,12 +34,8 @@ export const mutations = {
});
});
},
[types.UPDATE_NOTIFICATION]: ($state, primaryActorId) => {
Object.values($state.records).forEach(item => {
if (item.primary_actor_id === primaryActorId) {
Vue.set($state.records[item.id], 'read_at', true);
}
});
[types.UPDATE_NOTIFICATION]: ($state, { id, read_at }) => {
Vue.set($state.records[id], 'read_at', read_at);
},
[types.UPDATE_ALL_NOTIFICATIONS]: $state => {
Object.values($state.records).forEach(item => {
@@ -61,4 +58,22 @@ export const mutations = {
Vue.set($state.meta, 'unreadCount', unreadCount);
Vue.set($state.meta, 'count', count);
},
[types.SET_ALL_NOTIFICATIONS_LOADED]: $state => {
Vue.set($state.uiFlags, 'isAllNotificationsLoaded', true);
},
[types.DELETE_READ_NOTIFICATIONS]: $state => {
Object.values($state.records).forEach(item => {
if (item.read_at) {
Vue.delete($state.records, item.id);
}
});
},
[types.DELETE_ALL_NOTIFICATIONS]: $state => {
Vue.set($state, 'records', {});
},
[types.SNOOZE_NOTIFICATION]: ($state, { id, snoozed_until }) => {
Vue.set($state.records[id], 'snoozed_until', snoozed_until);
},
};
@@ -4,6 +4,10 @@ const accountData = {
id: 1,
name: 'Company one',
locale: 'en',
features: {
auto_resolve_conversations: false,
agent_management: false,
},
};
describe('#getters', () => {
@@ -29,4 +33,32 @@ describe('#getters', () => {
isDeleting: false,
});
});
it('isFeatureEnabledonAccount', () => {
const state = {
records: [accountData],
};
const rootGetters = {
getCurrentUser: {
type: 'SuperAdmin',
},
};
expect(
getters.isFeatureEnabledonAccount(
state,
null,
null,
rootGetters
)(1, 'auto_resolve_conversations')
).toEqual(true);
});
it('isFeatureEnabledGlobally', () => {
const state = {
records: [accountData],
};
expect(
getters.isFeatureEnabledGlobally(state)(1, 'auto_resolve_conversations')
).toEqual(false);
});
});
@@ -39,6 +39,38 @@ describe('#actions', () => {
});
});
describe('#index', () => {
it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({
data: {
data: {
payload: [{ id: 1 }],
meta: { count: 3, current_page: 1, unread_count: 2 },
},
},
});
await actions.index({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: true }],
[types.SET_NOTIFICATIONS, [{ id: 1 }]],
[
types.SET_NOTIFICATIONS_META,
{ count: 3, current_page: 1, unread_count: 2 },
],
[types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: false }],
[types.SET_ALL_NOTIFICATIONS_LOADED],
]);
});
it('sends correct actions if API is error', async () => {
axios.get.mockRejectedValue({ message: 'Incorrect header' });
await actions.index({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: true }],
[types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: false }],
]);
});
});
describe('#unReadCount', () => {
it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({ data: 1 });
@@ -62,18 +94,91 @@ describe('#actions', () => {
describe('#read', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({});
await actions.read({ commit }, { unreadCount: 2, primaryActorId: 1 });
await actions.read(
{ commit },
{ id: 1, unreadCount: 2, primaryActorId: 1 }
);
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: true }],
[types.SET_NOTIFICATIONS_UNREAD_COUNT, 1],
[types.UPDATE_NOTIFICATION, 1],
[types.UPDATE_NOTIFICATION, { id: 1, read_at: expect.any(Date) }],
[types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.read({ commit })).rejects.toThrow(Error);
await actions.read(
{ commit },
{ id: 1, unreadCount: 2, primaryActorId: 1 }
);
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: true }],
[types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false }],
]);
});
});
describe('#unread', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({});
await actions.unread({ commit }, { id: 1 });
expect(commit.mock.calls).toEqual([
['SET_NOTIFICATIONS_UI_FLAG', { isUpdating: true }],
['UPDATE_NOTIFICATION', { id: 1, read_at: null }],
['SET_NOTIFICATIONS_UI_FLAG', { isUpdating: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.unread({ commit })).rejects.toThrow(Error);
await actions.unread({ commit }, { id: 1 });
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: true }],
[types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false }],
]);
});
});
describe('#delete', () => {
it('sends correct actions if API is success', async () => {
axios.delete.mockResolvedValue({});
await actions.delete(
{ commit },
{
notification: { id: 1 },
count: 2,
unreadCount: 1,
}
);
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: true }],
[types.SET_NOTIFICATIONS_UNREAD_COUNT, 0],
[
types.DELETE_NOTIFICATION,
{ notification: { id: 1 }, count: 2, unreadCount: 1 },
],
[types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.delete.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.delete({ commit })).rejects.toThrow(Error);
await actions.delete(
{ commit },
{
notification: { id: 1 },
count: 2,
unreadCount: 1,
}
);
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: true }],
[types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false }],
]);
});
});
describe('#readAll', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({ data: 1 });
@@ -107,4 +212,73 @@ describe('#actions', () => {
]);
});
});
describe('clear', () => {
it('sends correct actions', async () => {
await actions.clear({ commit });
expect(commit.mock.calls).toEqual([[types.CLEAR_NOTIFICATIONS]]);
});
});
describe('deleteAllRead', () => {
it('sends correct actions if API is success', async () => {
axios.delete.mockResolvedValue({});
await actions.deleteAllRead({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: true }],
[types.DELETE_READ_NOTIFICATIONS],
[types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.delete.mockRejectedValue({ message: 'Incorrect header' });
await actions.deleteAllRead({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: true }],
[types.DELETE_READ_NOTIFICATIONS],
[types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false }],
]);
});
});
describe('deleteAll', () => {
it('sends correct actions if API is success', async () => {
axios.delete.mockResolvedValue({});
await actions.deleteAll({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: true }],
[types.DELETE_ALL_NOTIFICATIONS],
[types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.delete.mockRejectedValue({ message: 'Incorrect header' });
await actions.deleteAll({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: true }],
[types.DELETE_ALL_NOTIFICATIONS],
[types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false }],
]);
});
});
describe('snooze', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({});
await actions.snooze({ commit }, { id: 1, snoozedUntil: 1703057715 });
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: true }],
[types.SNOOZE_NOTIFICATION, { id: 1, snoozed_until: 1703057715 }],
[types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
await actions.snooze({ commit }, { id: 1, snoozedUntil: 1703057715 });
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: true }],
[types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false }],
]);
});
});
});
@@ -12,7 +12,10 @@ describe('#mutations', () => {
describe('#CLEAR_NOTIFICATIONS', () => {
it('clear notifications', () => {
const state = { records: { 1: { id: 1 } } };
const state = {
records: { 1: { id: 1 } },
uiFlags: { isAllNotificationsLoaded: true },
};
mutations[types.CLEAR_NOTIFICATIONS](state);
expect(state.records).toEqual({});
});
@@ -72,7 +75,10 @@ describe('#mutations', () => {
1: { id: 1, primary_actor_id: 1 },
},
};
mutations[types.UPDATE_NOTIFICATION](state, 1);
mutations[types.UPDATE_NOTIFICATION](state, {
id: 1,
read_at: true,
});
expect(state.records).toEqual({
1: { id: 1, primary_actor_id: 1, read_at: true },
});
@@ -141,4 +147,40 @@ describe('#mutations', () => {
expect(state.meta.count).toEqual(232);
});
});
describe('#SET_ALL_NOTIFICATIONS_LOADED', () => {
it('set all notifications loaded', () => {
const state = { uiFlags: { isAllNotificationsLoaded: false } };
mutations[types.SET_ALL_NOTIFICATIONS_LOADED](state);
expect(state.uiFlags).toEqual({ isAllNotificationsLoaded: true });
});
});
describe('#DELETE_READ_NOTIFICATIONS', () => {
it('delete read notifications', () => {
const state = {
records: {
1: { id: 1, primary_actor_id: 1, read_at: true },
2: { id: 2, primary_actor_id: 2 },
},
};
mutations[types.DELETE_READ_NOTIFICATIONS](state);
expect(state.records).toEqual({
2: { id: 2, primary_actor_id: 2 },
});
});
});
describe('#DELETE_ALL_NOTIFICATIONS', () => {
it('delete all notifications', () => {
const state = {
records: {
1: { id: 1, primary_actor_id: 1, read_at: true },
2: { id: 2, primary_actor_id: 2 },
},
};
mutations[types.DELETE_ALL_NOTIFICATIONS](state);
expect(state.records).toEqual({});
});
});
});
@@ -140,6 +140,10 @@ export default {
CLEAR_NOTIFICATIONS: 'CLEAR_NOTIFICATIONS',
EDIT_NOTIFICATIONS: 'EDIT_NOTIFICATIONS',
UPDATE_NOTIFICATIONS_PRESENCE: 'UPDATE_NOTIFICATIONS_PRESENCE',
SET_ALL_NOTIFICATIONS_LOADED: 'SET_ALL_NOTIFICATIONS_LOADED',
DELETE_READ_NOTIFICATIONS: 'DELETE_READ_NOTIFICATIONS',
DELETE_ALL_NOTIFICATIONS: 'DELETE_ALL_NOTIFICATIONS',
SNOOZE_NOTIFICATION: 'SNOOZE_NOTIFICATION',
// Contact Conversation
SET_CONTACT_CONVERSATIONS_UI_FLAG: 'SET_CONTACT_CONVERSATIONS_UI_FLAG',
@@ -125,6 +125,7 @@
"location-outline": "M5.843 4.568a8.707 8.707 0 1 1 12.314 12.314l-1.187 1.174c-.875.858-2.01 1.962-3.406 3.312a2.25 2.25 0 0 1-3.128 0l-3.491-3.396c-.439-.431-.806-.794-1.102-1.09a8.707 8.707 0 0 1 0-12.314Zm11.253 1.06A7.207 7.207 0 1 0 6.904 15.822L8.39 17.29a753.98 753.98 0 0 0 3.088 3 .75.75 0 0 0 1.043 0l3.394-3.3c.47-.461.863-.85 1.18-1.168a7.207 7.207 0 0 0 0-10.192ZM12 7.999a3.002 3.002 0 1 1 0 6.004 3.002 3.002 0 0 1 0-6.003Zm0 1.5a1.501 1.501 0 1 0 0 3.004 1.501 1.501 0 0 0 0-3.003Z",
"lock-closed-outline": "M12 2a4 4 0 0 1 4 4v2h1.75A2.25 2.25 0 0 1 20 10.25v9.5A2.25 2.25 0 0 1 17.75 22H6.25A2.25 2.25 0 0 1 4 19.75v-9.5A2.25 2.25 0 0 1 6.25 8H8V6a4 4 0 0 1 4-4Zm5.75 7.5H6.25a.75.75 0 0 0-.75.75v9.5c0 .414.336.75.75.75h11.5a.75.75 0 0 0 .75-.75v-9.5a.75.75 0 0 0-.75-.75Zm-5.75 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm0-10A2.5 2.5 0 0 0 9.5 6v2h5V6A2.5 2.5 0 0 0 12 3.5Z",
"lock-shield-outline": "M10 2a4 4 0 0 1 4 4v2h1.75A2.25 2.25 0 0 1 18 10.25V11c-.319 0-.637.11-.896.329l-.107.1c-.164.17-.33.323-.496.457L16.5 10.25a.75.75 0 0 0-.75-.75H4.25a.75.75 0 0 0-.75.75v9.5c0 .414.336.75.75.75h9.888a6.024 6.024 0 0 0 1.54 1.5H4.25A2.25 2.25 0 0 1 2 19.75v-9.5A2.25 2.25 0 0 1 4.25 8H6V6a4 4 0 0 1 4-4Zm8.284 10.122c.992 1.036 2.091 1.545 3.316 1.545.193 0 .355.143.392.332l.008.084v2.501c0 2.682-1.313 4.506-3.873 5.395a.385.385 0 0 1-.253 0c-2.476-.86-3.785-2.592-3.87-5.13L14 16.585v-2.5c0-.23.18-.417.4-.417 1.223 0 2.323-.51 3.318-1.545a.389.389 0 0 1 .566 0ZM10 13.5a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm0-10A2.5 2.5 0 0 0 7.5 6v2h5V6A2.5 2.5 0 0 0 10 3.5Z",
"mail-inbox-outline": "M6.25 3h11.5a3.25 3.25 0 0 1 3.245 3.066L21 6.25v11.5a3.25 3.25 0 0 1-3.066 3.245L17.75 21H6.25a3.25 3.25 0 0 1-3.245-3.066L3 17.75V6.25a3.25 3.25 0 0 1 3.066-3.245L6.25 3h11.5h-11.5ZM4.5 14.5v3.25a1.75 1.75 0 0 0 1.606 1.744l.144.006h11.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V14.5h-3.825a3.752 3.752 0 0 1-3.475 2.995l-.2.005a3.752 3.752 0 0 1-3.632-2.812l-.043-.188H4.5v3.25v-3.25Zm13.25-10H6.25a1.75 1.75 0 0 0-1.744 1.606L4.5 6.25V13H9a.75.75 0 0 1 .743.648l.007.102a2.25 2.25 0 0 0 4.495.154l.005-.154a.75.75 0 0 1 .648-.743L15 13h4.5V6.25a1.75 1.75 0 0 0-1.607-1.744L17.75 4.5Z",
"mail-inbox-all-outline": "M6.25 3h11.5a3.25 3.25 0 0 1 3.245 3.066L21 6.25v11.5a3.25 3.25 0 0 1-3.066 3.245L17.75 21H6.25a3.25 3.25 0 0 1-3.245-3.066L3 17.75V6.25a3.25 3.25 0 0 1 3.066-3.245L6.25 3Zm2.075 11.5H4.5v3.25a1.75 1.75 0 0 0 1.606 1.744l.144.006h11.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V14.5h-3.825a3.752 3.752 0 0 1-3.475 2.995l-.2.005a3.752 3.752 0 0 1-3.632-2.812l-.043-.188Zm9.425-10H6.25a1.75 1.75 0 0 0-1.744 1.606L4.5 6.25V13H9a.75.75 0 0 1 .743.648l.007.102a2.25 2.25 0 0 0 4.495.154l.005-.154a.75.75 0 0 1 .648-.743L15 13h4.5V6.25a1.75 1.75 0 0 0-1.607-1.744L17.75 4.5Zm-11 5h10.5a.75.75 0 0 1 .102 1.493L17.25 11H6.75a.75.75 0 0 1-.102-1.493L6.75 9.5h10.5-10.5Zm0-3h10.5a.75.75 0 0 1 .102 1.493L17.25 8H6.75a.75.75 0 0 1-.102-1.493L6.75 6.5h10.5-10.5Z",
"mail-unread-outline": "M16 6.5H5.25a1.75 1.75 0 0 0-1.744 1.606l-.004.1L11 12.153l6.03-3.174a3.489 3.489 0 0 0 2.97.985v6.786a3.25 3.25 0 0 1-3.066 3.245L16.75 20H5.25a3.25 3.25 0 0 1-3.245-3.066L2 16.75v-8.5a3.25 3.25 0 0 1 3.066-3.245L5.25 5h11.087A3.487 3.487 0 0 0 16 6.5Zm2.5 3.399-7.15 3.765a.75.75 0 0 1-.603.042l-.096-.042L3.5 9.9v6.85a1.75 1.75 0 0 0 1.606 1.744l.144.006h11.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V9.899ZM19.5 4a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5Z",
"mail-outline": "M5.25 4h13.5a3.25 3.25 0 0 1 3.245 3.066L22 7.25v9.5a3.25 3.25 0 0 1-3.066 3.245L18.75 20H5.25a3.25 3.25 0 0 1-3.245-3.066L2 16.75v-9.5a3.25 3.25 0 0 1 3.066-3.245L5.25 4h13.5-13.5ZM20.5 9.373l-8.15 4.29a.75.75 0 0 1-.603.043l-.096-.042L3.5 9.374v7.376a1.75 1.75 0 0 0 1.606 1.744l.144.006h13.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V9.373ZM18.75 5.5H5.25a1.75 1.75 0 0 0-1.744 1.606L3.5 7.25v.429l8.5 4.473 8.5-4.474V7.25a1.75 1.75 0 0 0-1.607-1.744L18.75 5.5Z",
+42 -37
View File
@@ -1,44 +1,40 @@
<template>
<div>
<label
v-if="label"
:for="name"
class="flex justify-between text-sm font-medium leading-6 text-slate-900 dark:text-white"
:class="{ 'text-red-500': hasError }"
>
{{ label }}
<slot />
</label>
<div class="mt-1">
<input
:id="name"
:name="name"
:type="type"
autocomplete="off"
:required="required"
:placeholder="placeholder"
:data-testid="dataTestid"
:value="value"
:class="{
'focus:ring-red-600 ring-red-600': hasError,
'dark:ring-slate-600 dark:focus:ring-woot-500 ring-slate-200':
!hasError,
}"
class="block w-full rounded-md border-0 px-3 py-3 appearance-none shadow-sm ring-1 ring-inset text-slate-900 dark:text-slate-100 placeholder:text-slate-400 focus:ring-2 focus:ring-inset focus:ring-woot-500 sm:text-sm sm:leading-6 outline-none dark:bg-slate-700"
@input="onInput"
@blur="$emit('blur')"
/>
<span
v-if="errorMessage && hasError"
class="text-xs leading-2 text-red-400"
>
{{ errorMessage }}
</span>
</div>
</div>
<with-label
:label="label"
:icon="icon"
:name="name"
:has-error="hasError"
:error-message="errorMessage"
>
<input
:id="name"
:name="name"
:type="type"
autocomplete="off"
:required="required"
:placeholder="placeholder"
:data-testid="dataTestid"
:value="value"
:class="{
'focus:ring-red-600 ring-red-600': hasError,
'dark:ring-slate-600 dark:focus:ring-woot-500 ring-slate-200':
!hasError,
'px-3 py-3': spacing === 'base',
'px-3 py-2 mb-0': spacing === 'compact',
'pl-9': icon,
}"
class="block w-full border-none rounded-xl shadow-sm appearance-none outline outline-1 outline-slate-200 dark:outline-slate-800 focus:outline-none focus:outline-0 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 focus:ring-2 focus:ring-woot-500 sm:text-sm sm:leading-6 dark:bg-slate-800"
@input="onInput"
@blur="$emit('blur')"
/>
</with-label>
</template>
<script>
import WithLabel from './WithLabel.vue';
export default {
components: {
WithLabel,
},
props: {
label: {
type: String,
@@ -64,6 +60,10 @@ export default {
type: [String, Number],
default: '',
},
icon: {
type: String,
default: '',
},
hasError: {
type: Boolean,
default: false,
@@ -76,6 +76,11 @@ export default {
type: String,
default: '',
},
spacing: {
type: String,
default: 'base',
validator: value => ['base', 'compact'].includes(value),
},
},
methods: {
onInput(e) {
@@ -0,0 +1,60 @@
<template>
<with-label
:label="label"
:name="name"
:has-error="hasError"
:error-message="errorMessage"
>
<div class="flex gap-2 flex-wrap">
<woot-button
v-for="option in options"
:key="option.value"
:variant="value === option.value ? '' : 'hollow'"
:color-scheme="value === option.value ? 'primary' : 'secondary'"
size="small"
@click="$emit('input', option.value)"
>
{{ option.label }}
</woot-button>
</div>
</with-label>
</template>
<script>
import WithLabel from './WithLabel.vue';
export default {
components: {
WithLabel,
},
props: {
id: {
type: String,
default: '',
},
options: {
type: Array,
default: () => [],
},
name: {
type: String,
required: true,
},
label: {
type: String,
required: true,
},
value: {
type: String,
default: '',
},
hasError: {
type: Boolean,
default: false,
},
errorMessage: {
type: String,
default: '',
},
},
};
</script>
@@ -0,0 +1,92 @@
<template>
<with-label
:label="label"
:icon="icon"
:name="name"
:has-error="hasError"
:error-message="errorMessage"
>
<select
:id="id"
:selected="value"
:name="name"
:class="{
'text-slate-400': !value,
'text-slate-900 dark:text-slate-100': value,
'pl-9': icon,
}"
class="mb-0 block w-full px-3 py-2 pr-6 border-0 rounded-xl shadow-sm outline-none appearance-none select-caret ring-1 ring-inset placeholder:text-slate-400 focus:ring-2 focus:ring-inset focus:ring-woot-500 sm:text-sm sm:leading-6 dark:bg-slate-700 dark:ring-slate-600 dark:focus:ring-woot-500 ring-slate-200"
@input="onInput"
>
<option value="" disabled selected class="hidden">
{{ placeholder }}
</option>
<slot>
<option v-for="opt in options" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</slot>
</select>
</with-label>
</template>
<script>
import WithLabel from './WithLabel.vue';
export default {
components: {
WithLabel,
},
props: {
id: {
type: String,
default: '',
},
options: {
type: Array,
default: () => [],
},
placeholder: {
type: String,
default: '',
},
name: {
type: String,
required: true,
},
icon: {
type: String,
default: '',
},
label: {
type: String,
required: true,
},
value: {
type: String,
default: '',
},
hasError: {
type: Boolean,
default: false,
},
errorMessage: {
type: String,
default: '',
},
},
methods: {
onInput(e) {
this.$emit('input', e.target.value);
},
},
};
</script>
<style scoped>
.select-caret {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' width='32' height='24' viewBox='0 0 32 24'><polygon points='0,0 32,0 16,24' style='fill: rgb%28110, 111, 115%29'></polygon></svg>");
background-origin: content-box;
background-position: right -1rem center;
background-repeat: no-repeat;
background-size: 9px 6px;
}
</style>
@@ -0,0 +1,57 @@
<template>
<div class="space-y-1">
<label
v-if="label"
:for="name"
class="flex justify-between text-sm font-medium leading-6 text-slate-900 dark:text-white"
:class="{ 'text-red-500': hasError }"
>
<slot name="label">
{{ label }}
</slot>
</label>
<div class="w-full">
<div class="flex items-center relative w-full">
<fluent-icon
v-if="icon"
size="16"
:icon="icon"
class="absolute left-2 transform text-slate-400 dark:text-slate-600 w-5 h-5"
/>
<slot />
</div>
<span
v-if="errorMessage && hasError"
class="text-xs text-red-400 leading-2"
>
{{ errorMessage }}
</span>
</div>
</div>
</template>
<script>
export default {
props: {
label: {
type: String,
default: '',
},
name: {
type: String,
required: true,
},
icon: {
type: String,
default: '',
},
hasError: {
type: Boolean,
default: false,
},
errorMessage: {
type: String,
default: '',
},
},
};
</script>
@@ -0,0 +1,54 @@
<template>
<div
class="flex items-center gap-2 p-2 text-lg font-bold mb-6 relative before:absolute before:h-10 before:w-[1px] before:bg-slate-200 before:-bottom-8 before:left-[24px] hide-before-of-last"
:class="{
'text-woot-500 ': isActive,
'text-slate-400': !isActive || isComplete,
'before:bg-woot-500': !isActive && isComplete,
}"
>
<div
class="grid w-8 h-8 border border-solid rounded-full place-content-center"
:class="{
'border-woot-500': !isActive || isComplete,
'border-slate-200 dark:border-slate-600': !isActive && !isComplete,
'text-woot-500': isComplete,
}"
>
<fluent-icon v-if="isComplete" size="20" icon="checkmark" />
<fluent-icon v-else size="20" :icon="icon" />
</div>
<span>
{{ title }}
</span>
</div>
</template>
<script>
export default {
name: 'OnboardingStep',
props: {
title: {
type: String,
required: true,
},
icon: {
type: String,
required: true,
},
isActive: {
type: Boolean,
default: false,
},
isComplete: {
type: Boolean,
default: false,
},
},
};
</script>
<style scoped>
.hide-before-of-last:last-child::before {
display: none;
}
</style>
@@ -2,8 +2,10 @@ class Channels::Whatsapp::TemplatesSyncSchedulerJob < ApplicationJob
queue_as :low
def perform
Channel::Whatsapp.where('message_templates_last_updated <= ? OR message_templates_last_updated IS NULL',
3.hours.ago).limit(Limits::BULK_EXTERNAL_HTTP_CALLS_LIMIT).all.each do |channel|
Channel::Whatsapp.order(Arel.sql('message_templates_last_updated IS NULL DESC, message_templates_last_updated ASC'))
.where('message_templates_last_updated <= ? OR message_templates_last_updated IS NULL', 3.hours.ago)
.limit(Limits::BULK_EXTERNAL_HTTP_CALLS_LIMIT)
.each do |channel|
Channels::Whatsapp::TemplatesSyncJob.perform_later(channel)
end
end
+8 -7
View File
@@ -51,6 +51,11 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
return if email_already_present?(channel, message_id)
if message_id.blank?
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Empty message id for #{channel.email} with seq no. <#{seq_no}>."
return
end
# Fetch the original mail content using the sequence no
mail_str = imap_client.fetch(seq_no, 'RFC822')[0].attr['RFC822']
@@ -74,7 +79,7 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
message_ids_with_seq = []
seq_nums.each_slice(10).each do |batch|
# Fetch only message-id only without mail body or contents.
batch_message_ids = imap_client.fetch(batch, 'BODY.PEEK[HEADER.FIELDS (MESSAGE-ID)]')
batch_message_ids = imap_client.fetch(batch, 'BODY.PEEK[HEADER]')
# .fetch returns an array of Net::IMAP::FetchData or nil
# (instead of an empty array) if there is no matching message.
@@ -85,7 +90,7 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
end
batch_message_ids.each do |data|
message_id = build_mail_from_string(data.attr['BODY[HEADER.FIELDS (MESSAGE-ID)]']).message_id
message_id = build_mail_from_string(data.attr['BODY[HEADER]']).message_id
message_ids_with_seq.push([data.seqno, message_id])
end
end
@@ -97,7 +102,7 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
# created between yesterday and today and returns message sequence numbers.
# Return <message set>
def fetch_available_mail_sequence_numbers(imap_client)
imap_client.search(['BEFORE', tomorrow, 'SINCE', yesterday])
imap_client.search(['SINCE', yesterday])
end
def fetch_mail_for_ms_provider(channel)
@@ -161,8 +166,4 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
def yesterday
(Time.zone.today - 1).strftime('%d-%b-%Y')
end
def tomorrow
(Time.zone.today + 1).strftime('%d-%b-%Y')
end
end
+10 -14
View File
@@ -4,21 +4,17 @@ class Internal::CheckNewVersionsJob < ApplicationJob
def perform
return unless Rails.env.production?
instance_info = ChatwootHub.sync_with_hub
return unless instance_info
::Redis::Alfred.set(::Redis::Alfred::LATEST_CHATWOOT_VERSION, instance_info['version'])
update_installation_config(key: 'INSTALLATION_PRICING_PLAN', value: instance_info['plan'])
update_installation_config(key: 'INSTALLATION_PRICING_PLAN_QUANTITY', value: instance_info['plan_quantity'])
update_installation_config(key: 'CHATWOOT_SUPPORT_WEBSITE_TOKEN', value: instance_info['chatwoot_support_website_token'])
update_installation_config(key: 'CHATWOOT_SUPPORT_IDENTIFIER_HASH', value: instance_info['chatwoot_support_identifier_hash'])
update_installation_config(key: 'CHATWOOT_SUPPORT_SCRIPT_URL', value: instance_info['chatwoot_support_script_url'])
@instance_info = ChatwootHub.sync_with_hub
update_version_info
end
def update_installation_config(key:, value:)
config = InstallationConfig.find_or_initialize_by(name: key)
config.value = value
config.locked = true
config.save!
private
def update_version_info
return if @instance_info['version'].blank?
::Redis::Alfred.set(::Redis::Alfred::LATEST_CHATWOOT_VERSION, @instance_info['version'])
end
end
Internal::CheckNewVersionsJob.prepend_mod_with('Internal::CheckNewVersionsJob')
@@ -0,0 +1,15 @@
class Notification::DeleteNotificationJob < ApplicationJob
queue_as :low
def perform(user, type: :all)
ActiveRecord::Base.transaction do
if type == :all
# Delete all notifications
user.notifications.destroy_all
elsif type == :read
# Delete only read notifications
user.notifications.where.not(read_at: nil).destroy_all
end
end
end
end
-1
View File
@@ -32,7 +32,6 @@ class Account < ApplicationRecord
check_for_column: false
}.freeze
validates :name, presence: true
validates :auto_resolve_duration, numericality: { greater_than_or_equal_to: 1, less_than_or_equal_to: 999, allow_nil: true }
validates :domain, length: { maximum: 100 }
+12 -6
View File
@@ -30,10 +30,15 @@ class AutomationRule < ApplicationRecord
scope :active, -> { where(active: true) }
CONDITIONS_ATTRS = %w[content email country_code status message_type browser_language assignee_id team_id referer city company inbox_id
mail_subject phone_number priority conversation_language].freeze
ACTIONS_ATTRS = %w[send_message add_label remove_label send_email_to_team assign_team assign_agent send_webhook_event mute_conversation
send_attachment change_status resolve_conversation snooze_conversation change_priority send_email_transcript].freeze
def conditions_attributes
%w[content email country_code status message_type browser_language assignee_id team_id referer city company inbox_id
mail_subject phone_number priority conversation_language]
end
def actions_attributes
%w[send_message add_label remove_label send_email_to_team assign_team assign_agent send_webhook_event mute_conversation
send_attachment change_status resolve_conversation snooze_conversation change_priority send_email_transcript].freeze
end
def file_base_data
files.map do |file|
@@ -55,7 +60,7 @@ class AutomationRule < ApplicationRecord
return if conditions.blank?
attributes = conditions.map { |obj, _| obj['attribute_key'] }
conditions = attributes - CONDITIONS_ATTRS
conditions = attributes - conditions_attributes
conditions -= account.custom_attribute_definitions.pluck(:attribute_key)
errors.add(:conditions, "Automation conditions #{conditions.join(',')} not supported.") if conditions.any?
end
@@ -64,7 +69,7 @@ class AutomationRule < ApplicationRecord
return if actions.blank?
attributes = actions.map { |obj, _| obj['action_name'] }
actions = attributes - ACTIONS_ATTRS
actions = attributes - actions_attributes
errors.add(:actions, "Automation actions #{actions.join(',')} not supported.") if actions.any?
end
@@ -78,3 +83,4 @@ class AutomationRule < ApplicationRecord
end
AutomationRule.include_mod_with('Audit::AutomationRule')
AutomationRule.prepend_mod_with('AutomationRule')
+1 -2
View File
@@ -8,7 +8,6 @@
# imap_address :string default("")
# imap_enable_ssl :boolean default(TRUE)
# imap_enabled :boolean default(FALSE)
# imap_inbox_synced_at :datetime
# imap_login :string default("")
# imap_password :string default("")
# imap_port :integer default(0)
@@ -41,7 +40,7 @@ class Channel::Email < ApplicationRecord
AUTHORIZATION_ERROR_THRESHOLD = 10
self.table_name = 'channel_email'
EDITABLE_ATTRS = [:email, :imap_enabled, :imap_login, :imap_password, :imap_address, :imap_port, :imap_enable_ssl, :imap_inbox_synced_at,
EDITABLE_ATTRS = [:email, :imap_enabled, :imap_login, :imap_password, :imap_address, :imap_port, :imap_enable_ssl,
:smtp_enabled, :smtp_login, :smtp_password, :smtp_address, :smtp_port, :smtp_domain, :smtp_enable_starttls_auto,
:smtp_enable_ssl_tls, :smtp_openssl_verify_mode, :smtp_authentication, :provider].freeze
+1
View File
@@ -61,6 +61,7 @@ class Notification < ApplicationRecord
user: user&.push_event_data,
created_at: created_at.to_i,
last_activity_at: last_activity_at.to_i,
snoozed_until: snoozed_until,
account_id: account_id
}
+2 -2
View File
@@ -68,8 +68,7 @@ class User < ApplicationRecord
# work because :validatable in devise overrides this.
# validates_uniqueness_of :email, scope: :account_id
validates :email, :name, presence: true
validates_length_of :name, minimum: 1, maximum: 255
validates :email, presence: true
has_many :account_users, dependent: :destroy_async
has_many :accounts, through: :account_users
@@ -161,3 +160,4 @@ class User < ApplicationRecord
end
User.include_mod_with('Audit::User')
User.include_mod_with('Concerns::User')
+4
View File
@@ -14,4 +14,8 @@ class UserPolicy < ApplicationPolicy
def destroy?
@account_user.administrator?
end
def bulk_create?
@account_user.administrator?
end
end
+2
View File
@@ -89,3 +89,5 @@ class ActionService
@conversation.additional_attributes['type'] == 'tweet'
end
end
ActionService.include_mod_with('ActionService')
@@ -1,48 +1,39 @@
# refer: https://gitlab.com/gitlab-org/ruby/gems/gitlab-mail_room/-/blob/master/lib/mail_room/microsoft_graph/connection.rb
# refer: https://github.com/microsoftgraph/msgraph-sample-rubyrailsapp/tree/b4a6869fe4a438cde42b161196484a929f1bee46
# https://learn.microsoft.com/en-us/azure/active-directory/develop/active-directory-configurable-token-lifetimes
# Refer: https://learn.microsoft.com/en-us/entra/identity-platform/configurable-token-lifetimes
class Microsoft::RefreshOauthTokenService
pattr_initialize [:channel!]
# if the token is not expired yet then skip the refresh token step
# Additional references: https://gitlab.com/gitlab-org/ruby/gems/gitlab-mail_room/-/blob/master/lib/mail_room/microsoft_graph/connection.rb
def access_token
provider_config = channel.provider_config.with_indifferent_access
if Time.current.utc >= expires_on(provider_config['expires_on'])
# Token expired, refresh
new_hash = refresh_tokens
new_hash[:access_token]
else
provider_config[:access_token]
end
return provider_config[:access_token] unless access_token_expired?
refreshed_tokens = refresh_tokens
refreshed_tokens[:access_token]
end
def expires_on(expiry)
# we will give it a 5 minute gap for safety
expiry.presence ? DateTime.parse(expiry) - 5.minutes : Time.current.utc
def access_token_expired?
expiry = provider_config[:expires_on]
return true if expiry.blank?
# Adding a 5 minute window to expiry check to avoid any race
# conditions during the fetch operation. This would assure that the
# tokens are updated when we fetch the emails.
Time.current.utc >= DateTime.parse(expiry) - 5.minutes
end
# <RefreshTokensSnippet>
# Refresh the access tokens using the refresh token
# Refer: https://github.com/microsoftgraph/msgraph-sample-rubyrailsapp/tree/b4a6869fe4a438cde42b161196484a929f1bee46
def refresh_tokens
token_hash = channel.provider_config.with_indifferent_access
oauth_strategy = ::MicrosoftGraphAuth.new(
nil, ENV.fetch('AZURE_APP_ID', nil), ENV.fetch('AZURE_APP_SECRET', nil)
)
oauth_strategy = build_oauth_strategy
token_service = build_token_service(oauth_strategy)
token_service = OAuth2::AccessToken.new(
oauth_strategy.client, token_hash['access_token'],
refresh_token: token_hash['refresh_token']
)
# Refresh the tokens
new_tokens = token_service.refresh!.to_hash.slice(:access_token, :refresh_token, :expires_at)
update_channel_provider_config(new_tokens)
channel.provider_config
channel.reload.provider_config
end
# </RefreshTokensSnippet>
def update_channel_provider_config(new_tokens)
new_tokens = new_tokens.with_indifferent_access
channel.provider_config = {
access_token: new_tokens[:access_token],
refresh_token: new_tokens[:refresh_token],
@@ -50,4 +41,24 @@ class Microsoft::RefreshOauthTokenService
}
channel.save!
end
private
def provider_config
@provider_config ||= channel.provider_config.with_indifferent_access
end
# Builds the OAuth strategy for Microsoft Graph
def build_oauth_strategy
::MicrosoftGraphAuth.new(nil, ENV.fetch('AZURE_APP_ID'), ENV.fetch('AZURE_APP_SECRET'))
end
# Builds the token service using OAuth2
def build_token_service(oauth_strategy)
OAuth2::AccessToken.new(
oauth_strategy.client,
provider_config[:access_token],
refresh_token: provider_config[:refresh_token]
)
end
end
@@ -20,6 +20,7 @@ json.data do
json.user notification.user.push_event_data
json.created_at notification.created_at.to_i
json.last_activity_at notification.last_activity_at.to_i
json.snoozed_until notification.snoozed_until
end
end
end
@@ -4,11 +4,15 @@
<div class="field-unit__field feature-container">
<% field.data.each do |key,val| %>
<div class='feature-cell'>
<% if ['audit_logs', 'response_bot'].include? key %>
<span class='icon-container'><i class="ion ion-asterisk"></i></span>
<% is_premium = SuperAdmin::AccountFeaturesHelper.account_premium_features.include? key %>
<% if is_premium %>
<span class='icon-container'>
<svg class="inline" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512"><path d="M480 224l-186.828 7.487L401.688 64l-59.247-32L256 208 169.824 32l-59.496 32 108.5 167.487L32 224v64l185.537-10.066L113.65 448l55.969 32L256 304l86.381 176 55.949-32-103.867-170.066L480 288z" fill="currentColor"/></svg>
</span>
<% end %>
<span><%= key %></span>
<span class='value-container'><%= check_box "enabled_features", "feature_#{key}", { checked: val }, true, false %> </span>
<% should_disable = is_premium && ChatwootHub.pricing_plan == 'community' %>
<span class='value-container'><%= check_box "enabled_features", "feature_#{key}", { checked: val, disabled: should_disable }, true, false %> </span>
</div>
<% end %>
</div>
@@ -1,8 +1,10 @@
<div class='feature-container'>
<% field.data.each do |key,val| %>
<div class='feature-cell'>
<% if ['audit_logs', 'response_bot'].include? key %>
<span class='icon-container'><i class="ion ion-asterisk"></i></span>
<% if SuperAdmin::AccountFeaturesHelper.account_premium_features.include? key %>
<span class='icon-container'>
<svg class="inline" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512"><path d="M480 224l-186.828 7.487L401.688 64l-59.247-32L256 208 169.824 32l-59.496 32 108.5 167.487L32 224v64l185.537-10.066L113.65 448l55.969 32L256 304l86.381 176 55.949-32-103.867-170.066L480 288z" fill="currentColor"/></svg>
</span>
<% end %>
<span><%= key %></span>
<span class='value-container'><%= val.present? ? '✅' : '❌' %> </span>
@@ -1,5 +1,5 @@
<% content_for(:title) do %>
Configure Settings
Configure Settings - <%= @config.titleize %>
<% end %>
<header class="main-content__header" role="banner">
<h1 class="main-content__page-title" id="page-title">
+10 -1
View File
@@ -13,6 +13,15 @@
</div>
</header>
<section class="main-content__body">
<% if Redis::Alfred.get(Redis::Alfred::CHATWOOT_INSTALLATION_CONFIG_RESET_WARNING) %>
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-5" role="alert">
<strong class="font-bold">Alert!</strong>
<span class="block sm:inline">Unauthorized premium changes detected in Chatwoot. To keep using them, please upgrade your plan.
Contact for help :</span><span class="inline rounded-full bg-red-200 px-2 text-white ml-2">sales@chatwoot.com</span>
</div>
<% end %>
<div class="bg-white py-2 px-3">
<div class="mb-4">
<div class="flex items-center gap-2">
@@ -44,7 +53,7 @@
<% if ChatwootHub.pricing_plan != 'community' && User.count > ChatwootHub.pricing_plan_quantity %>
<div role="alert">
<div class="border border-t-0 border-red-400 rounded-b bg-red-100 px-4 py-3 text-red-700">
<p>You have <%= User.count %> agents. Please add more licenses.</p>
<p>You have <%= User.count %> agents. Please add more licenses to add more users.</p>
</div>
</div>
<% end %>
+5
View File
@@ -11,6 +11,7 @@
enabled: false
- name: disable_branding
enabled: false
premium: true
- name: email_continuity_on_api_channel
enabled: false
- name: help_center
@@ -55,9 +56,13 @@
enabled: false
- name: audit_logs
enabled: false
premium: true
- name: response_bot
enabled: false
premium: true
- name: message_reply_to
enabled: false
- name: insert_article_in_reply
enabled: false
- name: inbox_view
enabled: false
+5
View File
@@ -94,6 +94,11 @@ class Rack::Attack
end
end
## Resend confirmation throttling
throttle('resend_confirmation/ip', limit: 5, period: 30.minutes) do |req|
req.ip if req.path_without_extentions == '/api/v1/profile/resend_confirmation' && req.post?
end
## Prevent Brute-Force Signup Attacks ###
throttle('accounts/ip', limit: 5, period: 30.minutes) do |req|
req.ip if req.path_without_extentions == '/api/v1/accounts' && req.post?
+107 -31
View File
@@ -1,5 +1,20 @@
# if you don't specify locked attribute, the default value will be true
# which means the particular config will be locked
# This file contains all the installation wide configuration which controls various settings in Chatwoot
# This is internal config and should not be modified by the user directly in database
# Chatwoot might override and modify these values during the upgrade process
# Configs which can be modified by the user are available in the dashboard under appropriate UI
#
# name: the name of the config referenced in the code
# value: the value of the config
# display_title: the title of the config displayed in the dashboard UI
# description: the description of the config displayed in the dashboard UI
# locked: if you don't specify locked attribute in yaml, the default value will be true,
# which means the particular config will be locked and won't be available in `super_admin/installation_configs`
# premium: These values get overwritten unless the user is on a premium plan
# type: The type of the config. Default is text, boolean is also supported
# ------- Branding Related Config ------- #
- name: INSTALLATION_NAME
value: 'Chatwoot'
display_title: 'Installation Name'
@@ -41,32 +56,20 @@
display_title: 'Chatwoot Metadata'
description: 'Display default Chatwoot metadata like favicons and upgrade warnings'
type: boolean
- name: MAILER_INBOUND_EMAIL_DOMAIN
value:
locked: false
- name: MAILER_SUPPORT_EMAIL
value:
# ------- End of Branding Related Config ------- #
# ------- Signup & Account Related Config ------- #
- name: ENABLE_ACCOUNT_SIGNUP
display_title: 'Enable Account Signup'
value: false
description: 'Allow users to signup for new accounts'
locked: false
type: boolean
- name: CREATE_NEW_ACCOUNT_FROM_DASHBOARD
value: false
locked: false
- name: INSTALLATION_EVENTS_WEBHOOK_URL
value:
locked: false
- name: CHATWOOT_INBOX_TOKEN
value:
locked: false
- name: CHATWOOT_INBOX_HMAC_KEY
value:
locked: false
- name: API_CHANNEL_NAME
value:
- name: API_CHANNEL_THUMBNAIL
value:
- name: ANALYTICS_TOKEN
value:
- name: DIRECT_UPLOADS_ENABLED
value: false
description: 'Allow users to create new accounts from the dashboard'
locked: false
- name: HCAPTCHA_SITE_KEY
value:
@@ -74,34 +77,107 @@
- name: HCAPTCHA_SERVER_KEY
value:
locked: false
- name: LOGOUT_REDIRECT_LINK
value: /app/login
- name: INSTALLATION_EVENTS_WEBHOOK_URL
value:
display_title: 'System events Webhook URL'
description: 'The URL to which the system events like new accounts created will be sent'
locked: false
- name: DISABLE_USER_PROFILE_UPDATE
- name: DIRECT_UPLOADS_ENABLED
type: boolean
value: false
description: 'Enable direct uploads to cloud storage'
locked: false
# ------- End of Account Related Config ------- #
# ------- Email Related Config ------- #
- name: MAILER_INBOUND_EMAIL_DOMAIN
value:
description: 'The domain name to be used for generating conversation continuity emails (reply+id@domain.com)'
locked: false
- name: MAILER_SUPPORT_EMAIL
value:
locked: false
# ------- End of Email Related Config ------- #
# ------- Facebook Channel Related Config ------- #
- name: FB_APP_ID
display_title: 'Facebook App ID'
locked: false
- name: FB_VERIFY_TOKEN
display_title: 'Facebook Verify Token'
description: 'The verify token used for Facebook Messenger Webhook'
locked: false
- name: FB_APP_SECRET
display_title: 'Facebook App Secret'
locked: false
- name: IG_VERIFY_TOKEN
display_title: 'Instagram Verify Token'
description: 'The verify token used for Instagram Webhook'
locked: false
- name: ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT
display_title: 'Enable human agent'
value: false
locked: false
- name: CSML_BOT_HOST
description: 'Enable human agent for messenger channel for longer message back period. Needs additional app approval: https://developers.facebook.com/docs/features-reference/human-agent/'
type: boolean
# ------- End of Facebook Channel Related Config ------- #
# ------- Chatwoot Internal Config for Cloud ----#
- name: CHATWOOT_INBOX_TOKEN
value:
description: 'The Chatwoot Inbox Token for Contact Support in Cloud'
locked: false
- name: CSML_BOT_API_KEY
- name: CHATWOOT_INBOX_HMAC_KEY
value:
description: 'The Chatwoot Inbox HMAC Key for Contact Support in Cloud'
locked: false
- name: CHATWOOT_CLOUD_PLANS
value:
description: 'Config to store stripe plans for cloud'
- name: DEPLOYMENT_ENV
value: self-hosted
- name: CSML_EDITOR_HOST
description: 'The deployment environment of the installation, to differentiate between Chatwoot cloud and self-hosted'
- name: ANALYTICS_TOKEN
value:
description: 'The June.so analytics token for Chatwoot cloud'
# ------- End of Chatwoot Internal Config for Cloud ----#
# ------- Chatwoot Internal Config for Self Hosted ----#
- name: INSTALLATION_PRICING_PLAN
value: 'community'
description: 'The pricing plan for the installation, retrieved from the billing API'
- name: INSTALLATION_PRICING_PLAN_QUANTITY
value: 0
description: 'The number of licenses purchased for the installation, retrieved from the billing API'
- name: CHATWOOT_SUPPORT_WEBSITE_TOKEN
value:
description: 'The Chatwoot website token, used to identify the Chatwoot inbox and display the "Contact Support" option on the billing page'
- name: CHATWOOT_SUPPORT_SCRIPT_URL
value:
description: 'The Chatwoot script base URL, to display the "Contact Support" option on the billing page'
- name: CHATWOOT_SUPPORT_IDENTIFIER_HASH
value:
description: 'The Chatwoot identifier hash, to validate the contact in the live chat window.'
# ------- End of Chatwoot Internal Config for Self Hosted ----#
## ------ Configs added for enterprise clients ------ ##
- name: API_CHANNEL_NAME
value:
description: 'Custom name for the API channel'
- name: API_CHANNEL_THUMBNAIL
value:
description: 'Custom thumbnail for the API channel'
- name: LOGOUT_REDIRECT_LINK
value: /app/login
locked: false
description: 'Redirect to a different link after logout'
- name: DISABLE_USER_PROFILE_UPDATE
value: false
locked: false
description: 'Disable rendering profile update page for users'
## ------ End of Configs added for enterprise clients ------ ##
+1
View File
@@ -45,6 +45,7 @@ en:
disposable_email: We do not allow disposable emails
invalid_email: You have entered an invalid email
email_already_exists: "You have already signed up for an account with %{email}"
invalid_params: 'Invalid, please check the signup paramters and try again'
failed: Signup failed
data_import:
data_type:
+18 -11
View File
@@ -44,7 +44,9 @@ Rails.application.routes.draw do
resource :contact_merge, only: [:create]
end
resource :bulk_actions, only: [:create]
resources :agents, only: [:index, :create, :update, :destroy]
resources :agents, only: [:index, :create, :update, :destroy] do
post :bulk_create, on: :collection
end
resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do
delete :avatar, on: :member
end
@@ -173,9 +175,11 @@ Rails.application.routes.draw do
collection do
post :read_all
get :unread_count
post :destroy_all
end
member do
post :snooze
post :unread
end
end
resource :notification_settings, only: [:show, :update]
@@ -247,6 +251,7 @@ Rails.application.routes.draw do
post :availability
post :auto_offline
put :set_active_account
post :resend_confirmation
end
end
@@ -287,16 +292,18 @@ Rails.application.routes.draw do
end
namespace :v2 do
resources :accounts, only: [], module: :accounts do
resources :reports, only: [:index] do
collection do
get :summary
get :agents
get :inboxes
get :labels
get :teams
get :conversations
get :conversation_traffic
resources :accounts, only: [:create] do
scope module: :accounts do
resources :reports, only: [:index] do
collection do
get :summary
get :agents
get :inboxes
get :labels
get :teams
get :conversations
get :conversation_traffic
end
end
end
end
-280
View File
@@ -1,280 +0,0 @@
class InitSchema < ActiveRecord::Migration[6.0]
def up
# These are extensions that must be enabled in order to support this database
enable_extension 'plpgsql'
create_table 'account_users' do |t|
t.bigint 'account_id'
t.bigint 'user_id'
t.integer 'role', default: 0
t.bigint 'inviter_id'
t.datetime 'created_at', precision: 6, null: false
t.datetime 'updated_at', precision: 6, null: false
t.index %w[account_id user_id], name: 'uniq_user_id_per_account_id', unique: true
t.index ['account_id'], name: 'index_account_users_on_account_id'
t.index ['user_id'], name: 'index_account_users_on_user_id'
end
create_table 'accounts', id: :serial do |t|
t.string 'name', null: false
t.datetime 'created_at', null: false
t.datetime 'updated_at', null: false
end
create_table 'active_storage_attachments' do |t|
t.string 'name', null: false
t.string 'record_type', null: false
t.bigint 'record_id', null: false
t.bigint 'blob_id', null: false
t.datetime 'created_at', null: false
t.index ['blob_id'], name: 'index_active_storage_attachments_on_blob_id'
t.index %w[record_type record_id name blob_id], name: 'index_active_storage_attachments_uniqueness', unique: true
end
create_table 'active_storage_blobs' do |t|
t.string 'key', null: false
t.string 'filename', null: false
t.string 'content_type'
t.text 'metadata'
t.bigint 'byte_size', null: false
t.string 'checksum', null: false
t.datetime 'created_at', null: false
t.index ['key'], name: 'index_active_storage_blobs_on_key', unique: true
end
create_table 'agent_bot_inboxes' do |t|
t.integer 'inbox_id'
t.integer 'agent_bot_id'
t.integer 'status', default: 0
t.datetime 'created_at', precision: 6, null: false
t.datetime 'updated_at', precision: 6, null: false
end
create_table 'agent_bots' do |t|
t.string 'name'
t.string 'description'
t.string 'outgoing_url'
t.string 'auth_token'
t.datetime 'created_at', precision: 6, null: false
t.datetime 'updated_at', precision: 6, null: false
end
create_table 'attachments', id: :serial do |t|
t.integer 'file_type', default: 0
t.string 'external_url'
t.float 'coordinates_lat', default: 0.0
t.float 'coordinates_long', default: 0.0
t.integer 'message_id', null: false
t.integer 'account_id', null: false
t.datetime 'created_at', null: false
t.datetime 'updated_at', null: false
t.string 'fallback_title'
t.string 'extension'
end
create_table 'canned_responses', id: :serial do |t|
t.integer 'account_id', null: false
t.string 'short_code'
t.text 'content'
t.datetime 'created_at', null: false
t.datetime 'updated_at', null: false
end
create_table 'channel_facebook_pages', id: :serial do |t|
t.string 'name', null: false
t.string 'page_id', null: false
t.string 'user_access_token', null: false
t.string 'page_access_token', null: false
t.integer 'account_id', null: false
t.datetime 'created_at', null: false
t.datetime 'updated_at', null: false
t.index %w[page_id account_id], name: 'index_channel_facebook_pages_on_page_id_and_account_id', unique: true
t.index ['page_id'], name: 'index_channel_facebook_pages_on_page_id'
end
create_table 'channel_twitter_profiles' do |t|
t.string 'name'
t.string 'profile_id', null: false
t.string 'twitter_access_token', null: false
t.string 'twitter_access_token_secret', null: false
t.integer 'account_id', null: false
t.datetime 'created_at', precision: 6, null: false
t.datetime 'updated_at', precision: 6, null: false
end
create_table 'channel_web_widgets', id: :serial do |t|
t.string 'website_name'
t.string 'website_url'
t.integer 'account_id'
t.datetime 'created_at', null: false
t.datetime 'updated_at', null: false
t.string 'website_token'
t.string 'widget_color', default: '#1f93ff'
t.index ['website_token'], name: 'index_channel_web_widgets_on_website_token', unique: true
end
create_table 'contact_inboxes' do |t|
t.bigint 'contact_id'
t.bigint 'inbox_id'
t.string 'source_id', null: false
t.datetime 'created_at', precision: 6, null: false
t.datetime 'updated_at', precision: 6, null: false
t.index ['contact_id'], name: 'index_contact_inboxes_on_contact_id'
t.index %w[inbox_id source_id], name: 'index_contact_inboxes_on_inbox_id_and_source_id', unique: true
t.index ['inbox_id'], name: 'index_contact_inboxes_on_inbox_id'
t.index ['source_id'], name: 'index_contact_inboxes_on_source_id'
end
create_table 'contacts', id: :serial do |t|
t.string 'name'
t.string 'email'
t.string 'phone_number'
t.integer 'account_id', null: false
t.datetime 'created_at', null: false
t.datetime 'updated_at', null: false
t.string 'pubsub_token'
t.jsonb 'additional_attributes'
t.index ['account_id'], name: 'index_contacts_on_account_id'
t.index ['pubsub_token'], name: 'index_contacts_on_pubsub_token', unique: true
end
create_table 'conversations', id: :serial do |t|
t.integer 'account_id', null: false
t.integer 'inbox_id', null: false
t.integer 'status', default: 0, null: false
t.integer 'assignee_id'
t.datetime 'created_at', null: false
t.datetime 'updated_at', null: false
t.bigint 'contact_id'
t.integer 'display_id', null: false
t.datetime 'user_last_seen_at'
t.datetime 'agent_last_seen_at'
t.boolean 'locked', default: false
t.jsonb 'additional_attributes'
t.bigint 'contact_inbox_id'
t.index %w[account_id display_id], name: 'index_conversations_on_account_id_and_display_id', unique: true
t.index ['account_id'], name: 'index_conversations_on_account_id'
t.index ['contact_inbox_id'], name: 'index_conversations_on_contact_inbox_id'
end
create_table 'inbox_members', id: :serial do |t|
t.integer 'user_id', null: false
t.integer 'inbox_id', null: false
t.datetime 'created_at', null: false
t.datetime 'updated_at', null: false
t.index ['inbox_id'], name: 'index_inbox_members_on_inbox_id'
end
create_table 'inboxes', id: :serial do |t|
t.integer 'channel_id', null: false
t.integer 'account_id', null: false
t.string 'name', null: false
t.datetime 'created_at', null: false
t.datetime 'updated_at', null: false
t.string 'channel_type'
t.boolean 'enable_auto_assignment', default: true
t.index ['account_id'], name: 'index_inboxes_on_account_id'
end
create_table 'messages', id: :serial do |t|
t.text 'content'
t.integer 'account_id', null: false
t.integer 'inbox_id', null: false
t.integer 'conversation_id', null: false
t.integer 'message_type', null: false
t.datetime 'created_at', null: false
t.datetime 'updated_at', null: false
t.boolean 'private', default: false
t.integer 'user_id'
t.integer 'status', default: 0
t.string 'source_id'
t.integer 'content_type', default: 0
t.json 'content_attributes', default: {}
t.bigint 'contact_id'
t.index ['contact_id'], name: 'index_messages_on_contact_id'
t.index ['conversation_id'], name: 'index_messages_on_conversation_id'
t.index ['source_id'], name: 'index_messages_on_source_id'
end
create_table 'notification_settings' do |t|
t.integer 'account_id'
t.integer 'user_id'
t.integer 'email_flags', default: 0, null: false
t.datetime 'created_at', precision: 6, null: false
t.datetime 'updated_at', precision: 6, null: false
t.index %w[account_id user_id], name: 'by_account_user', unique: true
end
create_table 'subscriptions', id: :serial do |t|
t.string 'pricing_version'
t.integer 'account_id'
t.datetime 'expiry'
t.string 'billing_plan', default: 'trial'
t.string 'stripe_customer_id'
t.datetime 'created_at', null: false
t.datetime 'updated_at', null: false
t.integer 'state', default: 0
t.boolean 'payment_source_added', default: false
end
create_table 'taggings', id: :serial do |t|
t.integer 'tag_id'
t.string 'taggable_type'
t.integer 'taggable_id'
t.string 'tagger_type'
t.integer 'tagger_id'
t.string 'context', limit: 128
t.datetime 'created_at'
t.index ['context'], name: 'index_taggings_on_context'
t.index %w[tag_id taggable_id taggable_type context tagger_id tagger_type], name: 'taggings_idx', unique: true
t.index ['tag_id'], name: 'index_taggings_on_tag_id'
t.index %w[taggable_id taggable_type context], name: 'index_taggings_on_taggable_id_and_taggable_type_and_context'
t.index %w[taggable_id taggable_type tagger_id context], name: 'taggings_idy'
t.index ['taggable_id'], name: 'index_taggings_on_taggable_id'
t.index %w[taggable_type taggable_id], name: 'index_taggings_on_taggable_type_and_taggable_id'
t.index ['taggable_type'], name: 'index_taggings_on_taggable_type'
t.index %w[tagger_id tagger_type], name: 'index_taggings_on_tagger_id_and_tagger_type'
t.index ['tagger_id'], name: 'index_taggings_on_tagger_id'
t.index %w[tagger_type tagger_id], name: 'index_taggings_on_tagger_type_and_tagger_id'
end
create_table 'tags', id: :serial do |t|
t.string 'name'
t.integer 'taggings_count', default: 0
t.index ['name'], name: 'index_tags_on_name', unique: true
end
create_table 'telegram_bots', id: :serial do |t|
t.string 'name'
t.string 'auth_key'
t.integer 'account_id'
t.datetime 'created_at', null: false
t.datetime 'updated_at', null: false
end
create_table 'users', id: :serial do |t|
t.string 'provider', default: 'email', null: false
t.string 'uid', default: '', null: false
t.string 'encrypted_password', default: '', null: false
t.string 'reset_password_token'
t.datetime 'reset_password_sent_at'
t.datetime 'remember_created_at'
t.integer 'sign_in_count', default: 0, null: false
t.datetime 'current_sign_in_at'
t.datetime 'last_sign_in_at'
t.string 'current_sign_in_ip'
t.string 'last_sign_in_ip'
t.string 'confirmation_token'
t.datetime 'confirmed_at'
t.datetime 'confirmation_sent_at'
t.string 'unconfirmed_email'
t.string 'name', null: false
t.string 'nickname'
t.string 'email'
t.json 'tokens'
t.datetime 'created_at', null: false
t.datetime 'updated_at', null: false
t.string 'pubsub_token'
t.index ['email'], name: 'index_users_on_email'
t.index ['pubsub_token'], name: 'index_users_on_pubsub_token', unique: true
t.index ['reset_password_token'], name: 'index_users_on_reset_password_token', unique: true
t.index %w[uid provider], name: 'index_users_on_uid_and_provider', unique: true
end
create_table 'webhooks' do |t|
t.integer 'account_id'
t.integer 'inbox_id'
t.string 'url'
t.datetime 'created_at', precision: 6, null: false
t.datetime 'updated_at', precision: 6, null: false
t.integer 'webhook_type', default: 0
end
add_foreign_key 'account_users', 'accounts'
add_foreign_key 'account_users', 'users'
add_foreign_key 'active_storage_attachments', 'active_storage_blobs', column: 'blob_id'
add_foreign_key 'contact_inboxes', 'contacts'
add_foreign_key 'contact_inboxes', 'inboxes'
add_foreign_key 'conversations', 'contact_inboxes'
add_foreign_key 'messages', 'contacts'
end
def down
raise ActiveRecord::IrreversibleMigration, 'The initial migration is not revertable'
end
end
@@ -1,23 +0,0 @@
class CreateAccessTokens < ActiveRecord::Migration[6.0]
def change
create_table :access_tokens do |t|
t.references :owner, polymorphic: true, index: true
t.string :token, index: { unique: true }
t.timestamps
end
remove_column :agent_bots, :auth_token, :string
[::User, ::AgentBot].each do |access_tokenable|
generate_access_tokens(access_tokenable)
end
end
def generate_access_tokens(access_tokenable)
access_tokenable.find_in_batches do |record_batch|
record_batch.each do |record|
record.create_access_token if record.access_token.blank?
end
end
end
end

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