Refactor and simplify state management in campaign editor.

- Simplify and fix content conversion between formats.
- Fix state management issues.
- Rename `Apply` to `Import` on the visual template UI.
This commit is contained in:
Kailash Nadh 2025-04-07 14:36:14 +05:30
parent fca5ec5abe
commit 110345d659
3 changed files with 138 additions and 119 deletions

View file

@ -533,6 +533,9 @@ body.is-noscroll {
} }
} }
.button.is-primary[disabled] {
border-color: $grey-light;
}
.has-addons { .has-addons {
.controls .button.is-primary { .controls .button.is-primary {
border-top-left-radius: 0; border-top-left-radius: 0;

View file

@ -4,7 +4,7 @@
<div class="columns"> <div class="columns">
<div class="column is-three-quarters is-inline-flex"> <div class="column is-three-quarters is-inline-flex">
<b-field :label="$t('campaigns.format')" label-position="on-border" class="mr-4 mb-0"> <b-field :label="$t('campaigns.format')" label-position="on-border" class="mr-4 mb-0">
<b-select v-model="contentType"> <b-select v-model="contentTypeSel">
<option :disabled="disabled" name="format" value="richtext" data-cy="check-richtext"> <option :disabled="disabled" name="format" value="richtext" data-cy="check-richtext">
{{ $t('campaigns.richText') }} {{ $t('campaigns.richText') }}
</option> </option>
@ -27,10 +27,9 @@
</b-select> </b-select>
</b-field> </b-field>
<b-field v-if="computedValue.contentType !== 'visual'" :label="$t('globals.terms.baseTemplate')" <b-field v-if="self.contentType !== 'visual'" :label="$tc('globals.terms.template')" label-position="on-border">
label-position="on-border">
<b-select :placeholder="$t('globals.terms.none')" v-model="templateId" name="template" :disabled="disabled"> <b-select :placeholder="$t('globals.terms.none')" v-model="templateId" name="template" :disabled="disabled">
<template v-for="t in applicableTemplates"> <template v-for="t in validTemplates">
<option :value="t.id" :key="t.id"> <option :value="t.id" :key="t.id">
{{ t.name }} {{ t.name }}
</option> </option>
@ -41,22 +40,23 @@
<div v-else> <div v-else>
<b-button v-if="!isVisualTplSelector" @click="onShowVisualTplSelector" type="is-ghost" <b-button v-if="!isVisualTplSelector" @click="onShowVisualTplSelector" type="is-ghost"
icon-left="file-find-outline" data-cy="btn-select-visual-tpl"> icon-left="file-find-outline" data-cy="btn-select-visual-tpl">
{{ $t('globals.terms.copyVisualTemplate') }} {{ $t('campaigns.importVisualTemplate') }}
</b-button> </b-button>
<b-field v-else :label="$t('globals.terms.copyVisualTemplate')" label-position="on-border"> <b-field v-else :label="$tc('globals.terms.template')" label-position="on-border">
<b-select :placeholder="$t('globals.terms.none')" v-model="visualTemplateId" name="template" <b-select :placeholder="$t('globals.terms.none')" v-model="visualTemplateId"
:disabled="disabled" class="copy-visual-template-list"> @input="() => isVisualTplDisabled = false" name="template" :disabled="disabled"
<template v-for="t in applicableTemplates"> class="copy-visual-template-list">
<template v-for="t in validTemplates">
<option :value="t.id" :key="t.id"> <option :value="t.id" :key="t.id">
{{ t.name }} {{ t.name }}
</option> </option>
</template> </template>
</b-select> </b-select>
<b-button :disabled="isVisualTplApplied" class="ml-3" @click="onApplyVisualTpl" type="is-primary" <b-button :disabled="disabled || isVisualTplDisabled" class="ml-3" @click="onImportVisualTpl"
icon-left="content-save-outline" data-cy="btn-save-visual-tpl"> type="is-primary" icon-left="content-save-outline" data-cy="btn-save-visual-tpl">
{{ $t('globals.terms.apply') }} {{ $t('globals.terms.import') }}
</b-button> </b-button>
</b-field> </b-field>
</div> </div>
@ -69,25 +69,25 @@
</div> </div>
<!-- wsywig //--> <!-- wsywig //-->
<richtext-editor v-if="computedValue.contentType === 'richtext'" v-model="computedValue.body" /> <richtext-editor v-if="self.contentType === 'richtext'" v-model="self.body" />
<!-- visual editor //--> <!-- visual editor //-->
<visual-editor v-if="computedValue.contentType === 'visual'" :source="computedValue.bodySource" <visual-editor v-if="self.contentType === 'visual'" :source="self.bodySource" @change="onVisualEditorChange"
@change="onChangeVisualEditor" height="65vh" /> height="65vh" />
<!-- raw html editor //--> <!-- raw html editor //-->
<html-editor v-if="computedValue.contentType === 'html'" v-model="computedValue.body" /> <html-editor v-if="self.contentType === 'html'" v-model="self.body" />
<!-- markdown editor //--> <!-- markdown editor //-->
<markdown-editor v-if="computedValue.contentType === 'markdown'" v-model="computedValue.body" /> <markdown-editor v-if="self.contentType === 'markdown'" v-model="self.body" />
<!-- plain text //--> <!-- plain text //-->
<b-input v-if="computedValue.contentType === 'plain'" v-model="computedValue.body" type="textarea" name="content" <b-input v-if="self.contentType === 'plain'" v-model="self.body" type="textarea" name="content" ref="plainEditor"
ref="plainEditor" class="plain-editor" /> class="plain-editor" />
<!-- campaign preview //--> <!-- campaign preview //-->
<campaign-preview v-if="isPreviewing" is-post @close="onTogglePreview" type="campaign" :id="id" :title="title" <campaign-preview v-if="isPreviewing" is-post @close="onTogglePreview" type="campaign" :id="id" :title="title"
:content-type="computedValue.contentType" :template-id="templateId" :body="computedValue.body" /> :content-type="self.contentType" :template-id="templateId" :body="self.body" />
</section> </section>
</template> </template>
@ -118,6 +118,10 @@ export default {
title: { type: String, default: '' }, title: { type: String, default: '' },
disabled: { type: Boolean, default: false }, disabled: { type: Boolean, default: false },
templates: { type: Array, default: null }, templates: { type: Array, default: null },
// value is provided by the parent component.
// Throught the editor, `this.self` (a mutable clone of `value`) is used,
// instead of `this.value` directly.
value: { value: {
type: Object, type: Object,
default: () => ({ default: () => ({
@ -133,8 +137,8 @@ export default {
return { return {
isPreviewing: false, isPreviewing: false,
isVisualTplSelector: false, isVisualTplSelector: false,
isVisualTplApplied: false, isVisualTplDisabled: false,
contentType: this.$props.value.contentType, contentTypeSel: this.$props.value.contentType,
templateId: '', templateId: '',
visualTemplateId: '', visualTemplateId: '',
}; };
@ -142,79 +146,97 @@ export default {
methods: { methods: {
onContentTypeChange(to, from) { onContentTypeChange(to, from) {
if (this.computedValue.body?.trim() === '') { // Ask for confirmation as pretty much all conversions are lossy.
this.computedValue.contentType = this.contentType; const msgKey = to === 'visual' ? 'campaigns.confirmOverwriteContent' : 'campaigns.confirmSwitchFormat';
return;
}
// To avoid prompt loop.
if (to === this.computedValue.contentType) {
return;
}
// Content isn't empty. Warn.
this.$utils.confirm( this.$utils.confirm(
this.$t('campaigns.confirmSwitchFormat'), this.$t(msgKey),
() => { () => {
this.computedValue.contentType = this.contentType; this.convertContentType(to, from);
}, },
() => { () => {
this.contentType = from; // Cancelled. Reset the <select> to the last value.
this.contentTypeSel = from;
}, },
); );
}, },
convertContentType(to, from) { convertContentType(to, from) {
let body; let body = this.self.body ?? '';
// Skip UI update (markdown => richtext, html requires a backenbd call).
let skip = false; let skip = false;
if ((from === 'richtext' || from === 'html') && to === 'plain') { // If `from` is HTML content, strip out `<body>..` etc. and keep the beautified HTML.
// richtext, html => plain let isHTML = false;
if (from === 'richtext' || from === 'html' || from === 'visual') {
// Preserve line breaks when converting HTML to plaintext.
const d = document.createElement('div'); const d = document.createElement('div');
d.innerHTML = this.beautifyHTML(this.computedValue.body); d.innerHTML = body;
body = this.trimLines(d.innerText.trim(), true); body = this.beautifyHTML(d.innerHTML.trim());
} else if ((from === 'richtext' || from === 'html') && to === 'markdown') { isHTML = true;
// richtext, html => markdown }
body = turndown.turndown(this.computedValue.body).replace(/\n\n+/ig, '\n\n');
} else if (from === 'plain' && (to === 'richtext' || to === 'html')) { // HTML => Non-HTML.
// plain => richtext, html if (isHTML) {
body = this.computedValue.body.replace(/\n/ig, '<br>\n'); switch (to) {
} else if (from === 'richtext' && to === 'html') { case 'plain': {
// richtext => html const d = document.createElement('div');
body = this.beautifyHTML(this.computedValue.body); d.innerHTML = body;
body = this.trimLines(d.innerText.trim(), true);
break;
}
case 'markdown': {
body = turndown.turndown(body).replace(/\n\n+/ig, '\n\n');
break;
}
default:
// Switching between HTML formats, no need to do anything further
// as body is already beautified.
// richtext|html => visual, the contents are simply lost.
break;
}
// Markdown to HTML requires a backend call.
} else if (from === 'markdown' && (to === 'richtext' || to === 'html')) { } else if (from === 'markdown' && (to === 'richtext' || to === 'html')) {
// Skip default update.
skip = true; skip = true;
// markdown => richtext, html.
this.$api.convertCampaignContent({ this.$api.convertCampaignContent({
id: 1, body: this.computedValue.body, from, to, id: 1, body, from, to,
}).then((data) => { }).then((data) => {
this.$nextTick(() => { this.$nextTick(() => {
this.computedValue.body = this.beautifyHTML(data.trim()); // Both type + body should be updated in one cycle to avoid firing
this.computedValue.bodySource = null; // multiple events.
this.self.contentType = to;
this.self.body = this.beautifyHTML(data.trim());
}); });
}); });
// Plain to an HTML type, change plain line breaks to HTML breaks.
} else if (from === 'plain' && (to === 'richtext' || to === 'html')) {
body = body.replace(/\n/ig, '<br>\n');
} }
if (!skip) { // =======================================================================
// Update the current body. // If the target is visual, empty the visual editor's block content source.
this.$nextTick(() => { if (to !== 'visual') {
this.computedValue.body = body; this.self.bodySource = null;
// If not visual editor then set bodySource to null
// this makes sure previous bodySource is not used when switching to visual editor.
if (to !== 'visual') {
this.computedValue.bodySource = null;
}
});
} }
// Reset template ID only if its converted to or from visual template. // Reset the campaign template ID if its converted to or from visual template.
if (to === 'visual' || from === 'visual') { if (to === 'visual' || from === 'visual') {
this.templateId = null; this.templateId = null;
this.computedValue.templateId = null; this.self.templateId = null;
}
// =======================================================================
// Apply the conversion on the editor UI.
if (!skip) {
this.$nextTick(() => {
// Both type + body should be updated in one cycle to avoid firing
// multiple events.
this.self.contentType = to;
this.self.body = body;
});
} }
}, },
@ -229,9 +251,9 @@ export default {
} }
}, },
onChangeVisualEditor({ body, source }) { onVisualEditorChange({ body, source }) {
this.computedValue.body = body; this.self.body = body;
this.computedValue.bodySource = source; this.self.bodySource = source;
}, },
beautifyHTML(str) { beautifyHTML(str) {
@ -267,33 +289,33 @@ export default {
this.setDefaultTemplate(); this.setDefaultTemplate();
}, },
onApplyVisualTpl() { onImportVisualTpl() {
this.$utils.confirm( this.$utils.confirm(
this.$t('campaigns.confirmApplyVisualTemplate'), this.$t('campaigns.confirmOverwriteContent'),
() => { () => {
let found = false; let found = false;
this.templates.forEach((t) => { this.templates.forEach((t) => {
if (t.id === this.visualTemplateId) { if (t.id === this.visualTemplateId) {
found = true; found = true;
this.computedValue.body = t.body; this.self.body = t.body;
this.computedValue.bodySource = t.bodySource; this.self.bodySource = t.bodySource;
// Deplay update so that applied template is propogated to visual editor // Deplay update so that applied template is propogated to visual editor
// and it doesn't enable the apply button again. Delay here is arbitrary. // and it doesn't enable the apply button again. Delay here is arbitrary.
setTimeout(() => { setTimeout(() => {
this.isVisualTplApplied = true; this.isVisualTplDisabled = true;
}, 250); }, 250);
} }
}); });
if (!found) { if (!found) {
this.computedValue.body = ''; this.self.body = '';
this.computedValue.bodySource = null; this.self.bodySource = null;
// Deplay update so that applied template is propogated to visual editor // Deplay update so that applied template is propogated to visual editor
// and it doesn't enable the apply button again. Delay here is arbitrary. // and it doesn't enable the apply button again. Delay here is arbitrary.
setTimeout(() => { setTimeout(() => {
this.isVisualTplApplied = true; this.isVisualTplDisabled = true;
}, 250); }, 250);
} }
}, },
@ -301,18 +323,18 @@ export default {
}, },
setDefaultTemplate() { setDefaultTemplate() {
if (this.computedValue.contentType === 'visual') { if (this.self.contentType === 'visual') {
this.visualTemplateId = this.applicableTemplates[0]?.id || null; this.visualTemplateId = this.validTemplates[0]?.id || null;
} else { } else {
const defaultTemplate = this.applicableTemplates.find((t) => t.isDefault === true); const defaultTemplate = this.validTemplates.find((t) => t.isDefault === true);
this.templateId = defaultTemplate?.id || this.applicableTemplates[0]?.id || null; this.templateId = defaultTemplate?.id || this.validTemplates[0]?.id || null;
} }
}, },
}, },
mounted() { mounted() {
// Set initial content type for the selector. // Set initial content type for the selector.
this.contentType = this.value.contentType; this.contentTypeSel = this.value.contentType;
this.templateId = this.value.templateId; this.templateId = this.value.templateId;
window.addEventListener('keydown', this.onPreviewShortcut); window.addEventListener('keydown', this.onPreviewShortcut);
@ -325,53 +347,48 @@ export default {
computed: { computed: {
...mapState(['serverConfig']), ...mapState(['serverConfig']),
computedValue: { // This is a clone of the incoming `value` prop that's mutated here.
self: {
get() { get() {
return this.value; return this.value;
}, },
set(newValue) {
this.$emit('input', newValue); // Any change to the local copy, emit it to the parent.
set(val) {
this.$emit('input', val);
}, },
}, },
applicableTemplates() { // Returns the list of valid (visual vs. normal) templates for the template dropdown.
if (this.computedValue.contentType === 'visual') { validTemplates() {
return this.templates.filter((t) => t.type === 'campaign_visual'); const typ = this.self.contentType === 'visual' ? 'campaign_visual' : 'campaign';
} return this.templates.filter((t) => (t.type === typ));
return this.templates.filter((t) => t.type === 'campaign');
}, },
}, },
watch: { watch: {
contentType(to, from) { validTemplates() {
this.onContentTypeChange(to, from, true); // When the filtered list of validTemplates changes (visual vs. regular),
}, // select the appropriate 'default' in the template select list.
// eslint-disable-next-line func-names
'computedValue.contentType': function (to, from) {
this.convertContentType(to, from);
},
applicableTemplates() {
this.setDefaultTemplate(); this.setDefaultTemplate();
}, },
contentTypeSel(to, from) {
// Show the conversion prompt if the value in the dropdown isn't the same
// as the current selection. This happens when eg: contentTypeSel = html -> visual happens
// in the selector, the prompt is shown, and Cancel is clicked,
// at which point, contentTypeSel = html again, which triggers this event.
if (from !== to && to !== this.self.contentType) {
this.onContentTypeChange(to, from);
}
},
templateId(to) { templateId(to) {
if (this.computedValue.templateId === to) { if (this.self.templateId === to) {
return; return;
} }
this.computedValue.templateId = to;
},
// eslint-disable-next-line func-names this.self.templateId = to;
'computedValue.bodySource': function (to, from) {
this.isVisualTplApplied = !(JSON.stringify(to) !== JSON.stringify(from));
},
visualTemplateId(to, from) {
if (from && from !== to) {
this.isVisualTplApplied = false;
}
}, },
}, },
}; };

View file

@ -74,6 +74,7 @@
"campaigns.rawHTML": "Raw HTML", "campaigns.rawHTML": "Raw HTML",
"campaigns.removeAltText": "Remove alternate plain text message", "campaigns.removeAltText": "Remove alternate plain text message",
"campaigns.richText": "Rich text", "campaigns.richText": "Rich text",
"campaigns.importVisualTemplate": "Import visual template",
"campaigns.visual": "Visual", "campaigns.visual": "Visual",
"campaigns.format": "Format", "campaigns.format": "Format",
"campaigns.schedule": "Schedule campaign", "campaigns.schedule": "Schedule campaign",
@ -239,13 +240,11 @@
"globals.terms.tags": "Tags", "globals.terms.tags": "Tags",
"globals.terms.template": "Template | Templates", "globals.terms.template": "Template | Templates",
"globals.terms.templates": "Templates", "globals.terms.templates": "Templates",
"globals.terms.baseTemplate": "Base template",
"globals.terms.applyVisualTemplate": "Apply visual template",
"globals.terms.tx": "Transactional | Transactional", "globals.terms.tx": "Transactional | Transactional",
"globals.terms.user": "User | Users", "globals.terms.user": "User | Users",
"globals.terms.users": "Users", "globals.terms.users": "Users",
"globals.terms.year": "Year | Years", "globals.terms.year": "Year | Years",
"globals.terms.apply": "Apply", "globals.terms.import": "Import",
"import.alreadyRunning": "An import is already running. Wait for it to finish or stop it before trying again.", "import.alreadyRunning": "An import is already running. Wait for it to finish or stop it before trying again.",
"import.blocklist": "Blocklist", "import.blocklist": "Blocklist",
"import.csvDelim": "CSV delimiter", "import.csvDelim": "CSV delimiter",