feat: Add new sidebar for Chatwoot V4 (#10291)

This PR has the initial version of the new sidebar targeted for the next major redesign of the app. This PR includes the following changes

- Components in the `layouts-next` and `base-next` directories in `dashboard/components`
- Two generic components `Avatar` and `Icon`
- `SidebarGroup` component to manage expandable sidebar groups with nested navigation items. This includes handling active states, transitions, and permissions.
- `SidebarGroupHeader` component to display the header of each navigation group with optional icons and active state indication.
- `SidebarGroupLeaf` component for individual navigation items within a group, supporting icons and active state.
- `SidebarGroupSeparator` component to visually separate nested navigation items. (They look a lot like header)
- `SidebarGroupEmptyLeaf` component to render empty state of any navigation groups.

----

Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Pranav <pranavrajs@gmail.com>
This commit is contained in:
Shivam Mishra
2024-10-24 07:02:37 +05:30
committed by GitHub
parent 601a0f8a76
commit 6d3ecfe3c1
47 changed files with 2188 additions and 155 deletions

View File

@@ -7,28 +7,58 @@ import Avatar from './Avatar.vue';
<Variant title="Default">
<div class="p-4 bg-white dark:bg-slate-900">
<Avatar
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Amaya"
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Amaya"
class="bg-ruby-300 dark:bg-ruby-900"
/>
</div>
</Variant>
<Variant title="Default with upload">
<div class="p-4 bg-white dark:bg-slate-900">
<Avatar
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Amaya"
class="bg-ruby-300 dark:bg-ruby-900"
allow-upload
/>
</div>
</Variant>
<Variant title="Invalid or empty SRC">
<div class="p-4 bg-white dark:bg-slate-900 space-x-4">
<Avatar src="https://example.com/ruby.png" name="Ruby" allow-upload />
<Avatar name="Bruce Wayne" allow-upload />
</div>
</Variant>
<Variant title="Rounded Full">
<div class="p-4 bg-white dark:bg-slate-900 space-x-4">
<Avatar
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Amaya"
allow-upload
rounded-full
/>
</div>
</Variant>
<Variant title="Different Sizes">
<div class="flex flex-wrap gap-4 p-4 bg-white dark:bg-slate-900">
<Avatar
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Felix"
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Felix"
:size="48"
class="bg-green-300 dark:bg-green-900"
allow-upload
/>
<Avatar
:size="72"
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Jade"
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Jade"
class="bg-indigo-300 dark:bg-indigo-900"
allow-upload
/>
<Avatar
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Emery"
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Emery"
:size="96"
class="bg-woot-300 dark:bg-woot-900"
allow-upload
/>
</div>
</Variant>

View File

@@ -1,52 +1,122 @@
<script setup>
import { computed } from 'vue';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { computed, ref, watch } from 'vue';
import wootConstants from 'dashboard/constants/globals';
const props = defineProps({
src: {
type: String,
default: '',
},
name: {
type: String,
required: true,
},
size: {
type: Number,
default: 72,
default: 32,
},
allowUpload: {
type: Boolean,
default: false,
},
roundedFull: {
type: Boolean,
default: false,
},
status: {
type: String,
default: null,
validator: value => {
if (!value) return true;
return wootConstants.AVAILABILITY_STATUS_KEYS.includes(value);
},
},
});
const emit = defineEmits(['upload']);
const avatarSize = computed(() => `${props.size}px`);
const iconSize = computed(() => `${props.size / 2}px`);
const isImageValid = ref(true);
const handleUploadAvatar = () => {
emit('upload');
};
function invalidateCurrentImage() {
isImageValid.value = false;
}
const initials = computed(() => {
const splitNames = props.name.split(' ');
if (splitNames.length > 1) {
const firstName = splitNames[0];
const lastName = splitNames[splitNames.length - 1];
return firstName[0] + lastName[0];
}
const firstName = splitNames[0];
return firstName[0];
});
watch(
() => props.src,
() => {
isImageValid.value = true;
}
);
</script>
<template>
<div
class="relative flex flex-col items-center gap-2 select-none rounded-xl group/avatar"
<span
class="relative inline"
:style="{
width: avatarSize,
height: avatarSize,
width: `${size}px`,
height: `${size}px`,
}"
>
<img
v-if="src"
:src="props.src"
alt="avatar"
class="w-full h-full shadow-sm rounded-xl"
/>
<div
class="absolute inset-0 flex items-center justify-center invisible w-full h-full transition-all duration-500 ease-in-out opacity-0 rounded-xl dark:bg-slate-900/50 bg-slate-900/20 group-hover/avatar:visible group-hover/avatar:opacity-100"
@click="handleUploadAvatar"
>
<FluentIcon
icon="upload-lucide"
icon-lib="lucide"
:size="iconSize"
class="text-white dark:text-white"
<slot name="badge" :size>
<div
class="rounded-full w-2.5 h-2.5 absolute z-20"
:style="{
top: `${size - 10}px`,
left: `${size - 10}px`,
}"
:class="{
'bg-n-teal-10': status === 'online',
'bg-n-amber-10': status === 'busy',
'bg-n-slate-10': status === 'offline',
}"
/>
</div>
</div>
</slot>
<span
role="img"
class="inline-flex relative items-center justify-center object-cover overflow-hidden font-medium bg-woot-50 text-woot-500 group/avatar"
:class="{
'rounded-full': roundedFull,
'rounded-xl': !roundedFull,
}"
:style="{
width: `${size}px`,
height: `${size}px`,
}"
>
<img
v-if="src && isImageValid"
:src="src"
:alt="name"
@error="invalidateCurrentImage"
/>
<span v-else>
{{ initials }}
</span>
<div
v-if="allowUpload"
role="button"
class="absolute inset-0 flex items-center justify-center invisible w-full h-full transition-all duration-500 ease-in-out opacity-0 rounded-xl dark:bg-slate-900/50 bg-slate-900/20 group-hover/avatar:visible group-hover/avatar:opacity-100"
@click="emit('upload')"
>
<Icon
icon="i-lucide-upload"
class="text-white dark:text-white size-4"
/>
</div>
</span>
</span>
</template>

View File

@@ -118,7 +118,11 @@ const handleClick = () => {
:icon-lib="iconLib"
class="flex-shrink-0"
/>
<span v-if="label" class="min-w-0 truncate">{{ label }}</span>
<slot>
<span v-if="label" class="min-w-0 truncate">
{{ label }}
</span>
</slot>
<FluentIcon
v-if="icon && iconPosition === 'right'"
:icon="icon"

View File

@@ -0,0 +1,19 @@
<script setup>
import { h, isVNode } from 'vue';
const props = defineProps({
icon: { type: [String, Object, Function], required: true },
});
const renderIcon = () => {
if (!props.icon) return null;
if (typeof props.icon === 'function' || isVNode(props.icon)) {
return props.icon;
}
return h('span', { class: props.icon });
};
</script>
<template>
<component :is="renderIcon" />
</template>

View File

@@ -0,0 +1,28 @@
<template>
<svg
v-once
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#woot-logo-clip-2342424e23u32098)">
<path
d="M8 16C12.4183 16 16 12.4183 16 8C16 3.58172 12.4183 0 8 0C3.58172 0 0 3.58172 0 8C0 12.4183 3.58172 16 8 16Z"
fill="#2781F6"
/>
<path
d="M11.4172 11.4172H7.70831C5.66383 11.4172 4 9.75328 4 7.70828C4 5.66394 5.66383 4 7.70835 4C9.75339 4 11.4172 5.66394 11.4172 7.70828V11.4172Z"
fill="white"
stroke="white"
stroke-width="0.1875"
/>
</g>
<defs>
<clipPath id="woot-logo-clip-2342424e23u32098">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>
</template>

View File

@@ -0,0 +1,71 @@
<script setup>
import { computed } from 'vue';
import Icon from 'next/icon/Icon.vue';
const props = defineProps({
label: {
type: String,
required: true,
},
active: {
type: Boolean,
default: false,
},
inbox: {
type: Object,
required: true,
},
});
const channelTypeIconMap = {
'Channel::Api': 'i-ri-cloudy-fill',
'Channel::Email': 'i-ri-mail-fill',
'Channel::FacebookPage': 'i-ri-messenger-fill',
'Channel::Line': 'i-ri-line-fill',
'Channel::Sms': 'i-ri-chat-1-fill',
'Channel::Telegram': 'i-ri-telegram-fill',
'Channel::TwilioSms': 'i-ri-chat-1-fill',
'Channel::TwitterProfile': 'i-ri-twitter-x-fill',
'Channel::WebWidget': 'i-ri-global-fill',
'Channel::Whatsapp': 'i-ri-whatsapp-fill',
};
const providerIconMap = {
microsoft: 'i-ri-microsoft-fill',
google: 'i-ri-google-fill',
};
const channelIcon = computed(() => {
const type = props.inbox.channel_type;
let icon = channelTypeIconMap[type];
if (type === 'Channel::Email' && props.inbox.provider) {
if (Object.keys(providerIconMap).includes(props.inbox.provider)) {
icon = providerIconMap[props.inbox.provider];
}
}
return icon ?? 'i-ri-global-fill';
});
const reauthorizationRequired = computed(() => {
return props.inbox.reauthorization_required;
});
</script>
<template>
<span
class="size-4 grid place-content-center rounded-full bg-n-alpha-2"
:class="{ 'bg-n-blue/20': active }"
>
<Icon :icon="channelIcon" class="size-3" />
</span>
<div class="flex-1 truncate min-w-0">{{ label }}</div>
<div
v-if="reauthorizationRequired"
v-tooltip.top-end="$t('SIDEBAR.REAUTHORIZE')"
class="grid place-content-center size-5 bg-n-ruby-5/60 rounded-full"
>
<Icon icon="i-woot-alert" class="size-3 text-n-ruby-9" />
</div>
</template>

View File

@@ -0,0 +1,473 @@
<script setup>
import { h, computed, onMounted } from 'vue';
import { provideSidebarContext } from './provider';
import { useAccount } from 'dashboard/composables/useAccount';
import { useKbd } from 'dashboard/composables/utils/useKbd';
import { useMapGetter } from 'dashboard/composables/store';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useStorage } from '@vueuse/core';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import SidebarGroup from './SidebarGroup.vue';
import SidebarProfileMenu from './SidebarProfileMenu.vue';
import ChannelLeaf from './ChannelLeaf.vue';
import SidebarNotificationBell from './SidebarNotificationBell.vue';
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
import Logo from 'next/icon/Logo.vue';
const emit = defineEmits([
'openNotificationPanel',
'closeKeyShortcutModal',
'openKeyShortcutModal',
'showCreateAccountModal',
]);
const { accountScopedRoute } = useAccount();
const store = useStore();
const searchShortcut = useKbd([`$mod`, 'k']);
const { t } = useI18n();
const enableNewConversation = false;
const toggleShortcutModalFn = show => {
if (show) {
emit('openKeyShortcutModal');
} else {
emit('closeKeyShortcutModal');
}
};
useSidebarKeyboardShortcuts(toggleShortcutModalFn);
// We're using localStorage to store the expanded item in the sidebar
// This helps preserve context when navigating between portal and dashboard layouts
// and also when the user refreshes the page
const expandedItem = useStorage(
'next-sidebar-expanded-item',
null,
localStorage
);
const setExpandedItem = name => {
expandedItem.value = expandedItem.value === name ? null : name;
};
provideSidebarContext({
expandedItem,
setExpandedItem,
});
const inboxes = useMapGetter('inboxes/getInboxes');
const labels = useMapGetter('labels/getLabelsOnSidebar');
const teams = useMapGetter('teams/getMyTeams');
const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
const conversationCustomViews = useMapGetter(
'customViews/getConversationCustomViews'
);
onMounted(() => {
store.dispatch('labels/get');
store.dispatch('inboxes/get');
store.dispatch('notifications/unReadCount');
store.dispatch('teams/get');
store.dispatch('attributes/get');
store.dispatch('customViews/get', 'conversation');
store.dispatch('customViews/get', 'contact');
});
const sortedInboxes = computed(() =>
inboxes.value.slice().sort((a, b) => a.name.localeCompare(b.name))
);
const menuItems = computed(() => {
return [
{
name: 'Inbox',
label: t('SIDEBAR.INBOX'),
icon: 'i-lucide-inbox',
to: accountScopedRoute('inbox_view'),
},
{
name: 'Conversation',
label: t('SIDEBAR.CONVERSATIONS'),
icon: 'i-lucide-message-circle',
children: [
{
name: 'All',
label: t('SIDEBAR.ALL_CONVERSATIONS'),
activeOn: ['inbox_conversation'],
to: accountScopedRoute('home'),
},
{
name: 'Mentions',
label: t('SIDEBAR.MENTIONED_CONVERSATIONS'),
activeOn: ['conversation_through_mentions'],
to: accountScopedRoute('conversation_mentions'),
},
{
name: 'Unattended',
activeOn: ['conversation_through_unattended'],
label: t('SIDEBAR.UNATTENDED_CONVERSATIONS'),
to: accountScopedRoute('conversation_unattended'),
},
{
name: 'Folders',
label: t('SIDEBAR.CUSTOM_VIEWS_FOLDER'),
icon: 'i-lucide-folder',
activeOn: ['conversations_through_folders'],
children: conversationCustomViews.value.map(view => ({
name: `${view.name}-${view.id}`,
label: view.name,
to: accountScopedRoute('folder_conversations', { id: view.id }),
})),
},
{
name: 'Teams',
label: t('SIDEBAR.TEAMS'),
icon: 'i-lucide-users',
activeOn: ['conversations_through_team'],
children: teams.value.map(team => ({
name: `${team.name}-${team.id}`,
label: team.name,
to: accountScopedRoute('team_conversations', { teamId: team.id }),
})),
},
{
name: 'Channels',
label: t('SIDEBAR.CHANNELS'),
icon: 'i-lucide-mailbox',
activeOn: ['conversation_through_inbox'],
children: sortedInboxes.value.map(inbox => ({
name: `${inbox.name}-${inbox.id}`,
label: inbox.name,
to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }),
component: leafProps => h(ChannelLeaf, { ...leafProps, inbox }),
})),
},
{
name: 'Labels',
label: t('SIDEBAR.LABELS'),
icon: 'i-lucide-tag',
activeOn: ['conversations_through_label'],
children: labels.value.map(label => ({
name: `${label.title}-${label.id}`,
label: label.title,
icon: h('span', {
class: `size-[12px] ring-1 ring-n-alpha-1 dark:ring-white/20 ring-inset rounded-sm`,
style: { backgroundColor: label.color },
}),
to: accountScopedRoute('label_conversations', {
label: label.title,
}),
})),
},
],
},
{
name: 'Captain',
icon: 'i-lucide-bot',
label: t('SIDEBAR.CAPTAIN'),
to: accountScopedRoute('captain'),
},
{
name: 'Contacts',
label: t('SIDEBAR.CONTACTS'),
icon: 'i-lucide-contact',
children: [
{
name: 'All Contacts',
label: t('SIDEBAR.ALL_CONTACTS'),
to: accountScopedRoute('contacts_dashboard'),
},
{
name: 'Segments',
icon: 'i-lucide-group',
label: t('SIDEBAR.CUSTOM_VIEWS_SEGMENTS'),
children: contactCustomViews.value.map(view => ({
name: `${view.name}-${view.id}`,
label: view.name,
to: accountScopedRoute('contacts_segments_dashboard', {
id: view.id,
}),
})),
},
{
name: 'Tagged With',
icon: 'i-lucide-tag',
label: t('SIDEBAR.TAGGED_WITH'),
children: labels.value.map(label => ({
name: `${label.title}-${label.id}`,
label: label.title,
icon: h('span', {
class: `size-[12px] ring-1 ring-n-alpha-1 dark:ring-white/20 ring-inset rounded-sm`,
style: { backgroundColor: label.color },
}),
to: accountScopedRoute('contacts_labels_dashboard', {
label: label.title,
}),
})),
},
],
},
{
name: 'Reports',
label: t('SIDEBAR.REPORTS'),
icon: 'i-lucide-chart-spline',
children: [
{
name: 'Report Overview',
label: t('SIDEBAR.REPORTS_OVERVIEW'),
to: accountScopedRoute('account_overview_reports'),
},
{
name: 'Report Conversation',
label: t('SIDEBAR.REPORTS_CONVERSATION'),
to: accountScopedRoute('conversation_reports'),
},
{
name: 'Reports CSAT',
label: t('SIDEBAR.CSAT'),
to: accountScopedRoute('csat_reports'),
},
{
name: 'Reports Bot',
label: t('SIDEBAR.REPORTS_BOT'),
to: accountScopedRoute('bot_reports'),
},
{
name: 'Reports Agent',
label: t('SIDEBAR.REPORTS_AGENT'),
to: accountScopedRoute('agent_reports'),
},
{
name: 'Reports Label',
label: t('SIDEBAR.REPORTS_LABEL'),
to: accountScopedRoute('label_reports'),
},
{
name: 'Reports Inbox',
label: t('SIDEBAR.REPORTS_INBOX'),
to: accountScopedRoute('inbox_reports'),
},
{
name: 'Reports Team',
label: t('SIDEBAR.REPORTS_TEAM'),
to: accountScopedRoute('team_reports'),
},
{
name: 'Reports SLA',
label: t('SIDEBAR.REPORTS_SLA'),
to: accountScopedRoute('sla_reports'),
},
],
},
{
name: 'Campaigns',
label: t('SIDEBAR.CAMPAIGNS'),
icon: 'i-lucide-megaphone',
children: [
{
name: 'Ongoing',
label: t('SIDEBAR.ONGOING'),
to: accountScopedRoute('ongoing_campaigns'),
},
{
name: 'One-off',
label: t('SIDEBAR.ONE_OFF'),
to: accountScopedRoute('one_off'),
},
],
},
{
name: 'Portals',
label: t('SIDEBAR.HELP_CENTER.TITLE'),
icon: 'i-lucide-library-big',
to: accountScopedRoute('default_portal_articles'),
activeOn: [
'all_locale_categories',
'default_portal_articles',
'edit_article',
'edit_category',
'edit_portal_customization',
'edit_portal_information',
'edit_portal_locales',
'list_all_locale_articles',
'list_all_locale_categories',
'list_all_portals',
'list_archived_articles',
'list_draft_articles',
'list_mine_articles',
'new_article',
'new_category_in_locale',
'new_portal_information',
'portalSlug',
'portal_customization',
'portal_finish',
'show_category',
'show_category_articles',
],
},
{
name: 'Settings',
label: t('SIDEBAR.SETTINGS'),
icon: 'i-lucide-bolt',
children: [
{
name: 'Settings Account Settings',
label: t('SIDEBAR.ACCOUNT_SETTINGS'),
icon: 'i-lucide-briefcase',
to: accountScopedRoute('general_settings_index'),
},
{
name: 'Settings Agents',
label: t('SIDEBAR.AGENTS'),
icon: 'i-lucide-square-user',
to: accountScopedRoute('agent_list'),
},
{
name: 'Settings Teams',
label: t('SIDEBAR.TEAMS'),
icon: 'i-lucide-users',
to: accountScopedRoute('settings_teams_list'),
},
{
name: 'Settings Inboxes',
label: t('SIDEBAR.INBOXES'),
icon: 'i-lucide-inbox',
to: accountScopedRoute('settings_inbox_list'),
},
{
name: 'Settings Labels',
label: t('SIDEBAR.LABELS'),
icon: 'i-lucide-tags',
to: accountScopedRoute('labels_list'),
},
{
name: 'Settings Custom Attributes',
label: t('SIDEBAR.CUSTOM_ATTRIBUTES'),
icon: 'i-lucide-code',
to: accountScopedRoute('attributes_list'),
},
{
name: 'Settings Automation',
label: t('SIDEBAR.AUTOMATION'),
icon: 'i-lucide-workflow',
to: accountScopedRoute('automation_list'),
},
{
name: 'Settings Agent Bots',
label: t('SIDEBAR.AGENT_BOTS'),
icon: 'i-lucide-bot',
to: accountScopedRoute('agent_bots'),
},
{
name: 'Settings Macros',
label: t('SIDEBAR.MACROS'),
icon: 'i-lucide-toy-brick',
to: accountScopedRoute('macros_wrapper'),
},
{
name: 'Settings Canned Responses',
label: t('SIDEBAR.CANNED_RESPONSES'),
icon: 'i-lucide-message-square-quote',
to: accountScopedRoute('canned_list'),
},
{
name: 'Settings Integrations',
label: t('SIDEBAR.INTEGRATIONS'),
icon: 'i-lucide-blocks',
to: accountScopedRoute('settings_applications'),
},
{
name: 'Settings Audit Logs',
label: t('SIDEBAR.AUDIT_LOGS'),
icon: 'i-lucide-briefcase',
to: accountScopedRoute('auditlogs_list'),
},
{
name: 'Settings Custom Roles',
label: t('SIDEBAR.CUSTOM_ROLES'),
icon: 'i-lucide-shield-plus',
to: accountScopedRoute('custom_roles_list'),
},
{
name: 'Settings Sla',
label: t('SIDEBAR.SLA'),
icon: 'i-lucide-clock-alert',
to: accountScopedRoute('sla_list'),
},
{
name: 'Settings Billing',
label: t('SIDEBAR.BILLING'),
icon: 'i-lucide-credit-card',
to: accountScopedRoute('billing_settings_index'),
},
],
},
];
});
</script>
<template>
<aside
class="w-[200px] bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak h-screen flex flex-col text-sm pt-2 pb-1"
>
<section class="grid gap-2 mt-2 mb-4">
<div class="flex gap-2 px-2 items-center min-w-0">
<div class="size-6 grid place-content-center flex-shrink-0">
<Logo />
</div>
<div class="w-px h-3 bg-n-strong flex-shrink-0" />
<SidebarAccountSwitcher
class="-mx-1 flex-grow min-w-0"
@show-create-account-modal="emit('showCreateAccountModal')"
/>
</div>
<div class="gap-2 flex px-2">
<RouterLink
:to="{ name: 'search' }"
class="rounded-lg py-1 flex items-center gap-2 px-2 border-n-weak border bg-n-solid-3 dark:bg-n-black/30 w-full"
>
<span class="i-lucide-search size-4 text-n-slate-11 flex-shrink-0" />
<span class="flex-grow text-left">
{{ t('COMBOBOX.SEARCH_PLACEHOLDER') }}
</span>
<span
class="tracking-wide select-none pointer-events-none text-n-slate-10 hidden"
>
{{ searchShortcut }}
</span>
</RouterLink>
<button
v-if="enableNewConversation"
class="rounded-lg py-1 flex items-center gap-2 px-2 border-n-weak border bg-n-solid-3 w-full"
>
<span
class="i-lucide-square-pen size-4 text-n-slate-11 flex-shrink-0"
/>
</button>
</div>
</section>
<nav class="grid gap-2 overflow-y-scroll no-scrollbar px-2 flex-grow pb-5">
<ul class="flex flex-col gap-2 list-none m-0">
<SidebarGroup
v-for="item in menuItems"
:key="item.name"
v-bind="item"
/>
</ul>
</nav>
<section
class="p-1 border-t border-n-strong shadow-[0px_-2px_4px_0px_rgba(27,28,29,0.02)] flex-shrink-0 flex justify-between gap-2 items-center"
>
<SidebarProfileMenu
@open-key-shortcut-modal="emit('openKeyShortcutModal')"
/>
<div v-if="false" class="flex items-center">
<div class="w-px h-3 bg-n-strong flex-shrink-0" />
<SidebarNotificationBell
@open-notification-panel="emit('openNotificationPanel')"
/>
</div>
</section>
</aside>
</template>

View File

@@ -0,0 +1,110 @@
<script setup>
import { useAccount } from 'dashboard/composables/useAccount';
import { useMapGetter } from 'dashboard/composables/store';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import ButtonNext from 'next/button/Button.vue';
import Icon from 'next/icon/Icon.vue';
const emit = defineEmits(['showCreateAccountModal']);
const { t } = useI18n();
const { accountId, currentAccount } = useAccount();
const currentUser = useMapGetter('getCurrentUser');
const globalConfig = useMapGetter('globalConfig/get');
const [showDropdown, toggleDropdown] = useToggle(false);
const close = () => {
if (showDropdown.value) {
toggleDropdown(false);
}
};
const onChangeAccount = newId => {
const accountUrl = `/app/accounts/${newId}/dashboard`;
window.location.href = accountUrl;
};
const emitNewAccount = () => {
close();
emit('showCreateAccountModal');
};
</script>
<template>
<div class="relative z-20">
<button
id="sidebar-account-switcher"
:data-account-id="accountId"
aria-haspopup="listbox"
aria-controls="account-options"
class="flex items-center gap-2 justify-between w-full rounded-lg hover:bg-n-alpha-1 px-2"
:class="{ 'bg-n-alpha-1': showDropdown }"
@click="toggleDropdown()"
>
<span
class="text-sm font-medium leading-5 text-n-slate-12 truncate"
aria-live="polite"
>
{{ currentAccount.name }}
</span>
<span
aria-hidden="true"
class="i-lucide-chevron-down size-4 text-n-slate-10 flex-shrink-0"
/>
</button>
<div v-if="showDropdown" v-on-clickaway="close" class="absolute top-8 z-50">
<div
class="w-72 text-sm block bg-n-solid-1 border border-n-weak rounded-xl shadow-sm"
>
<div
class="px-4 pt-3 pb-2 leading-4 font-medium tracking-[0.2px] text-n-slate-10 text-xs"
>
{{ t('SIDEBAR_ITEMS.CHANGE_ACCOUNTS') }}
</div>
<div class="px-1 gap-1 grid">
<button
v-for="account in currentUser.accounts"
:id="`account-${account.id}`"
:key="account.id"
class="flex w-full hover:bg-n-alpha-1 space-x-4"
@click="onChangeAccount(account.id)"
>
<div
:for="account.name"
class="text-left rtl:text-right flex gap-2"
>
<span class="text-n-slate-12">
{{ account.name }}
</span>
<span class="text-n-slate-11 capitalize">
{{
account.custom_role_id
? account.custom_role.name
: account.role
}}
</span>
</div>
<Icon
v-show="account.id === accountId"
icon="i-lucide-check"
class="text-n-teal-11 size-5"
/>
</button>
</div>
<div class="px-2 mt-2 pb-2">
<ButtonNext
v-if="globalConfig.createNewAccountFromDashboard"
variant="secondary"
class="w-full"
size="sm"
@click="emitNewAccount"
>
{{ t('CREATE_ACCOUNT.NEW_ACCOUNT') }}
</ButtonNext>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,146 @@
<script setup>
import { computed, watch, ref } from 'vue';
import { useSidebarContext } from './provider';
import { useRoute } from 'vue-router';
import Policy from 'dashboard/components/policy.vue';
import SidebarGroupHeader from './SidebarGroupHeader.vue';
import SidebarGroupLeaf from './SidebarGroupLeaf.vue';
import SidebarSubGroup from './SidebarSubGroup.vue';
import SidebarGroupEmptyLeaf from './SidebarGroupEmptyLeaf.vue';
const props = defineProps({
name: { type: String, required: true },
label: { type: String, required: true },
icon: { type: [String, Object, Function], default: null },
to: { type: Object, default: null },
activeOn: { type: Array, default: () => [] },
children: { type: Array, default: undefined },
});
const {
expandedItem,
setExpandedItem,
resolvePath,
resolvePermissions,
resolveFeatureFlag,
isAllowed,
} = useSidebarContext();
const parentEl = ref(null);
const locateLastChild = () => {
parentEl.value?.querySelectorAll('.child-item').forEach((child, index) => {
if (index === parentEl.value.querySelectorAll('.child-item').length - 1) {
child.classList.add('last-child-item');
}
});
};
const navigableChildren = computed(() => {
return props.children?.flatMap(child => child.children || child) || [];
});
const route = useRoute();
const isExpanded = computed(() => expandedItem.value === props.name);
const isExpandable = computed(() => props.children);
const hasChildren = computed(
() => Array.isArray(props.children) && props.children.length > 0
);
const accessibleItems = computed(() => {
if (!hasChildren.value) return [];
return props.children.filter(child => isAllowed(child.to));
});
const hasAccessibleItems = computed(() => {
// default true so that rendering is not blocked
if (!hasChildren.value) return true;
return accessibleItems.value.length > 0;
});
const isActive = computed(() => {
if (props.to) {
if (route.path === resolvePath(props.to)) return true;
return props.activeOn.includes(route.name);
}
return false;
});
// We could use the RouterLink isActive too, but our routes are not always
// nested correctly, so we need to check the active state ourselves
// TODO: Audit the routes and fix the nesting and remove this
const activeChild = computed(() => {
const pathSame = navigableChildren.value.find(
child => child.to && route.path === resolvePath(child.to)
);
if (pathSame) return pathSame;
const pathSatrtsWith = navigableChildren.value.find(
child => child.to && route.path.startsWith(resolvePath(child.to))
);
if (pathSatrtsWith) return pathSatrtsWith;
return navigableChildren.value.find(child =>
child.activeOn?.includes(route.name)
);
});
const hasActiveChild = computed(() => {
return activeChild.value !== undefined;
});
watch(expandedItem, locateLastChild, {
immediate: true,
});
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<Policy
v-if="hasAccessibleItems"
:permissions="resolvePermissions(to)"
:feature-flag="resolveFeatureFlag(to)"
as="li"
class="text-sm cursor-pointer select-none gap-1 grid"
>
<SidebarGroupHeader
:icon
:name
:label
:to
:is-active="isActive"
:has-active-child="hasActiveChild"
:expandable="hasChildren"
:is-expanded="isExpanded"
@toggle="setExpandedItem(name)"
/>
<ul
v-if="hasChildren"
v-show="isExpanded || hasActiveChild"
ref="parentEl"
class="list-none m-0 grid sidebar-group-children"
>
<template v-for="child in children" :key="child.name">
<SidebarSubGroup
v-if="child.children"
v-bind="child"
:is-expanded="isExpanded"
:active-child="activeChild"
/>
<SidebarGroupLeaf
v-else
v-show="isExpanded || activeChild?.name === child.name"
v-bind="child"
:active="activeChild?.name === child.name"
/>
</template>
</ul>
<ul v-else-if="isExpandable && isExpanded">
<SidebarGroupEmptyLeaf />
</ul>
</Policy>
</template>

View File

@@ -0,0 +1,13 @@
<script setup>
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
</script>
<template>
<li
class="py-1 pl-3 text-n-slate-10 border rounded-lg border-dashed text-center border-n-alpha-2 text-xs h-8 grid place-content-center select-none pointer-events-none"
>
<slot>{{ t('SIDEBAR.NO_ITEMS') }}</slot>
</li>
</template>

View File

@@ -0,0 +1,42 @@
<script setup>
import Icon from 'next/icon/Icon.vue';
defineProps({
to: { type: [Object, String], default: '' },
label: { type: String, default: '' },
icon: { type: [String, Object], default: '' },
expandable: { type: Boolean, default: false },
isExpanded: { type: Boolean, default: false },
isActive: { type: Boolean, default: false },
hasActiveChild: { type: Boolean, default: false },
});
const emit = defineEmits(['toggle']);
</script>
<template>
<component
:is="to ? 'router-link' : 'div'"
class="flex items-center gap-2 px-2 py-1.5 rounded-lg h-8"
role="button"
:to="to"
:title="label"
:class="{
'text-n-blue bg-n-alpha-2 font-medium': isActive && !hasActiveChild,
'text-n-slate-12 font-medium': hasActiveChild,
'text-n-slate-11 hover:bg-n-alpha-2': !isActive && !hasActiveChild,
}"
@click.stop="emit('toggle')"
>
<Icon v-if="icon" :icon="icon" class="size-4" />
<span class="text-sm font-medium leading-5 flex-grow">
{{ label }}
</span>
<span
v-if="expandable"
v-show="isExpanded"
class="i-lucide-chevron-up size-3"
@click.stop="emit('toggle')"
/>
</component>
</template>

View File

@@ -0,0 +1,92 @@
<script setup>
import { isVNode, computed } from 'vue';
import Icon from 'next/icon/Icon.vue';
import Policy from 'dashboard/components/policy.vue';
import { useSidebarContext } from './provider';
const props = defineProps({
label: { type: String, required: true },
to: { type: [String, Object], required: true },
icon: { type: [String, Object], default: null },
active: { type: Boolean, default: false },
component: { type: Function, default: null },
});
const { resolvePermissions, resolveFeatureFlag } = useSidebarContext();
const shouldRenderComponent = computed(() => {
return typeof props.component === 'function' || isVNode(props.component);
});
</script>
<template>
<Policy
:permissions="resolvePermissions(to)"
:feature-flag="resolveFeatureFlag(to)"
as="li"
class="py-0.5 ltr:pl-3 rtl:pr-3 rtl:mr-3 ltr:ml-3 relative text-n-slate-11 child-item before:bg-n-slate-4 after:bg-transparent after:border-n-slate-4 before:left-0 rtl:before:right-0"
>
<component
:is="to ? 'router-link' : 'div'"
:to="to"
:title="label"
class="flex h-8 items-center gap-2 px-2 py-1 rounded-lg max-w-[151px] hover:bg-gradient-to-r from-transparent via-n-slate-3/70 to-n-slate-3/70 group"
:class="{
'text-n-blue bg-n-alpha-2 active': active,
}"
>
<component
:is="component"
v-if="shouldRenderComponent"
:label
:icon
:active
/>
<template v-else>
<Icon v-if="icon" :icon="icon" class="size-4 inline-block" />
<div class="flex-1 truncate min-w-0">{{ label }}</div>
</template>
</component>
</Policy>
</template>
<style scoped>
.child-item::before {
content: '';
position: absolute;
width: 0.125rem;
/* 0.5px */
height: 100%;
}
.child-item:first-child::before {
border-radius: 4px 4px 0 0;
}
.last-child-item::before {
height: 20%;
}
.last-child-item::after {
content: '';
position: absolute;
width: 10px;
height: 12px;
bottom: calc(50% - 2px);
border-bottom-width: 0.125rem;
border-left-width: 0.125rem;
border-right-width: 0px;
border-top-width: 0px;
border-radius: 0 0 0 4px;
left: 0;
}
.app-rtl--wrapper .last-child-item::after {
right: 0;
border-bottom-width: 0.125rem;
border-right-width: 0.125rem;
border-left-width: 0px;
border-top-width: 0px;
border-radius: 0 0 4px 0px;
}
</style>

View File

@@ -0,0 +1,25 @@
<script setup>
import Icon from 'next/icon/Icon.vue';
defineProps({
label: {
type: String,
default: '',
},
icon: {
type: String,
default: '',
},
});
</script>
<template>
<div
class="flex items-center gap-2 px-2 py-1.5 rounded-lg h-8 text-n-slate-10 select-none pointer-events-none"
>
<Icon v-if="icon" :icon="icon" class="size-4" />
<span class="text-sm font-medium leading-5 flex-grow">
{{ label }}
</span>
</div>
</template>

View File

@@ -0,0 +1,40 @@
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
const emit = defineEmits(['openNotificationPanel']);
const notificationMetadata = useMapGetter('notifications/getMeta');
const route = useRoute();
const unreadCount = computed(() => {
if (!notificationMetadata.value.unreadCount) {
return '';
}
return notificationMetadata.value.unreadCount < 100
? `${notificationMetadata.value.unreadCount}`
: '99+';
});
function openNotificationPanel() {
if (route.name !== 'notifications_index') {
emit('openNotificationPanel');
}
}
</script>
<template>
<button
class="size-8 rounded-lg hover:bg-n-alpha-1 flex-shrink-0 grid place-content-center relative"
@click="openNotificationPanel"
>
<span class="i-lucide-bell size-4" />
<span
v-if="unreadCount"
class="min-h-2 min-w-2 p-0.5 px-1 bg-n-ruby-9 rounded-lg absolute -top-1 -right-1.5 grid place-items-center text-[9px] leading-none text-n-ruby-3"
>
{{ unreadCount }}
</span>
</button>
</template>

View File

@@ -0,0 +1,146 @@
<script setup>
import { computed } from 'vue';
import Auth from 'dashboard/api/auth';
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import Avatar from 'next/avatar/Avatar.vue';
import Icon from 'next/icon/Icon.vue';
import SidebarProfileMenuStatus from './SidebarProfileMenuStatus.vue';
const emit = defineEmits(['close', 'openKeyShortcutModal']);
defineOptions({
inheritAttrs: false,
});
const { t } = useI18n();
const router = useRouter();
const globalConfig = useMapGetter('globalConfig/get');
const currentUser = useMapGetter('getCurrentUser');
const currentUserAvailability = useMapGetter('getCurrentUserAvailability');
const [showProfileMenu, toggleProfileMenu] = useToggle(false);
const closeMenu = () => {
if (showProfileMenu.value) {
emit('close');
toggleProfileMenu(false);
}
};
const menuItems = computed(() => {
return [
{
show: !!globalConfig.value.chatwootInboxToken,
label: t('SIDEBAR_ITEMS.CONTACT_SUPPORT'),
icon: 'i-lucide-life-buoy',
click: () => {
closeMenu();
window.$chatwoot.toggle();
},
},
{
show: true,
label: t('SIDEBAR_ITEMS.KEYBOARD_SHORTCUTS'),
icon: 'i-lucide-keyboard',
click: () => {
closeMenu();
emit('openKeyShortcutModal');
},
},
{
show: true,
label: t('SIDEBAR_ITEMS.PROFILE_SETTINGS'),
icon: 'i-lucide-user-pen',
click: () => {
closeMenu();
router.push({ name: 'profile_settings_index' });
},
},
{
show: true,
label: t('SIDEBAR_ITEMS.APPEARANCE'),
icon: 'i-lucide-swatch-book',
click: () => {
closeMenu();
const ninja = document.querySelector('ninja-keys');
ninja.open({ parent: 'appearance_settings' });
},
},
{
show: currentUser.value.type === 'SuperAdmin',
label: t('SIDEBAR_ITEMS.SUPER_ADMIN_CONSOLE'),
icon: 'i-lucide-castle',
link: '/super_admin',
target: '_blank',
},
{
show: true,
label: t('SIDEBAR_ITEMS.LOGOUT'),
icon: 'i-lucide-log-out',
click: Auth.logout,
},
];
});
const allowedMenuItems = computed(() => {
return menuItems.value.filter(item => item.show);
});
</script>
<template>
<div class="relative z-20 w-full min-w-0">
<button
class="flex gap-2 items-center rounded-lg cursor-pointer text-left w-full hover:bg-n-alpha-1 p-1"
v-bind="$attrs"
:class="{
'bg-n-alpha-1': showProfileMenu,
}"
@click="toggleProfileMenu"
>
<Avatar
:size="32"
:name="currentUser.available_name"
:src="currentUser.avatar_url"
:status="currentUserAvailability"
class="flex-shrink-0"
rounded-full
/>
<div class="min-w-0">
<div class="text-n-slate-12 text-sm leading-4 font-medium truncate">
{{ currentUser.available_name }}
</div>
<div class="text-n-slate-11 text-xs truncate">
{{ currentUser.email }}
</div>
</div>
</button>
<div
v-if="showProfileMenu"
v-on-clickaway="closeMenu"
class="absolute left-0 bottom-12 z-50"
>
<div
class="w-72 min-h-32 bg-n-solid-1 border border-n-weak rounded-xl shadow-sm"
>
<SidebarProfileMenuStatus />
<div class="border-t border-n-strong mx-2 my-0" />
<ul class="list-none m-0 grid gap-1 p-1 text-n-slate-12">
<li v-for="item in allowedMenuItems" :key="item.label" class="m-0">
<component
:is="item.link ? 'a' : 'button'"
v-bind="item.link ? { target: item.target, href: item.link } : {}"
class="text-left hover:bg-n-alpha-1 px-2 py-1.5 w-full flex items-center gap-2"
@click="item.click"
>
<Icon :icon="item.icon" class="size-4" />
{{ item.label }}
</component>
</li>
</ul>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,119 @@
<script setup>
import { computed } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import wootConstants from 'dashboard/constants/globals';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import Icon from 'next/icon/Icon.vue';
const { t } = useI18n();
const store = useStore();
const currentUserAvailability = useMapGetter('getCurrentUserAvailability');
const currentAccountId = useMapGetter('getCurrentAccountId');
const currentUserAutoOffline = useMapGetter('getCurrentUserAutoOffline');
const { AVAILABILITY_STATUS_KEYS } = wootConstants;
const statusList = computed(() => {
return [
t('PROFILE_SETTINGS.FORM.AVAILABILITY.STATUS.ONLINE'),
t('PROFILE_SETTINGS.FORM.AVAILABILITY.STATUS.BUSY'),
t('PROFILE_SETTINGS.FORM.AVAILABILITY.STATUS.OFFLINE'),
];
});
const availabilityStatuses = computed(() => {
return statusList.value.map((statusLabel, index) => ({
label: statusLabel,
value: AVAILABILITY_STATUS_KEYS[index],
active: currentUserAvailability.value === AVAILABILITY_STATUS_KEYS[index],
}));
});
function changeAvailabilityStatus(availability) {
try {
store.dispatch('updateAvailability', {
availability,
account_id: currentAccountId.value,
});
} catch (error) {
useAlert(t('PROFILE_SETTINGS.FORM.AVAILABILITY.SET_AVAILABILITY_ERROR'));
}
}
function updateAutoOffline(autoOffline) {
store.dispatch('updateAutoOffline', {
accountId: currentAccountId.value,
autoOffline,
});
}
</script>
<template>
<div class="pt-2 text-n-slate-12">
<span
class="px-3 leading-4 font-medium tracking-[0.2px] text-n-slate-10 text-xs"
>
{{ t('SIDEBAR.SET_AVAILABILITY_TITLE') }}
</span>
<ul class="list-none m-0 grid gap-1 p-1">
<li
v-for="status in availabilityStatuses"
:key="status.value"
class="flex items-baseline"
>
<button
class="text-left rtl:text-right hover:bg-n-alpha-1 px-2 py-1.5 w-full flex items-center gap-2"
:class="{
'pointer-events-none bg-n-amber-10/10': status.active,
'bg-n-teal-3': status.active && status.value === 'online',
'bg-n-amber-3': status.active && status.value === 'busy',
'bg-n-slate-3': status.active && status.value === 'offline',
}"
@click="changeAvailabilityStatus(status.value)"
>
<div
class="rounded-full w-2.5 h-2.5"
:class="{
'bg-n-teal-10': status.value === 'online',
'bg-n-amber-10': status.value === 'busy',
'bg-n-slate-10': status.value === 'offline',
}"
/>
<span class="flex-grow">{{ status.label }}</span>
<Icon
v-if="status.active"
icon="i-lucide-check"
class="size-4 flex-shrink-0"
:class="{
'text-n-teal-11': status.value === 'online',
'text-n-amber-11': status.value === 'busy',
'text-n-slate-11': status.value === 'offline',
}"
/>
</button>
</li>
</ul>
</div>
<div class="border-t border-n-strong mx-2 my-0" />
<ul class="list-none m-0 grid gap-1 p-1">
<li class="px-2 py-1.5 flex items-start w-full gap-2">
<div class="h-5 flex items-center flex-shrink-0">
<Icon icon="i-lucide-info" class="size-4" />
</div>
<div class="flex-grow">
<div class="h-5 leading-none flex place-items-center text-n-slate-12">
{{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }}
</div>
<div class="text-xs leading-tight text-n-slate-10 mt-1">
{{ t('SIDEBAR.SET_AUTO_OFFLINE.INFO_SHORT') }}
</div>
</div>
<woot-switch
class="flex-shrink-0"
:model-value="currentUserAutoOffline"
@input="updateAutoOffline"
/>
</li>
</ul>
</template>

View File

@@ -0,0 +1,114 @@
<script setup>
import { computed, ref } from 'vue';
import SidebarGroupLeaf from './SidebarGroupLeaf.vue';
import SidebarGroupSeparator from './SidebarGroupSeparator.vue';
import SidebarGroupEmptyLeaf from './SidebarGroupEmptyLeaf.vue';
import { useSidebarContext } from './provider';
import { useEventListener } from '@vueuse/core';
const props = defineProps({
isExpanded: { type: Boolean, default: false },
label: { type: String, required: true },
icon: { type: [Object, String], required: true },
children: { type: Array, default: undefined },
activeChild: { type: Object, default: undefined },
});
const { isAllowed } = useSidebarContext();
const scrollableContainer = ref(null);
const accessibleItems = computed(() =>
props.children.filter(child => isAllowed(child.to))
);
const hasAccessibleItems = computed(() => {
if (props.children.length === 0) {
// cases like segment, folder and labels where users can create new items
return true;
}
return accessibleItems.value.length > 0;
});
const isScrollable = computed(() => {
return accessibleItems.value.length > 7;
});
const scrollEnd = ref(false);
// set scrollEnd to true when the scroll reaches the end
useEventListener(scrollableContainer, 'scroll', () => {
const { scrollHeight, scrollTop, clientHeight } = scrollableContainer.value;
scrollEnd.value = scrollHeight - scrollTop === clientHeight;
});
</script>
<template>
<SidebarGroupSeparator
v-if="hasAccessibleItems"
v-show="isExpanded"
:label
:icon
class="my-1"
/>
<ul class="m-0 list-none relative group">
<!-- Each element has h-8, which is 32px, we will show 7 items with one hidden at the end,
which is 14rem. Then we add 16px so that we have some text visible from the next item -->
<div
ref="scrollableContainer"
:class="{
'max-h-[calc(14rem+16px)] overflow-y-scroll no-scrollbar': isScrollable,
}"
>
<template v-if="children.length">
<SidebarGroupLeaf
v-for="child in children"
v-show="isExpanded || activeChild?.name === child.name"
v-bind="child"
:key="child.name"
:active="activeChild?.name === child.name"
/>
</template>
<SidebarGroupEmptyLeaf v-else v-show="isExpanded" class="ml-3 rtl:mr-3" />
</div>
<div
v-if="isScrollable && isExpanded"
v-show="!scrollEnd"
class="absolute bg-gradient-to-t from-n-solid-2 w-full h-12 to-transparent -bottom-1 pointer-events-none flex items-end justify-end px-2 animate-fade-in-up"
>
<svg
width="16"
height="24"
viewBox="0 0 16 24"
fill="none"
class="text-n-slate-9 opacity-50 group-hover:opacity-100"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 4L8 8L12 4"
stroke="currentColor"
opacity="0.5"
stroke-width="1.33333"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M4 10L8 14L12 10"
stroke="currentColor"
opacity="0.75"
stroke-width="1.33333"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M4 16L8 20L12 16"
stroke="currentColor"
stroke-width="1.33333"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
</ul>
</template>

View File

@@ -0,0 +1,49 @@
import { inject, provide } from 'vue';
import { usePolicy } from 'dashboard/composables/usePolicy';
import { useRouter } from 'vue-router';
const SidebarControl = Symbol('SidebarControl');
export function useSidebarContext() {
const context = inject(SidebarControl, null);
if (context === null) {
throw new Error(`Component is missing a parent <Sidebar /> component.`);
}
const router = useRouter();
const { checkFeatureAllowed, checkPermissions } = usePolicy();
const resolvePath = to => {
if (to) return router.resolve(to)?.path || '/';
return '/';
};
const resolvePermissions = to => {
if (to) return router.resolve(to)?.meta?.permissions ?? [];
return [];
};
const resolveFeatureFlag = to => {
if (to) return router.resolve(to)?.meta?.featureFlag || '';
return '';
};
const isAllowed = to => {
const permissions = resolvePermissions(to);
const featureFlag = resolveFeatureFlag(to);
return checkPermissions(permissions) && checkFeatureAllowed(featureFlag);
};
return {
...context,
resolvePath,
resolvePermissions,
resolveFeatureFlag,
isAllowed,
};
}
export function provideSidebarContext(context) {
provide(SidebarControl, context);
}

View File

@@ -0,0 +1,39 @@
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useRoute, useRouter } from 'vue-router';
export function useSidebarKeyboardShortcuts(toggleShortcutModalFn) {
const route = useRoute();
const router = useRouter();
const isCurrentRouteSameAsNavigation = routeName => {
return route.name === routeName;
};
const navigateToRoute = routeName => {
if (!isCurrentRouteSameAsNavigation(routeName)) {
router.push({ name: routeName });
}
};
const keyboardEvents = {
'$mod+Slash': {
action: () => toggleShortcutModalFn(true),
},
'$mod+Escape': {
action: () => toggleShortcutModalFn(false),
},
'Alt+KeyC': {
action: () => navigateToRoute('home'),
},
'Alt+KeyV': {
action: () => navigateToRoute('contacts_dashboard'),
},
'Alt+KeyR': {
action: () => navigateToRoute('account_overview_reports'),
},
'Alt+KeyS': {
action: () => navigateToRoute('agent_list'),
},
};
return useKeyboardEvents(keyboardEvents);
}