### Description
When integrating the web widget via the JS SDK, customers call
setConversationCustomAttributes and setLabel on chatwoot:ready — before
any conversation exists. These API calls silently fail because the
backend endpoints require an existing conversation. When the visitor
sends their first message, the conversation is created without those
attributes/labels, so the message_created webhook payload is missing the
expected metadata.
This change queues SDK-set conversation custom attributes and labels in
the widget store when no conversation exists yet, and includes them in
the API request when the first message (or attachment) creates the
conversation. The backend now permits and applies these params during
conversation creation — before the message is saved and webhooks fire.
### How to test
1. Configure a web widget without a pre-chat form.
2. Open the widget on a test page and run the following in the browser
console after chatwoot:ready:
`window.$chatwoot.setConversationCustomAttributes({ plan: 'enterprise'
});`
`window.$chatwoot.setLabel('vip');` // must be a label that exists in
the account
3. Send the first message from the widget.
4. Verify in the Chatwoot dashboard that the conversation has plan:
enterprise in custom attributes and the vip label applied.
5. Set up a webhook subscriber for `message_created` confirm the first
payload includes the conversation metadata.
6. Verify that calling `setConversationCustomAttributes` / `setLabel` on
an existing conversation still works as before (direct API path, no
regression).
7. Verify the pre-chat form flow still works as expected.
322 lines
9.2 KiB
JavaScript
322 lines
9.2 KiB
JavaScript
import { mutations } from '../../conversation/mutations';
|
|
|
|
const temporaryMessagePayload = {
|
|
content: 'hello',
|
|
id: 1,
|
|
message_type: 0,
|
|
status: 'in_progress',
|
|
};
|
|
|
|
const incomingMessagePayload = {
|
|
content: 'hello',
|
|
id: 1,
|
|
message_type: 0,
|
|
status: 'sent',
|
|
};
|
|
|
|
const outgoingMessagePayload = {
|
|
content: 'hello',
|
|
id: 1,
|
|
message_type: 1,
|
|
status: 'sent',
|
|
};
|
|
|
|
describe('#mutations', () => {
|
|
describe('#pushMessageToConversation', () => {
|
|
it('add message to conversation if outgoing', () => {
|
|
const state = { conversations: {} };
|
|
mutations.pushMessageToConversation(state, outgoingMessagePayload);
|
|
expect(state.conversations).toEqual({
|
|
1: outgoingMessagePayload,
|
|
});
|
|
});
|
|
|
|
it('add message to conversation if message in undelivered', () => {
|
|
const state = { conversations: {} };
|
|
mutations.pushMessageToConversation(state, temporaryMessagePayload);
|
|
expect(state.conversations).toEqual({
|
|
1: temporaryMessagePayload,
|
|
});
|
|
});
|
|
|
|
it('replaces temporary message in conversation with actual message', () => {
|
|
const state = {
|
|
conversations: {
|
|
rand_id_123: {
|
|
content: 'hello',
|
|
id: 'rand_id_123',
|
|
message_type: 0,
|
|
status: 'in_progress',
|
|
},
|
|
},
|
|
};
|
|
mutations.pushMessageToConversation(state, incomingMessagePayload);
|
|
expect(state.conversations).toEqual({
|
|
1: incomingMessagePayload,
|
|
});
|
|
});
|
|
|
|
it('adds message in conversation if it is a new message', () => {
|
|
const state = { conversations: {} };
|
|
mutations.pushMessageToConversation(state, incomingMessagePayload);
|
|
expect(state.conversations).toEqual({
|
|
1: incomingMessagePayload,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('#setConversationListLoading', () => {
|
|
it('set status correctly', () => {
|
|
const state = { uiFlags: { isFetchingList: false } };
|
|
mutations.setConversationListLoading(state, true);
|
|
expect(state.uiFlags.isFetchingList).toEqual(true);
|
|
});
|
|
});
|
|
|
|
describe('#setConversationUIFlag', () => {
|
|
it('set uiFlags correctly', () => {
|
|
const state = { uiFlags: { isFetchingList: false } };
|
|
mutations.setConversationUIFlag(state, { isCreating: true });
|
|
expect(state.uiFlags).toEqual({
|
|
isFetchingList: false,
|
|
isCreating: true,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('#setMessagesInConversation', () => {
|
|
it('sets allMessagesLoaded flag if payload is empty', () => {
|
|
const state = { uiFlags: { allMessagesLoaded: false } };
|
|
mutations.setMessagesInConversation(state, []);
|
|
expect(state.uiFlags.allMessagesLoaded).toEqual(true);
|
|
});
|
|
|
|
it('sets messages if payload is not empty', () => {
|
|
const state = {
|
|
uiFlags: { allMessagesLoaded: false },
|
|
conversations: {},
|
|
};
|
|
mutations.setMessagesInConversation(state, [{ id: 1, content: 'hello' }]);
|
|
expect(state.conversations).toEqual({
|
|
1: { id: 1, content: 'hello' },
|
|
});
|
|
expect(state.uiFlags.allMessagesLoaded).toEqual(false);
|
|
});
|
|
});
|
|
|
|
describe('#toggleAgentTypingStatus', () => {
|
|
it('sets isAgentTyping flag to true', () => {
|
|
const state = { uiFlags: { isAgentTyping: false } };
|
|
mutations.toggleAgentTypingStatus(state, { status: 'on' });
|
|
expect(state.uiFlags.isAgentTyping).toEqual(true);
|
|
});
|
|
|
|
it('sets isAgentTyping flag to false', () => {
|
|
const state = { uiFlags: { isAgentTyping: true } };
|
|
mutations.toggleAgentTypingStatus(state, { status: 'off' });
|
|
expect(state.uiFlags.isAgentTyping).toEqual(false);
|
|
});
|
|
});
|
|
|
|
describe('#updateAttachmentMessageStatus', () => {
|
|
it('Updates status of loading messages if payload is not empty', () => {
|
|
const state = {
|
|
conversations: {
|
|
rand_id_123: {
|
|
content: '',
|
|
id: 'rand_id_123',
|
|
message_type: 0,
|
|
status: 'in_progress',
|
|
attachment: {
|
|
file: '',
|
|
file_type: 'image',
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const message = {
|
|
id: '1',
|
|
content: '',
|
|
status: 'sent',
|
|
message_type: 0,
|
|
attachments: [
|
|
{
|
|
file: '',
|
|
file_type: 'image',
|
|
},
|
|
],
|
|
};
|
|
mutations.updateAttachmentMessageStatus(state, {
|
|
message,
|
|
tempId: 'rand_id_123',
|
|
});
|
|
|
|
expect(state.conversations).toEqual({
|
|
1: {
|
|
id: '1',
|
|
content: '',
|
|
message_type: 0,
|
|
status: 'sent',
|
|
attachments: [
|
|
{
|
|
file: '',
|
|
file_type: 'image',
|
|
},
|
|
],
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('#clearConversations', () => {
|
|
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([]);
|
|
});
|
|
});
|
|
|
|
describe('#deleteMessage', () => {
|
|
it('delete the message from conversation', () => {
|
|
const state = { conversations: { 1: { id: 1 } } };
|
|
mutations.deleteMessage(state, 1);
|
|
expect(state.conversations).toEqual({});
|
|
});
|
|
});
|
|
|
|
describe('#setMissingMessages', () => {
|
|
it('sets messages if payload is not empty', () => {
|
|
const state = {
|
|
uiFlags: { allMessagesLoaded: false },
|
|
conversations: {
|
|
454: {
|
|
id: 454,
|
|
content: 'hi',
|
|
message_type: 0,
|
|
content_type: 'text',
|
|
content_attributes: {},
|
|
created_at: 1682432667,
|
|
conversation_id: 20,
|
|
},
|
|
464: {
|
|
id: 464,
|
|
content: 'hey will be back soon',
|
|
message_type: 3,
|
|
content_type: 'text',
|
|
content_attributes: {},
|
|
created_at: 1682490729,
|
|
conversation_id: 20,
|
|
},
|
|
},
|
|
};
|
|
mutations.setMessagesInConversation(state, [
|
|
{
|
|
id: 455,
|
|
content: 'Hey billowing-grass-423 how are you?',
|
|
message_type: 3,
|
|
content_type: 'text',
|
|
content_attributes: {},
|
|
created_at: 1682432667,
|
|
conversation_id: 20,
|
|
},
|
|
]);
|
|
expect(state.conversations).toEqual({
|
|
454: {
|
|
id: 454,
|
|
content: 'hi',
|
|
message_type: 0,
|
|
content_type: 'text',
|
|
content_attributes: {},
|
|
created_at: 1682432667,
|
|
conversation_id: 20,
|
|
},
|
|
455: {
|
|
id: 455,
|
|
content: 'Hey billowing-grass-423 how are you?',
|
|
message_type: 3,
|
|
content_type: 'text',
|
|
content_attributes: {},
|
|
created_at: 1682432667,
|
|
conversation_id: 20,
|
|
},
|
|
464: {
|
|
id: 464,
|
|
content: 'hey will be back soon',
|
|
message_type: 3,
|
|
content_type: 'text',
|
|
content_attributes: {},
|
|
created_at: 1682490729,
|
|
conversation_id: 20,
|
|
},
|
|
});
|
|
});
|
|
});
|
|
});
|