feat: Add dropdown component (#10358)

This PR adds dropdown primitives to help compose custom dropdowns across the app. The following the sample usage

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
This commit is contained in:
Shivam Mishra
2024-11-19 06:59:27 +05:30
committed by GitHub
parent 54afed9fb4
commit aaa328be87
22 changed files with 497 additions and 224 deletions

View File

@@ -31,7 +31,13 @@ hr {
ul, ul,
ol, ol,
dl { dl {
@apply mb-2 list-disc list-outside leading-[1.65]; @apply list-disc list-outside leading-[1.65];
}
ul:not(.reset-base),
ol:not(.reset-base),
dl:not(.reset-base) {
@apply mb-0;
} }
// Form elements // Form elements

View File

@@ -96,7 +96,7 @@ button {
} }
// @TODDO - Remove after moving all buttons to woot-button // @TODDO - Remove after moving all buttons to woot-button
.icon+.button__content { .icon + .button__content {
@apply w-auto; @apply w-auto;
} }

View File

@@ -0,0 +1,79 @@
<script setup>
import { ref } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownContainer from './base/DropdownContainer.vue';
import DropdownBody from './base/DropdownBody.vue';
import DropdownSection from './base/DropdownSection.vue';
import DropdownItem from './base/DropdownItem.vue';
import DropdownSeparator from './base/DropdownSeparator.vue';
import WootSwitch from 'components/ui/Switch.vue';
const currentUserAutoOffline = ref(false);
const menuItems = ref([
{
label: 'Contact Support',
icon: 'i-lucide-life-buoy',
click: () => window.alert('Contact Support'),
},
{
label: 'Keyboard Shortcuts',
icon: 'i-lucide-keyboard',
click: () => window.alert('Keyboard Shortcuts'),
},
{
label: 'Profile Settings',
icon: 'i-lucide-user-pen',
click: () => window.alert('Profile Settings'),
},
{
label: 'Change Appearance',
icon: 'i-lucide-swatch-book',
click: () => window.alert('Change Appearance'),
},
{
label: 'Open SuperAdmin',
icon: 'i-lucide-castle',
link: '/super_admin',
target: '_blank',
},
{
label: 'Log Out',
icon: 'i-lucide-log-out',
click: () => window.alert('Log Out'),
},
]);
</script>
<template>
<Story
title="Components/DropdownPrimitives"
:layout="{ type: 'grid', width: 400, height: 800 }"
>
<Variant title="Profile Menu">
<div class="p-4 bg-white h-[500px] dark:bg-slate-900">
<DropdownContainer>
<template #trigger="{ toggle }">
<Button label="Open Menu" size="sm" @click="toggle" />
</template>
<DropdownBody class="w-80">
<DropdownSection title="Profile Options">
<DropdownItem label="Contact Support" class="justify-between">
<span>{{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }}</span>
<div class="flex-shrink-0">
<WootSwitch v-model="currentUserAutoOffline" />
</div>
</DropdownItem>
</DropdownSection>
<DropdownSeparator />
<DropdownItem
v-for="item in menuItems"
:key="item.label"
v-bind="item"
/>
</DropdownBody>
</DropdownContainer>
</div>
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,9 @@
<template>
<div class="absolute">
<ul
class="text-sm bg-n-solid-1 border border-n-weak rounded-xl shadow-sm py-2 n-dropdown-body gap-2 grid list-none px-2 reset-base"
>
<slot />
</ul>
</div>
</template>

View File

@@ -0,0 +1,29 @@
<script setup>
import { useToggle } from '@vueuse/core';
import { provideDropdownContext } from './provider.js';
const emit = defineEmits(['close']);
const [isOpen, toggle] = useToggle(false);
const closeMenu = () => {
if (isOpen.value) {
emit('close');
toggle(false);
}
};
provideDropdownContext({
isOpen,
toggle,
closeMenu,
});
</script>
<template>
<div class="relative z-20 space-y-2">
<slot name="trigger" :is-open :toggle="() => toggle()" />
<div v-if="isOpen" v-on-clickaway="closeMenu" class="absolute">
<slot />
</div>
</div>
</template>

View File

@@ -0,0 +1,55 @@
<script setup>
import { computed } from 'vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { useDropdownContext } from './provider.js';
const props = defineProps({
label: { type: String, default: '' },
icon: { type: [String, Object, Function], default: '' },
link: { type: String, default: '' },
click: { type: Function, default: null },
preserveOpen: { type: Boolean, default: false },
});
defineOptions({
inheritAttrs: false,
});
const { closeMenu } = useDropdownContext();
const componentIs = computed(() => {
if (props.link) return 'router-link';
if (props.click) return 'button';
return 'div';
});
const triggerClick = () => {
if (props.click) {
props.click();
if (!props.preserveOpen) closeMenu();
}
};
</script>
<template>
<li class="n-dropdown-item">
<component
:is="componentIs"
v-bind="$attrs"
class="flex text-left rtl:text-right items-center p-2 reset-base text-sm text-n-slate-12 w-full border-0"
:class="{
'hover:bg-n-alpha-1 rounded-lg w-full gap-3': !$slots.default,
}"
:href="props.link || null"
@click="triggerClick"
>
<slot>
<slot name="icon">
<Icon v-if="icon" class="size-4 text-n-slate-11" :icon="icon" />
</slot>
<slot name="label">{{ label }}</slot>
</slot>
</component>
</li>
</template>

View File

@@ -0,0 +1,22 @@
<script setup>
defineProps({
title: {
type: String,
default: '',
},
});
</script>
<template>
<div class="-mx-2 n-dropdown-section">
<div
v-if="title"
class="px-4 mb-3 mt-1 leading-4 font-medium tracking-[0.2px] text-n-slate-10 text-xs"
>
{{ title }}
</div>
<ul class="gap-2 grid reset-base list-none px-2">
<slot />
</ul>
</div>
</template>

View File

@@ -0,0 +1,3 @@
<template>
<div class="h-0 border-b border-n-strong -mx-2" />
</template>

View File

@@ -0,0 +1,13 @@
import DropdownBody from './DropdownBody.vue';
import DropdownContainer from './DropdownContainer.vue';
import DropdownItem from './DropdownItem.vue';
import DropdownSection from './DropdownSection.vue';
import DropdownSeparator from './DropdownSeparator.vue';
export {
DropdownBody,
DropdownContainer,
DropdownItem,
DropdownSection,
DropdownSeparator,
};

View File

@@ -0,0 +1,19 @@
import { inject, provide } from 'vue';
const DropdownControl = Symbol('DropdownControl');
export function useDropdownContext() {
const context = inject(DropdownControl, null);
if (context === null) {
throw new Error(
`Component is missing a parent <DropdownContainer /> component.`
);
}
return context;
}
export function provideDropdownContext(context) {
provide(DropdownControl, context);
}

View File

@@ -1,24 +1,23 @@
<script setup> <script setup>
import { useAccount } from 'dashboard/composables/useAccount'; import { useAccount } from 'dashboard/composables/useAccount';
import { useMapGetter } from 'dashboard/composables/store'; import { useMapGetter } from 'dashboard/composables/store';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import ButtonNext from 'next/button/Button.vue'; import ButtonNext from 'next/button/Button.vue';
import Icon from 'next/icon/Icon.vue'; import Icon from 'next/icon/Icon.vue';
import {
DropdownContainer,
DropdownBody,
DropdownSection,
DropdownItem,
} from 'next/dropdown-menu/base';
const emit = defineEmits(['showCreateAccountModal']); const emit = defineEmits(['showCreateAccountModal']);
const { t } = useI18n(); const { t } = useI18n();
const { accountId, currentAccount } = useAccount(); const { accountId, currentAccount } = useAccount();
const currentUser = useMapGetter('getCurrentUser'); const currentUser = useMapGetter('getCurrentUser');
const globalConfig = useMapGetter('globalConfig/get'); const globalConfig = useMapGetter('globalConfig/get');
const [showDropdown, toggleDropdown] = useToggle(false);
const close = () => {
if (showDropdown.value) {
toggleDropdown(false);
}
};
const onChangeAccount = newId => { const onChangeAccount = newId => {
const accountUrl = `/app/accounts/${newId}/dashboard`; const accountUrl = `/app/accounts/${newId}/dashboard`;
@@ -26,51 +25,45 @@ const onChangeAccount = newId => {
}; };
const emitNewAccount = () => { const emitNewAccount = () => {
close();
emit('showCreateAccountModal'); emit('showCreateAccountModal');
}; };
</script> </script>
<template> <template>
<div class="relative z-20"> <DropdownContainer>
<button <template #trigger="{ toggle, isOpen }">
id="sidebar-account-switcher" <button
:data-account-id="accountId" id="sidebar-account-switcher"
aria-haspopup="listbox" :data-account-id="accountId"
aria-controls="account-options" aria-haspopup="listbox"
class="flex items-center gap-2 justify-between w-full rounded-lg hover:bg-n-alpha-1 px-2" aria-controls="account-options"
:class="{ 'bg-n-alpha-1': showDropdown }" class="flex items-center gap-2 justify-between w-full rounded-lg hover:bg-n-alpha-1 px-2"
@click="toggleDropdown()" :class="{ 'bg-n-alpha-1': isOpen }"
> @click="toggle"
<span
class="text-sm font-medium leading-5 text-n-slate-12 truncate"
aria-live="polite"
> >
{{ currentAccount.name }} <span
</span> class="text-sm font-medium leading-5 text-n-slate-12 truncate"
aria-live="polite"
<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="min-w-72 max-w-96 text-sm bg-n-solid-1 border border-n-weak rounded-xl shadow-sm py-4 px-2 flex flex-col gap-2"
>
<div
class="px-4 leading-4 font-medium tracking-[0.2px] text-n-slate-10 text-xs"
> >
{{ t('SIDEBAR_ITEMS.CHANGE_ACCOUNTS') }} {{ currentAccount.name }}
</div> </span>
<div class="px-1 gap-1 grid">
<button <span
v-for="account in currentUser.accounts" aria-hidden="true"
:id="`account-${account.id}`" class="i-lucide-chevron-down size-4 text-n-slate-10 flex-shrink-0"
:key="account.id" />
class="flex w-full hover:bg-n-alpha-1 space-x-4" </button>
@click="onChangeAccount(account.id)" </template>
> <DropdownBody class="min-w-80">
<DropdownSection :title="t('SIDEBAR_ITEMS.SWITCH_WORKSPACE')">
<DropdownItem
v-for="account in currentUser.accounts"
:id="`account-${account.id}`"
:key="account.id"
class="cursor-pointer"
@click="onChangeAccount(account.id)"
>
<template #label>
<div <div
:for="account.name" :for="account.name"
class="text-left rtl:text-right flex gap-2 items-center" class="text-left rtl:text-right flex gap-2 items-center"
@@ -98,19 +91,20 @@ const emitNewAccount = () => {
icon="i-lucide-check" icon="i-lucide-check"
class="text-n-teal-11 size-5" class="text-n-teal-11 size-5"
/> />
</button> </template>
</div> </DropdownItem>
<div v-if="globalConfig.createNewAccountFromDashboard" class="px-2"> </DropdownSection>
<ButtonNext <DropdownItem v-if="globalConfig.createNewAccountFromDashboard">
variant="secondary" <ButtonNext
class="w-full" color="slate"
size="sm" variant="faded"
@click="emitNewAccount" class="w-full"
> size="sm"
{{ t('CREATE_ACCOUNT.NEW_ACCOUNT') }} @click="emitNewAccount"
</ButtonNext> >
</div> {{ t('CREATE_ACCOUNT.NEW_ACCOUNT') }}
</div> </ButtonNext>
</div> </DropdownItem>
</div> </DropdownBody>
</DropdownContainer>
</template> </template>

View File

@@ -101,7 +101,7 @@ const toggleTrigger = () => {
:permissions="resolvePermissions(to)" :permissions="resolvePermissions(to)"
:feature-flag="resolveFeatureFlag(to)" :feature-flag="resolveFeatureFlag(to)"
as="li" as="li"
class="text-sm cursor-pointer select-none gap-1 grid" class="grid gap-1 text-sm cursor-pointer select-none"
> >
<SidebarGroupHeader <SidebarGroupHeader
:icon :icon
@@ -117,7 +117,7 @@ const toggleTrigger = () => {
<ul <ul
v-if="hasChildren" v-if="hasChildren"
v-show="isExpanded || hasActiveChild" v-show="isExpanded || hasActiveChild"
class="list-none m-0 grid sidebar-group-children" class="grid m-0 list-none sidebar-group-children"
> >
<template v-for="child in children" :key="child.name"> <template v-for="child in children" :key="child.name">
<SidebarSubGroup <SidebarSubGroup

View File

@@ -4,11 +4,16 @@ import Auth from 'dashboard/api/auth';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store'; import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import Avatar from 'next/avatar/Avatar.vue'; import Avatar from 'next/avatar/Avatar.vue';
import Icon from 'next/icon/Icon.vue';
import SidebarProfileMenuStatus from './SidebarProfileMenuStatus.vue'; import SidebarProfileMenuStatus from './SidebarProfileMenuStatus.vue';
import {
DropdownContainer,
DropdownBody,
DropdownSeparator,
DropdownItem,
} from 'next/dropdown-menu/base';
const emit = defineEmits(['close', 'openKeyShortcutModal']); const emit = defineEmits(['close', 'openKeyShortcutModal']);
defineOptions({ defineOptions({
@@ -21,14 +26,6 @@ const router = useRouter();
const globalConfig = useMapGetter('globalConfig/get'); const globalConfig = useMapGetter('globalConfig/get');
const currentUser = useMapGetter('getCurrentUser'); const currentUser = useMapGetter('getCurrentUser');
const currentUserAvailability = useMapGetter('getCurrentUserAvailability'); const currentUserAvailability = useMapGetter('getCurrentUserAvailability');
const [showProfileMenu, toggleProfileMenu] = useToggle(false);
const closeMenu = () => {
if (showProfileMenu.value) {
emit('close');
toggleProfileMenu(false);
}
};
const menuItems = computed(() => { const menuItems = computed(() => {
return [ return [
@@ -37,7 +34,6 @@ const menuItems = computed(() => {
label: t('SIDEBAR_ITEMS.CONTACT_SUPPORT'), label: t('SIDEBAR_ITEMS.CONTACT_SUPPORT'),
icon: 'i-lucide-life-buoy', icon: 'i-lucide-life-buoy',
click: () => { click: () => {
closeMenu();
window.$chatwoot.toggle(); window.$chatwoot.toggle();
}, },
}, },
@@ -46,7 +42,6 @@ const menuItems = computed(() => {
label: t('SIDEBAR_ITEMS.KEYBOARD_SHORTCUTS'), label: t('SIDEBAR_ITEMS.KEYBOARD_SHORTCUTS'),
icon: 'i-lucide-keyboard', icon: 'i-lucide-keyboard',
click: () => { click: () => {
closeMenu();
emit('openKeyShortcutModal'); emit('openKeyShortcutModal');
}, },
}, },
@@ -55,20 +50,26 @@ const menuItems = computed(() => {
label: t('SIDEBAR_ITEMS.PROFILE_SETTINGS'), label: t('SIDEBAR_ITEMS.PROFILE_SETTINGS'),
icon: 'i-lucide-user-pen', icon: 'i-lucide-user-pen',
click: () => { click: () => {
closeMenu();
router.push({ name: 'profile_settings_index' }); router.push({ name: 'profile_settings_index' });
}, },
}, },
{ {
show: true, show: true,
label: t('SIDEBAR_ITEMS.APPEARANCE'), label: t('SIDEBAR_ITEMS.APPEARANCE'),
icon: 'i-lucide-swatch-book', icon: 'i-lucide-palette',
click: () => { click: () => {
closeMenu();
const ninja = document.querySelector('ninja-keys'); const ninja = document.querySelector('ninja-keys');
ninja.open({ parent: 'appearance_settings' }); ninja.open({ parent: 'appearance_settings' });
}, },
}, },
{
show: true,
label: t('SIDEBAR_ITEMS.DOCS'),
icon: 'i-lucide-book',
click: () => {
window.open('https://www.chatwoot.com/hc/user-guide/en', '_blank');
},
},
{ {
show: currentUser.value.type === 'SuperAdmin', show: currentUser.value.type === 'SuperAdmin',
label: t('SIDEBAR_ITEMS.SUPER_ADMIN_CONSOLE'), label: t('SIDEBAR_ITEMS.SUPER_ADMIN_CONSOLE'),
@@ -79,7 +80,7 @@ const menuItems = computed(() => {
{ {
show: true, show: true,
label: t('SIDEBAR_ITEMS.LOGOUT'), label: t('SIDEBAR_ITEMS.LOGOUT'),
icon: 'i-lucide-log-out', icon: 'i-lucide-power',
click: Auth.logout, click: Auth.logout,
}, },
]; ];
@@ -91,56 +92,40 @@ const allowedMenuItems = computed(() => {
</script> </script>
<template> <template>
<div class="relative z-20 w-full min-w-0"> <DropdownContainer
<button class="relative z-20 w-full min-w-0"
class="flex gap-2 items-center rounded-lg cursor-pointer text-left w-full hover:bg-n-alpha-1 p-1" @close="emit('close')"
v-bind="$attrs" >
:class="{ <template #trigger="{ toggle, isOpen }">
'bg-n-alpha-1': showProfileMenu, <button
}" class="flex gap-2 items-center rounded-lg cursor-pointer text-left w-full hover:bg-n-alpha-1 p-1"
@click="toggleProfileMenu" :class="{ 'bg-n-alpha-1': isOpen }"
> @click="toggle"
<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 /> <Avatar
<div class="border-t border-n-strong mx-2 my-0" /> :size="32"
<ul class="list-none m-0 grid gap-1 p-1 text-n-slate-12"> :name="currentUser.available_name"
<li v-for="item in allowedMenuItems" :key="item.label" class="m-0"> :src="currentUser.avatar_url"
<component :status="currentUserAvailability"
:is="item.link ? 'a' : 'button'" class="flex-shrink-0"
v-bind="item.link ? { target: item.target, href: item.link } : {}" rounded-full
class="text-left hover:bg-n-alpha-1 px-2 py-1.5 w-full flex items-center gap-2" />
@click="item.click" <div class="min-w-0">
> <div class="text-n-slate-12 text-sm leading-4 font-medium truncate">
<Icon :icon="item.icon" class="size-4" /> {{ currentUser.available_name }}
{{ item.label }} </div>
</component> <div class="text-n-slate-11 text-xs truncate">
</li> {{ currentUser.email }}
</ul> </div>
</div> </div>
</div> </button>
</div> </template>
<DropdownBody class="left-0 bottom-12 z-50 w-80 mb-1">
<SidebarProfileMenuStatus />
<DropdownSeparator />
<template v-for="item in allowedMenuItems" :key="item.label">
<DropdownItem v-if="item.show" v-bind="item" />
</template>
</DropdownBody>
</DropdownContainer>
</template> </template>

View File

@@ -1,11 +1,18 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed, h } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store'; import { useMapGetter, useStore } from 'dashboard/composables/store';
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import {
DropdownContainer,
DropdownBody,
DropdownSection,
DropdownItem,
} from 'next/dropdown-menu/base';
import Icon from 'next/icon/Icon.vue'; import Icon from 'next/icon/Icon.vue';
import Button from 'next/button/Button.vue';
const { t } = useI18n(); const { t } = useI18n();
const store = useStore(); const store = useStore();
@@ -22,14 +29,22 @@ const statusList = computed(() => {
]; ];
}); });
const statusColors = ['bg-n-teal-9', 'bg-n-amber-9', 'bg-n-slate-9'];
const availabilityStatuses = computed(() => { const availabilityStatuses = computed(() => {
return statusList.value.map((statusLabel, index) => ({ return statusList.value.map((statusLabel, index) => ({
label: statusLabel, label: statusLabel,
value: AVAILABILITY_STATUS_KEYS[index], value: AVAILABILITY_STATUS_KEYS[index],
color: statusColors[index],
icon: h('span', { class: [statusColors[index], 'size-[12px] rounded'] }),
active: currentUserAvailability.value === AVAILABILITY_STATUS_KEYS[index], active: currentUserAvailability.value === AVAILABILITY_STATUS_KEYS[index],
})); }));
}); });
const activeStatus = computed(() => {
return availabilityStatuses.value.find(status => status.active);
});
function changeAvailabilityStatus(availability) { function changeAvailabilityStatus(availability) {
try { try {
store.dispatch('updateAvailability', { store.dispatch('updateAvailability', {
@@ -50,70 +65,58 @@ function updateAutoOffline(autoOffline) {
</script> </script>
<template> <template>
<div class="pt-2 text-n-slate-12"> <DropdownSection>
<span <div class="grid gap-0">
class="px-3 leading-4 font-medium tracking-[0.2px] text-n-slate-10 text-xs" <DropdownItem>
> <div class="flex-grow flex items-center gap-1">
{{ t('SIDEBAR.SET_AVAILABILITY_TITLE') }} {{ $t('SIDEBAR.SET_YOUR_AVAILABILITY') }}
</span> </div>
<ul class="list-none m-0 grid gap-1 p-1"> <DropdownContainer>
<li <template #trigger="{ toggle }">
v-for="status in availabilityStatuses" <Button
:key="status.value" size="sm"
class="flex items-baseline" color="slate"
> variant="faded"
<button class="min-w-[96px]"
class="text-left rtl:text-right hover:bg-n-alpha-1 px-2 py-1.5 w-full flex items-center gap-2" icon="i-lucide-chevron-down"
:class="{ trailing-icon
'pointer-events-none bg-n-amber-10/10': status.active, @click="toggle"
'bg-n-teal-3': status.active && status.value === 'online', >
'bg-n-amber-3': status.active && status.value === 'busy', <div class="flex gap-1 items-center flex-grow text-sm">
'bg-n-slate-3': status.active && status.value === 'offline', <div class="p-1 flex-shrink-0">
}" <div class="size-2 rounded-sm" :class="activeStatus.color" />
@click="changeAvailabilityStatus(status.value)" </div>
> <span>{{ activeStatus.label }}</span>
<div </div>
class="rounded-full w-2.5 h-2.5" </Button>
:class="{ </template>
'bg-n-teal-10': status.value === 'online', <DropdownBody class="min-w-32">
'bg-n-amber-10': status.value === 'busy', <DropdownItem
'bg-n-slate-10': status.value === 'offline', v-for="status in availabilityStatuses"
}" :key="status.value"
/> :label="status.label"
<span class="flex-grow">{{ status.label }}</span> :icon="status.icon"
<Icon class="cursor-pointer"
v-if="status.active" @click="changeAvailabilityStatus(status.value)"
icon="i-lucide-check" />
class="size-4 flex-shrink-0" </DropdownBody>
:class="{ </DropdownContainer>
'text-n-teal-11': status.value === 'online', </DropdownItem>
'text-n-amber-11': status.value === 'busy', <DropdownItem>
'text-n-slate-11': status.value === 'offline', <div class="flex-grow flex items-center gap-1">
}"
/>
</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') }} {{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }}
<Icon
v-tooltip.top="$t('SIDEBAR.SET_AUTO_OFFLINE.INFO_SHORT')"
icon="i-lucide-info"
class="size-4 text-n-slate-10"
/>
</div> </div>
<div class="text-xs leading-tight text-n-slate-10 mt-1"> <woot-switch
{{ t('SIDEBAR.SET_AUTO_OFFLINE.INFO_SHORT') }} class="flex-shrink-0"
</div> :model-value="currentUserAutoOffline"
</div> @input="updateAutoOffline"
<woot-switch />
class="flex-shrink-0" </DropdownItem>
:model-value="currentUserAutoOffline" </div>
@input="updateAutoOffline" </DropdownSection>
/>
</li>
</ul>
</template> </template>

View File

@@ -52,7 +52,7 @@ useEventListener(scrollableContainer, 'scroll', () => {
:icon :icon
class="my-1" class="my-1"
/> />
<ul class="m-0 list-none relative group"> <ul class="m-0 list-none reset-base relative group">
<!-- Each element has h-8, which is 32px, we will show 7 items with one hidden at the end, <!-- 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 --> which is 14rem. Then we add 16px so that we have some text visible from the next item -->
<div <div

View File

@@ -247,7 +247,7 @@ export default {
<transition-group <transition-group
name="menu-list" name="menu-list"
tag="ul" tag="ul"
class="pt-2 mb-0 ml-0 list-none" class="pt-2 reset-base list-none"
> >
<SecondaryNavItem <SecondaryNavItem
v-for="menuItem in accessibleMenuItems" v-for="menuItem in accessibleMenuItems"

View File

@@ -242,7 +242,7 @@ export default {
</span> </span>
</router-link> </router-link>
<ul v-if="hasSubMenu" class="mb-0 ml-0 list-none"> <ul v-if="hasSubMenu" class="reset-base list-none">
<SecondaryChildNavItem <SecondaryChildNavItem
v-for="child in menuItem.children" v-for="child in menuItem.children"
:key="child.id" :key="child.id"

View File

@@ -53,7 +53,7 @@ describe('AccountSelector', () => {
it('title and sub title exist', () => { it('title and sub title exist', () => {
const headerComponent = accountSelector.findComponent(WootModalHeader); const headerComponent = accountSelector.findComponent(WootModalHeader);
const title = headerComponent.find('[data-test-id="modal-header-title"]'); const title = headerComponent.find('[data-test-id="modal-header-title"]');
expect(title.text()).toBe('Switch Account'); expect(title.text()).toBe('Switch account');
const content = headerComponent.find( const content = headerComponent.find(
'[data-test-id="modal-header-content"]' '[data-test-id="modal-header-content"]'
); );

View File

@@ -177,14 +177,16 @@
}, },
"SIDEBAR_ITEMS": { "SIDEBAR_ITEMS": {
"CHANGE_AVAILABILITY_STATUS": "Change", "CHANGE_AVAILABILITY_STATUS": "Change",
"CHANGE_ACCOUNTS": "Switch Account", "CHANGE_ACCOUNTS": "Switch account",
"CONTACT_SUPPORT": "Contact Support", "SWITCH_WORKSPACE": "Switch workspace",
"CONTACT_SUPPORT": "Contact support",
"SELECTOR_SUBTITLE": "Select an account from the following list", "SELECTOR_SUBTITLE": "Select an account from the following list",
"PROFILE_SETTINGS": "Profile Settings", "PROFILE_SETTINGS": "Profile settings",
"KEYBOARD_SHORTCUTS": "Keyboard Shortcuts", "KEYBOARD_SHORTCUTS": "Keyboard shortcuts",
"APPEARANCE": "Change Appearance", "APPEARANCE": "Change appearance",
"SUPER_ADMIN_CONSOLE": "Super Admin Console", "SUPER_ADMIN_CONSOLE": "SuperAdmin console",
"LOGOUT": "Logout" "DOCS": "Read documentation",
"LOGOUT": "Log out"
}, },
"APP_GLOBAL": { "APP_GLOBAL": {
"TRIAL_MESSAGE": "days trial remaining.", "TRIAL_MESSAGE": "days trial remaining.",
@@ -279,6 +281,7 @@
"REPORTS_INBOX": "Inbox", "REPORTS_INBOX": "Inbox",
"REPORTS_TEAM": "Team", "REPORTS_TEAM": "Team",
"SET_AVAILABILITY_TITLE": "Set yourself as", "SET_AVAILABILITY_TITLE": "Set yourself as",
"SET_YOUR_AVAILABILITY": "Set your availability",
"SLA": "SLA", "SLA": "SLA",
"CUSTOM_ROLES": "Custom Roles", "CUSTOM_ROLES": "Custom Roles",
"BETA": "Beta", "BETA": "Beta",

View File

@@ -151,8 +151,15 @@ export const actions = {
} }
}, },
updateAvailability: async ({ commit, dispatch }, params) => { updateAvailability: async (
{ commit, dispatch, getters: _getters },
params
) => {
const previousStatus = _getters.getCurrentUserAvailability;
try { try {
// optimisticly update current status
commit(types.SET_CURRENT_USER_AVAILABILITY, params.availability);
const response = await authAPI.updateAvailability(params); const response = await authAPI.updateAvailability(params);
const userData = response.data; const userData = response.data;
const { id } = userData; const { id } = userData;
@@ -162,16 +169,23 @@ export const actions = {
availabilityStatus: params.availability, availabilityStatus: params.availability,
}); });
} catch (error) { } catch (error) {
// Ignore error // revert back to previous status if update fails
commit(types.SET_CURRENT_USER_AVAILABILITY, previousStatus);
} }
}, },
updateAutoOffline: async ({ commit }, { accountId, autoOffline }) => { updateAutoOffline: async (
{ commit, getters: _getters },
{ accountId, autoOffline }
) => {
const previousAutoOffline = _getters.getCurrentUserAutoOffline;
try { try {
commit(types.SET_CURRENT_USER_AUTO_OFFLINE, autoOffline);
const response = await authAPI.updateAutoOffline(accountId, autoOffline); const response = await authAPI.updateAutoOffline(accountId, autoOffline);
commit(types.SET_CURRENT_USER, response.data); commit(types.SET_CURRENT_USER, response.data);
} catch (error) { } catch (error) {
// Ignore error commit(types.SET_CURRENT_USER_AUTO_OFFLINE, previousAutoOffline);
} }
}, },
@@ -212,6 +226,19 @@ export const mutations = {
accounts, accounts,
}; };
}, },
[types.SET_CURRENT_USER_AUTO_OFFLINE](_state, autoOffline) {
const accounts = _state.currentUser.accounts.map(account => {
if (account.id === _state.currentUser.account_id) {
return { ...account, autoOffline: autoOffline };
}
return account;
});
_state.currentUser = {
..._state.currentUser,
accounts,
};
},
[types.CLEAR_USER](_state) { [types.CLEAR_USER](_state) {
_state.currentUser = initialState.currentUser; _state.currentUser = initialState.currentUser;
}, },

View File

@@ -1,7 +1,7 @@
import axios from 'axios'; import axios from 'axios';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import { actions } from '../../auth'; import { actions } from '../../auth';
import * as types from '../../../mutation-types'; import types from '../../../mutation-types';
import * as APIHelpers from '../../../utils/api'; import * as APIHelpers from '../../../utils/api';
import '../../../../routes'; import '../../../../routes';
@@ -25,7 +25,7 @@ describe('#actions', () => {
await actions.validityCheck({ commit }); await actions.validityCheck({ commit });
expect(APIHelpers.setUser).toHaveBeenCalledTimes(1); expect(APIHelpers.setUser).toHaveBeenCalledTimes(1);
expect(commit.mock.calls).toEqual([ expect(commit.mock.calls).toEqual([
[types.default.SET_CURRENT_USER, { id: 1, name: 'John' }], [types.SET_CURRENT_USER, { id: 1, name: 'John' }],
]); ]);
}); });
it('sends correct actions if API is error', async () => { it('sends correct actions if API is error', async () => {
@@ -45,7 +45,7 @@ describe('#actions', () => {
}); });
await actions.updateProfile({ commit }, { name: 'Pranav' }); await actions.updateProfile({ commit }, { name: 'Pranav' });
expect(commit.mock.calls).toEqual([ expect(commit.mock.calls).toEqual([
[types.default.SET_CURRENT_USER, { id: 1, name: 'John' }], [types.SET_CURRENT_USER, { id: 1, name: 'John' }],
]); ]);
}); });
}); });
@@ -61,12 +61,13 @@ describe('#actions', () => {
headers: { expiry: 581842904 }, headers: { expiry: 581842904 },
}); });
await actions.updateAvailability( await actions.updateAvailability(
{ commit, dispatch }, { commit, dispatch, getters: { getCurrentUserAvailability: 'online' } },
{ availability: 'offline', account_id: 1 } { availability: 'offline', account_id: 1 }
); );
expect(commit.mock.calls).toEqual([ expect(commit.mock.calls).toEqual([
[types.SET_CURRENT_USER_AVAILABILITY, 'offline'],
[ [
types.default.SET_CURRENT_USER, types.SET_CURRENT_USER,
{ {
id: 1, id: 1,
name: 'John', name: 'John',
@@ -81,6 +82,18 @@ describe('#actions', () => {
], ],
]); ]);
}); });
it('sends correct actions if API is a failure', async () => {
axios.post.mockRejectedValue({ error: 'Authentication Failure' });
await actions.updateAvailability(
{ commit, dispatch, getters: { getCurrentUserAvailability: 'online' } },
{ availability: 'offline', account_id: 1 }
);
expect(commit.mock.calls).toEqual([
[types.SET_CURRENT_USER_AVAILABILITY, 'offline'],
[types.SET_CURRENT_USER_AVAILABILITY, 'online'],
]);
});
}); });
describe('#updateAutoOffline', () => { describe('#updateAutoOffline', () => {
@@ -99,12 +112,13 @@ describe('#actions', () => {
headers: { expiry: 581842904 }, headers: { expiry: 581842904 },
}); });
await actions.updateAutoOffline( await actions.updateAutoOffline(
{ commit, dispatch }, { commit, dispatch, getters: { getCurrentUserAutoOffline: true } },
{ autoOffline: false, accountId: 1 } { autoOffline: false, accountId: 1 }
); );
expect(commit.mock.calls).toEqual([ expect(commit.mock.calls).toEqual([
[types.SET_CURRENT_USER_AUTO_OFFLINE, false],
[ [
types.default.SET_CURRENT_USER, types.SET_CURRENT_USER,
{ {
id: 1, id: 1,
name: 'John', name: 'John',
@@ -113,6 +127,17 @@ describe('#actions', () => {
], ],
]); ]);
}); });
it('sends correct actions if API is failure', async () => {
axios.post.mockRejectedValue({ error: 'Authentication Failure' });
await actions.updateAutoOffline(
{ commit, dispatch, getters: { getCurrentUserAutoOffline: true } },
{ autoOffline: false, accountId: 1 }
);
expect(commit.mock.calls).toEqual([
[types.SET_CURRENT_USER_AUTO_OFFLINE, false],
[types.SET_CURRENT_USER_AUTO_OFFLINE, true],
]);
});
}); });
describe('#updateUISettings', () => { describe('#updateUISettings', () => {
@@ -132,11 +157,11 @@ describe('#actions', () => {
); );
expect(commit.mock.calls).toEqual([ expect(commit.mock.calls).toEqual([
[ [
types.default.SET_CURRENT_USER_UI_SETTINGS, types.SET_CURRENT_USER_UI_SETTINGS,
{ uiSettings: { is_contact_sidebar_open: false } }, { uiSettings: { is_contact_sidebar_open: false } },
], ],
[ [
types.default.SET_CURRENT_USER, types.SET_CURRENT_USER,
{ {
id: 1, id: 1,
name: 'John', name: 'John',
@@ -160,8 +185,8 @@ describe('#actions', () => {
Cookies.get.mockImplementation(() => false); Cookies.get.mockImplementation(() => false);
actions.setUser({ commit, dispatch }); actions.setUser({ commit, dispatch });
expect(commit.mock.calls).toEqual([ expect(commit.mock.calls).toEqual([
[types.default.CLEAR_USER], [types.CLEAR_USER],
[types.default.SET_CURRENT_USER_UI_FLAGS, { isFetching: false }], [types.SET_CURRENT_USER_UI_FLAGS, { isFetching: false }],
]); ]);
expect(dispatch).toHaveBeenCalledTimes(0); expect(dispatch).toHaveBeenCalledTimes(0);
}); });
@@ -177,7 +202,7 @@ describe('#actions', () => {
{ 1: 'online' } { 1: 'online' }
); );
expect(commit.mock.calls).toEqual([ expect(commit.mock.calls).toEqual([
[types.default.SET_CURRENT_USER_AVAILABILITY, 'online'], [types.SET_CURRENT_USER_AVAILABILITY, 'online'],
]); ]);
}); });

View File

@@ -3,6 +3,7 @@ export default {
CLEAR_USER: 'LOGOUT', CLEAR_USER: 'LOGOUT',
SET_CURRENT_USER: 'SET_CURRENT_USER', SET_CURRENT_USER: 'SET_CURRENT_USER',
SET_CURRENT_USER_AVAILABILITY: 'SET_CURRENT_USER_AVAILABILITY', SET_CURRENT_USER_AVAILABILITY: 'SET_CURRENT_USER_AVAILABILITY',
SET_CURRENT_USER_AUTO_OFFLINE: 'SET_CURRENT_USER_AUTO_OFFLINE',
SET_CURRENT_USER_UI_SETTINGS: 'SET_CURRENT_USER_UI_SETTINGS', SET_CURRENT_USER_UI_SETTINGS: 'SET_CURRENT_USER_UI_SETTINGS',
SET_CURRENT_USER_UI_FLAGS: 'SET_CURRENT_USER_UI_FLAGS', SET_CURRENT_USER_UI_FLAGS: 'SET_CURRENT_USER_UI_FLAGS',