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:
parent
a1a9f3ac6a
commit
9d2bc9c41d
10 changed files with 109 additions and 125 deletions
|
@ -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"}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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": []
|
||||
}
|
|
@ -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) => {
|
||||
|
|
|
@ -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++;
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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" . }}',
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in a new issue