From 9d2bc9c41d4b0a903c720b5eb762f3154fd45071 Mon Sep 17 00:00:00 2001 From: Kailash Nadh Date: Sun, 26 Sep 2021 20:13:12 +0530 Subject: [PATCH] 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. --- cmd/subscribers.go | 7 +- cmd/templates.go | 2 +- frontend/cypress/downloads/data.json | 28 ------- frontend/cypress/integration/campaigns.js | 87 ++++++++++++++++++--- frontend/cypress/integration/lists.js | 11 +-- frontend/cypress/integration/templates.js | 4 +- frontend/src/components/Editor.vue | 81 ++----------------- frontend/src/views/Lists.vue | 4 +- frontend/src/views/TemplateForm.vue | 8 +- static/email-templates/campaign-status.html | 2 +- 10 files changed, 109 insertions(+), 125 deletions(-) delete mode 100644 frontend/cypress/downloads/data.json diff --git a/cmd/subscribers.go b/cmd/subscribers.go index 105faa9..5778083 100644 --- a/cmd/subscribers.go +++ b/cmd/subscribers.go @@ -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"} diff --git a/cmd/templates.go b/cmd/templates.go index 7ed0145..8d5235c 100644 --- a/cmd/templates.go +++ b/cmd/templates.go @@ -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. diff --git a/frontend/cypress/downloads/data.json b/frontend/cypress/downloads/data.json deleted file mode 100644 index 81f86d4..0000000 --- a/frontend/cypress/downloads/data.json +++ /dev/null @@ -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": [] -} \ No newline at end of file diff --git a/frontend/cypress/integration/campaigns.js b/frontend/cypress/integration/campaigns.js index 3009b33..da26a94 100644 --- a/frontend/cypress/integration/campaigns.js +++ b/frontend/cypress/integration/campaigns.js @@ -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 = 'hello \{\{ .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 = `hello${n} \{\{ .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) => { diff --git a/frontend/cypress/integration/lists.js b/frontend/cypress/integration/lists.js index a8210fb..8d5ecf8 100644 --- a/frontend/cypress/integration/lists.js +++ b/frontend/cypress/integration/lists.js @@ -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++; }); diff --git a/frontend/cypress/integration/templates.js b/frontend/cypress/integration/templates.js index 7e64e02..8ad8ba0 100644 --- a/frontend/cypress/integration/templates.js +++ b/frontend/cypress/integration/templates.js @@ -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('test {{ template "content" . }}', - { parseSpecialCharSequences: false, delay: 0 }); + cy.get('code-flask').shadow().find('.codeflask textarea').invoke('val', 'test {{ 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'); diff --git a/frontend/src/components/Editor.vue b/frontend/src/components/Editor.vue index 547e92b..df267e2 100644 --- a/frontend/src/components/Editor.vue +++ b/frontend/src/components/Editor.vue @@ -29,7 +29,9 @@
{{ $t('campaigns.preview') }} + icon-left="file-find-outline" data-cy="btn-preview"> + {{ $t('campaigns.preview') }} +
@@ -42,8 +44,7 @@ /> -
+ 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 = ` - -
- `; - 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 diff --git a/frontend/src/views/Lists.vue b/frontend/src/views/Lists.vue index 365584e..7e3f6db 100644 --- a/frontend/src/views/Lists.vue +++ b/frontend/src/views/Lists.vue @@ -48,12 +48,14 @@ {{ $t(`lists.types.${props.row.type}`) }} {{ ' ' }} - + + {{ ' ' }} {{ $t(`lists.optins.${props.row.optin}`) }} {{ ' ' }} + diff --git a/frontend/src/views/TemplateForm.vue b/frontend/src/views/TemplateForm.vue index 944be76..0342572 100644 --- a/frontend/src/views/TemplateForm.vue +++ b/frontend/src/views/TemplateForm.vue @@ -16,8 +16,9 @@ :placeholder="$t('globals.fields.name')" required /> - - + +

@@ -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" . }}', diff --git a/static/email-templates/campaign-status.html b/static/email-templates/campaign-status.html index 2fa5fb6..4ecb03d 100644 --- a/static/email-templates/campaign-status.html +++ b/static/email-templates/campaign-status.html @@ -3,7 +3,7 @@

{{ L.Ts "email.status.campaignUpdateTitle" }}

- +
{{ L.Ts "global.terms.campaign" }}{{ L.Ts "globals.terms.campaign" }} {{ index . "Name" }}