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,
ol,
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

View File

@@ -96,7 +96,7 @@ button {
}
// @TODDO - Remove after moving all buttons to woot-button
.icon+.button__content {
.icon + .button__content {
@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>
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';
import {
DropdownContainer,
DropdownBody,
DropdownSection,
DropdownItem,
} from 'next/dropdown-menu/base';
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`;
@@ -26,51 +25,45 @@ const onChangeAccount = newId => {
};
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"
<DropdownContainer>
<template #trigger="{ toggle, isOpen }">
<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': isOpen }"
@click="toggle"
>
{{ 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="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"
<span
class="text-sm font-medium leading-5 text-n-slate-12 truncate"
aria-live="polite"
>
{{ 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)"
>
{{ currentAccount.name }}
</span>
<span
aria-hidden="true"
class="i-lucide-chevron-down size-4 text-n-slate-10 flex-shrink-0"
/>
</button>
</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
:for="account.name"
class="text-left rtl:text-right flex gap-2 items-center"
@@ -98,19 +91,20 @@ const emitNewAccount = () => {
icon="i-lucide-check"
class="text-n-teal-11 size-5"
/>
</button>
</div>
<div v-if="globalConfig.createNewAccountFromDashboard" class="px-2">
<ButtonNext
variant="secondary"
class="w-full"
size="sm"
@click="emitNewAccount"
>
{{ t('CREATE_ACCOUNT.NEW_ACCOUNT') }}
</ButtonNext>
</div>
</div>
</div>
</div>
</template>
</DropdownItem>
</DropdownSection>
<DropdownItem v-if="globalConfig.createNewAccountFromDashboard">
<ButtonNext
color="slate"
variant="faded"
class="w-full"
size="sm"
@click="emitNewAccount"
>
{{ t('CREATE_ACCOUNT.NEW_ACCOUNT') }}
</ButtonNext>
</DropdownItem>
</DropdownBody>
</DropdownContainer>
</template>

View File

@@ -101,7 +101,7 @@ const toggleTrigger = () => {
:permissions="resolvePermissions(to)"
:feature-flag="resolveFeatureFlag(to)"
as="li"
class="text-sm cursor-pointer select-none gap-1 grid"
class="grid gap-1 text-sm cursor-pointer select-none"
>
<SidebarGroupHeader
:icon
@@ -117,7 +117,7 @@ const toggleTrigger = () => {
<ul
v-if="hasChildren"
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">
<SidebarSubGroup

View File

@@ -4,11 +4,16 @@ 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';
import {
DropdownContainer,
DropdownBody,
DropdownSeparator,
DropdownItem,
} from 'next/dropdown-menu/base';
const emit = defineEmits(['close', 'openKeyShortcutModal']);
defineOptions({
@@ -21,14 +26,6 @@ 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 [
@@ -37,7 +34,6 @@ const menuItems = computed(() => {
label: t('SIDEBAR_ITEMS.CONTACT_SUPPORT'),
icon: 'i-lucide-life-buoy',
click: () => {
closeMenu();
window.$chatwoot.toggle();
},
},
@@ -46,7 +42,6 @@ const menuItems = computed(() => {
label: t('SIDEBAR_ITEMS.KEYBOARD_SHORTCUTS'),
icon: 'i-lucide-keyboard',
click: () => {
closeMenu();
emit('openKeyShortcutModal');
},
},
@@ -55,20 +50,26 @@ const menuItems = computed(() => {
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',
icon: 'i-lucide-palette',
click: () => {
closeMenu();
const ninja = document.querySelector('ninja-keys');
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',
label: t('SIDEBAR_ITEMS.SUPER_ADMIN_CONSOLE'),
@@ -79,7 +80,7 @@ const menuItems = computed(() => {
{
show: true,
label: t('SIDEBAR_ITEMS.LOGOUT'),
icon: 'i-lucide-log-out',
icon: 'i-lucide-power',
click: Auth.logout,
},
];
@@ -91,56 +92,40 @@ const allowedMenuItems = computed(() => {
</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"
<DropdownContainer
class="relative z-20 w-full min-w-0"
@close="emit('close')"
>
<template #trigger="{ toggle, isOpen }">
<button
class="flex gap-2 items-center rounded-lg cursor-pointer text-left w-full hover:bg-n-alpha-1 p-1"
:class="{ 'bg-n-alpha-1': isOpen }"
@click="toggle"
>
<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>
<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>
</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>

View File

@@ -1,11 +1,18 @@
<script setup>
import { computed } from 'vue';
import { computed, h } 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 {
DropdownContainer,
DropdownBody,
DropdownSection,
DropdownItem,
} from 'next/dropdown-menu/base';
import Icon from 'next/icon/Icon.vue';
import Button from 'next/button/Button.vue';
const { t } = useI18n();
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(() => {
return statusList.value.map((statusLabel, index) => ({
label: statusLabel,
value: AVAILABILITY_STATUS_KEYS[index],
color: statusColors[index],
icon: h('span', { class: [statusColors[index], 'size-[12px] rounded'] }),
active: currentUserAvailability.value === AVAILABILITY_STATUS_KEYS[index],
}));
});
const activeStatus = computed(() => {
return availabilityStatuses.value.find(status => status.active);
});
function changeAvailabilityStatus(availability) {
try {
store.dispatch('updateAvailability', {
@@ -50,70 +65,58 @@ function updateAutoOffline(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">
<DropdownSection>
<div class="grid gap-0">
<DropdownItem>
<div class="flex-grow flex items-center gap-1">
{{ $t('SIDEBAR.SET_YOUR_AVAILABILITY') }}
</div>
<DropdownContainer>
<template #trigger="{ toggle }">
<Button
size="sm"
color="slate"
variant="faded"
class="min-w-[96px]"
icon="i-lucide-chevron-down"
trailing-icon
@click="toggle"
>
<div class="flex gap-1 items-center flex-grow text-sm">
<div class="p-1 flex-shrink-0">
<div class="size-2 rounded-sm" :class="activeStatus.color" />
</div>
<span>{{ activeStatus.label }}</span>
</div>
</Button>
</template>
<DropdownBody class="min-w-32">
<DropdownItem
v-for="status in availabilityStatuses"
:key="status.value"
:label="status.label"
:icon="status.icon"
class="cursor-pointer"
@click="changeAvailabilityStatus(status.value)"
/>
</DropdownBody>
</DropdownContainer>
</DropdownItem>
<DropdownItem>
<div class="flex-grow flex items-center gap-1">
{{ $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 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>
<woot-switch
class="flex-shrink-0"
:model-value="currentUserAutoOffline"
@input="updateAutoOffline"
/>
</DropdownItem>
</div>
</DropdownSection>
</template>

View File

@@ -52,7 +52,7 @@ useEventListener(scrollableContainer, 'scroll', () => {
:icon
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,
which is 14rem. Then we add 16px so that we have some text visible from the next item -->
<div

View File

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

View File

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

View File

@@ -53,7 +53,7 @@ describe('AccountSelector', () => {
it('title and sub title exist', () => {
const headerComponent = accountSelector.findComponent(WootModalHeader);
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(
'[data-test-id="modal-header-content"]'
);

View File

@@ -177,14 +177,16 @@
},
"SIDEBAR_ITEMS": {
"CHANGE_AVAILABILITY_STATUS": "Change",
"CHANGE_ACCOUNTS": "Switch Account",
"CONTACT_SUPPORT": "Contact Support",
"CHANGE_ACCOUNTS": "Switch account",
"SWITCH_WORKSPACE": "Switch workspace",
"CONTACT_SUPPORT": "Contact support",
"SELECTOR_SUBTITLE": "Select an account from the following list",
"PROFILE_SETTINGS": "Profile Settings",
"KEYBOARD_SHORTCUTS": "Keyboard Shortcuts",
"APPEARANCE": "Change Appearance",
"SUPER_ADMIN_CONSOLE": "Super Admin Console",
"LOGOUT": "Logout"
"PROFILE_SETTINGS": "Profile settings",
"KEYBOARD_SHORTCUTS": "Keyboard shortcuts",
"APPEARANCE": "Change appearance",
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
"DOCS": "Read documentation",
"LOGOUT": "Log out"
},
"APP_GLOBAL": {
"TRIAL_MESSAGE": "days trial remaining.",
@@ -279,6 +281,7 @@
"REPORTS_INBOX": "Inbox",
"REPORTS_TEAM": "Team",
"SET_AVAILABILITY_TITLE": "Set yourself as",
"SET_YOUR_AVAILABILITY": "Set your availability",
"SLA": "SLA",
"CUSTOM_ROLES": "Custom Roles",
"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 {
// optimisticly update current status
commit(types.SET_CURRENT_USER_AVAILABILITY, params.availability);
const response = await authAPI.updateAvailability(params);
const userData = response.data;
const { id } = userData;
@@ -162,16 +169,23 @@ export const actions = {
availabilityStatus: params.availability,
});
} 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 {
commit(types.SET_CURRENT_USER_AUTO_OFFLINE, autoOffline);
const response = await authAPI.updateAutoOffline(accountId, autoOffline);
commit(types.SET_CURRENT_USER, response.data);
} catch (error) {
// Ignore error
commit(types.SET_CURRENT_USER_AUTO_OFFLINE, previousAutoOffline);
}
},
@@ -212,6 +226,19 @@ export const mutations = {
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) {
_state.currentUser = initialState.currentUser;
},

View File

@@ -1,7 +1,7 @@
import axios from 'axios';
import Cookies from 'js-cookie';
import { actions } from '../../auth';
import * as types from '../../../mutation-types';
import types from '../../../mutation-types';
import * as APIHelpers from '../../../utils/api';
import '../../../../routes';
@@ -25,7 +25,7 @@ describe('#actions', () => {
await actions.validityCheck({ commit });
expect(APIHelpers.setUser).toHaveBeenCalledTimes(1);
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 () => {
@@ -45,7 +45,7 @@ describe('#actions', () => {
});
await actions.updateProfile({ commit }, { name: 'Pranav' });
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 },
});
await actions.updateAvailability(
{ commit, dispatch },
{ commit, dispatch, getters: { getCurrentUserAvailability: 'online' } },
{ availability: 'offline', account_id: 1 }
);
expect(commit.mock.calls).toEqual([
[types.SET_CURRENT_USER_AVAILABILITY, 'offline'],
[
types.default.SET_CURRENT_USER,
types.SET_CURRENT_USER,
{
id: 1,
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', () => {
@@ -99,12 +112,13 @@ describe('#actions', () => {
headers: { expiry: 581842904 },
});
await actions.updateAutoOffline(
{ commit, dispatch },
{ commit, dispatch, getters: { getCurrentUserAutoOffline: true } },
{ autoOffline: false, accountId: 1 }
);
expect(commit.mock.calls).toEqual([
[types.SET_CURRENT_USER_AUTO_OFFLINE, false],
[
types.default.SET_CURRENT_USER,
types.SET_CURRENT_USER,
{
id: 1,
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', () => {
@@ -132,11 +157,11 @@ describe('#actions', () => {
);
expect(commit.mock.calls).toEqual([
[
types.default.SET_CURRENT_USER_UI_SETTINGS,
types.SET_CURRENT_USER_UI_SETTINGS,
{ uiSettings: { is_contact_sidebar_open: false } },
],
[
types.default.SET_CURRENT_USER,
types.SET_CURRENT_USER,
{
id: 1,
name: 'John',
@@ -160,8 +185,8 @@ describe('#actions', () => {
Cookies.get.mockImplementation(() => false);
actions.setUser({ commit, dispatch });
expect(commit.mock.calls).toEqual([
[types.default.CLEAR_USER],
[types.default.SET_CURRENT_USER_UI_FLAGS, { isFetching: false }],
[types.CLEAR_USER],
[types.SET_CURRENT_USER_UI_FLAGS, { isFetching: false }],
]);
expect(dispatch).toHaveBeenCalledTimes(0);
});
@@ -177,7 +202,7 @@ describe('#actions', () => {
{ 1: 'online' }
);
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',
SET_CURRENT_USER: 'SET_CURRENT_USER',
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_FLAGS: 'SET_CURRENT_USER_UI_FLAGS',