Add HTML syntax highlighted editing to the template editor.

- Refactor codeflask HTML editor into a standalone html-editor
  component.
- Replace the plaintext box in the template editor with html-editor.
- Replace codeflask in the campaign editor with the new html-editor.
- Refactor templates Cypress tests to test the new editor.
- Refactor campaigns Cypress tests to test the new editor and also
  test switching between different editors and content formats.
This commit is contained in:
Kailash Nadh 2021-09-26 20:13:12 +05:30
parent a1a9f3ac6a
commit 9d2bc9c41d
10 changed files with 109 additions and 125 deletions

View file

@ -70,9 +70,10 @@ type subOptin struct {
var (
dummySubscriber = models.Subscriber{
Email: "demo@listmonk.app",
Name: "Demo Subscriber",
UUID: dummyUUID,
Email: "demo@listmonk.app",
Name: "Demo Subscriber",
UUID: dummyUUID,
Attribs: models.SubscriberAttribs{"city": "Bengaluru"},
}
subQuerySortFields = []string{"email", "name", "created_at", "updated_at"}

View file

@ -137,7 +137,7 @@ func handleCreateTemplate(c echo.Context) error {
}
if err := validateTemplate(o, app); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
return err
}
// Insert and read ID.

View file

@ -1,28 +0,0 @@
{
"profile": [
{
"id": 2,
"uuid": "0954ba2e-50e4-4847-86f4-c2b8b72dace8",
"email": "anon@example.com",
"name": "Anon Doe",
"attribs": {
"city": "Bengaluru",
"good": true,
"type": "unknown"
},
"status": "enabled",
"created_at": "2021-02-20T15:52:16.251648+05:30",
"updated_at": "2021-02-20T15:52:16.251648+05:30"
}
],
"subscriptions": [
{
"subscription_status": "unconfirmed",
"name": "Opt-in list",
"type": "public",
"created_at": "2021-02-20T15:52:16.251648+05:30"
}
],
"campaign_views": [],
"link_clicks": []
}

View file

@ -29,9 +29,13 @@ describe('Campaigns', () => {
// Enable schedule.
cy.get('[data-cy=btn-send-later] .check').click();
cy.wait(100);
cy.get('.datepicker input').click();
cy.wait(100);
cy.get('.datepicker-header .control:nth-child(2) select').select((new Date().getFullYear() + 1).toString());
cy.wait(100);
cy.get('.datepicker-body a.is-selectable:first').click();
cy.wait(100);
cy.get('body').click(1, 1);
// Switch to content tab.
@ -71,7 +75,49 @@ describe('Campaigns', () => {
cy.get('tbody td[data-label=Status] .tag.scheduled');
});
it('Switches formats', () => {
cy.resetDB()
cy.loginAndVisit('/campaigns');
const formats = ['html', 'markdown', 'plain'];
const htmlBody = '<strong>hello</strong> \{\{ .Subscriber.Name \}\} from {\{ .Subscriber.Attribs.city \}\}';
const plainBody = 'hello Demo Subscriber from Bengaluru';
// Set test content the first time.
cy.get('td[data-label=Status] a').click();
cy.get('.b-tabs nav a').eq(1).click();
cy.window().then((win) => {
win.tinymce.editors[0].setContent(htmlBody);
win.tinymce.editors[0].save();
});
cy.get('button[data-cy=btn-save]').click();
formats.forEach((c) => {
cy.loginAndVisit('/campaigns');
cy.get('td[data-label=Status] a').click();
// Switch to content tab.
cy.get('.b-tabs nav a').eq(1).click();
// Switch format.
cy.get(`label[data-cy=check-${c}]`).click();
cy.get('.modal button.is-primary').click();
// Check content.
cy.get('button[data-cy=btn-preview]').click();
cy.wait(200);
cy.get("#iframe").then(($f) => {
const doc = $f.contents();
expect(doc.find('.wrap').text().trim().replace(/(\s|\n)+/, ' ')).equal(plainBody);
});
cy.get('.modal-card-foot button').click();
});
});
it('Clones campaign', () => {
cy.loginAndVisit('/campaigns');
for (let n = 0; n < 3; n++) {
// Clone the campaign.
cy.get('[data-cy=btn-clone]').first().click();
@ -109,7 +155,7 @@ describe('Campaigns', () => {
it('Adds new campaigns', () => {
const lists = [[1], [1, 2]];
const cTypes = ['richtext', 'html', 'plain'];
const cTypes = ['richtext', 'html', 'markdown', 'plain'];
let n = 0;
cTypes.forEach((c) => {
@ -136,12 +182,6 @@ describe('Campaigns', () => {
cy.get('button[data-cy=btn-continue]').click();
cy.wait(250);
// Insert content.
cy.window().then((win) => {
win.tinymce.editors[0].setContent(`hello${n} \{\{ .Subscriber.Name \}\}\n\{\{ .Subscriber.Attribs.city \}\}`);
});
cy.wait(200);
// Select content type.
cy.get(`label[data-cy=check-${c}]`).click();
@ -150,9 +190,38 @@ describe('Campaigns', () => {
cy.get('.modal button.is-primary').click();
}
// Insert content.
const htmlBody = `<strong>hello${n}</strong> \{\{ .Subscriber.Name \}\} from {\{ .Subscriber.Attribs.city \}\}`;
const plainBody = `hello${n} Demo Subscriber from Bengaluru`;
const markdownBody = `**hello${n}** Demo Subscriber from Bengaluru`;
if (c === 'richtext') {
cy.window().then((win) => {
win.tinymce.editors[0].setContent(htmlBody);
win.tinymce.editors[0].save();
});
cy.wait(200);
} else if (c === 'html') {
cy.get('code-flask').shadow().find('.codeflask textarea').invoke('val', htmlBody).trigger('input');
} else if (c === 'markdown') {
cy.get('textarea[name=content]').invoke('val', markdownBody).trigger('input');
} else if (c === 'plain') {
cy.get('textarea[name=content]').invoke('val', plainBody).trigger('input');
}
// Save.
cy.get('button[data-cy=btn-save]').click();
// Preview and match the body.
cy.get('button[data-cy=btn-preview]').click();
cy.wait(200);
cy.get("#iframe").then(($f) => {
const doc = $f.contents();
expect(doc.find('.wrap').text().trim()).equal(plainBody);
});
cy.get('.modal-card-foot button').click();
cy.clickMenu('all-campaigns');
cy.wait(250);
@ -200,8 +269,8 @@ describe('Campaigns', () => {
});
it('Sorts campaigns', () => {
const asc = [5, 6, 7, 8, 9, 10];
const desc = [10, 9, 8, 7, 6, 5];
const asc = [5, 6, 7, 8, 9, 10, 11, 12];
const desc = [12, 11, 10, 9, 8, 7, 6, 5];
const cases = ['cy-name', 'cy-timestamp'];
cases.forEach((c) => {

View file

@ -30,7 +30,7 @@ describe('Lists', () => {
it('Checks individual subscribers in lists', () => {
const subs = [{ listID: 1, email: 'john@example.com' },
{ listID: 2, email: 'anon@example.com' }];
{ listID: 2, email: 'anon@example.com' }];
// Click on each list on the lists page, go the the subscribers page
// for that list, and check the subscriber details.
@ -94,16 +94,17 @@ describe('Lists', () => {
cy.get('select[name=optin]').select(o);
cy.get('input[name=tags]').type(`tag${n}{enter}${t}{enter}${o}{enter}`);
cy.get('button[type=submit]').click();
cy.wait(200);
// Confirm the addition by inspecting the newly created list row.
const tr = `tbody tr:nth-child(${n + 1})`;
cy.get(`${tr} td[data-label=Name]`).contains(name);
cy.get(`${tr} td[data-label=Type] [data-cy=type-${t}]`);
cy.get(`${tr} td[data-label=Type] [data-cy=optin-${o}]`);
cy.get(`${tr} td[data-label=Type] .tag[data-cy=type-${t}]`);
cy.get(`${tr} td[data-label=Type] .tag[data-cy=optin-${o}]`);
cy.get(`${tr} .tags`)
.should('contain', `tag${n}`)
.and('contain', t)
.and('contain', o);
.and('contain', t, { matchCase: false })
.and('contain', o, { matchCase: false });
n++;
});

View file

@ -24,8 +24,8 @@ describe('Templates', () => {
cy.get('tbody td.actions [data-cy=btn-edit]').first().click();
cy.wait(250);
cy.get('input[name=name]').clear().type('edited');
cy.get('textarea[name=body]').clear().type('<span>test</span> {{ template "content" . }}',
{ parseSpecialCharSequences: false, delay: 0 });
cy.get('code-flask').shadow().find('.codeflask textarea').invoke('val', '<span>test</span> {{ template "content" . }}').trigger('input');
cy.get('.modal-card-foot button.is-primary').click();
cy.wait(250);
cy.get('tbody td[data-label="Name"] a').contains('edited');

View file

@ -29,7 +29,9 @@
</div>
<div class="column is-6 has-text-right">
<b-button @click="onTogglePreview" type="is-primary"
icon-left="file-find-outline">{{ $t('campaigns.preview') }}</b-button>
icon-left="file-find-outline" data-cy="btn-preview">
{{ $t('campaigns.preview') }}
</b-button>
</div>
</div>
@ -42,8 +44,7 @@
/>
<!-- raw html editor //-->
<div v-if="form.format === 'html'"
ref="htmlEditor" id="html-editor" class="html-editor"></div>
<html-editor v-if="form.format === 'html'" v-model="form.body" />
<!-- plain text / markdown editor //-->
<b-input v-if="form.format === 'plain' || form.format === 'markdown'"
@ -72,7 +73,6 @@
<script>
import { mapState } from 'vuex';
import CodeFlask from 'codeflask';
import TurndownService from 'turndown';
import { indent } from 'indent.js';
@ -80,7 +80,6 @@ import 'tinymce';
import 'tinymce/icons/default';
import 'tinymce/themes/silver';
import 'tinymce/skins/ui/oxide/skin.css';
import 'tinymce/plugins/autoresize';
import 'tinymce/plugins/autolink';
import 'tinymce/plugins/charmap';
@ -103,9 +102,10 @@ import 'tinymce/plugins/textcolor';
import 'tinymce/plugins/visualblocks';
import 'tinymce/plugins/visualchars';
import 'tinymce/plugins/wordcount';
import TinyMce from '@tinymce/tinymce-vue';
import CampaignPreview from './CampaignPreview.vue';
import HTMLEditor from './HTMLEditor.vue';
import Media from '../views/Media.vue';
import { colors, uris } from '../constants';
@ -129,6 +129,7 @@ export default {
components: {
Media,
CampaignPreview,
'html-editor': HTMLEditor,
TinyMce,
},
@ -164,9 +165,6 @@ export default {
// was opened. This is used to insert media on selection from the poup
// where the caret may be lost.
lastSel: null,
// HTML editor.
flask: null,
};
},
@ -219,42 +217,6 @@ export default {
this.isRichtextReady = true;
},
initHTMLEditor() {
// CodeFlask editor is rendered in a shadow DOM tree to keep its styles
// sandboxed away from the global styles.
const el = document.createElement('code-flask');
el.attachShadow({ mode: 'open' });
el.shadowRoot.innerHTML = `
<style>
.codeflask .codeflask__flatten { font-size: 15px; }
.codeflask .codeflask__lines { background: #fafafa; z-index: 10; }
.codeflask .token.tag { font-weight: bold; }
.codeflask .token.attr-name { color: #111; }
.codeflask .token.attr-value { color: ${colors.primary} !important; }
</style>
<div id="area"></area>
`;
this.$refs.htmlEditor.appendChild(el);
this.flask = new CodeFlask(el.shadowRoot.getElementById('area'), {
language: 'html',
lineNumbers: false,
styleParent: el.shadowRoot,
readonly: this.disabled,
});
this.flask.onUpdate((b) => {
this.form.body = b;
this.$emit('input', { contentType: this.form.format, body: this.form.body });
});
this.updateHTMLEditor();
this.isReady = true;
},
updateHTMLEditor() {
this.flask.updateCode(this.form.body);
},
onFormatChange(format) {
this.$utils.confirm(
this.$t('campaigns.confirmSwitchFormat'),
@ -362,26 +324,6 @@ export default {
return indent.html(s, { tabString: ' ' }).trim();
},
formatHTMLNode(node, level) {
const lvl = level + 1;
const indentBefore = new Array(lvl + 1).join(' ');
const indentAfter = new Array(lvl - 1).join(' ');
let textNode = null;
for (let i = 0; i < node.children.length; i += 1) {
textNode = document.createTextNode(`\n${indentBefore}`);
node.insertBefore(textNode, node.children[i]);
this.formatHTMLNode(node.children[i], lvl);
if (node.lastElementChild === node.children[i]) {
textNode = document.createTextNode(`\n${indentAfter}`);
node.appendChild(textNode);
}
}
return node;
},
trimLines(str, removeEmptyLines) {
const out = str.split('\n');
for (let i = 0; i < out.length; i += 1) {
@ -415,7 +357,7 @@ export default {
this.form.format = f;
this.form.radioFormat = f;
if (f === 'plain' || f === 'markdown') {
if (f !== 'richtext') {
this.isReady = true;
}
@ -435,13 +377,6 @@ export default {
},
htmlFormat(to, from) {
// On switch to HTML, initialize the HTML editor.
if (to === 'html') {
this.$nextTick(() => {
this.initHTMLEditor();
});
}
if ((from === 'richtext' || from === 'html') && to === 'plain') {
// richtext, html => plain

View file

@ -48,12 +48,14 @@
{{ $t(`lists.types.${props.row.type}`) }}
</b-tag>
{{ ' ' }}
<b-tag :data-cy="`optin-${props.row.optin}`">
<b-tag :class="props.row.optin" :data-cy="`optin-${props.row.optin}`">
<b-icon :icon="props.row.optin === 'double' ?
'account-check-outline' : 'account-off-outline'" size="is-small" />
{{ ' ' }}
{{ $t(`lists.optins.${props.row.optin}`) }}
</b-tag>{{ ' ' }}
<a v-if="props.row.optin === 'double'" class="is-size-7 send-optin"
href="#" @click="$utils.confirm(null, () => createOptinCampaign(props.row))"
data-cy="btn-send-optin-campaign">

View file

@ -16,8 +16,9 @@
:placeholder="$t('globals.fields.name')" required />
</b-field>
<b-field :label="$t('templates.rawHTML')" label-position="on-border">
<b-input v-model="form.body" type="textarea" name="body" required />
<b-field v-if="form.body !== null"
:label="$t('templates.rawHTML')" label-position="on-border">
<html-editor v-model="form.body" name="body" required />
</b-field>
<p class="is-size-7">
@ -46,10 +47,12 @@
import Vue from 'vue';
import { mapState } from 'vuex';
import CampaignPreview from '../components/CampaignPreview.vue';
import HTMLEditor from '../components/HTMLEditor.vue';
export default Vue.extend({
components: {
CampaignPreview,
'html-editor': HTMLEditor,
},
props: {
@ -64,6 +67,7 @@ export default Vue.extend({
name: '',
type: '',
optin: '',
body: null,
},
previewItem: null,
egPlaceholder: '{{ template "content" . }}',

View file

@ -3,7 +3,7 @@
<h2>{{ L.Ts "email.status.campaignUpdateTitle" }}</h2>
<table width="100%">
<tr>
<td width="30%"><strong>{{ L.Ts "global.terms.campaign" }}</strong></td>
<td width="30%"><strong>{{ L.Ts "globals.terms.campaign" }}</strong></td>
<td><a href="{{ RootURL }}/campaigns/{{ index . "ID" }}">{{ index . "Name" }}</a></td>
</tr>
<tr>