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

@@ -2,8 +2,26 @@ import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import types from '../mutation-types';
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 = {
records: [],
[VIEW_TYPES.CONVERSATION]: {
records: [],
},
[VIEW_TYPES.CONTACT]: {
records: [],
},
uiFlags: {
isFetching: false,
isCreating: false,
@@ -16,11 +34,15 @@ export const getters = {
getUIFlags(_state) {
return _state.uiFlags;
},
getCustomViews(_state) {
return _state.records;
getCustomViewsByFilterType: _state => key => {
const filterType = FILTER_KEYS[key];
return _state[filterType].records;
},
getCustomViewsByFilterType: _state => filterType => {
return _state.records.filter(record => record.filter_type === filterType);
getConversationCustomViews(_state) {
return _state[VIEW_TYPES.CONVERSATION].records;
},
getContactCustomViews(_state) {
return _state[VIEW_TYPES.CONTACT].records;
},
getActiveConversationFolder(_state) {
return _state.activeConversationFolder;
@@ -33,7 +55,7 @@ export const actions = {
try {
const response =
await CustomViewsAPI.getCustomViewsByFilterType(filterType);
commit(types.SET_CUSTOM_VIEW, response.data);
commit(types.SET_CUSTOM_VIEW, { data: response.data, filterType });
} catch (error) {
// Ignore error
} finally {
@@ -44,7 +66,10 @@ export const actions = {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: true });
try {
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) {
const errorMessage = error?.response?.data?.message;
throw new Error(errorMessage);
@@ -56,7 +81,10 @@ export const actions = {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: true });
try {
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) {
const errorMessage = error?.response?.data?.message;
throw new Error(errorMessage);
@@ -68,7 +96,7 @@ export const actions = {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isDeleting: true });
try {
await CustomViewsAPI.deleteCustomViews(id, filterType);
commit(types.DELETE_CUSTOM_VIEW, id);
commit(types.DELETE_CUSTOM_VIEW, { data: id, filterType });
} catch (error) {
throw new Error(error);
} finally {
@@ -88,10 +116,18 @@ export const mutations = {
};
},
[types.ADD_CUSTOM_VIEW]: MutationHelpers.create,
[types.SET_CUSTOM_VIEW]: MutationHelpers.set,
[types.UPDATE_CUSTOM_VIEW]: MutationHelpers.update,
[types.DELETE_CUSTOM_VIEW]: MutationHelpers.destroy,
[types.ADD_CUSTOM_VIEW]: (_state, { data, filterType }) => {
MutationHelpers.create(_state[filterType], data);
},
[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) {
_state.activeConversationFolder = folder;

View File

@@ -11,10 +11,13 @@ describe('#actions', () => {
describe('#get', () => {
it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({ data: customViewList });
await actions.get({ commit });
await actions.get({ commit }, 'conversation');
expect(commit.mock.calls).toEqual([
[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 }],
]);
});
@@ -30,11 +33,15 @@ describe('#actions', () => {
describe('#create', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({ data: customViewList[0] });
await actions.create({ commit }, customViewList[0]);
const firstItem = customViewList[0];
axios.post.mockResolvedValue({ data: firstItem });
await actions.create({ commit }, firstItem);
expect(commit.mock.calls).toEqual([
[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 }],
]);
});
@@ -54,7 +61,7 @@ describe('#actions', () => {
await actions.delete({ commit }, { id: 1, filterType: 'contact' });
expect(commit.mock.calls).toEqual([
[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 }],
]);
});
@@ -70,15 +77,15 @@ describe('#actions', () => {
describe('#update', () => {
it('sends correct actions if API is success', async () => {
axios.patch.mockResolvedValue({ data: updateCustomViewList[0] });
await actions.update(
{ commit },
updateCustomViewList[0].id,
updateCustomViewList[0]
);
const item = updateCustomViewList[0];
axios.patch.mockResolvedValue({ data: item });
await actions.update({ commit }, item);
expect(commit.mock.calls).toEqual([
[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 }],
]);
});

View File

@@ -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 = [
{
name: 'Custom view',
@@ -21,14 +38,14 @@ export const customViewList = [
},
{
name: 'Custom view 1',
filter_type: 1,
filter_type: 0,
query: {
payload: [
{
attribute_key: 'name',
attribute_key: 'assignee_id',
filter_operator: 'equal_to',
values: ['john doe'],
query_operator: null,
values: [45],
query_operator: 'and',
},
],
},

View File

@@ -1,49 +1,9 @@
import { getters } from '../../customViews';
import { customViewList } from './fixtures';
import { contactViewList, customViewList } from './fixtures';
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', () => {
const state = { records: customViewList };
const state = { contact: { records: contactViewList } };
expect(getters.getCustomViewsByFilterType(state)(1)).toEqual([
{
name: 'Custom view 1',

View File

@@ -4,34 +4,122 @@ import { customViewList, updateCustomViewList } from './fixtures';
describe('#mutations', () => {
describe('#SET_CUSTOM_VIEW', () => {
it('set custom view records', () => {
const state = { records: [] };
mutations[types.SET_CUSTOM_VIEW](state, customViewList);
expect(state.records).toEqual(customViewList);
it('[Conversation] set custom view records', () => {
const state = {
records: [],
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', () => {
it('push newly created custom views to the store', () => {
const state = { records: [customViewList] };
mutations[types.ADD_CUSTOM_VIEW](state, customViewList[0]);
expect(state.records).toEqual([customViewList, customViewList[0]]);
it('[Conversation] push newly created custom views to the store', () => {
const state = {
conversation: { records: [customViewList] },
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', () => {
it('delete custom view record', () => {
const state = { records: [customViewList[0]] };
mutations[types.DELETE_CUSTOM_VIEW](state, customViewList[0]);
expect(state.records).toEqual([customViewList[0]]);
it('[Conversation] delete custom view record', () => {
const state = {
conversation: { records: [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', () => {
it('update custom view record', () => {
const state = { records: [updateCustomViewList[0]] };
mutations[types.UPDATE_CUSTOM_VIEW](state, updateCustomViewList[0]);
expect(state.records).toEqual(updateCustomViewList);
it('[Conversation] update custom view record', () => {
const state = {
conversation: { records: [updateCustomViewList[0]] },
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([]);
});
});