Merge branch 'develop' into inline-edit

This commit is contained in:
Sivin Varghese
2026-04-02 22:37:56 +05:30
committed by GitHub
22 changed files with 608 additions and 57 deletions
+17 -4
View File
@@ -6,13 +6,26 @@ const createConversationAPI = async content => {
return API.post(urlData.url, urlData.params);
};
const sendMessageAPI = async (content, replyTo = null) => {
const urlData = endPoints.sendMessage(content, replyTo);
const sendMessageAPI = async (
content,
replyTo = null,
{ customAttributes, labels } = {}
) => {
const urlData = endPoints.sendMessage(content, replyTo, {
customAttributes,
labels,
});
return API.post(urlData.url, urlData.params);
};
const sendAttachmentAPI = async (attachment, replyTo = null) => {
const urlData = endPoints.sendAttachment(attachment, replyTo);
const sendAttachmentAPI = async (
attachment,
{ customAttributes, labels } = {}
) => {
const urlData = endPoints.sendAttachment(attachment, {
customAttributes,
labels,
});
return API.post(urlData.url, urlData.params);
};
+28 -11
View File
@@ -22,23 +22,30 @@ const createConversation = params => {
};
};
const sendMessage = (content, replyTo) => {
const sendMessage = (content, replyTo, { customAttributes, labels } = {}) => {
const referrerURL = window.referrerURL || '';
const search = buildSearchParamsWithLocale(window.location.search);
return {
url: `/api/v1/widget/messages${search}`,
params: {
message: {
content,
reply_to: replyTo,
timestamp: new Date().toString(),
referer_url: referrerURL,
},
const params = {
message: {
content,
reply_to: replyTo,
timestamp: new Date().toString(),
referer_url: referrerURL,
},
};
if (customAttributes && Object.keys(customAttributes).length > 0) {
params.custom_attributes = customAttributes;
}
if (labels && labels.length > 0) {
params.labels = labels;
}
return { url: `/api/v1/widget/messages${search}`, params };
};
const sendAttachment = ({ attachment, replyTo = null }) => {
const sendAttachment = (
{ attachment, replyTo = null },
{ customAttributes, labels } = {}
) => {
const { referrerURL = '' } = window;
const timestamp = new Date().toString();
const { file } = attachment;
@@ -55,6 +62,16 @@ const sendAttachment = ({ attachment, replyTo = null }) => {
if (replyTo !== null) {
formData.append('message[reply_to]', replyTo);
}
if (customAttributes && Object.keys(customAttributes).length > 0) {
Object.entries(customAttributes).forEach(([key, value]) => {
formData.append(`custom_attributes[${key}]`, value);
});
}
if (labels && labels.length > 0) {
labels.forEach(label => {
formData.append('labels[]', label);
});
}
return {
url: `/api/v1/widget/messages${window.location.search}`,
params: formData,
@@ -32,6 +32,50 @@ describe('#sendMessage', () => {
});
});
describe('#sendMessage with pending metadata', () => {
it('includes custom_attributes and labels in payload', () => {
const spy = vi.spyOn(global, 'Date').mockImplementation(() => ({
toString: () => 'mock date',
}));
vi.spyOn(window, 'location', 'get').mockReturnValue({
...window.location,
search: '?param=1',
});
window.WOOT_WIDGET = {
$root: { $i18n: { locale: 'ar' } },
};
const result = endPoints.sendMessage('hello', null, {
customAttributes: { plan: 'enterprise' },
labels: ['vip'],
});
expect(result.params.custom_attributes).toEqual({ plan: 'enterprise' });
expect(result.params.labels).toEqual(['vip']);
spy.mockRestore();
});
it('does not include metadata keys when not provided', () => {
const spy = vi.spyOn(global, 'Date').mockImplementation(() => ({
toString: () => 'mock date',
}));
vi.spyOn(window, 'location', 'get').mockReturnValue({
...window.location,
search: '?param=1',
});
window.WOOT_WIDGET = {
$root: { $i18n: { locale: 'ar' } },
};
const result = endPoints.sendMessage('hello');
expect(result.params.custom_attributes).toBeUndefined();
expect(result.params.labels).toBeUndefined();
spy.mockRestore();
});
});
describe('#getConversation', () => {
it('returns correct payload', () => {
vi.spyOn(window, 'location', 'get').mockReturnValue({
+1 -1
View File
@@ -7,7 +7,7 @@
html,
body {
@apply antialiased h-full bg-n-slate-2 dark:bg-n-solid-1;
@apply antialiased h-full;
}
.is-mobile {
@@ -85,10 +85,9 @@ export default {
},
methods: {
async retrySendMessage() {
await this.$store.dispatch(
'conversation/sendMessageWithData',
this.message
);
await this.$store.dispatch('conversation/sendMessageWithData', {
message: this.message,
});
},
onImageLoadError() {
this.hasImageError = true;
@@ -1,4 +1,4 @@
import { computed, watchEffect } from 'vue';
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
const isDarkModeAuto = mode => mode === 'auto';
@@ -23,10 +23,6 @@ export function useDarkMode() {
calculatePrefersDarkMode(darkMode.value, systemPreference.value)
);
watchEffect(() => {
document.documentElement.classList.toggle('dark', prefersDarkMode.value);
});
return {
darkMode,
prefersDarkMode,
@@ -30,18 +30,37 @@ export const actions = {
commit('setConversationUIFlag', { isCreating: false });
}
},
sendMessage: async ({ dispatch }, params) => {
sendMessage: async ({ dispatch, state: conversationState }, params) => {
const { content, replyTo } = params;
const message = createTemporaryMessage({ content, replyTo });
dispatch('sendMessageWithData', message);
const { pendingCustomAttributes, pendingLabels } = conversationState;
dispatch('sendMessageWithData', {
message,
pendingCustomAttributes,
pendingLabels,
});
},
sendMessageWithData: async ({ commit }, message) => {
sendMessageWithData: async (
{ commit },
{ message, pendingCustomAttributes = {}, pendingLabels = [] }
) => {
const { id, content, replyTo, meta = {} } = message;
const hasPendingMetadata =
Object.keys(pendingCustomAttributes).length > 0 ||
pendingLabels.length > 0;
commit('pushMessageToConversation', message);
commit('updateMessageMeta', { id, meta: { ...meta, error: '' } });
try {
const { data } = await sendMessageAPI(content, replyTo);
const { data } = await sendMessageAPI(content, replyTo, {
customAttributes: hasPendingMetadata
? pendingCustomAttributes
: undefined,
labels: hasPendingMetadata ? pendingLabels : undefined,
});
if (hasPendingMetadata) {
commit('clearPendingConversationMetadata');
}
// [VITE] Don't delete this manually, since `pushMessageToConversation` does the replacement for us anyway
// commit('deleteMessage', message.id);
@@ -59,7 +78,7 @@ export const actions = {
commit('setLastMessageId');
},
sendAttachment: async ({ commit }, params) => {
sendAttachment: async ({ commit, state: conversationState }, params) => {
const {
attachment: { thumbUrl, fileType },
meta = {},
@@ -74,9 +93,22 @@ export const actions = {
attachments: [attachment],
replyTo: params.replyTo,
});
const { pendingCustomAttributes, pendingLabels } = conversationState;
const hasPendingMetadata =
Object.keys(pendingCustomAttributes).length > 0 ||
pendingLabels.length > 0;
commit('pushMessageToConversation', tempMessage);
try {
const { data } = await sendAttachmentAPI(params);
const { data } = await sendAttachmentAPI(params, {
customAttributes: hasPendingMetadata
? pendingCustomAttributes
: undefined,
labels: hasPendingMetadata ? pendingLabels : undefined,
});
if (hasPendingMetadata) {
commit('clearPendingConversationMetadata');
}
commit('updateAttachmentMessageStatus', {
message: data,
tempId: tempMessage.id,
@@ -180,7 +212,14 @@ export const actions = {
await toggleStatus();
},
setCustomAttributes: async (_, customAttributes = {}) => {
setCustomAttributes: async (
{ commit, rootGetters },
customAttributes = {}
) => {
if (!rootGetters['conversationAttributes/getConversationParams']?.id) {
commit('setPendingCustomAttributes', customAttributes);
return;
}
try {
await setCustomAttributes(customAttributes);
} catch (error) {
@@ -188,7 +227,11 @@ export const actions = {
}
},
deleteCustomAttribute: async (_, customAttribute) => {
deleteCustomAttribute: async ({ commit, rootGetters }, customAttribute) => {
if (!rootGetters['conversationAttributes/getConversationParams']?.id) {
commit('removePendingCustomAttribute', customAttribute);
return;
}
try {
await deleteCustomAttribute(customAttribute);
} catch (error) {
@@ -33,6 +33,8 @@ export const getters = {
messages: groupConversationBySender(conversationGroupedByDate[date]),
}));
},
getPendingCustomAttributes: _state => _state.pendingCustomAttributes,
getPendingLabels: _state => _state.pendingLabels,
getIsFetchingList: _state => _state.uiFlags.isFetchingList,
getMessageCount: _state => {
return Object.values(_state.conversations).length;
@@ -14,6 +14,8 @@ const state = {
isCreating: false,
},
lastMessageId: null,
pendingCustomAttributes: {},
pendingLabels: [],
};
export default {
@@ -4,6 +4,8 @@ import { findUndeliveredMessage } from './helpers';
export const mutations = {
clearConversations($state) {
$state.conversations = {};
$state.pendingCustomAttributes = {};
$state.pendingLabels = [];
},
pushMessageToConversation($state, message) {
const { id, status, message_type: type } = message;
@@ -113,4 +115,31 @@ export const mutations = {
const { id } = lastMessage;
$state.lastMessageId = id;
},
setPendingCustomAttributes($state, data) {
$state.pendingCustomAttributes = {
...$state.pendingCustomAttributes,
...data,
};
},
setPendingLabels($state, label) {
if (!$state.pendingLabels.includes(label)) {
$state.pendingLabels.push(label);
}
},
removePendingCustomAttribute($state, key) {
const { [key]: _, ...rest } = $state.pendingCustomAttributes;
$state.pendingCustomAttributes = rest;
},
removePendingLabel($state, label) {
$state.pendingLabels = $state.pendingLabels.filter(l => l !== label);
},
clearPendingConversationMetadata($state) {
$state.pendingCustomAttributes = {};
$state.pendingLabels = [];
},
};
@@ -5,14 +5,22 @@ const state = {};
export const getters = {};
export const actions = {
create: async (_, label) => {
create: async ({ commit, rootGetters }, label) => {
if (!rootGetters['conversationAttributes/getConversationParams']?.id) {
commit('conversation/setPendingLabels', label, { root: true });
return;
}
try {
await conversationLabels.create(label);
} catch (error) {
// Ignore error
}
},
destroy: async (_, label) => {
destroy: async ({ commit, rootGetters }, label) => {
if (!rootGetters['conversationAttributes/getConversationParams']?.id) {
commit('conversation/removePendingLabel', label, { root: true });
return;
}
try {
await conversationLabels.destroy(label);
} catch (error) {
@@ -111,20 +111,45 @@ describe('#actions', () => {
search: '?param=1',
},
}));
const state = { pendingCustomAttributes: {}, pendingLabels: [] };
await actions.sendMessage(
{ commit, dispatch },
{ commit, dispatch, state },
{ content: 'hello', replyTo: 124 }
);
spy.mockRestore();
windowSpy.mockRestore();
expect(dispatch).toBeCalledWith('sendMessageWithData', {
attachments: undefined,
content: 'hello',
created_at: 1466424490,
id: '1111',
message_type: 0,
replyTo: 124,
status: 'in_progress',
message: {
attachments: undefined,
content: 'hello',
created_at: 1466424490,
id: '1111',
message_type: 0,
replyTo: 124,
status: 'in_progress',
},
pendingCustomAttributes: {},
pendingLabels: [],
});
});
it('includes pending metadata when available', async () => {
const mockDate = new Date(1466424490000);
getUuid.mockImplementationOnce(() => '2222');
const spy = vi.spyOn(global, 'Date').mockImplementation(() => mockDate);
const state = {
pendingCustomAttributes: { plan: 'enterprise' },
pendingLabels: ['vip'],
};
await actions.sendMessage(
{ commit, dispatch, state },
{ content: 'hello' }
);
spy.mockRestore();
expect(dispatch).toBeCalledWith('sendMessageWithData', {
message: expect.objectContaining({ content: 'hello' }),
pendingCustomAttributes: { plan: 'enterprise' },
pendingLabels: ['vip'],
});
});
});
@@ -136,9 +161,10 @@ describe('#actions', () => {
const spy = vi.spyOn(global, 'Date').mockImplementation(() => mockDate);
const thumbUrl = '';
const attachment = { thumbUrl, fileType: 'file' };
const state = { pendingCustomAttributes: {}, pendingLabels: [] };
actions.sendAttachment(
{ commit, dispatch },
{ commit, dispatch, state },
{ attachment, replyTo: 135 }
);
spy.mockRestore();
@@ -180,6 +206,58 @@ describe('#actions', () => {
});
});
describe('#setCustomAttributes', () => {
it('queues to pending state when no conversation exists', async () => {
const rootGetters = {
'conversationAttributes/getConversationParams': { id: '' },
};
await actions.setCustomAttributes(
{ commit, rootGetters },
{ plan: 'enterprise' }
);
expect(commit).toBeCalledWith('setPendingCustomAttributes', {
plan: 'enterprise',
});
});
it('calls API when conversation exists', async () => {
API.post.mockResolvedValue({ data: {} });
const rootGetters = {
'conversationAttributes/getConversationParams': { id: 123 },
};
await actions.setCustomAttributes(
{ commit, rootGetters },
{ plan: 'enterprise' }
);
expect(commit).not.toBeCalledWith(
'setPendingCustomAttributes',
expect.anything()
);
});
});
describe('#deleteCustomAttribute', () => {
it('removes from pending state when no conversation exists', async () => {
const rootGetters = {
'conversationAttributes/getConversationParams': { id: '' },
};
await actions.deleteCustomAttribute({ commit, rootGetters }, 'plan');
expect(commit).toBeCalledWith('removePendingCustomAttribute', 'plan');
});
it('calls API when conversation exists', async () => {
API.post.mockResolvedValue({ data: {} });
const rootGetters = {
'conversationAttributes/getConversationParams': { id: 123 },
};
await actions.deleteCustomAttribute({ commit, rootGetters }, 'plan');
expect(commit).not.toBeCalledWith(
'removePendingCustomAttribute',
expect.anything()
);
});
});
describe('#clearConversations', () => {
it('sends correct mutations', () => {
actions.clearConversations({ commit });
@@ -169,10 +169,77 @@ describe('#mutations', () => {
});
describe('#clearConversations', () => {
it('clears the state', () => {
const state = { conversations: { 1: { id: 1 } } };
it('clears conversations and pending metadata', () => {
const state = {
conversations: { 1: { id: 1 } },
pendingCustomAttributes: { plan: 'enterprise' },
pendingLabels: ['vip'],
};
mutations.clearConversations(state);
expect(state.conversations).toEqual({});
expect(state.pendingCustomAttributes).toEqual({});
expect(state.pendingLabels).toEqual([]);
});
});
describe('#setPendingCustomAttributes', () => {
it('merges custom attributes into pending state', () => {
const state = { pendingCustomAttributes: { existing: 'value' } };
mutations.setPendingCustomAttributes(state, { plan: 'enterprise' });
expect(state.pendingCustomAttributes).toEqual({
existing: 'value',
plan: 'enterprise',
});
});
});
describe('#setPendingLabels', () => {
it('adds label to pending state', () => {
const state = { pendingLabels: [] };
mutations.setPendingLabels(state, 'vip');
expect(state.pendingLabels).toEqual(['vip']);
});
it('does not add duplicate labels', () => {
const state = { pendingLabels: ['vip'] };
mutations.setPendingLabels(state, 'vip');
expect(state.pendingLabels).toEqual(['vip']);
});
});
describe('#removePendingCustomAttribute', () => {
it('removes a single key from pending custom attributes', () => {
const state = {
pendingCustomAttributes: { plan: 'enterprise', region: 'us' },
};
mutations.removePendingCustomAttribute(state, 'plan');
expect(state.pendingCustomAttributes).toEqual({ region: 'us' });
});
});
describe('#removePendingLabel', () => {
it('removes a label from pending labels', () => {
const state = { pendingLabels: ['vip', 'premium'] };
mutations.removePendingLabel(state, 'vip');
expect(state.pendingLabels).toEqual(['premium']);
});
it('does nothing if label not present', () => {
const state = { pendingLabels: ['vip'] };
mutations.removePendingLabel(state, 'premium');
expect(state.pendingLabels).toEqual(['vip']);
});
});
describe('#clearPendingConversationMetadata', () => {
it('clears pending custom attributes and labels', () => {
const state = {
pendingCustomAttributes: { plan: 'enterprise' },
pendingLabels: ['vip'],
};
mutations.clearPendingConversationMetadata(state);
expect(state.pendingCustomAttributes).toEqual({});
expect(state.pendingLabels).toEqual([]);
});
});
@@ -10,7 +10,7 @@ export default {
</script>
<template>
<div class="bg-n-solid-1 h-full">
<div class="bg-white h-full">
<IframeLoader :url="$route.query.link" />
</div>
</template>