feat(whatsapp): improve template listing controls (#15377)
Improves the WhatsApp template listing so account admins can understand template state and refresh templates from one place. Templates that have not entered the WhatsApp approval flow now use clearer wording, the latest sync attempt appears once at page level, and templates can be filtered by content type. The page can now start a template sync across all active WhatsApp inboxes in the account and reports complete or partial failures. The New template action is removed for now; provider-side template management remains unchanged. ### Related Related: https://github.com/chatwoot/chatwoot/pull/15312 ### How to test 1. Open **Settings → Templates** in an account with WhatsApp templates. 2. Confirm the latest sync attempt appears below the page description and is no longer repeated on every template. 3. Confirm an unsubmitted template displays **Not submitted for WhatsApp approval**. 4. Filter templates by content type and verify the list updates. 5. Click **Sync templates** and confirm sync starts for the account's active WhatsApp inboxes. 6. Confirm the **New template** button is not displayed. Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
This commit is contained in:
@@ -4,7 +4,10 @@
|
||||
"DESCRIPTION": "View the message templates synced from your WhatsApp inboxes. To create or edit a template, manage it with your provider.",
|
||||
"COUNT": "{n} template | {n} templates",
|
||||
"KNOW_MORE": "Know more",
|
||||
"NEW_TEMPLATE": "New template",
|
||||
"SYNC_TEMPLATES": "Sync templates",
|
||||
"SYNC_SUCCESS": "Template sync started for all WhatsApp inboxes. Updates may take a couple of minutes.",
|
||||
"PARTIAL_SYNC_ERROR": "Template sync started for some WhatsApp inboxes, but others could not be synced.",
|
||||
"SYNC_ERROR": "Unable to start template sync. Please try again.",
|
||||
"SEARCH_PLACEHOLDER": "Search by content or name...",
|
||||
"MANAGE_IN_META": "Manage in Meta",
|
||||
"MANAGE_IN_TWILIO": "Manage in Twilio",
|
||||
@@ -15,9 +18,13 @@
|
||||
"PARTIAL_FETCH_ERROR": "Some WhatsApp templates could not be loaded. Please try again.",
|
||||
"LAST_SYNC_ATTEMPT": "Last sync attempt on {date}",
|
||||
"MORE_INBOXES": "+{n}",
|
||||
"STATUSES": {
|
||||
"UNSUBMITTED": "Not submitted for WhatsApp approval"
|
||||
},
|
||||
"FILTERS": {
|
||||
"ALL_INBOXES": "All inboxes",
|
||||
"ALL_LANGUAGES": "All languages"
|
||||
"ALL_LANGUAGES": "All languages",
|
||||
"ALL_TYPES": "All types"
|
||||
},
|
||||
"TYPES": {
|
||||
"TEXT": "Text",
|
||||
@@ -40,8 +47,7 @@
|
||||
"TYPE": "Content type",
|
||||
"CATEGORY": "Category",
|
||||
"LANGUAGE": "Language",
|
||||
"INBOXES": "Inboxes",
|
||||
"LAST_SYNC_ATTEMPT": "Last sync attempt"
|
||||
"INBOXES": "Inboxes"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,12 @@ import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
import SettingsLayout from '../SettingsLayout.vue';
|
||||
import TemplateCard from './TemplateCard.vue';
|
||||
import TemplatePreviewDrawer from './TemplatePreviewDrawer.vue';
|
||||
import { formatTemplateLanguage, groupTemplates } from './templateUtils';
|
||||
import {
|
||||
formatTemplateDate,
|
||||
formatTemplateLanguage,
|
||||
groupTemplates,
|
||||
templateTypeKey,
|
||||
} from './templateUtils';
|
||||
|
||||
const FUZZY_SEARCH_KEYS = [
|
||||
{ name: 'name', weight: 4 },
|
||||
@@ -27,10 +32,6 @@ const FUZZY_SEARCH_KEYS = [
|
||||
'searchableContent',
|
||||
];
|
||||
|
||||
const META_TEMPLATE_MANAGER_URL =
|
||||
'https://business.facebook.com/latest/whatsapp_manager/message_templates';
|
||||
const TWILIO_TEMPLATE_MANAGER_URL =
|
||||
'https://console.twilio.com/us1/develop/sms/content-editor';
|
||||
const TEMPLATE_LEARN_MORE_URL =
|
||||
'https://www.chatwoot.com/hc/user-guide/articles/1754940076-whatsapp-templates';
|
||||
|
||||
@@ -42,10 +43,13 @@ const templates = ref([]);
|
||||
const searchQuery = ref('');
|
||||
const selectedInboxId = ref('all');
|
||||
const selectedLanguage = ref('all');
|
||||
const selectedType = ref('all');
|
||||
const selectedTemplate = ref(null);
|
||||
const openFilterMenu = ref(null);
|
||||
const previewPanelRef = ref(null);
|
||||
const templateRecordsByInboxId = new Map();
|
||||
const lastSyncAttemptsByInboxId = ref({});
|
||||
const isSyncing = ref(false);
|
||||
const {
|
||||
run: runTemplateRequest,
|
||||
abort: abortTemplateRequest,
|
||||
@@ -54,6 +58,27 @@ const {
|
||||
|
||||
const hasTemplates = computed(() => templates.value.length > 0);
|
||||
|
||||
const lastSyncAttemptAt = computed(() => {
|
||||
const timestamps = Object.values(lastSyncAttemptsByInboxId.value)
|
||||
.filter(Boolean)
|
||||
.map(value => new Date(value).getTime())
|
||||
.filter(Number.isFinite);
|
||||
|
||||
return timestamps.length ? new Date(Math.max(...timestamps)) : null;
|
||||
});
|
||||
|
||||
const typeLabels = computed(() => ({
|
||||
TEXT: t('WHATSAPP_TEMPLATE_MGMT.TYPES.TEXT'),
|
||||
IMAGE: t('WHATSAPP_TEMPLATE_MGMT.TYPES.IMAGE'),
|
||||
VIDEO: t('WHATSAPP_TEMPLATE_MGMT.TYPES.VIDEO'),
|
||||
DOCUMENT: t('WHATSAPP_TEMPLATE_MGMT.TYPES.DOCUMENT'),
|
||||
MEDIA: t('WHATSAPP_TEMPLATE_MGMT.TYPES.MEDIA'),
|
||||
QUICK_REPLY: t('WHATSAPP_TEMPLATE_MGMT.TYPES.QUICK_REPLY'),
|
||||
CALL_TO_ACTION: t('WHATSAPP_TEMPLATE_MGMT.TYPES.CALL_TO_ACTION'),
|
||||
CATALOG: t('WHATSAPP_TEMPLATE_MGMT.TYPES.CATALOG'),
|
||||
COPY_CODE: t('WHATSAPP_TEMPLATE_MGMT.TYPES.COPY_CODE'),
|
||||
}));
|
||||
|
||||
const whatsappInboxes = computed(() =>
|
||||
inboxes.value.filter(
|
||||
inbox =>
|
||||
@@ -63,54 +88,6 @@ const whatsappInboxes = computed(() =>
|
||||
)
|
||||
);
|
||||
|
||||
const canManageInMeta = computed(() =>
|
||||
whatsappInboxes.value.some(inbox => inbox.provider === 'whatsapp_cloud')
|
||||
);
|
||||
|
||||
const hasTwilioInboxes = computed(() =>
|
||||
whatsappInboxes.value.some(inbox => inbox.channel_type === INBOX_TYPES.TWILIO)
|
||||
);
|
||||
|
||||
const hasUnsupportedWhatsappInboxes = computed(() =>
|
||||
whatsappInboxes.value.some(
|
||||
inbox =>
|
||||
inbox.channel_type === INBOX_TYPES.WHATSAPP &&
|
||||
inbox.provider !== 'whatsapp_cloud'
|
||||
)
|
||||
);
|
||||
|
||||
const selectedInbox = computed(() =>
|
||||
whatsappInboxes.value.find(
|
||||
inbox => String(inbox.id) === selectedInboxId.value
|
||||
)
|
||||
);
|
||||
|
||||
const newTemplateUrl = computed(() => {
|
||||
if (selectedInbox.value) {
|
||||
if (selectedInbox.value.channel_type === INBOX_TYPES.TWILIO) {
|
||||
return TWILIO_TEMPLATE_MANAGER_URL;
|
||||
}
|
||||
|
||||
return selectedInbox.value.provider === 'whatsapp_cloud'
|
||||
? META_TEMPLATE_MANAGER_URL
|
||||
: null;
|
||||
}
|
||||
|
||||
if (
|
||||
canManageInMeta.value &&
|
||||
!hasTwilioInboxes.value &&
|
||||
!hasUnsupportedWhatsappInboxes.value
|
||||
) {
|
||||
return META_TEMPLATE_MANAGER_URL;
|
||||
}
|
||||
|
||||
return hasTwilioInboxes.value &&
|
||||
!canManageInMeta.value &&
|
||||
!hasUnsupportedWhatsappInboxes.value
|
||||
? TWILIO_TEMPLATE_MANAGER_URL
|
||||
: null;
|
||||
});
|
||||
|
||||
const inboxOptions = computed(() => [
|
||||
{
|
||||
value: 'all',
|
||||
@@ -136,6 +113,19 @@ const languageOptions = computed(() => [
|
||||
})),
|
||||
]);
|
||||
|
||||
const typeOptions = computed(() => [
|
||||
{
|
||||
value: 'all',
|
||||
label: t('WHATSAPP_TEMPLATE_MGMT.FILTERS.ALL_TYPES'),
|
||||
},
|
||||
...[...new Set(templates.value.map(templateTypeKey))]
|
||||
.map(type => ({
|
||||
value: type,
|
||||
label: typeLabels.value[type],
|
||||
}))
|
||||
.sort((first, second) => first.label.localeCompare(second.label)),
|
||||
]);
|
||||
|
||||
const filterMenus = computed(() =>
|
||||
[
|
||||
{
|
||||
@@ -150,6 +140,12 @@ const filterMenus = computed(() =>
|
||||
options: languageOptions.value,
|
||||
active: selectedLanguage.value,
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
icon: 'i-lucide-layout-template',
|
||||
options: typeOptions.value,
|
||||
active: selectedType.value,
|
||||
},
|
||||
].map(menu => {
|
||||
const items = menu.options.map(option => ({
|
||||
...option,
|
||||
@@ -181,7 +177,8 @@ const openPreview = template => {
|
||||
const handleFilterAction = ({ action, value }) => {
|
||||
closeFilterMenu();
|
||||
if (action === 'inbox') selectedInboxId.value = value;
|
||||
else selectedLanguage.value = value;
|
||||
else if (action === 'language') selectedLanguage.value = value;
|
||||
else selectedType.value = value;
|
||||
};
|
||||
|
||||
const filteredTemplates = computed(() => {
|
||||
@@ -199,6 +196,12 @@ const filteredTemplates = computed(() => {
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedType.value !== 'all') {
|
||||
records = records.filter(
|
||||
template => templateTypeKey(template) === selectedType.value
|
||||
);
|
||||
}
|
||||
|
||||
const query = searchQuery.value.trim();
|
||||
if (!query) return records;
|
||||
|
||||
@@ -239,6 +242,7 @@ const fetchTemplates = async () => {
|
||||
|
||||
return {
|
||||
inboxId: inbox.id,
|
||||
lastSyncAttemptAt: data.meta?.last_sync_attempt_at,
|
||||
records: data.payload.map(template => ({
|
||||
template,
|
||||
inbox,
|
||||
@@ -254,14 +258,21 @@ const fetchTemplates = async () => {
|
||||
response => response.status === 'fulfilled'
|
||||
);
|
||||
const activeInboxIds = new Set(inboxesToFetch.map(inbox => inbox.id));
|
||||
const nextLastSyncAttempts = {
|
||||
...lastSyncAttemptsByInboxId.value,
|
||||
};
|
||||
|
||||
templateRecordsByInboxId.forEach((_, inboxId) => {
|
||||
if (!activeInboxIds.has(inboxId))
|
||||
if (!activeInboxIds.has(inboxId)) {
|
||||
templateRecordsByInboxId.delete(inboxId);
|
||||
delete nextLastSyncAttempts[inboxId];
|
||||
}
|
||||
});
|
||||
successfulResponses.forEach(({ value }) => {
|
||||
templateRecordsByInboxId.set(value.inboxId, value.records);
|
||||
nextLastSyncAttempts[value.inboxId] = value.lastSyncAttemptAt;
|
||||
});
|
||||
lastSyncAttemptsByInboxId.value = nextLastSyncAttempts;
|
||||
templates.value = groupTemplates(
|
||||
[...templateRecordsByInboxId.values()].flat()
|
||||
);
|
||||
@@ -276,6 +287,8 @@ const fetchTemplates = async () => {
|
||||
)
|
||||
)
|
||||
selectedLanguage.value = 'all';
|
||||
if (!typeOptions.value.some(({ value }) => value === selectedType.value))
|
||||
selectedType.value = 'all';
|
||||
|
||||
if (responses.some(response => response.status === 'rejected')) {
|
||||
const errorMessage = successfulResponses.length
|
||||
@@ -289,6 +302,31 @@ const fetchTemplates = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const syncTemplates = async () => {
|
||||
if (isSyncing.value) return;
|
||||
|
||||
isSyncing.value = true;
|
||||
|
||||
const responses = await Promise.allSettled(
|
||||
whatsappInboxes.value.map(inbox =>
|
||||
store.dispatch('inboxes/syncTemplates', inbox.id)
|
||||
)
|
||||
);
|
||||
const failedCount = responses.filter(
|
||||
response => response.status === 'rejected'
|
||||
).length;
|
||||
|
||||
if (!failedCount) {
|
||||
useAlert(t('WHATSAPP_TEMPLATE_MGMT.SYNC_SUCCESS'));
|
||||
} else if (failedCount < responses.length) {
|
||||
useAlert(t('WHATSAPP_TEMPLATE_MGMT.PARTIAL_SYNC_ERROR'));
|
||||
} else {
|
||||
useAlert(t('WHATSAPP_TEMPLATE_MGMT.SYNC_ERROR'));
|
||||
}
|
||||
|
||||
isSyncing.value = false;
|
||||
};
|
||||
|
||||
onActivated(fetchTemplates);
|
||||
onDeactivated(abortTemplateRequest);
|
||||
</script>
|
||||
@@ -318,6 +356,16 @@ onDeactivated(abortTemplateRequest);
|
||||
>
|
||||
{{ $t('WHATSAPP_TEMPLATE_MGMT.KNOW_MORE') }}
|
||||
</a>
|
||||
<span
|
||||
v-if="lastSyncAttemptAt"
|
||||
class="block mt-1 text-xs text-n-slate-10"
|
||||
>
|
||||
{{
|
||||
$t('WHATSAPP_TEMPLATE_MGMT.LAST_SYNC_ATTEMPT', {
|
||||
date: formatTemplateDate(lastSyncAttemptAt),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
<template #tabs>
|
||||
<div
|
||||
@@ -354,14 +402,16 @@ onDeactivated(abortTemplateRequest);
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="newTemplateUrl" #actions>
|
||||
<a :href="newTemplateUrl" target="_blank" rel="noopener noreferrer">
|
||||
<Button
|
||||
:label="$t('WHATSAPP_TEMPLATE_MGMT.NEW_TEMPLATE')"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
/>
|
||||
</a>
|
||||
<template #actions>
|
||||
<Button
|
||||
:label="$t('WHATSAPP_TEMPLATE_MGMT.SYNC_TEMPLATES')"
|
||||
icon="i-lucide-refresh-cw"
|
||||
color="slate"
|
||||
size="sm"
|
||||
:is-loading="isSyncing"
|
||||
:disabled="!whatsappInboxes.length || isSyncing"
|
||||
@click="syncTemplates"
|
||||
/>
|
||||
</template>
|
||||
</BaseSettingsHeader>
|
||||
</template>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
|
||||
import {
|
||||
formatTemplateDate,
|
||||
formatTemplateLabel,
|
||||
formatTemplateLanguage,
|
||||
templateStatusClasses,
|
||||
@@ -19,10 +19,16 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const emit = defineEmits(['preview']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const showStatus = computed(
|
||||
() => props.template.status?.toLowerCase() !== 'approved'
|
||||
);
|
||||
const statusLabel = computed(() =>
|
||||
props.template.status?.toLowerCase() === 'unsubmitted'
|
||||
? t('WHATSAPP_TEMPLATE_MGMT.STATUSES.UNSUBMITTED')
|
||||
: formatTemplateLabel(props.template.status)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -53,7 +59,7 @@ const showStatus = computed(
|
||||
class="inline-flex shrink-0 px-2 py-0.5 text-xs font-medium rounded-md"
|
||||
:class="templateStatusClasses(template.status)"
|
||||
>
|
||||
{{ formatTemplateLabel(template.status) }}
|
||||
{{ statusLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
@@ -68,14 +74,6 @@ const showStatus = computed(
|
||||
<span>{{ formatTemplateLanguage(template.language) }}</span>
|
||||
<div class="w-px h-3 rounded-lg bg-n-strong" />
|
||||
<span class="truncate">{{ template.inboxNames }}</span>
|
||||
<div class="w-px h-3 rounded-lg bg-n-strong" />
|
||||
<span class="whitespace-nowrap">
|
||||
{{
|
||||
$t('WHATSAPP_TEMPLATE_MGMT.LAST_SYNC_ATTEMPT', {
|
||||
date: formatTemplateDate(template.lastUpdatedAt),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
} from 'dashboard/components-next/template-preview';
|
||||
import { PLATFORMS } from 'dashboard/services/TemplateConstants';
|
||||
import {
|
||||
formatTemplateDate,
|
||||
formatTemplateLabel,
|
||||
formatTemplateLanguage,
|
||||
templateStatusClasses,
|
||||
@@ -55,6 +54,11 @@ const managementLabel = computed(() =>
|
||||
? t('WHATSAPP_TEMPLATE_MGMT.MANAGE_IN_TWILIO')
|
||||
: t('WHATSAPP_TEMPLATE_MGMT.MANAGE_IN_META')
|
||||
);
|
||||
const statusLabel = computed(() =>
|
||||
props.template?.status?.toLowerCase() === 'unsubmitted'
|
||||
? t('WHATSAPP_TEMPLATE_MGMT.STATUSES.UNSUBMITTED')
|
||||
: formatTemplateLabel(props.template?.status)
|
||||
);
|
||||
const open = () => panelRef.value?.open();
|
||||
const close = () => panelRef.value?.close();
|
||||
|
||||
@@ -92,7 +96,7 @@ defineExpose({ open, close });
|
||||
class="inline-flex px-2 py-0.5 text-xs font-medium rounded-md"
|
||||
:class="templateStatusClasses(template.status)"
|
||||
>
|
||||
{{ formatTemplateLabel(template.status) }}
|
||||
{{ statusLabel }}
|
||||
</span>
|
||||
</dd>
|
||||
<dt class="text-n-slate-10">
|
||||
@@ -119,12 +123,6 @@ defineExpose({ open, close });
|
||||
{{ $t('WHATSAPP_TEMPLATE_MGMT.PREVIEW.INBOXES') }}
|
||||
</dt>
|
||||
<dd class="text-n-slate-12">{{ template.inboxNames }}</dd>
|
||||
<dt class="text-n-slate-10">
|
||||
{{ $t('WHATSAPP_TEMPLATE_MGMT.PREVIEW.LAST_SYNC_ATTEMPT') }}
|
||||
</dt>
|
||||
<dd class="text-n-slate-12">
|
||||
{{ formatTemplateDate(template.lastUpdatedAt) }}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user