feat: Rewrite conversation/labelMixin to a composable (#9936)

# Pull Request Template

## Description

This PR will replace the usage of `conversation/labelMixin` with a
composable

Fixes
https://linear.app/chatwoot/issue/CW-3439/rewrite-conversationlabelmixin-mixin-to-a-composable

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

**Test cases**

1. Add/remove labels from conversation sidebar
2. See labels are showing up dynamically
3. Check add/remove labels working fine with CMD bar
4. Check card labels in conversation card and SLA reports table.


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
This commit is contained in:
Sivin Varghese
2024-08-12 17:41:12 +05:30
committed by GitHub
parent 452096f4b2
commit 4c6572c2c9
9 changed files with 252 additions and 79 deletions

View File

@@ -316,7 +316,11 @@ export default {
{{ unreadCount > 9 ? '9+' : unreadCount }}
</span>
</div>
<CardLabels :conversation-id="chat.id" class="mt-0.5 mx-2 mb-0">
<CardLabels
:conversation-id="chat.id"
:conversation-labels="chat.labels"
class="mt-0.5 mx-2 mb-0"
>
<template v-if="hasSlaPolicyId" #before>
<SLACardLabel :chat="chat" class="ltr:mr-1 rtl:ml-1" />
</template>

View File

@@ -1,23 +1,26 @@
<script>
import conversationLabelMixin from 'dashboard/mixins/conversation/labelMixin';
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
export default {
mixins: [conversationLabelMixin],
props: {
// conversationId prop is used in /conversation/labelMixin,
// remove this props when refactoring to composable if not needed
// eslint-disable-next-line vue/no-unused-properties
conversationId: {
type: Number,
conversationLabels: {
type: Array,
required: true,
},
// conversationLabels prop is used in /conversation/labelMixin,
// remove this props when refactoring to composable if not needed
// eslint-disable-next-line vue/no-unused-properties
conversationLabels: {
type: String,
required: false,
default: '',
},
},
setup(props) {
const accountLabels = useMapGetter('labels/getLabels');
const activeLabels = computed(() => {
return props.conversationLabels.map(label =>
accountLabels.value.find(l => l.title === label)
);
});
return {
activeLabels,
};
},
data() {
return {

View File

@@ -0,0 +1,87 @@
import { useConversationLabels } from '../useConversationLabels';
import { useStore, useStoreGetters } from 'dashboard/composables/store';
vi.mock('dashboard/composables/store');
describe('useConversationLabels', () => {
let store;
let getters;
beforeEach(() => {
store = {
getters: {
'conversationLabels/getConversationLabels': vi.fn(),
},
dispatch: vi.fn(),
};
getters = {
getSelectedChat: { value: { id: 1 } },
'labels/getLabels': {
value: [
{ id: 1, title: 'Label 1' },
{ id: 2, title: 'Label 2' },
{ id: 3, title: 'Label 3' },
],
},
};
useStore.mockReturnValue(store);
useStoreGetters.mockReturnValue(getters);
});
it('should return the correct computed properties', () => {
store.getters['conversationLabels/getConversationLabels'].mockReturnValue([
'Label 1',
'Label 2',
]);
const { accountLabels, savedLabels, activeLabels, inactiveLabels } =
useConversationLabels();
expect(accountLabels.value).toEqual(getters['labels/getLabels'].value);
expect(savedLabels.value).toEqual(['Label 1', 'Label 2']);
expect(activeLabels.value).toEqual([
{ id: 1, title: 'Label 1' },
{ id: 2, title: 'Label 2' },
]);
expect(inactiveLabels.value).toEqual([{ id: 3, title: 'Label 3' }]);
});
it('should update labels correctly', async () => {
const { onUpdateLabels } = useConversationLabels();
await onUpdateLabels(['Label 1', 'Label 3']);
expect(store.dispatch).toHaveBeenCalledWith('conversationLabels/update', {
conversationId: 1,
labels: ['Label 1', 'Label 3'],
});
});
it('should add a label to the conversation', () => {
store.getters['conversationLabels/getConversationLabels'].mockReturnValue([
'Label 1',
]);
const { addLabelToConversation } = useConversationLabels();
addLabelToConversation({ title: 'Label 2' });
expect(store.dispatch).toHaveBeenCalledWith('conversationLabels/update', {
conversationId: 1,
labels: ['Label 1', 'Label 2'],
});
});
it('should remove a label from the conversation', () => {
store.getters['conversationLabels/getConversationLabels'].mockReturnValue([
'Label 1',
'Label 2',
]);
const { removeLabelFromConversation } = useConversationLabels();
removeLabelFromConversation('Label 2');
expect(store.dispatch).toHaveBeenCalledWith('conversationLabels/update', {
conversationId: 1,
labels: ['Label 1'],
});
});
});

View File

@@ -17,7 +17,7 @@ export const useStoreGetters = () => {
);
};
export const mapGetter = key => {
export const useMapGetter = key => {
const store = useStore();
return computed(() => store.getters[key]);
};

View File

@@ -0,0 +1,101 @@
import { computed } from 'vue';
import { useStore, useStoreGetters } from 'dashboard/composables/store';
/**
* Composable for managing conversation labels
* @returns {Object} An object containing methods and computed properties for conversation labels
*/
export function useConversationLabels() {
const store = useStore();
const getters = useStoreGetters();
/**
* The currently selected chat
* @type {import('vue').ComputedRef<Object>}
*/
const currentChat = computed(() => getters.getSelectedChat.value);
/**
* The ID of the current conversation
* @type {import('vue').ComputedRef<number|null>}
*/
const conversationId = computed(() => currentChat.value?.id);
/**
* All labels available for the account
* @type {import('vue').ComputedRef<Array>}
*/
const accountLabels = computed(() => getters['labels/getLabels'].value);
/**
* Labels currently saved to the conversation
* @type {import('vue').ComputedRef<Array>}
*/
const savedLabels = computed(() => {
return store.getters['conversationLabels/getConversationLabels'](
conversationId.value
);
});
/**
* Labels currently active on the conversation
* @type {import('vue').ComputedRef<Array>}
*/
const activeLabels = computed(() =>
accountLabels.value.filter(({ title }) => savedLabels.value.includes(title))
);
/**
* Labels available but not active on the conversation
* @type {import('vue').ComputedRef<Array>}
*/
const inactiveLabels = computed(() =>
accountLabels.value.filter(
({ title }) => !savedLabels.value.includes(title)
)
);
/**
* Updates the labels for the current conversation
* @param {string[]} selectedLabels - Array of label titles to be set for the conversation
* @returns {Promise<void>}
*/
const onUpdateLabels = async selectedLabels => {
await store.dispatch('conversationLabels/update', {
conversationId: conversationId.value,
labels: selectedLabels,
});
};
/**
* Adds a label to the current conversation
* @param {Object} value - The label object to be added
* @param {string} value.title - The title of the label to be added
*/
const addLabelToConversation = value => {
const result = activeLabels.value.map(item => item.title);
result.push(value.title);
onUpdateLabels(result);
};
/**
* Removes a label from the current conversation
* @param {string} value - The title of the label to be removed
*/
const removeLabelFromConversation = value => {
const result = activeLabels.value
.map(label => label.title)
.filter(label => label !== value);
onUpdateLabels(result);
};
return {
accountLabels,
savedLabels,
activeLabels,
inactiveLabels,
addLabelToConversation,
removeLabelFromConversation,
onUpdateLabels,
};
}

View File

@@ -1,46 +0,0 @@
import { mapGetters } from 'vuex';
export default {
computed: {
...mapGetters({ accountLabels: 'labels/getLabels' }),
savedLabels() {
// If conversationLabels is passed as prop, use it
if (this.conversationLabels)
return this.conversationLabels.split(',').map(item => item.trim());
// Otherwise, get labels from store
return this.$store.getters['conversationLabels/getConversationLabels'](
this.conversationId
);
},
// TODO - Get rid of this from the mixin
activeLabels() {
return this.accountLabels.filter(({ title }) =>
this.savedLabels.includes(title)
);
},
inactiveLabels() {
return this.accountLabels.filter(
({ title }) => !this.savedLabels.includes(title)
);
},
},
methods: {
addLabelToConversation(value) {
const result = this.activeLabels.map(item => item.title);
result.push(value.title);
this.onUpdateLabels(result);
},
removeLabelFromConversation(value) {
const result = this.activeLabels
.map(label => label.title)
.filter(label => label !== value);
this.onUpdateLabels(result);
},
async onUpdateLabels(selectedLabels) {
this.$store.dispatch('conversationLabels/update', {
conversationId: this.conversationId,
labels: selectedLabels,
});
},
},
};

View File

@@ -1,5 +1,6 @@
<script>
import '@chatwoot/ninja-keys';
import { useConversationLabels } from 'dashboard/composables/useConversationLabels';
import wootConstants from 'dashboard/constants/globals';
import conversationHotKeysMixin from './conversationHotKeys';
import bulkActionsHotKeysMixin from './bulkActionsHotKeys';
@@ -7,7 +8,6 @@ import inboxHotKeysMixin from './inboxHotKeys';
import goToCommandHotKeys from './goToCommandHotKeys';
import appearanceHotKeys from './appearanceHotKeys';
import agentMixin from 'dashboard/mixins/agentMixin';
import conversationLabelMixin from 'dashboard/mixins/conversation/labelMixin';
import { GENERAL_EVENTS } from '../../../helper/AnalyticsHelper/events';
export default {
@@ -16,10 +16,25 @@ export default {
conversationHotKeysMixin,
bulkActionsHotKeysMixin,
inboxHotKeysMixin,
conversationLabelMixin,
appearanceHotKeys,
goToCommandHotKeys,
],
setup() {
// used in conversationHotKeysMixin
const {
activeLabels,
inactiveLabels,
addLabelToConversation,
removeLabelFromConversation,
} = useConversationLabels();
return {
activeLabels,
inactiveLabels,
addLabelToConversation,
removeLabelFromConversation,
};
},
data() {
return {
// Added selectedSnoozeType to track the selected snooze type

View File

@@ -2,11 +2,11 @@
import { ref } from 'vue';
import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useConversationLabels } from 'dashboard/composables/useConversationLabels';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import Spinner from 'shared/components/Spinner.vue';
import LabelDropdown from 'shared/components/ui/label/LabelDropdown.vue';
import AddLabel from 'shared/components/ui/dropdown/AddLabel.vue';
import conversationLabelMixin from 'dashboard/mixins/conversation/labelMixin';
export default {
components: {
@@ -14,20 +14,17 @@ export default {
LabelDropdown,
AddLabel,
},
mixins: [conversationLabelMixin],
props: {
// conversationId prop is used in /conversation/labelMixin,
// remove this props when refactoring to composable if not needed
// eslint-disable-next-line vue/no-unused-properties
conversationId: {
type: Number,
required: true,
},
},
setup() {
const { isAdmin } = useAdmin();
const {
savedLabels,
activeLabels,
accountLabels,
addLabelToConversation,
removeLabelFromConversation,
} = useConversationLabels();
const conversationLabelBoxRef = ref(null);
const showSearchDropdownLabel = ref(false);
@@ -58,6 +55,11 @@ export default {
useKeyboardEvents(keyboardEvents, conversationLabelBoxRef);
return {
isAdmin,
savedLabels,
activeLabels,
accountLabels,
addLabelToConversation,
removeLabelFromConversation,
conversationLabelBoxRef,
showSearchDropdownLabel,
closeDropdownLabel,

View File

@@ -1,8 +1,9 @@
<script setup>
import { computed } from 'vue';
import UserAvatarWithName from 'dashboard/components/widgets/UserAvatarWithName.vue';
import CardLabels from 'dashboard/components/widgets/conversation/conversationCardComponents/CardLabels.vue';
import SLAViewDetails from './SLAViewDetails.vue';
defineProps({
const props = defineProps({
slaName: {
type: String,
required: true,
@@ -20,6 +21,12 @@ defineProps({
default: () => [],
},
});
const conversationLabels = computed(() => {
return props.conversation.labels
? props.conversation.labels.split(',').map(item => item.trim())
: [];
});
</script>
<template>
@@ -41,7 +48,7 @@ defineProps({
<CardLabels
class="w-[80%]"
:conversation-id="conversationId"
:conversation-labels="conversation.labels"
:conversation-labels="conversationLabels"
/>
</div>
<div