Compare commits

..
Author SHA1 Message Date
Nithin David Thomas 34a287eec2 fix: Removes default slot for right of label 2024-02-14 21:11:17 +05:30
Nithin David Thomas 54bd3c38d5 Reverts unwanted changes 2024-02-14 20:33:34 +05:30
Nithin David Thomas 55317c8fad Mereg changes feat/api-split-signup 2024-02-14 20:30:16 +05:30
Nithin David Thomas 3d841369da Reverting unwanted changes 2024-02-14 19:59:29 +05:30
Sojan f9900947dc chore: Data needed to store onboarding flow 2024-02-14 19:55:45 +05:30
Nithin David Thomas 11886a4e5e Exposes meta data of company to response 2024-02-14 19:38:57 +05:30
Nithin David Thomas ea94a1aff6 feat: API changes to support multi step user signup 2024-02-14 19:33:24 +05:30
Nithin David ThomasandGitHub 4e7c65f11c Merge branch 'develop' into feat/onboarding 2024-02-13 05:33:51 -08:00
Nithin David Thomas 77364eaa07 Fixes signup form 2024-02-13 09:17:42 +05:30
Nithin David Thomas 74f046be75 Review fixes 2024-02-13 08:55:56 +05:30
Nithin David Thomas 0d9a94e7f5 Review fixes 2024-02-13 07:34:18 +05:30
Nithin David ThomasandGitHub 6621130669 Merge branch 'develop' into feat/onboarding 2024-02-12 17:31:44 -08:00
Nithin David Thomas d7baaaf83b Fixes style issues 2024-02-12 20:08:36 +05:30
Nithin David ThomasandGitHub 08875ebe61 Merge branch 'develop' into feat/onboarding 2024-02-12 04:53:23 -08:00
Nithin David Thomas 55e1cd92ac Adds founders note 2024-02-12 18:20:21 +05:30
Nithin David ThomasandGitHub f777fbe0a2 feat: Adds onboarding pager for founders note (#8886) 2024-02-12 04:41:32 -08:00
Nithin David Thomas 922db887e8 Changes to smoothen page transitions 2024-02-12 18:09:43 +05:30
Nithin David ThomasandGitHub 2d69cd0fdb feat: Adds onboarding page for inviting team (#8885) 2024-02-08 05:48:12 -08:00
Nithin David ThomasandGitHub 89d013882f feat: Adds onboarding page for setup company. (#8884)
feat: Adds page for setup company
2024-02-08 05:47:50 -08:00
Nithin David ThomasandGitHub 0073876c4b feat: creates page for setting user profile (#8883) 2024-02-08 05:47:37 -08:00
Nithin David ThomasandGitHub 4d149e7091 feat: Changes page layout for signup page (#8887) 2024-02-08 02:09:24 -08:00
Nithin David ThomasandGitHub a649c2733f feat: Changes account creation to support multiple steps (#8859)
* feat: Changes account creation to support multiple steps

* Adds API changes and new routes

* Fixes type
2024-02-08 02:02:02 -08:00
Nithin David ThomasandGitHub 17cb938bc8 feat: Adds layout page to use with onboarding views (#8855)
* feat: Wizard step component to use with onboarding

* feat: Adds layout page to use with onboarding views

* Adds dark mode assets

* Updates to layout bg image

* Updates to translations
2024-02-08 01:48:27 -08:00
Nithin David ThomasandGitHub 551a6b3c02 feat: Form textarea component to use with onboarding form (#8851)
* feat: Form textarea component to use with onboarding form

* Update WithLabel.vue

* Review fixes
2024-02-08 01:46:37 -08:00
72 changed files with 2142 additions and 585 deletions
@@ -49,9 +49,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
end
end
# 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
# remove onboarding_step key in present in Current account custom attributes
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, Current.user.email)
Account::ContactsExportJob.perform_later(Current.account.id, column_names)
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,8 +54,7 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
end
def snooze
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]
@notification.update(snoozed_until: parse_date_time(params[:snoozed_until].to_s)) if params[:snoozed_until]
render json: @notification
end
@@ -45,8 +45,7 @@ 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.merge!(custom_attributes_params)
@account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update'
@account.custom_attributes['onboarding_step'] = 'invite_team' if custom_attributes_params[:onboarding_step].present?
@account.save!
end
@@ -22,7 +22,6 @@ class Api::V2::AccountsController < Api::BaseController
).perform
fetch_account_and_user_info
update_account_info if @account.present?
if @user
send_auth_headers(@user)
@@ -34,18 +33,6 @@ 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: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'),
FACEBOOK_API_VERSION: 'v14.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 FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT]
%w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT]
when 'email'
['MAILER_INBOUND_EMAIL_DOMAIN']
else
+1 -7
View File
@@ -169,12 +169,6 @@ class ConversationFinder
)
sort_by, sort_order = SORT_OPTIONS[params[:sort_by]] || SORT_OPTIONS['last_activity_at_desc']
@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
@conversations.send(sort_by, sort_order).page(current_page).per(ENV.fetch('CONVERSATION_RESULTS_PER_PAGE', '25').to_i)
end
end
+10 -14
View File
@@ -10,8 +10,8 @@ class NotificationFinder
set_up
end
def notifications
@notifications.page(current_page).per(RESULTS_PER_PAGE).order(last_activity_at: sort_order)
def perform
notifications
end
def unread_count
@@ -26,31 +26,27 @@ class NotificationFinder
def set_up
find_all_notifications
filter_snoozed_notifications
fitler_read_notifications
filter_by_read_status
filter_by_status
end
def find_all_notifications
@notifications = current_user.notifications.where(account_id: @current_account.id)
end
def filter_snoozed_notifications
@notifications = @notifications.where(snoozed_until: nil) unless type_included?('snoozed')
def filter_by_status
@notifications = @notifications.where('snoozed_until > ?', DateTime.now.utc) if params[:status] == 'snoozed'
end
def fitler_read_notifications
@notifications = @notifications.where(read_at: nil) unless type_included?('read')
end
def type_included?(type)
(params[:includes] || []).include?(type)
def filter_by_read_status
@notifications = @notifications.where.not(read_at: nil) if params[:type] == 'read'
end
def current_page
params[:page] || 1
end
def sort_order
params[:sort_order] || :desc
def notifications
@notifications.page(current_page).per(RESULTS_PER_PAGE).order(last_activity_at: params[:sort_order] || :desc)
end
end
@@ -7,13 +7,12 @@ 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,29 +10,4 @@ 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,36 +27,20 @@ describe('#NotificationAPI', () => {
window.axios = originalAxios;
});
describe('#get', () => {
it('generates the API call if both params are available', () => {
notificationsAPI.get({
page: 1,
status: 'snoozed',
type: 'read',
sortOrder: 'desc',
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/notifications', {
params: {
page: 1,
sort_order: 'desc',
includes: ['snoozed', 'read'],
},
});
it('#get', () => {
notificationsAPI.get({
page: 1,
status: 'read',
type: 'Conversation',
sortOrder: 'desc',
});
it('generates the API call if one of the params are available', () => {
notificationsAPI.get({
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/notifications', {
params: {
page: 1,
type: 'read',
sortOrder: 'desc',
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/notifications', {
params: {
page: 1,
sort_order: 'desc',
includes: ['read'],
},
});
status: 'read',
type: 'Conversation',
sort_order: 'desc',
},
});
});
@@ -1,6 +1,6 @@
<template>
<banner
v-if="shouldShowBanner"
v-if="false"
color-scheme="alert"
:banner-message="bannerMessage"
:action-button-label="actionButtonMessage"
@@ -34,6 +34,9 @@ export default {
actionButtonMessage() {
return this.$t('GENERAL_SETTINGS.OPEN_BILLING');
},
isOnboardingView() {
return this.$route.name.includes('onboarding_');
},
shouldShowBanner() {
if (!this.isOnChatwootCloud) {
return false;
@@ -43,6 +46,9 @@ export default {
return false;
}
if (this.isOnboardingView) {
return false;
}
return this.isPaymentPending();
},
},
@@ -29,7 +29,13 @@ 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-[9999] bg-slate-25 dark:bg-slate-700 rounded-md border border-solid border-slate-75 dark:border-slate-800 shadow-lg;
@apply z-50 bg-slate-25 dark:bg-slate-700 rounded-md border border-solid border-slate-75 dark:border-slate-800;
h5 {
@apply dark:text-slate-25 text-slate-800;
@@ -79,13 +79,7 @@
"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",
"CONFIRM": {
"TITLE": "Export Contacts",
"MESSAGE": "Are you sure you want to export all contacts?",
"YES": "Yes, Export",
"NO": "No, Cancel"
}
"ERROR_MESSAGE": "There was an error, please try again"
},
"DELETE_NOTE": {
"CONFIRM": {
@@ -40,5 +40,88 @@
},
"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,13 +113,6 @@
</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>
@@ -182,13 +175,8 @@ export default {
toggleImport() {
this.$emit('on-toggle-import');
},
async submitExport() {
const ok =
await this.$refs.confirmExportContactsDialog.showConfirmation();
if (ok) {
this.$emit('on-export-submit');
}
submitExport() {
this.$emit('on-export-submit');
},
submitSearch() {
this.$emit('on-search-submit');
@@ -8,6 +8,11 @@ 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: [
@@ -29,5 +34,31 @@ 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,6 +45,13 @@ 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 { sortComparator } from './helpers';
import { applyInboxPageFilters, sortComparator } from './helpers';
export const getters = {
getNotifications($state) {
@@ -6,8 +6,11 @@ export const getters = {
},
getFilteredNotifications: $state => filters => {
const sortOrder = filters.sortOrder === 'desc' ? 'newest' : 'oldest';
const sortedNotifications = Object.values($state.records).sort((n1, n2) =>
sortComparator(n1, n2, sortOrder)
const filteredNotifications = Object.values($state.records).filter(
notification => applyInboxPageFilters(notification, filters)
);
const sortedNotifications = filteredNotifications.sort((a, b) =>
sortComparator(a, b, sortOrder)
);
return sortedNotifications;
},
@@ -1,3 +1,31 @@
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,8 +34,6 @@ 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,4 +1,10 @@
import { sortComparator } from '../../notifications/helpers';
import {
filterByStatus,
filterByType,
filterByTypeAndStatus,
applyInboxPageFilters,
sortComparator,
} from '../../notifications/helpers';
const notifications = [
{
@@ -39,6 +45,126 @@ 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,6 +113,10 @@
"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",
@@ -122,6 +126,7 @@
"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",
@@ -192,6 +197,7 @@
"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",
+15 -3
View File
@@ -27,9 +27,7 @@ export const login = async ({
export const register = async creds => {
try {
const response = await wootAPI.post('api/v1/accounts.json', {
account_name: creds.accountName.trim(),
user_full_name: creds.fullName.trim(),
const response = await wootAPI.post('api/v2/accounts.json', {
email: creds.email,
password: creds.password,
h_captcha_client_response: creds.hCaptchaClientResponse,
@@ -42,6 +40,20 @@ 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,6 +12,7 @@
:variant="value === option.value ? '' : 'hollow'"
:color-scheme="value === option.value ? 'primary' : 'secondary'"
size="small"
type=""
@click="$emit('input', option.value)"
>
{{ option.label }}
+10 -3
View File
@@ -15,12 +15,14 @@
:value="value"
:rows="rows"
:class="{
'focus:outline-red-600 outline-red-600': hasError,
'dark:outline-slate-600 dark:focus:outline-woot-500 outline-slate-200 focus:outline-woot-500':
'focus:ring-red-600 ring-red-600': hasError,
'dark:ring-slate-600 dark:focus:ring-woot-500 ring-slate-200':
!hasError,
'px-3 py-3': spacing === 'base',
'px-3 py-2': spacing === 'compact',
'resize-none': !allowResize,
}"
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"
class="block w-full border-none rounded-xl shadow-sm appearance-none outline outline-1 outline-slate-200 dark:outline-slate-800 focus:outline-none focus:outline-0 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 focus:ring-2 focus:ring-woot-500 sm:text-sm sm:leading-6 dark:bg-slate-800"
@input="onInput"
@blur="$emit('blur')"
/>
@@ -73,6 +75,11 @@ export default {
type: String,
default: '',
},
spacing: {
type: String,
default: 'base',
validator: value => ['base', 'compact'].includes(value),
},
},
methods: {
onInput(e) {
+51 -38
View File
@@ -1,24 +1,53 @@
<template>
<div class="h-full w-full dark:bg-slate-900">
<div v-show="!isLoading" class="flex h-full">
<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="flex-1 min-h-[640px] inline-flex items-center h-full justify-center overflow-auto py-6"
>
<div class="px-8 max-w-[560px] w-full overflow-auto">
<div class="mb-4">
<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">
<img
:src="globalConfig.logo"
:src="globalConfig.logoThumbnail"
:alt="globalConfig.installationName"
class="h-8 w-auto block dark:hidden"
class="h-24 w-24 block"
:class="{ 'dark:hidden': globalConfig.logoDarkThumbnail }"
/>
<img
v-if="globalConfig.logoDark"
:src="globalConfig.logoDark"
v-if="globalConfig.logoDarkThumbnail"
:src="globalConfig.logoDarkThumbnail"
:alt="globalConfig.installationName"
class="h-8 w-auto hidden dark:block"
/>
</div>
<div class="mb-8">
<h2
class="mb-7 mt-6 text-left text-3xl font-medium text-slate-900 dark:text-woot-50"
class="text-center text-3xl tracking-wide font-medium text-slate-900 dark:text-woot-50"
>
{{ $t('REGISTER.TRY_WOOT') }}
</h2>
@@ -37,17 +66,6 @@
</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>
@@ -56,32 +74,27 @@
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,52 +1,30 @@
<template>
<div class="flex-1 overflow-auto px-1">
<form class="space-y-3" @submit.prevent="submit">
<div class="flex">
<div class="flex-1 px-1 overflow-auto">
<form class="space-y-8" @submit.prevent="submit">
<section class="space-y-3">
<form-input
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"
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.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"
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>
<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"
/>
</section>
<div v-if="globalConfig.hCaptchaSiteKey" class="mb-3">
<vue-hcaptcha
ref="hCaptcha"
@@ -83,15 +61,17 @@ 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 '../../../../../components/Form/Input.vue';
import FormInput from 'v3/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,
@@ -103,10 +83,8 @@ export default {
data() {
return {
credentials: {
accountName: '',
fullName: '',
email: '',
password: '',
email: 'Testing123@shivam.dev',
password: 'Testing123@shivam.dev',
hCaptchaClientResponse: '',
},
didCaptchaReset: false,
@@ -116,14 +94,6 @@ export default {
},
validations: {
credentials: {
accountName: {
required,
minLength: minLength(2),
},
fullName: {
required,
minLength: minLength(2),
},
email: {
required,
email,
@@ -180,8 +150,11 @@ export default {
}
this.isSignupInProgress = true;
try {
await register(this.credentials);
window.location = DEFAULT_REDIRECT_URL;
const {
data: { account_id: accountId },
} = await register(this.credentials);
window.location = generateOnboardingUrl(accountId);
} catch (error) {
let errorMessage =
error?.message || this.$t('REGISTER.API.ERROR_MESSAGE');
@@ -0,0 +1,107 @@
<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>
@@ -0,0 +1,138 @@
<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>
@@ -0,0 +1,166 @@
<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-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="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="{
'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,
'border-woot-500': (!isActive && isComplete) || isComplete,
'border-slate-200 dark:border-slate-600': !isActive && !isComplete,
'text-woot-500': isComplete,
}"
@@ -0,0 +1,196 @@
<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>
@@ -0,0 +1,256 @@
<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>
@@ -0,0 +1,272 @@
<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, email_to)
def perform(account_id, column_names)
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, email_to)&.deliver_later
AdministratorNotifications::ChannelNotificationsMailer.with(account: account).contact_export_complete(file_url)&.deliver_later
end
def generate_csv(account, headers)
@@ -2,24 +2,9 @@ class Notification::ReopenSnoozedNotificationsJob < ApplicationJob
queue_as :low
def perform
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
)
# 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
end
end
@@ -60,13 +60,12 @@ class AdministratorNotifications::ChannelNotificationsMailer < ApplicationMailer
send_mail_with_liquid(to: admin_emails, subject: subject) and return
end
def contact_export_complete(file_url, email_to)
def contact_export_complete(file_url)
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: email_to, subject: subject) and return
send_mail_with_liquid(to: admin_emails, subject: subject) and return
end
private
+2 -3
View File
@@ -111,12 +111,11 @@ class Account < ApplicationRecord
end
def inbound_email_domain
domain.presence || GlobalConfig.get('MAILER_INBOUND_EMAIL_DOMAIN')['MAILER_INBOUND_EMAIL_DOMAIN'] || ENV.fetch('MAILER_INBOUND_EMAIL_DOMAIN',
false)
domain || GlobalConfig.get('MAILER_INBOUND_EMAIL_DOMAIN')['MAILER_INBOUND_EMAIL_DOMAIN'] || ENV.fetch('MAILER_INBOUND_EMAIL_DOMAIN', false)
end
def support_email
super.presence || ENV.fetch('MAILER_SENDER_EMAIL') { GlobalConfig.get('MAILER_SUPPORT_EMAIL')['MAILER_SUPPORT_EMAIL'] }
super || ENV.fetch('MAILER_SENDER_EMAIL') { GlobalConfig.get('MAILER_SUPPORT_EMAIL')['MAILER_SUPPORT_EMAIL'] }
end
def usage_limits
+9 -3
View File
@@ -4,7 +4,6 @@
#
# 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
@@ -64,17 +63,24 @@ 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&.push_event_data
payload[:primary_actor] = primary_actor_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,7 +3,6 @@ class ActionService
def initialize(conversation)
@conversation = conversation.reload
@account = @conversation.account
end
def mute_conversation(_params)
@@ -21,7 +21,6 @@ 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.6.0'
version: '3.5.2'
development:
<<: *shared
-5
View File
@@ -110,11 +110,6 @@
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
@@ -1,5 +0,0 @@
class AddMetaToNotifications < ActiveRecord::Migration[7.0]
def change
add_column :notifications, :meta, :jsonb, default: {}
end
end
@@ -1,8 +0,0 @@
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
+1 -3
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_16_055809) do
ActiveRecord::Schema[7.0].define(version: 2024_02_07_103014) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -122,7 +122,6 @@ ActiveRecord::Schema[7.0].define(version: 2024_02_16_055809) 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"
@@ -751,7 +750,6 @@ ActiveRecord::Schema[7.0].define(version: 2024_02_16_055809) 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,11 +2,12 @@ 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
update_user_info(data)
update_account_info(data)
end
def fetch_from_clearbit
@@ -16,25 +17,20 @@ module Enterprise::Api::V2::AccountsController
nil
end
def update_user_info
@user.update!(name: @data[:name]) if @data[:name].present?
def update_user_info(data)
@user.update!(name: data[:name])
end
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
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'
)
)
end
end
@@ -2,7 +2,7 @@ class Sla::TriggerSlasForAccountsJob < ApplicationJob
queue_as :scheduled_jobs
def perform
Account.joins(:sla_policies).distinct.find_each do |account|
Account.find_each do |account|
Rails.logger.info "Enqueuing ProcessAccountAppliedSlasJob for account #{account.id}"
Sla::ProcessAccountAppliedSlasJob.perform_later(account)
end
+3 -6
View File
@@ -12,17 +12,14 @@
#
# Indexes
#
# 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)
# 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)
#
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,18 +1,10 @@
module Enterprise::ActionService
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}"
def add_sla(sla_policy)
@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.6.0",
"version": "3.5.2",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 55 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 55 KiB

@@ -0,0 +1,5 @@
<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>

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -50,11 +50,8 @@ RSpec.describe 'Contacts API', type: :request do
expect(response).to have_http_status(:success)
response_body = response.parsed_body
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([])
expect(response_body['payload'].first['email']).to eq(contact.email)
expect(response_body['payload'].first['contact_inboxes'].blank?).to be(true)
end
it 'returns all contacts with company name desc order' do
@@ -197,7 +194,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, admin.email).once
expect(Account::ContactsExportJob).to receive(:perform_later).with(account.id, nil).once
get "/api/v1/accounts/#{account.id}/contacts/export",
headers: admin.create_new_auth_token,
@@ -207,7 +204,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], admin.email).once
expect(Account::ContactsExportJob).to receive(:perform_later).with(account.id, %w[phone_number email]).once
get "/api/v1/accounts/#{account.id}/contacts/export",
headers: admin.create_new_auth_token,
@@ -29,7 +29,6 @@ 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
@@ -179,7 +178,6 @@ 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,25 +213,6 @@ 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,20 +30,6 @@ 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,15 +41,5 @@ 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,22 +51,6 @@ 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,25 +1,16 @@
require 'rails_helper'
RSpec.describe Sla::TriggerSlasForAccountsJob do
context 'when perform is called' do
let(:account_with_sla) { create(:account) }
let(:account_without_sla) { create(:account) }
before do
create(:sla_policy, account: account_with_sla)
end
let(:account) { create(:account) }
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 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)
it 'calls the ProcessAccountAppliedSlasJob' do
expect(Sla::ProcessAccountAppliedSlasJob).to receive(:perform_later).with(account).and_call_original
described_class.perform_now
end
end
@@ -8,42 +8,16 @@ describe ActionService do
let(:conversation) { create(:conversation, account: account) }
let(:action_service) { described_class.new(conversation) }
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)
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)
# 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
# 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
end
-10
View File
@@ -6,15 +6,5 @@ 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,30 +146,6 @@ 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 } }
+73 -49
View File
@@ -1,79 +1,103 @@
require 'rails_helper'
RSpec.describe NotificationFinder do
describe NotificationFinder do
subject(:notification_finder) { described_class.new(user, account, params) }
let!(:account) { create(:account) }
let!(:user) { create(:user, account: account) }
let(:notification_finder) { described_class.new(user, account, params) }
before do
create(:notification, :snoozed, account: account, user: user)
create_list(:notification, 2, :read, account: account, user: user)
create_list(:notification, 3, account: account, user: user)
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)
end
describe '#notifications' do
subject { notification_finder.notifications }
context 'with default params (empty)' do
describe '#perform' do
context 'when params are empty' do
let(:params) { {} }
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)
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
end
end
context 'with params including read and snoozed statuses' do
let(:params) { { includes: %w[read snoozed] } }
context 'when snoozed param is passed' do
let(:params) { { status: 'snoozed' } }
it 'returns all notifications, including read and snoozed' do
expect(subject.size).to eq(6)
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
end
end
context 'with params including only read status' do
let(:params) { { includes: ['read'] } }
context 'when type read param is passed' do
let(:params) { { type: 'read' } }
it 'returns all notifications expect the snoozed' do
expect(subject.size).to eq(5)
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
end
end
context 'with params including only snoozed status' do
let(:params) { { includes: ['snoozed'] } }
context 'when type read and snoozed param is passed' do
let(:params) { { type: 'read', status: 'snoozed' } }
it 'rreturns all notifications only expect the read' do
expect(subject.size).to eq(4)
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
end
end
context 'with ascending sort order' do
context 'when sort order is passed' do
let(:params) { { sort_order: :asc } }
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)
it 'returns notifications in ascending order' do
result = notification_finder.perform
expect(result.first.last_activity_at).to be < result.last.last_activity_at
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, [], 'test@test.com')
described_class.perform_now(account.id, [])
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, 'test@test.com')
expect(mailer).to have_received(:contact_export_complete).with(file_url)
end
it 'generates valid data export file' do
described_class.perform_now(account.id, [], 'test@test.com')
described_class.perform_now(account.id, [])
csv_data = CSV.parse(account.contacts_export.download, headers: true)
emails = csv_data.pluck('email')
@@ -14,13 +14,9 @@ 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,6 +10,7 @@ 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
@@ -91,8 +92,8 @@ RSpec.describe AdministratorNotifications::ChannelNotificationsMailer do
end
describe 'contact_export_complete' do
let!(:file_url) { 'http://test.com/test' }
let(:mail) { described_class.with(account: account).contact_export_complete(file_url, administrator.email).deliver_now }
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 }
it 'renders the subject' do
expect(mail.subject).to eq("Your contact's export file is available to download.")
-50
View File
@@ -47,56 +47,6 @@ 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)