Compare commits

..
Author SHA1 Message Date
23230e0143 feat: sla-7 ensure applied_sla uniqueness (#8938)
* feat: refactor fetching sla in action service

* chore: modify spec

* chore: ensure applied_sla uniqueness

* chore: review fixes

* feat: add unique index on applied_sla

* chore: fix spec

* chore: add new specs to improve coverage

* chore: improve spec

* Update spec/enterprise/services/enterprise/action_service_spec.rb

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2024-02-20 21:59:49 +05:30
Vishnu NarayananandGitHub f92cea144c feat(perf): sla-9 improve perf of TriggerSlasForAccountsJob (#8953)
* feat: improve perf of TriggerSlasForAccountsJob
2024-02-20 21:54:37 +05:30
Muhsin KelothandGitHub e6cf8c39b7 feat: Update last_snoozed_at after the un-snooze notification (#8943) 2024-02-20 18:55:39 +05:30
Muhsin KelothandGitHub c5c0845151 feat: Add labels, status and priority in notification push_event_data (#8972) 2024-02-20 17:17:25 +05:30
5d9fb55370 feat: Export contact improvements (#8895)
This pull request enhances the export contacts feature by adding a confirmation step before exporting. Previously, clicking the export button would trigger the export action without confirmation.

Additionally, it ensures that only the intended recipient receives the export email, addressing the previous behaviour where all administrators received it.

Fixes: #8504

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2024-02-20 17:11:03 +05:30
721a2f5052 feat: API changes to support multi step user signup (#8933)
-API Changes to support the new onboarding flow 

Co-authored-by: Sojan <sojan@pepalo.com>
2024-02-20 16:48:51 +05:30
7320957405 chore: Add facebook_api_version to global config (#8965)
- Ability to configure facebook_api_version from global config

Co-authored-by: Pranav <pranav@chatwoot.com>
2024-02-20 15:53:29 +05:30
Sojan e4d072c79c Merge branch 'release/3.6.0' into develop 2024-02-19 15:59:26 +05:30
Sojan 71ee10c889 Bump version to 3.6.0 2024-02-19 15:58:21 +05:30
Sojan JoseandGitHub 978a8a4cb2 fix: support_email and inbound_email_domain returning empty string (#8963)
chore: Fix for inbound email domain being nil
2024-02-19 15:43:35 +05:30
cd06b2b337 fix: Inbox view Read/Snoozed display filters (#8907)
* fix: Notification filters

* Update notification_finder.rb

* Update notification_finder.rb

* Update notification_finder.rb

* fix: spec

* fix: specs

* Update notification_finder.rb

* fix: add more fixes

* Update notification_finder.rb

* fix: specs

* chore: better comments

* chore: removed filtering

* chore: refactoring

* fix: review fixes

* fix: API call

* chore: Minor fix

* Rename spec

* Fix params getting undefined

* Fix finder

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
2024-02-17 13:59:25 +05:30
6eb06377cc feat: Add textarea component to use in forms (#8932)
Co-authored-by: Pranav <pranav@chatwoot.com>
2024-02-16 12:19:08 -08:00
Sojan JoseandGitHub 94892e7168 chore: Fix flaky spec relying on unguaranteed order (#8956) 2024-02-16 12:11:39 -08:00
Muhsin KelothandGitHub fe744abe26 fix: Mark notification as unread after the un-snooze (#8917) 2024-02-16 17:30:54 +05:30
Nithin David ThomasandGitHub a902b49bc5 feat: Adds bulk_invite api for onboarding view (#8931)
- New API for bulk email invite
2024-02-16 17:01:27 +05:30
Sojan JoseandGitHub 9c07b6dd46 chore: Support for updated_within in ConverationFinder (#8947)
- `updated_within' accepts value in seconds and returns all conversations updated in the given period with out pagination. This API will assist in our refetch logic on socket disconnect

ref: #8888
2024-02-16 16:44:55 +05:30
Muhsin KelothandGitHub 3b7b06dbec feat: Add meta to notification model (#8941)
* feat: Add meta and description to notification model

* chore: remove description
2024-02-16 14:38:02 +05:30
Sivin VargheseandGitHub 597a4164e8 fix: Editor hyperlink popup is hidden under modal (#8950) 2024-02-16 08:19:06 +05:30
Nithin David ThomasandGitHub 678c00f254 fix: Removes default slot for right of label (#8936) 2024-02-14 10:48:54 -08:00
72 changed files with 586 additions and 2143 deletions
@@ -49,7 +49,9 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
end
end
# remove onboarding_step key in present in Current account custom attributes
# This endpoint is used to bulk create agents during onboarding
# onboarding_step key in present in Current account custom attributes, since this is a one time operation
Current.account.custom_attributes.delete('onboarding_step')
Current.account.save!
head :ok
@@ -46,7 +46,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def export
column_names = params['column_names']
Account::ContactsExportJob.perform_later(Current.account.id, column_names)
Account::ContactsExportJob.perform_later(Current.account.id, column_names, Current.user.email)
head :ok, message: I18n.t('errors.contacts.export.success')
end
@@ -7,8 +7,8 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
before_action :set_current_page, only: [:index]
def index
@notifications = notification_finder.notifications
@unread_count = notification_finder.unread_count
@notifications = notification_finder.perform
@count = notification_finder.count
end
@@ -54,7 +54,8 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
end
def snooze
@notification.update(snoozed_until: parse_date_time(params[:snoozed_until].to_s)) if params[:snoozed_until]
updated_meta = (@notification.meta || {}).merge('last_snoozed_at' => nil)
@notification.update(snoozed_until: parse_date_time(params[:snoozed_until].to_s), meta: updated_meta) if params[:snoozed_until]
render json: @notification
end
@@ -45,7 +45,8 @@ class Api::V1::AccountsController < Api::BaseController
def update
@account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email, :auto_resolve_duration))
@account.custom_attributes['onboarding_step'] = 'invite_team' if custom_attributes_params[:onboarding_step].present?
@account.custom_attributes.merge!(custom_attributes_params)
@account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update'
@account.save!
end
@@ -22,6 +22,7 @@ class Api::V2::AccountsController < Api::BaseController
).perform
fetch_account_and_user_info
update_account_info if @account.present?
if @user
send_auth_headers(@user)
@@ -33,6 +34,18 @@ class Api::V2::AccountsController < Api::BaseController
private
def account_attributes
{
custom_attributes: @account.custom_attributes.merge({ 'onboarding_step' => 'profile_update' })
}
end
def update_account_info
@account.update!(
account_attributes
)
end
def fetch_account_and_user_info; end
def fetch_account
+1 -1
View File
@@ -55,7 +55,7 @@ class DashboardController < ActionController::Base
VAPID_PUBLIC_KEY: VapidService.public_key,
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
FACEBOOK_API_VERSION: 'v14.0',
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'),
IS_ENTERPRISE: ChatwootApp.enterprise?,
AZURE_APP_ID: ENV.fetch('AZURE_APP_ID', ''),
GIT_SHA: GIT_HASH
@@ -34,7 +34,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
def allowed_configs
@allowed_configs = case @config
when 'facebook'
%w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT]
%w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT]
when 'email'
['MAILER_INBOUND_EMAIL_DOMAIN']
else
+7 -1
View File
@@ -169,6 +169,12 @@ class ConversationFinder
)
sort_by, sort_order = SORT_OPTIONS[params[:sort_by]] || SORT_OPTIONS['last_activity_at_desc']
@conversations.send(sort_by, sort_order).page(current_page).per(ENV.fetch('CONVERSATION_RESULTS_PER_PAGE', '25').to_i)
@conversations = @conversations.send(sort_by, sort_order)
if params[:updated_within].present?
@conversations.where('conversations.updated_at > ?', Time.zone.now - params[:updated_within].to_i.seconds)
else
@conversations.page(current_page).per(ENV.fetch('CONVERSATION_RESULTS_PER_PAGE', '25').to_i)
end
end
end
+14 -10
View File
@@ -10,8 +10,8 @@ class NotificationFinder
set_up
end
def perform
notifications
def notifications
@notifications.page(current_page).per(RESULTS_PER_PAGE).order(last_activity_at: sort_order)
end
def unread_count
@@ -26,27 +26,31 @@ class NotificationFinder
def set_up
find_all_notifications
filter_by_read_status
filter_by_status
filter_snoozed_notifications
fitler_read_notifications
end
def find_all_notifications
@notifications = current_user.notifications.where(account_id: @current_account.id)
end
def filter_by_status
@notifications = @notifications.where('snoozed_until > ?', DateTime.now.utc) if params[:status] == 'snoozed'
def filter_snoozed_notifications
@notifications = @notifications.where(snoozed_until: nil) unless type_included?('snoozed')
end
def filter_by_read_status
@notifications = @notifications.where.not(read_at: nil) if params[:type] == 'read'
def fitler_read_notifications
@notifications = @notifications.where(read_at: nil) unless type_included?('read')
end
def type_included?(type)
(params[:includes] || []).include?(type)
end
def current_page
params[:page] || 1
end
def notifications
@notifications.page(current_page).per(RESULTS_PER_PAGE).order(last_activity_at: params[:sort_order] || :desc)
def sort_order
params[:sort_order] || :desc
end
end
@@ -7,12 +7,13 @@ class NotificationsAPI extends ApiClient {
}
get({ page, status, type, sortOrder }) {
const includesFilter = [status, type].filter(value => !!value);
return axios.get(this.url, {
params: {
page,
status,
type,
sort_order: sortOrder,
includes: includesFilter,
},
});
}
@@ -10,4 +10,29 @@ describe('#AgentAPI', () => {
expect(agents).toHaveProperty('update');
expect(agents).toHaveProperty('delete');
});
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('#bulkInvite', () => {
agents.bulkInvite({ emails: ['hello@hi.com'] });
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/agents/bulk_create',
{
emails: ['hello@hi.com'],
}
);
});
});
});
@@ -27,20 +27,36 @@ describe('#NotificationAPI', () => {
window.axios = originalAxios;
});
it('#get', () => {
notificationsAPI.get({
page: 1,
status: 'read',
type: 'Conversation',
sortOrder: 'desc',
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/notifications', {
params: {
describe('#get', () => {
it('generates the API call if both params are available', () => {
notificationsAPI.get({
page: 1,
status: 'read',
type: 'Conversation',
sort_order: 'desc',
},
status: 'snoozed',
type: 'read',
sortOrder: 'desc',
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/notifications', {
params: {
page: 1,
sort_order: 'desc',
includes: ['snoozed', 'read'],
},
});
});
it('generates the API call if one of the params are available', () => {
notificationsAPI.get({
page: 1,
type: 'read',
sortOrder: 'desc',
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/notifications', {
params: {
page: 1,
sort_order: 'desc',
includes: ['read'],
},
});
});
});
@@ -1,6 +1,6 @@
<template>
<banner
v-if="false"
v-if="shouldShowBanner"
color-scheme="alert"
:banner-message="bannerMessage"
:action-button-label="actionButtonMessage"
@@ -34,9 +34,6 @@ export default {
actionButtonMessage() {
return this.$t('GENERAL_SETTINGS.OPEN_BILLING');
},
isOnboardingView() {
return this.$route.name.includes('onboarding_');
},
shouldShowBanner() {
if (!this.isOnChatwootCloud) {
return false;
@@ -46,9 +43,6 @@ export default {
return false;
}
if (this.isOnboardingView) {
return false;
}
return this.isPaymentPending();
},
},
@@ -29,13 +29,7 @@ export default {
actionButtonMessage() {
return this.$t('APP_GLOBAL.RESEND_VERIFICATION_MAIL');
},
isOnboardingView() {
return this.$route.name.includes('onboarding_');
},
shouldShowBanner() {
if (this.isOnboardingView) {
return false;
}
return !this.currentUser.confirmed;
},
},
@@ -753,7 +753,7 @@ export default {
}
.ProseMirror-prompt {
@apply z-50 bg-slate-25 dark:bg-slate-700 rounded-md border border-solid border-slate-75 dark:border-slate-800;
@apply z-[9999] bg-slate-25 dark:bg-slate-700 rounded-md border border-solid border-slate-75 dark:border-slate-800 shadow-lg;
h5 {
@apply dark:text-slate-25 text-slate-800;
@@ -79,7 +79,13 @@
"TITLE": "Export Contacts",
"DESC": "Export contacts to a CSV file.",
"SUCCESS_MESSAGE": "Export is in progress. You will be notified on email when the export file is ready to download.",
"ERROR_MESSAGE": "There was an error, please try again"
"ERROR_MESSAGE": "There was an error, please try again",
"CONFIRM": {
"TITLE": "Export Contacts",
"MESSAGE": "Are you sure you want to export all contacts?",
"YES": "Yes, Export",
"NO": "No, Cancel"
}
},
"DELETE_NOTE": {
"CONFIRM": {
@@ -40,88 +40,5 @@
},
"SUBMIT": "Create account",
"HAVE_AN_ACCOUNT": "Already have an account?"
},
"ONBOARDING": {
"PROFILE": {
"INTRO": "Welcome, let’s get started.",
"TITLE": "Set up profile",
"BODY": "These are details visible to end users, to make a positive first impression.",
"SUBMIT": {
"BUTTON": "Continue to company setup",
"ERROR": "Error in setting up profile, try again.",
"SUCCESS": "🔥 Lets add your business details"
},
"AVATAR": {
"LABEL": "Avatar",
"PLACEHOLDER": "Upload Profile Image"
},
"FULL_NAME": {
"LABEL": "Full name",
"PLACEHOLDER": "Enter your full name",
"ERROR": "Add a valid full name"
},
"DISPLAY_NAME": {
"LABEL": "Display name (Optional)",
"PLACEHOLDER": "Enter the name you want to display your customers",
"ERROR": "Add a valid display name or leave it blank"
},
"PHONE_NUMBER": {
"LABEL": "Phone number (Optional)",
"PLACEHOLDER": "Enter your phone number"
},
"SIGNATURE": {
"LABEL": "Signature",
"PLACEHOLDER": "Add a signature for your messages. You can set this later in your settings."
}
},
"COMPANY": {
"PAGE_TITLE": "Hello %{userName},\nwe are are almost done.",
"TITLE": "Tell us about your company",
"BODY": "In this section, we'll set up the language, timezone, and company size. This information allows us to provide contextually accurate information and suggest relevant features for you.",
"SUBMIT": {
"BUTTON": "Continue to team setup",
"ERROR": "Error in setting up company, try again.",
"SUCCESS": "🚀 Lets invite your team"
},
"COMPANY_NAME": {
"LABEL": "Company name",
"PLACEHOLDER": "Enter your company name",
"ERROR": "Company name is too short."
},
"INDUSTRY": {
"LABEL": "Industry",
"PLACEHOLDER": "Select your industry"
},
"TIMEZONE": {
"LABEL": "Timezone",
"PLACEHOLDER": "What timezone do you work from?"
},
"LOCALE": {
"LABEL": "Language",
"PLACEHOLDER": "Select the language you want to use"
},
"SIZE": {
"LABEL": "Company size",
"PLACEHOLDER": "How many people work at your company?"
}
},
"INVITE_TEAM": {
"TITLE": "Invite your team",
"ADD": "Add",
"LIST_EMPTY": "No members to invite",
"BODY": "Invite your team members to Chatwoot. You can add them later as well.",
"PLACEHOLDER": "robin@chatwoot.com",
"SKIP": "Skip for now",
"SUBMIT": {
"BUTTON": "Send invitations",
"ERROR": "Error while inviting team members, try again.",
"NO_MEMBER_ERROR": "Please add atleast one team member or skip this step",
"SUCCESS": "🪄 Setup complete"
}
},
"FOUNDERS_NOTE": {
"TITLE": "A note from the founder",
"SUBMIT": "Go to dashboard"
}
}
}
@@ -113,6 +113,13 @@
</woot-button>
</div>
</div>
<woot-confirm-modal
ref="confirmExportContactsDialog"
:title="$t('EXPORT_CONTACTS.CONFIRM.TITLE')"
:description="$t('EXPORT_CONTACTS.CONFIRM.MESSAGE')"
:confirm-label="$t('EXPORT_CONTACTS.CONFIRM.YES')"
:cancel-label="$t('EXPORT_CONTACTS.CONFIRM.NO')"
/>
</header>
</template>
@@ -175,8 +182,13 @@ export default {
toggleImport() {
this.$emit('on-toggle-import');
},
submitExport() {
this.$emit('on-export-submit');
async submitExport() {
const ok =
await this.$refs.confirmExportContactsDialog.showConfirmation();
if (ok) {
this.$emit('on-export-submit');
}
},
submitSearch() {
this.$emit('on-search-submit');
@@ -8,11 +8,6 @@ import helpcenterRoutes from './helpcenter/helpcenter.routes';
const AppContainer = () => import('./Dashboard.vue');
const Suspended = () => import('./suspended/Index.vue');
const RouteLayout = () => import('v3/views/onboarding/RouteLayout.vue');
const SetupProfile = () => import('v3/views/onboarding/SetupProfile.vue');
const SetupCompany = () => import('v3/views/onboarding/SetupCompany.vue');
const InviteTeam = () => import('v3/views/onboarding/InviteTeam.vue');
const FoundersNote = () => import('v3/views/onboarding/FoundersNote.vue');
export default {
routes: [
@@ -34,31 +29,5 @@ export default {
roles: ['administrator', 'agent'],
component: Suspended,
},
{
path: frontendURL('accounts/:accountId/start'),
component: RouteLayout,
children: [
{
path: frontendURL('accounts/:accountId/start/setup-profile'),
name: 'onboarding_setup_profile',
component: SetupProfile,
},
{
path: frontendURL('accounts/:accountId/start/setup-company'),
name: 'onboarding_setup_company',
component: SetupCompany,
},
{
path: frontendURL('accounts/:accountId/start/invite-team'),
name: 'onboarding_invite_team',
component: InviteTeam,
},
],
},
{
path: frontendURL('accounts/:accountId/start/founders-note'),
name: 'onboarding_founders_note',
component: FoundersNote,
},
],
};
-7
View File
@@ -45,13 +45,6 @@ export const validateAuthenticateRoutePermission = (to, next, { getters }) => {
return next(frontendURL(`accounts/${user.account_id}/dashboard`));
}
// we can let them access onboarding routes
// we will eventually need guards as onboarding is done, but for now
// we can let them access onboarding routes
if (to.name.startsWith('onboarding')) {
return next();
}
const nextRoute = validateLoggedInRoutes(
to,
getters.getCurrentUser,
@@ -1,4 +1,4 @@
import { applyInboxPageFilters, sortComparator } from './helpers';
import { sortComparator } from './helpers';
export const getters = {
getNotifications($state) {
@@ -6,11 +6,8 @@ export const getters = {
},
getFilteredNotifications: $state => filters => {
const sortOrder = filters.sortOrder === 'desc' ? 'newest' : 'oldest';
const filteredNotifications = Object.values($state.records).filter(
notification => applyInboxPageFilters(notification, filters)
);
const sortedNotifications = filteredNotifications.sort((a, b) =>
sortComparator(a, b, sortOrder)
const sortedNotifications = Object.values($state.records).sort((n1, n2) =>
sortComparator(n1, n2, sortOrder)
);
return sortedNotifications;
},
@@ -1,31 +1,3 @@
export const filterByStatus = (snoozedUntil, filterStatus) =>
filterStatus === 'snoozed' ? !!snoozedUntil : !snoozedUntil;
export const filterByType = (readAt, filterType) =>
filterType === 'read' ? !!readAt : !readAt;
export const filterByTypeAndStatus = (
readAt,
snoozedUntil,
filterType,
filterStatus
) => {
const shouldFilterByStatus = filterByStatus(snoozedUntil, filterStatus);
const shouldFilterByType = filterByType(readAt, filterType);
return shouldFilterByStatus && shouldFilterByType;
};
export const applyInboxPageFilters = (notification, filters) => {
const { status, type } = filters;
const { read_at: readAt, snoozed_until: snoozedUntil } = notification;
if (status && type)
return filterByTypeAndStatus(readAt, snoozedUntil, type, status);
if (status && !type) return filterByStatus(snoozedUntil, status);
if (!status && type) return filterByType(readAt, type);
return true;
};
const INBOX_SORT_OPTIONS = {
newest: 'desc',
oldest: 'asc',
@@ -34,6 +34,8 @@ describe('#getters', () => {
sortOrder: 'desc',
};
expect(getters.getFilteredNotifications(state)(filters)).toEqual([
{ id: 1, read_at: '2024-02-07T11:42:39.988Z', snoozed_until: null },
{ id: 2, read_at: null, snoozed_until: null },
{
id: 3,
read_at: '2024-02-07T11:42:39.988Z',
@@ -1,10 +1,4 @@
import {
filterByStatus,
filterByType,
filterByTypeAndStatus,
applyInboxPageFilters,
sortComparator,
} from '../../notifications/helpers';
import { sortComparator } from '../../notifications/helpers';
const notifications = [
{
@@ -45,126 +39,6 @@ const notifications = [
},
];
describe('#filterByStatus', () => {
it('returns the notifications with snoozed status', () => {
const filters = { status: 'snoozed' };
notifications.forEach(notification => {
expect(
filterByStatus(notification.snoozed_until, filters.status)
).toEqual(notification.snoozed_until !== null);
});
});
it('returns true if the notification is snoozed', () => {
const filters = { status: 'snoozed' };
expect(
filterByStatus(notifications[3].snoozed_until, filters.status)
).toEqual(true);
});
it('returns false if the notification is not snoozed', () => {
const filters = { status: 'snoozed' };
expect(
filterByStatus(notifications[2].snoozed_until, filters.status)
).toEqual(false);
});
});
describe('#filterByType', () => {
it('returns the notifications with read status', () => {
const filters = { type: 'read' };
notifications.forEach(notification => {
expect(filterByType(notification.read_at, filters.type)).toEqual(
notification.read_at !== null
);
});
});
it('returns true if the notification is read', () => {
const filters = { type: 'read' };
expect(filterByType(notifications[0].read_at, filters.type)).toEqual(true);
});
it('returns false if the notification is not read', () => {
const filters = { type: 'read' };
expect(filterByType(notifications[1].read_at, filters.type)).toEqual(false);
});
});
describe('#filterByTypeAndStatus', () => {
it('returns the notifications with type and status', () => {
const filters = { type: 'read', status: 'snoozed' };
notifications.forEach(notification => {
expect(
filterByTypeAndStatus(
notification.read_at,
notification.snoozed_until,
filters.type,
filters.status
)
).toEqual(
notification.read_at !== null && notification.snoozed_until !== null
);
});
});
it('returns true if the notification is read and snoozed', () => {
const filters = { type: 'read', status: 'snoozed' };
expect(
filterByTypeAndStatus(
notifications[4].read_at,
notifications[4].snoozed_until,
filters.type,
filters.status
)
).toEqual(true);
});
it('returns false if the notification is not read and snoozed', () => {
const filters = { type: 'read', status: 'snoozed' };
expect(
filterByTypeAndStatus(
notifications[3].read_at,
notifications[3].snoozed_until,
filters.type,
filters.status
)
).toEqual(false);
});
});
describe('#applyInboxPageFilters', () => {
it('returns the notifications with type and status', () => {
const filters = { type: 'read', status: 'snoozed' };
notifications.forEach(notification => {
expect(applyInboxPageFilters(notification, filters)).toEqual(
filterByTypeAndStatus(
notification.read_at,
notification.snoozed_until,
filters.type,
filters.status
)
);
});
});
it('returns the notifications with type only', () => {
const filters = { type: 'read', status: null };
notifications.forEach(notification => {
expect(applyInboxPageFilters(notification, filters)).toEqual(
filterByType(notification.read_at, filters.type)
);
});
});
it('returns the notifications with status only', () => {
const filters = { type: null, status: 'snoozed' };
notifications.forEach(notification => {
expect(applyInboxPageFilters(notification, filters)).toEqual(
filterByStatus(notification.snoozed_until, filters.status)
);
});
});
it('returns true if there are no filters', () => {
const filters = { type: null, status: null };
notifications.forEach(notification => {
expect(applyInboxPageFilters(notification, filters)).toEqual(true);
});
});
});
describe('#sortComparator', () => {
it('returns the notifications sorted by newest', () => {
const sortOrder = 'newest';
@@ -113,10 +113,6 @@
"folder-outline": "M8.207 4c.46 0 .908.141 1.284.402l.156.12L12.022 6.5h7.728a2.25 2.25 0 0 1 2.229 1.938l.016.158.005.154v9a2.25 2.25 0 0 1-2.096 2.245L19.75 20H4.25a2.25 2.25 0 0 1-2.245-2.096L2 17.75V6.25a2.25 2.25 0 0 1 2.096-2.245L4.25 4h3.957Zm1.44 5.979a2.25 2.25 0 0 1-1.244.512l-.196.009-4.707-.001v7.251c0 .38.282.694.648.743l.102.007h15.5a.75.75 0 0 0 .743-.648l.007-.102v-9a.75.75 0 0 0-.648-.743L19.75 8h-7.729L9.647 9.979ZM8.207 5.5H4.25a.75.75 0 0 0-.743.648L3.5 6.25v2.749L8.207 9a.75.75 0 0 0 .395-.113l.085-.06 1.891-1.578-1.89-1.575a.75.75 0 0 0-.377-.167L8.207 5.5Z",
"globe-desktop-outline": "M22.002 12C22.002 6.477 17.524 2 12 2 6.476 1.999 2 6.477 2 12.001c0 5.186 3.947 9.45 9.001 9.952V20.11c-.778-.612-1.478-1.905-1.939-3.61h1.94V15H8.737a18.969 18.969 0 0 1-.135-5h6.794c.068.64.105 1.31.105 2h1.5c0-.684-.033-1.353-.095-2h3.358c.154.64.237 1.31.237 2h1.5ZM4.786 16.5h2.722l.102.396c.317 1.17.748 2.195 1.27 3.015a8.532 8.532 0 0 1-4.094-3.41ZM3.736 10h3.358a20.847 20.847 0 0 0-.095 2c0 1.043.075 2.051.217 3H4.043a8.483 8.483 0 0 1-.544-3c0-.682.08-1.347.232-1.983L3.736 10Zm5.122-5.902.023-.008C8.16 5.222 7.611 6.748 7.298 8.5H4.25c.905-2 2.56-3.587 4.608-4.402Zm3.026-.594L12 3.5l.126.006c1.262.126 2.48 2.125 3.045 4.995H8.83c.568-2.878 1.79-4.88 3.055-4.996Zm3.343.76-.107-.174.291.121a8.533 8.533 0 0 1 4.339 4.29h-3.048c-.298-1.665-.806-3.125-1.475-4.237Z M12 19a1 1 0 0 0 1 1h3v2h-.5a.5.5 0 1 0 0 1h4a.5.5 0 0 0 0-1H19v-2h3a1 1 0 0 0 1-1v-5a1 1 0 0 0-1-1h-9a1 1 0 0 0-1 1v5Z",
"globe-outline": "M12 1.999c5.524 0 10.002 4.478 10.002 10.002 0 5.523-4.478 10.001-10.002 10.001-5.524 0-10.002-4.478-10.002-10.001C1.998 6.477 6.476 1.999 12 1.999ZM14.939 16.5H9.06c.652 2.414 1.786 4.002 2.939 4.002s2.287-1.588 2.939-4.002Zm-7.43 0H4.785a8.532 8.532 0 0 0 4.094 3.411c-.522-.82-.953-1.846-1.27-3.015l-.102-.395Zm11.705 0h-2.722c-.324 1.335-.792 2.5-1.373 3.411a8.528 8.528 0 0 0 3.91-3.127l.185-.283ZM7.094 10H3.735l-.005.017a8.525 8.525 0 0 0-.233 1.984c0 1.056.193 2.067.545 3h3.173a20.847 20.847 0 0 1-.123-5Zm8.303 0H8.603a18.966 18.966 0 0 0 .135 5h6.524a18.974 18.974 0 0 0 .135-5Zm4.868 0h-3.358c.062.647.095 1.317.095 2a20.3 20.3 0 0 1-.218 3h3.173a8.482 8.482 0 0 0 .544-3c0-.689-.082-1.36-.236-2ZM8.88 4.09l-.023.008A8.531 8.531 0 0 0 4.25 8.5h3.048c.314-1.752.86-3.278 1.583-4.41ZM12 3.499l-.116.005C10.62 3.62 9.396 5.622 8.83 8.5h6.342c-.566-2.87-1.783-4.869-3.045-4.995L12 3.5Zm3.12.59.107.175c.669 1.112 1.177 2.572 1.475 4.237h3.048a8.533 8.533 0 0 0-4.339-4.29l-.291-.121Z",
"globe-clock-outline": [
"M22.003 12.002C22.003 6.478 17.525 2 12.002 2 6.478 2 2 6.478 2 12.002c0 5.523 4.478 10.001 10.002 10.001.263 0 .524-.01.781-.03a6.506 6.506 0 0 1-1.065-1.502c-1.053-.235-2.057-1.754-2.655-3.97h2.013c.081-.523.224-1.026.422-1.5H8.74a18.966 18.966 0 0 1-.135-5h6.793c.046.426.077.865.092 1.316a6.429 6.429 0 0 1 1.49-.296 20.38 20.38 0 0 0-.072-1.02H20.266c.124.513.201 1.044.227 1.59l.008.317-.004-.177c.541.281 1.037.637 1.476 1.053.02-.258.03-.52.03-.782Zm-17.216 4.5H7.51c.324 1.334.791 2.5 1.372 3.41a8.531 8.531 0 0 1-4.095-3.41Zm-1.054-6.484.004-.017h3.358A20.845 20.845 0 0 0 7 12c0 1.043.075 2.051.218 3H4.045a8.483 8.483 0 0 1-.545-3c0-.682.08-1.346.233-1.983ZM8.86 4.1l.023-.008C8.16 5.223 7.613 6.749 7.3 8.5H4.252c.904-2 2.56-3.587 4.608-4.402Zm3.026-.594.116-.005c1.306 0 2.586 2.035 3.17 5.001H8.83c.568-2.878 1.79-4.88 3.056-4.996Zm3.342.76-.106-.174a8.531 8.531 0 0 1 4.63 4.41h-3.049c-.297-1.664-.805-3.124-1.475-4.236Z",
"M23 17.5a5.5 5.5 0 1 0-11 0 5.5 5.5 0 0 0 11 0Zm-5.5 0h2a.5.5 0 0 1 0 1H17a.5.5 0 0 1-.5-.491v-3.01a.5.5 0 0 1 1 0V17.5Z"
],
"headset-add-outline": "M23 6.5C23 3.46243 20.5376 1 17.5 1C14.4624 1 12 3.46243 12 6.5C12 9.53757 14.4624 12 17.5 12C20.5376 12 23 9.53757 23 6.5ZM18.0006 7.00001L18.0011 9.50352C18.0011 9.77966 17.7773 10.0035 17.5011 10.0035C17.225 10.0035 17.0011 9.77966 17.0011 9.50352L17.0006 7.00001H14.4956C14.2197 7.00001 13.9961 6.77615 13.9961 6.50001C13.9961 6.22387 14.2197 6.00001 14.4956 6.00001H17.0005L17 3.49927C17 3.22313 17.2239 2.99927 17.5 2.99927C17.7761 2.99927 18 3.22313 18 3.49927L18.0005 6.00001H20.4966C20.7725 6.00001 20.9961 6.22387 20.9961 6.50001C20.9961 6.77615 20.7725 7.00001 20.4966 7.00001H18.0006ZM17.5 13C18.0163 13 18.5185 12.9398 19 12.8261V15C19 16.0544 18.1841 16.9182 17.1493 16.9945L17 17H15C14.4872 17 14.0645 16.614 14.0067 16.1166L14 16V12L14.0002 11.9783C14.4634 12.2748 14.9666 12.5142 15.5 12.6865V15.5H17C17.2455 15.5 17.4496 15.3231 17.4919 15.0899L17.5 15V13ZM12.0218 3.00003C11.7171 3.47601 11.4727 3.99433 11.2994 4.5442C8.59265 4.88833 6.5 7.19978 6.5 10V11H9C9.55228 11 10 11.4477 10 12V16C10 16.5523 9.55228 17 9 17H6.5V17.25C6.5 18.4409 7.42516 19.4156 8.59595 19.4948L8.75 19.5L9.87812 19.4997C10.1871 18.626 11.0204 18 12 18C13.2426 18 14.25 19.0074 14.25 20.25C14.25 21.4926 13.2426 22.5 12 22.5C11.0201 22.5 10.1864 21.8735 9.87776 20.9992L8.75 21C6.74574 21 5.10873 19.4276 5.0052 17.4492L5 17.25V10C5 6.13401 8.13401 3 12 3L12.0218 3.00003ZM12 19.5C11.5858 19.5 11.25 19.8358 11.25 20.25C11.25 20.6642 11.5858 21 12 21C12.4142 21 12.75 20.6642 12.75 20.25C12.75 19.8358 12.4142 19.5 12 19.5ZM8.5 12.5H6.5V15.5H8.5V12.5Z",
"headphones-sound-wave-outline": "M3.5 12a8.5 8.5 0 0 1 17 0v2h-2.25a.75.75 0 0 0-.75.75v6.5c0 .414.336.75.75.75H19a3 3 0 0 0 3-3v-7c0-5.523-4.477-10-10-10S2 6.477 2 12v7a3 3 0 0 0 3 3h.75a.75.75 0 0 0 .75-.75v-6.5a.75.75 0 0 0-.75-.75H3.5v-2Zm17 3.5V19a1.5 1.5 0 0 1-1.5 1.5v-5h1.5ZM3.5 19v-3.5H5v5A1.5 1.5 0 0 1 3.5 19Zm9.25-7.25a.75.75 0 0 0-1.5 0v10.5a.75.75 0 0 0 1.5 0v-10.5Zm-4 2.25a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5a.75.75 0 0 1 .75-.75Zm7.25.75a.75.75 0 0 0-1.5 0v4.5a.75.75 0 0 0 1.5 0v-4.5Z",
"image-outline": "M17.75 3A3.25 3.25 0 0 1 21 6.25v11.5A3.25 3.25 0 0 1 17.75 21H6.25A3.25 3.25 0 0 1 3 17.75V6.25A3.25 3.25 0 0 1 6.25 3h11.5Zm.58 16.401-5.805-5.686a.75.75 0 0 0-.966-.071l-.084.07-5.807 5.687c.182.064.378.099.582.099h11.5c.203 0 .399-.035.58-.099l-5.805-5.686L18.33 19.4ZM17.75 4.5H6.25A1.75 1.75 0 0 0 4.5 6.25v11.5c0 .208.036.408.103.594l5.823-5.701a2.25 2.25 0 0 1 3.02-.116l.128.116 5.822 5.702c.067-.186.104-.386.104-.595V6.25a1.75 1.75 0 0 0-1.75-1.75Zm-2.498 2a2.252 2.252 0 1 1 0 4.504 2.252 2.252 0 0 1 0-4.504Zm0 1.5a.752.752 0 1 0 0 1.504.752.752 0 0 0 0-1.504Z",
@@ -126,7 +122,6 @@
"library-outline": "M4 3h1c1.054 0 1.918.816 1.995 1.85L7 5v14a2.001 2.001 0 0 1-1.85 1.994L5 21H4a2.001 2.001 0 0 1-1.995-1.85L2 19V5c0-1.054.816-1.918 1.85-1.995L4 3h1-1Zm6 0h1c1.054 0 1.918.816 1.995 1.85L13 5v14a2.001 2.001 0 0 1-1.85 1.994L11 21h-1a2.001 2.001 0 0 1-1.995-1.85L8 19V5c0-1.054.816-1.918 1.85-1.995L10 3h1-1Zm6.974 2c.84 0 1.608.531 1.89 1.346l.047.157 3.015 11.745a2 2 0 0 1-1.296 2.392l-.144.043-.969.248a2.002 2.002 0 0 1-2.387-1.284l-.047-.155-3.016-11.745a2 2 0 0 1 1.298-2.392l.143-.043.968-.248c.166-.043.334-.064.498-.064ZM5 4.5H4a.501.501 0 0 0-.492.41L3.5 5v14c0 .244.177.45.41.492L4 19.5h1c.245 0 .45-.178.492-.41L5.5 19V5a.501.501 0 0 0-.41-.492L5 4.5Zm6 0h-1a.501.501 0 0 0-.492.41L9.5 5v14c0 .244.177.45.41.492l.09.008h1c.245 0 .45-.178.492-.41L11.5 19V5a.501.501 0 0 0-.41-.492L11 4.5Zm5.975 2-.063.004-.063.013-.968.247a.498.498 0 0 0-.376.51l.015.1 3.016 11.745a.5.5 0 0 0 .483.375l.063-.003.062-.012.97-.25a.5.5 0 0 0 .374-.519l-.015-.088-3.015-11.747a.501.501 0 0 0-.483-.375Z",
"link-outline": "M9.25 7a.75.75 0 0 1 .11 1.492l-.11.008H7a3.5 3.5 0 0 0-.206 6.994L7 15.5h2.25a.75.75 0 0 1 .11 1.492L9.25 17H7a5 5 0 0 1-.25-9.994L7 7h2.25ZM17 7a5 5 0 0 1 .25 9.994L17 17h-2.25a.75.75 0 0 1-.11-1.492l.11-.008H17a3.5 3.5 0 0 0 .206-6.994L17 8.5h-2.25a.75.75 0 0 1-.11-1.492L14.75 7H17ZM7 11.25h10a.75.75 0 0 1 .102 1.493L17 12.75H7a.75.75 0 0 1-.102-1.493L7 11.25h10H7Z",
"list-outline": "M2.75 18h12.5a.75.75 0 0 1 .102 1.493l-.102.007H2.75a.75.75 0 0 1-.102-1.494L2.75 18h12.5-12.5Zm0-6.5h18.5a.75.75 0 0 1 .102 1.493L21.25 13H2.75a.75.75 0 0 1-.102-1.493l.102-.007h18.5-18.5Zm0-6.497h15.5a.75.75 0 0 1 .102 1.493l-.102.007H2.75a.75.75 0 0 1-.102-1.493l.102-.007h15.5-15.5Z",
"local-language-outline": "m9.34 6.372.05.105 5.56 14.5a.755.755 0 0 1-.418.971.73.73 0 0 1-.91-.333l-.043-.092-1.433-3.737H5.06l-.094-.006-1.546 3.76a.73.73 0 0 1-.963.401.754.754 0 0 1-.427-.885l.033-.096 5.964-14.5a.73.73 0 0 1 1.314-.088Zm9.406-4.37a.75.75 0 0 1 .743.65l.007.1V7.5h1.75a.75.75 0 0 1 .743.649l.007.102a.75.75 0 0 1-.648.743L21.245 9l-1.75-.001v7.25a.75.75 0 0 1-.648.744l-.102.007a.75.75 0 0 1-.743-.648l-.007-.102V2.753a.75.75 0 0 1 .75-.75ZM8.81 8.748 5.65 16.286h6.11L8.81 8.747Zm1.937-6.744h5.498a.75.75 0 0 1 .743.648l.006.102v3.004c0 2.344-1.9 4.245-4.245 4.245a.75.75 0 0 1 0-1.5c1.46 0 2.654-1.14 2.74-2.578l.005-.167V3.503h-4.747a.75.75 0 0 1-.102-1.493l.102-.007h5.498-5.498Z",
"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",
@@ -197,7 +192,6 @@
"M2.75 17h7.775l-1.5 1.5H2.75a.75.75 0 0 1-.102-1.493L2.75 17ZM2.75 14.5h7.564a1.745 1.745 0 0 1-.241-1.5H2.75l-.102.007A.75.75 0 0 0 2.75 14.5ZM12.72 16.22a.75.75 0 1 1 1.06 1.06l-4.5 4.5a.75.75 0 0 1-1.06-1.06l4.5-4.5ZM2.75 9h10.5a.75.75 0 0 1 .102 1.493l-.102.007H2.75a.75.75 0 0 1-.102-1.493L2.75 9ZM2.75 5h18.5a.75.75 0 0 1 .102 1.493l-.102.007H2.75a.75.75 0 0 1-.102-1.493L2.75 5Z",
"M16.5 8a.75.75 0 0 1 .744.658l.14 1.13a3.25 3.25 0 0 0 2.828 2.829l1.13.139a.75.75 0 0 1 0 1.488l-1.13.14a3.25 3.25 0 0 0-2.829 2.828l-.139 1.13a.75.75 0 0 1-1.488 0l-.14-1.13a3.25 3.25 0 0 0-2.828-2.829l-1.13-.139a.75.75 0 0 1 0-1.488l1.13-.14a3.25 3.25 0 0 0 2.829-2.828l.139-1.13A.75.75 0 0 1 16.5 8Zm0 3.774a4.761 4.761 0 0 1-1.726 1.726 4.761 4.761 0 0 1 1.726 1.726 4.761 4.761 0 0 1 1.726-1.726 4.761 4.761 0 0 1-1.726-1.726Z"
],
"toolbox-outline": "M7 6.252V7H4.25A2.25 2.25 0 0 0 2 9.25v8.497a2.25 2.25 0 0 0 2.25 2.25h15.5a2.25 2.25 0 0 0 2.25-2.25V9.25A2.25 2.25 0 0 0 19.75 7H17v-.748a2.25 2.25 0 0 0-2.25-2.25h-5.5A2.25 2.25 0 0 0 7 6.252Zm2.25-.75h5.5a.75.75 0 0 1 .75.75V7h-7v-.748a.75.75 0 0 1 .75-.75Zm-2.25 3h1.5V8.5h7v.002H17V8.5h2.75a.75.75 0 0 1 .75.75v2.25h-3v-.75a.75.75 0 0 0-1.5 0v.75H8v-.75a.75.75 0 0 0-1.5 0v.75h-3V9.25a.75.75 0 0 1 .75-.75H7v.002ZM16 13v1.25a.75.75 0 0 0 1.5 0V13h3v4.747a.75.75 0 0 1-.75.75H4.25a.75.75 0 0 1-.75-.75V13h3v1.25a.75.75 0 0 0 1.5 0V13h8Z",
"translate-outline": "M16.953 5.303a1 1 0 0 0-1.906-.606c-.124.389-.236.899-.324 1.344-.565.012-1.12 0-1.652-.038a1 1 0 1 0-.142 1.995c.46.032.934.048 1.416.047a25.649 25.649 0 0 0-.24 1.698c-1.263.716-2.142 1.684-2.636 2.7-.624 1.283-.7 2.857.239 3.883.675.736 1.704.758 2.499.588.322-.068.654-.176.988-.32a1 1 0 0 0 1.746-.93 13.17 13.17 0 0 0-.041-.115 8.404 8.404 0 0 0 2.735-4.06c.286.251.507.55.658.864.284.594.334 1.271.099 1.91-.234.633-.78 1.313-1.84 1.843a1 1 0 0 0 .895 1.789c1.44-.72 2.385-1.758 2.821-2.94a4.436 4.436 0 0 0-.17-3.464 4.752 4.752 0 0 0-2.104-2.165C19.998 9.22 20 9.11 20 9a1 1 0 0 0-1.974-.23 5.984 5.984 0 0 0-1.796.138c.047-.305.102-.626.166-.964a20.142 20.142 0 0 0 2.842-.473 1 1 0 0 0-.476-1.942c-.622.152-1.286.272-1.964.358.048-.208.1-.409.155-.584Zm-3.686 8.015c.166-.34.414-.697.758-1.037.02.348.053.67.098.973.083.56.207 1.048.341 1.477a3.41 3.41 0 0 1-.674.227c-.429.092-.588.019-.614.006l-.004-.001c-.162-.193-.329-.774.095-1.645Zm4.498-2.562a6.362 6.362 0 0 1-1.568 2.73 7.763 7.763 0 0 1-.095-.525 10.294 10.294 0 0 1-.088-1.904c.033-.013.067-.024.1-.036l1.651-.265Zm0 0-1.651.265c.602-.212 1.155-.29 1.651-.265ZM7.536 6.29a6.342 6.342 0 0 0-4.456.331 1 1 0 0 0 .848 1.811 4.342 4.342 0 0 1 3.049-.222c.364.107.568.248.69.37.12.123.203.27.257.454.067.225.087.446.09.69a8.195 8.195 0 0 0-.555-.117c-1.146-.199-2.733-.215-4.262.64-1.271.713-1.796 2.168-1.682 3.448.12 1.326.94 2.679 2.572 3.136 1.48.414 2.913-.045 3.877-.507l.08-.04a1 1 0 0 0 1.96-.281V10.5c0-.053.002-.12.005-.2.012-.417.034-1.16-.168-1.838a3.043 3.043 0 0 0-.755-1.29c-.394-.398-.91-.694-1.547-.881h-.003Zm-.419 5.288c.344.06.647.143.887.222v2.197a7.021 7.021 0 0 1-.905.524c-.792.38-1.682.605-2.473.384-.698-.195-1.06-.742-1.119-1.389-.062-.693.243-1.286.667-1.523.987-.553 2.06-.569 2.943-.415Z",
"eye-show-outline": "M12 9.005a4 4 0 1 1 0 8 4 4 0 0 1 0-8Zm0 1.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM12 5.5c4.613 0 8.596 3.15 9.701 7.564a.75.75 0 1 1-1.455.365 8.503 8.503 0 0 0-16.493.004.75.75 0 0 1-1.455-.363A10.003 10.003 0 0 1 12 5.5Z",
"eye-hide-outline": "M2.22 2.22a.75.75 0 0 0-.073.976l.073.084 4.034 4.035a9.986 9.986 0 0 0-3.955 5.75.75.75 0 0 0 1.455.364 8.49 8.49 0 0 1 3.58-5.034l1.81 1.81A4 4 0 0 0 14.8 15.86l5.919 5.92a.75.75 0 0 0 1.133-.977l-.073-.084-6.113-6.114.001-.002-1.2-1.198-2.87-2.87h.002L8.719 7.658l.001-.002-1.133-1.13L3.28 2.22a.75.75 0 0 0-1.06 0Zm7.984 9.045 3.535 3.536a2.5 2.5 0 0 1-3.535-3.535ZM12 5.5c-1 0-1.97.148-2.889.425l1.237 1.236a8.503 8.503 0 0 1 9.899 6.272.75.75 0 0 0 1.455-.363A10.003 10.003 0 0 0 12 5.5Zm.195 3.51 3.801 3.8a4.003 4.003 0 0 0-3.801-3.8Z",
+3 -15
View File
@@ -27,7 +27,9 @@ export const login = async ({
export const register = async creds => {
try {
const response = await wootAPI.post('api/v2/accounts.json', {
const response = await wootAPI.post('api/v1/accounts.json', {
account_name: creds.accountName.trim(),
user_full_name: creds.fullName.trim(),
email: creds.email,
password: creds.password,
h_captcha_client_response: creds.hCaptchaClientResponse,
@@ -40,20 +42,6 @@ export const register = async creds => {
return null;
};
export const accountSetup = async ({ id, name, locale }) => {
try {
const response = await wootAPI.put(`api/v2/accounts/${id}.json`, {
name,
locale,
});
// setAuthCredentials(response);
return response.data;
} catch (error) {
throwErrorMessage(error);
}
return null;
};
export const verifyPasswordToken = async ({ confirmationToken }) => {
try {
const response = await wootAPI.post('auth/confirmation', {
@@ -12,7 +12,6 @@
:variant="value === option.value ? '' : 'hollow'"
:color-scheme="value === option.value ? 'primary' : 'secondary'"
size="small"
type=""
@click="$emit('input', option.value)"
>
{{ option.label }}
+3 -10
View File
@@ -15,14 +15,12 @@
:value="value"
:rows="rows"
:class="{
'focus:ring-red-600 ring-red-600': hasError,
'dark:ring-slate-600 dark:focus:ring-woot-500 ring-slate-200':
'focus:outline-red-600 outline-red-600': hasError,
'dark:outline-slate-600 dark:focus:outline-woot-500 outline-slate-200 focus:outline-woot-500':
!hasError,
'px-3 py-3': spacing === 'base',
'px-3 py-2': spacing === 'compact',
'resize-none': !allowResize,
}"
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"
class="block w-full p-3 border-none rounded-xl shadow-sm appearance-none outline outline-1 focus:outline focus:outline-2 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 bg-white dark:bg-slate-800"
@input="onInput"
@blur="$emit('blur')"
/>
@@ -75,11 +73,6 @@ export default {
type: String,
default: '',
},
spacing: {
type: String,
default: 'base',
validator: value => ['base', 'compact'].includes(value),
},
},
methods: {
onInput(e) {
+38 -51
View File
@@ -1,53 +1,24 @@
<template>
<div class="h-full w-full dark:bg-slate-900 relative">
<div
class="absolute inset-0 h-full w-full bg-white dark:bg-slate-900 bg-[radial-gradient(var(--w-100)_1px,transparent_1px)] dark:bg-[radial-gradient(var(--w-800)_1px,transparent_1px)] [background-size:16px_16px] z-0"
/>
<div
class="absolute h-full w-full overlay-gradient top-0 left-0 blur-[3px]"
/>
<svg
width="528"
height="96"
viewBox="0 0 528 96"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<mask id="cutoutMask">
<path
d="M263.999 65.0769C293.048 65.0769 317.641 45.9499 325.929 19.5735C329.219 9.10348 337.794 0 348.744 0H508.172C519.122 0 527.999 8.89875 527.999 19.8759V95.5C527.999 95.5 519.122 95.5 508.172 95.5H263.999H-0.000976562V19.8759C-0.000976562 8.89877 8.87604 0 19.8264 0H179.254C190.204 0 198.779 9.10349 202.069 19.5735C210.357 45.9499 234.95 65.0769 263.999 65.0769Z"
fill="#FCFCFD"
/>
</mask>
</defs>
</svg>
<div class="flex h-full relative z-50">
<div class="h-full w-full dark:bg-slate-900">
<div v-show="!isLoading" class="flex h-full">
<div
class="flex-1 min-h-[640px] inline-flex items-center h-full justify-center overflow-auto py-6"
>
<div
class="relative bg-white dark:bg-slate-800 before:bg-white dark:before:bg-slate-800 px-16 pt-8 pb-16 max-w-[528px] w-full rounded-3xl signup-box"
>
<div class="absolute -top-[116px] right-0 left-0 w-24 h-24 mx-auto">
<div class="px-8 max-w-[560px] w-full overflow-auto">
<div class="mb-4">
<img
:src="globalConfig.logoThumbnail"
:src="globalConfig.logo"
:alt="globalConfig.installationName"
class="h-24 w-24 block"
:class="{ 'dark:hidden': globalConfig.logoDarkThumbnail }"
class="h-8 w-auto block dark:hidden"
/>
<img
v-if="globalConfig.logoDarkThumbnail"
:src="globalConfig.logoDarkThumbnail"
v-if="globalConfig.logoDark"
:src="globalConfig.logoDark"
:alt="globalConfig.installationName"
class="h-8 w-auto hidden dark:block"
/>
</div>
<div class="mb-8">
<h2
class="text-center text-3xl tracking-wide font-medium text-slate-900 dark:text-woot-50"
class="mb-7 mt-6 text-left text-3xl font-medium text-slate-900 dark:text-woot-50"
>
{{ $t('REGISTER.TRY_WOOT') }}
</h2>
@@ -66,6 +37,17 @@
</div>
</div>
</div>
<testimonials
v-if="isAChatwootInstance"
class="flex-1"
@resize-containers="resizeContainers"
/>
</div>
<div
v-show="isLoading"
class="flex items-center justify-center h-full w-full"
>
<spinner color-scheme="primary" size="" />
</div>
</div>
</template>
@@ -74,27 +56,32 @@
import { mapGetters } from 'vuex';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import SignupForm from './components/Signup/Form.vue';
import Testimonials from './components/Testimonials/Index.vue';
import Spinner from 'shared/components/Spinner.vue';
export default {
components: {
SignupForm,
Spinner,
Testimonials,
},
mixins: [globalConfigMixin],
data() {
return { isLoading: false };
},
computed: {
...mapGetters({ globalConfig: 'globalConfig/get' }),
isAChatwootInstance() {
return this.globalConfig.installationName === 'Chatwoot';
},
},
beforeMount() {
this.isLoading = this.isAChatwootInstance;
},
methods: {
resizeContainers() {
this.isLoading = false;
},
},
};
</script>
<style>
.signup-box::before {
width: 100%;
height: 200px;
content: '';
display: block;
left: 0;
top: -66px;
position: absolute;
-webkit-mask-image: url('#cutoutMask');
mask-image: url('#cutoutMask');
}
</style>
@@ -1,30 +1,52 @@
<template>
<div class="flex-1 px-1 overflow-auto">
<form class="space-y-8" @submit.prevent="submit">
<section class="space-y-3">
<div class="flex-1 overflow-auto px-1">
<form class="space-y-3" @submit.prevent="submit">
<div class="flex">
<form-input
v-model.trim="credentials.email"
type="email"
name="email_address"
:class="{ error: $v.credentials.email.$error }"
:label="$t('REGISTER.EMAIL.LABEL')"
:placeholder="$t('REGISTER.EMAIL.PLACEHOLDER')"
:has-error="$v.credentials.email.$error"
:error-message="$t('REGISTER.EMAIL.ERROR')"
@blur="$v.credentials.email.$touch"
v-model.trim="credentials.fullName"
name="full_name"
class="flex-1"
:class="{ error: $v.credentials.fullName.$error }"
:label="$t('REGISTER.FULL_NAME.LABEL')"
:placeholder="$t('REGISTER.FULL_NAME.PLACEHOLDER')"
:has-error="$v.credentials.fullName.$error"
:error-message="$t('REGISTER.FULL_NAME.ERROR')"
@blur="$v.credentials.fullName.$touch"
/>
<form-input
v-model.trim="credentials.password"
type="password"
name="password"
:class="{ error: $v.credentials.password.$error }"
:label="$t('LOGIN.PASSWORD.LABEL')"
:placeholder="$t('SET_NEW_PASSWORD.PASSWORD.PLACEHOLDER')"
:has-error="$v.credentials.password.$error"
:error-message="passwordErrorText"
@blur="$v.credentials.password.$touch"
v-model.trim="credentials.accountName"
name="account_name"
class="flex-1 ml-2"
:class="{ error: $v.credentials.accountName.$error }"
:label="$t('REGISTER.COMPANY_NAME.LABEL')"
:placeholder="$t('REGISTER.COMPANY_NAME.PLACEHOLDER')"
:has-error="$v.credentials.accountName.$error"
:error-message="$t('REGISTER.COMPANY_NAME.ERROR')"
@blur="$v.credentials.accountName.$touch"
/>
</section>
</div>
<form-input
v-model.trim="credentials.email"
type="email"
name="email_address"
:class="{ error: $v.credentials.email.$error }"
:label="$t('REGISTER.EMAIL.LABEL')"
:placeholder="$t('REGISTER.EMAIL.PLACEHOLDER')"
:has-error="$v.credentials.email.$error"
:error-message="$t('REGISTER.EMAIL.ERROR')"
@blur="$v.credentials.email.$touch"
/>
<form-input
v-model.trim="credentials.password"
type="password"
name="password"
:class="{ error: $v.credentials.password.$error }"
:label="$t('LOGIN.PASSWORD.LABEL')"
:placeholder="$t('SET_NEW_PASSWORD.PASSWORD.PLACEHOLDER')"
:has-error="$v.credentials.password.$error"
:error-message="passwordErrorText"
@blur="$v.credentials.password.$touch"
/>
<div v-if="globalConfig.hCaptchaSiteKey" class="mb-3">
<vue-hcaptcha
ref="hCaptcha"
@@ -61,17 +83,15 @@ import { required, minLength, email } from 'vuelidate/lib/validators';
import { mapGetters } from 'vuex';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import alertMixin from 'shared/mixins/alertMixin';
import { DEFAULT_REDIRECT_URL } from 'dashboard/constants/globals';
import VueHcaptcha from '@hcaptcha/vue-hcaptcha';
import FormInput from 'v3/components/Form/Input.vue';
import FormInput from '../../../../../components/Form/Input.vue';
import SubmitButton from '../../../../../components/Button/SubmitButton.vue';
import { isValidPassword } from 'shared/helpers/Validators';
import GoogleOAuthButton from '../../../../../components/GoogleOauth/Button.vue';
import { register } from '../../../../../api/auth';
var CompanyEmailValidator = require('company-email-validator');
export const generateOnboardingUrl = accountId =>
`/app/accounts/${accountId}/start/setup-profile`;
export default {
components: {
FormInput,
@@ -83,8 +103,10 @@ export default {
data() {
return {
credentials: {
email: 'Testing123@shivam.dev',
password: 'Testing123@shivam.dev',
accountName: '',
fullName: '',
email: '',
password: '',
hCaptchaClientResponse: '',
},
didCaptchaReset: false,
@@ -94,6 +116,14 @@ export default {
},
validations: {
credentials: {
accountName: {
required,
minLength: minLength(2),
},
fullName: {
required,
minLength: minLength(2),
},
email: {
required,
email,
@@ -150,11 +180,8 @@ export default {
}
this.isSignupInProgress = true;
try {
const {
data: { account_id: accountId },
} = await register(this.credentials);
window.location = generateOnboardingUrl(accountId);
await register(this.credentials);
window.location = DEFAULT_REDIRECT_URL;
} catch (error) {
let errorMessage =
error?.message || this.$t('REGISTER.API.ERROR_MESSAGE');
@@ -1,107 +0,0 @@
<template>
<div class="h-full w-full dark:bg-slate-900 relative overflow-hidden">
<div
class="absolute inset-0 h-full w-full bg-white dark:bg-slate-900 bg-[radial-gradient(var(--w-100)_1px,transparent_1px)] [background-size:16px_16px] z-0"
/>
<div
class="absolute h-full w-full overlay-gradient top-0 left-0 scale-y-110 blur-[3px]"
/>
<div class="flex h-full relative z-50">
<div
class="flex-1 min-h-[640px] inline-flex items-center h-full justify-center overflow-auto py-6"
>
<div
class="p-10 max-w-[560px] w-full overflow-auto bg-white dark:bg-slate-900 border border-solid border-slate-100 dark:border-slate-800 rounded-2xl"
>
<section
class="mt-4 space-y-6 text-sm leading-relaxed text-slate-900 dark:text-white"
>
<div
class="rounded-full h-16 w-16 border border-solid border-woot-500 p-4"
>
<div
class="bg-[url('/assets/images/dashboard/onboarding/waving-hand.svg')] bg-contain w-8 h-8"
/>
</div>
<div>
<p
class="uppercase tracking-wide text-slate-900 dark:text-white mb-1"
>
a note from the founder
</p>
<h3 class="text-slate-900 dark:text-white text-3xl font-semibold">
Hey {{ userName }},
</h3>
</div>
<p>
I'm Pranav, one of the co-founders at Chatwoot. Thanks for
checking out Chatwoot. We're eager to understand your needs and
expectations.
</p>
<p>
If you are facing trouble using the app or achieving your use
case, we want to help you as quickly as possible.
</p>
<p>
Feel free to schedule a call with our team. We are open to your
feedback. Talk soon!
</p>
</section>
<figure
class="mt-5 text-sm leading-relaxed text-slate-900 dark:text-white"
>
<woot-thumbnail
src="https://www.chatwoot.com/images/team/pranav.jpg"
username="Pranav"
/>
<p class="mt-2">Pranav, <br />Co-founder & CEO, Chatwoot</p>
</figure>
<submit-button
button-class="flex justify-center w-full text-sm text-center mt-8"
:button-text="$t('ONBOARDING.FOUNDERS_NOTE.SUBMIT')"
@click="onSubmit"
/>
</div>
</div>
</div>
</div>
</template>
<script>
import SubmitButton from 'dashboard/components/buttons/FormSubmitButton.vue';
import { mapGetters } from 'vuex';
export default {
components: {
SubmitButton,
},
computed: {
...mapGetters({
currentUser: 'getCurrentUser',
}),
userName() {
return this.currentUser.name;
},
},
methods: {
onSubmit() {
this.$router.push({ name: 'home' });
},
},
};
</script>
<style>
.overlay-gradient {
background: linear-gradient(90deg, rgba(252, 252, 253, 0) 61.8%, #fcfcfd 90%),
linear-gradient(270deg, rgba(252, 252, 253, 0) 66.93%, #fcfcfd 90%),
linear-gradient(0deg, rgba(252, 252, 253, 0) 68.63%, #fcfcfd 90%),
linear-gradient(180deg, rgba(252, 252, 253, 0) 63.2%, #fcfcfd 80%);
}
.dark .overlay-gradient {
background: linear-gradient(270deg, rgba(24, 24, 26, 0) 61.93%, #151718 90%),
linear-gradient(90deg, rgba(24, 24, 26, 0) 66.8%, #151718 90%),
linear-gradient(0deg, rgba(24, 24, 26, 0) 68.63%, #151718 90%),
linear-gradient(180deg, rgba(24, 24, 26, 0) 63.2%, #151718 90%);
}
</style>
@@ -1,138 +0,0 @@
<template>
<div
id="modal-body"
class="dark:shadow-[#000] rounded-3xl p-10 pt-14 border shadow border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 z-10 relative w-full max-w-[560px]"
>
<h2
class="font-bold text-[28px] leading-8 tracking-[2%] text-slate-900 dark:text-white"
>
{{ $t('ONBOARDING.INVITE_TEAM.TITLE') }}
</h2>
<p
class="text-base text-slate-600 leading-6 tracking-[-1.1%] mt-2 dark:text-slate-200"
>
{{ $t('ONBOARDING.INVITE_TEAM.BODY') }}
</p>
<div class="mt-10">
<section class="space-y-5">
<div class="flex gap-2">
<form-input
v-model="emailToAdd"
name="email"
spacing="compact"
class="flex-grow"
:placeholder="$t('ONBOARDING.INVITE_TEAM.PLACEHOLDER')"
@keyup.enter="pushEmail"
/>
<woot-button
variant="smooth"
color-scheme="secondary"
@click="pushEmail"
>
{{ $t('ONBOARDING.INVITE_TEAM.ADD') }}
</woot-button>
</div>
<div
class="rounded-md divide-y divide-slate-100 dark:divide-slate-700 border border-slate-200 dark:border-slate-700 max-h-[40vh] overflow-auto"
:class="{
' border-dashed grid place-content-center':
emailsToInvite.length === 0,
}"
>
<div v-if="emailsToInvite.length === 0" class="px-5 py-16">
<p class="text-sm text-slate-400">
{{ $t('ONBOARDING.INVITE_TEAM.LIST_EMPTY') }}
</p>
</div>
<div
v-for="email in emailsToInvite"
:key="email"
class="flex items-center justify-between p-4"
>
<div class="flex items-center gap-2">
<woot-thumbnail :username="email" size="24px" />
<span
class="text-slate-700 dark:text-slate-200 text-sm font-medium"
>
{{ email }}
</span>
</div>
<woot-button
variant="clear"
size="small"
color-scheme="secondary"
icon="dismiss"
@click="emailsToInvite.splice(emailsToInvite.indexOf(email), 1)"
/>
</div>
</div>
<div class="space-y-2">
<submit-button
button-class="flex justify-center w-full text-sm text-center"
:button-text="$t('ONBOARDING.INVITE_TEAM.SUBMIT.BUTTON')"
@click="onSubmit"
/>
<woot-button
variant="clear"
color-scheme="secondary"
size="expanded"
is-expanded
@click="skipToFoundersNote"
>
{{ $t('ONBOARDING.INVITE_TEAM.SKIP') }}
</woot-button>
</div>
</section>
</div>
</div>
</template>
<script>
import FormInput from 'v3/components/Form/Input.vue';
import SubmitButton from 'dashboard/components/buttons/FormSubmitButton.vue';
import AgentAPI from 'dashboard/api/agents.js';
import alertMixin from 'shared/mixins/alertMixin';
export default {
components: {
SubmitButton,
FormInput,
},
mixins: [alertMixin],
data() {
return {
emailsToInvite: [],
emailToAdd: '',
};
},
methods: {
pushEmail() {
if (!this.emailToAdd) return;
this.emailsToInvite.push(this.emailToAdd);
this.emailsToInvite = [...new Set(this.emailsToInvite)];
this.emailToAdd = '';
},
skipToFoundersNote() {
this.$router.push({ name: 'onboarding_founders_note' });
},
async onSubmit() {
if (this.emailsToInvite.length === 0) {
this.showAlert(
this.$t('ONBOARDING.INVITE_TEAM.SUBMIT.NO_MEMBER_ERROR')
);
return;
}
try {
await AgentAPI.bulkInvite({ emails: this.emailsToInvite });
this.showAlert(this.$t('ONBOARDING.INVITE_TEAM.SUBMIT.SUCCESS'));
this.$router.push({ name: 'onboarding_founders_note' });
} catch (error) {
this.showAlert(this.$t('ONBOARDING.INVITE_TEAM.SUBMIT.ERROR'));
}
},
},
};
</script>
@@ -1,166 +0,0 @@
<template>
<section
class="relative min-h-screen px-8 dark:text-white bg-[#FCFCFD] dark:bg-slate-900 flex items-center justify-center bg-[url('/assets/images/dashboard/onboarding/intro.svg')] dark:bg-[url('/assets/images/dashboard/onboarding/intro-dark.svg')] bg-[length:715px_555px] bg-no-repeat bg-[left_calc(-56px)_bottom_calc(-133px)]"
>
<div
class="relative max-w-[1440px] w-full mx-auto flex justify-center gap-16"
>
<div class="relative w-4/12 px-16 py-[88px]">
<div class="mb-10 z-0">
<img
:src="globalConfig.logo"
:alt="globalConfig.installationName"
class="h-8 w-auto block dark:hidden"
/>
<img
v-if="globalConfig.logoDark"
:src="globalConfig.logoDark"
:alt="globalConfig.installationName"
class="h-8 w-auto hidden dark:block"
/>
<h1
class="font-bold dark:text-white text-slate-900 text-[40px] tracking-[1%] leading-[56px] mt-6 mb-16"
>
<slot name="intro">
{{ intro }}
</slot>
</h1>
</div>
<div id="steps" class="z-0">
<onboarding-step
v-for="(step, index) in steps"
:key="step.name"
v-bind="step"
:step-number="index + 1"
:icon="step.icon"
/>
</div>
</div>
<div
class="relative w-8/12 py-[88px] flex items-center justify-center overflow-hidden px-24"
>
<div
class="absolute inset-0 h-full w-full bg-white dark:bg-slate-900 bg-[radial-gradient(var(--w-100)_1px,transparent_1px)] [background-size:16px_16px]"
/>
<div
class="absolute h-full w-full overlay-gradient top-0 left-0 scale-y-110 blur-[3px]"
/>
<div
id="modal-body"
class="dark:shadow-[#000] rounded-3xl p-10 pt-14 border shadow border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 z-10 relative max-w-[560px]"
>
<h2
class="font-bold text-[28px] leading-8 tracking-[2%] text-slate-900 dark:text-white"
>
<slot name="title">
{{ title }}
</slot>
</h2>
<slot name="body">
<p
class="text-base text-slate-600 leading-6 tracking-[-1.1%] mt-2 dark:text-slate-200"
>
{{ body }}
</p>
</slot>
<div class="mt-10">
<slot />
</div>
</div>
</div>
</div>
</section>
</template>
<script>
import { mapGetters } from 'vuex';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import OnboardingStep from './OnboardingStep.vue';
const UISteps = [
{
name: 'profile',
title: 'Setup Profile',
icon: 'person',
isActive: false,
isComplete: false,
},
{
name: 'company',
title: 'Setup Company',
icon: 'toolbox',
isActive: false,
isComplete: false,
},
{
name: 'invite',
title: 'Invite your team',
icon: 'people-team',
isActive: false,
isComplete: false,
},
{
name: 'founders-note',
title: "Founder's Note",
icon: '',
hidden: true,
isActive: false,
isComplete: false,
},
];
export default {
components: {
OnboardingStep,
},
mixins: [globalConfigMixin],
props: {
intro: {
type: String,
default: '',
},
title: {
type: String,
default: '',
},
body: {
type: String,
default: '',
},
currentStep: {
type: String,
required: true,
},
},
computed: {
steps() {
let foundCurrentStep = false;
return UISteps.map(step => {
if (step.name === this.currentStep) {
foundCurrentStep = true;
}
return {
...step,
isActive: step.name === this.currentStep,
isComplete: !foundCurrentStep,
};
});
},
...mapGetters({ globalConfig: 'globalConfig/get' }),
},
};
</script>
<style>
.overlay-gradient {
background: linear-gradient(90deg, rgba(252, 252, 253, 0) 81.8%, #fcfcfd 100%),
linear-gradient(270deg, rgba(252, 252, 253, 0) 76.93%, #fcfcfd 100%),
linear-gradient(0deg, rgba(252, 252, 253, 0) 68.63%, #fcfcfd 100%),
linear-gradient(180deg, rgba(252, 252, 253, 0) 73.2%, #fcfcfd 100%);
}
.dark .overlay-gradient {
background: linear-gradient(270deg, rgba(24, 24, 26, 0) 76.93%, #151718 100%),
linear-gradient(90deg, rgba(24, 24, 26, 0) 81.8%, #151718 100%),
linear-gradient(0deg, rgba(24, 24, 26, 0) 68.63%, #151718 100%),
linear-gradient(180deg, rgba(24, 24, 26, 0) 73.2%, #151718 100%);
}
</style>
@@ -1,6 +1,6 @@
<template>
<div
class="flex items-center gap-2 p-2 text-lg font-medium 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="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,
@@ -10,7 +10,7 @@
<div
class="grid w-8 h-8 border border-solid rounded-full place-content-center"
:class="{
'border-woot-500': (!isActive && isComplete) || isComplete,
'border-woot-500': !isActive || isComplete,
'border-slate-200 dark:border-slate-600': !isActive && !isComplete,
'text-woot-500': isComplete,
}"
@@ -1,196 +0,0 @@
<template>
<section
class="relative min-h-screen px-8 dark:text-white bg-[#FCFCFD] dark:bg-slate-900 flex items-center justify-center bg-[url('/assets/images/dashboard/onboarding/intro.svg')] dark:bg-[url('/assets/images/dashboard/onboarding/intro-dark.svg')] bg-[length:715px_555px] bg-no-repeat bg-[left_calc(-56px)_bottom_calc(-133px)]"
>
<div
class="relative max-w-[1440px] w-full mx-auto flex gap-16 min-h-[80vh] justify-center"
>
<div class="relative w-5/12 px-16 py-[88px] mt-10">
<div class="mb-10 z-0">
<img
:src="globalConfig.logo"
:alt="globalConfig.installationName"
class="h-8 w-auto block dark:hidden"
/>
<img
v-if="globalConfig.logoDark"
:src="globalConfig.logoDark"
:alt="globalConfig.installationName"
class="h-8 w-auto hidden dark:block"
/>
<transition name="fade">
<h1
v-show="showIntroHeader"
class="font-bold dark:text-white text-slate-900 text-[40px] leading-[56px] whitespace-pre-line"
>
{{ intro }}
</h1>
</transition>
</div>
<transition name="slide-fade">
<div v-if="showIntroHeader" id="steps" class="z-0">
<onboarding-step
v-for="(step, index) in steps"
:key="step.name"
v-bind="step"
:step-number="index + 1"
:icon="step.icon"
/>
</div>
</transition>
</div>
<div
class="relative w-7/12 py-[88px] flex justify-center overflow-hidden h-fit"
>
<div
class="absolute inset-0 h-full w-full bg-white dark:bg-slate-900 bg-[radial-gradient(var(--w-200)_1px,transparent_1px)] [background-size:16px_16px]"
/>
<div
class="absolute h-full w-full overlay-gradient top-0 left-0 scale-y-110 blur-[3px]"
/>
<transition name="slide-fade">
<router-view />
</transition>
</div>
</div>
</section>
</template>
<script>
import { mapGetters } from 'vuex';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import OnboardingStep from './OnboardingStep.vue';
const UISteps = [
{
name: 'onboarding_setup_profile',
title: 'Setup Profile',
icon: 'person',
isActive: false,
isComplete: false,
},
{
name: 'onboarding_setup_company',
title: 'Setup Company',
icon: 'toolbox',
isActive: false,
isComplete: false,
},
{
name: 'onboarding_invite_team',
title: 'Invite your team',
icon: 'people-team',
isActive: false,
isComplete: false,
},
// {
// name: 'onboarding_founders_note',
// title: "Founder's Note",
// icon: '',
// hidden: true,
// isActive: false,
// isComplete: false,
// },
];
export default {
components: {
OnboardingStep,
},
mixins: [globalConfigMixin],
data() {
return {
showIntroHeader: false,
};
},
computed: {
currentStep() {
return this.$route.name;
},
intro() {
if (
this.currentStep === 'onboarding_setup_company' ||
this.currentStep === 'onboarding_invite_team'
) {
return this.$t('ONBOARDING.COMPANY.PAGE_TITLE', {
userName: this.userName,
});
}
return this.$t('ONBOARDING.PROFILE.INTRO');
},
steps() {
let foundCurrentStep = false;
return UISteps.map(step => {
if (step.name === this.currentStep) {
foundCurrentStep = true;
}
return {
...step,
isActive: step.name === this.currentStep,
isComplete: !foundCurrentStep,
};
});
},
userName() {
return this.currentUser.name;
},
...mapGetters({
globalConfig: 'globalConfig/get',
currentUser: 'getCurrentUser',
}),
},
watch: {
watch: {
$route() {
this.showIntroHeader = false;
setTimeout(() => {
this.showIntroHeader = true;
}, 10);
},
},
},
mounted() {
this.showIntroHeader = true;
},
beforeDestroy() {
this.showIntroHeader = false;
},
};
</script>
<style scoped>
.overlay-gradient {
background: linear-gradient(90deg, rgba(252, 252, 253, 0) 81.8%, #fcfcfd 95%),
linear-gradient(270deg, rgba(252, 252, 253, 0) 76.93%, #fcfcfd 95%),
linear-gradient(0deg, rgba(252, 252, 253, 0) 68.63%, #fcfcfd 95%),
linear-gradient(180deg, rgba(252, 252, 253, 0) 73.2%, #fcfcfd 95%);
}
.dark .overlay-gradient {
background: linear-gradient(270deg, rgba(24, 24, 26, 0) 76.93%, #151718 95%),
linear-gradient(90deg, rgba(24, 24, 26, 0) 81.8%, #151718 95%),
linear-gradient(0deg, rgba(24, 24, 26, 0) 68.63%, #151718 95%),
linear-gradient(180deg, rgba(24, 24, 26, 0) 73.2%, #151718 95%);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s;
}
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
opacity: 0;
}
/* Enter and leave animations can use different */
/* durations and timing functions. */
.slide-fade-enter-active {
transition: all 0.5s ease;
}
.slide-fade-leave-active {
position: absolute;
transition: all 0.4s cubic-bezier(1, 0.5, 0.8, 1);
}
.slide-fade-enter, .slide-fade-leave-to
/* .slide-fade-leave-active below version 2.1.8 */ {
transform: translateY(10px);
opacity: 0;
}
</style>
@@ -1,256 +0,0 @@
<template>
<div
id="modal-body"
class="dark:shadow-[#000] rounded-3xl p-10 pt-14 border shadow border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 z-10 relative w-full max-w-[560px]"
>
<h2
class="font-bold text-[28px] leading-8 tracking-[2%] text-slate-900 dark:text-white"
>
{{ $t('ONBOARDING.COMPANY.TITLE') }}
</h2>
<p
class="text-base text-slate-600 leading-6 tracking-[-1.1%] mt-2 dark:text-slate-200"
>
{{ $t('ONBOARDING.COMPANY.BODY') }}
</p>
<div class="space-y-8 mt-10">
<div class="space-y-3">
<form-input
v-model="companyName"
icon="building-bank"
name="companyName"
spacing="compact"
:has-error="$v.companyName.$error"
:label="$t('ONBOARDING.COMPANY.COMPANY_NAME.LABEL')"
:placeholder="$t('ONBOARDING.COMPANY.COMPANY_NAME.PLACEHOLDER')"
:error-message="$t('ONBOARDING.COMPANY.COMPANY_NAME.ERROR')"
/>
<form-select
v-model="locale"
name="locale"
icon="local-language"
:label="$t('ONBOARDING.COMPANY.LOCALE.LABEL')"
:placeholder="$t('ONBOARDING.COMPANY.LOCALE.PLACEHOLDER')"
>
<option
v-for="lang in languagesSortedByCode"
:key="lang.iso_639_1_code"
:value="lang.iso_639_1_code"
:selected="locale === lang.iso_639_1_code"
>
{{ lang.name }}
</option>
</form-select>
<form-select
v-model="timezone"
name="timezone"
icon="globe-clock"
spacing="compact"
:label="$t('ONBOARDING.COMPANY.TIMEZONE.LABEL')"
:placeholder="$t('ONBOARDING.COMPANY.TIMEZONE.PLACEHOLDER')"
>
<option
v-for="zone in timeZones"
:key="zone.label"
:value="zone.value"
:selected="timezone === zone.value"
>
{{ zone.label }}
</option>
</form-select>
<form-radio-tags
v-model="industry"
name="industry"
:label="$t('ONBOARDING.COMPANY.INDUSTRY.LABEL')"
:placeholder="$t('ONBOARDING.COMPANY.SIZE.PLACEHOLDER')"
:options="industryOptions"
/>
<form-radio-tags
v-model="companySize"
name="companySize"
:label="$t('ONBOARDING.COMPANY.SIZE.LABEL')"
:placeholder="$t('ONBOARDING.COMPANY.SIZE.PLACEHOLDER')"
:options="companySizeOptions"
/>
</div>
<submit-button
button-class="flex justify-center w-full text-sm text-center"
:button-text="$t('ONBOARDING.COMPANY.SUBMIT.BUTTON')"
@click="onSubmit"
/>
</div>
</div>
</template>
<script>
import FormInput from 'v3/components/Form/Input.vue';
import FormSelect from 'v3/components/Form/Select.vue';
import FormRadioTags from 'v3/components/Form/RadioTags.vue';
import { required, minLength } from 'vuelidate/lib/validators';
import { mapGetters } from 'vuex';
import SubmitButton from 'dashboard/components/buttons/FormSubmitButton.vue';
import { timeZoneOptions } from 'dashboard/routes/dashboard/settings/inbox/helpers/businessHour.js';
import alertMixin from 'shared/mixins/alertMixin';
import configMixin from 'shared/mixins/configMixin';
export default {
components: {
FormInput,
SubmitButton,
FormSelect,
FormRadioTags,
},
mixins: [configMixin, alertMixin],
data() {
return {
companyName: '',
timezone: '',
locale: '',
companySize: '',
industry: '',
companySizeOptions: [
{ value: '1-10', label: '1-10' },
{ value: '11-50', label: '11-50' },
{ value: '51-500', label: '51-500' },
{ value: '501-1000', label: '501-1000' },
{ value: '1001+', label: 'Over 1000' },
],
industryOptions: [
{ value: 'information_technology', label: 'Information technology' },
{ value: 'finance', label: 'Finance' },
{ value: 'health_medicine', label: 'Health & Medicine' },
{ value: 'education', label: 'Education' },
{ value: 'other', label: 'Other' },
],
};
},
validations: {
companyName: {
required,
minLength: minLength(2),
},
},
computed: {
...mapGetters({
getAccount: 'accounts/getAccount',
currentAccountId: 'getCurrentAccountId',
}),
accountDetails() {
return this.getAccount(this.currentAccountId);
},
languagesSortedByCode() {
const enabledLanguages = [...this.enabledLanguages];
return enabledLanguages.sort((l1, l2) =>
l1.iso_639_1_code.localeCompare(l2.iso_639_1_code)
);
},
timeZones() {
return [...timeZoneOptions()];
},
intelligentData() {
const { clearbit_data: data } = this.getAccount;
return data;
},
},
watch: {
locale(val) {
this.setLocale(val);
},
accountDetails() {
this.initFormData();
},
},
mounted() {
this.setLocaleFromBrowser();
this.setTimezone();
this.initFormData();
},
methods: {
async onSubmit() {
this.$v.$touch();
if (this.$v.$invalid) {
return;
}
try {
await this.$store.dispatch('accounts/update', {
name: this.companyName,
timezone: this.timezone,
locale: this.locale,
company_size: this.companySize,
industry: this.industry,
});
this.showAlert(this.$t('ONBOARDING.COMPANY.SUBMIT.SUCCESS'));
this.$router.push({ name: 'onboarding_invite_team' });
} catch (error) {
this.showAlert(this.$t('ONBOARDING.COMPANY.SUBMIT.ERROR'));
}
},
setLocale(locale) {
this.$root.$i18n.locale = locale;
},
setLocaleFromBrowser() {
const localeWithVariant = window.navigator.language.replace('-', '_');
const localeWithoutVariant = localeWithVariant.split('_')[0];
const { iso_639_1_code: locale } =
this.enabledLanguages.find(
lang =>
lang.iso_639_1_code === localeWithVariant ||
lang.iso_639_1_code === localeWithoutVariant
) || {};
if (locale) {
this.locale = locale;
this.setLocale(locale);
}
},
setTimezone() {
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (!this.intelligentData) {
this.timezone = browserTimezone;
return;
}
const { timezone } = this.intelligentData;
const allZones = this.timeZones.map(zone => zone.value);
const matchForIntelligentZone = allZones.find(zone => zone === timezone);
if (matchForIntelligentZone) {
this.timezone = timezone;
}
},
setFromIntelligentData() {
if (!this.intelligentData) return;
const { name: companyName, industry, size } = this.intelligentData;
this.companyName = companyName;
this.industry = industry;
this.companySize = size;
},
setSavedData() {
const {
name: companyName,
industry,
size,
locale,
timezone,
} = this.accountDetails;
this.companyName = companyName;
this.industry = industry;
this.companySize = size;
this.locale = locale;
this.timezone = timezone;
},
initFormData() {
const { name } = this.accountDetails;
if (!name) {
this.setFromIntelligentData();
} else {
this.setSavedData();
}
},
},
};
</script>
@@ -1,272 +0,0 @@
<template>
<div
id="modal-body"
class="dark:shadow-[#000] rounded-3xl p-10 pt-14 border shadow border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 z-10 relative max-w-[560px]"
>
<h2
class="font-bold text-[28px] leading-8 tracking-[2%] text-slate-900 dark:text-white"
>
{{ $t('ONBOARDING.PROFILE.TITLE') }}
</h2>
<p
class="text-base text-slate-600 leading-6 tracking-[-1.1%] mt-2 dark:text-slate-200"
>
{{ $t('ONBOARDING.PROFILE.BODY') }}
</p>
<div class="mt-10">
<form class="space-y-6" @submit="onSubmit">
<div class="space-y-6">
<div>
<with-label name="user_avatar">
<template #label>
{{ $t('ONBOARDING.PROFILE.AVATAR.LABEL') }}
</template>
<div class="flex gap-2">
<woot-thumbnail
v-if="avatarUrl"
size="40px"
:src="avatarUrl"
:username="fullName"
variant="square"
:has-border="true"
/>
<div
v-else
class="rounded-xl h-10 w-10 bg-slate-200 flex items-center justify-center"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g id="user-3-fill">
<path
id="Vector"
d="M18.8574 20.5714H5.14307V18.8571C5.14307 17.7205 5.5946 16.6304 6.39832 15.8267C7.20205 15.0229 8.29214 14.5714 9.42878 14.5714H14.5716C15.7083 14.5714 16.7984 15.0229 17.6021 15.8267C18.4058 16.6304 18.8574 17.7205 18.8574 18.8571V20.5714ZM12.0002 12.8571C11.3248 12.8571 10.6561 12.7241 10.0321 12.4656C9.40816 12.2072 8.84122 11.8284 8.36366 11.3508C7.8861 10.8733 7.50728 10.3063 7.24883 9.68235C6.99038 9.05839 6.85735 8.38964 6.85735 7.71427C6.85735 7.0389 6.99038 6.37014 7.24883 5.74618C7.50728 5.12222 7.8861 4.55528 8.36366 4.07772C8.84122 3.60016 9.40816 3.22134 10.0321 2.96289C10.6561 2.70444 11.3248 2.57141 12.0002 2.57141C13.3642 2.57141 14.6723 3.11325 15.6368 4.07772C16.6012 5.04219 17.1431 6.3503 17.1431 7.71427C17.1431 9.07824 16.6012 10.3863 15.6368 11.3508C14.6723 12.3153 13.3642 12.8571 12.0002 12.8571Z"
fill="#B9BBC6"
/>
</g>
</svg>
</div>
<input
id="user_avatar"
ref="imageUpload"
name="user_avatar"
class="invisible h-0 w-0"
type="file"
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
@change="handleImageUpload"
/>
<woot-button
v-if="!avatarFile"
color-scheme="secondary"
variant="hollow"
@click="onFileBrowse"
>
{{ $t('ONBOARDING.PROFILE.AVATAR.PLACEHOLDER') }}
</woot-button>
<woot-button
v-else
type="button"
color-scheme="alert"
variant="hollow"
@click="deleteAvatar"
>
{{ $t('PROFILE_SETTINGS.DELETE_AVATAR') }}
</woot-button>
</div>
</with-label>
</div>
<form-input
v-model="fullName"
name="full_name"
spacing="compact"
:has-error="$v.fullName.$error"
:label="$t('ONBOARDING.PROFILE.FULL_NAME.LABEL')"
:placeholder="$t('ONBOARDING.PROFILE.FULL_NAME.PLACEHOLDER')"
:error-message="$t('ONBOARDING.PROFILE.FULL_NAME.ERROR')"
/>
<form-input
v-model="displayName"
name="display_name"
spacing="compact"
:has-error="$v.displayName.$error"
:label="$t('ONBOARDING.PROFILE.DISPLAY_NAME.LABEL')"
:placeholder="$t('ONBOARDING.PROFILE.DISPLAY_NAME.PLACEHOLDER')"
:error-message="$t('ONBOARDING.PROFILE.DISPLAY_NAME.ERROR')"
/>
<form-input
v-model="phoneNumber"
name="phone_number"
spacing="compact"
:has-error="$v.phoneNumber.$error"
:label="$t('ONBOARDING.PROFILE.PHONE_NUMBER.LABEL')"
:placeholder="$t('ONBOARDING.PROFILE.PHONE_NUMBER.PLACEHOLDER')"
:error-message="$t('CONTACT_FORM.FORM.PHONE_NUMBER.ERROR')"
/>
<form-text-area
v-model="signature"
name="signature"
spacing="compact"
:allow-resize="false"
:label="$t('ONBOARDING.PROFILE.SIGNATURE.LABEL')"
:placeholder="$t('ONBOARDING.PROFILE.SIGNATURE.PLACEHOLDER')"
/>
</div>
<submit-button
button-class="flex justify-center w-full text-sm text-center"
:button-text="$t('ONBOARDING.PROFILE.SUBMIT.BUTTON')"
/>
</form>
</div>
</div>
</template>
<script>
import md5 from 'md5';
import { required, minLength, helpers } from 'vuelidate/lib/validators';
import parsePhoneNumber from 'libphonenumber-js';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { validationMixin } from 'vuelidate';
import { mapGetters } from 'vuex';
import alertMixin from 'shared/mixins/alertMixin';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import FormInput from 'v3/components/Form/Input.vue';
import FormTextArea from 'v3/components/Form/Textarea.vue';
import SubmitButton from 'dashboard/components/buttons/FormSubmitButton.vue';
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
export default {
components: {
WithLabel,
FormInput,
SubmitButton,
FormTextArea,
},
mixins: [validationMixin, alertMixin],
data() {
return {
avatarUrl: '',
avatarFile: '',
fullName: '',
displayName: '',
phoneNumber: '',
signature: '',
};
},
validations: {
fullName: {
required,
minLength: minLength(2),
},
displayName: {
minLength: minLength(2),
},
phoneNumber: {
validOrNoPhone: value =>
!helpers.req(value) || parsePhoneNumber(value).isValid(),
},
},
computed: {
...mapGetters({
getAccount: 'accounts/getAccount',
currentUser: 'getCurrentUser',
}),
intelligentData() {
const { clearbit_data: data } = this.getAccount;
return data;
},
},
watch: {
currentUser(user = {}) {
if (!user.name && this.intelligentData && this.intelligentData.name) {
this.fullName = this.intelligentData.name;
}
},
},
mounted() {
const {
email,
name: fullName,
avatar_url: avatarUrl,
display_name: displayName,
message_signature: signature,
} = this.currentUser || {};
if (!fullName && this.intelligentData && this.intelligentData.name) {
this.fullName = this.intelligentData.name;
} else {
this.fullName = fullName;
}
this.displayName = displayName;
this.signature = signature;
this.avatarUrl =
avatarUrl ||
`https://gravatar.com/avatar/${md5(email)}?s=400&d=robohash&r=x`;
},
methods: {
async onSubmit(event) {
event.stopPropagation();
event.preventDefault();
this.$v.$touch();
if (this.$v.$invalid) {
return;
}
try {
await this.$store.dispatch('updateProfile', {
name: this.name,
displayName: this.displayName,
phone_number: this.phoneNumber,
avatar: this.avatarFile,
message_signature: this.signature || '',
});
this.showAlert(this.$t('ONBOARDING.PROFILE.SUBMIT.SUCCESS'));
document.startViewTransition(() => {
this.$router.push({ name: 'onboarding_setup_company' });
});
} catch (error) {
this.showAlert(this.$t('ONBOARDING.PROFILE.SUBMIT.ERROR'));
}
},
async deleteAvatar(event) {
event.stopPropagation();
event.preventDefault();
const { email } = this.currentUser;
this.avatarUrl = `https://gravatar.com/avatar/${md5(
email
)}?s=400&d=robohash&r=x`;
this.avatarFile = '';
this.$refs.imageUpload.value = '';
},
handleImageUpload() {
const file = this.$refs.imageUpload.files[0];
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
this.avatarFile = file;
this.avatarUrl = URL.createObjectURL(file);
} else {
this.showAlert(
this.$t(
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SIZE_ERROR',
{
size: MAXIMUM_FILE_UPLOAD_SIZE,
}
)
);
}
this.$refs.imageUpload.value = '';
},
onFileBrowse(event) {
event.preventDefault();
const fileInputElement = this.$refs.imageUpload;
fileInputElement.click();
},
},
};
</script>
+2 -2
View File
@@ -1,13 +1,13 @@
class Account::ContactsExportJob < ApplicationJob
queue_as :low
def perform(account_id, column_names)
def perform(account_id, column_names, email_to)
account = Account.find(account_id)
headers = valid_headers(column_names)
generate_csv(account, headers)
file_url = account_contact_export_url(account)
AdministratorNotifications::ChannelNotificationsMailer.with(account: account).contact_export_complete(file_url)&.deliver_later
AdministratorNotifications::ChannelNotificationsMailer.with(account: account).contact_export_complete(file_url, email_to)&.deliver_later
end
def generate_csv(account, headers)
@@ -2,9 +2,24 @@ class Notification::ReopenSnoozedNotificationsJob < ApplicationJob
queue_as :low
def perform
# rubocop:disable Rails/SkipsModelValidations
Notification.where(snoozed_until: 3.days.ago..Time.current)
.update_all(snoozed_until: nil, updated_at: Time.current, last_activity_at: Time.current)
# rubocop:enable Rails/SkipsModelValidations
Notification.where(snoozed_until: 3.days.ago..Time.current).find_in_batches(batch_size: 100) do |notifications_batch|
notifications_batch.each do |notification|
update_notification(notification)
end
end
end
private
def update_notification(notification)
updated_meta = (notification.meta || {}).merge('last_snoozed_at' => notification.snoozed_until)
notification.update!(
snoozed_until: nil,
updated_at: Time.current,
last_activity_at: Time.current,
meta: updated_meta,
read_at: nil
)
end
end
@@ -60,12 +60,13 @@ class AdministratorNotifications::ChannelNotificationsMailer < ApplicationMailer
send_mail_with_liquid(to: admin_emails, subject: subject) and return
end
def contact_export_complete(file_url)
def contact_export_complete(file_url, email_to)
return unless smtp_config_set_or_development?
@action_url = file_url
subject = "Your contact's export file is available to download."
send_mail_with_liquid(to: admin_emails, subject: subject) and return
send_mail_with_liquid(to: email_to, subject: subject) and return
end
private
+3 -2
View File
@@ -111,11 +111,12 @@ class Account < ApplicationRecord
end
def inbound_email_domain
domain || GlobalConfig.get('MAILER_INBOUND_EMAIL_DOMAIN')['MAILER_INBOUND_EMAIL_DOMAIN'] || ENV.fetch('MAILER_INBOUND_EMAIL_DOMAIN', false)
domain.presence || GlobalConfig.get('MAILER_INBOUND_EMAIL_DOMAIN')['MAILER_INBOUND_EMAIL_DOMAIN'] || ENV.fetch('MAILER_INBOUND_EMAIL_DOMAIN',
false)
end
def support_email
super || ENV.fetch('MAILER_SENDER_EMAIL') { GlobalConfig.get('MAILER_SUPPORT_EMAIL')['MAILER_SUPPORT_EMAIL'] }
super.presence || ENV.fetch('MAILER_SENDER_EMAIL') { GlobalConfig.get('MAILER_SUPPORT_EMAIL')['MAILER_SUPPORT_EMAIL'] }
end
def usage_limits
+3 -9
View File
@@ -4,6 +4,7 @@
#
# id :bigint not null, primary key
# last_activity_at :datetime
# meta :jsonb
# notification_type :integer not null
# primary_actor_type :string not null
# read_at :datetime
@@ -63,24 +64,17 @@ class Notification < ApplicationRecord
created_at: created_at.to_i,
last_activity_at: last_activity_at.to_i,
snoozed_until: snoozed_until,
meta: meta,
account_id: account_id
}
if primary_actor.present?
payload[:primary_actor] = primary_actor_data
payload[:primary_actor] = primary_actor&.push_event_data
payload[:push_message_title] = push_message_title
end
payload
end
def primary_actor_data
{
id: primary_actor.push_event_data[:id],
meta: primary_actor.push_event_data[:meta],
inbox_id: primary_actor.push_event_data[:inbox_id]
}
end
def fcm_push_data
{
id: id,
+1
View File
@@ -3,6 +3,7 @@ class ActionService
def initialize(conversation)
@conversation = conversation.reload
@account = @conversation.account
end
def mute_conversation(_params)
@@ -21,6 +21,7 @@ json.data do
json.created_at notification.created_at.to_i
json.last_activity_at notification.last_activity_at.to_i
json.snoozed_until notification.snoozed_until
json.meta notification.meta
end
end
end
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '3.5.2'
version: '3.6.0'
development:
<<: *shared
+5
View File
@@ -110,6 +110,11 @@
display_title: 'Instagram Verify Token'
description: 'The verify token used for Instagram Webhook'
locked: false
- name: FACEBOOK_API_VERSION
display_title: 'Facebook API Version'
description: 'Configure this if you want to use a different Facebook API version. Make sure its prefixed with `v`'
value: 'v17.0'
locked: false
- name: ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT
display_title: 'Enable human agent'
value: false
@@ -0,0 +1,5 @@
class AddMetaToNotifications < ActiveRecord::Migration[7.0]
def change
add_column :notifications, :meta, :jsonb, default: {}
end
end
@@ -0,0 +1,8 @@
class AddUniqueIndexToAppliedSlas < ActiveRecord::Migration[7.0]
def change
add_index :applied_slas,
[:account_id, :sla_policy_id, :conversation_id],
unique: true,
name: 'index_applied_slas_on_account_sla_policy_conversation'
end
end
+3 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2024_02_07_103014) do
ActiveRecord::Schema[7.0].define(version: 2024_02_16_055809) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -122,6 +122,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_02_07_103014) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "sla_status", default: 0
t.index ["account_id", "sla_policy_id", "conversation_id"], name: "index_applied_slas_on_account_sla_policy_conversation", unique: true
t.index ["account_id"], name: "index_applied_slas_on_account_id"
t.index ["conversation_id"], name: "index_applied_slas_on_conversation_id"
t.index ["sla_policy_id"], name: "index_applied_slas_on_sla_policy_id"
@@ -750,6 +751,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_02_07_103014) do
t.datetime "updated_at", null: false
t.datetime "snoozed_until"
t.datetime "last_activity_at", default: -> { "CURRENT_TIMESTAMP" }
t.jsonb "meta", default: {}
t.index ["account_id"], name: "index_notifications_on_account_id"
t.index ["last_activity_at"], name: "index_notifications_on_last_activity_at"
t.index ["primary_actor_type", "primary_actor_id"], name: "uniq_primary_actor_per_account_notifications"
@@ -2,12 +2,11 @@ module Enterprise::Api::V2::AccountsController
private
def fetch_account_and_user_info
data = fetch_from_clearbit
@data = fetch_from_clearbit
return if data.blank?
return if @data.blank?
update_user_info(data)
update_account_info(data)
update_user_info
end
def fetch_from_clearbit
@@ -17,20 +16,25 @@ module Enterprise::Api::V2::AccountsController
nil
end
def update_user_info(data)
@user.update!(name: data[:name])
def update_user_info
@user.update!(name: @data[:name]) if @data[:name].present?
end
def update_account_info(data)
@account.update!(
name: data[:company_name],
custom_attributes: @account.custom_attributes.merge(
'industry' => data[:industry],
'company_size' => data[:company_size],
'timezone' => data[:timezone],
'logo' => data[:logo],
'onboarding_step' => 'account_update'
)
def data_from_clearbit
return {} if @data.blank?
{ name: @data[:company_name],
custom_attributes: {
'industry' => @data[:industry],
'company_size' => @data[:company_size],
'timezone' => @data[:timezone],
'logo' => @data[:logo]
} }
end
def account_attributes
super.deep_merge(
data_from_clearbit
)
end
end
@@ -2,7 +2,7 @@ class Sla::TriggerSlasForAccountsJob < ApplicationJob
queue_as :scheduled_jobs
def perform
Account.find_each do |account|
Account.joins(:sla_policies).distinct.find_each do |account|
Rails.logger.info "Enqueuing ProcessAccountAppliedSlasJob for account #{account.id}"
Sla::ProcessAccountAppliedSlasJob.perform_later(account)
end
+6 -3
View File
@@ -12,14 +12,17 @@
#
# Indexes
#
# index_applied_slas_on_account_id (account_id)
# index_applied_slas_on_conversation_id (conversation_id)
# index_applied_slas_on_sla_policy_id (sla_policy_id)
# index_applied_slas_on_account_id (account_id)
# index_applied_slas_on_account_sla_policy_conversation (account_id,sla_policy_id,conversation_id) UNIQUE
# index_applied_slas_on_conversation_id (conversation_id)
# index_applied_slas_on_sla_policy_id (sla_policy_id)
#
class AppliedSla < ApplicationRecord
belongs_to :account
belongs_to :sla_policy
belongs_to :conversation
validates :account_id, uniqueness: { scope: %i[sla_policy_id conversation_id] }
enum sla_status: { active: 0, hit: 1, missed: 2 }
end
@@ -1,10 +1,18 @@
module Enterprise::ActionService
def add_sla(sla_policy)
def add_sla(sla_policy_id)
return if sla_policy_id.blank?
sla_policy = @account.sla_policies.find_by(id: sla_policy_id.first)
return if sla_policy.nil?
return if @conversation.sla_policy.present?
Rails.logger.info "SLA:: Adding SLA #{sla_policy.id} to conversation: #{@conversation.id}"
@conversation.update!(sla_policy_id: sla_policy.id)
create_applied_sla(sla_policy)
end
def create_applied_sla(sla_policy)
Rails.logger.info "SLA:: Creating Applied SLA for conversation: #{@conversation.id}"
AppliedSla.create!(
account_id: @conversation.account_id,
sla_policy_id: sla_policy.id,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
"version": "3.5.2",
"version": "3.6.0",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 55 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 55 KiB

@@ -1,5 +0,0 @@
<svg width="32" height="33" viewBox="0 0 32 33" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="waving_hand_FILL0_wght400_GRAD0_opsz24 1">
<path id="Vector" d="M14.3322 15.8336L23.7655 6.40029C24.0322 6.13363 24.3433 6.00029 24.6988 6.00029C25.0544 6.00029 25.3655 6.13363 25.6322 6.40029C25.8988 6.66696 26.0322 6.97807 26.0322 7.33363C26.0322 7.68918 25.8988 8.00029 25.6322 8.26696L16.2322 17.7003L14.3322 15.8336ZM17.6322 19.1336L26.0988 10.6336C26.3655 10.367 26.6822 10.2336 27.0488 10.2336C27.4155 10.2336 27.7322 10.367 27.9988 10.6336C28.2655 10.9003 28.3988 11.217 28.3988 11.5836C28.3988 11.9503 28.2655 12.267 27.9988 12.5336L19.5322 21.0003L17.6322 19.1336ZM7.03216 25.467C5.00994 23.4447 3.99883 21.0114 3.99883 18.167C3.99883 15.3225 5.00994 12.8892 7.03216 10.867L11.0322 6.86696L12.9988 8.83363C13.1544 8.98918 13.2877 9.15029 13.3988 9.31696C13.5099 9.48363 13.621 9.65585 13.7322 9.83363L18.6655 4.86696C18.9322 4.60029 19.2488 4.46696 19.6155 4.46696C19.9822 4.46696 20.2988 4.60029 20.5655 4.86696C20.8322 5.13363 20.9655 5.45029 20.9655 5.81696C20.9655 6.18363 20.8322 6.50029 20.5655 6.76696L14.7988 12.5336L11.9655 15.3336L12.5988 15.967C13.621 16.9892 14.1099 18.2114 14.0655 19.6336C14.021 21.0558 13.4766 22.2892 12.4322 23.3336L10.5322 21.467C11.0433 20.9558 11.3266 20.3503 11.3822 19.6503C11.4377 18.9503 11.2099 18.3447 10.6988 17.8336L9.13216 16.3003C8.86549 16.0336 8.73216 15.717 8.73216 15.3503C8.73216 14.9836 8.86549 14.667 9.13216 14.4003L11.0322 12.5336C11.2988 12.267 11.4322 11.9503 11.4322 11.5836C11.4322 11.217 11.2988 10.9003 11.0322 10.6336L8.89883 12.767C7.38772 14.2781 6.63216 16.0836 6.63216 18.1836C6.63216 20.2836 7.38772 22.0892 8.89883 23.6003C10.4099 25.1114 12.2211 25.867 14.3322 25.867C16.4433 25.867 18.2544 25.1114 19.7655 23.6003L27.7322 15.6003C27.9988 15.3336 28.3155 15.2003 28.6822 15.2003C29.0488 15.2003 29.3655 15.3336 29.6322 15.6003C29.8988 15.867 30.0322 16.1836 30.0322 16.5503C30.0322 16.917 29.8988 17.2336 29.6322 17.5003L21.6322 25.467C19.6099 27.4892 17.1766 28.5003 14.3322 28.5003C11.4877 28.5003 9.05438 27.4892 7.03216 25.467ZM22.6655 31.2003V28.5003C24.1322 28.5003 25.3877 27.9781 26.4322 26.9336C27.4766 25.8892 27.9988 24.6336 27.9988 23.167H30.6988C30.6988 25.3892 29.9155 27.2836 28.3488 28.8503C26.7822 30.417 24.8877 31.2003 22.6655 31.2003ZM1.29883 9.83363C1.29883 7.6114 2.08216 5.71696 3.64883 4.15029C5.21549 2.58363 7.10994 1.80029 9.33216 1.80029V4.50029C7.86549 4.50029 6.60994 5.02252 5.56549 6.06696C4.52105 7.1114 3.99883 8.36696 3.99883 9.83363H1.29883Z" fill="#1F93FF"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

@@ -50,8 +50,11 @@ RSpec.describe 'Contacts API', type: :request do
expect(response).to have_http_status(:success)
response_body = response.parsed_body
expect(response_body['payload'].first['email']).to eq(contact.email)
expect(response_body['payload'].first['contact_inboxes'].blank?).to be(true)
contact_emails = response_body['payload'].pluck('email')
contact_inboxes = response_body['payload'].pluck('contact_inboxes').flatten.compact
expect(contact_emails).to include(contact.email)
expect(contact_inboxes).to eq([])
end
it 'returns all contacts with company name desc order' do
@@ -194,7 +197,7 @@ RSpec.describe 'Contacts API', type: :request do
let(:admin) { create(:user, account: account, role: :administrator) }
it 'enqueues a contact export job' do
expect(Account::ContactsExportJob).to receive(:perform_later).with(account.id, nil).once
expect(Account::ContactsExportJob).to receive(:perform_later).with(account.id, nil, admin.email).once
get "/api/v1/accounts/#{account.id}/contacts/export",
headers: admin.create_new_auth_token,
@@ -204,7 +207,7 @@ RSpec.describe 'Contacts API', type: :request do
end
it 'enqueues a contact export job with sent_columns' do
expect(Account::ContactsExportJob).to receive(:perform_later).with(account.id, %w[phone_number email]).once
expect(Account::ContactsExportJob).to receive(:perform_later).with(account.id, %w[phone_number email], admin.email).once
get "/api/v1/accounts/#{account.id}/contacts/export",
headers: admin.create_new_auth_token,
@@ -29,6 +29,7 @@ RSpec.describe 'Notifications API', type: :request do
expect(response_json['data']['meta']['count']).to eq 2
# notification appear in descending order
expect(response_json['data']['payload'].first['id']).to eq notification2.id
expect(response_json['data']['payload'].first['primary_actor']).not_to be_nil
end
end
end
@@ -178,6 +179,7 @@ RSpec.describe 'Notifications API', type: :request do
expect(response).to have_http_status(:success)
expect(notification.reload.snoozed_until).not_to eq('')
expect(notification.reload.meta['last_snoozed_at']).to be_nil
end
end
end
@@ -213,6 +213,25 @@ RSpec.describe 'Accounts API', type: :request do
end
end
it 'updates onboarding step to invite_team if onboarding step is present in account custom attributes' do
account.update(custom_attributes: { onboarding_step: 'account_update' })
put "/api/v1/accounts/#{account.id}",
params: params,
headers: admin.create_new_auth_token,
as: :json
expect(account.reload.custom_attributes['onboarding_step']).to eq('invite_team')
end
it 'will not update onboarding step if onboarding step is not present in account custom attributes' do
put "/api/v1/accounts/#{account.id}",
params: params,
headers: admin.create_new_auth_token,
as: :json
expect(account.reload.custom_attributes['onboarding_step']).to be_nil
end
it 'Throws error 422' do
params[:name] = 'test' * 999
@@ -30,6 +30,20 @@ RSpec.describe 'Accounts API', type: :request do
end
end
it 'updates the onboarding step in custom attributes' do
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do
allow(account_builder).to receive(:perform).and_return([user, account])
params = { email: email, user: nil, locale: nil, password: 'Password1!' }
post api_v2_accounts_url,
params: params,
as: :json
expect(account.reload.custom_attributes['onboarding_step']).to eq('profile_update')
end
end
it 'calls ChatwootCaptcha' do
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do
captcha = double
@@ -41,5 +41,15 @@ RSpec.describe 'Agents API', type: :request do
expect(response.body).to include('Account limit exceeded. Please purchase more licenses')
end
end
context 'when onboarding step is present in account custom attributes' do
it 'removes onboarding step from account custom attributes' do
account.update(custom_attributes: { onboarding_step: 'completed' })
post "/api/v1/accounts/#{account.id}/agents/bulk_create", params: bulk_create_params, headers: admin.create_new_auth_token
expect(account.reload.custom_attributes).not_to include('onboarding_step')
end
end
end
end
@@ -51,6 +51,22 @@ RSpec.describe Enterprise::Api::V2::AccountsController, type: :request do
end
end
it 'updates the onboarding step in custom attributes' do
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do
allow(account_builder).to receive(:perform).and_return([user, account])
params = { email: email, user: nil, locale: nil, password: 'Password1!' }
post api_v2_accounts_url,
params: params,
as: :json
custom_attributes = account.custom_attributes
expect(custom_attributes['onboarding_step']).to eq('profile_update')
end
end
it 'handles errors when fetching data from clearbit' do
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do
allow(account_builder).to receive(:perform).and_return([user, account])
@@ -1,16 +1,25 @@
require 'rails_helper'
RSpec.describe Sla::TriggerSlasForAccountsJob do
context 'when perform is called' do
let(:account) { create(:account) }
let(:account_with_sla) { create(:account) }
let(:account_without_sla) { create(:account) }
before do
create(:sla_policy, account: account_with_sla)
end
it 'enqueues the job' do
expect { described_class.perform_later }.to have_enqueued_job(described_class)
.on_queue('scheduled_jobs')
end
it 'calls the ProcessAccountAppliedSlasJob' do
expect(Sla::ProcessAccountAppliedSlasJob).to receive(:perform_later).with(account).and_call_original
it 'calls the ProcessAccountAppliedSlasJob for accounts with SLA' do
expect(Sla::ProcessAccountAppliedSlasJob).to receive(:perform_later).with(account_with_sla).and_call_original
described_class.perform_now
end
it 'does not call the ProcessAccountAppliedSlasJob for accounts without SLA' do
expect(Sla::ProcessAccountAppliedSlasJob).not_to receive(:perform_later).with(account_without_sla)
described_class.perform_now
end
end
@@ -8,16 +8,42 @@ describe ActionService do
let(:conversation) { create(:conversation, account: account) }
let(:action_service) { described_class.new(conversation) }
it 'adds the sla policy to the conversation and create applied_sla entry' do
action_service.add_sla(sla_policy)
expect(conversation.reload.sla_policy_id).to eq(sla_policy.id)
context 'when sla_policy_id is present' do
it 'adds the sla policy to the conversation and create applied_sla entry' do
action_service.add_sla([sla_policy.id])
expect(conversation.reload.sla_policy_id).to eq(sla_policy.id)
# check if appliedsla table entry is created with matching attributes
applied_sla = AppliedSla.last
expect(applied_sla.account_id).to eq(account.id)
expect(applied_sla.sla_policy_id).to eq(sla_policy.id)
expect(applied_sla.conversation_id).to eq(conversation.id)
expect(applied_sla.sla_status).to eq('active')
# check if appliedsla table entry is created with matching attributes
applied_sla = AppliedSla.last
expect(applied_sla.account_id).to eq(account.id)
expect(applied_sla.sla_policy_id).to eq(sla_policy.id)
expect(applied_sla.conversation_id).to eq(conversation.id)
expect(applied_sla.sla_status).to eq('active')
end
end
context 'when sla_policy_id is not present' do
it 'does not add the sla policy to the conversation' do
action_service.add_sla(nil)
expect(conversation.reload.sla_policy_id).to be_nil
end
end
context 'when conversation already has a sla policy' do
it 'does not add the new sla policy to the conversation' do
existing_sla_policy = sla_policy
new_sla_policy = create(:sla_policy, account: account)
conversation.update!(sla_policy_id: existing_sla_policy.id)
action_service.add_sla([new_sla_policy.id])
expect(conversation.reload.sla_policy_id).to eq(existing_sla_policy.id)
end
end
context 'when sla_policy is not found' do
it 'does not add the sla policy to the conversation' do
action_service.add_sla([sla_policy.id + 1])
expect(conversation.reload.sla_policy_id).to be_nil
end
end
end
end
+10
View File
@@ -6,5 +6,15 @@ FactoryBot.define do
notification_type { 'conversation_assignment' }
user
account
read_at { nil }
snoozed_until { nil }
end
trait :read do
read_at { DateTime.now.utc - 3.days }
end
trait :snoozed do
snoozed_until { DateTime.now.utc + 3.days }
end
end
+24
View File
@@ -146,6 +146,30 @@ describe ConversationFinder do
end
end
context 'with updated_within' do
let(:params) { { updated_within: 20, assignee_type: 'unassigned', sort_by: 'created_at_asc' } }
it 'filters based on params, sort order but returns all conversations without pagination with in time range' do
# value of updated_within is in seconds
# write spec based on that
conversations = create_list(:conversation, 50, account: account,
inbox: inbox, assignee: nil,
updated_at: Time.now.utc - 30.seconds,
created_at: Time.now.utc - 30.seconds)
# update updated_at of 27 conversations to be with in 20 seconds
conversations[0..27].each do |conversation|
conversation.update(updated_at: Time.now.utc - 10.seconds)
end
result = conversation_finder.perform
# pagination is not applied
# filters are applied
# modified conversations + 1 conversation created during set up
expect(result[:conversations].length).to be 29
# ensure that the conversations are sorted by created_at
expect(result[:conversations].first.created_at).to be < result[:conversations].last.created_at
end
end
context 'with pagination' do
let(:params) { { status: 'open', assignee_type: 'me', page: 1 } }
+49 -73
View File
@@ -1,103 +1,79 @@
require 'rails_helper'
describe NotificationFinder do
subject(:notification_finder) { described_class.new(user, account, params) }
RSpec.describe NotificationFinder do
let!(:account) { create(:account) }
let!(:user) { create(:user, account: account) }
let(:notification_finder) { described_class.new(user, account, params) }
before do
create(:notification, account: account, user: user, updated_at: DateTime.now.utc + 1.day, last_activity_at: DateTime.now.utc + 1.day,
read_at: DateTime.now.utc)
create(:notification, account: account, user: user, snoozed_until: DateTime.now.utc + 3.days, updated_at: DateTime.now.utc,
last_activity_at: DateTime.now.utc)
create(:notification, account: account, user: user, updated_at: DateTime.now.utc + 2.days, last_activity_at: DateTime.now.utc + 2.days)
create(:notification, account: account, user: user, updated_at: DateTime.now.utc + 4.days, last_activity_at: DateTime.now.utc + 4.days,
notification_type: :conversation_creation, read_at: DateTime.now.utc)
create(:notification, account: account, user: user, updated_at: DateTime.now.utc + 5.days, last_activity_at: DateTime.now.utc + 5.days,
notification_type: :conversation_mention)
create(:notification, account: account, user: user, updated_at: DateTime.now.utc + 6.days, last_activity_at: DateTime.now.utc + 6.days,
notification_type: :participating_conversation_new_message)
create(:notification, :snoozed, account: account, user: user)
create_list(:notification, 2, :read, account: account, user: user)
create_list(:notification, 3, account: account, user: user)
end
describe '#perform' do
context 'when params are empty' do
describe '#notifications' do
subject { notification_finder.notifications }
context 'with default params (empty)' do
let(:params) { {} }
it 'returns all the notifications' do
result = notification_finder.perform
expect(result.length).to be 6
end
it 'orders notifications by last activity at' do
result = notification_finder.perform
expect(result.first.last_activity_at).to be > result.last.last_activity_at
end
it 'returns unread count' do
result = notification_finder.unread_count
expect(result).to be 4
end
it 'returns count' do
result = notification_finder.count
expect(result).to be 6
it 'returns all unread and unsnoozed notifications, ordered by last activity' do
expect(subject.size).to eq(3)
expect(subject).to match_array(subject.sort_by(&:last_activity_at).reverse)
end
end
context 'when snoozed param is passed' do
let(:params) { { status: 'snoozed' } }
context 'with params including read and snoozed statuses' do
let(:params) { { includes: %w[read snoozed] } }
it 'returns only snoozed notifications' do
result = notification_finder.perform
expect(result.length).to be 1
end
it 'returns unread count' do
result = notification_finder.unread_count
expect(result).to be 1
end
it 'returns count' do
result = notification_finder.count
expect(result).to be 1
it 'returns all notifications, including read and snoozed' do
expect(subject.size).to eq(6)
end
end
context 'when type read param is passed' do
let(:params) { { type: 'read' } }
context 'with params including only read status' do
let(:params) { { includes: ['read'] } }
it 'returns only read notifications' do
result = notification_finder.perform
expect(result.length).to be 2
end
it 'returns count' do
result = notification_finder.count
expect(result).to be 2
it 'returns all notifications expect the snoozed' do
expect(subject.size).to eq(5)
end
end
context 'when type read and snoozed param is passed' do
let(:params) { { type: 'read', status: 'snoozed' } }
context 'with params including only snoozed status' do
let(:params) { { includes: ['snoozed'] } }
it 'returns only read notifications' do
result = notification_finder.perform
expect(result.length).to be 0
end
it 'returns count' do
result = notification_finder.count
expect(result).to be 0
it 'rreturns all notifications only expect the read' do
expect(subject.size).to eq(4)
end
end
context 'when sort order is passed' do
context 'with ascending sort order' do
let(:params) { { sort_order: :asc } }
it 'returns notifications in ascending order' do
result = notification_finder.perform
expect(result.first.last_activity_at).to be < result.last.last_activity_at
it 'returns notifications in ascending order by last activity' do
expect(subject.first.last_activity_at).to be < subject.last.last_activity_at
end
end
end
describe 'counts' do
subject { notification_finder }
context 'without specific filters' do
let(:params) { {} }
it 'correctly reports unread and total counts' do
expect(subject.unread_count).to eq(3)
expect(subject.count).to eq(3)
end
end
context 'with filters applied' do
let(:params) { { includes: %w[read snoozed] } }
it 'adjusts counts based on included statuses' do
expect(subject.unread_count).to eq(4)
expect(subject.count).to eq(6)
end
end
end
@@ -24,17 +24,17 @@ RSpec.describe Account::ContactsExportJob do
allow(AdministratorNotifications::ChannelNotificationsMailer).to receive(:with).with(account: account).and_return(mailer)
allow(mailer).to receive(:contact_export_complete)
described_class.perform_now(account.id, [])
described_class.perform_now(account.id, [], 'test@test.com')
file_url = Rails.application.routes.url_helpers.rails_blob_url(account.contacts_export)
expect(account.contacts_export).to be_present
expect(file_url).to be_present
expect(mailer).to have_received(:contact_export_complete).with(file_url)
expect(mailer).to have_received(:contact_export_complete).with(file_url, 'test@test.com')
end
it 'generates valid data export file' do
described_class.perform_now(account.id, [])
described_class.perform_now(account.id, [], 'test@test.com')
csv_data = CSV.parse(account.contacts_export.download, headers: true)
emails = csv_data.pluck('email')
@@ -14,9 +14,13 @@ RSpec.describe Notification::ReopenSnoozedNotificationsJob do
it 'reopens snoozed notifications whose snooze until has passed' do
described_class.perform_now
snoozed_until = snoozed_till_5_minutes_ago.reload.snoozed_until
expect(snoozed_till_5_minutes_ago.reload.snoozed_until).to be_nil
expect(snoozed_till_tomorrow.reload.snoozed_until.to_date).to eq 1.day.from_now.to_date
expect(snoozed_indefinitely.reload.snoozed_until).to be_nil
expect(snoozed_indefinitely.reload.read_at).to be_nil
expect(snoozed_until).to eq(snoozed_till_5_minutes_ago.reload.meta['snoozed_until'])
end
end
end
@@ -10,7 +10,6 @@ RSpec.describe AdministratorNotifications::ChannelNotificationsMailer do
before do
allow(described_class).to receive(:new).and_return(class_instance)
allow(class_instance).to receive(:smtp_config_set_or_development?).and_return(true)
Account::ContactsExportJob.perform_now(account.id, [])
end
describe 'slack_disconnect' do
@@ -92,8 +91,8 @@ RSpec.describe AdministratorNotifications::ChannelNotificationsMailer do
end
describe 'contact_export_complete' do
let!(:file_url) { Rails.application.routes.url_helpers.rails_blob_url(account.contacts_export) }
let(:mail) { described_class.with(account: account).contact_export_complete(file_url).deliver_now }
let!(:file_url) { 'http://test.com/test' }
let(:mail) { described_class.with(account: account).contact_export_complete(file_url, administrator.email).deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq("Your contact's export file is available to download.")
+50
View File
@@ -47,6 +47,56 @@ RSpec.describe Account do
end
end
describe 'inbound_email_domain' do
let(:account) { create(:account) }
it 'returns the domain from inbox if inbox value is present' do
account.update(domain: 'test.com')
with_modified_env MAILER_INBOUND_EMAIL_DOMAIN: 'test2.com' do
expect(account.inbound_email_domain).to eq('test.com')
end
end
it 'returns the domain from ENV if inbox value is nil' do
account.update(domain: nil)
with_modified_env MAILER_INBOUND_EMAIL_DOMAIN: 'test.com' do
expect(account.inbound_email_domain).to eq('test.com')
end
end
it 'returns the domain from ENV if inbox value is empty string' do
account.update(domain: '')
with_modified_env MAILER_INBOUND_EMAIL_DOMAIN: 'test.com' do
expect(account.inbound_email_domain).to eq('test.com')
end
end
end
describe 'support_email' do
let(:account) { create(:account) }
it 'returns the support email from inbox if inbox value is present' do
account.update(support_email: 'support@chatwoot.com')
with_modified_env MAILER_SENDER_EMAIL: 'hello@chatwoot.com' do
expect(account.support_email).to eq('support@chatwoot.com')
end
end
it 'returns the support email from ENV if inbox value is nil' do
account.update(support_email: nil)
with_modified_env MAILER_SENDER_EMAIL: 'hello@chatwoot.com' do
expect(account.support_email).to eq('hello@chatwoot.com')
end
end
it 'returns the support email from ENV if inbox value is empty string' do
account.update(support_email: '')
with_modified_env MAILER_SENDER_EMAIL: 'hello@chatwoot.com' do
expect(account.support_email).to eq('hello@chatwoot.com')
end
end
end
context 'when after_destroy is called' do
it 'conv_dpid_seq and camp_dpid_seq_ are deleted' do
account = create(:account)