diff --git a/app/javascript/dashboard/assets/scss/_woot.scss b/app/javascript/dashboard/assets/scss/_woot.scss index 8afefe384..75df32cfe 100644 --- a/app/javascript/dashboard/assets/scss/_woot.scss +++ b/app/javascript/dashboard/assets/scss/_woot.scss @@ -540,3 +540,15 @@ --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 */ + } +} diff --git a/app/javascript/dashboard/components-next/avatar/Avatar.story.vue b/app/javascript/dashboard/components-next/avatar/Avatar.story.vue index 869970f94..d502291c9 100644 --- a/app/javascript/dashboard/components-next/avatar/Avatar.story.vue +++ b/app/javascript/dashboard/components-next/avatar/Avatar.story.vue @@ -7,28 +7,58 @@ import Avatar from './Avatar.vue'; + + + + + + + + + + + + + + + + + + + diff --git a/app/javascript/dashboard/components-next/avatar/Avatar.vue b/app/javascript/dashboard/components-next/avatar/Avatar.vue index d5bb653b2..d558f030a 100644 --- a/app/javascript/dashboard/components-next/avatar/Avatar.vue +++ b/app/javascript/dashboard/components-next/avatar/Avatar.vue @@ -1,52 +1,122 @@ - - - - + - - + + + + + {{ initials }} + + + + + + diff --git a/app/javascript/dashboard/components-next/button/Button.vue b/app/javascript/dashboard/components-next/button/Button.vue index 5f7e8d6c7..d2487cabe 100644 --- a/app/javascript/dashboard/components-next/button/Button.vue +++ b/app/javascript/dashboard/components-next/button/Button.vue @@ -118,7 +118,11 @@ const handleClick = () => { :icon-lib="iconLib" class="flex-shrink-0" /> - {{ label }} + + + {{ label }} + + +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 }); +}; + + + + + diff --git a/app/javascript/dashboard/components-next/icon/Logo.vue b/app/javascript/dashboard/components-next/icon/Logo.vue new file mode 100644 index 000000000..2330021c3 --- /dev/null +++ b/app/javascript/dashboard/components-next/icon/Logo.vue @@ -0,0 +1,28 @@ + + + + + + + + + + + + + diff --git a/app/javascript/dashboard/components-next/sidebar/ChannelLeaf.vue b/app/javascript/dashboard/components-next/sidebar/ChannelLeaf.vue new file mode 100644 index 000000000..2ae5bd0c5 --- /dev/null +++ b/app/javascript/dashboard/components-next/sidebar/ChannelLeaf.vue @@ -0,0 +1,71 @@ + + + + + + + {{ label }} + + + + diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue new file mode 100644 index 000000000..a84db8ae5 --- /dev/null +++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue @@ -0,0 +1,473 @@ + + + + + diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarAccountSwitcher.vue b/app/javascript/dashboard/components-next/sidebar/SidebarAccountSwitcher.vue new file mode 100644 index 000000000..881034b4c --- /dev/null +++ b/app/javascript/dashboard/components-next/sidebar/SidebarAccountSwitcher.vue @@ -0,0 +1,110 @@ + + + + + + + {{ currentAccount.name }} + + + + + + + + {{ t('SIDEBAR_ITEMS.CHANGE_ACCOUNTS') }} + + + + + + {{ account.name }} + + + {{ + account.custom_role_id + ? account.custom_role.name + : account.role + }} + + + + + + + + {{ t('CREATE_ACCOUNT.NEW_ACCOUNT') }} + + + + + + diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarGroup.vue b/app/javascript/dashboard/components-next/sidebar/SidebarGroup.vue new file mode 100644 index 000000000..ecc189bea --- /dev/null +++ b/app/javascript/dashboard/components-next/sidebar/SidebarGroup.vue @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarGroupEmptyLeaf.vue b/app/javascript/dashboard/components-next/sidebar/SidebarGroupEmptyLeaf.vue new file mode 100644 index 000000000..dd4128dfe --- /dev/null +++ b/app/javascript/dashboard/components-next/sidebar/SidebarGroupEmptyLeaf.vue @@ -0,0 +1,13 @@ + + + + + {{ t('SIDEBAR.NO_ITEMS') }} + + diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarGroupHeader.vue b/app/javascript/dashboard/components-next/sidebar/SidebarGroupHeader.vue new file mode 100644 index 000000000..289ec73a4 --- /dev/null +++ b/app/javascript/dashboard/components-next/sidebar/SidebarGroupHeader.vue @@ -0,0 +1,42 @@ + + + + + + + {{ label }} + + + + diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarGroupLeaf.vue b/app/javascript/dashboard/components-next/sidebar/SidebarGroupLeaf.vue new file mode 100644 index 000000000..13c893fa5 --- /dev/null +++ b/app/javascript/dashboard/components-next/sidebar/SidebarGroupLeaf.vue @@ -0,0 +1,92 @@ + + + + + + + + + {{ label }} + + + + + + diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarGroupSeparator.vue b/app/javascript/dashboard/components-next/sidebar/SidebarGroupSeparator.vue new file mode 100644 index 000000000..ddc92a2b3 --- /dev/null +++ b/app/javascript/dashboard/components-next/sidebar/SidebarGroupSeparator.vue @@ -0,0 +1,25 @@ + + + + + + + {{ label }} + + + diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarNotificationBell.vue b/app/javascript/dashboard/components-next/sidebar/SidebarNotificationBell.vue new file mode 100644 index 000000000..94f3dd6bd --- /dev/null +++ b/app/javascript/dashboard/components-next/sidebar/SidebarNotificationBell.vue @@ -0,0 +1,40 @@ + + + + + + + {{ unreadCount }} + + + diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarProfileMenu.vue b/app/javascript/dashboard/components-next/sidebar/SidebarProfileMenu.vue new file mode 100644 index 000000000..ae88ebe69 --- /dev/null +++ b/app/javascript/dashboard/components-next/sidebar/SidebarProfileMenu.vue @@ -0,0 +1,146 @@ + + + + + + + + + {{ currentUser.available_name }} + + + {{ currentUser.email }} + + + + + + + + + + + + {{ item.label }} + + + + + + + diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarProfileMenuStatus.vue b/app/javascript/dashboard/components-next/sidebar/SidebarProfileMenuStatus.vue new file mode 100644 index 000000000..9642e4103 --- /dev/null +++ b/app/javascript/dashboard/components-next/sidebar/SidebarProfileMenuStatus.vue @@ -0,0 +1,119 @@ + + + + + + {{ t('SIDEBAR.SET_AVAILABILITY_TITLE') }} + + + + + + {{ status.label }} + + + + + + + + + + + + + + {{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }} + + + {{ t('SIDEBAR.SET_AUTO_OFFLINE.INFO_SHORT') }} + + + + + + diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarSubGroup.vue b/app/javascript/dashboard/components-next/sidebar/SidebarSubGroup.vue new file mode 100644 index 000000000..8c7f5c3a7 --- /dev/null +++ b/app/javascript/dashboard/components-next/sidebar/SidebarSubGroup.vue @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/javascript/dashboard/components-next/sidebar/provider.js b/app/javascript/dashboard/components-next/sidebar/provider.js new file mode 100644 index 000000000..d6571dcd9 --- /dev/null +++ b/app/javascript/dashboard/components-next/sidebar/provider.js @@ -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 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); +} diff --git a/app/javascript/dashboard/components-next/sidebar/useSidebarKeyboardShortcuts.js b/app/javascript/dashboard/components-next/sidebar/useSidebarKeyboardShortcuts.js new file mode 100644 index 000000000..0ef050369 --- /dev/null +++ b/app/javascript/dashboard/components-next/sidebar/useSidebarKeyboardShortcuts.js @@ -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); +} diff --git a/app/javascript/dashboard/components/ChatList.vue b/app/javascript/dashboard/components/ChatList.vue index 267b9e422..3ebefebe6 100644 --- a/app/javascript/dashboard/components/ChatList.vue +++ b/app/javascript/dashboard/components/ChatList.vue @@ -109,7 +109,7 @@ const chatListLoading = useMapGetter('getChatListLoadingStatus'); const activeInbox = useMapGetter('getSelectedInbox'); const conversationStats = useMapGetter('conversationStats/getStats'); const appliedFilters = useMapGetter('getAppliedConversationFilters'); -const folders = useMapGetter('customViews/getCustomViews'); +const folders = useMapGetter('customViews/getConversationCustomViews'); const agentList = useMapGetter('agents/getAgents'); const teamsList = useMapGetter('teams/getTeams'); const inboxesList = useMapGetter('inboxes/getInboxes'); diff --git a/app/javascript/dashboard/components/SidemenuIcon.vue b/app/javascript/dashboard/components/SidemenuIcon.vue index a81a3fedb..59ef4c4c2 100644 --- a/app/javascript/dashboard/components/SidemenuIcon.vue +++ b/app/javascript/dashboard/components/SidemenuIcon.vue @@ -1,5 +1,7 @@ + -import { useStoreGetters } from 'dashboard/composables/store'; +import { usePolicy } from 'dashboard/composables/usePolicy'; import { computed } from 'vue'; -import { - getUserPermissions, - hasPermissions, -} from '../helper/permissionsHelper'; const props = defineProps({ + as: { + type: String, + default: 'div', + }, permissions: { type: Array, required: true, }, + featureFlag: { + type: String, + default: null, + }, }); -const getters = useStoreGetters(); -const user = computed(() => getters.getCurrentUser.value); -const accountId = computed(() => getters.getCurrentAccountId.value); -const userPermissions = computed(() => { - return getUserPermissions(user.value, accountId.value); -}); -const hasPermission = computed(() => { - return hasPermissions(props.permissions, userPermissions.value); -}); +const { checkFeatureAllowed, checkPermissions } = usePolicy(); + +const isFeatureAllowed = computed(() => checkFeatureAllowed(props.featureFlag)); +const hasPermission = computed(() => checkPermissions(props.permissions)); - + - + diff --git a/app/javascript/dashboard/components/specs/SidemenuIcon.spec.js b/app/javascript/dashboard/components/specs/SidemenuIcon.spec.js index 5029eed48..59cf8f4ec 100644 --- a/app/javascript/dashboard/components/specs/SidemenuIcon.spec.js +++ b/app/javascript/dashboard/components/specs/SidemenuIcon.spec.js @@ -1,10 +1,29 @@ import { shallowMount } from '@vue/test-utils'; +import { createStore } from 'vuex'; import SidemenuIcon from '../SidemenuIcon.vue'; +const store = createStore({ + modules: { + auth: { + namespaced: false, + getters: { + getCurrentAccountId: () => 1, + }, + }, + accounts: { + namespaced: true, + getters: { + isFeatureEnabledonAccount: () => () => false, + }, + }, + }, +}); + describe('SidemenuIcon', () => { test('matches snapshot', () => { const wrapper = shallowMount(SidemenuIcon, { stubs: { WootButton: { template: '' } }, + global: { plugins: [store] }, }); expect(wrapper.vm).toBeTruthy(); expect(wrapper.element).toMatchSnapshot(); diff --git a/app/javascript/dashboard/composables/spec/useAccount.spec.js b/app/javascript/dashboard/composables/spec/useAccount.spec.js index 5523c6d91..bb4bfdee3 100644 --- a/app/javascript/dashboard/composables/spec/useAccount.spec.js +++ b/app/javascript/dashboard/composables/spec/useAccount.spec.js @@ -1,6 +1,38 @@ +import { defineComponent, h } from 'vue'; +import { createStore } from 'vuex'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { useAccount } from '../useAccount'; 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'); @@ -8,31 +40,77 @@ describe('useAccount', () => { beforeEach(() => { useRoute.mockReturnValue({ 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', () => { - const { accountId } = useAccount(); - expect(accountId.value).toBe(123); + const wrapper = mount(createComponent(), mountParams); + const { accountId } = wrapper.vm; + expect(accountId).toBe(123); }); it('generates account-scoped URLs correctly', () => { - const { accountScopedUrl } = useAccount(); + const wrapper = mount(createComponent(), mountParams); + const { accountScopedUrl } = wrapper.vm; const result = accountScopedUrl('settings/inbox/new'); expect(result).toBe('/app/accounts/123/settings/inbox/new'); }); it('handles URLs with leading slash', () => { - const { accountScopedUrl } = useAccount(); + const wrapper = mount(createComponent(), mountParams); + const { accountScopedUrl } = wrapper.vm; 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', () => { - const { accountScopedUrl } = useAccount(); + const wrapper = mount(createComponent(), mountParams); + const { accountScopedUrl } = wrapper.vm; const result = accountScopedUrl(''); 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 + }); }); diff --git a/app/javascript/dashboard/composables/useAccount.js b/app/javascript/dashboard/composables/useAccount.js index 0cc0663b4..08e291e39 100644 --- a/app/javascript/dashboard/composables/useAccount.js +++ b/app/javascript/dashboard/composables/useAccount.js @@ -1,5 +1,6 @@ import { computed } from 'vue'; import { useRoute } from 'vue-router'; +import { useMapGetter } from './store'; /** * Composable for account-related operations. @@ -11,11 +12,13 @@ export function useAccount() { * @type {import('vue').ComputedRef} */ const route = useRoute(); - + const getAccountFn = useMapGetter('accounts/getAccount'); const accountId = computed(() => { return Number(route.params.accountId); }); + const currentAccount = computed(() => getAccountFn.value(accountId.value)); + /** * Generates an account-scoped URL. * @param {string} url - The URL to be scoped to the account. @@ -25,8 +28,18 @@ export function useAccount() { return `/app/accounts/${accountId.value}/${url}`; }; + const accountScopedRoute = (name, params) => { + return { + name, + params: { accountId: accountId.value, ...params }, + }; + }; + return { accountId, + route, + currentAccount, accountScopedUrl, + accountScopedRoute, }; } diff --git a/app/javascript/dashboard/composables/usePolicy.js b/app/javascript/dashboard/composables/usePolicy.js new file mode 100644 index 000000000..0653003ec --- /dev/null +++ b/app/javascript/dashboard/composables/usePolicy.js @@ -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 }; +} diff --git a/app/javascript/dashboard/composables/utils/useKbd.js b/app/javascript/dashboard/composables/utils/useKbd.js new file mode 100644 index 000000000..800c270c4 --- /dev/null +++ b/app/javascript/dashboard/composables/utils/useKbd.js @@ -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(); + }); +} diff --git a/app/javascript/dashboard/featureFlags.js b/app/javascript/dashboard/featureFlags.js index 73ea57f06..4417ef164 100644 --- a/app/javascript/dashboard/featureFlags.js +++ b/app/javascript/dashboard/featureFlags.js @@ -32,4 +32,5 @@ export const FEATURE_FLAGS = { LINEAR: 'linear_integration', CAPTAIN: 'captain_integration', CUSTOM_ROLES: 'custom_roles', + CHATWOOT_V4: 'chatwoot_v4', }; diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index eb007916a..1cdf82357 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -227,6 +227,7 @@ } }, "SIDEBAR": { + "NO_ITEMS": "No items", "CURRENTLY_VIEWING_ACCOUNT": "Currently viewing:", "SWITCH": "Switch", "INBOX_VIEW": "Inbox View", @@ -291,9 +292,11 @@ "SETTINGS": "Settings", "CATEGORY_EMPTY_MESSAGE": "No categories found" }, + "CHANNELS": "Channels", "SET_AUTO_OFFLINE": { "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" }, diff --git a/app/javascript/dashboard/routes/dashboard/Dashboard.vue b/app/javascript/dashboard/routes/dashboard/Dashboard.vue index fa8df4c89..3b3199afe 100644 --- a/app/javascript/dashboard/routes/dashboard/Dashboard.vue +++ b/app/javascript/dashboard/routes/dashboard/Dashboard.vue @@ -1,14 +1,22 @@