From 49d583088d027ae12ef2ee9940f0d29f108ea7ed Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Wed, 6 Mar 2024 00:18:16 +0000 Subject: [PATCH] Add component based whatsapp templates --- .../localization/MsgLocalizationForm.tsx | 100 +++++------ .../MsgLocalizationForm.test.ts.snap | 4 +- .../flow/actions/localization/helpers.ts | 20 +-- .../__snapshots__/SayMsgForm.test.ts.snap | 1 - .../flow/actions/sendmsg/SendMsgForm.tsx | 169 ++++++------------ .../__snapshots__/SendMsgForm.test.ts.snap | 21 +-- .../flow/actions/sendmsg/helpers.ts | 64 ++++--- src/components/flow/props.ts | 2 + src/components/form/assetselector/helpers.ts | 11 -- src/components/form/assetselector/widgets.tsx | 8 - src/components/nodeeditor/NodeEditor.tsx | 5 +- .../translator/TranslatorTab.test.tsx | 12 +- .../__snapshots__/TranslatorTab.test.tsx.snap | 14 -- src/components/translator/helpers.ts | 2 +- .../__snapshots__/typeConfigs.test.ts.snap | 2 - src/config/typeConfigs.ts | 2 +- src/flowTypes.ts | 25 ++- src/services/Localization.ts | 10 +- src/store/helpers.ts | 6 +- src/temba/TembaComponent.jsx | 33 ++++ src/testUtils/assetCreators.ts | 5 +- src/untyped.d.ts | 1 + 22 files changed, 222 insertions(+), 295 deletions(-) create mode 100644 src/temba/TembaComponent.jsx diff --git a/src/components/flow/actions/localization/MsgLocalizationForm.tsx b/src/components/flow/actions/localization/MsgLocalizationForm.tsx index a20cfad1b..e7e166e51 100644 --- a/src/components/flow/actions/localization/MsgLocalizationForm.tsx +++ b/src/components/flow/actions/localization/MsgLocalizationForm.tsx @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ import { react as bindCallbacks } from 'auto-bind'; import Dialog, { ButtonSet, Tab } from 'components/dialog/Dialog'; import styles from 'components/flow/actions/action/Action.module.scss'; @@ -16,16 +18,16 @@ import { MaxOfTenItems, validate } from 'store/validators'; import { initializeLocalizedForm } from './helpers'; import i18n from 'config/i18n'; import { Trans } from 'react-i18next'; -import { range } from 'utils'; import { renderIssues } from '../helpers'; import { Attachment, renderAttachments } from '../sendmsg/attachments'; import { AxiosError, AxiosResponse } from 'axios'; +import { TembaComponent } from 'temba/TembaComponent'; export interface MsgLocalizationFormState extends FormState { message: StringEntry; quickReplies: StringArrayEntry; audio: StringEntry; - templateVariables: StringEntry[]; + params: any; templating: MsgTemplating; attachments: Attachment[]; uploadInProgress: boolean; @@ -91,7 +93,7 @@ export default class MsgLocalizationForm extends React.Component< } private handleSave(): void { - const { message: text, quickReplies, audio, templateVariables, attachments } = this.state; + const { message: text, quickReplies, audio, attachments } = this.state; // make sure we are valid for saving, only quick replies can be invalid const typeConfig = determineTypeConfig(this.props.nodeSettings); @@ -125,17 +127,22 @@ export default class MsgLocalizationForm extends React.Component< } ]; - // if we have template variables, they show up on their own key - const hasTemplateVariables = templateVariables.find( - (entry: StringEntry) => entry.value.length > 0 - ); - if (hasTemplateVariables) { - localizations.push({ - uuid: this.state.templating.uuid, - translations: { variables: templateVariables.map((entry: StringEntry) => entry.value) } + // save our template components + const templating = (this.props.nodeSettings.originalAction as SendMsg).templating; + if (this.state.params && templating) { + const components = templating.components; + + // find the matching component for our params + Object.keys(this.state.params).forEach((key: any) => { + const component = components.find((c: any) => c.name === key); + if (component) { + localizations.push({ + uuid: component.uuid, + translations: { params: this.state.params[key] } + }); + } }); } - this.props.updateLocalizations(this.props.language.id, localizations); // notify our modal we are done @@ -157,26 +164,20 @@ export default class MsgLocalizationForm extends React.Component< this.handleUpdate({ quickReplies }); } - private handleTemplateVariableChanged(updatedText: string, num: number): void { - const entry = validate(`Variable ${num + 1}`, updatedText, []); - - const templateVariables = mutate(this.state.templateVariables, { - $merge: { [num]: entry } - }) as StringEntry[]; - - this.setState({ templateVariables }); + private handleTemplateVariableChanged(event: any): void { + this.setState({ params: event.detail.params }); } private handleAttachmentUploading(isUploading: boolean) { - const uploadError: string = ''; + const uploadError = ''; console.log(uploadError); this.setState({ uploadError }); if (isUploading) { - const uploadInProgress: boolean = true; + const uploadInProgress = true; this.setState({ uploadInProgress }); } else { - const uploadInProgress: boolean = false; + const uploadInProgress = false; this.setState({ uploadInProgress }); } } @@ -193,18 +194,18 @@ export default class MsgLocalizationForm extends React.Component< }); this.setState({ attachments }); - const uploadError: string = ''; + const uploadError = ''; console.log(uploadError); this.setState({ uploadError }); } - const uploadInProgress: boolean = false; + const uploadInProgress = false; this.setState({ uploadInProgress }); } private handleAttachmentUploadFailed(error: AxiosError) { //nginx returns a 300+ if there's an error - let uploadError: string = ''; + let uploadError = ''; const status = error.response.status; if (status >= 500) { uploadError = i18n.t('file_upload_failed_generic', 'File upload failed, please try again'); @@ -215,7 +216,7 @@ export default class MsgLocalizationForm extends React.Component< } this.setState({ uploadError }); - const uploadInProgress: boolean = false; + const uploadInProgress = false; this.setState({ uploadInProgress }); } @@ -249,16 +250,7 @@ export default class MsgLocalizationForm extends React.Component< const typeConfig = determineTypeConfig(this.props.nodeSettings); const tabs: Tab[] = []; - if ( - this.state.templating && - typeConfig.localizeableKeys!.indexOf('templating.variables') > -1 - ) { - const hasLocalizedValue = !!this.state.templateVariables.find( - (entry: StringEntry) => entry.value.length > 0 - ); - - const variable = i18n.t('forms.variable', 'Variable'); - + if (this.state.templating) { tabs.push({ name: 'WhatsApp', body: ( @@ -269,30 +261,22 @@ export default class MsgLocalizationForm extends React.Component< 'Sending messages over a WhatsApp channel requires that a template be used if you have not received a message from a contact in the last 24 hours. Setting a template to use over WhatsApp is especially important for the first message in your flow.' )}

- {this.state.templating && this.state.templating.variables.length > 0 ? ( - <> - {range(0, this.state.templating.variables.length).map((num: number) => { - const entry = this.state.templateVariables[num] || { value: '' }; - return ( -
- { - this.handleTemplateVariableChanged(updatedText, num); - }} - entry={entry} - autocomplete={true} - /> -
- ); - })} - + {this.state.templating ? ( + ) : null} ), - checked: hasLocalizedValue + checked: true //hasLocalizedValue }); } diff --git a/src/components/flow/actions/localization/__snapshots__/MsgLocalizationForm.test.ts.snap b/src/components/flow/actions/localization/__snapshots__/MsgLocalizationForm.test.ts.snap index 203ae4267..79deaf718 100644 --- a/src/components/flow/actions/localization/__snapshots__/MsgLocalizationForm.test.ts.snap +++ b/src/components/flow/actions/localization/__snapshots__/MsgLocalizationForm.test.ts.snap @@ -281,11 +281,11 @@ Object { "message": Object { "value": "", }, + "params": Object {}, "quickReplies": Object { "validationFailures": Array [], "value": Array [], }, - "templateVariables": Array [], "templating": null, "uploadError": "", "uploadInProgress": false, @@ -317,6 +317,7 @@ Object { "validationFailures": Array [], "value": "What is your favorite color?", }, + "params": Object {}, "quickReplies": Object { "validationFailures": Array [], "value": Array [ @@ -325,7 +326,6 @@ Object { "blue", ], }, - "templateVariables": Array [], "templating": null, "uploadError": "", "uploadInProgress": false, diff --git a/src/components/flow/actions/localization/helpers.ts b/src/components/flow/actions/localization/helpers.ts index 63f000350..1c00427b7 100644 --- a/src/components/flow/actions/localization/helpers.ts +++ b/src/components/flow/actions/localization/helpers.ts @@ -3,7 +3,7 @@ import { MsgLocalizationFormState } from 'components/flow/actions/localization/M import { Types } from 'config/interfaces'; import { getTypeConfig } from 'config/typeConfigs'; import { NodeEditorSettings, StringEntry } from 'store/nodeEditor'; -import { SendMsg, MsgTemplating, SayMsg } from 'flowTypes'; +import { SendMsg, SayMsg } from 'flowTypes'; import { Attachment } from '../sendmsg/attachments'; export const initializeLocalizedKeyForm = ( @@ -30,7 +30,7 @@ export const initializeLocalizedForm = (settings: NodeEditorSettings): MsgLocali const state: MsgLocalizationFormState = { message: { value: '' }, quickReplies: { value: [] }, - templateVariables: [], + params: {}, templating: null, audio: { value: null }, valid: true, @@ -49,17 +49,11 @@ export const initializeLocalizedForm = (settings: NodeEditorSettings): MsgLocali ) { if (settings.originalAction && (settings.originalAction as any).templating) { state.templating = (settings.originalAction as any).templating; - state.templateVariables = state.templating.variables.map((value: string) => { - return { - value: '' - }; - }); } for (const localized of settings.localizations) { if (localized.isLocalized()) { const localizedObject = localized.getObject() as any; - if (localizedObject.text) { const action = localizedObject as (SendMsg & SayMsg); state.message.value = 'text' in localized.localizedKeys ? action.text : ''; @@ -88,14 +82,8 @@ export const initializeLocalizedForm = (settings: NodeEditorSettings): MsgLocali state.valid = true; } - if (localizedObject.variables) { - const templating = localizedObject as MsgTemplating; - state.templateVariables = templating.variables.map((value: string) => { - return { - value: 'variables' in localized.localizedKeys ? value : '' - }; - }); - state.valid = true; + if (localized.localizedKeys.params) { + state.params[localizedObject.name] = localizedObject.params; } } } diff --git a/src/components/flow/actions/saymsg/__snapshots__/SayMsgForm.test.ts.snap b/src/components/flow/actions/saymsg/__snapshots__/SayMsgForm.test.ts.snap index 3b32ad8f4..8d2c74021 100644 --- a/src/components/flow/actions/saymsg/__snapshots__/SayMsgForm.test.ts.snap +++ b/src/components/flow/actions/saymsg/__snapshots__/SayMsgForm.test.ts.snap @@ -28,7 +28,6 @@ exports[`SayMsgForm render should render 1`] = ` "localizeableKeys": Array [ "text", "quick_replies", - "templating.variables", ], "massageForDisplay": [Function], "name": "Send Message", diff --git a/src/components/flow/actions/sendmsg/SendMsgForm.tsx b/src/components/flow/actions/sendmsg/SendMsgForm.tsx index 48f85d107..9c25c62ef 100644 --- a/src/components/flow/actions/sendmsg/SendMsgForm.tsx +++ b/src/components/flow/actions/sendmsg/SendMsgForm.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/explicit-member-accessibility */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { react as bindCallbacks } from 'auto-bind'; @@ -10,19 +11,17 @@ import { TOPIC_OPTIONS } from 'components/flow/actions/sendmsg/helpers'; import { ActionFormProps } from 'components/flow/props'; -import AssetSelector from 'components/form/assetselector/AssetSelector'; -import { hasUseableTranslation } from 'components/form/assetselector/helpers'; import CheckboxElement from 'components/form/checkbox/CheckboxElement'; import MultiChoiceInput from 'components/form/multichoice/MultiChoice'; import SelectElement, { SelectOption } from 'components/form/select/SelectElement'; import TextInputElement from 'components/form/textinput/TextInputElement'; import TypeList from 'components/nodeeditor/TypeList'; import { fakePropType } from 'config/ConfigProvider'; -import { fetchAsset } from 'external'; -import { Template, TemplateTranslation } from 'flowTypes'; +// import { fetchAsset } from 'external'; +import { TemplateTranslation } from 'flowTypes'; import mutate from 'immutability-helper'; import * as React from 'react'; -import { Asset } from 'store/flowContext'; +// import { Asset } from 'store/flowContext'; import { FormState, mergeForm, @@ -31,16 +30,15 @@ import { SelectOptionEntry, FormEntry } from 'store/nodeEditor'; -import { MaxOfTenItems, Required, shouldRequireIf, validate } from 'store/validators'; -import { range } from 'utils'; +import { MaxOfTenItems, shouldRequireIf, validate } from 'store/validators'; -import styles from './SendMsgForm.module.scss'; import { hasFeature } from 'config/typeConfigs'; import { FeatureFilter } from 'config/interfaces'; import i18n from 'config/i18n'; import { Trans } from 'react-i18next'; import { Attachment, renderAttachments } from './attachments'; +import { TembaComponent } from 'temba/TembaComponent'; export interface SendMsgFormState extends FormState { message: StringEntry; @@ -52,30 +50,19 @@ export interface SendMsgFormState extends FormState { uploadError: string; template: FormEntry; topic: SelectOptionEntry; - templateVariables: StringEntry[]; templateTranslation?: TemplateTranslation; + + // template uuid to dict of component key to array + paramsByTemplate: { [uuid: string]: { [key: string]: [] } }; } export default class SendMsgForm extends React.Component { - private filePicker: any; - constructor(props: ActionFormProps) { super(props); - this.state = stateToForm(this.props.nodeSettings, this.props.assetStore); + this.state = stateToForm(this.props.nodeSettings); bindCallbacks(this, { include: [/^handle/, /^on/] }); - - // intialize our templates if we have them - if (this.state.template.value !== null) { - fetchAsset(this.props.assetStore.templates, this.state.template.value.uuid).then( - (asset: Asset) => { - if (asset !== null) { - this.handleTemplateChanged([{ ...this.state.template.value, ...asset.content }]); - } - } - ); - } } public static contextTypes = { @@ -139,25 +126,12 @@ export default class SendMsgForm extends React.Component { - const updated = validate(`Variable ${num + 1}`, variable.value, [Required]); - templateVariables = mutate(templateVariables, { - [num]: { $merge: updated } - }) as StringEntry[]; - valid = valid && !hasErrors(updated); - }); - valid = valid && !hasErrors(this.state.quickReplyEntry); if (valid) { this.props.updateAction(stateToAction(this.props.nodeSettings, this.state)); // notify our modal we are done this.props.onClose(false); - } else { - this.setState({ templateVariables, valid }); } } @@ -171,46 +145,28 @@ export default class SendMsgForm extends React.Component { - return { - value: '' - }; - }) - : this.state.templateVariables; - - this.setState({ - template: { value: template }, - templateTranslation, - templateVariables - }); + const newParams: { [uuid: string]: {} } = { ...this.state.paramsByTemplate }; + if (template && newParams[template.uuid] === undefined) { + newParams[template.uuid] = params; } - } - private handleTemplateVariableChanged(updatedText: string, num: number): void { - const entry = validate(`Variable ${num + 1}`, updatedText, [Required]); - const templateVariables = mutate(this.state.templateVariables, { - $merge: { [num]: entry } - }) as StringEntry[]; - this.setState({ templateVariables }); + this.setState({ + template: { value: template }, + templateTranslation: translation, + paramsByTemplate: newParams + }); } - private handleShouldExcludeTemplate(template: any): boolean { - return !hasUseableTranslation(template as Template); + private handleTemplateVariableChanged(event: any): void { + const { template, params } = event.detail; + if (template) { + const newParams: { [uuid: string]: {} } = { ...this.state.paramsByTemplate }; + newParams[template.uuid] = params; + this.setState({ paramsByTemplate: newParams }); + } } private renderTopicConfig(): JSX.Element { @@ -243,6 +199,7 @@ export default class SendMsgForm extends React.Component

@@ -251,51 +208,39 @@ export default class SendMsgForm extends React.Component - - {this.state.templateTranslation ? ( - <> -

{this.state.templateTranslation.content}
- {range(0, this.state.templateTranslation.variable_count).map((num: number) => { - return ( -
- { - this.handleTemplateVariableChanged(updatedText, num); - }} - entry={this.state.templateVariables[num]} - autocomplete={true} - /> -
- ); - })} - - ) : null} + ); } private handleAttachmentUploading(isUploading: boolean) { - const uploadError: string = ''; + const uploadError = ''; console.log(uploadError); this.setState({ uploadError }); if (isUploading) { - const uploadInProgress: boolean = true; + const uploadInProgress = true; this.setState({ uploadInProgress }); } else { - const uploadInProgress: boolean = false; + const uploadInProgress = false; this.setState({ uploadInProgress }); } } @@ -310,21 +255,19 @@ export default class SendMsgForm extends React.Component= 500) { uploadError = i18n.t('file_upload_failed_generic', 'File upload failed, please try again'); @@ -335,7 +278,7 @@ export default class SendMsgForm extends React.Component hasErrors(entry)) + checked: this.state.template.value != null + // hasErrors: !!this.state.templateVariables.find((entry: StringEntry) => hasErrors(entry)) }; tabs.splice(0, 0, templates); } diff --git a/src/components/flow/actions/sendmsg/__snapshots__/SendMsgForm.test.ts.snap b/src/components/flow/actions/sendmsg/__snapshots__/SendMsgForm.test.ts.snap index c5f01c14c..2ba45971d 100644 --- a/src/components/flow/actions/sendmsg/__snapshots__/SendMsgForm.test.ts.snap +++ b/src/components/flow/actions/sendmsg/__snapshots__/SendMsgForm.test.ts.snap @@ -125,22 +125,20 @@ exports[`SendMsgForm render should render 1`] = `

Sending messages over a WhatsApp channel requires that a template be used if you have not received a message from a contact in the last 24 hours. Setting a template to use over WhatsApp is especially important for the first message in your flow.

- , "checked": false, - "hasErrors": false, "name": "WhatsApp", }, ] @@ -158,7 +156,6 @@ exports[`SendMsgForm render should render 1`] = ` "localizeableKeys": Array [ "text", "quick_replies", - "templating.variables", ], "massageForDisplay": [Function], "name": "Send Message", @@ -207,6 +204,7 @@ Object { "validationFailures": Array [], "value": "What is your favorite color?", }, + "paramsByTemplate": Object {}, "quickReplies": Object { "validationFailures": Array [], "value": Array [ @@ -222,7 +220,6 @@ Object { "template": Object { "value": null, }, - "templateVariables": Array [], "topic": Object { "value": undefined, }, diff --git a/src/components/flow/actions/sendmsg/helpers.ts b/src/components/flow/actions/sendmsg/helpers.ts index 50b988df7..73dcfc529 100644 --- a/src/components/flow/actions/sendmsg/helpers.ts +++ b/src/components/flow/actions/sendmsg/helpers.ts @@ -1,13 +1,13 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { getActionUUID } from 'components/flow/actions/helpers'; import { SendMsgFormState } from 'components/flow/actions/sendmsg/SendMsgForm'; import { Types } from 'config/interfaces'; import { MsgTemplating, SendMsg } from 'flowTypes'; -import { AssetStore } from 'store/flowContext'; -import { FormEntry, NodeEditorSettings, StringEntry } from 'store/nodeEditor'; +import { FormEntry, NodeEditorSettings } from 'store/nodeEditor'; import { SelectOption } from 'components/form/select/SelectElement'; -import { createUUID } from 'utils'; import { Attachment } from './attachments'; +import { createUUID } from 'utils'; export const TOPIC_OPTIONS: SelectOption[] = [ { value: 'event', name: 'Event' }, @@ -16,12 +16,8 @@ export const TOPIC_OPTIONS: SelectOption[] = [ { value: 'agent', name: 'Agent' } ]; -export const initializeForm = ( - settings: NodeEditorSettings, - assetStore: AssetStore -): SendMsgFormState => { +export const initializeForm = (settings: NodeEditorSettings): SendMsgFormState => { let template: FormEntry = { value: null }; - let templateVariables: StringEntry[] = []; if (settings.originalAction && settings.originalAction.type === Types.send_msg) { const action = settings.originalAction as SendMsg; @@ -39,6 +35,7 @@ export const initializeForm = ( attachments.push(attachment); }); + let paramsByTemplate: any = {}; if (action.templating) { const msgTemplate = action.templating.template; template = { @@ -47,17 +44,20 @@ export const initializeForm = ( name: msgTemplate.name } }; - templateVariables = action.templating.variables.map((value: string) => { - return { - value + + if (action.templating.components && action.templating.components.length > 0) { + paramsByTemplate = { + [action.templating.template.uuid]: {} }; - }); + action.templating.components.forEach((component: any) => { + paramsByTemplate[action.templating.template.uuid][component.name] = component.params; + }); + } } return { topic: { value: TOPIC_OPTIONS.find(option => option.value === action.topic) }, template, - templateVariables, attachments, uploadInProgress: false, uploadError: '', @@ -65,6 +65,7 @@ export const initializeForm = ( quickReplies: { value: action.quick_replies || [] }, quickReplyEntry: { value: '' }, sendAll: action.all_urns, + paramsByTemplate, valid: true }; } @@ -72,13 +73,13 @@ export const initializeForm = ( return { topic: { value: null }, template, - templateVariables: [], attachments: [], uploadInProgress: false, uploadError: '', message: { value: '' }, quickReplies: { value: [] }, quickReplyEntry: { value: '' }, + paramsByTemplate: {}, sendAll: false, valid: false }; @@ -90,27 +91,36 @@ export const stateToAction = (settings: NodeEditorSettings, state: SendMsgFormSt .map((attachment: Attachment) => `${attachment.type}:${attachment.url}`); let templating: MsgTemplating = null; - if (state.template && state.template.value) { - let templatingUUID = createUUID(); - if (settings.originalAction && settings.originalAction.type === Types.send_msg) { - const action = settings.originalAction as SendMsg; - if ( - action.templating && - action.templating.template && - action.templating.template.uuid === state.template.value.id - ) { - templatingUUID = action.templating.uuid; + const components = Object.keys(state.templateTranslation.components).map((key: string) => { + let uuid = createUUID(); + + // try looking up the uuid from the original action + if (settings.originalAction && settings.originalAction.type === Types.send_msg) { + const originalAction = settings.originalAction as SendMsg; + if (originalAction.templating) { + const originalComponent = originalAction.templating.components.find( + (component: any) => component.name === key + ); + if (originalComponent) { + uuid = originalComponent.uuid; + } + } } - } + + return { + uuid, + name: key, + params: state.paramsByTemplate[state.template.value.uuid][key] + }; + }); templating = { - uuid: templatingUUID, template: { uuid: state.template.value.uuid, name: state.template.value.name }, - variables: state.templateVariables.map((variable: StringEntry) => variable.value) + components }; } diff --git a/src/components/flow/props.ts b/src/components/flow/props.ts index 022f42726..f2226f70f 100644 --- a/src/components/flow/props.ts +++ b/src/components/flow/props.ts @@ -15,6 +15,7 @@ export interface ActionFormProps extends IssueProps { nodeSettings: NodeEditorSettings; typeConfig: Type; assetStore: AssetStore; + language: Asset; addAsset(assetType: string, asset: Asset): void; @@ -49,6 +50,7 @@ export interface LocalizationFormProps extends IssueProps { updateLocalizations(languageCode: string, localizations: any[]): void; onClose(canceled: boolean): void; helpArticles: { [key: string]: string }; + assetStore: AssetStore; } export const NAME_PROPERTY: Asset = { diff --git a/src/components/form/assetselector/helpers.ts b/src/components/form/assetselector/helpers.ts index ca4e93da2..44706d4a0 100644 --- a/src/components/form/assetselector/helpers.ts +++ b/src/components/form/assetselector/helpers.ts @@ -1,4 +1,3 @@ -import { Template } from 'flowTypes'; import { Asset, REMOVE_VALUE_ASSET } from 'store/flowContext'; /** @@ -22,13 +21,3 @@ export const sortByName = (a: Asset, b: Asset): number => { } return 0; }; - -export const hasPendingTranslation = (template: Template) => { - return !!template.translations.find(translation => translation.status === 'pending'); -}; - -export const hasUseableTranslation = (template: Template) => { - return !!template.translations.find( - translation => translation.status === 'pending' || translation.status === 'approved' - ); -}; diff --git a/src/components/form/assetselector/widgets.tsx b/src/components/form/assetselector/widgets.tsx index 062748932..e2c30d561 100644 --- a/src/components/form/assetselector/widgets.tsx +++ b/src/components/form/assetselector/widgets.tsx @@ -1,6 +1,4 @@ import * as React from 'react'; -import { hasPendingTranslation } from 'components/form/assetselector/helpers'; -import { Template } from 'flowTypes'; import { Asset, AssetType } from 'store/flowContext'; export const getIconForAssetType = (asset: Asset): JSX.Element => { @@ -13,12 +11,6 @@ export const getIconForAssetType = (asset: Asset): JSX.Element => { return ; case AssetType.Scheme: return ; - case AssetType.Template: - if (hasPendingTranslation(asset.content as Template)) { - return ; - } else { - return ; - } case AssetType.Remove: return ( <> diff --git a/src/components/nodeeditor/NodeEditor.tsx b/src/components/nodeeditor/NodeEditor.tsx index 502e21e7b..af8d80a8d 100644 --- a/src/components/nodeeditor/NodeEditor.tsx +++ b/src/components/nodeeditor/NodeEditor.tsx @@ -68,6 +68,7 @@ export interface FormProps { addAsset(assetType: string, asset: Asset): void; + language: Asset; assetStore: AssetStore; issues: FlowIssue[]; helpArticles: { [key: string]: string }; @@ -151,6 +152,7 @@ export class NodeEditor extends React.Component { onClose: this.close, language: this.props.language, helpArticles: this.props.helpArticles, + assetStore: this.props.assetStore, issues: this.props.issues.filter( (issue: FlowIssue) => issue.language === this.props.language.id ) @@ -176,7 +178,8 @@ export class NodeEditor extends React.Component { issues: this.props.issues.filter((issue: FlowIssue) => !issue.language), typeConfig: this.props.typeConfig, onTypeChange: this.props.handleTypeConfigChange, - onClose: this.close + onClose: this.close, + language: this.props.language }; return ( diff --git a/src/components/translator/TranslatorTab.test.tsx b/src/components/translator/TranslatorTab.test.tsx index 9ef99ae9d..ffa711d91 100644 --- a/src/components/translator/TranslatorTab.test.tsx +++ b/src/components/translator/TranslatorTab.test.tsx @@ -49,9 +49,14 @@ const createMessageNode = ( if (variables.length > 0) { sendMsg.templating = { - uuid: createUUID(), template: { uuid: createUUID(), name: 'My Template' }, - variables + components: [ + { + uuid: createUUID(), + name: 'body', + params: variables + } + ] }; } @@ -99,13 +104,12 @@ describe(TranslatorTab.name, () => { it('finds message translations', () => { const { baseElement, getByText, rerender } = render(); - const updates = createMessageNode('Hello World!', ['yes', 'no'], ['var1', 'var2']); + const updates = createMessageNode('Hello World!', ['yes', 'no']); rerender(); // we pulled out all the localizable bits getByText('Hello World!'); getByText('Quick Replies'); - getByText('Template Variables'); getByText('0%'); expect(baseElement).toMatchSnapshot(); diff --git a/src/components/translator/__snapshots__/TranslatorTab.test.tsx.snap b/src/components/translator/__snapshots__/TranslatorTab.test.tsx.snap index 206bd8dbc..1448b4064 100644 --- a/src/components/translator/__snapshots__/TranslatorTab.test.tsx.snap +++ b/src/components/translator/__snapshots__/TranslatorTab.test.tsx.snap @@ -261,20 +261,6 @@ exports[`TranslatorTab finds message translations 1`] = ` Quick Replies -
-
- var1, var2 -
-
- Template Variables -
-
diff --git a/src/components/translator/helpers.ts b/src/components/translator/helpers.ts index fa3b9a974..8ca5490d7 100644 --- a/src/components/translator/helpers.ts +++ b/src/components/translator/helpers.ts @@ -101,7 +101,7 @@ export const getFriendlyAttribute = (attribute: string) => { return i18next.t('translation.attributes.quick_replies', 'Quick Replies'); } - if (attribute === 'templating.variables') { + if (attribute === 'templating.components') { return i18next.t('translation.attributes.templates', 'Template Variables'); } diff --git a/src/config/__snapshots__/typeConfigs.test.ts.snap b/src/config/__snapshots__/typeConfigs.test.ts.snap index ae0557388..91bd44bb2 100644 --- a/src/config/__snapshots__/typeConfigs.test.ts.snap +++ b/src/config/__snapshots__/typeConfigs.test.ts.snap @@ -84,7 +84,6 @@ Array [ "localizeableKeys": Array [ "text", "quick_replies", - "templating.variables", ], "massageForDisplay": [Function], "name": "Send Message", @@ -648,7 +647,6 @@ Object { "localizeableKeys": Array [ "text", "quick_replies", - "templating.variables", ], "massageForDisplay": [Function], "name": "Send Message", diff --git a/src/config/typeConfigs.ts b/src/config/typeConfigs.ts index a52fb57ba..fe07c818a 100644 --- a/src/config/typeConfigs.ts +++ b/src/config/typeConfigs.ts @@ -256,7 +256,7 @@ export const typeConfigList: Type[] = [ description: i18n.t('actions.send_msg.description', 'Send the contact a message'), form: SendMsgForm, localization: MsgLocalizationForm, - localizeableKeys: ['text', 'quick_replies', 'templating.variables'], + localizeableKeys: ['text', 'quick_replies'], component: SendMsgComp, massageForDisplay: (action: SendMsg) => { // quick replies are optional in the definition, make sure we have diff --git a/src/flowTypes.ts b/src/flowTypes.ts index 68b263da8..8bbd454c9 100644 --- a/src/flowTypes.ts +++ b/src/flowTypes.ts @@ -1,9 +1,10 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { Methods } from 'components/flow/routers/webhook/helpers'; import { FlowTypes, Operators, Types, ContactStatus } from 'config/interfaces'; import { ExclusionsCheckboxEntry } from 'store/nodeEditor'; // we don't concern ourselves with patch versions -export const SPEC_VERSION = '13.2'; +export const SPEC_VERSION = '13.4'; export interface Languages { [iso: string]: string; @@ -204,18 +205,6 @@ export interface Category { exit_uuid: string; } -export interface TemplateTranslation { - language: string; - status: string; - content: string; -} - -export interface Template { - created_on: Date; - modified_on: Date; - translations: TemplateTranslation[]; -} - export interface SwitchRouter extends Router { cases: Case[]; operand: string; @@ -356,6 +345,7 @@ export interface TemplateTranslation { language: string; status: string; variable_count: number; + components: { [key: string]: { params: string[]; content: string } }; } export interface TemplateOptions { @@ -367,10 +357,15 @@ export interface MsgTemplate { uuid: string; } -export interface MsgTemplating { +export interface MsgTemplateComponent { uuid: string; + name: string; + params: string[]; +} + +export interface MsgTemplating { template: MsgTemplate; - variables: string[]; + components?: MsgTemplateComponent[]; } export interface SendMsg extends Action { diff --git a/src/services/Localization.ts b/src/services/Localization.ts index e917de07d..76f44e004 100644 --- a/src/services/Localization.ts +++ b/src/services/Localization.ts @@ -1,8 +1,8 @@ -import { Action, Case, Category, Language, MsgTemplating } from 'flowTypes'; +import { Action, Case, Category, Language, MsgTemplateComponent } from 'flowTypes'; import { Asset } from 'store/flowContext'; // list of keys that should always be treated as an array -const ARRAY_KEYS = ['attachments']; +const ARRAY_KEYS = ['attachments', 'components']; export class LocalizedObject { public localizedKeys: { [key: string]: boolean } = {}; @@ -13,7 +13,7 @@ export class LocalizedObject { private name: string; private language: Language; - constructor(object: Action | Category | Case | MsgTemplating, { id, name }: Asset) { + constructor(object: Action | Category | Case | MsgTemplateComponent, { id, name }: Asset) { this.localizedObject = object; this.iso = id; this.language = { iso: this.iso, name }; @@ -58,14 +58,14 @@ export class LocalizedObject { return this.localized; } - public getObject(): Action | Case | Category | MsgTemplating { + public getObject(): Action | Case | Category { return this.localizedObject; } } export default class Localization { public static translate( - object: Action | Category | Case | MsgTemplating, + object: Action | Category | Case | MsgTemplateComponent, language: Asset, translations?: { [uuid: string]: any } ): LocalizedObject { diff --git a/src/store/helpers.ts b/src/store/helpers.ts index c7273b129..0570eeee9 100644 --- a/src/store/helpers.ts +++ b/src/store/helpers.ts @@ -187,9 +187,9 @@ export const getLocalizations = ( if (action.type === Types.send_msg) { const sendMsgAction = action as SendMsg; if (sendMsgAction.templating) { - localizations.push( - Localization.translate(sendMsgAction.templating, language, translations) - ); + sendMsgAction.templating.components.forEach((component: any) => { + localizations.push(Localization.translate(component, language, translations)); + }); } } } diff --git a/src/temba/TembaComponent.jsx b/src/temba/TembaComponent.jsx new file mode 100644 index 000000000..43e6fdbcf --- /dev/null +++ b/src/temba/TembaComponent.jsx @@ -0,0 +1,33 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import * as React from 'react'; + +export class TembaComponent extends React.Component { + eventCallbacks = {}; + componentRef = React.createRef(); + onEvent = eventType => { + return event => { + // we will get the latest callback when the event happens + // eslint-disable-next-line react/prop-types + this.props.eventHandlers[eventType](event); + }; + }; + + componentDidMount() { + // eslint-disable-next-line react/prop-types + const { eventHandlers } = this.props; + Object.keys(eventHandlers || {}).forEach(eventType => { + this.eventCallbacks[eventType] = this.onEvent(eventType); + this.componentRef.current.addEventListener(eventType, this.eventCallbacks[eventType]); + }); + } + componentWillUnmount() { + Object.keys(this.eventCallbacks).forEach(eventType => { + this.componentRef.current.removeEventListener(eventType, this.eventCallbacks[eventType]); + }); + } + render() { + // eslint-disable-next-line react/prop-types + const { tag: Component, ...restProps } = this.props; + return ; + } +} diff --git a/src/testUtils/assetCreators.ts b/src/testUtils/assetCreators.ts index c9737307f..5f6fa73f1 100644 --- a/src/testUtils/assetCreators.ts +++ b/src/testUtils/assetCreators.ts @@ -467,12 +467,14 @@ export const getLocalizationFormProps = ( }), originalAction: action, localizations: [Localization.translate(action, language, translations)] - } + }, + assetStore: {} }; }; export const getActionFormProps = (action: AnyAction): ActionFormProps => ({ assetStore: { + templates: { items: {}, type: AssetType.Template, endpoint: 'assets/templates.json' }, channels: { items: {}, type: AssetType.Channel }, fields: { items: {}, type: AssetType.Field }, languages: { items: {}, type: AssetType.Language }, @@ -488,6 +490,7 @@ export const getActionFormProps = (action: AnyAction): ActionFormProps => ({ onTypeChange: jest.fn(), issues: [], typeConfig: getTypeConfig(action.type), + language: null, nodeSettings: { originalNode: createRenderNode({ actions: [action], diff --git a/src/untyped.d.ts b/src/untyped.d.ts index acf8d2dab..e0b1e8fa6 100644 --- a/src/untyped.d.ts +++ b/src/untyped.d.ts @@ -14,5 +14,6 @@ declare namespace JSX { 'temba-compose': any; 'temba-icon': any; 'temba-sortable-list': any; + 'temba-template-editor': any; } }