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>
This commit is contained in:
Muhsin Keloth
2026-07-29 20:34:33 +04:00
committed by GitHub
parent 502c45f73b
commit 9d769dfcdd
8 changed files with 195 additions and 35 deletions

View File

@@ -105,7 +105,7 @@ const formErrors = computed(() => ({
})); }));
const hasRequiredTemplateParams = computed(() => { const hasRequiredTemplateParams = computed(() => {
return templateParserRef.value?.v$?.$invalid === false || true; return templateParserRef.value?.isFormInvalid === false;
}); });
const isSubmitDisabled = computed( const isSubmitDisabled = computed(
@@ -154,7 +154,7 @@ const prepareCampaignDetails = () => {
const handleSubmit = async () => { const handleSubmit = async () => {
const isFormValid = await v$.value.$validate(); const isFormValid = await v$.value.$validate();
if (!isFormValid) return; if (!isFormValid || !hasRequiredTemplateParams.value) return;
emit('submit', prepareCampaignDetails()); emit('submit', prepareCampaignDetails());
resetState(); resetState();

View File

@@ -18,12 +18,12 @@ import Input from 'dashboard/components-next/input/Input.vue';
import { import {
buildTemplateParameters, buildTemplateParameters,
allKeysRequired, allKeysRequired,
replaceTemplateVariables,
DEFAULT_LANGUAGE, DEFAULT_LANGUAGE,
DEFAULT_CATEGORY, DEFAULT_CATEGORY,
COMPONENT_TYPES, COMPONENT_TYPES,
MEDIA_FORMATS, MEDIA_FORMATS,
findComponentByType, findComponentByType,
renderTemplatePreview,
} from 'dashboard/helper/templateHelper'; } from 'dashboard/helper/templateHelper';
const props = defineProps({ const props = defineProps({
@@ -64,6 +64,12 @@ const bodyText = computed(() => {
return bodyComponent.value?.text || ''; return bodyComponent.value?.text || '';
}); });
const headerText = computed(() => {
return headerComponent.value?.format === 'TEXT'
? headerComponent.value?.text || ''
: '';
});
const hasMediaHeader = computed(() => const hasMediaHeader = computed(() =>
MEDIA_FORMATS.includes(headerComponent.value?.format) MEDIA_FORMATS.includes(headerComponent.value?.format)
); );
@@ -77,12 +83,30 @@ const isDocumentTemplate = computed(() => {
return headerComponent.value?.format?.toLowerCase() === 'document'; return headerComponent.value?.format?.toLowerCase() === 'document';
}); });
const hasVariables = computed(() => { const hasBodyVariables = computed(() => {
return bodyText.value?.match(/{{([^}]+)}}/g) !== null; 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(() => { 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. // Completeness validation is shared with the mobile app via @chatwoot/utils.
@@ -161,7 +185,9 @@ defineExpose({
hasMediaHeader, hasMediaHeader,
isDocumentTemplate, isDocumentTemplate,
headerComponent, headerComponent,
renderedHeader,
renderedTemplate, renderedTemplate,
isFormInvalid,
v$, v$,
updateMediaUrl, updateMediaUrl,
updateMediaName, updateMediaName,
@@ -185,6 +211,12 @@ defineExpose({
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="rounded-md"> <div class="rounded-md">
<div
v-if="renderedHeader"
class="mb-2 text-sm font-medium whitespace-pre-wrap text-n-slate-12"
>
{{ renderedHeader }}
</div>
<div class="text-sm whitespace-pre-wrap text-n-slate-12"> <div class="text-sm whitespace-pre-wrap text-n-slate-12">
{{ renderedTemplate }} {{ renderedTemplate }}
</div> </div>
@@ -231,6 +263,29 @@ defineExpose({
</div> </div>
</div> </div>
<!-- Text Header Variables Section -->
<div v-if="hasTextHeaderVariables && processedParams.header">
<p class="mb-2.5 text-sm font-semibold">
{{ $t('WHATSAPP_TEMPLATES.PARSER.HEADER_VARIABLES_LABEL') }}
</p>
<div
v-for="(variable, key) in processedParams.header"
:key="`header-${key}`"
class="flex items-center mb-2.5"
>
<Input
v-model="processedParams.header[key]"
type="text"
class="flex-1"
:placeholder="
t('WHATSAPP_TEMPLATES.PARSER.VARIABLE_PLACEHOLDER', {
variable: key,
})
"
/>
</div>
</div>
<!-- Body Variables Section --> <!-- Body Variables Section -->
<div v-if="processedParams.body"> <div v-if="processedParams.body">
<p class="mb-2.5 text-sm font-semibold"> <p class="mb-2.5 text-sm font-semibold">

View File

@@ -7,6 +7,7 @@ export {
COMPONENT_TYPES, COMPONENT_TYPES,
findComponentByType, findComponentByType,
processVariable, processVariable,
renderTemplatePreview,
} from '@chatwoot/utils'; } from '@chatwoot/utils';
export const DEFAULT_LANGUAGE = 'en'; export const DEFAULT_LANGUAGE = 'en';

View File

@@ -27,6 +27,7 @@
}, },
"PARSER": { "PARSER": {
"VARIABLES_LABEL": "Variables", "VARIABLES_LABEL": "Variables",
"HEADER_VARIABLES_LABEL": "Header variables",
"LANGUAGE": "Language", "LANGUAGE": "Language",
"CATEGORY": "Category", "CATEGORY": "Category",
"VARIABLE_PLACEHOLDER": "Enter {variable} value", "VARIABLE_PLACEHOLDER": "Enter {variable} value",

View File

@@ -41,7 +41,7 @@ class Whatsapp::TemplateProcessorService
processed_params ||= template_params['processed_params'] processed_params ||= template_params['processed_params']
components = [] 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_body_components(processed_params, template))
components.concat(process_footer_components(processed_params)) components.concat(process_footer_components(processed_params))
components.concat(process_button_components(processed_params)) components.concat(process_button_components(processed_params))
@@ -49,31 +49,31 @@ class Whatsapp::TemplateProcessorService
@template_params = components @template_params = components
end end
def process_header_components(processed_params) def process_header_components(processed_params, template)
return [] if processed_params['header'].blank? 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 }] : [] header_params.present? ? [{ type: 'header', parameters: header_params }] : []
end end
def build_header_params(header_data) def build_header_params(header_data, template)
header_params = [] header_component = template['components']&.find { |component| component['type'] == 'HEADER' }
header_data.each do |key, value| return build_text_header_params(header_data, template) if header_component&.dig('format') == 'TEXT'
next if value.blank?
if media_url_with_type?(key, header_data) build_media_header_params(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
end end
def media_url_with_type?(key, header_data) def build_text_header_params(header_data, template)
key == 'media_url' && header_data['media_type'].present? 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 end
def process_body_components(processed_params, template) def process_body_components(processed_params, template)
@@ -82,17 +82,18 @@ class Whatsapp::TemplateProcessorService
body_params = processed_params['body'].filter_map do |key, value| body_params = processed_params['body'].filter_map do |key, value|
next if value.blank? next if value.blank?
parameter_format = template['parameter_format'] build_text_parameter(key, value, template)
if parameter_format == 'NAMED'
parameter_builder.build_named_parameter(key, value)
else
parameter_builder.build_parameter(value)
end
end end
body_params.present? ? [{ type: 'body', parameters: body_params }] : [] body_params.present? ? [{ type: 'body', parameters: body_params }] : []
end 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) def process_footer_components(processed_params)
return [] if processed_params['footer'].blank? return [] if processed_params['footer'].blank?

View File

@@ -35,7 +35,7 @@
"@breezystack/lamejs": "^1.2.7", "@breezystack/lamejs": "^1.2.7",
"@chatwoot/ninja-keys": "1.2.3", "@chatwoot/ninja-keys": "1.2.3",
"@chatwoot/prosemirror-schema": "1.4.1", "@chatwoot/prosemirror-schema": "1.4.1",
"@chatwoot/utils": "^0.0.56", "@chatwoot/utils": "^0.0.57",
"@formkit/core": "^1.7.2", "@formkit/core": "^1.7.2",
"@formkit/vue": "^1.7.2", "@formkit/vue": "^1.7.2",
"@hcaptcha/vue3-hcaptcha": "^1.3.0", "@hcaptcha/vue3-hcaptcha": "^1.3.0",

10
pnpm-lock.yaml generated
View File

@@ -28,8 +28,8 @@ importers:
specifier: 1.4.1 specifier: 1.4.1
version: 1.4.1 version: 1.4.1
'@chatwoot/utils': '@chatwoot/utils':
specifier: ^0.0.56 specifier: ^0.0.57
version: 0.0.56 version: 0.0.57
'@formkit/core': '@formkit/core':
specifier: ^1.7.2 specifier: ^1.7.2
version: 1.7.2 version: 1.7.2
@@ -455,8 +455,8 @@ packages:
'@chatwoot/prosemirror-schema@1.4.1': '@chatwoot/prosemirror-schema@1.4.1':
resolution: {integrity: sha512-skBLslveAW4yYQP0RkJFOJ9lXZbPWE95pLHA+AolsPKw/a1iS/g0SMXHx2gbyHoacrVX6v44+PHMKQYP6L3u0Q==} resolution: {integrity: sha512-skBLslveAW4yYQP0RkJFOJ9lXZbPWE95pLHA+AolsPKw/a1iS/g0SMXHx2gbyHoacrVX6v44+PHMKQYP6L3u0Q==}
'@chatwoot/utils@0.0.56': '@chatwoot/utils@0.0.57':
resolution: {integrity: sha512-A6dmPLfTSrW4qYNY73btyi4PqpfzcXRSaucscZTQdzNqF6G/QUdgnBmHtho8HeiYby/kSHXaSxLJj+0dx3yEQQ==} resolution: {integrity: sha512-L4QrCmimkULY9ZQk0MJRLfwry3Pqq2e7psETqoZGYopMZ+8DSWe4t6cModSjTCBnDjqgp/gTl0oorntCYrDE8w==}
engines: {node: '>=10'} engines: {node: '>=10'}
'@codemirror/commands@6.7.0': '@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-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3)
prosemirror-view: 1.34.1 prosemirror-view: 1.34.1
'@chatwoot/utils@0.0.56': '@chatwoot/utils@0.0.57':
dependencies: dependencies:
date-fns: 2.30.0 date-fns: 2.30.0

View File

@@ -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