mirror of
https://github.com/lingble/chatwoot.git
synced 2025-10-29 10:12:34 +00:00
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:
@@ -540,3 +540,15 @@
|
|||||||
--color-orange-900: 255 224 194;
|
--color-orange-900: 255 224 194;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||||
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
/* Hide scrollbar for IE, Edge and Firefox */
|
||||||
|
.no-scrollbar {
|
||||||
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,28 +7,58 @@ import Avatar from './Avatar.vue';
|
|||||||
<Variant title="Default">
|
<Variant title="Default">
|
||||||
<div class="p-4 bg-white dark:bg-slate-900">
|
<div class="p-4 bg-white dark:bg-slate-900">
|
||||||
<Avatar
|
<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"
|
class="bg-ruby-300 dark:bg-ruby-900"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Variant>
|
</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">
|
<Variant title="Different Sizes">
|
||||||
<div class="flex flex-wrap gap-4 p-4 bg-white dark:bg-slate-900">
|
<div class="flex flex-wrap gap-4 p-4 bg-white dark:bg-slate-900">
|
||||||
<Avatar
|
<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"
|
:size="48"
|
||||||
class="bg-green-300 dark:bg-green-900"
|
class="bg-green-300 dark:bg-green-900"
|
||||||
|
allow-upload
|
||||||
/>
|
/>
|
||||||
<Avatar
|
<Avatar
|
||||||
:size="72"
|
: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"
|
class="bg-indigo-300 dark:bg-indigo-900"
|
||||||
|
allow-upload
|
||||||
/>
|
/>
|
||||||
<Avatar
|
<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"
|
:size="96"
|
||||||
class="bg-woot-300 dark:bg-woot-900"
|
class="bg-woot-300 dark:bg-woot-900"
|
||||||
|
allow-upload
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Variant>
|
</Variant>
|
||||||
|
|||||||
@@ -1,52 +1,122 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import wootConstants from 'dashboard/constants/globals';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
src: {
|
src: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
size: {
|
size: {
|
||||||
type: Number,
|
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 emit = defineEmits(['upload']);
|
||||||
|
|
||||||
const avatarSize = computed(() => `${props.size}px`);
|
const isImageValid = ref(true);
|
||||||
const iconSize = computed(() => `${props.size / 2}px`);
|
|
||||||
|
|
||||||
const handleUploadAvatar = () => {
|
function invalidateCurrentImage() {
|
||||||
emit('upload');
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<span
|
||||||
class="relative flex flex-col items-center gap-2 select-none rounded-xl group/avatar"
|
class="relative inline"
|
||||||
:style="{
|
:style="{
|
||||||
width: avatarSize,
|
width: `${size}px`,
|
||||||
height: avatarSize,
|
height: `${size}px`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<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',
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</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
|
<img
|
||||||
v-if="src"
|
v-if="src && isImageValid"
|
||||||
:src="props.src"
|
:src="src"
|
||||||
alt="avatar"
|
:alt="name"
|
||||||
class="w-full h-full shadow-sm rounded-xl"
|
@error="invalidateCurrentImage"
|
||||||
/>
|
/>
|
||||||
|
<span v-else>
|
||||||
|
{{ initials }}
|
||||||
|
</span>
|
||||||
<div
|
<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"
|
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"
|
@click="emit('upload')"
|
||||||
>
|
>
|
||||||
<FluentIcon
|
<Icon
|
||||||
icon="upload-lucide"
|
icon="i-lucide-upload"
|
||||||
icon-lib="lucide"
|
class="text-white dark:text-white size-4"
|
||||||
:size="iconSize"
|
|
||||||
class="text-white dark:text-white"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</span>
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -118,7 +118,11 @@ const handleClick = () => {
|
|||||||
:icon-lib="iconLib"
|
:icon-lib="iconLib"
|
||||||
class="flex-shrink-0"
|
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
|
<FluentIcon
|
||||||
v-if="icon && iconPosition === 'right'"
|
v-if="icon && iconPosition === 'right'"
|
||||||
:icon="icon"
|
:icon="icon"
|
||||||
|
|||||||
19
app/javascript/dashboard/components-next/icon/Icon.vue
Normal file
19
app/javascript/dashboard/components-next/icon/Icon.vue
Normal 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>
|
||||||
28
app/javascript/dashboard/components-next/icon/Logo.vue
Normal file
28
app/javascript/dashboard/components-next/icon/Logo.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
473
app/javascript/dashboard/components-next/sidebar/Sidebar.vue
Normal file
473
app/javascript/dashboard/components-next/sidebar/Sidebar.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
49
app/javascript/dashboard/components-next/sidebar/provider.js
Normal file
49
app/javascript/dashboard/components-next/sidebar/provider.js
Normal 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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -109,7 +109,7 @@ const chatListLoading = useMapGetter('getChatListLoadingStatus');
|
|||||||
const activeInbox = useMapGetter('getSelectedInbox');
|
const activeInbox = useMapGetter('getSelectedInbox');
|
||||||
const conversationStats = useMapGetter('conversationStats/getStats');
|
const conversationStats = useMapGetter('conversationStats/getStats');
|
||||||
const appliedFilters = useMapGetter('getAppliedConversationFilters');
|
const appliedFilters = useMapGetter('getAppliedConversationFilters');
|
||||||
const folders = useMapGetter('customViews/getCustomViews');
|
const folders = useMapGetter('customViews/getConversationCustomViews');
|
||||||
const agentList = useMapGetter('agents/getAgents');
|
const agentList = useMapGetter('agents/getAgents');
|
||||||
const teamsList = useMapGetter('teams/getTeams');
|
const teamsList = useMapGetter('teams/getTeams');
|
||||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||||
|
import { mapGetters } from 'vuex';
|
||||||
import { emitter } from 'shared/helpers/mitt';
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -9,6 +11,18 @@ export default {
|
|||||||
default: 'small',
|
default: 'small',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
...mapGetters({
|
||||||
|
accountId: 'getCurrentAccountId',
|
||||||
|
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||||
|
}),
|
||||||
|
hasNextSidebar() {
|
||||||
|
return this.isFeatureEnabledonAccount(
|
||||||
|
this.accountId,
|
||||||
|
FEATURE_FLAGS.CHATWOOT_V4
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
onMenuItemClick() {
|
onMenuItemClick() {
|
||||||
emitter.emit(BUS_EVENTS.TOGGLE_SIDEMENU);
|
emitter.emit(BUS_EVENTS.TOGGLE_SIDEMENU);
|
||||||
@@ -17,8 +31,10 @@ export default {
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||||
<template>
|
<template>
|
||||||
<woot-button
|
<woot-button
|
||||||
|
v-if="!hasNextSidebar"
|
||||||
:size="size"
|
:size="size"
|
||||||
variant="clear"
|
variant="clear"
|
||||||
color-scheme="secondary"
|
color-scheme="secondary"
|
||||||
|
|||||||
@@ -104,6 +104,9 @@ export default {
|
|||||||
return '';
|
return '';
|
||||||
},
|
},
|
||||||
customViews() {
|
customViews() {
|
||||||
|
if (!this.activeCustomView) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
return this.$store.getters['customViews/getCustomViewsByFilterType'](
|
return this.$store.getters['customViews/getCustomViewsByFilterType'](
|
||||||
this.activeCustomView
|
this.activeCustomView
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,32 +1,31 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { useStoreGetters } from 'dashboard/composables/store';
|
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import {
|
|
||||||
getUserPermissions,
|
|
||||||
hasPermissions,
|
|
||||||
} from '../helper/permissionsHelper';
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
as: {
|
||||||
|
type: String,
|
||||||
|
default: 'div',
|
||||||
|
},
|
||||||
permissions: {
|
permissions: {
|
||||||
type: Array,
|
type: Array,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
featureFlag: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const getters = useStoreGetters();
|
const { checkFeatureAllowed, checkPermissions } = usePolicy();
|
||||||
const user = computed(() => getters.getCurrentUser.value);
|
|
||||||
const accountId = computed(() => getters.getCurrentAccountId.value);
|
const isFeatureAllowed = computed(() => checkFeatureAllowed(props.featureFlag));
|
||||||
const userPermissions = computed(() => {
|
const hasPermission = computed(() => checkPermissions(props.permissions));
|
||||||
return getUserPermissions(user.value, accountId.value);
|
|
||||||
});
|
|
||||||
const hasPermission = computed(() => {
|
|
||||||
return hasPermissions(props.permissions, userPermissions.value);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- eslint-disable vue/no-root-v-if -->
|
<!-- eslint-disable vue/no-root-v-if -->
|
||||||
<template>
|
<template>
|
||||||
<div v-if="hasPermission">
|
<component :is="as" v-if="isFeatureAllowed && hasPermission">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</component>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,10 +1,29 @@
|
|||||||
import { shallowMount } from '@vue/test-utils';
|
import { shallowMount } from '@vue/test-utils';
|
||||||
|
import { createStore } from 'vuex';
|
||||||
import SidemenuIcon from '../SidemenuIcon.vue';
|
import SidemenuIcon from '../SidemenuIcon.vue';
|
||||||
|
|
||||||
|
const store = createStore({
|
||||||
|
modules: {
|
||||||
|
auth: {
|
||||||
|
namespaced: false,
|
||||||
|
getters: {
|
||||||
|
getCurrentAccountId: () => 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
accounts: {
|
||||||
|
namespaced: true,
|
||||||
|
getters: {
|
||||||
|
isFeatureEnabledonAccount: () => () => false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
describe('SidemenuIcon', () => {
|
describe('SidemenuIcon', () => {
|
||||||
test('matches snapshot', () => {
|
test('matches snapshot', () => {
|
||||||
const wrapper = shallowMount(SidemenuIcon, {
|
const wrapper = shallowMount(SidemenuIcon, {
|
||||||
stubs: { WootButton: { template: '<button><slot /></button>' } },
|
stubs: { WootButton: { template: '<button><slot /></button>' } },
|
||||||
|
global: { plugins: [store] },
|
||||||
});
|
});
|
||||||
expect(wrapper.vm).toBeTruthy();
|
expect(wrapper.vm).toBeTruthy();
|
||||||
expect(wrapper.element).toMatchSnapshot();
|
expect(wrapper.element).toMatchSnapshot();
|
||||||
|
|||||||
@@ -1,6 +1,38 @@
|
|||||||
|
import { defineComponent, h } from 'vue';
|
||||||
|
import { createStore } from 'vuex';
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { useAccount } from '../useAccount';
|
import { useAccount } from '../useAccount';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
|
||||||
|
const store = createStore({
|
||||||
|
modules: {
|
||||||
|
auth: {
|
||||||
|
namespaced: false,
|
||||||
|
getters: {
|
||||||
|
getCurrentAccountId: () => 1,
|
||||||
|
getCurrentUser: () => ({
|
||||||
|
accounts: [
|
||||||
|
{ id: 1, name: 'Chatwoot', role: 'administrator' },
|
||||||
|
{ id: 2, name: 'GitX', role: 'agent' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
accounts: {
|
||||||
|
namespaced: true,
|
||||||
|
getters: {
|
||||||
|
getAccount: () => id => ({ id, name: 'Chatwoot' }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mountParams = {
|
||||||
|
global: {
|
||||||
|
plugins: [store],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
vi.mock('vue-router');
|
vi.mock('vue-router');
|
||||||
|
|
||||||
@@ -8,31 +40,77 @@ describe('useAccount', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useRoute.mockReturnValue({
|
useRoute.mockReturnValue({
|
||||||
params: {
|
params: {
|
||||||
accountId: 123,
|
accountId: '123',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const createComponent = () =>
|
||||||
|
defineComponent({
|
||||||
|
setup() {
|
||||||
|
return useAccount();
|
||||||
|
},
|
||||||
|
render() {
|
||||||
|
return h('div'); // Dummy render to satisfy mount
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
it('returns accountId as a computed property', () => {
|
it('returns accountId as a computed property', () => {
|
||||||
const { accountId } = useAccount();
|
const wrapper = mount(createComponent(), mountParams);
|
||||||
expect(accountId.value).toBe(123);
|
const { accountId } = wrapper.vm;
|
||||||
|
expect(accountId).toBe(123);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('generates account-scoped URLs correctly', () => {
|
it('generates account-scoped URLs correctly', () => {
|
||||||
const { accountScopedUrl } = useAccount();
|
const wrapper = mount(createComponent(), mountParams);
|
||||||
|
const { accountScopedUrl } = wrapper.vm;
|
||||||
const result = accountScopedUrl('settings/inbox/new');
|
const result = accountScopedUrl('settings/inbox/new');
|
||||||
expect(result).toBe('/app/accounts/123/settings/inbox/new');
|
expect(result).toBe('/app/accounts/123/settings/inbox/new');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles URLs with leading slash', () => {
|
it('handles URLs with leading slash', () => {
|
||||||
const { accountScopedUrl } = useAccount();
|
const wrapper = mount(createComponent(), mountParams);
|
||||||
|
const { accountScopedUrl } = wrapper.vm;
|
||||||
const result = accountScopedUrl('users');
|
const result = accountScopedUrl('users');
|
||||||
expect(result).toBe('/app/accounts/123/users');
|
expect(result).toBe('/app/accounts/123/users'); // Ensures no double slashes
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles empty URL', () => {
|
it('handles empty URL', () => {
|
||||||
const { accountScopedUrl } = useAccount();
|
const wrapper = mount(createComponent(), mountParams);
|
||||||
|
const { accountScopedUrl } = wrapper.vm;
|
||||||
const result = accountScopedUrl('');
|
const result = accountScopedUrl('');
|
||||||
expect(result).toBe('/app/accounts/123/');
|
expect(result).toBe('/app/accounts/123/');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns current account based on accountId', () => {
|
||||||
|
const wrapper = mount(createComponent(), mountParams);
|
||||||
|
const { currentAccount } = wrapper.vm;
|
||||||
|
expect(currentAccount).toEqual({ id: 123, name: 'Chatwoot' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an account-scoped route', () => {
|
||||||
|
const wrapper = mount(createComponent(), mountParams);
|
||||||
|
const { accountScopedRoute } = wrapper.vm;
|
||||||
|
const result = accountScopedRoute('accountDetail', { userId: 456 });
|
||||||
|
expect(result).toEqual({
|
||||||
|
name: 'accountDetail',
|
||||||
|
params: { accountId: 123, userId: 456 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns route with correct params', () => {
|
||||||
|
const wrapper = mount(createComponent(), mountParams);
|
||||||
|
const { route } = wrapper.vm;
|
||||||
|
expect(route.params).toEqual({ accountId: '123' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles non-numeric accountId gracefully', async () => {
|
||||||
|
useRoute.mockReturnValueOnce({
|
||||||
|
params: { accountId: 'abc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapper = mount(createComponent(), mountParams);
|
||||||
|
const { accountId } = wrapper.vm;
|
||||||
|
expect(accountId).toBeNaN(); // Handles invalid numeric conversion
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
|
import { useMapGetter } from './store';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composable for account-related operations.
|
* Composable for account-related operations.
|
||||||
@@ -11,11 +12,13 @@ export function useAccount() {
|
|||||||
* @type {import('vue').ComputedRef<number>}
|
* @type {import('vue').ComputedRef<number>}
|
||||||
*/
|
*/
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const getAccountFn = useMapGetter('accounts/getAccount');
|
||||||
const accountId = computed(() => {
|
const accountId = computed(() => {
|
||||||
return Number(route.params.accountId);
|
return Number(route.params.accountId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const currentAccount = computed(() => getAccountFn.value(accountId.value));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates an account-scoped URL.
|
* Generates an account-scoped URL.
|
||||||
* @param {string} url - The URL to be scoped to the account.
|
* @param {string} url - The URL to be scoped to the account.
|
||||||
@@ -25,8 +28,18 @@ export function useAccount() {
|
|||||||
return `/app/accounts/${accountId.value}/${url}`;
|
return `/app/accounts/${accountId.value}/${url}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const accountScopedRoute = (name, params) => {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
params: { accountId: accountId.value, ...params },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accountId,
|
accountId,
|
||||||
|
route,
|
||||||
|
currentAccount,
|
||||||
accountScopedUrl,
|
accountScopedUrl,
|
||||||
|
accountScopedRoute,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
29
app/javascript/dashboard/composables/usePolicy.js
Normal file
29
app/javascript/dashboard/composables/usePolicy.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
|
import {
|
||||||
|
getUserPermissions,
|
||||||
|
hasPermissions,
|
||||||
|
} from 'dashboard/helper/permissionsHelper';
|
||||||
|
|
||||||
|
export function usePolicy() {
|
||||||
|
const user = useMapGetter('getCurrentUser');
|
||||||
|
const isFeatureEnabled = useMapGetter('accounts/isFeatureEnabledonAccount');
|
||||||
|
const { accountId } = useAccount();
|
||||||
|
|
||||||
|
const getUserPermissionsForAccount = () => {
|
||||||
|
return getUserPermissions(user.value, accountId.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkFeatureAllowed = featureFlag => {
|
||||||
|
if (!featureFlag) return true;
|
||||||
|
return isFeatureEnabled.value(accountId.value, featureFlag);
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkPermissions = requiredPermissions => {
|
||||||
|
if (!requiredPermissions || !requiredPermissions.length) return true;
|
||||||
|
const userPermissions = getUserPermissionsForAccount();
|
||||||
|
return hasPermissions(requiredPermissions, userPermissions);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { checkFeatureAllowed, checkPermissions };
|
||||||
|
}
|
||||||
22
app/javascript/dashboard/composables/utils/useKbd.js
Normal file
22
app/javascript/dashboard/composables/utils/useKbd.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
export function useKbd(keys) {
|
||||||
|
const keySymbols = {
|
||||||
|
$mod: navigator.platform.includes('Mac') ? '⌘' : 'Ctrl',
|
||||||
|
shift: '⇧',
|
||||||
|
alt: '⌥',
|
||||||
|
ctrl: 'Ctrl',
|
||||||
|
cmd: '⌘',
|
||||||
|
option: '⌥',
|
||||||
|
enter: '↩',
|
||||||
|
tab: '⇥',
|
||||||
|
esc: '⎋',
|
||||||
|
};
|
||||||
|
|
||||||
|
return computed(() => {
|
||||||
|
return keys
|
||||||
|
.map(key => keySymbols[key.toLowerCase()] || key)
|
||||||
|
.join('')
|
||||||
|
.toUpperCase();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -32,4 +32,5 @@ export const FEATURE_FLAGS = {
|
|||||||
LINEAR: 'linear_integration',
|
LINEAR: 'linear_integration',
|
||||||
CAPTAIN: 'captain_integration',
|
CAPTAIN: 'captain_integration',
|
||||||
CUSTOM_ROLES: 'custom_roles',
|
CUSTOM_ROLES: 'custom_roles',
|
||||||
|
CHATWOOT_V4: 'chatwoot_v4',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -227,6 +227,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"SIDEBAR": {
|
"SIDEBAR": {
|
||||||
|
"NO_ITEMS": "No items",
|
||||||
"CURRENTLY_VIEWING_ACCOUNT": "Currently viewing:",
|
"CURRENTLY_VIEWING_ACCOUNT": "Currently viewing:",
|
||||||
"SWITCH": "Switch",
|
"SWITCH": "Switch",
|
||||||
"INBOX_VIEW": "Inbox View",
|
"INBOX_VIEW": "Inbox View",
|
||||||
@@ -291,9 +292,11 @@
|
|||||||
"SETTINGS": "Settings",
|
"SETTINGS": "Settings",
|
||||||
"CATEGORY_EMPTY_MESSAGE": "No categories found"
|
"CATEGORY_EMPTY_MESSAGE": "No categories found"
|
||||||
},
|
},
|
||||||
|
"CHANNELS": "Channels",
|
||||||
"SET_AUTO_OFFLINE": {
|
"SET_AUTO_OFFLINE": {
|
||||||
"TEXT": "Mark offline automatically",
|
"TEXT": "Mark offline automatically",
|
||||||
"INFO_TEXT": "Let the system automatically mark you offline when you aren't using the app or dashboard."
|
"INFO_TEXT": "Let the system automatically mark you offline when you aren't using the app or dashboard.",
|
||||||
|
"INFO_SHORT": "Automatically mark offline when you aren't using the app."
|
||||||
},
|
},
|
||||||
"DOCS": "Read docs"
|
"DOCS": "Read docs"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { mapGetters } from 'vuex';
|
||||||
import { defineAsyncComponent } from 'vue';
|
import { defineAsyncComponent } from 'vue';
|
||||||
|
|
||||||
|
import NextSidebar from 'next/sidebar/Sidebar.vue';
|
||||||
import Sidebar from '../../components/layout/Sidebar.vue';
|
import Sidebar from '../../components/layout/Sidebar.vue';
|
||||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
|
||||||
import WootKeyShortcutModal from 'dashboard/components/widgets/modal/WootKeyShortcutModal.vue';
|
import WootKeyShortcutModal from 'dashboard/components/widgets/modal/WootKeyShortcutModal.vue';
|
||||||
import AddAccountModal from 'dashboard/components/layout/sidebarComponents/AddAccountModal.vue';
|
import AddAccountModal from 'dashboard/components/layout/sidebarComponents/AddAccountModal.vue';
|
||||||
import AccountSelector from 'dashboard/components/layout/sidebarComponents/AccountSelector.vue';
|
import AccountSelector from 'dashboard/components/layout/sidebarComponents/AccountSelector.vue';
|
||||||
import AddLabelModal from 'dashboard/routes/dashboard/settings/labels/AddLabel.vue';
|
import AddLabelModal from 'dashboard/routes/dashboard/settings/labels/AddLabel.vue';
|
||||||
import NotificationPanel from 'dashboard/routes/dashboard/notifications/components/NotificationPanel.vue';
|
import NotificationPanel from 'dashboard/routes/dashboard/notifications/components/NotificationPanel.vue';
|
||||||
|
|
||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
|
|
||||||
import wootConstants from 'dashboard/constants/globals';
|
import wootConstants from 'dashboard/constants/globals';
|
||||||
|
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||||
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
|
|
||||||
const CommandBar = defineAsyncComponent(
|
const CommandBar = defineAsyncComponent(
|
||||||
() => import('./commands/commandbar.vue')
|
() => import('./commands/commandbar.vue')
|
||||||
);
|
);
|
||||||
@@ -16,6 +24,7 @@ import { emitter } from 'shared/helpers/mitt';
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
|
NextSidebar,
|
||||||
Sidebar,
|
Sidebar,
|
||||||
CommandBar,
|
CommandBar,
|
||||||
WootKeyShortcutModal,
|
WootKeyShortcutModal,
|
||||||
@@ -26,10 +35,12 @@ export default {
|
|||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
const { uiSettings, updateUISettings } = useUISettings();
|
const { uiSettings, updateUISettings } = useUISettings();
|
||||||
|
const { accountId } = useAccount();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
uiSettings,
|
uiSettings,
|
||||||
updateUISettings,
|
updateUISettings,
|
||||||
|
accountId,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -44,6 +55,9 @@ export default {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
...mapGetters({
|
||||||
|
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||||
|
}),
|
||||||
currentRoute() {
|
currentRoute() {
|
||||||
return ' ';
|
return ' ';
|
||||||
},
|
},
|
||||||
@@ -62,6 +76,12 @@ export default {
|
|||||||
this.uiSettings;
|
this.uiSettings;
|
||||||
return showSecondarySidebar;
|
return showSecondarySidebar;
|
||||||
},
|
},
|
||||||
|
showNextSidebar() {
|
||||||
|
return this.isFeatureEnabledonAccount(
|
||||||
|
this.accountId,
|
||||||
|
FEATURE_FLAGS.CHATWOOT_V4
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
displayLayoutType() {
|
displayLayoutType() {
|
||||||
@@ -155,7 +175,16 @@ export default {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-wrap app-wrapper dark:text-slate-300">
|
<div class="flex flex-wrap app-wrapper dark:text-slate-300">
|
||||||
|
<NextSidebar
|
||||||
|
v-if="showNextSidebar"
|
||||||
|
@toggle-account-modal="toggleAccountModal"
|
||||||
|
@open-notification-panel="openNotificationPanel"
|
||||||
|
@open-key-shortcut-modal="toggleKeyShortcutModal"
|
||||||
|
@close-key-shortcut-modal="closeKeyShortcutModal"
|
||||||
|
@show-create-account-modal="openCreateAccountModal"
|
||||||
|
/>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
|
v-else
|
||||||
:route="currentRoute"
|
:route="currentRoute"
|
||||||
:has-banner="hasBanner"
|
:has-banner="hasBanner"
|
||||||
:show-secondary-sidebar="isSidebarOpen"
|
:show-secondary-sidebar="isSidebarOpen"
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export default {
|
|||||||
records: 'contacts/getContacts',
|
records: 'contacts/getContacts',
|
||||||
uiFlags: 'contacts/getUIFlags',
|
uiFlags: 'contacts/getUIFlags',
|
||||||
meta: 'contacts/getMeta',
|
meta: 'contacts/getMeta',
|
||||||
segments: 'customViews/getCustomViews',
|
segments: 'customViews/getContactCustomViews',
|
||||||
getAppliedContactFilters: 'contacts/getAppliedContactFilters',
|
getAppliedContactFilters: 'contacts/getAppliedContactFilters',
|
||||||
}),
|
}),
|
||||||
showEmptySearchResult() {
|
showEmptySearchResult() {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { routes as inboxRoutes } from './inbox/routes';
|
|||||||
import { frontendURL } from '../../helper/URLHelper';
|
import { frontendURL } from '../../helper/URLHelper';
|
||||||
import helpcenterRoutes from './helpcenter/helpcenter.routes';
|
import helpcenterRoutes from './helpcenter/helpcenter.routes';
|
||||||
|
|
||||||
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
|
|
||||||
const AppContainer = () => import('./Dashboard.vue');
|
const AppContainer = () => import('./Dashboard.vue');
|
||||||
const Captain = () => import('./Captain.vue');
|
const Captain = () => import('./Captain.vue');
|
||||||
const Suspended = () => import('./suspended/Index.vue');
|
const Suspended = () => import('./suspended/Index.vue');
|
||||||
@@ -24,6 +26,7 @@ export default {
|
|||||||
component: Captain,
|
component: Captain,
|
||||||
meta: {
|
meta: {
|
||||||
permissions: ['administrator', 'agent'],
|
permissions: ['administrator', 'agent'],
|
||||||
|
featureFlag: FEATURE_FLAGS.CAPTAIN,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
...inboxRoutes,
|
...inboxRoutes,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { defineAsyncComponent } from 'vue';
|
import { defineAsyncComponent } from 'vue';
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
import UpgradePage from './UpgradePage.vue';
|
import UpgradePage from './UpgradePage.vue';
|
||||||
|
import NextSidebar from 'next/sidebar/Sidebar.vue';
|
||||||
import { frontendURL } from '../../../../helper/URLHelper';
|
import { frontendURL } from '../../../../helper/URLHelper';
|
||||||
import Sidebar from 'dashboard/components/layout/Sidebar.vue';
|
import Sidebar from 'dashboard/components/layout/Sidebar.vue';
|
||||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||||
@@ -22,6 +23,7 @@ const CommandBar = defineAsyncComponent(
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
|
NextSidebar,
|
||||||
AccountSelector,
|
AccountSelector,
|
||||||
AddCategory,
|
AddCategory,
|
||||||
CommandBar,
|
CommandBar,
|
||||||
@@ -68,6 +70,12 @@ export default {
|
|||||||
FEATURE_FLAGS.HELP_CENTER
|
FEATURE_FLAGS.HELP_CENTER
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
showNextSidebar() {
|
||||||
|
return this.isFeatureEnabledonAccount(
|
||||||
|
this.accountId,
|
||||||
|
FEATURE_FLAGS.CHATWOOT_V4
|
||||||
|
);
|
||||||
|
},
|
||||||
isSidebarOpen() {
|
isSidebarOpen() {
|
||||||
const { show_help_center_secondary_sidebar: showSecondarySidebar } =
|
const { show_help_center_secondary_sidebar: showSecondarySidebar } =
|
||||||
this.uiSettings;
|
this.uiSettings;
|
||||||
@@ -289,7 +297,15 @@ export default {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-grow-0 w-full h-full min-h-0 app-wrapper">
|
<div class="flex flex-grow-0 w-full h-full min-h-0 app-wrapper">
|
||||||
|
<NextSidebar
|
||||||
|
v-if="showNextSidebar"
|
||||||
|
@toggle-account-modal="toggleAccountModal"
|
||||||
|
@open-notification-panel="openNotificationPanel"
|
||||||
|
@open-key-shortcut-modal="toggleKeyShortcutModal"
|
||||||
|
@close-key-shortcut-modal="closeKeyShortcutModal"
|
||||||
|
/>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
|
v-else
|
||||||
:route="currentRoute"
|
:route="currentRoute"
|
||||||
@toggle-account-modal="toggleAccountModal"
|
@toggle-account-modal="toggleAccountModal"
|
||||||
@open-notification-panel="openNotificationPanel"
|
@open-notification-panel="openNotificationPanel"
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
CONVERSATION_PERMISSIONS,
|
CONVERSATION_PERMISSIONS,
|
||||||
} from 'dashboard/constants/permissions.js';
|
} from 'dashboard/constants/permissions.js';
|
||||||
|
|
||||||
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
|
|
||||||
export const routes = [
|
export const routes = [
|
||||||
{
|
{
|
||||||
path: frontendURL('accounts/:accountId/inbox-view'),
|
path: frontendURL('accounts/:accountId/inbox-view'),
|
||||||
@@ -18,6 +20,7 @@ export const routes = [
|
|||||||
component: InboxEmptyStateView,
|
component: InboxEmptyStateView,
|
||||||
meta: {
|
meta: {
|
||||||
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
||||||
|
featureFlag: FEATURE_FLAGS.INBOX_VIEW,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -26,6 +29,7 @@ export const routes = [
|
|||||||
component: InboxDetailView,
|
component: InboxDetailView,
|
||||||
meta: {
|
meta: {
|
||||||
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
||||||
|
featureFlag: FEATURE_FLAGS.INBOX_VIEW,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { frontendURL } from '../../../../helper/URLHelper';
|
import { frontendURL } from '../../../../helper/URLHelper';
|
||||||
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
|
|
||||||
const SettingsContent = () => import('../Wrapper.vue');
|
const SettingsContent = () => import('../Wrapper.vue');
|
||||||
const Index = () => import('./Index.vue');
|
const Index = () => import('./Index.vue');
|
||||||
@@ -90,6 +91,7 @@ export default {
|
|||||||
name: 'bot_reports',
|
name: 'bot_reports',
|
||||||
meta: {
|
meta: {
|
||||||
permissions: ['administrator', 'report_manage'],
|
permissions: ['administrator', 'report_manage'],
|
||||||
|
featureFlag: FEATURE_FLAGS.RESPONSE_BOT,
|
||||||
},
|
},
|
||||||
component: BotReports,
|
component: BotReports,
|
||||||
},
|
},
|
||||||
@@ -184,6 +186,7 @@ export default {
|
|||||||
name: 'sla_reports',
|
name: 'sla_reports',
|
||||||
meta: {
|
meta: {
|
||||||
permissions: ['administrator', 'report_manage'],
|
permissions: ['administrator', 'report_manage'],
|
||||||
|
featureFlag: FEATURE_FLAGS.SLA,
|
||||||
},
|
},
|
||||||
component: SLAReports,
|
component: SLAReports,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,8 +2,26 @@ import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
|
|||||||
import types from '../mutation-types';
|
import types from '../mutation-types';
|
||||||
import CustomViewsAPI from '../../api/customViews';
|
import CustomViewsAPI from '../../api/customViews';
|
||||||
|
|
||||||
|
const VIEW_TYPES = {
|
||||||
|
CONVERSATION: 'conversation',
|
||||||
|
CONTACT: 'contact',
|
||||||
|
};
|
||||||
|
|
||||||
|
// use to normalize the filter type
|
||||||
|
const FILTER_KEYS = {
|
||||||
|
0: VIEW_TYPES.CONVERSATION,
|
||||||
|
1: VIEW_TYPES.CONTACT,
|
||||||
|
[VIEW_TYPES.CONVERSATION]: VIEW_TYPES.CONVERSATION,
|
||||||
|
[VIEW_TYPES.CONTACT]: VIEW_TYPES.CONTACT,
|
||||||
|
};
|
||||||
|
|
||||||
export const state = {
|
export const state = {
|
||||||
|
[VIEW_TYPES.CONVERSATION]: {
|
||||||
records: [],
|
records: [],
|
||||||
|
},
|
||||||
|
[VIEW_TYPES.CONTACT]: {
|
||||||
|
records: [],
|
||||||
|
},
|
||||||
uiFlags: {
|
uiFlags: {
|
||||||
isFetching: false,
|
isFetching: false,
|
||||||
isCreating: false,
|
isCreating: false,
|
||||||
@@ -16,11 +34,15 @@ export const getters = {
|
|||||||
getUIFlags(_state) {
|
getUIFlags(_state) {
|
||||||
return _state.uiFlags;
|
return _state.uiFlags;
|
||||||
},
|
},
|
||||||
getCustomViews(_state) {
|
getCustomViewsByFilterType: _state => key => {
|
||||||
return _state.records;
|
const filterType = FILTER_KEYS[key];
|
||||||
|
return _state[filterType].records;
|
||||||
},
|
},
|
||||||
getCustomViewsByFilterType: _state => filterType => {
|
getConversationCustomViews(_state) {
|
||||||
return _state.records.filter(record => record.filter_type === filterType);
|
return _state[VIEW_TYPES.CONVERSATION].records;
|
||||||
|
},
|
||||||
|
getContactCustomViews(_state) {
|
||||||
|
return _state[VIEW_TYPES.CONTACT].records;
|
||||||
},
|
},
|
||||||
getActiveConversationFolder(_state) {
|
getActiveConversationFolder(_state) {
|
||||||
return _state.activeConversationFolder;
|
return _state.activeConversationFolder;
|
||||||
@@ -33,7 +55,7 @@ export const actions = {
|
|||||||
try {
|
try {
|
||||||
const response =
|
const response =
|
||||||
await CustomViewsAPI.getCustomViewsByFilterType(filterType);
|
await CustomViewsAPI.getCustomViewsByFilterType(filterType);
|
||||||
commit(types.SET_CUSTOM_VIEW, response.data);
|
commit(types.SET_CUSTOM_VIEW, { data: response.data, filterType });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ignore error
|
// Ignore error
|
||||||
} finally {
|
} finally {
|
||||||
@@ -44,7 +66,10 @@ export const actions = {
|
|||||||
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: true });
|
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: true });
|
||||||
try {
|
try {
|
||||||
const response = await CustomViewsAPI.create(obj);
|
const response = await CustomViewsAPI.create(obj);
|
||||||
commit(types.ADD_CUSTOM_VIEW, response.data);
|
commit(types.ADD_CUSTOM_VIEW, {
|
||||||
|
data: response.data,
|
||||||
|
filterType: FILTER_KEYS[obj.filter_type],
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error?.response?.data?.message;
|
const errorMessage = error?.response?.data?.message;
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
@@ -56,7 +81,10 @@ export const actions = {
|
|||||||
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: true });
|
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: true });
|
||||||
try {
|
try {
|
||||||
const response = await CustomViewsAPI.update(obj.id, obj);
|
const response = await CustomViewsAPI.update(obj.id, obj);
|
||||||
commit(types.UPDATE_CUSTOM_VIEW, response.data);
|
commit(types.UPDATE_CUSTOM_VIEW, {
|
||||||
|
data: response.data,
|
||||||
|
filterType: FILTER_KEYS[obj.filter_type],
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error?.response?.data?.message;
|
const errorMessage = error?.response?.data?.message;
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
@@ -68,7 +96,7 @@ export const actions = {
|
|||||||
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isDeleting: true });
|
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isDeleting: true });
|
||||||
try {
|
try {
|
||||||
await CustomViewsAPI.deleteCustomViews(id, filterType);
|
await CustomViewsAPI.deleteCustomViews(id, filterType);
|
||||||
commit(types.DELETE_CUSTOM_VIEW, id);
|
commit(types.DELETE_CUSTOM_VIEW, { data: id, filterType });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(error);
|
throw new Error(error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -88,10 +116,18 @@ export const mutations = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
[types.ADD_CUSTOM_VIEW]: MutationHelpers.create,
|
[types.ADD_CUSTOM_VIEW]: (_state, { data, filterType }) => {
|
||||||
[types.SET_CUSTOM_VIEW]: MutationHelpers.set,
|
MutationHelpers.create(_state[filterType], data);
|
||||||
[types.UPDATE_CUSTOM_VIEW]: MutationHelpers.update,
|
},
|
||||||
[types.DELETE_CUSTOM_VIEW]: MutationHelpers.destroy,
|
[types.SET_CUSTOM_VIEW]: (_state, { data, filterType }) => {
|
||||||
|
MutationHelpers.set(_state[filterType], data);
|
||||||
|
},
|
||||||
|
[types.UPDATE_CUSTOM_VIEW]: (_state, { data, filterType }) => {
|
||||||
|
MutationHelpers.update(_state[filterType], data);
|
||||||
|
},
|
||||||
|
[types.DELETE_CUSTOM_VIEW]: (_state, { data, filterType }) => {
|
||||||
|
MutationHelpers.destroy(_state[filterType], data);
|
||||||
|
},
|
||||||
|
|
||||||
[types.SET_ACTIVE_CONVERSATION_FOLDER](_state, folder) {
|
[types.SET_ACTIVE_CONVERSATION_FOLDER](_state, folder) {
|
||||||
_state.activeConversationFolder = folder;
|
_state.activeConversationFolder = folder;
|
||||||
|
|||||||
@@ -11,10 +11,13 @@ describe('#actions', () => {
|
|||||||
describe('#get', () => {
|
describe('#get', () => {
|
||||||
it('sends correct actions if API is success', async () => {
|
it('sends correct actions if API is success', async () => {
|
||||||
axios.get.mockResolvedValue({ data: customViewList });
|
axios.get.mockResolvedValue({ data: customViewList });
|
||||||
await actions.get({ commit });
|
await actions.get({ commit }, 'conversation');
|
||||||
expect(commit.mock.calls).toEqual([
|
expect(commit.mock.calls).toEqual([
|
||||||
[types.default.SET_CUSTOM_VIEW_UI_FLAG, { isFetching: true }],
|
[types.default.SET_CUSTOM_VIEW_UI_FLAG, { isFetching: true }],
|
||||||
[types.default.SET_CUSTOM_VIEW, customViewList],
|
[
|
||||||
|
types.default.SET_CUSTOM_VIEW,
|
||||||
|
{ data: customViewList, filterType: 'conversation' },
|
||||||
|
],
|
||||||
[types.default.SET_CUSTOM_VIEW_UI_FLAG, { isFetching: false }],
|
[types.default.SET_CUSTOM_VIEW_UI_FLAG, { isFetching: false }],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -30,11 +33,15 @@ describe('#actions', () => {
|
|||||||
|
|
||||||
describe('#create', () => {
|
describe('#create', () => {
|
||||||
it('sends correct actions if API is success', async () => {
|
it('sends correct actions if API is success', async () => {
|
||||||
axios.post.mockResolvedValue({ data: customViewList[0] });
|
const firstItem = customViewList[0];
|
||||||
await actions.create({ commit }, customViewList[0]);
|
axios.post.mockResolvedValue({ data: firstItem });
|
||||||
|
await actions.create({ commit }, firstItem);
|
||||||
expect(commit.mock.calls).toEqual([
|
expect(commit.mock.calls).toEqual([
|
||||||
[types.default.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: true }],
|
[types.default.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: true }],
|
||||||
[types.default.ADD_CUSTOM_VIEW, customViewList[0]],
|
[
|
||||||
|
types.default.ADD_CUSTOM_VIEW,
|
||||||
|
{ data: firstItem, filterType: 'conversation' },
|
||||||
|
],
|
||||||
[types.default.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: false }],
|
[types.default.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: false }],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -54,7 +61,7 @@ describe('#actions', () => {
|
|||||||
await actions.delete({ commit }, { id: 1, filterType: 'contact' });
|
await actions.delete({ commit }, { id: 1, filterType: 'contact' });
|
||||||
expect(commit.mock.calls).toEqual([
|
expect(commit.mock.calls).toEqual([
|
||||||
[types.default.SET_CUSTOM_VIEW_UI_FLAG, { isDeleting: true }],
|
[types.default.SET_CUSTOM_VIEW_UI_FLAG, { isDeleting: true }],
|
||||||
[types.default.DELETE_CUSTOM_VIEW, 1],
|
[types.default.DELETE_CUSTOM_VIEW, { data: 1, filterType: 'contact' }],
|
||||||
[types.default.SET_CUSTOM_VIEW_UI_FLAG, { isDeleting: false }],
|
[types.default.SET_CUSTOM_VIEW_UI_FLAG, { isDeleting: false }],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -70,15 +77,15 @@ describe('#actions', () => {
|
|||||||
|
|
||||||
describe('#update', () => {
|
describe('#update', () => {
|
||||||
it('sends correct actions if API is success', async () => {
|
it('sends correct actions if API is success', async () => {
|
||||||
axios.patch.mockResolvedValue({ data: updateCustomViewList[0] });
|
const item = updateCustomViewList[0];
|
||||||
await actions.update(
|
axios.patch.mockResolvedValue({ data: item });
|
||||||
{ commit },
|
await actions.update({ commit }, item);
|
||||||
updateCustomViewList[0].id,
|
|
||||||
updateCustomViewList[0]
|
|
||||||
);
|
|
||||||
expect(commit.mock.calls).toEqual([
|
expect(commit.mock.calls).toEqual([
|
||||||
[types.default.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: true }],
|
[types.default.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: true }],
|
||||||
[types.default.UPDATE_CUSTOM_VIEW, updateCustomViewList[0]],
|
[
|
||||||
|
types.default.UPDATE_CUSTOM_VIEW,
|
||||||
|
{ data: item, filterType: 'conversation' },
|
||||||
|
],
|
||||||
[types.default.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: false }],
|
[types.default.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: false }],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,20 @@
|
|||||||
|
export const contactViewList = [
|
||||||
|
{
|
||||||
|
name: 'Custom view 1',
|
||||||
|
filter_type: 1,
|
||||||
|
query: {
|
||||||
|
payload: [
|
||||||
|
{
|
||||||
|
attribute_key: 'name',
|
||||||
|
filter_operator: 'equal_to',
|
||||||
|
values: ['john doe'],
|
||||||
|
query_operator: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export const customViewList = [
|
export const customViewList = [
|
||||||
{
|
{
|
||||||
name: 'Custom view',
|
name: 'Custom view',
|
||||||
@@ -21,14 +38,14 @@ export const customViewList = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Custom view 1',
|
name: 'Custom view 1',
|
||||||
filter_type: 1,
|
filter_type: 0,
|
||||||
query: {
|
query: {
|
||||||
payload: [
|
payload: [
|
||||||
{
|
{
|
||||||
attribute_key: 'name',
|
attribute_key: 'assignee_id',
|
||||||
filter_operator: 'equal_to',
|
filter_operator: 'equal_to',
|
||||||
values: ['john doe'],
|
values: [45],
|
||||||
query_operator: null,
|
query_operator: 'and',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,49 +1,9 @@
|
|||||||
import { getters } from '../../customViews';
|
import { getters } from '../../customViews';
|
||||||
import { customViewList } from './fixtures';
|
import { contactViewList, customViewList } from './fixtures';
|
||||||
|
|
||||||
describe('#getters', () => {
|
describe('#getters', () => {
|
||||||
it('getCustomViews', () => {
|
|
||||||
const state = { records: customViewList };
|
|
||||||
expect(getters.getCustomViews(state)).toEqual([
|
|
||||||
{
|
|
||||||
name: 'Custom view',
|
|
||||||
filter_type: 0,
|
|
||||||
query: {
|
|
||||||
payload: [
|
|
||||||
{
|
|
||||||
attribute_key: 'assignee_id',
|
|
||||||
filter_operator: 'equal_to',
|
|
||||||
values: [45],
|
|
||||||
query_operator: 'and',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
attribute_key: 'inbox_id',
|
|
||||||
filter_operator: 'equal_to',
|
|
||||||
values: [144],
|
|
||||||
query_operator: 'and',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Custom view 1',
|
|
||||||
filter_type: 1,
|
|
||||||
query: {
|
|
||||||
payload: [
|
|
||||||
{
|
|
||||||
attribute_key: 'name',
|
|
||||||
filter_operator: 'equal_to',
|
|
||||||
values: ['john doe'],
|
|
||||||
query_operator: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('getCustomViewsByFilterType', () => {
|
it('getCustomViewsByFilterType', () => {
|
||||||
const state = { records: customViewList };
|
const state = { contact: { records: contactViewList } };
|
||||||
expect(getters.getCustomViewsByFilterType(state)(1)).toEqual([
|
expect(getters.getCustomViewsByFilterType(state)(1)).toEqual([
|
||||||
{
|
{
|
||||||
name: 'Custom view 1',
|
name: 'Custom view 1',
|
||||||
|
|||||||
@@ -4,34 +4,122 @@ import { customViewList, updateCustomViewList } from './fixtures';
|
|||||||
|
|
||||||
describe('#mutations', () => {
|
describe('#mutations', () => {
|
||||||
describe('#SET_CUSTOM_VIEW', () => {
|
describe('#SET_CUSTOM_VIEW', () => {
|
||||||
it('set custom view records', () => {
|
it('[Conversation] set custom view records', () => {
|
||||||
const state = { records: [] };
|
const state = {
|
||||||
mutations[types.SET_CUSTOM_VIEW](state, customViewList);
|
records: [],
|
||||||
expect(state.records).toEqual(customViewList);
|
conversation: { records: [] },
|
||||||
|
contact: { records: [] },
|
||||||
|
};
|
||||||
|
mutations[types.SET_CUSTOM_VIEW](state, {
|
||||||
|
data: customViewList,
|
||||||
|
filterType: 'conversation',
|
||||||
|
});
|
||||||
|
expect(state.conversation.records).toEqual(customViewList);
|
||||||
|
expect(state.contact.records).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('[Contact] set custom view records', () => {
|
||||||
|
const state = {
|
||||||
|
records: [],
|
||||||
|
conversation: { records: [] },
|
||||||
|
contact: { records: [] },
|
||||||
|
};
|
||||||
|
mutations[types.SET_CUSTOM_VIEW](state, {
|
||||||
|
data: customViewList,
|
||||||
|
filterType: 'contact',
|
||||||
|
});
|
||||||
|
expect(state.contact.records).toEqual(customViewList);
|
||||||
|
expect(state.conversation.records).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('#ADD_CUSTOM_VIEW', () => {
|
describe('#ADD_CUSTOM_VIEW', () => {
|
||||||
it('push newly created custom views to the store', () => {
|
it('[Conversation] push newly created custom views to the store', () => {
|
||||||
const state = { records: [customViewList] };
|
const state = {
|
||||||
mutations[types.ADD_CUSTOM_VIEW](state, customViewList[0]);
|
conversation: { records: [customViewList] },
|
||||||
expect(state.records).toEqual([customViewList, customViewList[0]]);
|
contact: { records: [] },
|
||||||
|
};
|
||||||
|
mutations[types.ADD_CUSTOM_VIEW](state, {
|
||||||
|
data: customViewList[0],
|
||||||
|
filterType: 'conversation',
|
||||||
|
});
|
||||||
|
expect(state.conversation.records).toEqual([
|
||||||
|
customViewList,
|
||||||
|
customViewList[0],
|
||||||
|
]);
|
||||||
|
expect(state.contact.records).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('[Contact] push newly created custom views to the store', () => {
|
||||||
|
const state = {
|
||||||
|
conversation: { records: [] },
|
||||||
|
contact: { records: [customViewList] },
|
||||||
|
};
|
||||||
|
mutations[types.ADD_CUSTOM_VIEW](state, {
|
||||||
|
data: customViewList[0],
|
||||||
|
filterType: 'contact',
|
||||||
|
});
|
||||||
|
expect(state.contact.records).toEqual([
|
||||||
|
customViewList,
|
||||||
|
customViewList[0],
|
||||||
|
]);
|
||||||
|
expect(state.conversation.records).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('#DELETE_CUSTOM_VIEW', () => {
|
describe('#DELETE_CUSTOM_VIEW', () => {
|
||||||
it('delete custom view record', () => {
|
it('[Conversation] delete custom view record', () => {
|
||||||
const state = { records: [customViewList[0]] };
|
const state = {
|
||||||
mutations[types.DELETE_CUSTOM_VIEW](state, customViewList[0]);
|
conversation: { records: [customViewList[0]] },
|
||||||
expect(state.records).toEqual([customViewList[0]]);
|
contact: { records: [] },
|
||||||
|
};
|
||||||
|
mutations[types.DELETE_CUSTOM_VIEW](state, {
|
||||||
|
data: customViewList[0],
|
||||||
|
filterType: 'conversation',
|
||||||
|
});
|
||||||
|
expect(state.conversation.records).toEqual([customViewList[0]]);
|
||||||
|
expect(state.contact.records).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('[Contact] delete custom view record', () => {
|
||||||
|
const state = {
|
||||||
|
contact: { records: [customViewList[0]] },
|
||||||
|
conversation: { records: [] },
|
||||||
|
};
|
||||||
|
mutations[types.DELETE_CUSTOM_VIEW](state, {
|
||||||
|
data: customViewList[0],
|
||||||
|
filterType: 'contact',
|
||||||
|
});
|
||||||
|
expect(state.contact.records).toEqual([customViewList[0]]);
|
||||||
|
expect(state.conversation.records).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('#UPDATE_CUSTOM_VIEW', () => {
|
describe('#UPDATE_CUSTOM_VIEW', () => {
|
||||||
it('update custom view record', () => {
|
it('[Conversation] update custom view record', () => {
|
||||||
const state = { records: [updateCustomViewList[0]] };
|
const state = {
|
||||||
mutations[types.UPDATE_CUSTOM_VIEW](state, updateCustomViewList[0]);
|
conversation: { records: [updateCustomViewList[0]] },
|
||||||
expect(state.records).toEqual(updateCustomViewList);
|
contact: { records: [] },
|
||||||
|
};
|
||||||
|
mutations[types.UPDATE_CUSTOM_VIEW](state, {
|
||||||
|
data: updateCustomViewList[0],
|
||||||
|
filterType: 'conversation',
|
||||||
|
});
|
||||||
|
expect(state.conversation.records).toEqual(updateCustomViewList);
|
||||||
|
expect(state.contact.records).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('[Contact] update custom view record', () => {
|
||||||
|
const state = {
|
||||||
|
contact: { records: [updateCustomViewList[0]] },
|
||||||
|
conversation: { records: [] },
|
||||||
|
};
|
||||||
|
mutations[types.UPDATE_CUSTOM_VIEW](state, {
|
||||||
|
data: updateCustomViewList[0],
|
||||||
|
filterType: 'contact',
|
||||||
|
});
|
||||||
|
expect(state.contact.records).toEqual(updateCustomViewList);
|
||||||
|
expect(state.conversation.records).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -92,3 +92,5 @@
|
|||||||
- name: custom_roles
|
- name: custom_roles
|
||||||
enabled: false
|
enabled: false
|
||||||
premium: true
|
premium: true
|
||||||
|
- name: chatwoot_v4
|
||||||
|
enabled: false
|
||||||
|
|||||||
@@ -100,6 +100,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@egoist/tailwindcss-icons": "^1.8.1",
|
"@egoist/tailwindcss-icons": "^1.8.1",
|
||||||
|
"@iconify-json/ri": "^1.2.1",
|
||||||
"@histoire/plugin-vue": "0.17.15",
|
"@histoire/plugin-vue": "0.17.15",
|
||||||
"@iconify-json/logos": "^1.2.0",
|
"@iconify-json/logos": "^1.2.0",
|
||||||
"@iconify-json/lucide": "^1.2.5",
|
"@iconify-json/lucide": "^1.2.5",
|
||||||
|
|||||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -222,6 +222,9 @@ importers:
|
|||||||
'@iconify-json/lucide':
|
'@iconify-json/lucide':
|
||||||
specifier: ^1.2.5
|
specifier: ^1.2.5
|
||||||
version: 1.2.5
|
version: 1.2.5
|
||||||
|
'@iconify-json/ri':
|
||||||
|
specifier: ^1.2.1
|
||||||
|
version: 1.2.1
|
||||||
'@size-limit/file':
|
'@size-limit/file':
|
||||||
specifier: ^8.2.4
|
specifier: ^8.2.4
|
||||||
version: 8.2.6(size-limit@8.2.6)
|
version: 8.2.6(size-limit@8.2.6)
|
||||||
@@ -835,6 +838,9 @@ packages:
|
|||||||
'@iconify-json/lucide@1.2.5':
|
'@iconify-json/lucide@1.2.5':
|
||||||
resolution: {integrity: sha512-ZRw1GRcN5CQ+9BW+yBEFjRNf4pQfsU8gvNVcCY81yVmwKLUegTncGHXjBuspK6HSmsJspOhGBgLqNbHb0dpxfw==}
|
resolution: {integrity: sha512-ZRw1GRcN5CQ+9BW+yBEFjRNf4pQfsU8gvNVcCY81yVmwKLUegTncGHXjBuspK6HSmsJspOhGBgLqNbHb0dpxfw==}
|
||||||
|
|
||||||
|
'@iconify-json/ri@1.2.1':
|
||||||
|
resolution: {integrity: sha512-xI3+xZHBI+wlhQqd6jRRcLD5K8B8vQNyxcSB43myxNZ/SfXIn7Ny28h0jyPo9e0gT8fGhqx6R5PeLz/UBB8jwQ==}
|
||||||
|
|
||||||
'@iconify/types@2.0.0':
|
'@iconify/types@2.0.0':
|
||||||
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
|
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
|
||||||
|
|
||||||
@@ -5479,6 +5485,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@iconify/types': 2.0.0
|
'@iconify/types': 2.0.0
|
||||||
|
|
||||||
|
'@iconify-json/ri@1.2.1':
|
||||||
|
dependencies:
|
||||||
|
'@iconify/types': 2.0.0
|
||||||
|
|
||||||
'@iconify/types@2.0.0': {}
|
'@iconify/types@2.0.0': {}
|
||||||
|
|
||||||
'@iconify/utils@2.1.32':
|
'@iconify/utils@2.1.32':
|
||||||
|
|||||||
@@ -6,7 +6,19 @@ const {
|
|||||||
getIconCollections,
|
getIconCollections,
|
||||||
} = require('@egoist/tailwindcss-icons');
|
} = require('@egoist/tailwindcss-icons');
|
||||||
|
|
||||||
module.exports = {
|
const defaultSansFonts = [
|
||||||
|
'-apple-system',
|
||||||
|
'system-ui',
|
||||||
|
'BlinkMacSystemFont',
|
||||||
|
'"Segoe UI"',
|
||||||
|
'Roboto',
|
||||||
|
'"Helvetica Neue"',
|
||||||
|
'Tahoma',
|
||||||
|
'Arial',
|
||||||
|
'sans-serif !important',
|
||||||
|
];
|
||||||
|
|
||||||
|
const tailwindConfig = {
|
||||||
darkMode: 'class',
|
darkMode: 'class',
|
||||||
content: [
|
content: [
|
||||||
'./enterprise/app/views/**/*.html.erb',
|
'./enterprise/app/views/**/*.html.erb',
|
||||||
@@ -21,8 +33,9 @@ module.exports = {
|
|||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
inter: ['Inter', ...defaultTheme.fontFamily.sans],
|
sans: defaultSansFonts,
|
||||||
interDisplay: ['Inter Display', ...defaultTheme.fontFamily.sans],
|
inter: ['Inter', ...defaultSansFonts],
|
||||||
|
interDisplay: ['Inter Display', ...defaultSansFonts],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
screens: {
|
screens: {
|
||||||
@@ -58,6 +71,10 @@ module.exports = {
|
|||||||
'90%': { transform: 'translateX(-0.375rem)' },
|
'90%': { transform: 'translateX(-0.375rem)' },
|
||||||
'100%': { transform: 'translateX(0)' },
|
'100%': { transform: 'translateX(0)' },
|
||||||
},
|
},
|
||||||
|
'fade-in-up': {
|
||||||
|
'0%': { opacity: 0, transform: 'translateY(0.5rem)' },
|
||||||
|
'100%': { opacity: 1, transform: 'translateY(0)' },
|
||||||
|
},
|
||||||
'loader-pulse': {
|
'loader-pulse': {
|
||||||
'0%': { opacity: 0.4 },
|
'0%': { opacity: 0.4 },
|
||||||
'50%': { opacity: 1 },
|
'50%': { opacity: 1 },
|
||||||
@@ -81,6 +98,7 @@ module.exports = {
|
|||||||
animation: {
|
animation: {
|
||||||
...defaultTheme.animation,
|
...defaultTheme.animation,
|
||||||
wiggle: 'wiggle 0.5s ease-in-out',
|
wiggle: 'wiggle 0.5s ease-in-out',
|
||||||
|
'fade-in-up': 'fade-in-up 0.3s ease-out',
|
||||||
'loader-pulse': 'loader-pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
'loader-pulse': 'loader-pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||||
'card-select': 'card-select 0.25s ease-in-out',
|
'card-select': 'card-select 0.25s ease-in-out',
|
||||||
shake: 'shake 0.3s ease-in-out 0s 2',
|
shake: 'shake 0.3s ease-in-out 0s 2',
|
||||||
@@ -90,7 +108,20 @@ module.exports = {
|
|||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
require('@tailwindcss/typography'),
|
require('@tailwindcss/typography'),
|
||||||
iconsPlugin({
|
iconsPlugin({
|
||||||
collections: getIconCollections(['lucide', 'logos']),
|
collections: {
|
||||||
|
woot: {
|
||||||
|
icons: {
|
||||||
|
alert: {
|
||||||
|
body: `<path d="M1.81348 0.9375L1.69727 7.95117H0.302734L0.179688 0.9375H1.81348ZM1 11.1025C0.494141 11.1025 0.0908203 10.7061 0.0976562 10.2207C0.0908203 9.72852 0.494141 9.33203 1 9.33203C1.49219 9.33203 1.89551 9.72852 1.90234 10.2207C1.89551 10.7061 1.49219 11.1025 1 11.1025Z" fill="currentColor" />`,
|
||||||
|
width: 2,
|
||||||
|
height: 12,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...getIconCollections(['lucide', 'logos', 'ri']),
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
module.exports = tailwindConfig;
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export default defineConfig({
|
|||||||
alias: {
|
alias: {
|
||||||
vue: 'vue/dist/vue.esm-bundler.js',
|
vue: 'vue/dist/vue.esm-bundler.js',
|
||||||
components: path.resolve('./app/javascript/dashboard/components'),
|
components: path.resolve('./app/javascript/dashboard/components'),
|
||||||
|
next: path.resolve('./app/javascript/dashboard/components-next'),
|
||||||
v3: path.resolve('./app/javascript/v3'),
|
v3: path.resolve('./app/javascript/v3'),
|
||||||
dashboard: path.resolve('./app/javascript/dashboard'),
|
dashboard: path.resolve('./app/javascript/dashboard'),
|
||||||
helpers: path.resolve('./app/javascript/shared/helpers'),
|
helpers: path.resolve('./app/javascript/shared/helpers'),
|
||||||
|
|||||||
Reference in New Issue
Block a user