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

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

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

----

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

View File

@@ -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
});
});

View File

@@ -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<number>}
*/
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,
};
}

View 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 };
}

View 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();
});
}