Skip to content

Commit

Permalink
Add HTML syntax highlighted editing to the template editor.
Browse files Browse the repository at this point in the history
- 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.
  • Loading branch information
knadh committed Sep 26, 2021
1 parent a1a9f3a commit 9d2bc9c
Show file tree
Hide file tree
Showing 10 changed files with 109 additions and 125 deletions.
7 changes: 4 additions & 3 deletions cmd/subscribers.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,10 @@ type subOptin struct {

var (
dummySubscriber = models.Subscriber{
Email: "[email protected]",
Name: "Demo Subscriber",
UUID: dummyUUID,
Email: "[email protected]",
Name: "Demo Subscriber",
UUID: dummyUUID,
Attribs: models.SubscriberAttribs{"city": "Bengaluru"},
}

subQuerySortFields = []string{"email", "name", "created_at", "updated_at"}
Expand Down
2 changes: 1 addition & 1 deletion cmd/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 0 additions & 28 deletions frontend/cypress/downloads/data.json

This file was deleted.

87 changes: 78 additions & 9 deletions frontend/cypress/integration/campaigns.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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) => {
Expand All @@ -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();

Expand All @@ -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);

Expand Down Expand Up @@ -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) => {
Expand Down
11 changes: 6 additions & 5 deletions frontend/cypress/integration/lists.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ describe('Lists', () => {

it('Checks individual subscribers in lists', () => {
const subs = [{ listID: 1, email: '[email protected]' },
{ listID: 2, email: '[email protected]' }];
{ listID: 2, email: '[email protected]' }];

// Click on each list on the lists page, go the the subscribers page
// for that list, and check the subscriber details.
Expand Down Expand Up @@ -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++;
});
Expand Down
4 changes: 2 additions & 2 deletions frontend/cypress/integration/templates.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
81 changes: 8 additions & 73 deletions frontend/src/components/Editor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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>

Expand All @@ -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'"
Expand Down Expand Up @@ -72,15 +73,13 @@

<script>
import { mapState } from 'vuex';
import CodeFlask from 'codeflask';
import TurndownService from 'turndown';
import { indent } from 'indent.js';
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';
Expand All @@ -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';
Expand All @@ -129,6 +129,7 @@ export default {
components: {
Media,
CampaignPreview,
'html-editor': HTMLEditor,
TinyMce,
},
Expand Down Expand Up @@ -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,
};
},
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}
Expand All @@ -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
Expand Down
Loading

0 comments on commit 9d2bc9c

Please sign in to comment.