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:
@@ -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();
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|
||||||
|
|||||||
@@ -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
10
pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|
||||||
|
|||||||
102
spec/services/whatsapp/template_processor_service_spec.rb
Normal file
102
spec/services/whatsapp/template_processor_service_spec.rb
Normal 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
|
||||||
Reference in New Issue
Block a user