feat: Add manual WhatsApp templates sync with UI (#12007)

Fixes https://github.com/chatwoot/chatwoot/issues/9600

**Summary**
- Added manual WhatsApp templates sync functionality accessible via UI
- Added refresh button in both inbox settings and template picker modal
- Enhanced template picker to always show for WhatsApp Cloud channels
(even when empty)


**Preview**

<img width="1456" height="798" alt="CleanShot 2025-07-22 at 14 15 28@2x"
src="https://github.com/user-attachments/assets/8a04ff26-61fa-42ee-a1b8-5e06433ae6e0"
/>


<img width="2376" height="1574" alt="CleanShot 2025-07-22 at 14 19
24@2x"
src="https://github.com/user-attachments/assets/1c772034-04ff-484d-88dd-ca8123e93065"
/>

---------

Co-authored-by: iamsivin <iamsivin@gmail.com>
This commit is contained in:
Muhsin Keloth
2025-07-23 12:22:51 +04:00
committed by GitHub
parent d276025419
commit ab1ba1c4c7
15 changed files with 243 additions and 30 deletions

View File

@@ -69,6 +69,17 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') } render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
end end
def sync_templates
unless @inbox.channel.is_a?(Channel::Whatsapp)
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' }
end
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
render status: :ok, json: { message: 'Template sync initiated successfully' }
rescue StandardError => e
render status: :internal_server_error, json: { error: e.message }
end
private private
def fetch_inbox def fetch_inbox

View File

@@ -28,6 +28,10 @@ class Inboxes extends CacheEnabledApiClient {
agent_bot: botId, agent_bot: botId,
}); });
} }
syncTemplates(inboxId) {
return axios.post(`${this.url}/${inboxId}/sync_templates`);
}
} }
export default new Inboxes(); export default new Inboxes();

View File

@@ -12,6 +12,7 @@ describe('#InboxesAPI', () => {
expect(inboxesAPI).toHaveProperty('getCampaigns'); expect(inboxesAPI).toHaveProperty('getCampaigns');
expect(inboxesAPI).toHaveProperty('getAgentBot'); expect(inboxesAPI).toHaveProperty('getAgentBot');
expect(inboxesAPI).toHaveProperty('setAgentBot'); expect(inboxesAPI).toHaveProperty('setAgentBot');
expect(inboxesAPI).toHaveProperty('syncTemplates');
}); });
describe('API calls', () => { describe('API calls', () => {
@@ -40,5 +41,12 @@ describe('#InboxesAPI', () => {
inboxesAPI.deleteInboxAvatar(2); inboxesAPI.deleteInboxAvatar(2);
expect(axiosMock.delete).toHaveBeenCalledWith('/api/v1/inboxes/2/avatar'); expect(axiosMock.delete).toHaveBeenCalledWith('/api/v1/inboxes/2/avatar');
}); });
it('#syncTemplates', () => {
inboxesAPI.syncTemplates(2);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/inboxes/2/sync_templates'
);
});
}); });
}); });

View File

@@ -61,7 +61,9 @@ const onCopy = async e => {
<template> <template>
<div class="relative text-left"> <div class="relative text-left">
<div class="top-1.5 absolute right-1.5 flex items-center gap-1"> <div
class="top-1.5 absolute ltr:right-1.5 rtl:left-1.5 flex items-center gap-1"
>
<form <form
v-if="enableCodePen" v-if="enableCodePen"
class="flex items-center" class="flex items-center"
@@ -86,6 +88,11 @@ const onCopy = async e => {
@click="onCopy" @click="onCopy"
/> />
</div> </div>
<highlightjs v-if="script" :language="lang" :code="scrubbedScript" /> <highlightjs
v-if="script"
:language="lang"
:code="scrubbedScript"
class="[&_code]:ltr:!pr-16 [&_code]:rtl:!pl-16 [&_code]:truncate [&_code]:text-start"
/>
</div> </div>
</template> </template>

View File

@@ -94,7 +94,7 @@ export default {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
hasWhatsappTemplates: { enableWhatsAppTemplates: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
@@ -333,7 +333,7 @@ export default {
@click="toggleMessageSignature" @click="toggleMessageSignature"
/> />
<NextButton <NextButton
v-if="hasWhatsappTemplates" v-if="enableWhatsAppTemplates"
v-tooltip.top-end="$t('CONVERSATION.FOOTER.WHATSAPP_TEMPLATES')" v-tooltip.top-end="$t('CONVERSATION.FOOTER.WHATSAPP_TEMPLATES')"
icon="i-ph-whatsapp-logo" icon="i-ph-whatsapp-logo"
slate slate

View File

@@ -184,9 +184,8 @@ export default {
return false; return false;
}, },
hasWhatsappTemplates() { showWhatsappTemplates() {
return !!this.$store.getters['inboxes/getWhatsAppTemplates'](this.inboxId) return this.isAWhatsAppCloudChannel && !this.isPrivate;
.length;
}, },
isPrivate() { isPrivate() {
if (this.currentChat.can_reply || this.isAWhatsAppChannel) { if (this.currentChat.can_reply || this.isAWhatsAppChannel) {
@@ -1212,7 +1211,7 @@ export default {
<ReplyBottomPanel <ReplyBottomPanel
:conversation-id="conversationId" :conversation-id="conversationId"
:enable-multiple-file-upload="enableMultipleFileUpload" :enable-multiple-file-upload="enableMultipleFileUpload"
:has-whatsapp-templates="hasWhatsappTemplates" :enable-whats-app-templates="showWhatsappTemplates"
:inbox="inbox" :inbox="inbox"
:is-on-private-note="isOnPrivateNote" :is-on-private-note="isOnPrivateNote"
:is-recording-audio="isRecordingAudio" :is-recording-audio="isRecordingAudio"

View File

@@ -1,8 +1,13 @@
<script> <script>
import { useAlert } from 'dashboard/composables';
import Icon from 'dashboard/components-next/icon/Icon.vue';
// TODO: Remove this when we support all formats // TODO: Remove this when we support all formats
const formatsToRemove = ['DOCUMENT', 'IMAGE', 'VIDEO']; const formatsToRemove = ['DOCUMENT', 'IMAGE', 'VIDEO'];
export default { export default {
components: {
Icon,
},
props: { props: {
inboxId: { inboxId: {
type: Number, type: Number,
@@ -13,6 +18,7 @@ export default {
data() { data() {
return { return {
query: '', query: '',
isRefreshing: false,
}; };
}, },
computed: { computed: {
@@ -37,14 +43,26 @@ export default {
return template.components.find(component => component.type === 'BODY') return template.components.find(component => component.type === 'BODY')
.text; .text;
}, },
async refreshTemplates() {
this.isRefreshing = true;
try {
await this.$store.dispatch('inboxes/syncTemplates', this.inboxId);
useAlert(this.$t('WHATSAPP_TEMPLATES.PICKER.REFRESH_SUCCESS'));
} catch (error) {
useAlert(this.$t('WHATSAPP_TEMPLATES.PICKER.REFRESH_ERROR'));
} finally {
this.isRefreshing = false;
}
},
}, },
}; };
</script> </script>
<template> <template>
<div class="w-full"> <div class="w-full">
<div class="flex gap-2 mb-2.5">
<div <div
class="gap-1 bg-n-alpha-black2 items-center flex mb-2.5 py-0 px-2.5 rounded-lg outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 focus-within:outline-n-brand dark:focus-within:outline-n-brand" class="flex flex-1 gap-1 items-center px-2.5 py-0 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 focus-within:outline-n-brand dark:focus-within:outline-n-brand"
> >
<fluent-icon icon="search" class="text-n-slate-12" size="16" /> <fluent-icon icon="search" class="text-n-slate-12" size="16" />
<input <input
@@ -54,21 +72,34 @@ export default {
class="reset-base w-full h-9 bg-transparent text-n-slate-12 !text-sm !outline-0" class="reset-base w-full h-9 bg-transparent text-n-slate-12 !text-sm !outline-0"
/> />
</div> </div>
<button
:disabled="isRefreshing"
class="flex justify-center items-center w-9 h-9 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 hover:bg-n-alpha-2 dark:hover:bg-n-solid-2 disabled:opacity-50 disabled:cursor-not-allowed"
:title="$t('WHATSAPP_TEMPLATES.PICKER.REFRESH_BUTTON')"
@click="refreshTemplates"
>
<Icon
icon="i-lucide-refresh-ccw"
class="text-n-slate-12 size-4"
:class="{ 'animate-spin': isRefreshing }"
/>
</button>
</div>
<div <div
class="bg-n-background outline-n-container outline outline-1 rounded-lg max-h-[18.75rem] overflow-y-auto p-2.5" class="bg-n-background outline-n-container outline outline-1 rounded-lg max-h-[18.75rem] overflow-y-auto p-2.5"
> >
<div v-for="(template, i) in filteredTemplateMessages" :key="template.id"> <div v-for="(template, i) in filteredTemplateMessages" :key="template.id">
<button <button
class="rounded-lg cursor-pointer block p-2.5 text-left w-full hover:bg-n-alpha-2 dark:hover:bg-n-solid-2" class="block p-2.5 w-full text-left rounded-lg cursor-pointer hover:bg-n-alpha-2 dark:hover:bg-n-solid-2"
@click="$emit('onSelect', template)" @click="$emit('onSelect', template)"
> >
<div> <div>
<div class="flex items-center justify-between mb-2.5"> <div class="flex justify-between items-center mb-2.5">
<p class="text-sm"> <p class="text-sm">
{{ template.name }} {{ template.name }}
</p> </p>
<span <span
class="inline-block px-2 py-1 text-xs leading-none bg-n-slate-3 rounded-lg cursor-default text-n-slate-12" class="inline-block px-2 py-1 text-xs leading-none rounded-lg cursor-default bg-n-slate-3 text-n-slate-12"
> >
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.LANGUAGE') }} : {{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.LANGUAGE') }} :
{{ template.language }} {{ template.language }}
@@ -94,12 +125,19 @@ export default {
class="border-b border-solid border-n-weak my-2.5 mx-auto max-w-[95%]" class="border-b border-solid border-n-weak my-2.5 mx-auto max-w-[95%]"
/> />
</div> </div>
<div v-if="!filteredTemplateMessages.length"> <div v-if="!filteredTemplateMessages.length" class="py-8 text-center">
<div v-if="query && whatsAppTemplateMessages.length">
<p> <p>
{{ $t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }} {{ $t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }}
<strong>{{ query }}</strong> <strong>{{ query }}</strong>
</p> </p>
</div> </div>
<div v-else-if="!whatsAppTemplateMessages.length" class="space-y-4">
<p class="text-n-slate-11">
{{ $t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_AVAILABLE') }}
</p>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -600,6 +600,10 @@
"WHATSAPP_SECTION_UPDATE_BUTTON": "Update", "WHATSAPP_SECTION_UPDATE_BUTTON": "Update",
"WHATSAPP_WEBHOOK_TITLE": "Webhook Verification Token", "WHATSAPP_WEBHOOK_TITLE": "Webhook Verification Token",
"WHATSAPP_WEBHOOK_SUBHEADER": "This token is used to verify the authenticity of the webhook endpoint.", "WHATSAPP_WEBHOOK_SUBHEADER": "This token is used to verify the authenticity of the webhook endpoint.",
"WHATSAPP_TEMPLATES_SYNC_TITLE": "Sync Templates",
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Manually sync message templates from WhatsApp to update your available templates.",
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "Sync Templates",
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Templates sync initiated successfully. It may take a couple of minutes to update.",
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings" "UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings"
}, },
"HELP_CENTER": { "HELP_CENTER": {

View File

@@ -8,6 +8,10 @@
"PICKER": { "PICKER": {
"SEARCH_PLACEHOLDER": "Search Templates", "SEARCH_PLACEHOLDER": "Search Templates",
"NO_TEMPLATES_FOUND": "No templates found for", "NO_TEMPLATES_FOUND": "No templates found for",
"NO_TEMPLATES_AVAILABLE": "No WhatsApp templates available. Click refresh to sync templates from WhatsApp.",
"REFRESH_BUTTON": "Refresh templates",
"REFRESH_SUCCESS": "Templates refresh initiated. It may take a couple of minutes to update.",
"REFRESH_ERROR": "Failed to refresh templates. Please try again.",
"LABELS": { "LABELS": {
"LANGUAGE": "Language", "LANGUAGE": "Language",
"TEMPLATE_BODY": "Template Body", "TEMPLATE_BODY": "Template Body",

View File

@@ -29,6 +29,7 @@ export default {
return { return {
hmacMandatory: false, hmacMandatory: false,
whatsAppInboxAPIKey: '', whatsAppInboxAPIKey: '',
isSyncingTemplates: false,
}; };
}, },
validations: { validations: {
@@ -83,6 +84,19 @@ export default {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE')); useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
} }
}, },
async syncTemplates() {
this.isSyncingTemplates = true;
try {
await this.$store.dispatch('inboxes/syncTemplates', this.inbox.id);
useAlert(
this.$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_SUCCESS')
);
} catch (error) {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
} finally {
this.isSyncingTemplates = false;
}
},
}, },
}; };
</script> </script>
@@ -137,7 +151,7 @@ export default {
:title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_VERIFICATION')" :title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_VERIFICATION')"
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_DESCRIPTION')" :sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_DESCRIPTION')"
> >
<div class="flex items-center gap-2"> <div class="flex gap-2 items-center">
<input <input
id="hmacMandatory" id="hmacMandatory"
v-model="hmacMandatory" v-model="hmacMandatory"
@@ -169,7 +183,7 @@ export default {
:title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_VERIFICATION')" :title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_VERIFICATION')"
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_DESCRIPTION')" :sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_DESCRIPTION')"
> >
<div class="flex items-center gap-2"> <div class="flex gap-2 items-center">
<input <input
id="hmacMandatory" id="hmacMandatory"
v-model="hmacMandatory" v-model="hmacMandatory"
@@ -215,12 +229,12 @@ export default {
" "
> >
<div <div
class="flex items-center justify-between flex-1 mt-2 whatsapp-settings--content" class="flex flex-1 justify-between items-center mt-2 whatsapp-settings--content"
> >
<woot-input <woot-input
v-model="whatsAppInboxAPIKey" v-model="whatsAppInboxAPIKey"
type="text" type="text"
class="flex-1 mr-2" class="flex-1 mr-2 [&>input]:!mb-0"
:placeholder=" :placeholder="
$t( $t(
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_PLACEHOLDER' 'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_PLACEHOLDER'
@@ -235,6 +249,18 @@ export default {
</NextButton> </NextButton>
</div> </div>
</SettingsSection> </SettingsSection>
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_TITLE')"
:sub-title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_SUBHEADER')
"
>
<div class="flex justify-start items-center mt-2">
<NextButton :disabled="isSyncingTemplates" @click="syncTemplates">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_BUTTON') }}
</NextButton>
</div>
</SettingsSection>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -285,6 +285,13 @@ export const actions = {
throw new Error(error); throw new Error(error);
} }
}, },
syncTemplates: async (_, inboxId) => {
try {
await InboxesAPI.syncTemplates(inboxId);
} catch (error) {
throw new Error(error);
}
},
}; };
export const mutations = { export const mutations = {

View File

@@ -231,4 +231,28 @@ describe('#actions', () => {
).rejects.toThrow(Error); ).rejects.toThrow(Error);
}); });
}); });
describe('#syncTemplates', () => {
it('sends correct API call when sync is successful', async () => {
axios.post.mockResolvedValue({
data: { message: 'Template sync initiated successfully' },
});
await actions.syncTemplates({ commit }, 123);
expect(axios.post).toHaveBeenCalledWith(
'/api/v1/inboxes/123/sync_templates'
);
});
it('throws error when API call fails', async () => {
const errorMessage =
'Template sync is only available for WhatsApp channels';
axios.post.mockRejectedValue(new Error(errorMessage));
await expect(actions.syncTemplates({ commit }, 123)).rejects.toThrow(
errorMessage
);
});
});
}); });

View File

@@ -57,4 +57,8 @@ class InboxPolicy < ApplicationPolicy
def avatar? def avatar?
@account_user.administrator? @account_user.administrator?
end end
def sync_templates?
@account_user.administrator?
end
end end

View File

@@ -185,6 +185,7 @@ Rails.application.routes.draw do
get :agent_bot, on: :member get :agent_bot, on: :member
post :set_agent_bot, on: :member post :set_agent_bot, on: :member
delete :avatar, on: :member delete :avatar, on: :member
post :sync_templates, on: :member
end end
resources :inbox_members, only: [:create, :show], param: :inbox_id do resources :inbox_members, only: [:create, :show], param: :inbox_id do
collection do collection do

View File

@@ -904,4 +904,80 @@ RSpec.describe 'Inboxes API', type: :request do
end end
end end
end end
describe 'POST /api/v1/accounts/{account.id}/inboxes/:id/sync_templates' do
let(:whatsapp_channel) do
create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false)
end
let(:whatsapp_inbox) { create(:inbox, account: account, channel: whatsapp_channel) }
let(:non_whatsapp_inbox) { create(:inbox, account: account) }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/sync_templates"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated agent' do
it 'returns unauthorized for agent' do
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/sync_templates",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated administrator' do
context 'with WhatsApp inbox' do
it 'successfully initiates template sync' do
expect(Channels::Whatsapp::TemplatesSyncJob).to receive(:perform_later).with(whatsapp_channel)
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/sync_templates",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['message']).to eq('Template sync initiated successfully')
end
it 'handles job errors gracefully' do
allow(Channels::Whatsapp::TemplatesSyncJob).to receive(:perform_later).and_raise(StandardError, 'Job failed')
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/sync_templates",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:internal_server_error)
json_response = response.parsed_body
expect(json_response['error']).to eq('Job failed')
end
end
context 'with non-WhatsApp inbox' do
it 'returns unprocessable entity error' do
post "/api/v1/accounts/#{account.id}/inboxes/#{non_whatsapp_inbox.id}/sync_templates",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
json_response = response.parsed_body
expect(json_response['error']).to eq('Template sync is only available for WhatsApp channels')
end
end
context 'with non-existent inbox' do
it 'returns not found error' do
post "/api/v1/accounts/#{account.id}/inboxes/999999/sync_templates",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:not_found)
end
end
end
end
end end