Merge branch 'develop' into feat/scroll-to-notification

This commit is contained in:
Sivin Varghese
2024-05-23 11:37:46 +05:30
committed by GitHub
755 changed files with 9905 additions and 4990 deletions
@@ -0,0 +1,85 @@
import { mapGetters } from 'vuex';
import wootConstants from 'dashboard/constants/globals';
import {
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
CMD_BULK_ACTION_REOPEN_CONVERSATION,
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
} from './commandBarBusEvents';
import {
ICON_SNOOZE_CONVERSATION,
ICON_REOPEN_CONVERSATION,
ICON_RESOLVE_CONVERSATION,
} from './CommandBarIcons';
import { createSnoozeHandlers } from './commandBarActions';
const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS;
export const SNOOZE_CONVERSATION_BULK_ACTIONS = [
{
id: 'bulk_action_snooze_conversation',
title: 'COMMAND_BAR.COMMANDS.SNOOZE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
icon: ICON_SNOOZE_CONVERSATION,
children: Object.values(SNOOZE_OPTIONS),
},
...createSnoozeHandlers(
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
'bulk_action_snooze_conversation',
'COMMAND_BAR.SECTIONS.BULK_ACTIONS'
),
];
export const RESOLVED_CONVERSATION_BULK_ACTIONS = [
{
id: 'bulk_action_reopen_conversation',
title: 'COMMAND_BAR.COMMANDS.REOPEN_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
icon: ICON_REOPEN_CONVERSATION,
handler: () => bus.$emit(CMD_BULK_ACTION_REOPEN_CONVERSATION),
},
];
export const OPEN_CONVERSATION_BULK_ACTIONS = [
{
id: 'bulk_action_resolve_conversation',
title: 'COMMAND_BAR.COMMANDS.RESOLVE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
icon: ICON_RESOLVE_CONVERSATION,
handler: () => bus.$emit(CMD_BULK_ACTION_RESOLVE_CONVERSATION),
},
];
export default {
computed: {
...mapGetters({
selectedConversations: 'bulkActions/getSelectedConversationIds',
}),
bulkActionsHotKeys() {
let actions = [];
if (this.selectedConversations.length > 0) {
actions = [
...SNOOZE_CONVERSATION_BULK_ACTIONS,
...RESOLVED_CONVERSATION_BULK_ACTIONS,
...OPEN_CONVERSATION_BULK_ACTIONS,
];
}
return this.prepareActions(actions);
},
},
watch: {
selectedConversations() {
this.setCommandbarData();
},
},
methods: {
prepareActions(actions) {
return actions.map(action => ({
...action,
title: this.$t(action.title),
section: this.$t(action.section),
}));
},
},
};
@@ -30,6 +30,17 @@ export const OPEN_CONVERSATION_ACTIONS = [
},
];
export const createSnoozeHandlers = (busEventName, parentId, section) => {
return Object.values(SNOOZE_OPTIONS).map(option => ({
id: option,
title: `COMMAND_BAR.COMMANDS.${option.toUpperCase()}`,
parent: parentId,
section: section,
icon: ICON_SNOOZE_CONVERSATION,
handler: () => bus.$emit(busEventName, option),
}));
};
export const SNOOZE_CONVERSATION_ACTIONS = [
{
id: 'snooze_conversation',
@@ -37,61 +48,11 @@ export const SNOOZE_CONVERSATION_ACTIONS = [
icon: ICON_SNOOZE_CONVERSATION,
children: Object.values(SNOOZE_OPTIONS),
},
{
id: SNOOZE_OPTIONS.UNTIL_NEXT_REPLY,
title: 'COMMAND_BAR.COMMANDS.UNTIL_NEXT_REPLY',
parent: 'snooze_conversation',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(CMD_SNOOZE_CONVERSATION, SNOOZE_OPTIONS.UNTIL_NEXT_REPLY),
},
{
id: SNOOZE_OPTIONS.AN_HOUR_FROM_NOW,
title: 'COMMAND_BAR.COMMANDS.AN_HOUR_FROM_NOW',
parent: 'snooze_conversation',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(CMD_SNOOZE_CONVERSATION, SNOOZE_OPTIONS.AN_HOUR_FROM_NOW),
},
{
id: SNOOZE_OPTIONS.UNTIL_TOMORROW,
title: 'COMMAND_BAR.COMMANDS.UNTIL_TOMORROW',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION',
parent: 'snooze_conversation',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(CMD_SNOOZE_CONVERSATION, SNOOZE_OPTIONS.UNTIL_TOMORROW),
},
{
id: SNOOZE_OPTIONS.UNTIL_NEXT_WEEK,
title: 'COMMAND_BAR.COMMANDS.UNTIL_NEXT_WEEK',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION',
parent: 'snooze_conversation',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(CMD_SNOOZE_CONVERSATION, SNOOZE_OPTIONS.UNTIL_NEXT_WEEK),
},
{
id: SNOOZE_OPTIONS.UNTIL_NEXT_MONTH,
title: 'COMMAND_BAR.COMMANDS.UNTIL_NEXT_MONTH',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION',
parent: 'snooze_conversation',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(CMD_SNOOZE_CONVERSATION, SNOOZE_OPTIONS.UNTIL_NEXT_MONTH),
},
{
id: SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME,
title: 'COMMAND_BAR.COMMANDS.CUSTOM',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION',
parent: 'snooze_conversation',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(CMD_SNOOZE_CONVERSATION, SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME),
},
...createSnoozeHandlers(
CMD_SNOOZE_CONVERSATION,
'snooze_conversation',
'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION'
),
];
export const RESOLVED_CONVERSATION_ACTIONS = [
@@ -16,5 +16,13 @@ export const CMD_RESOLVE_CONVERSATION = 'CMD_RESOLVE_CONVERSATION';
export const CMD_SNOOZE_CONVERSATION = 'CMD_SNOOZE_CONVERSATION';
export const CMD_AI_ASSIST = 'CMD_AI_ASSIST';
// Bulk Actions
export const CMD_BULK_ACTION_SNOOZE_CONVERSATION =
'CMD_BULK_ACTION_SNOOZE_CONVERSATION';
export const CMD_BULK_ACTION_REOPEN_CONVERSATION =
'CMD_BULK_ACTION_REOPEN_CONVERSATION';
export const CMD_BULK_ACTION_RESOLVE_CONVERSATION =
'CMD_BULK_ACTION_RESOLVE_CONVERSATION';
// Inbox Commands (Notifications)
export const CMD_SNOOZE_NOTIFICATION = 'CMD_SNOOZE_NOTIFICATION';
@@ -6,12 +6,15 @@
hideBreadcrumbs
:placeholder="placeholder"
@selected="onSelected"
@closed="onClosed"
/>
</template>
<script>
import 'ninja-keys';
import '@chatwoot/ninja-keys';
import wootConstants from 'dashboard/constants/globals';
import conversationHotKeysMixin from './conversationHotKeys';
import bulkActionsHotKeysMixin from './bulkActionsHotKeys';
import inboxHotKeysMixin from './inboxHotKeys';
import goToCommandHotKeys from './goToCommandHotKeys';
import appearanceHotKeys from './appearanceHotKeys';
@@ -26,12 +29,21 @@ export default {
adminMixin,
agentMixin,
conversationHotKeysMixin,
bulkActionsHotKeysMixin,
inboxHotKeysMixin,
conversationLabelMixin,
conversationTeamMixin,
appearanceHotKeys,
goToCommandHotKeys,
],
data() {
return {
// Added selectedSnoozeType to track the selected snooze type
// So if the selected snooze type is "custom snooze" then we set selectedSnoozeType with the CMD action id
// So that we can track the selected snooze type and when we close the command bar
selectedSnoozeType: null,
};
},
computed: {
placeholder() {
return this.$t('COMMAND_BAR.SEARCH_PLACEHOLDER');
@@ -46,6 +58,7 @@ export default {
return [
...this.inboxHotKeys,
...this.conversationHotKeys,
...this.bulkActionsHotKeys,
...this.goToCommandHotKeys,
...this.goToAppearanceHotKeys,
];
@@ -64,14 +77,35 @@ export default {
this.$refs.ninjakeys.data = this.hotKeys;
},
onSelected(item) {
const { detail: { action: { title = null, section = null } = {} } = {} } =
item;
const {
detail: {
action: { title = null, section = null, id = null } = {},
} = {},
} = item;
// Added this condition to prevent setting the selectedSnoozeType to null
// When we select the "custom snooze" (CMD bar will close and the custom snooze modal will open)
if (id === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.selectedSnoozeType =
wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME;
} else {
this.selectedSnoozeType = null;
}
this.$track(GENERAL_EVENTS.COMMAND_BAR, {
section,
action: title,
});
this.setCommandbarData();
},
onClosed() {
// If the selectedSnoozeType is not "SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME (custom snooze)" then we set the context menu chat id to null
// Else we do nothing and its handled in the ChatList.vue hideCustomSnoozeModal() method
if (
this.selectedSnoozeType !==
wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME
) {
this.$store.dispatch('setContextMenuChatId', null);
}
},
},
};
</script>
@@ -55,11 +55,15 @@ export default {
replyMode() {
this.setCommandbarData();
},
contextMenuChatId() {
this.setCommandbarData();
},
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
replyMode: 'draftMessages/getReplyEditorMode',
contextMenuChatId: 'getContextMenuChatId',
}),
draftMessage() {
return this.$store.getters['draftMessages/get'](this.draftKey);
@@ -93,6 +97,7 @@ export default {
}
return this.prepareActions(actions);
},
priorityOptions() {
return [
{
@@ -327,25 +332,42 @@ export default {
];
},
conversationHotKeys() {
if (
isConversationOrInboxRoute() {
return (
isAConversationRoute(this.$route.name) ||
isAInboxViewRoute(this.$route.name)
) {
const defaultConversationHotKeys = [
...this.statusActions,
...this.conversationAdditionalActions,
...this.assignAgentActions,
...this.assignTeamActions,
...this.labelActions,
...this.assignPriorityActions,
];
if (this.isAIIntegrationEnabled) {
return [...defaultConversationHotKeys, ...this.AIAssistActions];
}
return defaultConversationHotKeys;
}
);
},
shouldShowSnoozeOption() {
return (
isAConversationRoute(this.$route.name, true, false) &&
this.contextMenuChatId
);
},
getDefaultConversationHotKeys() {
const defaultConversationHotKeys = [
...this.statusActions,
...this.conversationAdditionalActions,
...this.assignAgentActions,
...this.assignTeamActions,
...this.labelActions,
...this.assignPriorityActions,
];
if (this.isAIIntegrationEnabled) {
return [...defaultConversationHotKeys, ...this.AIAssistActions];
}
return defaultConversationHotKeys;
},
conversationHotKeys() {
if (this.shouldShowSnoozeOption) {
return this.prepareActions(SNOOZE_CONVERSATION_ACTIONS);
}
if (this.isConversationOrInboxRoute) {
return this.getDefaultConversationHotKeys;
}
return [];
},
},
@@ -1,5 +1,5 @@
<template>
<div class="w-full flex flex-row">
<div class="flex flex-row w-full">
<div class="flex flex-col h-full" :class="wrapClass">
<contacts-header
:search-query="searchQuery"
@@ -391,8 +391,19 @@ export default {
this.fetchContacts(this.pageParameter);
},
onExportSubmit() {
let query = { payload: [] };
if (this.hasActiveSegments) {
query = this.activeSegment.query;
} else if (this.hasAppliedFilters) {
query = filterQueryGenerator(this.getAppliedContactFilters);
}
try {
this.$store.dispatch('contacts/export');
this.$store.dispatch('contacts/export', {
...query,
label: this.label,
});
this.showAlert(this.$t('EXPORT_CONTACTS.SUCCESS_MESSAGE'));
} catch (error) {
this.showAlert(
@@ -1,12 +1,12 @@
<template>
<header
class="bg-white dark:bg-slate-900 border-b border-slate-50 dark:border-slate-800"
class="bg-white border-b dark:bg-slate-900 border-slate-50 dark:border-slate-800"
>
<div class="flex justify-between w-full py-2 px-4">
<div class="flex justify-between w-full px-4 py-2">
<div class="flex items-center justify-center max-w-full min-w-[6.25rem]">
<woot-sidemenu-icon />
<h1
class="m-0 text-xl text-slate-900 dark:text-slate-100 overflow-hidden whitespace-nowrap text-ellipsis my-0 mx-2"
class="m-0 mx-2 my-0 overflow-hidden text-xl text-slate-900 dark:text-slate-100 whitespace-nowrap text-ellipsis"
>
{{ headerTitle }}
</h1>
@@ -18,7 +18,7 @@
<div class="flex items-center absolute h-full left-2.5">
<fluent-icon
icon="search"
class="h-5 leading-9 text-sm text-slate-700 dark:text-slate-200"
class="h-5 text-sm leading-9 text-slate-700 dark:text-slate-200"
/>
</div>
<input
@@ -59,7 +59,7 @@
<div v-if="!hasActiveSegments" class="relative">
<div
v-if="hasAppliedFilters"
class="absolute h-2 w-2 top-1 right-3 bg-slate-500 dark:bg-slate-500 rounded-full"
class="absolute w-2 h-2 rounded-full top-1 right-3 bg-slate-500 dark:bg-slate-500"
/>
<woot-button
class="clear"
@@ -116,7 +116,7 @@
<woot-confirm-modal
ref="confirmExportContactsDialog"
:title="$t('EXPORT_CONTACTS.CONFIRM.TITLE')"
:description="$t('EXPORT_CONTACTS.CONFIRM.MESSAGE')"
:description="exportDescription"
:confirm-label="$t('EXPORT_CONTACTS.CONFIRM.YES')"
:cancel-label="$t('EXPORT_CONTACTS.CONFIRM.NO')"
/>
@@ -162,6 +162,11 @@ export default {
hasActiveSegments() {
return this.segmentsId !== 0;
},
exportDescription() {
return this.hasAppliedFilters
? this.$t('EXPORT_CONTACTS.CONFIRM.FILTERED_MESSAGE')
: this.$t('EXPORT_CONTACTS.CONFIRM.MESSAGE');
},
},
methods: {
onToggleSegmentsModal() {
@@ -32,9 +32,25 @@
<th
v-for="thHeader in $t('CANNED_MGMT.LIST.TABLE_HEADER')"
:key="thHeader"
class="last:text-right"
class="last:text-right first:m-0 first:p-0"
>
{{ thHeader }}
<p v-if="thHeader !== $t('CANNED_MGMT.LIST.TABLE_HEADER[0]')">
{{ thHeader }}
</p>
<button
v-if="thHeader === $t('CANNED_MGMT.LIST.TABLE_HEADER[0]')"
class="cursor-pointer flex items-center p-0"
@click="toggleSort"
>
<p class="uppercase">
{{ thHeader }}
</p>
<fluent-icon
class="mb-2 ml-2"
:icon="sortOrder === 'asc' ? 'chevron-up' : 'chevron-down'"
/>
</button>
</th>
</thead>
<tbody>
@@ -132,6 +148,7 @@ export default {
cannedResponseAPI: {
message: '',
},
sortOrder: 'asc',
};
},
computed: {
@@ -156,9 +173,20 @@ export default {
},
mounted() {
// Fetch API Call
this.$store.dispatch('getCannedResponse');
this.$store.dispatch('getCannedResponse').then(() => {
this.toggleSort();
});
},
methods: {
toggleSort() {
this.records.sort((a, b) => {
if (this.sortOrder === 'asc') {
return a.short_code.localeCompare(b.short_code);
}
return b.short_code.localeCompare(a.short_code);
});
this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc';
},
showAlert(message) {
// Reset loading, current selected agent
this.loading[this.selectedResponse.id] = false;
@@ -3,6 +3,7 @@
class="border border-slate-25 dark:border-slate-800/60 bg-white dark:bg-slate-900 h-full p-6 w-full max-w-full md:w-3/4 md:max-w-[75%] flex-shrink-0 flex-grow-0"
>
<page-header
class="max-w-4xl"
:header-title="$t('INBOX_MGMT.ADD.AUTH.TITLE')"
:header-content="
useInstallationName(
@@ -11,7 +12,9 @@
)
"
/>
<div class="mt-6 mx-0 flex flex-wrap">
<div
class="mt-6 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 mx-0 max-w-3xl"
>
<channel-item
v-for="channel in channelList"
:key="channel.key"
@@ -21,6 +21,11 @@
</woot-tabs>
</setting-intro-banner>
<inbox-reconnection-required
v-if="isReconnectionRequired"
class="mx-8 mt-5"
/>
<div v-if="selectedTabKey === 'inbox_settings'" class="mx-8">
<settings-section
:title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_UPDATE_TITLE')"
@@ -287,7 +292,7 @@
<label v-if="isAWebWidgetInbox">
{{ $t('INBOX_MGMT.FEATURES.LABEL') }}
</label>
<div v-if="isAWebWidgetInbox" class="pt-2 pb-4 flex gap-2">
<div v-if="isAWebWidgetInbox" class="flex gap-2 pt-2 pb-4">
<input
v-model="selectedFeatureFlags"
type="checkbox"
@@ -298,7 +303,7 @@
{{ $t('INBOX_MGMT.FEATURES.DISPLAY_FILE_PICKER') }}
</label>
</div>
<div v-if="isAWebWidgetInbox" class="pb-4 flex gap-2">
<div v-if="isAWebWidgetInbox" class="flex gap-2 pb-4">
<input
v-model="selectedFeatureFlags"
type="checkbox"
@@ -309,7 +314,7 @@
{{ $t('INBOX_MGMT.FEATURES.DISPLAY_EMOJI_PICKER') }}
</label>
</div>
<div v-if="isAWebWidgetInbox" class="pb-4 flex gap-2">
<div v-if="isAWebWidgetInbox" class="flex gap-2 pb-4">
<input
v-model="selectedFeatureFlags"
type="checkbox"
@@ -320,7 +325,7 @@
{{ $t('INBOX_MGMT.FEATURES.ALLOW_END_CONVERSATION') }}
</label>
</div>
<div v-if="isAWebWidgetInbox" class="pb-4 flex gap-2">
<div v-if="isAWebWidgetInbox" class="flex gap-2 pb-4">
<input
v-model="selectedFeatureFlags"
type="checkbox"
@@ -435,6 +440,7 @@ import WeeklyAvailability from './components/WeeklyAvailability.vue';
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
import ConfigurationPage from './settingsPage/ConfigurationPage.vue';
import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue';
import InboxReconnectionRequired from './components/InboxReconnectionRequired';
import WidgetBuilder from './WidgetBuilder.vue';
import BotConfiguration from './components/BotConfiguration.vue';
import { FEATURE_FLAGS } from '../../../../featureFlags';
@@ -453,6 +459,7 @@ export default {
WeeklyAvailability,
WidgetBuilder,
SenderNameExamplePreview,
InboxReconnectionRequired,
},
mixins: [alertMixin, configMixin, inboxMixin],
data() {
@@ -614,6 +621,9 @@ export default {
return true;
return false;
},
isReconnectionRequired() {
return false;
},
},
watch: {
$route(to) {
@@ -111,7 +111,9 @@ export default {
},
});
} catch (error) {
this.showAlert(this.$t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE'));
this.showAlert(
error.message || this.$t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE')
);
}
},
},
@@ -155,7 +155,9 @@ export default {
},
});
} catch (error) {
this.showAlert(this.$t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE'));
this.showAlert(
error.message || this.$t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE')
);
}
},
},
@@ -4,16 +4,18 @@
class="border border-slate-25 dark:border-slate-800/60 bg-white dark:bg-slate-900 h-full p-6 w-full md:w-full max-w-full md:max-w-[75%] flex-shrink-0 flex-grow-0"
>
<page-header
class="max-w-4xl"
:header-title="$t('INBOX_MGMT.ADD.EMAIL_PROVIDER.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.EMAIL_PROVIDER.DESCRIPTION')"
/>
<div class="flex flex-row flex-wrap mx-0 mt-6">
<div class="grid grid-cols-4 max-w-3xl mx-0 mt-6">
<channel-selector
v-for="emailProvider in emailProviderList"
:key="emailProvider.key"
:class="{ inactive: !emailProvider.isEnabled }"
:title="emailProvider.title"
:src="emailProvider.src"
@click="() => onClick(emailProvider.key)"
@click="() => onClick(emailProvider)"
/>
</div>
</div>
@@ -38,7 +40,10 @@ export default {
return { provider: '' };
},
computed: {
...mapGetters({ globalConfig: 'globalConfig/get' }),
...mapGetters({
globalConfig: 'globalConfig/get',
isAChatwootInstance: 'globalConfig/isAChatwootInstance',
}),
emailProviderList() {
return [
{
@@ -53,12 +58,19 @@ export default {
key: 'other_provider',
src: '/assets/images/dashboard/channels/email.png',
},
].filter(provider => provider.isEnabled);
].filter(provider => {
if (this.isAChatwootInstance) {
return true;
}
return provider.isEnabled;
});
},
},
methods: {
onClick(provider) {
this.provider = provider;
onClick(emailProvider) {
if (emailProvider.isEnabled) {
this.provider = emailProvider.key;
}
},
},
};
@@ -86,7 +86,8 @@ export default {
});
} catch (error) {
this.showAlert(
this.$t('INBOX_MGMT.ADD.TELEGRAM_CHANNEL.API.ERROR_MESSAGE')
error.message ||
this.$t('INBOX_MGMT.ADD.TELEGRAM_CHANNEL.API.ERROR_MESSAGE')
);
}
},
@@ -16,9 +16,6 @@
<option value="twilio">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO') }}
</option>
<option value="360dialog">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.360_DIALOG') }}
</option>
</select>
</label>
</div>
@@ -0,0 +1,19 @@
<script setup>
defineProps({
actionUrl: {
type: String,
required: true,
},
});
</script>
<template>
<div
class="flex items-center gap-2 px-4 py-3 text-sm text-white bg-red-500 rounded-md dark:bg-red-800/30 dark:text-red-50 min-h-10"
>
<fluent-icon icon="error-circle" class="text-white dark:text-red-50" />
<slot>
<span v-html="$t('INBOX_MGMT.RECONNECTION_REQUIRED', { actionUrl })" />
</slot>
</div>
</template>
@@ -1,16 +1,16 @@
<template>
<div class="flex-shrink flex-grow overflow-auto p-4">
<div class="flex-grow flex-shrink p-4 overflow-auto">
<div class="flex flex-col">
<div v-if="uiFlags.isFetching" class="my-0 mx-auto">
<div v-if="uiFlags.isFetching" class="mx-auto my-0">
<woot-loading-state :message="$t('INTEGRATION_APPS.FETCHING')" />
</div>
<div v-else class="w-full">
<div>
<div
v-for="item in integrationsList"
v-for="item in enabledIntegrations"
:key="item.id"
class="bg-white dark:bg-slate-800 border border-solid border-slate-75 dark:border-slate-700/50 rounded-sm mb-4 p-4"
class="p-4 mb-4 bg-white border border-solid rounded-sm dark:bg-slate-800 border-slate-75 dark:border-slate-700/50"
>
<integration-item
:integration-id="item.id"
@@ -25,22 +25,38 @@
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import IntegrationItem from './IntegrationItem.vue';
export default {
components: {
IntegrationItem,
},
computed: {
...mapGetters({
uiFlags: 'labels/getUIFlags',
integrationsList: 'integrations/getAppIntegrations',
}),
},
mounted() {
this.$store.dispatch('integrations/get');
},
};
<script setup>
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import { computed, onMounted } from 'vue';
import IntegrationItem from './IntegrationItem.vue';
const store = useStore();
const getters = useStoreGetters();
const uiFlags = getters['integrations/getUIFlags'];
const accountId = getters.getCurrentAccountId;
const integrationList = computed(() => {
return getters['integrations/getAppIntegrations'].value;
});
const isLinearIntegrationEnabled = computed(() => {
return getters['accounts/isFeatureEnabledonAccount'].value(
accountId.value,
'linear_integration'
);
});
const enabledIntegrations = computed(() => {
if (!isLinearIntegrationEnabled.value) {
return integrationList.value.filter(
integration => integration.id !== 'linear'
);
}
return integrationList.value;
});
onMounted(() => {
store.dispatch('integrations/get');
});
</script>
@@ -2,15 +2,15 @@
<tr>
<td>{{ macro.name }}</td>
<td>
<div class="avatar-container">
<thumbnail :username="macro.created_by.name" size="24px" />
<span>{{ macro.created_by.name }}</span>
<div v-if="macro.created_by" class="avatar-container">
<thumbnail :username="createdByName" size="24px" />
<span>{{ createdByName }}</span>
</div>
</td>
<td>
<div class="avatar-container">
<thumbnail :username="macro.updated_by.name" size="24px" />
<span>{{ macro.updated_by.name }}</span>
<div v-if="macro.updated_by" class="avatar-container">
<thumbnail :username="updatedByName" size="24px" />
<span>{{ updatedByName }}</span>
</div>
</td>
<td>{{ visibilityLabel }}</td>
@@ -53,6 +53,14 @@ export default {
},
},
computed: {
createdByName() {
const createdBy = this.macro.created_by;
return createdBy.available_name ?? createdBy.email ?? '';
},
updatedByName() {
const updatedBy = this.macro.updated_by;
return updatedBy.available_name ?? updatedBy.email ?? '';
},
visibilityLabel() {
return this.macro.visibility === 'global'
? this.$t('MACROS.EDITOR.VISIBILITY.GLOBAL.LABEL')
@@ -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,245 +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
:header="$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
:header="$t('PROFILE_SETTINGS.FORM.PASSWORD_SECTION.TITLE')"
>
<change-password v-if="!globalConfig.disableUserProfileUpdate" />
</form-section>
<form-section
:header="
$t('PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.TITLE')
"
:description="
$t('PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.NOTE')
"
>
<audio-notifications />
</form-section>
<form-section :header="$t('PROFILE_SETTINGS.FORM.NOTIFICATIONS.TITLE')">
<notification-preferences />
</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 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';
export default {
components: {
MessageSignature,
FormSection,
UserProfilePicture,
UserBasicDetails,
HotKeyCard,
ChangePassword,
NotificationPreferences,
AudioNotifications,
},
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')
);
},
},
};
</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,
},
},
],
};
@@ -0,0 +1,58 @@
<template>
<div class="flex flex-row justify-between gap-4">
<woot-input
name="access_token"
class="flex-1 [&>input]:!py-1.5 ltr:[&>input]:!pr-9 ltr:[&>input]:!pl-3 rtl:[&>input]:!pl-9 rtl:[&>input]:!pr-3 focus:[&>input]:!border-slate-200 focus:[&>input]:dark:!border-slate-600 [&>input]:cursor-not-allowed relative"
:styles="{
borderRadius: '12px',
fontSize: '14px',
marginBottom: '2px',
}"
:type="inputType"
:value="value"
readonly
>
<template #masked>
<button
class="absolute top-1.5 ltr:right-0.5 rtl:left-0.5"
@click="toggleMasked"
>
<fluent-icon :icon="maskIcon" :size="16" />
</button>
</template>
</woot-input>
<form-button
type="submit"
size="large"
icon="text-copy"
variant="outline"
color-scheme="secondary"
@click="onClick"
>
{{ $t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.COPY') }}
</form-button>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import FormButton from 'v3/components/Form/Button.vue';
const props = defineProps({
value: {
type: String,
default: '',
},
});
const emit = defineEmits(['on-copy']);
const inputType = ref('password');
const toggleMasked = () => {
inputType.value = inputType.value === 'password' ? 'text' : 'password';
};
const maskIcon = computed(() => {
return inputType.value === 'password' ? 'eye-hide' : 'eye-show';
});
const onClick = () => {
emit('on-copy', props.value);
};
</script>
@@ -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',
@@ -1,6 +1,6 @@
<script setup>
import FilterButton from './FilterButton.vue';
import FilterListDropdown from './FilterListDropdown.vue';
import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
import FilterListDropdown from 'dashboard/components/ui/Dropdown/DropdownList.vue';
const props = defineProps({
name: {
@@ -1,8 +1,8 @@
<script setup>
import FilterButton from './FilterButton.vue';
import FilterListDropdown from './FilterListDropdown.vue';
import FilterListItemButton from './FilterListItemButton.vue';
import FilterDropdownEmptyState from './FilterDropdownEmptyState.vue';
import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
import FilterListDropdown from 'dashboard/components/ui/Dropdown/DropdownList.vue';
import FilterListItemButton from 'dashboard/components/ui/Dropdown/DropdownListItemButton.vue';
import FilterDropdownEmptyState from 'dashboard/components/ui/Dropdown/DropdownEmptyState.vue';
import { ref } from 'vue';
@@ -1,47 +0,0 @@
<script setup>
defineProps({
buttonText: {
type: String,
default: '',
},
rightIcon: {
type: String,
default: '',
},
leftIcon: {
type: String,
default: '',
},
});
</script>
<template>
<button
class="inline-flex relative items-center p-1.5 w-fit h-8 gap-1.5 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800 active:bg-slate-75 dark:active:bg-slate-800"
@click="$emit('click')"
>
<slot name="leftIcon">
<fluent-icon
v-if="leftIcon"
:icon="leftIcon"
size="18"
class="flex-shrink-0 text-slate-900 dark:text-slate-50"
/>
</slot>
<span
v-if="buttonText"
class="text-sm font-medium truncate text-slate-900 dark:text-slate-50"
>
{{ buttonText }}
</span>
<slot name="rightIcon">
<fluent-icon
v-if="rightIcon"
:icon="rightIcon"
size="18"
class="flex-shrink-0 text-slate-900 dark:text-slate-50"
/>
</slot>
<slot name="dropdown" />
</button>
</template>
@@ -1,15 +0,0 @@
<script setup>
defineProps({
message: {
type: String,
default: '',
},
});
</script>
<template>
<div
class="flex items-center justify-center h-10 text-sm text-slate-500 dark:text-slate-300"
>
{{ message }}
</div>
</template>
@@ -1,47 +0,0 @@
<script setup>
defineProps({
inputValue: {
type: String,
default: '',
},
inputPlaceholder: {
type: String,
default: '',
},
showClearFilter: {
type: Boolean,
default: false,
},
});
</script>
<template>
<div
class="flex items-center justify-between h-10 min-h-[40px] sticky top-0 bg-white z-10 dark:bg-slate-800 gap-2 px-3 border-b rounded-t-xl border-slate-50 dark:border-slate-700"
>
<div class="flex items-center w-full gap-2">
<fluent-icon
icon="search"
size="18"
class="text-slate-400 dark:text-slate-400"
/>
<input
type="text"
class="w-full mb-0 text-sm bg-white dark:bg-slate-800 text-slate-800 dark:text-slate-75 reset-base"
:placeholder="inputPlaceholder"
:value="inputValue"
@input="$emit('input', $event.target.value)"
/>
</div>
<!-- Clear filter button -->
<woot-button
v-if="!inputValue && showClearFilter"
size="small"
variant="clear"
color-scheme="primary"
class="!px-1 !py-1.5"
@click="$emit('click')"
>
{{ $t('REPORT.FILTER_ACTIONS.CLEAR_FILTER') }}
</woot-button>
</div>
</template>
@@ -1,80 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { picoSearch } from '@scmmishra/pico-search';
import FilterListItemButton from './FilterListItemButton.vue';
import FilterDropdownSearch from './FilterDropdownSearch.vue';
import FilterDropdownEmptyState from './FilterDropdownEmptyState.vue';
const props = defineProps({
listItems: {
type: Array,
default: () => [],
},
enableSearch: {
type: Boolean,
default: false,
},
inputPlaceholder: {
type: String,
default: '',
},
activeFilterId: {
type: Number,
default: null,
},
showClearFilter: {
type: Boolean,
default: false,
},
});
const searchTerm = ref('');
const onSearch = value => {
searchTerm.value = value;
};
const filteredListItems = computed(() => {
if (!searchTerm.value) return props.listItems;
return picoSearch(props.listItems, searchTerm.value, ['name']);
});
const isDropdownListEmpty = computed(() => {
return !filteredListItems.value.length;
});
const isFilterActive = id => {
if (!props.activeFilterId) return false;
return id === props.activeFilterId;
};
</script>
<template>
<div
class="absolute z-20 w-40 bg-white border shadow dark:bg-slate-800 rounded-xl border-slate-50 dark:border-slate-700/50 max-h-[400px]"
@click.stop
>
<slot name="search">
<filter-dropdown-search
v-if="enableSearch && listItems.length"
:input-value="searchTerm"
:input-placeholder="inputPlaceholder"
:show-clear-filter="showClearFilter"
@input="onSearch"
@click="$emit('removeFilter')"
/>
</slot>
<slot name="listItem">
<filter-dropdown-empty-state
v-if="isDropdownListEmpty"
:message="$t('REPORT.FILTER_ACTIONS.EMPTY_LIST')"
/>
<filter-list-item-button
v-for="item in filteredListItems"
:key="item.id"
:is-active="isFilterActive(item.id)"
:button-text="item.name"
@click="$emit('click', item)"
/>
</slot>
</div>
</template>
@@ -1,36 +0,0 @@
<script setup>
defineProps({
buttonText: {
type: String,
default: '',
},
isActive: {
type: Boolean,
default: false,
},
});
</script>
<template>
<button
class="relative inline-flex items-center justify-start w-full p-3 border-0 rounded-none first:rounded-t-xl last:rounded-b-xl h-11 hover:bg-slate-50 dark:hover:bg-slate-700 active:bg-slate-75 dark:active:bg-slate-800"
@click.stop="$emit('click')"
@mouseenter="$emit('mouseenter')"
@mouseleave="$emit('mouseleave')"
@focus="$emit('focus')"
>
<div class="inline-flex items-center gap-3 overflow-hidden">
<span
class="text-sm font-medium truncate text-slate-900 dark:text-slate-50"
>
{{ buttonText }}
</span>
<fluent-icon
v-if="isActive"
icon="checkmark"
size="18"
class="flex-shrink-0 text-slate-900 dark:text-slate-50"
/>
</div>
<slot name="dropdown" />
</button>
</template>
@@ -63,7 +63,7 @@ import {
getActiveFilter,
getFilterType,
} from './helpers/SLAFilterHelpers';
import FilterButton from '../Filters/v3/FilterButton.vue';
import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
import ActiveFilterChip from '../Filters/v3/ActiveFilterChip.vue';
import AddFilterChip from '../Filters/v3/AddFilterChip.vue';
@@ -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,
],
};