Merge branch 'develop' into feat/add_read_replica_support

This commit is contained in:
Vishnu Narayanan
2024-05-07 12:33:32 +10:00
committed by GitHub
40 changed files with 400 additions and 1843 deletions
+3 -2
View File
@@ -10,14 +10,15 @@ class AsyncDispatcher < BaseDispatcher
def listeners
[
AutomationRuleListener.instance,
CampaignListener.instance,
CsatSurveyListener.instance,
HookListener.instance,
InstallationWebhookListener.instance,
NotificationListener.instance,
ParticipationListener.instance,
ReportingEventListener.instance,
WebhookListener.instance,
AutomationRuleListener.instance
WebhookListener.instance
]
end
end
@@ -12,7 +12,6 @@ h6 {
p {
text-rendering: optimizeLegibility;
word-spacing: 0.12em;
@apply mb-2 leading-[1.65] text-sm;
@@ -49,7 +49,7 @@
</template>
<script>
import { evaluateSLAStatus } from '../helpers/SLAHelper';
import { evaluateSLAStatus } from '@chatwoot/utils';
import SLAPopoverCard from './SLAPopoverCard.vue';
const REFRESH_INTERVAL = 60000;
@@ -137,7 +137,10 @@ export default {
}, REFRESH_INTERVAL);
},
updateSlaStatus() {
this.slaStatus = evaluateSLAStatus(this.appliedSLA, this.chat);
this.slaStatus = evaluateSLAStatus({
appliedSla: this.appliedSLA,
chat: this.chat,
});
},
openSlaPopover() {
if (!this.showExtendedInfo) return;
@@ -1,117 +0,0 @@
const calculateThreshold = (timeOffset, threshold) => {
// Calculate the time left for the SLA to breach or the time since the SLA has missed
if (threshold === null) return null;
const currentTime = Math.floor(Date.now() / 1000);
return timeOffset + threshold - currentTime;
};
const findMostUrgentSLAStatus = SLAStatuses => {
// Sort the SLAs based on the threshold and return the most urgent SLA
SLAStatuses.sort(
(sla1, sla2) => Math.abs(sla1.threshold) - Math.abs(sla2.threshold)
);
return SLAStatuses[0];
};
const formatSLATime = seconds => {
const units = {
y: 31536000, // 60 * 60 * 24 * 365
mo: 2592000, // 60 * 60 * 24 * 30
d: 86400, // 60 * 60 * 24
h: 3600, // 60 * 60
m: 60,
};
if (seconds < 60) {
return '1m';
}
// we will only show two parts, two max granularity's, h-m, y-d, d-h, m, but no seconds
const parts = [];
Object.keys(units).forEach(unit => {
const value = Math.floor(seconds / units[unit]);
if (seconds < 60 && parts.length > 0) return;
if (parts.length === 2) return;
if (value > 0) {
parts.push(value + unit);
seconds -= value * units[unit];
}
});
return parts.join(' ');
};
const createSLAObject = (
type,
{
sla_first_response_time_threshold: frtThreshold,
sla_next_response_time_threshold: nrtThreshold,
sla_resolution_time_threshold: rtThreshold,
created_at: createdAt,
} = {},
{
first_reply_created_at: firstReplyCreatedAt,
waiting_since: waitingSince,
status,
} = {}
) => {
// Mapping of breach types to their logic
const SLATypes = {
FRT: {
threshold: calculateThreshold(createdAt, frtThreshold),
// Check FRT only if threshold is not null and first reply hasn't been made
condition:
frtThreshold !== null &&
(!firstReplyCreatedAt || firstReplyCreatedAt === 0),
},
NRT: {
threshold: calculateThreshold(waitingSince, nrtThreshold),
// Check NRT only if threshold is not null, first reply has been made and we are waiting since
condition:
nrtThreshold !== null && !!firstReplyCreatedAt && !!waitingSince,
},
RT: {
threshold: calculateThreshold(createdAt, rtThreshold),
// Check RT only if the conversation is open and threshold is not null
condition: status === 'open' && rtThreshold !== null,
},
};
const SLAStatus = SLATypes[type];
return SLAStatus ? { ...SLAStatus, type } : null;
};
const evaluateSLAConditions = (appliedSla, chat) => {
// Filter out the SLA based on conditions and update the object with the breach status(icon, isSlaMissed)
const SLATypes = ['FRT', 'NRT', 'RT'];
return SLATypes.map(type => createSLAObject(type, appliedSla, chat))
.filter(SLAStatus => SLAStatus && SLAStatus.condition)
.map(SLAStatus => ({
...SLAStatus,
icon: SLAStatus.threshold <= 0 ? 'flame' : 'alarm',
isSlaMissed: SLAStatus.threshold <= 0,
}));
};
export const evaluateSLAStatus = (appliedSla, chat) => {
if (!appliedSla || !chat)
return { type: '', threshold: '', icon: '', isSlaMissed: false };
// Filter out the SLA and create the object for each breach
const SLAStatuses = evaluateSLAConditions(appliedSla, chat);
// Return the most urgent SLA which is latest to breach or has missed
const mostUrgent = findMostUrgentSLAStatus(SLAStatuses);
return mostUrgent
? {
type: mostUrgent.type,
threshold: formatSLATime(
mostUrgent.threshold <= 0
? -mostUrgent.threshold
: mostUrgent.threshold
),
icon: mostUrgent.icon,
isSlaMissed: mostUrgent.isSlaMissed,
}
: { type: '', threshold: '', icon: '', isSlaMissed: false };
};
@@ -1,150 +0,0 @@
import { evaluateSLAStatus } from '../SLAHelper';
beforeEach(() => {
jest
.spyOn(Date, 'now')
.mockImplementation(() => new Date('2024-01-01T00:00:00Z').getTime());
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('SLAHelper', () => {
describe('evaluateSLAStatus', () => {
it('returns an empty object when sla or chat is not present', () => {
expect(evaluateSLAStatus(null, null)).toEqual({
type: '',
threshold: '',
icon: '',
isSlaMissed: false,
});
});
// Case when FRT SLA is missed
it('correctly identifies a missed FRT SLA', () => {
const appliedSla = {
sla_first_response_time_threshold: 600,
sla_next_response_time_threshold: 1200,
sla_resolution_time_threshold: 1800,
created_at: 1704066540,
};
const chatMissed = {
first_reply_created_at: 0,
waiting_since: 0,
status: 'open',
};
expect(evaluateSLAStatus(appliedSla, chatMissed)).toEqual({
type: 'FRT',
threshold: '1m',
icon: 'flame',
isSlaMissed: true,
});
});
// Case when FRT SLA is not missed
it('correctly identifies an FRT SLA not yet breached', () => {
const appliedSla = {
sla_first_response_time_threshold: 600,
sla_next_response_time_threshold: 1200,
sla_resolution_time_threshold: 1800,
created_at: 1704066660,
};
const chatNotMissed = {
first_reply_created_at: 0,
waiting_since: 0,
status: 'open',
};
expect(evaluateSLAStatus(appliedSla, chatNotMissed)).toEqual({
type: 'FRT',
threshold: '1m',
icon: 'alarm',
isSlaMissed: false,
});
});
// Case when NRT SLA is missed
it('correctly identifies a missed NRT SLA', () => {
const appliedSla = {
sla_first_response_time_threshold: 600,
sla_next_response_time_threshold: 1200,
sla_resolution_time_threshold: 1800,
created_at: 1704065200,
};
const chatMissed = {
first_reply_created_at: 1704066200,
waiting_since: 1704065940,
status: 'open',
};
expect(evaluateSLAStatus(appliedSla, chatMissed)).toEqual({
type: 'NRT',
threshold: '1m',
icon: 'flame',
isSlaMissed: true,
});
});
// Case when NRT SLA is not missed
it('correctly identifies an NRT SLA not yet breached', () => {
const appliedSla = {
sla_first_response_time_threshold: 600,
sla_next_response_time_threshold: 1200,
sla_resolution_time_threshold: 1800,
created_at: 1704065200 - 2000,
};
const chatNotMissed = {
first_reply_created_at: 1704066200,
waiting_since: 1704066060,
status: 'open',
};
expect(evaluateSLAStatus(appliedSla, chatNotMissed)).toEqual({
type: 'NRT',
threshold: '1m',
icon: 'alarm',
isSlaMissed: false,
});
});
// Case when RT SLA is missed
it('correctly identifies a missed RT SLA', () => {
const appliedSla = {
sla_first_response_time_threshold: 600,
sla_next_response_time_threshold: 1200,
sla_resolution_time_threshold: 1800,
created_at: 1704065340,
};
const chatMissed = {
first_reply_created_at: 1704066200,
waiting_since: 0,
status: 'open',
};
expect(evaluateSLAStatus(appliedSla, chatMissed)).toEqual({
type: 'RT',
threshold: '1m',
icon: 'flame',
isSlaMissed: true,
});
});
// Case when RT SLA is not missed
it('correctly identifies an RT SLA not yet breached', () => {
const appliedSla = {
sla_first_response_time_threshold: 600,
sla_next_response_time_threshold: 1200,
sla_resolution_time_threshold: 1800,
created_at: 1704065460,
};
const chatNotMissed = {
first_reply_created_at: 1704066200,
waiting_since: 0,
status: 'open',
};
expect(evaluateSLAStatus(appliedSla, chatNotMissed)).toEqual({
type: 'RT',
threshold: '1m',
icon: 'alarm',
isSlaMissed: false,
});
});
});
});
@@ -109,7 +109,8 @@
"SLA_MISSED_FIRST_RESPONSE": "A conversation misses first response SLA",
"SLA_MISSED_NEXT_RESPONSE": "A conversation misses next response SLA",
"SLA_MISSED_RESOLUTION": "A conversation misses resolution SLA"
}
},
"BROWSER_PERMISSION": "Enable push notifications for your browser so youre able to receive them"
},
"API": {
"UPDATE_SUCCESS": "Your notification preferences are updated successfully",
@@ -1,137 +0,0 @@
<template>
<form @submit.prevent="changePassword()">
<div class="flex flex-col w-full gap-4">
<woot-input
v-model="currentPassword"
type="password"
:styles="inputStyles"
:class="{ error: $v.currentPassword.$error }"
:label="$t('PROFILE_SETTINGS.FORM.CURRENT_PASSWORD.LABEL')"
:placeholder="$t('PROFILE_SETTINGS.FORM.CURRENT_PASSWORD.PLACEHOLDER')"
:error="`${
$v.currentPassword.$error
? $t('PROFILE_SETTINGS.FORM.CURRENT_PASSWORD.ERROR')
: ''
}`"
@input="$v.currentPassword.$touch"
/>
<woot-input
v-model="password"
type="password"
:styles="inputStyles"
:class="{ error: $v.password.$error }"
:label="$t('PROFILE_SETTINGS.FORM.PASSWORD.LABEL')"
:placeholder="$t('PROFILE_SETTINGS.FORM.PASSWORD.PLACEHOLDER')"
:error="`${
$v.password.$error ? $t('PROFILE_SETTINGS.FORM.PASSWORD.ERROR') : ''
}`"
@input="$v.password.$touch"
/>
<woot-input
v-model="passwordConfirmation"
type="password"
:styles="inputStyles"
:class="{ error: $v.passwordConfirmation.$error }"
:label="$t('PROFILE_SETTINGS.FORM.PASSWORD_CONFIRMATION.LABEL')"
:placeholder="
$t('PROFILE_SETTINGS.FORM.PASSWORD_CONFIRMATION.PLACEHOLDER')
"
:error="`${
$v.passwordConfirmation.$error
? $t('PROFILE_SETTINGS.FORM.PASSWORD_CONFIRMATION.ERROR')
: ''
}`"
@input="$v.passwordConfirmation.$touch"
/>
<form-button
type="submit"
color-scheme="primary"
variant="solid"
size="large"
:disabled="isButtonDisabled"
>
{{ $t('PROFILE_SETTINGS.FORM.PASSWORD_SECTION.BTN_TEXT') }}
</form-button>
</div>
</form>
</template>
<script>
import { required, minLength } from 'vuelidate/lib/validators';
import alertMixin from 'shared/mixins/alertMixin';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import FormButton from 'v3/components/Form/Button.vue';
export default {
components: {
FormButton,
},
mixins: [alertMixin],
data() {
return {
currentPassword: '',
password: '',
passwordConfirmation: '',
isPasswordChanging: false,
errorMessage: '',
inputStyles: {
borderRadius: '12px',
padding: '6px 12px',
fontSize: '14px',
marginBottom: '2px',
},
};
},
validations: {
currentPassword: {
required,
},
password: {
minLength: minLength(6),
},
passwordConfirmation: {
minLength: minLength(6),
isEqPassword(value) {
if (value !== this.password) {
return false;
}
return true;
},
},
},
computed: {
isButtonDisabled() {
return (
!this.currentPassword ||
!this.passwordConfirmation ||
!this.$v.passwordConfirmation.isEqPassword
);
},
},
methods: {
async changePassword() {
this.$v.$touch();
if (this.$v.$invalid) {
this.showAlert(this.$t('PROFILE_SETTINGS.FORM.ERROR'));
return;
}
let alertMessage = this.$t('PROFILE_SETTINGS.PASSWORD_UPDATE_SUCCESS');
try {
await this.$store.dispatch('updateProfile', {
password: this.password,
password_confirmation: this.passwordConfirmation,
current_password: this.currentPassword,
});
} catch (error) {
alertMessage =
parseAPIErrorResponse(error) ||
this.$t('RESET_PASSWORD.API.ERROR_MESSAGE');
} finally {
this.showAlert(alertMessage);
}
},
},
};
</script>
@@ -1,264 +0,0 @@
<template>
<div class="flex items-center w-full overflow-y-auto">
<div class="flex flex-col h-full p-5 pt-16 mx-auto my-0 font-inter">
<div class="flex flex-col gap-16 pb-8 sm:max-w-[720px]">
<div class="flex flex-col gap-6">
<h2 class="mt-4 text-2xl font-medium text-ash-900">
{{ $t('PROFILE_SETTINGS.TITLE') }}
</h2>
<user-profile-picture
:src="avatarUrl"
:name="name"
size="72px"
@change="updateProfilePicture"
@delete="deleteProfilePicture"
/>
<user-basic-details
:name="name"
:display-name="displayName"
:email="email"
:email-enabled="!globalConfig.disableUserProfileUpdate"
@update-user="updateProfile"
/>
</div>
<form-section
:title="$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.TITLE')"
:description="
$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.NOTE')
"
>
<message-signature
:message-signature="messageSignature"
@update-signature="updateSignature"
/>
</form-section>
<form-section
:title="$t('PROFILE_SETTINGS.FORM.SEND_MESSAGE.TITLE')"
:description="$t('PROFILE_SETTINGS.FORM.SEND_MESSAGE.NOTE')"
>
<div
class="flex flex-col justify-between w-full gap-5 sm:gap-4 sm:flex-row"
>
<button
v-for="hotKey in hotKeys"
:key="hotKey.key"
class="px-0 reset-base"
>
<hot-key-card
:key="hotKey.title"
:title="hotKey.title"
:description="hotKey.description"
:light-image="hotKey.lightImage"
:dark-image="hotKey.darkImage"
:active="isEditorHotKeyEnabled(uiSettings, hotKey.key)"
@click="toggleHotKey(hotKey.key)"
/>
</button>
</div>
</form-section>
<form-section
:title="$t('PROFILE_SETTINGS.FORM.PASSWORD_SECTION.TITLE')"
>
<change-password v-if="!globalConfig.disableUserProfileUpdate" />
</form-section>
<form-section
:title="$t('PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.TITLE')"
:description="
$t('PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.NOTE')
"
>
<audio-notifications />
</form-section>
<form-section :title="$t('PROFILE_SETTINGS.FORM.NOTIFICATIONS.TITLE')">
<notification-preferences />
</form-section>
<form-section
:title="$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.TITLE')"
:description="
useInstallationName(
$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.NOTE'),
globalConfig.installationName
)
"
>
<access-token
:value="currentUser.access_token"
@on-copy="onCopyToken"
/>
</form-section>
</div>
</div>
</div>
</template>
<script>
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import uiSettingsMixin, {
isEditorHotKeyEnabled,
} from 'dashboard/mixins/uiSettings';
import alertMixin from 'shared/mixins/alertMixin';
import { mapGetters } from 'vuex';
import { clearCookiesOnLogout } from 'dashboard/store/utils/api.js';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import UserProfilePicture from './UserProfilePicture.vue';
import UserBasicDetails from './UserBasicDetails.vue';
import MessageSignature from './MessageSignature.vue';
import HotKeyCard from './HotKeyCard.vue';
import ChangePassword from './ChangePassword.vue';
import NotificationPreferences from './NotificationPreferences.vue';
import AudioNotifications from './AudioNotifications.vue';
import FormSection from 'dashboard/components/FormSection.vue';
import AccessToken from './AccessToken.vue';
export default {
components: {
MessageSignature,
FormSection,
UserProfilePicture,
UserBasicDetails,
HotKeyCard,
ChangePassword,
NotificationPreferences,
AudioNotifications,
AccessToken,
},
mixins: [alertMixin, globalConfigMixin, uiSettingsMixin],
data() {
return {
avatarFile: '',
avatarUrl: '',
name: '',
displayName: '',
email: '',
messageSignature: '',
hotKeys: [
{
key: 'enter',
title: this.$t(
'PROFILE_SETTINGS.FORM.SEND_MESSAGE.CARD.ENTER_KEY.HEADING'
),
description: this.$t(
'PROFILE_SETTINGS.FORM.SEND_MESSAGE.CARD.ENTER_KEY.CONTENT'
),
lightImage: '/assets/images/dashboard/profile/hot-key-enter.svg',
darkImage: '/assets/images/dashboard/profile/hot-key-enter-dark.svg',
},
{
key: 'cmd_enter',
title: this.$t(
'PROFILE_SETTINGS.FORM.SEND_MESSAGE.CARD.CMD_ENTER_KEY.HEADING'
),
description: this.$t(
'PROFILE_SETTINGS.FORM.SEND_MESSAGE.CARD.CMD_ENTER_KEY.CONTENT'
),
lightImage: '/assets/images/dashboard/profile/hot-key-ctrl-enter.svg',
darkImage:
'/assets/images/dashboard/profile/hot-key-ctrl-enter-dark.svg',
},
],
};
},
computed: {
...mapGetters({
currentUser: 'getCurrentUser',
currentUserId: 'getCurrentUserID',
globalConfig: 'globalConfig/get',
}),
},
mounted() {
if (this.currentUserId) {
this.initializeUser();
}
},
methods: {
initializeUser() {
this.name = this.currentUser.name;
this.email = this.currentUser.email;
this.avatarUrl = this.currentUser.avatar_url;
this.displayName = this.currentUser.display_name;
this.messageSignature = this.currentUser.message_signature;
},
isEditorHotKeyEnabled,
async dispatchUpdate(payload, successMessage, errorMessage) {
let alertMessage = '';
try {
await this.$store.dispatch('updateProfile', payload);
alertMessage = successMessage;
return true; // return the value so that the status can be known
} catch (error) {
alertMessage = error?.response?.data?.error
? error.response.data.error
: errorMessage;
return false; // return the value so that the status can be known
} finally {
this.showAlert(alertMessage);
}
},
async updateProfile(userAttributes) {
const { name, email, displayName } = userAttributes;
const hasEmailChanged = this.currentUser.email !== email;
this.name = name || this.name;
this.email = email || this.email;
this.displayName = displayName || this.displayName;
const updatePayload = {
name: this.name,
email: this.email,
displayName: this.displayName,
avatar: this.avatarFile,
};
const success = await this.dispatchUpdate(
updatePayload,
hasEmailChanged
? this.$t('PROFILE_SETTINGS.AFTER_EMAIL_CHANGED')
: this.$t('PROFILE_SETTINGS.UPDATE_SUCCESS'),
this.$t('RESET_PASSWORD.API.ERROR_MESSAGE')
);
if (hasEmailChanged && success) clearCookiesOnLogout();
},
async updateSignature(signature) {
const payload = { message_signature: signature };
let successMessage = this.$t(
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.API_SUCCESS'
);
let errorMessage = this.$t(
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.API_ERROR'
);
await this.dispatchUpdate(payload, successMessage, errorMessage);
},
updateProfilePicture({ file, url }) {
this.avatarFile = file;
this.avatarUrl = url;
},
async deleteProfilePicture() {
try {
await this.$store.dispatch('deleteAvatar');
this.avatarUrl = '';
this.avatarFile = '';
this.showAlert(this.$t('PROFILE_SETTINGS.AVATAR_DELETE_SUCCESS'));
} catch (error) {
this.showAlert(this.$t('PROFILE_SETTINGS.AVATAR_DELETE_FAILED'));
}
},
toggleHotKey(key) {
this.hotKeys = this.hotKeys.map(hotKey =>
hotKey.key === key ? { ...hotKey, active: !hotKey.active } : hotKey
);
this.updateUISettings({ editor_message_key: key });
this.showAlert(
this.$t('PROFILE_SETTINGS.FORM.SEND_MESSAGE.UPDATE_SUCCESS')
);
},
async onCopyToken(value) {
await copyTextToClipboard(value);
this.showAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
},
},
};
</script>
@@ -1,50 +0,0 @@
<template>
<form class="flex flex-col gap-6" @submit.prevent="updateSignature()">
<woot-message-editor
id="message-signature-input"
v-model="signature"
class="message-editor h-[10rem] !px-3"
:is-format-mode="true"
:placeholder="$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.PLACEHOLDER')"
:enabled-menu-options="customEditorMenuList"
:enable-suggestions="false"
:show-image-resize-toolbar="true"
/>
<form-button
type="submit"
color-scheme="primary"
variant="solid"
size="large"
>
{{ $t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.BTN_TEXT') }}
</form-button>
</form>
</template>
<script setup>
import { ref, watch } from 'vue';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import { MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import FormButton from 'v3/components/Form/Button.vue';
const props = defineProps({
messageSignature: {
type: String,
default: '',
},
});
const customEditorMenuList = MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS;
const signature = ref(props.messageSignature);
const emit = defineEmits(['update-signature']);
watch(
() => props.messageSignature,
newValue => {
signature.value = newValue;
}
);
const updateSignature = () => {
emit('update-signature', signature.value);
};
</script>
@@ -1,20 +0,0 @@
import { frontendURL } from 'dashboard/helper/URLHelper';
const Index = () => import('./Index.vue');
export default {
routes: [
{
path: frontendURL('accounts/:accountId/personal'),
name: 'personal_settings',
roles: ['administrator', 'agent'],
component: Index,
props: {
headerTitle: 'PROFILE_SETTINGS.TITLE',
icon: 'edit',
showNewButton: false,
showSidemenuIcon: false,
},
},
],
};
@@ -16,7 +16,7 @@
<input
:id="`radio-${option.value}`"
v-model="selectedValue"
class="shadow cursor-pointer grid place-items-center border-2 border-ash-200 appearance-none rounded-full w-4 h-4 checked:bg-primary-600 before:content-[''] before:bg-primary-600 before:border-4 before:rounded-full before:border-ash-25 checked:before:w-[14px] checked:before:h-[14px] checked:border checked:border-primary-600"
class="shadow-sm cursor-pointer grid place-items-center border-2 border-ash-200 appearance-none rounded-full w-4 h-4 checked:bg-primary-600 before:content-[''] before:bg-primary-600 before:border-4 before:rounded-full before:border-ash-25 checked:before:w-[14px] checked:before:h-[14px] checked:border checked:border-primary-600"
type="radio"
:value="option.value"
/>
@@ -1,82 +1,73 @@
<template>
<form @submit.prevent="changePassword()">
<div
class="profile--settings--row text-black-900 dark:text-slate-300 flex items-center"
>
<div class="w-1/4">
<h4 class="text-lg text-black-900 dark:text-slate-200">
{{ $t('PROFILE_SETTINGS.FORM.PASSWORD_SECTION.TITLE') }}
</h4>
<p>{{ $t('PROFILE_SETTINGS.FORM.PASSWORD_SECTION.NOTE') }}</p>
</div>
<div class="w-[45%] p-4">
<woot-input
v-model="currentPassword"
type="password"
:class="{ error: $v.currentPassword.$error }"
:label="$t('PROFILE_SETTINGS.FORM.CURRENT_PASSWORD.LABEL')"
:placeholder="
$t('PROFILE_SETTINGS.FORM.CURRENT_PASSWORD.PLACEHOLDER')
"
:error="
$v.currentPassword.$error
? $t('PROFILE_SETTINGS.FORM.CURRENT_PASSWORD.ERROR')
: ''
"
@blur="$v.currentPassword.$touch"
/>
<div class="flex flex-col w-full gap-4">
<woot-input
v-model="currentPassword"
type="password"
:styles="inputStyles"
:class="{ error: $v.currentPassword.$error }"
:label="$t('PROFILE_SETTINGS.FORM.CURRENT_PASSWORD.LABEL')"
:placeholder="$t('PROFILE_SETTINGS.FORM.CURRENT_PASSWORD.PLACEHOLDER')"
:error="`${
$v.currentPassword.$error
? $t('PROFILE_SETTINGS.FORM.CURRENT_PASSWORD.ERROR')
: ''
}`"
@input="$v.currentPassword.$touch"
/>
<woot-input
v-model="password"
type="password"
:class="{ error: $v.password.$error }"
:label="$t('PROFILE_SETTINGS.FORM.PASSWORD.LABEL')"
:placeholder="$t('PROFILE_SETTINGS.FORM.PASSWORD.PLACEHOLDER')"
:error="
$v.password.$error ? $t('PROFILE_SETTINGS.FORM.PASSWORD.ERROR') : ''
"
@blur="$v.password.$touch"
/>
<woot-input
v-model="password"
type="password"
:styles="inputStyles"
:class="{ error: $v.password.$error }"
:label="$t('PROFILE_SETTINGS.FORM.PASSWORD.LABEL')"
:placeholder="$t('PROFILE_SETTINGS.FORM.PASSWORD.PLACEHOLDER')"
:error="`${
$v.password.$error ? $t('PROFILE_SETTINGS.FORM.PASSWORD.ERROR') : ''
}`"
@input="$v.password.$touch"
/>
<woot-input
v-model="passwordConfirmation"
type="password"
:class="{ error: $v.passwordConfirmation.$error }"
:label="$t('PROFILE_SETTINGS.FORM.PASSWORD_CONFIRMATION.LABEL')"
:placeholder="
$t('PROFILE_SETTINGS.FORM.PASSWORD_CONFIRMATION.PLACEHOLDER')
"
:error="
$v.passwordConfirmation.$error
? $t('PROFILE_SETTINGS.FORM.PASSWORD_CONFIRMATION.ERROR')
: ''
"
@blur="$v.passwordConfirmation.$touch"
/>
<woot-input
v-model="passwordConfirmation"
type="password"
:styles="inputStyles"
:class="{ error: $v.passwordConfirmation.$error }"
:label="$t('PROFILE_SETTINGS.FORM.PASSWORD_CONFIRMATION.LABEL')"
:placeholder="
$t('PROFILE_SETTINGS.FORM.PASSWORD_CONFIRMATION.PLACEHOLDER')
"
:error="`${
$v.passwordConfirmation.$error
? $t('PROFILE_SETTINGS.FORM.PASSWORD_CONFIRMATION.ERROR')
: ''
}`"
@input="$v.passwordConfirmation.$touch"
/>
<woot-button
:is-loading="isPasswordChanging"
type="submit"
:disabled="
!currentPassword ||
!passwordConfirmation ||
!$v.passwordConfirmation.isEqPassword
"
>
{{ $t('PROFILE_SETTINGS.FORM.PASSWORD_SECTION.BTN_TEXT') }}
</woot-button>
</div>
<form-button
type="submit"
color-scheme="primary"
variant="solid"
size="large"
:disabled="isButtonDisabled"
>
{{ $t('PROFILE_SETTINGS.FORM.PASSWORD_SECTION.BTN_TEXT') }}
</form-button>
</div>
</form>
</template>
<script>
import { required, minLength } from 'vuelidate/lib/validators';
import { mapGetters } from 'vuex';
import alertMixin from 'shared/mixins/alertMixin';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import FormButton from 'v3/components/Form/Button.vue';
export default {
components: {
FormButton,
},
mixins: [alertMixin],
data() {
return {
@@ -85,6 +76,12 @@ export default {
passwordConfirmation: '',
isPasswordChanging: false,
errorMessage: '',
inputStyles: {
borderRadius: '12px',
padding: '6px 12px',
fontSize: '14px',
marginBottom: '2px',
},
};
},
validations: {
@@ -105,10 +102,13 @@ export default {
},
},
computed: {
...mapGetters({
currentUser: 'getCurrentUser',
currentUserId: 'getCurrentUserID',
}),
isButtonDisabled() {
return (
!this.currentPassword ||
!this.passwordConfirmation ||
!this.$v.passwordConfirmation.isEqPassword
);
},
},
methods: {
async changePassword() {
@@ -117,38 +117,21 @@ export default {
this.showAlert(this.$t('PROFILE_SETTINGS.FORM.ERROR'));
return;
}
let alertMessage = this.$t('PROFILE_SETTINGS.PASSWORD_UPDATE_SUCCESS');
try {
await this.$store.dispatch('updateProfile', {
password: this.password,
password_confirmation: this.passwordConfirmation,
current_password: this.currentPassword,
});
this.errorMessage = this.$t('PROFILE_SETTINGS.PASSWORD_UPDATE_SUCCESS');
} catch (error) {
this.errorMessage =
alertMessage =
parseAPIErrorResponse(error) ||
this.$t('RESET_PASSWORD.API.ERROR_MESSAGE');
} finally {
this.isPasswordChanging = false;
this.showAlert(this.errorMessage);
this.showAlert(alertMessage);
}
},
},
};
</script>
<style lang="scss">
@import '~dashboard/assets/scss/mixins.scss';
.profile--settings--row {
@include border-normal-bottom;
padding: var(--space-normal);
.small-3 {
padding: var(--space-normal) var(--space-medium) var(--space-normal) 0;
}
.small-9 {
padding: var(--space-normal);
}
}
</style>
@@ -1,152 +1,116 @@
<template>
<div class="overflow-auto p-6">
<form @submit.prevent="updateUser('profile')">
<div
class="flex flex-row border-b border-slate-50 dark:border-slate-700 items-center flex p-4"
>
<div class="w-1/4 py-4 pr-6 ml-0">
<h4 class="text-lg text-black-900 dark:text-slate-200">
{{ $t('PROFILE_SETTINGS.FORM.PROFILE_SECTION.TITLE') }}
</h4>
<p>{{ $t('PROFILE_SETTINGS.FORM.PROFILE_SECTION.NOTE') }}</p>
</div>
<div class="p-4 w-[45%]">
<woot-avatar-uploader
:label="$t('PROFILE_SETTINGS.FORM.PROFILE_IMAGE.LABEL')"
:src="avatarUrl"
@change="handleImageUpload"
/>
<div v-if="showDeleteButton" class="avatar-delete-btn">
<woot-button
type="button"
color-scheme="alert"
variant="hollow"
size="small"
@click="deleteAvatar"
>
{{ $t('PROFILE_SETTINGS.DELETE_AVATAR') }}
</woot-button>
</div>
<label :class="{ error: $v.name.$error }">
{{ $t('PROFILE_SETTINGS.FORM.NAME.LABEL') }}
<input
v-model="name"
type="text"
:placeholder="$t('PROFILE_SETTINGS.FORM.NAME.PLACEHOLDER')"
@input="$v.name.$touch"
/>
<span v-if="$v.name.$error" class="message">
{{ $t('PROFILE_SETTINGS.FORM.NAME.ERROR') }}
</span>
</label>
<label :class="{ error: $v.displayName.$error }">
{{ $t('PROFILE_SETTINGS.FORM.DISPLAY_NAME.LABEL') }}
<input
v-model="displayName"
type="text"
:placeholder="
$t('PROFILE_SETTINGS.FORM.DISPLAY_NAME.PLACEHOLDER')
"
@input="$v.displayName.$touch"
/>
</label>
<label
v-if="!globalConfig.disableUserProfileUpdate"
:class="{ error: $v.email.$error }"
>
{{ $t('PROFILE_SETTINGS.FORM.EMAIL.LABEL') }}
<input
v-model.trim="email"
type="email"
:placeholder="$t('PROFILE_SETTINGS.FORM.EMAIL.PLACEHOLDER')"
@input="$v.email.$touch"
/>
<span v-if="$v.email.$error" class="message">
{{ $t('PROFILE_SETTINGS.FORM.EMAIL.ERROR') }}
</span>
</label>
<woot-button type="submit" :is-loading="isProfileUpdating">
{{ $t('PROFILE_SETTINGS.BTN_TEXT') }}
</woot-button>
</div>
</div>
</form>
<message-signature />
<div
class="border-b border-slate-50 dark:border-slate-700 items-center flex p-4 text-black-900 dark:text-slate-300 row"
<div class="grid py-16 px-5 font-inter mx-auto gap-16 sm:max-w-[720px]">
<div class="flex flex-col gap-6">
<h2 class="text-2xl font-medium text-ash-900">
{{ $t('PROFILE_SETTINGS.TITLE') }}
</h2>
<user-profile-picture
:src="avatarUrl"
:name="name"
size="72px"
@change="updateProfilePicture"
@delete="deleteProfilePicture"
/>
<user-basic-details
:name="name"
:display-name="displayName"
:email="email"
:email-enabled="!globalConfig.disableUserProfileUpdate"
@update-user="updateProfile"
/>
</div>
<form-section
:title="$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.TITLE')"
:description="$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.NOTE')"
>
<div class="w-1/4 py-4 pr-6 ml-0">
<h4 class="text-lg text-black-900 dark:text-slate-200">
{{ $t('PROFILE_SETTINGS.FORM.SEND_MESSAGE.TITLE') }}
</h4>
<p>
{{ $t('PROFILE_SETTINGS.FORM.SEND_MESSAGE.NOTE') }}
</p>
</div>
<div class="p-4 w-[45%] flex gap-4 flex-row">
<message-signature
:message-signature="messageSignature"
@update-signature="updateSignature"
/>
</form-section>
<form-section
:title="$t('PROFILE_SETTINGS.FORM.SEND_MESSAGE.TITLE')"
:description="$t('PROFILE_SETTINGS.FORM.SEND_MESSAGE.NOTE')"
>
<div
class="flex flex-col justify-between w-full gap-5 sm:gap-4 sm:flex-row"
>
<button
v-for="keyOption in keyOptions"
:key="keyOption.key"
class="cursor-pointer p-0"
@click="toggleEditorMessageKey(keyOption.key)"
v-for="hotKey in hotKeys"
:key="hotKey.key"
class="px-0 reset-base"
>
<preview-card
:heading="keyOption.heading"
:content="keyOption.content"
:src="keyOption.src"
:active="isEditorHotKeyEnabled(uiSettings, keyOption.key)"
<hot-key-card
:key="hotKey.title"
:title="hotKey.title"
:description="hotKey.description"
:light-image="hotKey.lightImage"
:dark-image="hotKey.darkImage"
:active="isEditorHotKeyEnabled(uiSettings, hotKey.key)"
@click="toggleHotKey(hotKey.key)"
/>
</button>
</div>
</div>
<change-password v-if="!globalConfig.disableUserProfileUpdate" />
<notification-settings />
<div
class="border-b border-slate-50 dark:border-slate-700 items-center flex p-4 text-black-900 dark:text-slate-300 row"
</form-section>
<form-section :title="$t('PROFILE_SETTINGS.FORM.PASSWORD_SECTION.TITLE')">
<change-password v-if="!globalConfig.disableUserProfileUpdate" />
</form-section>
<form-section
:title="$t('PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.TITLE')"
:description="
$t('PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.NOTE')
"
>
<div class="w-1/4 py-4 pr-6 ml-0">
<h4 class="text-lg text-black-900 dark:text-slate-200">
{{ $t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.TITLE') }}
</h4>
<p>
{{
useInstallationName(
$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.NOTE'),
globalConfig.installationName
)
}}
</p>
</div>
<div class="p-4 w-[45%]">
<masked-text :value="currentUser.access_token" />
</div>
</div>
<audio-notifications />
</form-section>
<form-section :title="$t('PROFILE_SETTINGS.FORM.NOTIFICATIONS.TITLE')">
<notification-preferences />
</form-section>
<form-section
:title="$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.TITLE')"
:description="
useInstallationName(
$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.NOTE'),
globalConfig.installationName
)
"
>
<access-token :value="currentUser.access_token" @on-copy="onCopyToken" />
</form-section>
</div>
</template>
<script>
import { required, minLength, email } from 'vuelidate/lib/validators';
import { mapGetters } from 'vuex';
import { clearCookiesOnLogout } from '../../../../store/utils/api';
import { hasValidAvatarUrl } from 'dashboard/helper/URLHelper';
import NotificationSettings from './NotificationSettings.vue';
import alertMixin from 'shared/mixins/alertMixin';
import ChangePassword from './ChangePassword.vue';
import MessageSignature from './MessageSignature.vue';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import uiSettingsMixin, {
isEditorHotKeyEnabled,
} from 'dashboard/mixins/uiSettings';
import MaskedText from 'dashboard/components/MaskedText.vue';
import PreviewCard from 'dashboard/components/ui/PreviewCard.vue';
import alertMixin from 'shared/mixins/alertMixin';
import { mapGetters } from 'vuex';
import { clearCookiesOnLogout } from 'dashboard/store/utils/api.js';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import UserProfilePicture from './UserProfilePicture.vue';
import UserBasicDetails from './UserBasicDetails.vue';
import MessageSignature from './MessageSignature.vue';
import HotKeyCard from './HotKeyCard.vue';
import ChangePassword from './ChangePassword.vue';
import NotificationPreferences from './NotificationPreferences.vue';
import AudioNotifications from './AudioNotifications.vue';
import FormSection from 'dashboard/components/FormSection.vue';
import AccessToken from './AccessToken.vue';
export default {
components: {
NotificationSettings,
ChangePassword,
MessageSignature,
PreviewCard,
MaskedText,
FormSection,
UserProfilePicture,
UserBasicDetails,
HotKeyCard,
ChangePassword,
NotificationPreferences,
AudioNotifications,
AccessToken,
},
mixins: [alertMixin, globalConfigMixin, uiSettingsMixin],
data() {
@@ -156,59 +120,40 @@ export default {
name: '',
displayName: '',
email: '',
isProfileUpdating: false,
errorMessage: '',
keyOptions: [
messageSignature: '',
hotKeys: [
{
key: 'enter',
src: '/assets/images/dashboard/editor/enter-editor.png',
heading: this.$t(
title: this.$t(
'PROFILE_SETTINGS.FORM.SEND_MESSAGE.CARD.ENTER_KEY.HEADING'
),
content: this.$t(
description: this.$t(
'PROFILE_SETTINGS.FORM.SEND_MESSAGE.CARD.ENTER_KEY.CONTENT'
),
lightImage: '/assets/images/dashboard/profile/hot-key-enter.svg',
darkImage: '/assets/images/dashboard/profile/hot-key-enter-dark.svg',
},
{
key: 'cmd_enter',
src: '/assets/images/dashboard/editor/cmd-editor.png',
heading: this.$t(
title: this.$t(
'PROFILE_SETTINGS.FORM.SEND_MESSAGE.CARD.CMD_ENTER_KEY.HEADING'
),
content: this.$t(
description: this.$t(
'PROFILE_SETTINGS.FORM.SEND_MESSAGE.CARD.CMD_ENTER_KEY.CONTENT'
),
lightImage: '/assets/images/dashboard/profile/hot-key-ctrl-enter.svg',
darkImage:
'/assets/images/dashboard/profile/hot-key-ctrl-enter-dark.svg',
},
],
};
},
validations: {
name: {
required,
minLength: minLength(1),
},
displayName: {},
email: {
required,
email,
},
},
computed: {
...mapGetters({
currentUser: 'getCurrentUser',
currentUserId: 'getCurrentUserID',
globalConfig: 'globalConfig/get',
}),
showDeleteButton() {
return hasValidAvatarUrl(this.avatarUrl);
},
},
watch: {
currentUserId(newCurrentUserId, prevCurrentUserId) {
if (prevCurrentUserId !== newCurrentUserId) {
this.initializeUser();
}
},
},
mounted() {
if (this.currentUserId) {
@@ -221,45 +166,66 @@ export default {
this.email = this.currentUser.email;
this.avatarUrl = this.currentUser.avatar_url;
this.displayName = this.currentUser.display_name;
this.messageSignature = this.currentUser.message_signature;
},
isEditorHotKeyEnabled,
async updateUser() {
this.$v.$touch();
if (this.$v.$invalid) {
this.showAlert(this.$t('PROFILE_SETTINGS.FORM.ERROR'));
return;
}
this.isProfileUpdating = true;
const hasEmailChanged = this.currentUser.email !== this.email;
async dispatchUpdate(payload, successMessage, errorMessage) {
let alertMessage = '';
try {
await this.$store.dispatch('updateProfile', {
name: this.name,
email: this.email,
avatar: this.avatarFile,
displayName: this.displayName,
});
this.isProfileUpdating = false;
if (hasEmailChanged) {
clearCookiesOnLogout();
this.errorMessage = this.$t('PROFILE_SETTINGS.AFTER_EMAIL_CHANGED');
}
this.errorMessage = this.$t('PROFILE_SETTINGS.UPDATE_SUCCESS');
await this.$store.dispatch('updateProfile', payload);
alertMessage = successMessage;
return true; // return the value so that the status can be known
} catch (error) {
this.errorMessage = this.$t('RESET_PASSWORD.API.ERROR_MESSAGE');
if (error?.response?.data?.error) {
this.errorMessage = error.response.data.error;
}
alertMessage = error?.response?.data?.error
? error.response.data.error
: errorMessage;
return false; // return the value so that the status can be known
} finally {
this.isProfileUpdating = false;
this.showAlert(this.errorMessage);
this.showAlert(alertMessage);
}
},
handleImageUpload({ file, url }) {
async updateProfile(userAttributes) {
const { name, email, displayName } = userAttributes;
const hasEmailChanged = this.currentUser.email !== email;
this.name = name || this.name;
this.email = email || this.email;
this.displayName = displayName || this.displayName;
const updatePayload = {
name: this.name,
email: this.email,
displayName: this.displayName,
avatar: this.avatarFile,
};
const success = await this.dispatchUpdate(
updatePayload,
hasEmailChanged
? this.$t('PROFILE_SETTINGS.AFTER_EMAIL_CHANGED')
: this.$t('PROFILE_SETTINGS.UPDATE_SUCCESS'),
this.$t('RESET_PASSWORD.API.ERROR_MESSAGE')
);
if (hasEmailChanged && success) clearCookiesOnLogout();
},
async updateSignature(signature) {
const payload = { message_signature: signature };
let successMessage = this.$t(
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.API_SUCCESS'
);
let errorMessage = this.$t(
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.API_ERROR'
);
await this.dispatchUpdate(payload, successMessage, errorMessage);
},
updateProfilePicture({ file, url }) {
this.avatarFile = file;
this.avatarUrl = url;
},
async deleteAvatar() {
async deleteProfilePicture() {
try {
await this.$store.dispatch('deleteAvatar');
this.avatarUrl = '';
@@ -269,12 +235,19 @@ export default {
this.showAlert(this.$t('PROFILE_SETTINGS.AVATAR_DELETE_FAILED'));
}
},
toggleEditorMessageKey(key) {
toggleHotKey(key) {
this.hotKeys = this.hotKeys.map(hotKey =>
hotKey.key === key ? { ...hotKey, active: !hotKey.active } : hotKey
);
this.updateUISettings({ editor_message_key: key });
this.showAlert(
this.$t('PROFILE_SETTINGS.FORM.SEND_MESSAGE.UPDATE_SUCCESS')
);
},
async onCopyToken(value) {
await copyTextToClipboard(value);
this.showAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
},
},
};
</script>
@@ -1,110 +1,50 @@
<template>
<div
class="profile--settings--row text-black-900 dark:text-slate-300 flex items-center"
>
<div class="w-1/4 py-4 pr-6 ml-0">
<h4 class="text-lg text-black-900 dark:text-slate-200">
{{ $t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.TITLE') }}
</h4>
<p>{{ $t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.NOTE') }}</p>
</div>
<div class="p-4 w-[45%]">
<div>
<label for="message-signature-input">{{
$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.LABEL')
}}</label>
<woot-message-editor
id="message-signature-input"
v-model="messageSignature"
class="message-editor h-[10rem]"
:is-format-mode="true"
:placeholder="
$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.PLACEHOLDER')
"
:enabled-menu-options="customEditorMenuList"
:enable-suggestions="false"
:show-image-resize-toolbar="true"
/>
</div>
<woot-button
:is-loading="isUpdating"
type="button"
@click.prevent="updateSignature"
>
{{ $t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.BTN_TEXT') }}
</woot-button>
</div>
</div>
<form class="flex flex-col gap-6" @submit.prevent="updateSignature()">
<woot-message-editor
id="message-signature-input"
v-model="signature"
class="message-editor h-[10rem] !px-3"
:is-format-mode="true"
:placeholder="$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.PLACEHOLDER')"
:enabled-menu-options="customEditorMenuList"
:enable-suggestions="false"
:show-image-resize-toolbar="true"
/>
<form-button
type="submit"
color-scheme="primary"
variant="solid"
size="large"
>
{{ $t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.BTN_TEXT') }}
</form-button>
</form>
</template>
<script>
import { mapGetters } from 'vuex';
<script setup>
import { ref, watch } from 'vue';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import alertMixin from 'shared/mixins/alertMixin';
import { MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import FormButton from 'v3/components/Form/Button.vue';
export default {
components: {
WootMessageEditor,
},
mixins: [alertMixin],
data() {
return {
messageSignature: '',
enableMessageSignature: false,
isUpdating: false,
errorMessage: '',
customEditorMenuList: MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS,
};
},
computed: {
...mapGetters({
currentUser: 'getCurrentUser',
currentUserId: 'getCurrentUserID',
}),
},
mounted() {
this.initValues();
},
methods: {
initValues() {
const { message_signature: messageSignature } = this.currentUser;
this.messageSignature = messageSignature || '';
},
async updateSignature() {
try {
await this.$store.dispatch('updateProfile', {
message_signature: this.messageSignature || '',
});
this.errorMessage = this.$t(
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.API_SUCCESS'
);
} catch (error) {
this.errorMessage = this.$t(
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.API_ERROR'
);
if (error?.response?.data?.message) {
this.errorMessage = error.response.data.message;
}
} finally {
this.isUpdating = false;
this.initValues();
this.showAlert(this.errorMessage);
}
},
const props = defineProps({
messageSignature: {
type: String,
default: '',
},
});
const customEditorMenuList = MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS;
const signature = ref(props.messageSignature);
const emit = defineEmits(['update-signature']);
watch(
() => props.messageSignature,
newValue => {
signature.value = newValue;
}
);
const updateSignature = () => {
emit('update-signature', signature.value);
};
</script>
<style lang="scss" scoped>
.message-editor {
@apply px-3 mb-4;
::v-deep {
.ProseMirror-menubar {
@apply left-2;
}
}
}
</style>
@@ -31,10 +31,6 @@
>
{{ $t('PROFILE_SETTINGS.FORM.NOTIFICATIONS.PUSH') }}
</span>
<form-switch
:value="hasEnabledPushPermissions"
@input="onRequestPermissions"
/>
</div>
</table-header-cell>
</div>
@@ -94,10 +90,6 @@
<span class="text-sm font-medium normal-case text-ash-900">
{{ $t('PROFILE_SETTINGS.FORM.PUSH_NOTIFICATIONS_SECTION.TITLE') }}
</span>
<form-switch
:value="hasEnabledPushPermissions"
@input="onRequestPermissions"
/>
</div>
<div class="flex flex-col gap-4">
@@ -116,6 +108,25 @@
</div>
</div>
</div>
<div
class="flex items-center justify-between w-full gap-2 p-4 border border-solid border-ash-200 rounded-xl"
>
<div class="flex flex-row items-center gap-2">
<fluent-icon
icon="alert"
class="flex-shrink-0 text-ash-900"
size="18"
/>
<span class="text-sm text-ash-900">
{{ $t('PROFILE_SETTINGS.FORM.NOTIFICATIONS.BROWSER_PERMISSION') }}
</span>
</div>
<form-switch
:value="hasEnabledPushPermissions"
@input="onRequestPermissions"
/>
</div>
</div>
</template>
@@ -139,7 +150,6 @@ export default {
components: {
TableHeaderCell,
FormSwitch,
CheckBox,
},
mixins: [alertMixin, configMixin, uiSettingsMixin],
@@ -1,649 +0,0 @@
<template>
<div id="profile-settings-notifications">
<div
class="profile--settings--row text-black-900 dark:text-slate-300 flex items-center"
>
<div class="w-1/4">
<h4 class="text-lg text-black-900 dark:text-slate-200">
{{ $t('PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.TITLE') }}
</h4>
<p>
{{ $t('PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.NOTE') }}
</p>
</div>
<div class="w-[45%] p-4">
<div class="mb-4">
<span class="text-sm notification-label">
{{
$t(
'PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.ALERT_TYPE.TITLE'
)
}}
</span>
<div class="flex items-center gap-2 mb-1">
<input
id="audio_enable_alert_none"
v-model="enableAudioAlerts"
class="notification--checkbox"
type="radio"
value="none"
@input="handleAudioInput"
/>
<label for="audio_enable_alert_none">
{{
$t(
'PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.ALERT_TYPE.NONE'
)
}}
</label>
</div>
<div class="flex items-center gap-2 mb-1">
<input
id="audio_enable_alert_mine"
v-model="enableAudioAlerts"
class="notification--checkbox"
type="radio"
value="mine"
@input="handleAudioInput"
/>
<label for="audio_enable_alert_mine">
{{
$t(
'PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.ALERT_TYPE.ASSIGNED'
)
}}
</label>
</div>
<div class="flex items-center gap-2 mb-1">
<input
id="audio_enable_alert_all"
v-model="enableAudioAlerts"
class="notification--checkbox"
type="radio"
value="all"
@input="handleAudioInput"
/>
<label for="audio_enable_alert_all">
{{
$t(
'PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.ALERT_TYPE.ALL_CONVERSATIONS'
)
}}
</label>
</div>
</div>
<div class="mb-4">
<span class="text-sm notification-label">
{{
$t(
'PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.DEFAULT_TONE.TITLE'
)
}}
</span>
<div>
<select
v-model="notificationTone"
class="tone-selector mb-0"
@change="handleAudioToneChange"
>
<option
v-for="tone in notificationAlertTones"
:key="tone.value"
:value="tone.value"
>
{{ tone.label }}
</option>
</select>
</div>
</div>
<div class="mb-1">
<span class="text-sm notification-label">
{{
$t(
'PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.CONDITIONS.TITLE'
)
}}
</span>
<div class="flex items-center gap-2 mb-1">
<input
id="audio_alert_when_tab_is_inactive"
v-model="playAudioWhenTabIsInactive"
class="notification--checkbox"
type="checkbox"
value="tab_is_inactive"
@input="handleAudioAlertConditions"
/>
<label for="audio_alert_when_tab_is_inactive">
{{
$t(
'PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.CONDITIONS.CONDITION_ONE'
)
}}
</label>
</div>
<div class="flex items-center gap-2 mb-1">
<input
id="audio_alert_until_all_conversations_are_read"
v-model="alertIfUnreadConversationExist"
class="notification--checkbox"
type="checkbox"
value="conversations_are_read"
@input="handleAudioAlertConditions"
/>
<label for="audio_alert_until_all_conversations_are_read">
{{
$t(
'PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.CONDITIONS.CONDITION_TWO'
)
}}
</label>
</div>
</div>
</div>
</div>
<div
class="profile--settings--row text-black-900 dark:text-slate-300 flex items-center"
>
<div class="w-1/4">
<h4 class="text-lg text-black-900 dark:text-slate-200">
{{ $t('PROFILE_SETTINGS.FORM.EMAIL_NOTIFICATIONS_SECTION.TITLE') }}
</h4>
<p>
{{ $t('PROFILE_SETTINGS.FORM.EMAIL_NOTIFICATIONS_SECTION.NOTE') }}
</p>
</div>
<div class="w-[45%] p-4">
<div class="flex items-center gap-2 mb-1">
<input
v-model="selectedEmailFlags"
class="notification--checkbox"
type="checkbox"
value="email_conversation_creation"
@input="handleEmailInput"
/>
<label for="conversation_creation">
{{
$t(
'PROFILE_SETTINGS.FORM.EMAIL_NOTIFICATIONS_SECTION.CONVERSATION_CREATION'
)
}}
</label>
</div>
<div class="flex items-center gap-2 mb-1">
<input
v-model="selectedEmailFlags"
class="notification--checkbox"
type="checkbox"
value="email_conversation_assignment"
@input="handleEmailInput"
/>
<label for="conversation_assignment">
{{
$t(
'PROFILE_SETTINGS.FORM.EMAIL_NOTIFICATIONS_SECTION.CONVERSATION_ASSIGNMENT'
)
}}
</label>
</div>
<div class="flex items-center gap-2 mb-1">
<input
v-model="selectedEmailFlags"
class="notification--checkbox"
type="checkbox"
value="email_conversation_mention"
@input="handleEmailInput"
/>
<label for="conversation_mention">
{{
$t(
'PROFILE_SETTINGS.FORM.EMAIL_NOTIFICATIONS_SECTION.CONVERSATION_MENTION'
)
}}
</label>
</div>
<div class="flex items-center gap-2 mb-1">
<input
v-model="selectedEmailFlags"
class="notification--checkbox"
type="checkbox"
value="email_assigned_conversation_new_message"
@input="handleEmailInput"
/>
<label for="assigned_conversation_new_message">
{{
$t(
'PROFILE_SETTINGS.FORM.EMAIL_NOTIFICATIONS_SECTION.ASSIGNED_CONVERSATION_NEW_MESSAGE'
)
}}
</label>
</div>
<div class="flex items-center gap-2 mb-1">
<input
v-model="selectedEmailFlags"
class="notification--checkbox"
type="checkbox"
value="email_participating_conversation_new_message"
@input="handleEmailInput"
/>
<label for="assigned_conversation_new_message">
{{
$t(
'PROFILE_SETTINGS.FORM.EMAIL_NOTIFICATIONS_SECTION.PARTICIPATING_CONVERSATION_NEW_MESSAGE'
)
}}
</label>
</div>
<div v-if="isSLAEnabled" class="flex items-center gap-2 mb-1">
<input
v-model="selectedEmailFlags"
class="notification--checkbox"
type="checkbox"
value="email_sla_missed_first_response"
@input="handleEmailInput"
/>
<label for="sla_missed_first_response">
{{
$t(
'PROFILE_SETTINGS.FORM.EMAIL_NOTIFICATIONS_SECTION.SLA_MISSED_FIRST_RESPONSE'
)
}}
</label>
</div>
<div v-if="isSLAEnabled" class="flex items-center gap-2 mb-1">
<input
v-model="selectedEmailFlags"
class="notification--checkbox"
type="checkbox"
value="email_sla_missed_next_response"
@input="handleEmailInput"
/>
<label for="sla_missed_next_response">
{{
$t(
'PROFILE_SETTINGS.FORM.EMAIL_NOTIFICATIONS_SECTION.SLA_MISSED_NEXT_RESPONSE'
)
}}
</label>
</div>
<div v-if="isSLAEnabled" class="flex items-center gap-2 mb-1">
<input
v-model="selectedEmailFlags"
class="notification--checkbox"
type="checkbox"
value="email_sla_missed_resolution"
@input="handleEmailInput"
/>
<label for="sla_missed_resolution">
{{
$t(
'PROFILE_SETTINGS.FORM.EMAIL_NOTIFICATIONS_SECTION.SLA_MISSED_RESOLUTION'
)
}}
</label>
</div>
</div>
</div>
<div
v-if="vapidPublicKey && hasPushAPISupport"
class="profile--settings--row text-black-900 dark:text-slate-300 flex items-center push-row"
>
<div class="w-1/4">
<h4 class="text-lg text-black-900 dark:text-slate-200">
{{ $t('PROFILE_SETTINGS.FORM.PUSH_NOTIFICATIONS_SECTION.TITLE') }}
</h4>
<p>{{ $t('PROFILE_SETTINGS.FORM.PUSH_NOTIFICATIONS_SECTION.NOTE') }}</p>
</div>
<div class="w-[45%] p-4">
<p v-if="hasEnabledPushPermissions">
{{
$t(
'PROFILE_SETTINGS.FORM.PUSH_NOTIFICATIONS_SECTION.HAS_ENABLED_PUSH'
)
}}
</p>
<div v-else class="push-notification--button">
<woot-submit-button
:button-text="
$t(
'PROFILE_SETTINGS.FORM.PUSH_NOTIFICATIONS_SECTION.REQUEST_PUSH'
)
"
class="button nice small"
type="button"
@click="onRequestPermissions"
/>
</div>
<div class="flex items-center gap-2 mb-1">
<input
v-model="selectedPushFlags"
class="notification--checkbox"
type="checkbox"
value="push_conversation_creation"
@input="handlePushInput"
/>
<label for="conversation_creation">
{{
$t(
'PROFILE_SETTINGS.FORM.PUSH_NOTIFICATIONS_SECTION.CONVERSATION_CREATION'
)
}}
</label>
</div>
<div class="flex items-center gap-2 mb-1">
<input
v-model="selectedPushFlags"
class="notification--checkbox"
type="checkbox"
value="push_conversation_assignment"
@input="handlePushInput"
/>
<label for="conversation_assignment">
{{
$t(
'PROFILE_SETTINGS.FORM.PUSH_NOTIFICATIONS_SECTION.CONVERSATION_ASSIGNMENT'
)
}}
</label>
</div>
<div class="flex items-center gap-2 mb-1">
<input
v-model="selectedPushFlags"
class="notification--checkbox"
type="checkbox"
value="push_conversation_mention"
@input="handlePushInput"
/>
<label for="conversation_mention">
{{
$t(
'PROFILE_SETTINGS.FORM.PUSH_NOTIFICATIONS_SECTION.CONVERSATION_MENTION'
)
}}
</label>
</div>
<div class="flex items-center gap-2 mb-1">
<input
v-model="selectedPushFlags"
class="notification--checkbox"
type="checkbox"
value="push_assigned_conversation_new_message"
@input="handlePushInput"
/>
<label for="assigned_conversation_new_message">
{{
$t(
'PROFILE_SETTINGS.FORM.PUSH_NOTIFICATIONS_SECTION.ASSIGNED_CONVERSATION_NEW_MESSAGE'
)
}}
</label>
</div>
<div class="flex items-center gap-2 mb-1">
<input
v-model="selectedPushFlags"
class="notification--checkbox"
type="checkbox"
value="push_participating_conversation_new_message"
@input="handlePushInput"
/>
<label for="assigned_conversation_new_message">
{{
$t(
'PROFILE_SETTINGS.FORM.PUSH_NOTIFICATIONS_SECTION.PARTICIPATING_CONVERSATION_NEW_MESSAGE'
)
}}
</label>
</div>
<div v-if="isSLAEnabled" class="flex items-center gap-2 mb-1">
<input
v-model="selectedPushFlags"
class="notification--checkbox"
type="checkbox"
value="push_sla_missed_first_response"
@input="handlePushInput"
/>
<label for="sla_missed_first_response">
{{
$t(
'PROFILE_SETTINGS.FORM.PUSH_NOTIFICATIONS_SECTION.SLA_MISSED_FIRST_RESPONSE'
)
}}
</label>
</div>
<div v-if="isSLAEnabled" class="flex items-center gap-2 mb-1">
<input
v-model="selectedPushFlags"
class="notification--checkbox"
type="checkbox"
value="push_sla_missed_next_response"
@input="handlePushInput"
/>
<label for="sla_missed_next_response">
{{
$t(
'PROFILE_SETTINGS.FORM.PUSH_NOTIFICATIONS_SECTION.SLA_MISSED_NEXT_RESPONSE'
)
}}
</label>
</div>
<div v-if="isSLAEnabled" class="flex items-center gap-2 mb-1">
<input
v-model="selectedPushFlags"
class="notification--checkbox"
type="checkbox"
value="push_sla_missed_resolution"
@input="handlePushInput"
/>
<label for="sla_missed_resolution">
{{
$t(
'PROFILE_SETTINGS.FORM.PUSH_NOTIFICATIONS_SECTION.SLA_MISSED_RESOLUTION'
)
}}
</label>
</div>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import alertMixin from 'shared/mixins/alertMixin';
import configMixin from 'shared/mixins/configMixin';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import {
hasPushPermissions,
requestPushPermissions,
verifyServiceWorkerExistence,
} from '../../../../helper/pushHelper';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
export default {
mixins: [alertMixin, configMixin, uiSettingsMixin],
data() {
return {
selectedEmailFlags: [],
selectedPushFlags: [],
enableAudioAlerts: false,
hasEnabledPushPermissions: false,
playAudioWhenTabIsInactive: false,
alertIfUnreadConversationExist: false,
notificationTone: 'ding',
notificationAlertTones: [
{
value: 'ding',
label: 'Ding',
},
{
value: 'bell',
label: 'Bell',
},
],
};
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
emailFlags: 'userNotificationSettings/getSelectedEmailFlags',
pushFlags: 'userNotificationSettings/getSelectedPushFlags',
uiSettings: 'getUISettings',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
hasPushAPISupport() {
return !!('Notification' in window);
},
isSLAEnabled() {
return this.isFeatureEnabledonAccount(this.accountId, FEATURE_FLAGS.SLA);
},
},
watch: {
emailFlags(value) {
this.selectedEmailFlags = value;
},
pushFlags(value) {
this.selectedPushFlags = value;
},
uiSettings(value) {
this.notificationUISettings(value);
},
},
mounted() {
if (hasPushPermissions()) {
this.getPushSubscription();
}
this.notificationUISettings(this.uiSettings);
this.$store.dispatch('userNotificationSettings/get');
},
methods: {
notificationUISettings(uiSettings) {
const {
enable_audio_alerts: enableAudio = false,
always_play_audio_alert: alwaysPlayAudioAlert,
alert_if_unread_assigned_conversation_exist:
alertIfUnreadConversationExist,
notification_tone: notificationTone,
} = uiSettings;
this.enableAudioAlerts = enableAudio;
this.playAudioWhenTabIsInactive = !alwaysPlayAudioAlert;
this.alertIfUnreadConversationExist = alertIfUnreadConversationExist;
this.notificationTone = notificationTone || 'ding';
},
onRegistrationSuccess() {
this.hasEnabledPushPermissions = true;
},
onRequestPermissions() {
requestPushPermissions({
onSuccess: this.onRegistrationSuccess,
});
},
getPushSubscription() {
verifyServiceWorkerExistence(registration =>
registration.pushManager
.getSubscription()
.then(subscription => {
if (!subscription) {
this.hasEnabledPushPermissions = false;
} else {
this.hasEnabledPushPermissions = true;
}
})
// eslint-disable-next-line no-console
.catch(error => console.log(error))
);
},
async updateNotificationSettings() {
try {
this.$store.dispatch('userNotificationSettings/update', {
selectedEmailFlags: this.selectedEmailFlags,
selectedPushFlags: this.selectedPushFlags,
});
this.showAlert(this.$t('PROFILE_SETTINGS.FORM.API.UPDATE_SUCCESS'));
} catch (error) {
this.showAlert(this.$t('PROFILE_SETTINGS.FORM.API.UPDATE_ERROR'));
}
},
handleEmailInput(e) {
this.selectedEmailFlags = this.toggleInput(
this.selectedEmailFlags,
e.target.value
);
this.updateNotificationSettings();
},
handlePushInput(e) {
this.selectedPushFlags = this.toggleInput(
this.selectedPushFlags,
e.target.value
);
this.updateNotificationSettings();
},
handleAudioInput(e) {
this.enableAudioAlerts = e.target.value;
this.updateUISettings({
enable_audio_alerts: this.enableAudioAlerts,
});
this.showAlert(this.$t('PROFILE_SETTINGS.FORM.API.UPDATE_SUCCESS'));
},
handleAudioAlertConditions(e) {
let condition = e.target.value;
if (condition === 'tab_is_inactive') {
this.updateUISettings({
always_play_audio_alert: !e.target.checked,
});
} else if (condition === 'conversations_are_read') {
this.updateUISettings({
alert_if_unread_assigned_conversation_exist: e.target.checked,
});
}
this.showAlert(this.$t('PROFILE_SETTINGS.FORM.API.UPDATE_SUCCESS'));
},
handleAudioToneChange(e) {
this.updateUISettings({ notification_tone: e.target.value });
this.showAlert(this.$t('PROFILE_SETTINGS.FORM.API.UPDATE_SUCCESS'));
},
toggleInput(selected, current) {
if (selected.includes(current)) {
const newSelectedFlags = selected.filter(flag => flag !== current);
return newSelectedFlags;
}
return [...selected, current];
},
},
};
</script>
<style lang="scss" scoped>
@import '~dashboard/assets/scss/variables.scss';
.notification--checkbox {
font-size: $font-size-large;
}
.push-notification--button {
margin-bottom: var(--space-one);
}
.notification-label {
display: flex;
font-weight: var(--font-weight-bold);
margin-bottom: var(--space-small);
}
.tone-selector {
height: var(--space-large);
padding-bottom: var(--space-micro);
padding-top: var(--space-micro);
width: var(--space-mega);
}
</style>
@@ -0,0 +1,19 @@
<template>
<div
class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-white dark:bg-slate-900"
>
<keep-alive v-if="keepAlive">
<router-view />
</keep-alive>
<router-view v-else />
</div>
</template>
<script setup>
defineProps({
keepAlive: {
type: Boolean,
default: true,
},
});
</script>
@@ -1,6 +1,6 @@
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const SettingsContent = () => import('./Wrapper.vue');
const Index = () => import('./Index.vue');
export default {
@@ -10,12 +10,6 @@ export default {
name: 'profile_settings',
roles: ['administrator', 'agent'],
component: SettingsContent,
props: {
headerTitle: 'PROFILE_SETTINGS.TITLE',
icon: 'edit',
showNewButton: false,
showSidemenuIcon: false,
},
children: [
{
path: 'settings',
@@ -13,12 +13,11 @@ import integrationapps from './integrationapps/integrations.routes';
import integrations from './integrations/integrations.routes';
import labels from './labels/labels.routes';
import macros from './macros/macros.routes';
import profile from './profile/profile.routes';
import reports from './reports/reports.routes';
import store from '../../../store';
import sla from './sla/sla.routes';
import teams from './teams/teams.routes';
import personal from './personal/personal.routes';
import profile from './profile/profile.routes';
export default {
routes: [
@@ -47,10 +46,9 @@ export default {
...integrations.routes,
...labels.routes,
...macros.routes,
...profile.routes,
...reports.routes,
...sla.routes,
...teams.routes,
...personal.routes,
...profile.routes,
],
};
@@ -4,7 +4,7 @@
v-model="checked"
type="checkbox"
:value="value"
class="flex-shrink-0 mt-0.5 border-ash-200 border checked:border-none checked:bg-primary-600 dark:checked:bg-primary-600 shadow appearance-none rounded-[4px] w-4 h-4 focus:ring-1 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center after:text-center after:text-xs after:font-bold after:relative"
class="flex-shrink-0 mt-0.5 border-ash-200 border bg-ash-50 checked:border-none checked:bg-primary-600 dark:checked:bg-primary-600 shadow-sm appearance-none rounded-[4px] w-4 h-4 focus:ring-1 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center after:text-center after:text-xs after:font-bold after:relative"
/>
</template>
+8
View File
@@ -0,0 +1,8 @@
class ParticipationListener < BaseListener
include Events::Types
def assignee_changed(event)
conversation, _account = extract_conversation_and_account(event)
conversation.conversation_participants.find_or_create_by!(user_id: conversation.assignee_id) if conversation.assignee_id.present?
end
end
@@ -38,7 +38,6 @@ module AssignmentHandler
def process_assignment_changes
process_assignment_activities
process_participant_assignment
end
def process_assignment_activities
@@ -49,10 +48,4 @@ module AssignmentHandler
create_assignee_change_activity(user_name)
end
end
def process_participant_assignment
return unless saved_change_to_assignee_id? && assignee_id.present?
conversation_participants.find_or_create_by!(user_id: assignee_id)
end
end
@@ -15,7 +15,6 @@ module AutoAssignmentHandler
return unless should_run_auto_assignment?
::AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: inbox.member_ids_with_assignment_capacity).perform
conversation_participants.find_or_create_by(user_id: assignee_id) if assignee_id.present?
end
def should_run_auto_assignment?
+8 -3
View File
@@ -110,7 +110,7 @@ class Conversation < ApplicationRecord
after_update_commit :execute_after_update_commit_callbacks
after_create_commit :notify_conversation_creation
after_commit :set_display_id, unless: :display_id?
after_create_commit :load_attributes_created_by_db_triggers
delegate :auto_resolve_duration, to: :account
@@ -257,8 +257,13 @@ class Conversation < ApplicationRecord
assignee_id.present? && Current.user&.id == assignee_id
end
def set_display_id
reload
def load_attributes_created_by_db_triggers
# Display id is set via a trigger in the database
# So we need to specifically fetch it after the record is created
# We can't use reload because it will clear the previous changes, which we need for the dispatcher
obj_from_db = self.class.find(id)
self[:display_id] = obj_from_db[:display_id]
self[:uuid] = obj_from_db[:uuid]
end
def notify_status_change
@@ -58,10 +58,12 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
end
def message_text
if message.content.present?
message.content.gsub(MENTION_REGEX, '\1')
content = message.processed_message_content || message.content
if content.present?
content.gsub(MENTION_REGEX, '\1')
else
message.content
content
end
end
+1 -1
View File
@@ -32,7 +32,7 @@
"dependencies": {
"@braid/vue-formulate": "^2.5.2",
"@chatwoot/prosemirror-schema": "1.0.5",
"@chatwoot/utils": "^0.0.23",
"@chatwoot/utils": "^0.0.24",
"@hcaptcha/vue-hcaptcha": "^0.3.2",
"@june-so/analytics-next": "^2.0.0",
"@radix-ui/colors": "^1.0.1",
@@ -21,7 +21,7 @@ RSpec.describe Sla::EvaluateAppliedSlaService do
describe '#perform - SLA misses' do
context 'when first response SLA is missed' do
before { sla_policy.update(first_response_time_threshold: 1.hour) }
before { applied_sla.sla_policy.update(first_response_time_threshold: 1.hour) }
it 'updates the SLA status to missed and logs a warning' do
allow(Rails.logger).to receive(:warn)
@@ -42,7 +42,7 @@ RSpec.describe Sla::EvaluateAppliedSlaService do
context 'when next response SLA is missed' do
before do
sla_policy.update(next_response_time_threshold: 1.hour)
applied_sla.sla_policy.update(next_response_time_threshold: 1.hour)
conversation.update(first_reply_created_at: 5.hours.ago, waiting_since: 5.hours.ago)
end
@@ -64,7 +64,7 @@ RSpec.describe Sla::EvaluateAppliedSlaService do
end
context 'when resolution time SLA is missed' do
before { sla_policy.update(resolution_time_threshold: 1.hour) }
before { applied_sla.sla_policy.update(resolution_time_threshold: 1.hour) }
it 'updates the SLA status to missed and logs a warning' do
allow(Rails.logger).to receive(:warn)
@@ -89,7 +89,7 @@ RSpec.describe Sla::EvaluateAppliedSlaService do
context 'when resolved conversation with resolution time SLA is missed' do
before do
conversation.resolved!
sla_policy.update(resolution_time_threshold: 1.hour)
applied_sla.sla_policy.update(resolution_time_threshold: 1.hour)
end
it 'does not update the SLA status to missed' do
@@ -100,7 +100,7 @@ RSpec.describe Sla::EvaluateAppliedSlaService do
context 'when multiple SLAs are missed' do
before do
sla_policy.update(first_response_time_threshold: 1.hour, next_response_time_threshold: 1.hour, resolution_time_threshold: 1.hour)
applied_sla.sla_policy.update(first_response_time_threshold: 1.hour, next_response_time_threshold: 1.hour, resolution_time_threshold: 1.hour)
conversation.update(first_reply_created_at: 5.hours.ago, waiting_since: 5.hours.ago)
end
@@ -119,7 +119,7 @@ RSpec.describe Sla::EvaluateAppliedSlaService do
describe '#perform - SLA hits' do
context 'when first response SLA is hit' do
before do
sla_policy.update(first_response_time_threshold: 6.hours)
applied_sla.sla_policy.update(first_response_time_threshold: 6.hours)
conversation.update(first_reply_created_at: 30.minutes.ago)
end
@@ -142,7 +142,7 @@ RSpec.describe Sla::EvaluateAppliedSlaService do
context 'when next response SLA is hit' do
before do
sla_policy.update(next_response_time_threshold: 6.hours)
applied_sla.sla_policy.update(next_response_time_threshold: 6.hours)
conversation.update(first_reply_created_at: 30.minutes.ago, waiting_since: nil)
end
@@ -164,7 +164,7 @@ RSpec.describe Sla::EvaluateAppliedSlaService do
context 'when resolution time SLA is hit' do
before do
sla_policy.update(resolution_time_threshold: 8.hours)
applied_sla.sla_policy.update(resolution_time_threshold: 8.hours)
conversation.resolved!
end
@@ -182,7 +182,7 @@ RSpec.describe Sla::EvaluateAppliedSlaService do
describe 'SLA evaluation with frt hit, multiple nrt misses and rt miss' do
before do
# Setup SLA Policy thresholds
sla_policy.update(
applied_sla.sla_policy.update(
first_response_time_threshold: 2.hours, # Hit frt
next_response_time_threshold: 1.hour, # Miss nrt multiple times
resolution_time_threshold: 4.hours # Miss rt
@@ -0,0 +1,26 @@
require 'rails_helper'
describe ParticipationListener do
let(:listener) { described_class.instance }
let!(:account) { create(:account) }
let!(:admin) { create(:user, account: account, role: :administrator) }
let!(:inbox) { create(:inbox, account: account) }
let!(:agent) { create(:user, account: account, role: :agent) }
let!(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: agent) }
before do
create(:inbox_member, inbox: inbox, user: agent)
Current.user = nil
Current.account = nil
end
describe '#assignee_changed' do
let(:event_name) { :assignee_changed }
let!(:event) { Events::Base.new(event_name, Time.zone.now, conversation: conversation) }
it 'adds the assignee as a participant to the conversation' do
expect(conversation.conversation_participants.map(&:user_id)).not_to include(admin.id)
listener.assignee_changed(event)
expect(conversation.conversation_participants.map(&:user_id)).to include(agent.id)
end
end
end
@@ -26,10 +26,6 @@ shared_examples_for 'auto_assignment_handler' do
expect(conversation.reload.assignee).to eq(agent)
end
it 'adds assignee to conversation participants' do
expect(conversation.conversation_participants.map(&:user)).to include(agent)
end
it 'will not auto assign agent if enable_auto_assignment is false' do
inbox.update(enable_auto_assignment: false)
+5 -1
View File
@@ -107,7 +107,11 @@ RSpec.describe Message do
end
describe 'message create event' do
let(:conversation) { create(:conversation) }
let!(:conversation) { create(:conversation) }
before do
conversation.reload
end
it 'updates the conversation first reply created at if it is the first outgoing message' do
expect(conversation.first_reply_created_at).to be_nil
+8 -18
View File
@@ -3177,10 +3177,10 @@
prosemirror-utils "^0.9.6"
prosemirror-view "^1.17.2"
"@chatwoot/utils@^0.0.23":
version "0.0.23"
resolved "https://registry.yarnpkg.com/@chatwoot/utils/-/utils-0.0.23.tgz#e961fd87ef9ee19c442bfcedac5fe0be2ef37726"
integrity sha512-BQ7DprXr7FIkSbHdDc1WonwH0rt/+B+WaaLaXNMjCcxJgkX/gZ8QMltruOfRp/S8cFyd9JfFQhNF0T9lz1OMvA==
"@chatwoot/utils@^0.0.24":
version "0.0.24"
resolved "https://registry.yarnpkg.com/@chatwoot/utils/-/utils-0.0.24.tgz#584e26b7fba2a8b23c049cc555497d0f35a5aebd"
integrity sha512-TEPA1LxrCfEVFYZvBj3bckn2sEljStriiZttF6lu3j1rdNn2tysqK9IwYcRBHS4nyfleRMG4kSFrqD653t3YfA==
dependencies:
date-fns "^2.29.1"
@@ -8573,20 +8573,10 @@ caniuse-api@^3.0.0:
lodash.memoize "^4.1.2"
lodash.uniq "^4.5.0"
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000981, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001214:
version "1.0.30001494"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001494.tgz"
integrity sha512-sY2B5Qyl46ZzfYDegrl8GBCzdawSLT4ThM9b9F+aDYUrAG2zCOyMbd2Tq34mS1g4ZKBfjRlzOohQMxx28x6wJg==
caniuse-lite@^1.0.30001449, caniuse-lite@^1.0.30001517, caniuse-lite@^1.0.30001538:
version "1.0.30001538"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001538.tgz#9dbc6b9af1ff06b5eb12350c2012b3af56744f3f"
integrity sha512-HWJnhnID+0YMtGlzcp3T9drmBJUVDchPJ08tpUGFLs9CYlwWPH2uLgpHn8fND5pCgXVtnGS3H4QR9XLMHVNkHw==
caniuse-lite@^1.0.30001503:
version "1.0.30001507"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001507.tgz#fae53f6286e7564783eadea9b447819410a59534"
integrity sha512-SFpUDoSLCaE5XYL2jfqe9ova/pbQHEmbheDf5r4diNwbAgR3qxM9NQtfsiSscjqoya5K7kFcHPUQ+VsUkIJR4A==
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000981, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001214, caniuse-lite@^1.0.30001449, caniuse-lite@^1.0.30001503, caniuse-lite@^1.0.30001517, caniuse-lite@^1.0.30001538:
version "1.0.30001616"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001616.tgz"
integrity sha512-RHVYKov7IcdNjVHJFNY/78RdG4oGVjbayxv8u5IO74Wv7Hlq4PnJE6mo/OjFijjVFNy5ijnCt6H3IIo4t+wfEw==
capture-exit@^2.0.0:
version "2.0.0"