소스 검색

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.
Kailash Nadh 3 년 전
부모
커밋
9d2bc9c41d

+ 4 - 3
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"}

+ 1 - 1
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.

+ 0 - 28
frontend/cypress/downloads/data.json

@@ -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": []
-}

+ 78 - 9
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 = '<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) => {

+ 6 - 5
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++;
       });

+ 2 - 2
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('<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');

+ 8 - 73
frontend/src/components/Editor.vue

@@ -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
 

+ 3 - 1
frontend/src/views/Lists.vue

@@ -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">

+ 6 - 2
frontend/src/views/TemplateForm.vue

@@ -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" . }}',

+ 1 - 1
static/email-templates/campaign-status.html

@@ -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>