From 9d769dfcdd86f0af6fcafe6bc4f90aebdf39da14 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Wed, 29 Jul 2026 20:34:33 +0400 Subject: [PATCH] fix(whatsapp): collect text header parameters (#15199) WhatsApp templates with variables in both a text header and body currently show inputs only for the body. Agents therefore cannot provide the header value in the template composer, even though the API and backend support the corresponding `processed_params.header` payload. The composer now displays text-header variables separately, previews their substituted values, and sends them alongside body parameters. Templates using media headers remain unchanged. Related: https://github.com/chatwoot/utils/pull/65 ### Things to know This PR consumes the released `@chatwoot/utils@0.0.57`, which adds text-header parameter construction and completeness validation. ### How to reproduce 1. Open a WhatsApp template containing text header `Welcome {{1}}` and body variables `{{1}}` and `{{2}}`. 2. Observe that the current composer displays only two body inputs and omits the header input. ### How to test 1. Open the same template in the conversation composer. 2. Confirm one header input and two body inputs are displayed. 3. Fill the values and confirm both the header and body previews update. 4. Send the template and confirm `processed_params` contains `header.1`, `body.1`, and `body.2`. --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> --- .../WhatsAppCampaign/WhatsAppCampaignForm.vue | 4 +- .../whatsapp/WhatsAppTemplateParser.vue | 61 ++++++++++- .../dashboard/helper/templateHelper.js | 1 + .../i18n/locale/en/whatsappTemplates.json | 1 + .../whatsapp/template_processor_service.rb | 49 ++++----- package.json | 2 +- pnpm-lock.yaml | 10 +- .../template_processor_service_spec.rb | 102 ++++++++++++++++++ 8 files changed, 195 insertions(+), 35 deletions(-) create mode 100644 spec/services/whatsapp/template_processor_service_spec.rb diff --git a/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignForm.vue b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignForm.vue index 2bb1005b3..b95fda8a4 100644 --- a/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignForm.vue +++ b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignForm.vue @@ -105,7 +105,7 @@ const formErrors = computed(() => ({ })); const hasRequiredTemplateParams = computed(() => { - return templateParserRef.value?.v$?.$invalid === false || true; + return templateParserRef.value?.isFormInvalid === false; }); const isSubmitDisabled = computed( @@ -154,7 +154,7 @@ const prepareCampaignDetails = () => { const handleSubmit = async () => { const isFormValid = await v$.value.$validate(); - if (!isFormValid) return; + if (!isFormValid || !hasRequiredTemplateParams.value) return; emit('submit', prepareCampaignDetails()); resetState(); diff --git a/app/javascript/dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue b/app/javascript/dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue index 620955cb4..12968dd2c 100644 --- a/app/javascript/dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue +++ b/app/javascript/dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue @@ -18,12 +18,12 @@ import Input from 'dashboard/components-next/input/Input.vue'; import { buildTemplateParameters, allKeysRequired, - replaceTemplateVariables, DEFAULT_LANGUAGE, DEFAULT_CATEGORY, COMPONENT_TYPES, MEDIA_FORMATS, findComponentByType, + renderTemplatePreview, } from 'dashboard/helper/templateHelper'; const props = defineProps({ @@ -64,6 +64,12 @@ const bodyText = computed(() => { return bodyComponent.value?.text || ''; }); +const headerText = computed(() => { + return headerComponent.value?.format === 'TEXT' + ? headerComponent.value?.text || '' + : ''; +}); + const hasMediaHeader = computed(() => MEDIA_FORMATS.includes(headerComponent.value?.format) ); @@ -77,12 +83,30 @@ const isDocumentTemplate = computed(() => { return headerComponent.value?.format?.toLowerCase() === 'document'; }); -const hasVariables = computed(() => { +const hasBodyVariables = computed(() => { return bodyText.value?.match(/{{([^}]+)}}/g) !== null; }); +const hasTextHeaderVariables = computed(() => { + return headerText.value?.match(/{{([^}]+)}}/g) !== null; +}); + +const hasVariables = computed( + () => hasBodyVariables.value || hasTextHeaderVariables.value +); + +const renderedHeader = computed(() => { + return renderTemplatePreview( + headerText.value, + processedParams.value.header || {} + ); +}); + const renderedTemplate = computed(() => { - return replaceTemplateVariables(bodyText.value, processedParams.value); + return renderTemplatePreview( + bodyText.value, + processedParams.value.body || {} + ); }); // Completeness validation is shared with the mobile app via @chatwoot/utils. @@ -161,7 +185,9 @@ defineExpose({ hasMediaHeader, isDocumentTemplate, headerComponent, + renderedHeader, renderedTemplate, + isFormInvalid, v$, updateMediaUrl, updateMediaName, @@ -185,6 +211,12 @@ defineExpose({
+
+ {{ renderedHeader }} +
{{ renderedTemplate }}
@@ -231,6 +263,29 @@ defineExpose({
+ +
+

+ {{ $t('WHATSAPP_TEMPLATES.PARSER.HEADER_VARIABLES_LABEL') }} +

+
+ +
+
+

diff --git a/app/javascript/dashboard/helper/templateHelper.js b/app/javascript/dashboard/helper/templateHelper.js index c875871f4..88fe7948d 100644 --- a/app/javascript/dashboard/helper/templateHelper.js +++ b/app/javascript/dashboard/helper/templateHelper.js @@ -7,6 +7,7 @@ export { COMPONENT_TYPES, findComponentByType, processVariable, + renderTemplatePreview, } from '@chatwoot/utils'; export const DEFAULT_LANGUAGE = 'en'; diff --git a/app/javascript/dashboard/i18n/locale/en/whatsappTemplates.json b/app/javascript/dashboard/i18n/locale/en/whatsappTemplates.json index cf28312dc..294004db5 100644 --- a/app/javascript/dashboard/i18n/locale/en/whatsappTemplates.json +++ b/app/javascript/dashboard/i18n/locale/en/whatsappTemplates.json @@ -27,6 +27,7 @@ }, "PARSER": { "VARIABLES_LABEL": "Variables", + "HEADER_VARIABLES_LABEL": "Header variables", "LANGUAGE": "Language", "CATEGORY": "Category", "VARIABLE_PLACEHOLDER": "Enter {variable} value", diff --git a/app/services/whatsapp/template_processor_service.rb b/app/services/whatsapp/template_processor_service.rb index 0aaca2fb1..8f6ab763e 100644 --- a/app/services/whatsapp/template_processor_service.rb +++ b/app/services/whatsapp/template_processor_service.rb @@ -41,7 +41,7 @@ class Whatsapp::TemplateProcessorService processed_params ||= template_params['processed_params'] components = [] - components.concat(process_header_components(processed_params)) + components.concat(process_header_components(processed_params, template)) components.concat(process_body_components(processed_params, template)) components.concat(process_footer_components(processed_params)) components.concat(process_button_components(processed_params)) @@ -49,31 +49,31 @@ class Whatsapp::TemplateProcessorService @template_params = components end - def process_header_components(processed_params) + def process_header_components(processed_params, template) return [] if processed_params['header'].blank? - header_params = build_header_params(processed_params['header']) + header_params = build_header_params(processed_params['header'], template) header_params.present? ? [{ type: 'header', parameters: header_params }] : [] end - def build_header_params(header_data) - header_params = [] - header_data.each do |key, value| - next if value.blank? + def build_header_params(header_data, template) + header_component = template['components']&.find { |component| component['type'] == 'HEADER' } + return build_text_header_params(header_data, template) if header_component&.dig('format') == 'TEXT' - if media_url_with_type?(key, header_data) - media_name = header_data['media_name'] - media_param = parameter_builder.build_media_parameter(value, header_data['media_type'], media_name) - header_params << media_param if media_param - elsif key != 'media_type' && key != 'media_name' - header_params << parameter_builder.build_parameter(value) - end - end - header_params + build_media_header_params(header_data) end - def media_url_with_type?(key, header_data) - key == 'media_url' && header_data['media_type'].present? + def build_text_header_params(header_data, template) + header_data.filter_map do |key, value| + build_text_parameter(key, value, template) if value.present? + end + end + + def build_media_header_params(header_data) + return [] if header_data['media_url'].blank? || header_data['media_type'].blank? + + media_param = parameter_builder.build_media_parameter(header_data['media_url'], header_data['media_type'], header_data['media_name']) + media_param ? [media_param] : [] end def process_body_components(processed_params, template) @@ -82,17 +82,18 @@ class Whatsapp::TemplateProcessorService body_params = processed_params['body'].filter_map do |key, value| next if value.blank? - parameter_format = template['parameter_format'] - if parameter_format == 'NAMED' - parameter_builder.build_named_parameter(key, value) - else - parameter_builder.build_parameter(value) - end + build_text_parameter(key, value, template) end body_params.present? ? [{ type: 'body', parameters: body_params }] : [] end + def build_text_parameter(key, value, template) + return parameter_builder.build_named_parameter(key, value) if template['parameter_format'] == 'NAMED' + + parameter_builder.build_parameter(value) + end + def process_footer_components(processed_params) return [] if processed_params['footer'].blank? diff --git a/package.json b/package.json index 54f1b0350..ddcd1dd6f 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "@breezystack/lamejs": "^1.2.7", "@chatwoot/ninja-keys": "1.2.3", "@chatwoot/prosemirror-schema": "1.4.1", - "@chatwoot/utils": "^0.0.56", + "@chatwoot/utils": "^0.0.57", "@formkit/core": "^1.7.2", "@formkit/vue": "^1.7.2", "@hcaptcha/vue3-hcaptcha": "^1.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 443737da9..3718d657b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,8 +28,8 @@ importers: specifier: 1.4.1 version: 1.4.1 '@chatwoot/utils': - specifier: ^0.0.56 - version: 0.0.56 + specifier: ^0.0.57 + version: 0.0.57 '@formkit/core': specifier: ^1.7.2 version: 1.7.2 @@ -455,8 +455,8 @@ packages: '@chatwoot/prosemirror-schema@1.4.1': resolution: {integrity: sha512-skBLslveAW4yYQP0RkJFOJ9lXZbPWE95pLHA+AolsPKw/a1iS/g0SMXHx2gbyHoacrVX6v44+PHMKQYP6L3u0Q==} - '@chatwoot/utils@0.0.56': - resolution: {integrity: sha512-A6dmPLfTSrW4qYNY73btyi4PqpfzcXRSaucscZTQdzNqF6G/QUdgnBmHtho8HeiYby/kSHXaSxLJj+0dx3yEQQ==} + '@chatwoot/utils@0.0.57': + resolution: {integrity: sha512-L4QrCmimkULY9ZQk0MJRLfwry3Pqq2e7psETqoZGYopMZ+8DSWe4t6cModSjTCBnDjqgp/gTl0oorntCYrDE8w==} engines: {node: '>=10'} '@codemirror/commands@6.7.0': @@ -5151,7 +5151,7 @@ snapshots: prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3) prosemirror-view: 1.34.1 - '@chatwoot/utils@0.0.56': + '@chatwoot/utils@0.0.57': dependencies: date-fns: 2.30.0 diff --git a/spec/services/whatsapp/template_processor_service_spec.rb b/spec/services/whatsapp/template_processor_service_spec.rb new file mode 100644 index 000000000..454d323e8 --- /dev/null +++ b/spec/services/whatsapp/template_processor_service_spec.rb @@ -0,0 +1,102 @@ +require 'rails_helper' + +describe Whatsapp::TemplateProcessorService do + subject(:processed_components) do + described_class.new(channel: channel, template_params: template_params).call.last + end + + let(:channel) { instance_double(Channel::Whatsapp, message_templates: [template]) } + let(:template_params) do + { + 'name' => template['name'], + 'language' => template['language'], + 'processed_params' => { 'header' => header_params } + } + end + + context 'with a positional text header' do + let(:template) do + { + 'name' => 'positional_header', + 'language' => 'en_US', + 'status' => 'APPROVED', + 'parameter_format' => 'POSITIONAL', + 'components' => [{ 'type' => 'HEADER', 'format' => 'TEXT', 'text' => 'Welcome {{1}}' }] + } + end + let(:header_params) { { '1' => 'Jane' } } + + it 'builds a positional text parameter' do + expect(processed_components).to eq([ + { + type: 'header', + parameters: [{ type: 'text', text: 'Jane' }] + } + ]) + end + end + + context 'with a named text header' do + let(:template) do + { + 'name' => 'named_header', + 'language' => 'en_US', + 'status' => 'APPROVED', + 'parameter_format' => 'NAMED', + 'components' => [{ 'type' => 'HEADER', 'format' => 'TEXT', 'text' => "Welcome {{#{parameter_name}}}" }] + } + end + let(:header_params) { { parameter_name => 'Jane' } } + + %w[customer_name media_type media_name].each do |name| + context "when the parameter is #{name}" do + let(:parameter_name) { name } + + it 'preserves the parameter name' do + expect(processed_components).to eq([ + { + type: 'header', + parameters: [{ type: 'text', parameter_name: parameter_name, text: 'Jane' }] + } + ]) + end + end + end + end + + context 'with a media header' do + let(:template) do + { + 'name' => 'document_header', + 'language' => 'en_US', + 'status' => 'APPROVED', + 'parameter_format' => 'POSITIONAL', + 'components' => [{ 'type' => 'HEADER', 'format' => 'DOCUMENT' }] + } + end + let(:header_params) do + { + 'media_url' => 'https://example.com/report.pdf', + 'media_type' => 'document', + 'media_name' => 'report.pdf' + } + end + + it 'uses media metadata to build the attachment parameter' do + expect(processed_components).to eq([ + { + type: 'header', + parameters: [ + { + type: 'document', + document: { + link: 'https://example.com/report.pdf', + filename: 'report.pdf' + } + } + ] + } + ]) + end + end +end